From 1a221bf680c7648e40b8c34c44ddf5d5d6224d70 Mon Sep 17 00:00:00 2001 From: sepia Date: Tue, 22 Jul 2025 19:06:12 -0500 Subject: [PATCH] Add takeback button --- public/scripts/send-ws-messages.js | 14 ++- public/style.css | 1 - src/game/game-instance.ts | 20 ++- src/messages.ts | 12 +- src/web-socket-handler.tsx | 192 ++++++++++++++++++++++++++--- tsconfig.json | 4 +- 6 files changed, 217 insertions(+), 26 deletions(-) diff --git a/public/scripts/send-ws-messages.js b/public/scripts/send-ws-messages.js index 141955f..f11f22a 100644 --- a/public/scripts/send-ws-messages.js +++ b/public/scripts/send-ws-messages.js @@ -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', }; } }); diff --git a/public/style.css b/public/style.css index 7bdef16..2adbf1e 100644 --- a/public/style.css +++ b/public/style.css @@ -271,4 +271,3 @@ button:hover { #copy-link-button.copied-state { background-color: var(--color-success); } - diff --git a/src/game/game-instance.ts b/src/game/game-instance.ts index b138bd1..f0a64d9 100644 --- a/src/game/game-instance.ts +++ b/src/game/game-instance.ts @@ -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 diff --git a/src/messages.ts b/src/messages.ts index 22d0b1d..6df0c7a 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -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 {} diff --git a/src/web-socket-handler.tsx b/src/web-socket-handler.tsx index 42b7018..82563de 100644 --- a/src/web-socket-handler.tsx +++ b/src/web-socket-handler.tsx @@ -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; blackPlayerId?: string; whitePlayerId?: string; + takebackRequesterId: string | null = null; constructor(id: string) { this.id = id; @@ -50,7 +58,7 @@ class GameServer { if (this.whitePlayerId && this.blackPlayerId) { this.gomoku.status = 'playing'; } - + this.broadcastBoard(); this.broadcastButtons(); this.broadcastTitle(); @@ -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 = ; + if (this.takebackRequesterId) { + if (this.takebackRequesterId === conn.id) { + buttonsHtml = ( + + ); + } else { + buttonsHtml = ( +
+ + +
+ ); + } + } else { + buttonsHtml = ( +
+ + +
+ ); + } } conn.ws.send(
{buttonsHtml}
); console.log(`Sent buttons for game ${this.id} to player ${conn.id}`); } private playerTag(playerId: string, color: PlayerColor) { - const connectionIcon = this.connections.has(playerId) ? '' : `Disconnected`; - var turnClass = (this.gomoku.currentPlayerColor === color) ? 'player-to-play' : '' + const connectionIcon = this.connections.has(playerId) + ? '' + : `Disconnected`; + var turnClass = + this.gomoku.currentPlayerColor === color ? 'player-to-play' : ''; const classes = `player-name ${'player-' + color} ${turnClass}`.trim(); - return ({playerId}{connectionIcon}); + return ( + + {playerId} + {connectionIcon} + + ); } 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); } } - diff --git a/tsconfig.json b/tsconfig.json index 05967c3..34a8b7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "target": "ES2021", "types": ["bun-types"], "jsx": "react", - "jsxFactory": "Html.createElement", - "jsxFragmentFactory": "Html.Fragment" + "jsxFactory": "Html.createElement", + "jsxFragmentFactory": "Html.Fragment" } }