diff --git a/TableturfBattleClient/index.html b/TableturfBattleClient/index.html index b090e15..4cf4027 100644 --- a/TableturfBattleClient/index.html +++ b/TableturfBattleClient/index.html @@ -41,12 +41,18 @@

-

+ -

+ + +

@@ -141,26 +147,24 @@ -

+
- - -
- -
diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts index 2e35154..85670be 100644 --- a/TableturfBattleClient/src/Pages/GamePage.ts +++ b/TableturfBattleClient/src/Pages/GamePage.ts @@ -15,13 +15,13 @@ const gameDeckButton = document.getElementById('gameDeckButton') as HTMLButtonEl const handContainer = document.getElementById('handContainer')!; const redrawModal = document.getElementById('redrawModal')!; -const playControls = document.getElementById('playControls')!; -const resultContainer = document.getElementById('resultContainer')!; -const resultElement = document.getElementById('result')!; -const replayControls = document.getElementById('replayControls')!; +const gameControls = document.getElementById('gameControls')!; +const playRow = document.getElementById('playRow')!; +const spectatorRow = document.getElementById('spectatorRow')!; const replayNextButton = document.getElementById('replayNextButton')!; const replayPreviousButton = document.getElementById('replayPreviousButton')!; -const replayFlipBox = document.getElementById('replayFlipBox') as HTMLInputElement; +const flipLabel = document.getElementById('flipLabel') as HTMLLabelElement; +const flipBox = document.getElementById('flipBox') as HTMLInputElement; let replayAnimationAbortController: AbortController | null = null; const shareReplayLinkButton = document.getElementById('shareReplayLinkButton') as HTMLButtonElement; @@ -68,25 +68,56 @@ cols[4][21] = Space.SpecialInactive1; cols[4][4] = Space.SpecialInactive2; board.resize(cols); -function initGame() { +function clear() { testMode = false; gamePage.classList.remove('deckTest'); - playControls.hidden = false; - resultContainer.hidden = true; - replayControls.hidden = true; - gameButtonsContainer.hidden = false; + gamePage.classList.remove('gameEnded'); + gameControls.hidden = true; + handContainer.hidden = true; + playRow.hidden = true; + spectatorRow.hidden = true; + replayPreviousButton.hidden = true; + replayNextButton.hidden = true; + shareReplayLinkButton.hidden = true; + flipLabel.hidden = false; + flipBox.checked = false; + gameButtonsContainer.hidden = true; testControls.hidden = true; + for (const playerBar of playerBars) { + playerBar.resultElement.classList.remove('win'); + playerBar.resultElement.classList.remove('lose'); + playerBar.resultElement.classList.remove('draw'); + playerBar.resultElement.innerText = ''; + } +} + +function initGame() { + clear(); + gameControls.hidden = false; + handContainer.hidden = false; + playRow.hidden = false; + gameButtonsContainer.hidden = false; + showPage('game'); +} + +function initSpectator() { + clear(); + gameControls.hidden = false; + spectatorRow.hidden = false; + flipLabel.hidden = false; + flipBox.checked = false; + gameButtonsContainer.hidden = false; showPage('game'); } function initReplay() { - testMode = false; - gamePage.classList.remove('deckTest'); - playControls.hidden = true; - resultContainer.hidden = true; - replayControls.hidden = false; - gameButtonsContainer.hidden = true; - testControls.hidden = true; + clear(); + gameControls.hidden = false; + spectatorRow.hidden = false; + replayPreviousButton.hidden = false; + replayNextButton.hidden = false; + flipLabel.hidden = false; + flipBox.checked = false; canPlay = false; showPage('game'); clearPlayContainers(); @@ -108,11 +139,10 @@ function initTest(stage: Stage) { testPlacements.splice(0); clearChildren(testPlacementList); gamePage.classList.add('deckTest'); + gamePage.classList.remove('gameEnded'); gamePage.dataset.myPlayerIndex = '0'; gamePage.dataset.uiBaseColourIsSpecialColour = 'true'; - playControls.hidden = true; - resultContainer.hidden = true; - replayControls.hidden = true; + gameControls.hidden = true; gameButtonsContainer.hidden = false; testControls.hidden = false; clearPlayContainers(); @@ -242,8 +272,10 @@ function undoTurn(turn: PlacementResults) { board.refresh(); } -replayFlipBox.addEventListener('input', _ => { - if (currentGame == null || currentReplay == null) return; +flipBox.addEventListener('input', flipBox_input); + +function flipBox_input(e: Event) { + if (currentGame == null) return; if (replayAnimationAbortController) { replayAnimationAbortController.abort(); replayAnimationAbortController = null; @@ -253,11 +285,11 @@ replayFlipBox.addEventListener('input', _ => { updateStats(i); } } - board.flip = replayFlipBox.checked; + board.flip = (e.target as HTMLInputElement).checked; if (board.flip) gamePage.classList.add('boardFlipped'); else gamePage.classList.remove('boardFlipped'); board.resize(); -}); +} function addTestDeckCard(card: Card) { const button = new CardButton('radio', cardDatabase.get(card.number)); @@ -496,8 +528,13 @@ function showResult() { } if (!currentReplay) { - playControls.hidden = true; - resultContainer.hidden = false; + handContainer.hidden = true; + playRow.hidden = true; + spectatorRow.hidden = false; + replayPreviousButton.hidden = true; + replayNextButton.hidden = true; + shareReplayLinkButton.hidden = false; + flipLabel.hidden = true; canShareReplay = navigator.canShare && navigator.canShare({ url: window.location.href, title: 'Tableturf Battle Replay' }); shareReplayLinkButton.innerText = canShareReplay ? 'Share replay link' : 'Copy replay link'; } @@ -767,7 +804,7 @@ gameDeckButton.addEventListener('click', toggleShowDeck); showDeckCloseButton.addEventListener('click', toggleShowDeck); document.addEventListener('keydown', e => { - if (!pages.get('game')!.hidden && !playControls.hidden) { + if (!pages.get('game')!.hidden && !playRow.hidden) { switch (e.key) { case 'p': if (passButton.enabled) { @@ -808,24 +845,19 @@ shareReplayLinkButton.addEventListener('click', _ => { if (canShareReplay) { navigator.share({ url: url.href, title: 'Tableturf Battle Replay' }); } else { - navigator.clipboard.writeText(window.location.toString()).then(() => shareLinkButton.innerText = 'Copied'); + navigator.clipboard.writeText(url.href).then(() => shareReplayLinkButton.innerText = 'Copied'); } } }); req.send(); }); -document.getElementById('resultLeaveButton')!.addEventListener('click', e => { - e.preventDefault(); - clearPreGameForm(true); - showPage('preGame'); - newGameButton.focus(); -}); - -document.getElementById('replayLeaveButton')!.addEventListener('click', e => { +function leaveButton_click(e: MouseEvent) { e.preventDefault(); clearPreGameForm(true); showPage('preGame'); newGameButton.focus(); currentReplay = null; -}); +} + +document.getElementById('leaveButton')!.addEventListener('click', leaveButton_click); diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts index 9f8050f..0b7d256 100644 --- a/TableturfBattleClient/src/Pages/PreGamePage.ts +++ b/TableturfBattleClient/src/Pages/PreGamePage.ts @@ -34,10 +34,11 @@ maxPlayersBox.addEventListener('change', () => { preGameForm.addEventListener('submit', e => { e.preventDefault(); - if (e.submitter?.id == 'newGameButton' || (e.submitter?.id == 'preGameImplicitSubmitButton' && !gameIDBox.value)) { - const name = nameBox.value; - window.localStorage.setItem('name', name); + const name = nameBox.value; + window.localStorage.setItem('name', name); + + if (e.submitter?.id == 'newGameButton' || (e.submitter?.id == 'preGameImplicitSubmitButton' && !gameIDBox.value)) { let request = new XMLHttpRequest(); request.open('POST', `${config.apiBaseUrl}/games/new`); request.addEventListener('load', () => { @@ -64,16 +65,32 @@ preGameForm.addEventListener('submit', e => { data.append('maxPlayers', maxPlayersBox.value); request.send(data.toString()); setLoadingMessage('Creating a room...'); + } else if (e.submitter?.id?.startsWith('spectate')) { + spectate(false); } else { - const name = nameBox.value; - window.localStorage.setItem('name', name); tryJoinGame(name, gameIDBox.value, false); } }); -function tryJoinGame(name: string, idOrUrl: string, fromInitialLoad: boolean) { - const m = /(?:^|[#/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(idOrUrl); - if (!m) { +function spectate(fromInitialLoad: boolean) { + const gameID = parseGameID(gameIDBox.value); + if (!gameID) { + alert("Invalid game ID or link"); + if (fromInitialLoad) + clearPreGameForm(true); + else { + gameIDBox.focus(); + gameIDBox.setSelectionRange(0, gameIDBox.value.length); + } + return; + } + setGameUrl(gameID); + getGameInfo(gameID, null); +} + +function tryJoinGame(name: string, idOrUrl: string, fromInitialLoad: boolean) { + const gameID = parseGameID(idOrUrl); + if (!gameID) { alert("Invalid game ID or link"); if (fromInitialLoad) clearPreGameForm(true); @@ -83,7 +100,6 @@ function tryJoinGame(name: string, idOrUrl: string, fromInitialLoad: boolean) { } return; } - const gameID = m[1]; if (!fromInitialLoad) setGameUrl(gameID); diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts index c82dba4..9f5b40e 100644 --- a/TableturfBattleClient/src/app.ts +++ b/TableturfBattleClient/src/app.ts @@ -29,6 +29,10 @@ function delay(ms: number, abortSignal?: AbortSignal) { }); } +/** + * Schedules the specified callback to run when game data is initialised, or runs it synchronously if already initialised. + * Only one method may be scheduled this way. + */ function onInitialise(callback: () => void) { if (initialised) callback(); @@ -125,17 +129,19 @@ function onGameStateChange(game: any, playerData: PlayerData | null) { case GameState.Redraw: case GameState.Ongoing: case GameState.Ended: - if (playerData) - updateHand(playerData); board.autoHighlight = false; redrawModal.hidden = true; - initGame(); + if (playerData) { + updateHand(playerData); + initGame(); + } else + initSpectator(); gameButtonsContainer.hidden = currentGame.me == null || game.state == GameState.Ended; switch (game.state) { case GameState.Redraw: - redrawModal.hidden = false; + redrawModal.hidden = currentGame.me == null || currentGame.players[currentGame.me.playerIndex].isReady; turnNumberLabel.setTurnNumber(null); canPlay = false; break; @@ -317,7 +323,7 @@ function setupWebSocket(gameID: string, myPlayerIndex: number | null) { (async () => { await playInkAnimations(payload.data, anySpecialAttacks); - updateHand(payload.playerData); + if (payload.playerData) updateHand(payload.playerData); turnNumberLabel.setTurnNumber(payload.data.game.turnNumber); clearPlayContainers(); if (payload.event == 'gameEnd') { @@ -352,6 +358,8 @@ function processUrl() { } stopEditingDeck(); errorDialog.close(); + currentGame = null; + currentReplay = null; if (location.pathname.endsWith('/deckeditor') || location.hash == '#deckeditor') onInitialise(showDeckList); else { @@ -376,13 +384,48 @@ function processUrl() { return true; } +function parseGameID(s: string) { + const m = /(?:^|[#/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(s); + return m ? m[1] : null; +} + function presetGameID(url: string) { - document.getElementById('preGameDefaultSection')!.hidden = true; - document.getElementById('preGameJoinSection')!.hidden = false; - (document.getElementById('gameIDBox') as HTMLInputElement).value = url; + showPage('preGame'); + + const gameID = parseGameID(url); + if (!gameID) { + joinGameError('Invalid game ID or link.', true); + return; + } + onInitialise(() => { - if (playerName) - tryJoinGame(playerName, url, true); + let request = new XMLHttpRequest(); + request.open('GET', `${config.apiBaseUrl}/games/${gameID}/playerData` + (clientToken ? `?clientToken=${clientToken}` : '')); + request.addEventListener('load', () => { + switch (request.status) { + case 200: + let response = JSON.parse(request.responseText); + + if (response.playerData) { + // We are already in the game; go to the game page immediately. + onInitialise(() => getGameInfo(gameID, response.playerData.playerIndex)); + } else { + // We're not already in the game; offer the option to join or spectate. + document.getElementById('preGameDefaultSection')!.hidden = true; + document.getElementById('preGameJoinSection')!.hidden = false; + (document.getElementById('gameIDBox') as HTMLInputElement).value = gameID; + setLoadingMessage(null); + } + break; + case 404: joinGameError('The room was not found.', true); break; + default: joinGameError('Unable to join the room.', true); break; + } + }); + request.addEventListener('error', () => { + joinGameError('Unable to join the room.', true); + }); + request.send(); + setLoadingMessage('Checking room info...'); }); } diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css index 6e8f5a2..22216a7 100644 --- a/TableturfBattleClient/tableturf.css +++ b/TableturfBattleClient/tableturf.css @@ -511,15 +511,15 @@ dialog::backdrop { #gamePage.boardFlipped .pointsContainer[data-index="3"] { order: 2; } #gamePage.boardFlipped .pointsContainer[data-index="1"] { order: 3; } -#playControls, #resultContainer, #replayControls { +#gameControls { grid-column: 1 / span 2; grid-row: hand-row; align-self: center; } -#playControls:not([hidden]) { - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: [hand-row] auto [button-row] auto; +#gameControls:not([hidden]) { + display: flex; + flex-flow: column; + justify-content: center; } #handContainer { @@ -528,7 +528,12 @@ dialog::backdrop { text-align: center; } -:is(#playControls, #testControlsHeader) > label { +#playRow:not([hidden]) { + display: grid; + grid-template-columns: 1fr 1fr; +} + +:is(#playRow, #testControlsHeader) > label { grid-row: button-row; margin: 0.5em; padding: 0 0.5em; @@ -537,14 +542,23 @@ dialog::backdrop { text-shadow: 0 0 4px black; border-radius: 0.5em; } -:is(#playControls, #testControlsHeader) > label input { +:is(#playRow, #testControlsHeader) > label input { position: absolute; left: -333px; } -:is(#playControls, #testControlsHeader) > label:hover { background: var(--player-ui-highlight-colour); } -:is(#playControls, #testControlsHeader) > label:focus-within { outline: 2px solid var(--player-ui-highlight2-colour); } -:is(#playControls, #testControlsHeader) > label:is(:active, .checked) { background: var(--player-ui-highlight2-colour); } -:is(#playControls, #testControlsHeader) > label.disabled { background: grey; } +:is(#playRow, #testControlsHeader) > label:hover { background: var(--player-ui-highlight-colour); } +:is(#playRow, #testControlsHeader) > label:focus-within { outline: 2px solid var(--player-ui-highlight2-colour); } +:is(#playRow, #testControlsHeader) > label:is(:active, .checked) { background: var(--player-ui-highlight2-colour); } +:is(#playRow, #testControlsHeader) > label.disabled { background: grey; } + +#spectatorRow:not([hidden]) { + display: grid; + grid-template-columns: auto 1fr 1fr auto; + gap: 0.5em; +} +label[for="flipBox"] { + grid-column: 4; +} #showDeckContainer { grid-column: 1 / span 3; @@ -1219,14 +1233,15 @@ dialog::backdrop { margin: auto; } - #playControls:not([hidden]) { + #gameControls:not([hidden]) { grid-template-rows: [button-row] auto [hand-row] auto; } - #playControls, #resultContainer, #replayControls { + #gameControls { grid-column: 1 / -1; grid-row: 5; width: initial; + min-height: 12em; } #scoreSection:not([hidden]) { @@ -1246,7 +1261,7 @@ dialog::backdrop { margin: 0.5em 0; } - #handContainer { + #handContainer:not([hidden]) { font-size: x-small; display: grid; grid-template-columns: 1fr 1fr 1fr 1fr;