Add ability to spectate

This commit is contained in:
Andrio Celos 2023-03-07 14:15:55 +11:00
parent 3c0b3d3b3e
commit e735de5bfa
5 changed files with 202 additions and 92 deletions

View File

@ -41,12 +41,18 @@
</select>
</label>
</p>
<p><label for="gameIDBox">Enter a game link or ID to join a game:<br/>
<p>
<label for="gameIDBox">Enter a game link or ID to join a game:<br/>
<input type="text" id="gameIDBox" placeholder="Game link or ID"/></label>
<button type="submit" id="joinGameButton">Join game</button></p>
<button type="submit" id="joinGameButton">Join game</button>
<button type="submit" id="spectateGameButton">Spectate</button>
</p>
</div>
<div id="preGameJoinSection" hidden>
<p><button type="submit" id="joinGameButton2">Join game</button></p>
<p>
<button type="submit" id="joinGameButton2">Join game</button>
<button type="submit" id="spectateGameButton2">Spectate</button>
</p>
<a id="preGameBackButton" href="../..">Create or join a different room</a>
</div>
<p>
@ -141,26 +147,24 @@
</div>
</div>
<div id="playControls">
<div id="gameControls">
<div id="handContainer"></div>
<label for="passButton">
<input type="checkbox" id="passButton"/> Pass
</label>
<label for="specialButton">
<input type="checkbox" id="specialButton"/> Special Attack
</label>
</div>
<div id="resultContainer" hidden>
<button id="shareReplayLinkButton">Copy replay link</button>
<br/>
<a id="resultLeaveButton" href="#">Leave game</a>
</div>
<div id="replayControls" hidden>
<input type="checkbox" id="replayFlipBox" autocomplete="off"/>
<label for="replayFlipBox">Flip board</label>
<button id="replayPreviousButton">Previous turn</button>
<button id="replayNextButton">Next turn</button>
<a id="replayLeaveButton" href="#">Exit replay</a>
<div id="playRow">
<label for="passButton">
<input type="checkbox" id="passButton"/> Pass
</label>
<label for="specialButton">
<input type="checkbox" id="specialButton"/> Special Attack
</label>
</div>
<div id="spectatorRow">
<a id="leaveButton" href="#">Exit</a>
<button id="replayPreviousButton">Previous turn</button>
<button id="replayNextButton">Next turn</button>
<button id="shareReplayLinkButton">Copy replay link</button>
<label for="flipBox" id="flipLabel"><input type="checkbox" id="flipBox" autocomplete="off"/> Flip board</label>
</div>
</div>
<div class="playerBar" data-index="2">

View File

@ -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);

View File

@ -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);

View File

@ -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...');
});
}

View File

@ -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;