dao-game / commit
Starting to work on remote React game. Initial thought is to properly implement Game subclass that automatically handles the peer connection stuff.
src/game/remote-game-2.ts | 232 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/src/game/remote-game-2.ts b/src/game/remote-game-2.ts new file mode 100644 index 0000000..f3aebf6 --- /dev/null +++ b/src/game/remote-game-2.ts @@ -0,0 +1,232 @@ +import Peer, { PeerJSOption } from 'peerjs'; +import { showModal } from '../utils/modal'; +import { useSessionStorage } from '../utils/storage'; +import { showSnackbar } from "../utils/snackbar"; +import { Game, Move, MoveResult } from './game'; +import { Coord } from './coord'; +import { generateHash } from '../utils/hashing'; +import { delay } from '../utils/delay'; +import { copyToClipboard } from '../utils/clipboard'; +import { detect } from 'detect-browser'; + +interface LocalData { + id?: string; + peerId?: string; +} +interface Pos { + x: number; + y: number; +} +interface PeerData { + mousePos?: Pos; + move?: Move; +} + +const peerOptions: PeerJSOption = { debug: 0 }; +const { getter: getData, setter: setData } = useSessionStorage<LocalData>('dao-game', { id: undefined, peerId: undefined }); +const otherMouseElement = document.getElementById('other-mouse')!; +const shareContainer = document.getElementById('share-container')!; +const shareLinkButton = document.getElementById('share-link')!; +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; +}; +const dropFileExtension = (path: string) => { + const lastSlashIndex = path.lastIndexOf('/'); + const dirName = path.substring(0, lastSlashIndex); + return dirName; +} +const showMessageAndLeave = async (title: string, text: string) => { + await showModal({ title, text, willBackgroundCancel: false }); + sessionStorage.clear(); + const { protocol, host, pathname } = location; + const dir = dropFileExtension(pathname); + const newUrl = `${protocol}//${host}${dir}` + document.location = newUrl; +} + +export class PeerGame extends Game { + public conn: Peer.DataConnection; + public selfId: string; + public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string, selfId: string, conn: Peer.DataConnection) { + super(player1Id, player1Name, player2Id, player2Name); + this.selfId = selfId; + this.conn = conn; + } + public override setActiveUi(): void { + const { currentPlayerId, selfId } = this; + + const isLocalTurn = currentPlayerId === 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 override async handleMoveResult(result: MoveResult): Promise<void> { + const { isTerminal, message } = result; + if (isTerminal) { + await showMessageAndLeave('Game Over', message!); + } else { + this.resetUi(); + this.setActiveUi(); + } + } + + public static async createPeerGame(): Promise<PeerGame> { + let { id, peerId } = getData(); + const main = document.getElementsByTagName('main')[0]; + let game: PeerGame | undefined = undefined; + + const handleConn = async (conn: Peer.DataConnection) => { + shareContainer.classList.add('invisible'); + + await new Promise<void>((resolve) => { + conn.on('open', () => resolve()); + }); + + id = peer.id; + peerId = conn.peer; + setData({ id, peerId }); + + const isLocalP1 = getFirstPlayerId(id, peerId) === id; + game = isLocalP1 ? + new PeerGame(id, "Local", peerId, "Remote", id, conn) : + new PeerGame(peerId, "Remote", id, "Local", id, conn); + game.resetUi(); + game.setActiveUi(); + main.classList.remove('invisible'); + + const table = document.getElementById('table')!; + table.addEventListener('mousemove', (ev: MouseEvent) => { + const xTable = ev.clientX - table.offsetLeft; + const yTable = ev.clientY - table.offsetTop; + const xRatio = xTable / table.clientWidth; + const yRatio = yTable / table.clientHeight; + const peerData: PeerData = { mousePos: { x: xRatio, y: yRatio } }; + conn.send(JSON.stringify(peerData)); + }); + table.addEventListener('mouseleave', () => { + const peerData: PeerData = { mousePos: { x: -1, y: -1 } }; + conn.send(JSON.stringify(peerData)); + }); + + let lastTimerId: NodeJS.Timeout | undefined = undefined; + conn.on('data', (data: string) => { + if (!game) return; + + const peerData = JSON.parse(data) as PeerData; + const { mousePos, move } = peerData; + + if (mousePos) { + const isValidPos = mousePos.x !== -1 && mousePos.y !== -1; + if (isValidPos) { + const xRatio = mousePos.x; + const yRatio = mousePos.y; + const xTable = xRatio * table.clientWidth; + const yTable = yRatio * table.clientHeight; + const x = table.offsetLeft + xTable; + const y = table.offsetTop + yTable; + const style = `left:${x - otherMouseElement.clientWidth / 2}px;top:${y}px;display:block;`; + otherMouseElement.setAttribute('style', style); + if (lastTimerId !== undefined) { + clearTimeout(lastTimerId); + } + lastTimerId = setTimeout(() => { + otherMouseElement.setAttribute('style', ''); + lastTimerId = undefined; + }, 3000); + } else if (lastTimerId !== undefined) { + otherMouseElement.setAttribute('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.on('error', (err) => { + showMessageAndLeave(`Connection error`, err); + }); + conn.on('close', () => { + showMessageAndLeave(`Connection closed`, 'Connection closed for some reason') + }); + }; + + const { host, protocol, pathname, search } = location; + const queryParams = new URLSearchParams(search); + + const peer = id === undefined ? new Peer(peerOptions) : new Peer(id, peerOptions); + window.onunload = () => peer.destroy(); + peer.on('connection', (conn) => handleConn(conn)); + peer.on('error', async (err) => { + await showMessageAndLeave('Peer error encountered', 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); + handleConn(conn); + } else { + queryParams.set('peerId', id); + const newSearch = queryParams.toString(); + const newUrl = `${protocol}//${host}${pathname}?${newSearch}`; + shareLinkButton.onclick = () => { + showSnackbar('Url copied to clipboard'); + copyToClipboard(newUrl); + }; + shareContainer.classList.remove('invisible'); + } + }); + peer.on('disconnected', async () => { + await showMessageAndLeave('Peer disconnected', 'For some reason') + }); + while (game === undefined) { + console.log('Waiting to connect to peer'); + await delay(500); + } + return game; + } +} + +const isBrowserSupported = () => { + const browser = detect(); + switch (browser && browser.name) { + case 'chrome': + case 'firefox': + return true; + default: + return false; + } +} +export const playGame = async () => { + if (isBrowserSupported()) { + await PeerGame.createPeerGame(); + } else { + showMessageAndLeave( + 'Unsupported browser', + '<p>Your current browser is not yet supported.</p>' + + '<p>This PeerJs issue is tracking support in Microsoft Edge and Safari: </p>' + + '<p><a href="https://github.com/peers/peerjs/issues/899" target="_blank">https://github.com/peers/peerjs/issues/899</a></p>') + } +};