dao-game / commit
Multiplayer pretty much works.
.prettierrc | 3 +- .vscode/settings.json | 1 + public/cursor.png | Bin 0 -> 504 bytes public/index.html | 4 +- public/online.html | 2 +- public/styles.css | 8 +- readme.md | 4 +- src/game.ts | 300 ++++++++++++++++++---------------- src/local-game.ts | 443 ++++++++++++++++++++++++-------------------------- src/remote-game.ts | 149 ++++++++++------- src/storage.ts | 2 +- src/types.ts | 231 +++++++++++++------------- src/utils.ts | 20 +++ 13 files changed, 613 insertions(+), 554 deletions(-) diff --git a/.prettierrc b/.prettierrc index 91ef31e..8350497 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "useTabs": false, "semi": true, "trailingComma": "es5", - "singleQuote": true + "singleQuote": true, + "printWidth": 140 } diff --git a/.vscode/settings.json b/.vscode/settings.json index 03ad85b..989167c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "esbuild", "Newp", "outfile", + "peerjs", "servedir", "Todos" ] diff --git a/public/cursor.png b/public/cursor.png new file mode 100644 index 0000000..b34724b Binary files /dev/null and b/public/cursor.png differ diff --git a/public/index.html b/public/index.html index 2bcc389..dc8de5a 100644 --- a/public/index.html +++ b/public/index.html @@ -60,7 +60,9 @@ </p> </div> - <canvas id="back-canvas"></canvas> + <div id="other-mouse"> + <img src="./cursor.png" alt="cursor" /> + </div> </body> diff --git a/public/online.html b/public/online.html index ad554d7..5c4d114 100644 --- a/public/online.html +++ b/public/online.html @@ -12,7 +12,7 @@ <script defer src="bundle.js"></script> </head> - <body> + <body style="display: none;"> <div class="invisible"> <p class="piece p1-piece" id="default-p1-piece"></p> <p class="piece p2-piece" id="default-p2-piece"></p> diff --git a/public/styles.css b/public/styles.css index 6f25d87..6f9a281 100644 --- a/public/styles.css +++ b/public/styles.css @@ -31,11 +31,11 @@ div { flex: 1; */ } -#back-canvas { +#other-mouse { position: absolute; - top: 0px; - left: 0px; - z-index: -1000; + /* top: -16px; + left: -32px; */ + z-index: 1000; pointer-events: none; } diff --git a/readme.md b/readme.md index 17faa1b..46f2a15 100644 --- a/readme.md +++ b/readme.md @@ -19,5 +19,5 @@ This project is using [esbuild](https://esbuild.github.io/getting-started/#build ## Todos -1. Use esbuild and rewrite app in TypeScript -2. Online multiplayer +1. Online multiplayer +2. There's a bug where opening a new tab results in the same session data, look into this and figure out why and how to prevent this diff --git a/src/game.ts b/src/game.ts index 4b58f6d..f5a5881 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,166 +1,178 @@ -import { Coord, GameBoard, Move, MoveResult } from "./types"; +import { Coord, GameBoard, Move, MoveResult } from './types'; export interface IGame { - size: Coord; - moveHistory: Move[]; - player1Id: string; - player2Id: string; - currentPlayerId: string; - canUndoMove: boolean; - getPlayerPositions: (playerId: string) => Coord[]; - coordToBoardIndex: (coord: Coord) => number; - getValidMoves: (start: Coord) => Coord[]; - makeMove: (source: Coord, dest: Coord) => MoveResult; - undoMove: () => Move; + size: Coord; + moveHistory: Move[]; + player1Id: string; + player2Id: string; + currentPlayerId: string; + canUndoMove: boolean; + getPlayerPositions: (playerId: string) => Coord[]; + coordToBoardIndex: (coord: Coord) => number; + getValidMoves: (start: Coord) => Coord[]; + makeMove: (source: Coord, dest: Coord) => Promise<MoveResult>; + undoMove: () => Move; + getPiece(pos: Coord): string | undefined; } export class Game implements IGame { - private isGameOver: boolean; - private readonly gameBoard: GameBoard; - private readonly _player1Id: string; - private readonly _player2Id: string; - private readonly _moveHistory: Move[]; + private isGameOver: boolean; + private readonly gameBoard: GameBoard; + private readonly _player1Id: string; + private readonly _player2Id: string; + private readonly _moveHistory: Move[]; - public constructor(player1Id: string, player2Id: string) { - this.gameBoard = new GameBoard(player1Id, player2Id); - this._player1Id = player1Id; - this._player2Id = player2Id; - this.isGameOver = false; - this._moveHistory = []; - this.currentPlayerId = player1Id; - } - public currentPlayerId: string; - public get size(): Coord { return this.gameBoard.size; } - public get player1Id(): string { return this._player1Id; } - public get player2Id(): string { return this._player2Id; } - public get moveHistory(): Move[] { return this._moveHistory; } - public getPlayerPositions(playerId: string): Coord[] { return this.gameBoard.getCoords(playerId); } - public coordToBoardIndex(coord: Coord): number { return this.gameBoard.coordToBoardIndex(coord); } - public makeMove(source: Coord, dest: Coord): MoveResult { - const checkForWin = (isP1Turn: boolean): MoveResult => { - const doCoordsFormSquare = (coords: Coord[]): boolean => { - const allRows = coords.map((c) => c.row); - const allCols = coords.map((c) => c.col); - const min = new Coord(Math.min(...allRows), Math.min(...allCols)); - const max = new Coord(Math.max(...allRows), Math.max(...allCols)); - const diff = max.sub(min); - const isSquare = diff.row === 1 && diff.col === 1; - return isSquare; - }; - const { gameBoard, player1Id, player2Id } = this; - const playerId = isP1Turn ? player1Id : player2Id; - - const playerCoords = gameBoard.getCoords(playerId) - const squareCoords: Coord[] = [ - new Coord(0, 0), - new Coord(3, 0), - new Coord(3, 3), - new Coord(0, 3), - ]; + public constructor(player1Id: string, player2Id: string) { + this.gameBoard = new GameBoard(player1Id, player2Id); + this._player1Id = player1Id; + this._player2Id = player2Id; + this.isGameOver = false; + this._moveHistory = []; + // this.currentPlayerId = Math.random() > 0.5 ? player1Id : player2Id; + this.currentPlayerId = player1Id; + } + getPiece(pos: Coord): string { + const value = this.gameBoard.get(pos); + if (value === null) return null; + const b = value.valueOf(); + const id = b ? this.player1Id : this.player2Id; + return id; + } + public currentPlayerId: string; + public get size(): Coord { + return this.gameBoard.size; + } + public get player1Id(): string { + return this._player1Id; + } + public get player2Id(): string { + return this._player2Id; + } + public get moveHistory(): Move[] { + return this._moveHistory; + } + public getPlayerPositions(playerId: string): Coord[] { + return this.gameBoard.getCoords(playerId); + } + public coordToBoardIndex(coord: Coord): number { + return this.gameBoard.coordToBoardIndex(coord); + } + public async makeMove(source: Coord, dest: Coord): Promise<MoveResult> { + const checkForWin = (isP1Turn: boolean): MoveResult => { + const doCoordsFormSquare = (coords: Coord[]): boolean => { + const allRows = coords.map((c) => c.row); + const allCols = coords.map((c) => c.col); + const min = new Coord(Math.min(...allRows), Math.min(...allCols)); + const max = new Coord(Math.max(...allRows), Math.max(...allCols)); + const diff = max.sub(min); + const isSquare = diff.row === 1 && diff.col === 1; + return isSquare; + }; + const { gameBoard, player1Id, player2Id } = this; + const playerId = isP1Turn ? player1Id : player2Id; - // winning conditions: - // 1. all in one row - // 2. all in one column - // 3. all in corners - // 4. all in square - // losing conditions: - // 1. on three sides of an opponent + const playerCoords = gameBoard.getCoords(playerId); + const squareCoords: Coord[] = [new Coord(0, 0), new Coord(3, 0), new Coord(3, 3), new Coord(0, 3)]; - const firstPiece = playerCoords[0]; - const allInRow = playerCoords.every((pc) => pc.row === firstPiece.row); - const allInCol = playerCoords.every((pc) => pc.col === firstPiece.col); - const allInCorners = playerCoords.every( - (pc) => squareCoords.findIndex((sc) => pc.equals(sc)) >= 0 - ); - const allInSquare = doCoordsFormSquare(playerCoords); + // winning conditions: + // 1. all in one row + // 2. all in one column + // 3. all in corners + // 4. all in square + // losing conditions: + // 1. on three sides of an opponent - const winnerName = isP1Turn ? "Player 1" : "Player 2"; - let winningMessage = undefined; - if (allInRow) { - winningMessage = `${winnerName} wins by forming a row`; - } - if (allInCol) { - winningMessage = `${winnerName} wins by forming a column`; - } - if (allInCorners) { - winningMessage = `${winnerName} wins by putting pieces in corners`; - } - if (allInSquare) { - winningMessage = `${winnerName} wins by forming a square`; - } - if (winningMessage) { - return { - isTerminal: true, - message: winningMessage - }; - } else { - return { - isTerminal: false - }; - } + const firstPiece = playerCoords[0]; + const allInRow = playerCoords.every((pc) => pc.row === firstPiece.row); + const allInCol = playerCoords.every((pc) => pc.col === firstPiece.col); + const allInCorners = playerCoords.every((pc) => squareCoords.findIndex((sc) => pc.equals(sc)) >= 0); + const allInSquare = doCoordsFormSquare(playerCoords); + const winnerName = isP1Turn ? 'Player 1' : 'Player 2'; + let winningMessage = undefined; + if (allInRow) { + winningMessage = `${winnerName} wins by forming a row`; + } + if (allInCol) { + winningMessage = `${winnerName} wins by forming a column`; + } + if (allInCorners) { + winningMessage = `${winnerName} wins by putting pieces in corners`; + } + if (allInSquare) { + winningMessage = `${winnerName} wins by forming a square`; + } + if (winningMessage) { + return { + isTerminal: true, + message: winningMessage, + }; + } else { + return { + isTerminal: false, }; + } + }; - const { gameBoard, player1Id, player2Id } = this; - const isP1Turn = (this.currentPlayerId === player1Id); - const playerId = isP1Turn ? player1Id : player2Id; + const { gameBoard, player1Id, player2Id } = this; + const isP1Turn = this.currentPlayerId === player1Id; + const playerId = isP1Turn ? player1Id : player2Id; - this._moveHistory.push(new Move(playerId, source, dest)); - gameBoard.move(source, dest); + this._moveHistory.push(new Move(playerId, source, dest)); + gameBoard.move(source, dest); - const result = checkForWin(isP1Turn); - if (result.isTerminal) { - this.isGameOver = true; - } else { - this.currentPlayerId = isP1Turn ? this.player2Id : this.player1Id; - } - return result; + const result = checkForWin(isP1Turn); + if (result.isTerminal) { + this.isGameOver = true; + } else { + this.currentPlayerId = isP1Turn ? this.player2Id : this.player1Id; } - public getValidMoves(start: Coord): Coord[] { - const getFarthestCoord = ( - startCoord: Coord, - direction: Coord - ): Coord => { - const { gameBoard } = this; - let farthestPos = undefined; - let testPos = startCoord; - while (true) { - const pos = new Coord(testPos.row + direction.row, testPos.col + direction.col); + return result; + } + public getValidMoves(start: Coord): Coord[] { + const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord => { + const { gameBoard } = this; + let farthestPos = undefined; + let testPos = startCoord; + while (true) { + const pos = new Coord(testPos.row + direction.row, testPos.col + direction.col); - const withinBounds = gameBoard.size.contains(pos); - if (!withinBounds) { - break; - } - const isOccupied = gameBoard.get(pos) !== null; - if (isOccupied) { - break; - } + const withinBounds = gameBoard.size.contains(pos); + if (!withinBounds) { + break; + } + const isOccupied = gameBoard.get(pos) !== null; + if (isOccupied) { + break; + } - farthestPos = pos; - testPos = pos; - } - return farthestPos; - }; + farthestPos = pos; + testPos = pos; + } + return farthestPos; + }; - const farthestCoords = Coord.allDirections - .map((direction) => getFarthestCoord(start, direction)) - .filter((farCoord) => farCoord !== undefined); + const farthestCoords = Coord.allDirections + .map((direction) => getFarthestCoord(start, direction)) + .filter((farCoord) => farCoord !== undefined); - return farthestCoords; + return farthestCoords; + } + public undoMove(): Move { + if (!this.canUndoMove) { + throw "Newp! Can't undo!"; } - public undoMove(): Move { - if (!this.canUndoMove) { throw "Newp! Can't undo!" } - const { gameBoard } = this; - const move = this._moveHistory.pop(); - const { source, dest, playerId } = move; - this.currentPlayerId = playerId; - gameBoard.move(dest, source); - return move; - } - public get canUndoMove(): boolean { - if (this._moveHistory.length === 0 || this.isGameOver) { - return false; - } - return true; + const { gameBoard } = this; + const move = this._moveHistory.pop(); + const { source, dest, playerId } = move; + this.currentPlayerId = playerId; + gameBoard.move(dest, source); + return move; + } + public get canUndoMove(): boolean { + if (this._moveHistory.length === 0 || this.isGameOver) { + return false; } + return true; + } } diff --git a/src/local-game.ts b/src/local-game.ts index a8d9ffa..cfee804 100644 --- a/src/local-game.ts +++ b/src/local-game.ts @@ -1,248 +1,237 @@ -import { Game, IGame } from "./game"; -import { Coord } from "./types"; -import { ListenerManager } from "./listeners" +import { Game, IGame } from './game'; +import { Coord, MoveResult } from './types'; +import { ListenerManager } from './listeners'; const classNames = { - selectedPiece: "selected-piece", - selectedPlayerText: "selected-player-text", - activePlayerPiece: "active-player-piece", - possibleMove: "possible-move", + selectedPiece: 'selected-piece', + selectedPlayerText: 'selected-player-text', + activePlayerPiece: 'active-player-piece', + possibleMove: 'possible-move', }; export class LocalGame extends Game { - selectedPiece: Element | null = null; - listenerManager: ListenerManager = new ListenerManager(); - - // DOM references - private readonly dom = { - defaultP1Piece: document.getElementById("default-p1-piece"), - defaultP2Piece: document.getElementById("default-p2-piece"), - resetButton: document.getElementById("reset-button") as HTMLInputElement, - undoButton: document.getElementById("undo-button") as HTMLInputElement, - cells: () => Array.from(document.querySelectorAll("td")), - allPieces: () => Array.from(document.querySelectorAll(".piece")), - p1TurnText: () => Array.from(document.querySelectorAll(".p1-turn-text")), - p2TurnText: () => Array.from(document.querySelectorAll(".p2-turn-text")), + selectedPiece: Element | null = null; + listenerManager: ListenerManager = new ListenerManager(); + + // DOM references + private readonly dom = { + defaultP1Piece: document.getElementById('default-p1-piece'), + defaultP2Piece: document.getElementById('default-p2-piece'), + resetButton: document.getElementById('reset-button') as HTMLInputElement, + undoButton: document.getElementById('undo-button') as HTMLInputElement, + cells: () => Array.from(document.querySelectorAll('td')), + allPieces: () => Array.from(document.querySelectorAll('.piece')), + p1TurnText: () => Array.from(document.querySelectorAll('.p1-turn-text')), + p2TurnText: () => Array.from(document.querySelectorAll('.p2-turn-text')), + }; + + private getPiecesAndCoords(playerId: string) { + const allCells = this.dom.cells(); + const playerPositions = this.getPlayerPositions(playerId); + const piecesAndPositions = playerPositions.map((coord) => { + const index = this.coordToBoardIndex(coord); + const cell = allCells[index]; + const piece = cell.children[0]; + return { coord, piece }; + }); + return piecesAndPositions; + } + + /** + * Animates the motion of piece moving from source to dest + * @param source Starting Coord + * @param dest Ending Coord + * @param playerId Player id + */ + private async movePiece(source: Coord, dest: Coord) { + const { dom } = this; + const sourceIndex = this.coordToBoardIndex(source); + const destIndex = this.coordToBoardIndex(dest); + + // find current piece + const piece = dom.cells()[sourceIndex].children[0]; + // unselect piece + piece.classList.remove(classNames.selectedPiece); + + // create clone for new position + const destPiece = piece.cloneNode(true); + // add to destination cell + dom.cells()[destIndex].appendChild(destPiece); + + // start animations + const destAnimation = dom.cells()[destIndex].animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1000, iterations: 1 }); + const sourceAnimation = piece.animate( + [ + { + opacity: 1, + width: '45px', + height: '45px', + }, + { + opacity: 0, + width: '5px', + height: '5px', + }, + ], + { duration: 1000, iterations: 1 } + ); + + // await animations + await Promise.all([sourceAnimation.finished, destAnimation.finished]); + + // remove old piece + piece.parentNode.removeChild(piece); + } + + public constructor(player1Id: string, player2Id: string) { + super(player1Id, player2Id); + this.init(); + } + + public override async makeMove(source: Coord, dest: Coord): Promise<MoveResult> { + this.listenerManager.removeListeners(); + await this.movePiece(source, dest); + const result = await super.makeMove(source, dest); + this.handleMoveResult(result); + return result; + } + + public async handleMoveResult(result: MoveResult) { + if (result.isTerminal) { + alert(result.message); + document.location.reload(); + } else { + this.resetUi(); + this.setActiveUi(); } + } - private resetBoard() { - const { dom, listenerManager } = this; - // reset Cells - dom.cells().forEach(cell => cell.classList.remove(classNames.possibleMove)); - // reset pieces - dom.allPieces().forEach(piece => { - piece.classList.remove(classNames.activePlayerPiece) - piece.classList.remove(classNames.selectedPiece) - }); - // remove all listeners - listenerManager.removeListeners(); - } - - /** - * Animates the motion of piece moving from source to dest - * @param source Starting Coord - * @param dest Ending Coord - * @param playerId Player id - */ - private async movePiece(source: Coord, dest: Coord) { - const { dom } = this; - const sourceIndex = this.coordToBoardIndex(source); - const destIndex = this.coordToBoardIndex(dest); - - // find current piece - const piece = dom.cells()[sourceIndex].children[0]; - // unselect piece - piece.classList.remove(classNames.selectedPiece); - - // create clone for new position - const destPiece = piece.cloneNode(true); - // add to destination cell - dom.cells()[destIndex].appendChild(destPiece); - - // start animations - const destAnimation = dom.cells()[destIndex].animate( - [{ opacity: 0 }, { opacity: 1 }], - { duration: 1000, iterations: 1, }); - const sourceAnimation = piece.animate( - [{ - opacity: 1, - width: "45px", - height: "45px", - }, { - opacity: 0, - width: "5px", - height: "5px", - }], - { duration: 1000, iterations: 1 } - ); - - // await animations - await Promise.all([sourceAnimation.finished, destAnimation.finished]); - - // remove old piece - piece.parentNode.removeChild(piece); - } + public resetUi() { + const { dom, currentPlayerId, player1Id, canUndoMove, moveHistory, listenerManager, selectedPiece } = this; - public nextMove() { - const setActivePlayerPieces = () => { - const { - dom, - listenerManager, - selectedPiece, - currentPlayerId, player1Id, player2Id, - } = this; - const selectPiece = (piece: Element, source: Coord) => { - // clear current selection - if (selectedPiece !== null) { - selectedPiece.classList.remove(classNames.selectedPiece); - dom.cells().forEach(cell => cell.classList.remove(classNames.possibleMove)); - } - - // set as selected - piece.classList.add(classNames.selectedPiece); - this.selectedPiece = piece; - - // hook up allowed moves for: - // 1. blink - // 2. complete the move - const allowedMoves = this.getValidMoves(source); - allowedMoves.map((dest: Coord) => { - const cell = dom.cells()[this.coordToBoardIndex(dest)]; - cell.classList.add(classNames.possibleMove); - listenerManager.addListener(cell, "click", async () => await finishMove(source, dest)); - }); - }; - - // complete the move from source to dest - const finishMove = async (source: Coord, dest: Coord) => { - listenerManager.removeListeners(); - - const result = this.makeMove(source, dest); - await this.movePiece(source, dest); - - if (result.isTerminal) { - alert(result.message); - document.location.reload(); - } else { - this.nextMove(); - } - }; - - // mark active pieces - const allCells = dom.cells(); - const playerPositions = this.getPlayerPositions(currentPlayerId); - const piecesAndPositions = playerPositions.map(coord => { - const index = this.coordToBoardIndex(coord); - const cell = allCells[index]; - const piece = cell.children[0]; - return { coord, piece }; - }); - piecesAndPositions - .forEach(({ coord, piece }) => { - piece.classList.add(classNames.activePlayerPiece); - listenerManager.addListener(piece, "click", event => selectPiece(piece, coord)); - }); - }; - - const { - dom, - currentPlayerId, player1Id, player2Id, - canUndoMove, moveHistory, - } = this; - - // reset text - dom.p1TurnText().forEach((t) => t.classList.remove(classNames.selectedPlayerText)); - dom.p2TurnText().forEach((t) => t.classList.remove(classNames.selectedPlayerText)); - - // changePlayer - // 1. flip flag - // 2. set text - // 3. set click callbacks - const isP1Turn = currentPlayerId === player1Id; - if (isP1Turn) { - dom.p1TurnText().forEach((t) => t.classList.add(classNames.selectedPlayerText)); - } else { - dom.p2TurnText().forEach((t) => t.classList.add(classNames.selectedPlayerText)); - } + // reset text + dom.p1TurnText().forEach((t) => t.classList.remove(classNames.selectedPlayerText)); + dom.p2TurnText().forEach((t) => t.classList.remove(classNames.selectedPlayerText)); - dom.undoButton.disabled = canUndoMove; - if (moveHistory.length === 0) { - dom.undoButton.disabled = true; - dom.resetButton.disabled = true; - } else { - dom.undoButton.disabled = false; - dom.resetButton.disabled = false; - } - - this.selectedPiece = null; + const isP1Turn = currentPlayerId === player1Id; + if (isP1Turn) { + dom.p1TurnText().forEach((t) => t.classList.add(classNames.selectedPlayerText)); + } else { + dom.p2TurnText().forEach((t) => t.classList.add(classNames.selectedPlayerText)); + } - this.resetBoard(); - setActivePlayerPieces(); + dom.undoButton.disabled = canUndoMove; + if (moveHistory.length === 0) { + dom.undoButton.disabled = true; + dom.resetButton.disabled = true; + } else { + dom.undoButton.disabled = false; + dom.resetButton.disabled = false; } - public init() { - const { dom, player1Id, player2Id, size } = this; - - // create board and pieces - let isWhite = true; - const table = document.getElementById("table"); - for (let r = 0; r < size.row; r++) { - const tr = document.createElement("tr"); - table.appendChild(tr); - for (let c = 0; c < size.col; c++) { - const td = document.createElement("td"); - tr.appendChild(td); - td.className = isWhite ? "white" : "black"; - isWhite = !isWhite; - } - isWhite = !isWhite; + this.selectedPiece = null; + + // reset Cells + dom.cells().forEach((cell) => cell.classList.remove(classNames.possibleMove)); + // reset pieces + dom.allPieces().forEach((piece) => { + piece.classList.remove(classNames.activePlayerPiece); + piece.classList.remove(classNames.selectedPiece); + }); + // remove all listeners + listenerManager.removeListeners(); + } + public setActiveUi() { + const { dom, currentPlayerId, listenerManager, selectedPiece } = this; + + // mark active pieces + const piecesAndPositions = this.getPiecesAndCoords(currentPlayerId); + piecesAndPositions.forEach(({ coord: source, piece }) => { + piece.classList.add(classNames.activePlayerPiece); + listenerManager.addListener(piece, 'click', () => { + // clear current selection + if (selectedPiece !== null) { + selectedPiece.classList.remove(classNames.selectedPiece); + dom.cells().forEach((cell) => cell.classList.remove(classNames.possibleMove)); } - const createPiece = (isP1Turn: boolean, pieceId: string, coord: Coord) => { - const defaultPiece = isP1Turn - ? dom.defaultP1Piece - : dom.defaultP2Piece; - const newPiece = defaultPiece.cloneNode( - true - ) as HTMLParagraphElement; - newPiece.id = pieceId; - const index = this.coordToBoardIndex(coord); - dom.cells()[index].appendChild(newPiece); - }; - this.getPlayerPositions(player1Id).forEach(coord => createPiece(true, player1Id, coord)); - this.getPlayerPositions(player2Id).forEach(coord => createPiece(false, player2Id, coord)); - - // hook up buttons - let blockActions = false; - dom.resetButton.addEventListener("click", () => { - if (blockActions) { - return; - } - - blockActions = true; - if (confirm("Reset?")) { - window.location.reload(); - } - blockActions = false; - }); - dom.undoButton.addEventListener("click", async () => { - if (blockActions) { - return; - } - - blockActions = true; - - const move = this.undoMove(); - await this.movePiece(move.dest, move.source); - this.nextMove(); - blockActions = false; + // set as selected + piece.classList.add(classNames.selectedPiece); + this.selectedPiece = piece; + + // hook up allowed moves for: + // 1. blink + // 2. complete the move + const allowedMoves = this.getValidMoves(source); + allowedMoves.forEach((dest: Coord) => { + const cell = dom.cells()[this.coordToBoardIndex(dest)]; + cell.classList.add(classNames.possibleMove); + listenerManager.addListener(cell, 'click', async () => await this.makeMove(source, dest)); }); + }); + }); + } + + private init() { + const { dom, player1Id, player2Id, size } = this; + + // create board and pieces + let isWhite = true; + const table = document.getElementById('table'); + for (let r = 0; r < size.row; r++) { + const tr = document.createElement('tr'); + table.appendChild(tr); + for (let c = 0; c < size.col; c++) { + const td = document.createElement('td'); + tr.appendChild(td); + td.className = isWhite ? 'white' : 'black'; + isWhite = !isWhite; + } + isWhite = !isWhite; } + const createPiece = (isP1Turn: boolean, pieceId: string, coord: Coord) => { + const defaultPiece = isP1Turn ? dom.defaultP1Piece : dom.defaultP2Piece; + const newPiece = defaultPiece.cloneNode(true) as HTMLParagraphElement; + newPiece.id = pieceId; + const index = this.coordToBoardIndex(coord); + dom.cells()[index].appendChild(newPiece); + }; + this.getPlayerPositions(player1Id).forEach((coord) => createPiece(true, player1Id, coord)); + this.getPlayerPositions(player2Id).forEach((coord) => createPiece(false, player2Id, coord)); + + // hook up buttons + let blockActions = false; + dom.resetButton.addEventListener('click', () => { + if (blockActions) { + return; + } + + blockActions = true; + if (confirm('Reset?')) { + window.location.reload(); + } + blockActions = false; + }); + dom.undoButton.addEventListener('click', async () => { + if (blockActions) { + return; + } + + blockActions = true; + + const move = this.undoMove(); + await this.movePiece(move.dest, move.source); + this.resetUi(); + this.setActiveUi(); + + blockActions = false; + }); + } } // start! export const playGame = () => { - const lg = new LocalGame('Red', 'Blue'); - lg.init(); - lg.nextMove(); + const lg = new LocalGame('Red', 'Blue', 'Red'); + lg.resetUi(); + lg.setActiveUi(); }; diff --git a/src/remote-game.ts b/src/remote-game.ts index fdb631d..2923ead 100644 --- a/src/remote-game.ts +++ b/src/remote-game.ts @@ -1,6 +1,8 @@ import Peer, { PeerJSOption } from 'peerjs'; import { LocalGame } from './local-game'; -import { useLocalStorage } from './storage'; +import { useSessionStorage } from './storage'; +import { Coord, Move, MoveResult } from './types'; +import { delay, generateHash } from './utils'; interface LocalData { id: string; @@ -11,75 +13,113 @@ interface Pos { y: number; } interface PeerData { - mousePos: Pos; + mousePos?: Pos; + move?: Move; } - -// { -// selfPeerId: `dao-game-${prompt('Name?', 'Red') ?? 'Red'}`, -// otherPeerIds: [] -// } -const { getter: getData, setter: setData } = useLocalStorage<LocalData>( - 'dao-game-v2022-03-18-1412', - { id: undefined, peerId: undefined } -); +const { getter: getData, setter: setData } = useSessionStorage<LocalData>('dao-game', { id: undefined, peerId: undefined }); +const otherMouseElement = document.getElementById('other-mouse'); +const openInNewTab = (url: string) => { + const openInEdge = true; + if (openInEdge) { + // opening in the same browser causes a copy of the session storage, + // so we clear the session storage, open the url, and restore the session storage + const data = getData(); + sessionStorage.clear(); + try { + window.open(url, '_blank'); + } finally { + setData(data); + } + } else { + window.open(url, '_blank'); + } +}; +const minString = (a: string, b: string): string => (a < b ? a : b); +const maxString = (a: string, b: string): string => (a > b ? a : b); +const getFirstPlayerId = (playerId1: string, playerId2: string) => { + const hash1 = generateHash(playerId1); + const hash2 = generateHash(playerId2); + const prod = hash1 * hash2; + const isEven = prod % 2 == 0; + const id = isEven ? minString(playerId1, playerId2) : maxString(playerId1, playerId2); + return id; +}; export class PeerGame extends LocalGame { - peerMousePos: Pos; - - public constructor() { - super('Local', 'Remote'); + public conn: Peer.DataConnection; + public selfId: string; + public constructor(player1Id: string, player2Id: string, selfId: string, conn: Peer.DataConnection) { + super(player1Id, player2Id); + this.selfId = selfId; + this.conn = conn; + } + public override setActiveUi(): void { + const isLocalTurn = this.currentPlayerId === this.selfId; + if (isLocalTurn) { + super.setActiveUi(); + } + } + public override makeMove(source: Coord, dest: Coord): Promise<MoveResult> { + const result = super.makeMove(source, dest); + const peerData: PeerData = { move: { source, dest, playerId: this.currentPlayerId } }; + this.conn.send(JSON.stringify(peerData)); + return result; + } + public static async createPeerGame(): Promise<PeerGame> { let { id, peerId } = getData(); const main = document.getElementsByTagName('body')[0]; - const backCanvas = document.getElementById( - 'back-canvas' - ) as HTMLCanvasElement; + let game: PeerGame = undefined; const handleConn = (conn: Peer.DataConnection) => { id = peer.id; peerId = conn.peer; setData({ id, peerId }); - console.log('Adding event listener to main'); + const isLocalP1 = getFirstPlayerId(id, peerId) === id; + const game = isLocalP1 ? new PeerGame(id, peerId, id, conn) : new PeerGame(peerId, id, id, conn); + game.resetUi(); + game.setActiveUi(); + main.addEventListener('mousemove', (ev: MouseEvent) => { const peerData: PeerData = { mousePos: { x: ev.x, y: ev.y } }; - const text = JSON.stringify(peerData); - // console.log(`Sending data to ${conn.peer}: ${text}`); - conn.send(text); + conn.send(JSON.stringify(peerData)); }); - conn.on('open', () => { - console.log(`Connection to ${conn.peer} opened`); - conn.send('{}'); - }); conn.on('data', (data: string) => { - // console.log(`Receiving data from ${conn.peer}: ${data}`); const peerData = JSON.parse(data) as PeerData; - if (peerData && peerData.mousePos) { - this.peerMousePos = peerData.mousePos; + const { mousePos, move } = peerData; + + if (mousePos) { + const style = `top:${mousePos.y}px;left:${mousePos.x - otherMouseElement.clientWidth / 2}px;`; + otherMouseElement.setAttribute('style', style); + } + + const isWaitingForRemoteMove = peerId === game.currentPlayerId; + if (isWaitingForRemoteMove && move) { + const isMoveLegal = game.getPiece(move.source) === game.currentPlayerId && game.getPiece(move.dest) === null; + if (isMoveLegal) { + game.makeMove(move.source, move.dest); + } else { + console.log({ isMoveLegal, move }); + } } }); - conn.send('hi!'); }; const { host, protocol, pathname, search } = location; const queryParams = new URLSearchParams(search); - console.log(`Creating peer connection for ${id}`); const peerOptions: PeerJSOption = { debug: 0 }; - const peer = - id === undefined ? new Peer(peerOptions) : new Peer(id, peerOptions); - id = peer.id; + const peer = id === undefined ? new Peer(peerOptions) : new Peer(id, peerOptions); window.onunload = () => peer.destroy(); - // document.addEventListener('close', () => peer.destroy()); - peer.on('connection', (conn) => { - console.log(`Connection established to ${conn.peer}`); - handleConn(conn); - }); + peer.on('connection', (conn) => handleConn(conn)); + peer.on('error', (err) => console.error({ err })); peer.on('open', (trueId) => { id = trueId; peerId = queryParams.get('peerId') ?? peerId; setData({ id, peerId }); + if (peerId) { console.log(`${id} connecting to peer "${peerId}"`); const conn = peer.connect(peerId); @@ -88,33 +128,18 @@ export class PeerGame extends LocalGame { queryParams.set('peerId', id); const newSearch = queryParams.toString(); const newUrl = `microsoft-edge:${protocol}//${host}${pathname}?${newSearch}`; - const nw = window.open(newUrl, '_blank'); + openInNewTab(newUrl); } }); - peer.on('error', (err) => console.error({ err })); - const animate = () => { - const { peerMousePos } = this; - if (peerMousePos !== undefined) { - // console.log({ peerMousePos: this.peerMousePos }); - // this.peerMousePos = undefined; - backCanvas.width = window.innerWidth; - backCanvas.height = window.innerHeight; - const ctx = backCanvas.getContext('2d'); - ctx.fillStyle = 'green'; - ctx.beginPath(); - ctx.ellipse(peerMousePos.x, peerMousePos.y, 50, 50, 0, 0, Math.PI * 2); - ctx.closePath(); - ctx.fill(); - } - requestAnimationFrame(() => animate()); - }; - requestAnimationFrame(() => animate()); + while (game === undefined) { + await delay(500); + } + return game; } } -export const playGame = () => { - const pg = new PeerGame(); - pg.init(); - pg.nextMove(); +export const playGame = async () => { + document.getElementsByTagName('body')[0].style.setProperty('display', ''); + const game = await PeerGame.createPeerGame(); }; diff --git a/src/storage.ts b/src/storage.ts index 7e48952..d05cd5c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -2,7 +2,7 @@ export interface StorageInterface<T> { getter: () => T; setter: (data: T) => void; } -export const useLocalStorage = <T>( +export const useSessionStorage = <T>( key: string, defaultData?: T ): StorageInterface<T> => { diff --git a/src/types.ts b/src/types.ts index ff0e77f..ca8d96a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,129 +1,138 @@ export class Coord { - public row: number; - public col: number; + public row: number; + public col: number; - constructor(row: number, col: number) { - this.row = row; - this.col = col; - } + constructor(row: number, col: number) { + this.row = row; + this.col = col; + } - public add(coord: Coord): Coord { - return new Coord(this.row + coord.row, this.col + coord.col); - } + public add(coord: Coord): Coord { + return new Coord(this.row + coord.row, this.col + coord.col); + } - sub(coord: Coord): Coord { - return new Coord(this.row - coord.row, this.col - coord.col); - } + sub(coord: Coord): Coord { + return new Coord(this.row - coord.row, this.col - coord.col); + } - /** - * Finds if otherCoord is contained within a rectangle that sits between origin and this Coord - * @param otherCoord - */ - contains(otherCoord: Coord): boolean { - const xRatio = otherCoord.col / this.col; - const yRatio = otherCoord.row / this.row; - const xyInsideSquare = xRatio >= 0 && xRatio < 1 && yRatio >= 0 && yRatio < 1; - return xyInsideSquare; - } + /** + * Finds if otherCoord is contained within a rectangle that sits between origin and this Coord + * @param otherCoord + */ + contains(otherCoord: Coord): boolean { + const xRatio = otherCoord.col / this.col; + const yRatio = otherCoord.row / this.row; + const xyInsideSquare = xRatio >= 0 && xRatio < 1 && yRatio >= 0 && yRatio < 1; + return xyInsideSquare; + } - equals(otherCoord: Coord): boolean { - const areEqual = (this.col === otherCoord.col && this.row === otherCoord.row); - return areEqual; - } - - // clock face directions - static get allDirections(): Coord[] { - const allDirections = [ - new Coord(1, 0), // up - new Coord(1, 1), // up-right - new Coord(0, 1), // right - new Coord(-1, 1), // right-down - new Coord(-1, 0), // down - new Coord(-1, -1), // down-left - new Coord(0, -1), // left - new Coord(1, -1), // left-up - ] - return allDirections; - } + equals(otherCoord: Coord): boolean { + const areEqual = this.col === otherCoord.col && this.row === otherCoord.row; + return areEqual; + } + // clock face directions + static get allDirections(): Coord[] { + const allDirections = [ + new Coord(1, 0), // up + new Coord(1, 1), // up-right + new Coord(0, 1), // right + new Coord(-1, 1), // right-down + new Coord(-1, 0), // down + new Coord(-1, -1), // down-left + new Coord(0, -1), // left + new Coord(1, -1), // left-up + ]; + return allDirections; + } } export class Move { - public readonly source: Coord; - public readonly dest: Coord; - public readonly playerId: string; - constructor(playerId: string, source: Coord, dest: Coord) { - this.source = source; - this.dest = dest; - this.playerId = playerId; - } + public source: Coord; + public dest: Coord; + public playerId: string; + constructor(playerId: string, source: Coord, dest: Coord) { + this.source = source; + this.dest = dest; + this.playerId = playerId; + } } export interface MoveResult { - isTerminal: boolean; - message?: string; + isTerminal: boolean; + message?: string; } export class GameBoard { - private board: (string | null)[]; - public readonly size: Coord; - public readonly player1Id: string; - public readonly player2Id: string + private board: (string | null)[]; + public readonly size: Coord; + public readonly player1Id: string; + public readonly player2Id: string; - // testBoard: [ - // 4, null, null, null, - // null, 0, 1, 5, - // null, 2, 3, null, - // 6, null, null, 7 - // ], - public constructor(player1Id: string, player2Id: string) { - this.board = [ - player1Id, null, null, player2Id, - null, player1Id, player2Id, null, - null, player2Id, player1Id, null, - player2Id, null, null, player1Id - ]; - this.size = new Coord(4, 4); - this.player1Id = player1Id; - this.player2Id = player2Id; - } - public move(source: Coord, dest: Coord): void { - const isP1 = this.get(source); - if (isP1 === null) throw `Source ${source} points to an empty space`; - this.set(source, null); - this.set(dest, isP1); - } - public get(coord: Coord): boolean | null { - const index = this.coordToBoardIndex(coord); - if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length) throw `Index ${index} is out of bounds`; - const id = this.board[index]; - const isP1 = id === null ? null : (id === this.player1Id); - return isP1; - } - public getCoords(id: string): Coord[] { - const coords = this.board - .map((pieceId, index) => { - const isTarget = pieceId === id; - return isTarget ? this.boardIndexToCoord(index) : null - }) - .filter(coord => coord !== null); - return coords; - } - private set(coord: Coord, isP1: boolean | null): void { - const index = this.coordToBoardIndex(coord); - if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length) throw `Index ${index} is out of bounds`; - this.board[index] = isP1 === null ? - null : - (isP1 ? this.player1Id : this.player2Id); - } - public coordToBoardIndex(coord: Coord): number { - const { row, col } = coord; - const index = row * this.size.col + col; - return index; - } - private boardIndexToCoord(index: number): Coord { - const col = index % this.size.col; - const row = Math.floor(index / this.size.col); - return new Coord(row, col); - } + // testBoard: [ + // 4, null, null, null, + // null, 0, 1, 5, + // null, 2, 3, null, + // 6, null, null, 7 + // ], + public constructor(player1Id: string, player2Id: string) { + this.board = [ + player1Id, + null, + null, + player2Id, + null, + player1Id, + player2Id, + null, + null, + player2Id, + player1Id, + null, + player2Id, + null, + null, + player1Id, + ]; + this.size = new Coord(4, 4); + this.player1Id = player1Id; + this.player2Id = player2Id; + } + public move(source: Coord, dest: Coord): void { + const isP1 = this.get(source); + if (isP1 === null) throw `Source ${source} points to an empty space`; + this.set(source, null); + this.set(dest, isP1); + } + public get(coord: Coord): boolean | null { + const index = this.coordToBoardIndex(coord); + if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length) throw `Index ${index} is out of bounds`; + const id = this.board[index]; + const isP1 = id === null ? null : id === this.player1Id; + return isP1; + } + public getCoords(id: string): Coord[] { + const coords = this.board + .map((pieceId, index) => { + const isTarget = pieceId === id; + return isTarget ? this.boardIndexToCoord(index) : null; + }) + .filter((coord) => coord !== null); + return coords; + } + private set(coord: Coord, isP1: boolean | null): void { + const index = this.coordToBoardIndex(coord); + if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length) throw `Index ${index} is out of bounds`; + this.board[index] = isP1 === null ? null : isP1 ? this.player1Id : this.player2Id; + } + public coordToBoardIndex(coord: Coord): number { + const { row, col } = coord; + const index = row * this.size.col + col; + return index; + } + private boardIndexToCoord(index: number): Coord { + const col = index % this.size.col; + const row = Math.floor(index / this.size.col); + return new Coord(row, col); + } } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..2ad21ab --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,20 @@ +export function generateHash(text: string) { + var hash = 0; + if (text.length == 0) { + return hash; + } + for (let i = 0; i < text.length; i++) { + var charCode = text.charCodeAt(i); + hash = (hash << 7) - hash + charCode; + hash = hash & hash; + } + return hash; +} + +export async function delay(delayInMs: number) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(2); + }, delayInMs); + }); +}