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 @@
-
+
-
-
-
-
-
-
-
Exit replay
+
+
+
+
+
+
+
Exit
+
+
+
+
+
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;