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') {
|
} else if (e.target.id == 'resign-button') {
|
||||||
e.detail.parameters = {
|
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 {
|
#copy-link-button.copied-state {
|
||||||
background-color: var(--color-success);
|
background-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ export class GomokuGame {
|
||||||
public currentPlayerColor: null | PlayerColor;
|
public currentPlayerColor: null | PlayerColor;
|
||||||
public status: GameStatus;
|
public status: GameStatus;
|
||||||
public winnerColor: null | PlayerColor | 'draw';
|
public winnerColor: null | PlayerColor | 'draw';
|
||||||
public history: { row: number, col: number }[];
|
public history: { row: number; col: number }[];
|
||||||
|
|
||||||
private readonly boardSize = 15;
|
private readonly boardSize = 15;
|
||||||
private moveCount = 0;
|
private moveCount = 0;
|
||||||
|
@ -82,6 +82,24 @@ export class GomokuGame {
|
||||||
this.currentPlayerColor = null;
|
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 {
|
private checkWin(row: number, col: number, color: PlayerColor): boolean {
|
||||||
const directions = [
|
const directions = [
|
||||||
[1, 0], // vertical
|
[1, 0], // vertical
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
export interface Message {
|
export interface Message {
|
||||||
type: 'make_move' | 'resign';
|
type:
|
||||||
|
| 'make_move'
|
||||||
|
| 'resign'
|
||||||
|
| 'request_takeback'
|
||||||
|
| 'accept_takeback'
|
||||||
|
| 'decline_takeback';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MakeMoveMessage extends Message {
|
export interface MakeMoveMessage extends Message {
|
||||||
|
@ -9,3 +14,8 @@ export interface MakeMoveMessage extends Message {
|
||||||
|
|
||||||
export interface ResignationMessage 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 { Html } from '@elysiajs/html';
|
||||||
import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance';
|
import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance';
|
||||||
import { renderGameBoardHtml } from './view/board-renderer';
|
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';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
|
type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
|
||||||
|
@ -29,6 +36,7 @@ class GameServer {
|
||||||
connections: Map<string, PlayerConnection>;
|
connections: Map<string, PlayerConnection>;
|
||||||
blackPlayerId?: string;
|
blackPlayerId?: string;
|
||||||
whitePlayerId?: string;
|
whitePlayerId?: string;
|
||||||
|
takebackRequesterId: string | null = null;
|
||||||
|
|
||||||
constructor(id: string) {
|
constructor(id: string) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
@ -50,7 +58,7 @@ class GameServer {
|
||||||
if (this.whitePlayerId && this.blackPlayerId) {
|
if (this.whitePlayerId && this.blackPlayerId) {
|
||||||
this.gomoku.status = 'playing';
|
this.gomoku.status = 'playing';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.broadcastBoard();
|
this.broadcastBoard();
|
||||||
this.broadcastButtons();
|
this.broadcastButtons();
|
||||||
this.broadcastTitle();
|
this.broadcastTitle();
|
||||||
|
@ -62,19 +70,26 @@ class GameServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcastBoard() {
|
public broadcastBoard() {
|
||||||
this.connections.forEach((conn: PlayerConnection) => this.broadcastBoardToPlayer(conn));
|
this.connections.forEach((conn: PlayerConnection) =>
|
||||||
|
this.broadcastBoardToPlayer(conn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcastTitle() {
|
public broadcastTitle() {
|
||||||
this.connections.forEach((conn: PlayerConnection) => this.broadcastTitleToPlayer(conn));
|
this.connections.forEach((conn: PlayerConnection) =>
|
||||||
|
this.broadcastTitleToPlayer(conn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcastButtons() {
|
public broadcastButtons() {
|
||||||
this.connections.forEach((conn: PlayerConnection) => this.broadcastButtonsToPlayer(conn));
|
this.connections.forEach((conn: PlayerConnection) =>
|
||||||
|
this.broadcastButtonsToPlayer(conn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcastBoardToPlayer(conn: PlayerConnection) {
|
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);
|
const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay);
|
||||||
conn.ws.send(updatedBoardHtml);
|
conn.ws.send(updatedBoardHtml);
|
||||||
console.log(`Sent board for game ${this.id} to player ${conn.id}`);
|
console.log(`Sent board for game ${this.id} to player ${conn.id}`);
|
||||||
|
@ -120,23 +135,63 @@ class GameServer {
|
||||||
public broadcastButtonsToPlayer(conn: PlayerConnection) {
|
public broadcastButtonsToPlayer(conn: PlayerConnection) {
|
||||||
let buttonsHtml;
|
let buttonsHtml;
|
||||||
if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) {
|
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>);
|
conn.ws.send(<div id="button-box">{buttonsHtml}</div>);
|
||||||
console.log(`Sent buttons for game ${this.id} to player ${conn.id}`);
|
console.log(`Sent buttons for game ${this.id} to player ${conn.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playerTag(playerId: string, color: PlayerColor) {
|
private playerTag(playerId: string, color: PlayerColor) {
|
||||||
const connectionIcon = this.connections.has(playerId) ? '' : `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`;
|
const connectionIcon = this.connections.has(playerId)
|
||||||
var turnClass = (this.gomoku.currentPlayerColor === color) ? 'player-to-play' : ''
|
? ''
|
||||||
|
: `<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();
|
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 {
|
public handleMessage(ws: WS, message: Message): void {
|
||||||
const conn = this.connections.get(ws.data.query.playerId);
|
const conn = this.connections.get(ws.data.query.playerId);
|
||||||
if (!conn) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,6 +207,18 @@ class GameServer {
|
||||||
this.handleResignation(conn, message as ResignationMessage);
|
this.handleResignation(conn, message as ResignationMessage);
|
||||||
break;
|
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;
|
const { row, col } = message;
|
||||||
|
|
||||||
var playerColor;
|
var playerColor;
|
||||||
|
@ -174,7 +244,10 @@ class GameServer {
|
||||||
} else if (this.whitePlayerId == conn.id) {
|
} else if (this.whitePlayerId == conn.id) {
|
||||||
playerColor = 'white';
|
playerColor = 'white';
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
if (this.gomoku.currentPlayerColor !== playerColor) {
|
if (this.gomoku.currentPlayerColor !== playerColor) {
|
||||||
|
@ -182,6 +255,11 @@ class GameServer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.takebackRequesterId) {
|
||||||
|
this.takebackRequesterId = null;
|
||||||
|
this.broadcastButtons();
|
||||||
|
}
|
||||||
|
|
||||||
const stateBeforeMove = this.gomoku.status;
|
const stateBeforeMove = this.gomoku.status;
|
||||||
const result = this.gomoku.makeMove(playerColor, row, col);
|
const result = this.gomoku.makeMove(playerColor, row, col);
|
||||||
if (result.success) {
|
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(
|
console.log(
|
||||||
`Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`,
|
`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}`);
|
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 {
|
export class WebSocketHandler {
|
||||||
|
@ -246,7 +393,9 @@ export class WebSocketHandler {
|
||||||
|
|
||||||
const game = this.games.get(gameId);
|
const game = this.games.get(gameId);
|
||||||
if (!game) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -262,15 +411,19 @@ export class WebSocketHandler {
|
||||||
public handleMessage(ws: WS, messageUnparsed: any) {
|
public handleMessage(ws: WS, messageUnparsed: any) {
|
||||||
let message = messageUnparsed as Message;
|
let message = messageUnparsed as Message;
|
||||||
if (!message) {
|
if (!message) {
|
||||||
console.log(`Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`);
|
console.log(
|
||||||
ws.send("Error: malformed message!");
|
`Received malformed message ${messageUnparsed} from player ${ws.data.query.playerId}.`,
|
||||||
|
);
|
||||||
|
ws.send('Error: malformed message!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { gameId } = ws.data.query;
|
const { gameId } = ws.data.query;
|
||||||
const gameServer = this.games.get(gameId);
|
const gameServer = this.games.get(gameId);
|
||||||
if (!gameServer) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,4 +440,3 @@ export class WebSocketHandler {
|
||||||
return this.games.has(id);
|
return this.games.has(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"types": ["bun-types"],
|
"types": ["bun-types"],
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"jsxFactory": "Html.createElement",
|
"jsxFactory": "Html.createElement",
|
||||||
"jsxFragmentFactory": "Html.Fragment"
|
"jsxFragmentFactory": "Html.Fragment"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue