mirror of
https://github.com/AndrioCelos/TableturfBattleApp.git
synced 2026-07-19 08:41:22 -05:00
Add ability to spectate
This commit is contained in:
parent
3c0b3d3b3e
commit
e735de5bfa
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user