Add takeback button
This commit is contained in:
parent
3093754bd4
commit
1a221bf680
|
@ -13,7 +13,19 @@ document.addEventListener('htmx:wsConfigSend', function (e) {
|
|||
};
|
||||
} else if (e.target.id == 'resign-button') {
|
||||
e.detail.parameters = {
|
||||
type: 'resign'
|
||||
type: 'resign',
|
||||
};
|
||||
} else if (e.target.id == 'takeback-button') {
|
||||
e.detail.parameters = {
|
||||
type: 'request_takeback',
|
||||
};
|
||||
} else if (e.target.id == 'accept-takeback-button') {
|
||||
e.detail.parameters = {
|
||||
type: 'accept_takeback',
|
||||
};
|
||||
} else if (e.target.id == 'decline-takeback-button') {
|
||||
e.detail.parameters = {
|
||||
type: 'decline_takeback',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
@ -271,4 +271,3 @@ button:hover {
|
|||
#copy-link-button.copied-state {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ export class GomokuGame {
|
|||
public currentPlayerColor: null | PlayerColor;
|
||||
public status: GameStatus;
|
||||
public winnerColor: null | PlayerColor | 'draw';
|
||||
public history: { row: number, col: number }[];
|
||||
public history: { row: number; col: number }[];
|
||||
|
||||
private readonly boardSize = 15;
|
||||
private moveCount = 0;
|
||||
|
@ -82,6 +82,24 @@ export class GomokuGame {
|
|||
this.currentPlayerColor = null;
|
||||
}
|
||||
|
||||
public undoMove() {
|
||||
if (this.history.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMove = this.history.pop();
|
||||
if (lastMove) {
|
||||
this.board[lastMove.row][lastMove.col] = null;
|
||||
this.moveCount--;
|
||||
this.currentPlayerColor =
|
||||
this.currentPlayerColor === 'black' ? 'white' : 'black';
|
||||
if (this.status === 'finished') {
|
||||
this.status = 'playing';
|
||||
this.winnerColor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkWin(row: number, col: number, color: PlayerColor): boolean {
|
||||
const directions = [
|
||||
[1, 0], // vertical
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
export interface Message {
|
||||
type: 'make_move' | 'resign';
|
||||
type:
|
||||
| 'make_move'
|
||||
| 'resign'
|
||||
| 'request_takeback'
|
||||
| 'accept_takeback'
|
||||
| 'decline_takeback';
|
||||
}
|
||||
|
||||
export interface MakeMoveMessage extends Message {
|
||||
|
@ -9,3 +14,8 @@ export interface MakeMoveMessage extends Message {
|
|||
|
||||
export interface ResignationMessage extends Message {}
|
||||
|
||||
export interface RequestTakebackMessage extends Message {}
|
||||
|
||||
export interface AcceptTakebackMessage extends Message {}
|
||||
|
||||
export interface DeclineTakebackMessage extends Message {}
|
||||
|
|
|
@ -2,7 +2,14 @@ import { ElysiaWS } from 'elysia/dist/ws';
|
|||
import { Html } from '@elysiajs/html';
|
||||
import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance';
|
||||
import { renderGameBoardHtml } from './view/board-renderer';
|
||||
import { Message, MakeMoveMessage, ResignationMessage } from './messages';
|
||||
import {
|
||||
Message,
|
||||
MakeMoveMessage,
|
||||
ResignationMessage,
|
||||
RequestTakebackMessage,
|
||||
AcceptTakebackMessage,
|
||||
DeclineTakebackMessage,
|
||||
} from './messages';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
|
||||
|
@ -29,6 +36,7 @@ class GameServer {
|
|||
connections: Map<string, PlayerConnection>;
|
||||
blackPlayerId?: string;
|
||||
whitePlayerId?: string;
|
||||
takebackRequesterId: string | null = null;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
|
@ -62,19 +70,26 @@ class GameServer {
|
|||
}
|
||||
|
||||
public broadcastBoard() {
|
||||
this.connections.forEach((conn: PlayerConnection) => this.broadcastBoardToPlayer(conn));
|
||||
this.connections.forEach((conn: PlayerConnection) =>
|
||||
this.broadcastBoardToPlayer(conn),
|
||||
);
|
||||
}
|
||||
|
||||
public broadcastTitle() {
|
||||
this.connections.forEach((conn: PlayerConnection) => this.broadcastTitleToPlayer(conn));
|
||||
this.connections.forEach((conn: PlayerConnection) =>
|
||||
this.broadcastTitleToPlayer(conn),
|
||||
);
|
||||
}
|
||||
|
||||
public broadcastButtons() {
|
||||
this.connections.forEach((conn: PlayerConnection) => this.broadcastButtonsToPlayer(conn));
|
||||
this.connections.forEach((conn: PlayerConnection) =>
|
||||
this.broadcastButtonsToPlayer(conn),
|
||||
);
|
||||
}
|
||||
|
||||
public broadcastBoardToPlayer(conn: PlayerConnection) {
|
||||
const isToPlay = this.gomoku.currentPlayerColor == this.getPlayerColor(conn);
|
||||
const isToPlay =
|
||||
this.gomoku.currentPlayerColor == this.getPlayerColor(conn);
|
||||
const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay);
|
||||
conn.ws.send(updatedBoardHtml);
|
||||
console.log(`Sent board for game ${this.id} to player ${conn.id}`);
|
||||
|
@ -120,23 +135,63 @@ class GameServer {
|
|||
public broadcastButtonsToPlayer(conn: PlayerConnection) {
|
||||
let buttonsHtml;
|
||||
if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) {
|
||||
buttonsHtml = <button id="resign-button" ws-send="click">Resign</button>;
|
||||
if (this.takebackRequesterId) {
|
||||
if (this.takebackRequesterId === conn.id) {
|
||||
buttonsHtml = (
|
||||
<button id="takeback-button" disabled>
|
||||
Takeback Requested
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
buttonsHtml = (
|
||||
<div>
|
||||
<button id="accept-takeback-button" ws-send="click">
|
||||
Accept
|
||||
</button>
|
||||
<button id="decline-takeback-button" ws-send="click">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
buttonsHtml = (
|
||||
<div>
|
||||
<button id="resign-button" ws-send="click">
|
||||
Resign
|
||||
</button>
|
||||
<button id="takeback-button" ws-send="click">
|
||||
Takeback
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
conn.ws.send(<div id="button-box">{buttonsHtml}</div>);
|
||||
console.log(`Sent buttons for game ${this.id} to player ${conn.id}`);
|
||||
}
|
||||
|
||||
private playerTag(playerId: string, color: PlayerColor) {
|
||||
const connectionIcon = this.connections.has(playerId) ? '' : `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`;
|
||||
var turnClass = (this.gomoku.currentPlayerColor === color) ? 'player-to-play' : ''
|
||||
const connectionIcon = this.connections.has(playerId)
|
||||
? ''
|
||||
: `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`;
|
||||
var turnClass =
|
||||
this.gomoku.currentPlayerColor === color ? 'player-to-play' : '';
|
||||
const classes = `player-name ${'player-' + color} ${turnClass}`.trim();
|
||||
return (<span class={classes}>{playerId}{connectionIcon}</span>);
|
||||
return (
|
||||
<span class={classes}>
|
||||
{playerId}
|
||||
{connectionIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
public handleMessage(ws: WS, message: Message): void {
|
||||
const conn = this.connections.get(ws.data.query.playerId);
|
||||
if (!conn) {
|
||||
console.error(`Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`);
|
||||
console.error(
|
||||
`Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -152,6 +207,18 @@ class GameServer {
|
|||
this.handleResignation(conn, message as ResignationMessage);
|
||||
break;
|
||||
}
|
||||
case 'request_takeback': {
|
||||
this.handleRequestTakeback(conn, message as RequestTakebackMessage);
|
||||
break;
|
||||
}
|
||||
case 'accept_takeback': {
|
||||
this.handleAcceptTakeback(conn, message as AcceptTakebackMessage);
|
||||
break;
|
||||
}
|
||||
case 'decline_takeback': {
|
||||
this.handleDeclineTakeback(conn, message as DeclineTakebackMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -165,7 +232,10 @@ class GameServer {
|
|||
}
|
||||
}
|
||||
|
||||
private handleMakeMove(conn: PlayerConnection, message: MakeMoveMessage): void {
|
||||
private handleMakeMove(
|
||||
conn: PlayerConnection,
|
||||
message: MakeMoveMessage,
|
||||
): void {
|
||||
const { row, col } = message;
|
||||
|
||||
var playerColor;
|
||||
|
@ -174,7 +244,10 @@ class GameServer {
|
|||
} else if (this.whitePlayerId == conn.id) {
|
||||
playerColor = 'white';
|
||||
} else {
|
||||
conn.sendMessage('error', 'You are not a player in this game, you cannot make a move!');
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not a player in this game, you cannot make a move!',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.gomoku.currentPlayerColor !== playerColor) {
|
||||
|
@ -182,6 +255,11 @@ class GameServer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.takebackRequesterId) {
|
||||
this.takebackRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
const stateBeforeMove = this.gomoku.status;
|
||||
const result = this.gomoku.makeMove(playerColor, row, col);
|
||||
if (result.success) {
|
||||
|
@ -199,7 +277,10 @@ class GameServer {
|
|||
}
|
||||
}
|
||||
|
||||
private handleResignation(conn: PlayerConnection, message: ResignationMessage): void {
|
||||
private handleResignation(
|
||||
conn: PlayerConnection,
|
||||
message: ResignationMessage,
|
||||
): void {
|
||||
console.log(
|
||||
`Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`,
|
||||
);
|
||||
|
@ -222,6 +303,72 @@ class GameServer {
|
|||
|
||||
console.log(`Player ${conn.id} resigned from game ${this.id}`);
|
||||
}
|
||||
|
||||
private handleRequestTakeback(
|
||||
conn: PlayerConnection,
|
||||
message: RequestTakebackMessage,
|
||||
): void {
|
||||
if (this.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only request a takeback in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gomoku.history.length === 0) {
|
||||
conn.sendMessage('error', 'There are no moves to take back.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.takebackRequesterId = conn.id;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleAcceptTakeback(
|
||||
conn: PlayerConnection,
|
||||
message: AcceptTakebackMessage,
|
||||
): void {
|
||||
if (this.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only accept a takeback in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'No takeback has been requested.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.gomoku.undoMove();
|
||||
this.takebackRequesterId = null;
|
||||
this.broadcastBoard();
|
||||
this.broadcastButtons();
|
||||
this.broadcastTitle();
|
||||
}
|
||||
|
||||
private handleDeclineTakeback(
|
||||
conn: PlayerConnection,
|
||||
message: DeclineTakebackMessage,
|
||||
): void {
|
||||
if (this.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only decline a takeback in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'No takeback has been requested.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.takebackRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
}
|
||||
|
||||
export class WebSocketHandler {
|
||||
|
@ -246,7 +393,9 @@ export class WebSocketHandler {
|
|||
|
||||
const game = this.games.get(gameId);
|
||||
if (!game) {
|
||||
console.error(`Attempted to disconnect player ${playerId} from game ${gameId}, which does not exist!`);
|
||||
console.error(
|
||||
`Attempted to disconnect player ${playerId} from game ${gameId}, which does not exist!`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -262,15 +411,19 @@ export class WebSocketHandler {
|
|||
public handleMessage(ws: WS, messageUnparsed: any) {
|
||||
let message = messageUnparsed as Message;
|
||||
if (!message) {
|
||||
console.log(`Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`);
|
||||
ws.send("Error: malformed message!");
|
||||
console.log(
|
||||
`Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`,
|
||||
);
|
||||
ws.send('Error: malformed message!');
|
||||
return;
|
||||
}
|
||||
|
||||
const { gameId } = ws.data.query;
|
||||
const gameServer = this.games.get(gameId);
|
||||
if (!gameServer) {
|
||||
console.error(`A WebSocket connection was left open for the non-existent game ${gameId}`);
|
||||
console.error(
|
||||
`A WebSocket connection was left open for the non-existent game ${gameId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -287,4 +440,3 @@ export class WebSocketHandler {
|
|||
return this.games.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue