TableturfBattleApp/TableturfBattleClient/src/Pages/GamePage.ts

1278 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// <reference path="../CheckButtonGroup.ts"/>
const gamePage = document.getElementById('gamePage')!;
const board = new Board(document.getElementById('gameBoard') as HTMLTableElement);
const turnNumberLabel = new TurnNumberLabel(document.getElementById('turnNumberContainer')!, document.getElementById('turnNumberLabel')!);
const timeLabel = new TimeLabel(document.getElementById('timeLabel')!);
const passButton = CheckButton.fromId('passButton');
const specialButton = CheckButton.fromId('specialButton');
const gameButtonsContainer = document.getElementById('gameButtonsContainer')!;
const rotateLeftButton = document.getElementById('rotateLeftButton') as HTMLButtonElement;
const rotateRightButton = document.getElementById('rotateRightButton') as HTMLButtonElement;
const gameDeckButton = document.getElementById('gameDeckButton') as HTMLButtonElement;
const handContainer = document.getElementById('handContainer')!;
const handButtons = new CheckButtonGroup<Card>(handContainer);
const redrawModal = document.getElementById('redrawModal')!;
const gameControls = document.getElementById('gameControls')!;
const playRow = document.getElementById('playRow')!;
const spectatorRow = document.getElementById('spectatorRow')!;
const leaveButton = document.getElementById('leaveButton') as HTMLLinkElement;
const replayPreviousGameButton = CheckButton.fromId('replayPreviousGameButton');
const replayNextGameButton = CheckButton.fromId('replayNextGameButton');
const replayPreviousButton = CheckButton.fromId('replayPreviousButton');
const replayNextButton = CheckButton.fromId('replayNextButton');
const nextGameButton = CheckButton.fromId('nextGameButton');
const flipButton = document.getElementById('flipButton') as HTMLButtonElement;
let replayAnimationAbortController: AbortController | null = null;
const cardHint = HintLabel.fromId('cardHint');
const playHint = HintLabel.fromId('playHint');
const shareReplayLinkButton = document.getElementById('shareReplayLinkButton') as HTMLButtonElement;
let canShareReplay = false;
const playerBars = Array.from(document.getElementsByClassName('playerBar'), el => new PlayerBar(el as HTMLDivElement));
playerBars.sort((a, b) => a.playerIndex - b.playerIndex);
const showDeckContainer = document.getElementById('showDeckContainer')!;
const showDeckListElement = document.getElementById('showDeckList')!;
const showDeckButtons: CardButton[] = [ ];
const showDeckCloseButton = document.getElementById('showDeckCloseButton') as HTMLButtonElement;
const playContainers = Array.from(document.getElementsByClassName('playContainer')) as HTMLDivElement[];
playContainers.sort((a, b) => parseInt(a.dataset.index || '0') - parseInt(b.dataset.index || '0'));
const testControls = document.getElementById('testControls')!;
const testDeckList = document.getElementById('testDeckList')!;
const testAllCardsList = CardList.fromId('testAllCardsList', 'testAllCardsListSortBox', 'testAllCardsListFilterBox');
const testPlacementList = document.getElementById('testPlacementList')!;
const testDeckButton = CheckButton.fromId('testDeckButton');
const testDeckContainer = document.getElementById('testDeckContainer')!;
const testAllCardsButton = CheckButton.fromId('testAllCardsButton');
const testAllCardsContainer = document.getElementById('testAllCardsContainer')!;
const testUndoButton = CheckButton.fromId('testUndoButton');
const testBackButton = document.getElementById('testBackButton') as HTMLButtonElement;
const testCardButtonGroup = new CheckButtonGroup<Card>();
const testDeckCardButtons: CardButton[] = [ ];
const testPlacements: { card: Card, placementResults: PlacementResults }[] = [ ];
const testCardListBackdrop = document.getElementById('testCardListBackdrop')!;
let playHintHtml: string | null = null;
let testMode = false;
let canPlay = false;
let cols: Space[][] = [ ];
for (let x = 0; x < 9; x++) {
const col = [ ];
for (let y = 0; y < 26; y++)
col.push(Space.Empty);
cols.push(col);
}
board.startSpaces.push({ x: 4, y: 21 });
board.startSpaces.push({ x: 4, y: 4 });
cols[4][21] = Space.SpecialInactive1;
cols[4][4] = Space.SpecialInactive2;
board.resize(cols);
function clear() {
testMode = false;
gamePage.classList.remove('replay');
gamePage.classList.remove('deckTest');
gamePage.classList.remove('gameEnded');
gameControls.hidden = true;
handContainer.hidden = true;
playRow.hidden = true;
spectatorRow.hidden = true;
leaveButton.hidden = false;
replayPreviousGameButton.buttonElement.hidden = true;
replayNextGameButton.buttonElement.hidden = true;
replayPreviousButton.buttonElement.hidden = true;
replayNextButton.buttonElement.hidden = true;
nextGameButton.buttonElement.hidden = true;
shareReplayLinkButton.hidden = true;
flipButton.hidden = 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 = '';
}
}
/** Shows the game page for playing in a game. */
function initGame() {
clear();
gameControls.hidden = false;
handContainer.hidden = false;
playRow.hidden = false;
gameButtonsContainer.hidden = false;
showPage('game');
}
/** Shows the game page for spectating a game. */
function initSpectator() {
clear();
gameControls.hidden = false;
spectatorRow.hidden = false;
flipButton.hidden = false;
gameButtonsContainer.hidden = false;
showPage('game');
}
/** Shows the game page for viewing a replay. */
function initReplay() {
clear();
gamePage.classList.add('replay');
gameControls.hidden = false;
handContainer.hidden = false;
spectatorRow.hidden = false;
replayPreviousGameButton.buttonElement.hidden = false;
replayNextGameButton.buttonElement.hidden = false;
replayPreviousButton.buttonElement.hidden = false;
replayNextButton.buttonElement.hidden = false;
flipButton.hidden = false;
gameButtonsContainer.hidden = false;
gamePage.dataset.myPlayerIndex = '0';
gamePage.dataset.uiBaseColourIsSpecialColour = 'true';
canPlay = false;
showPage('game');
clearPlayContainers();
timeLabel.hide();
turnNumberLabel.setTurnNumber(null);
replaySwitchGame(0);
}
/** Shows the game page for deck editor testing. */
function initTest(stage: Stage) {
clear();
testMode = true;
gamePage.classList.add('deckTest');
currentGame = { id: 'test', state: GameState.Ongoing, maxPlayers: 2, players: [ ], webSocket: null, turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ] } };
board.resize(stage.copyGrid());
const startSpaces = stage.getStartSpaces(2);
board.startSpaces = startSpaces;
for (let i = 0; i < 2; i++)
board.grid[startSpaces[i].x][startSpaces[i].y] = Space.SpecialInactive1 | i;
board.refresh();
for (var o of playerBars)
o.element.hidden = true;
testPlacements.splice(0);
testUndoButton.enabled = false;
clearChildren(testPlacementList);
gamePage.dataset.myPlayerIndex = '0';
gamePage.dataset.uiBaseColourIsSpecialColour = 'true';
gameButtonsContainer.hidden = false;
testControls.hidden = false;
clearPlayContainers();
timeLabel.hide();
turnNumberLabel.setTurnNumber(null);
showPage('game');
testDeckButton.checked = true;
testAllCardsButton.checked = false;
setTestListPage(false);
testAllCardsList.clearFilter();
}
replayNextButton.buttonElement.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null || currentGame.state == GameState.GameEnded || currentGame.state == GameState.SetEnded)
return;
if (replayAnimationAbortController) {
replayUpdateHand();
replayAnimationAbortController.abort();
replayAnimationAbortController = null;
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
board.refresh();
const scores = board.getScores();
for (let i = 0; i < currentGame.players.length; i++) {
updateStats(i, scores);
}
}
clearPlayContainers();
if (currentGame.turnNumber == 0) {
// Show redraw decisions.
replayPreviousButton.enabled = true;
currentGame.state = GameState.Ongoing;
currentGame.turnNumber++;
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
replayUpdateHand();
} else if (currentGame.turnNumber > 12) {
currentGame.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
gameButtonsContainer.hidden = true;
gamePage.classList.add('gameEnded');
showResult();
} else {
const moves = currentReplay.turns[currentGame.turnNumber - 1];
const result = board.makePlacements(moves);
currentReplay.placements.push(result);
let anySpecialAttacks = false;
// Show the cards that were played.
const entry = handButtons.entries.find(b => b.value.number == moves[currentReplay!.watchingPlayer].card.number);
if (entry) entry.button.checked = true;
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.players[i];
const move = moves[i];
const button = new CardButton(move.card);
button.buttonElement.disabled = true;
if ((move as PlayMove).isSpecialAttack) {
anySpecialAttacks = true;
player.specialPoints -= (move as PlayMove).card.specialCost;
button.buttonElement.classList.add('specialAttack');
} else if (move.isPass) {
player.passes++;
player.specialPoints++;
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
button.buttonElement.appendChild(el);
}
playContainers[i].append(button.buttonElement);
}
for (const p of result.specialSpacesActivated) {
const space = board.grid[p.x][p.y];
const player2 = currentGame.players[space & 3];
player2.specialPoints++;
player2.totalSpecialPoints++;
}
currentGame.turnNumber++;
replayAnimationAbortController = new AbortController();
(async () => {
await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.turnNumber, players: currentGame.players }, moves, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, anySpecialAttacks, replayAnimationAbortController.signal);
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
clearPlayContainers();
if (currentGame.turnNumber > 12) {
currentGame.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
gameButtonsContainer.hidden = true;
gamePage.classList.add('gameEnded');
showResult();
} else
replayUpdateHand();
})();
}
});
replayPreviousButton.buttonElement.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null || currentGame.turnNumber == 0) return;
replayAnimationAbortController?.abort();
replayAnimationAbortController = null;
if (currentGame.state == GameState.GameEnded || currentGame.state == GameState.SetEnded) {
for (let i = 0; i < currentGame.players.length; i++) {
if (currentReplay.games[currentReplay.gameNumber].playerData[i].won)
currentGame.players[i].gamesWon--;
playerBars[i].winCounter.wins = currentGame.players[i].gamesWon;
}
}
if (currentGame.turnNumber > 1) {
const result = currentReplay.placements.pop();
if (!result) return;
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
const el = playerBars[i].resultElement;
el.innerText = '';
}
undoTurn(result);
}
currentGame.turnNumber--;
replayNextButton.enabled = true;
gamePage.classList.remove('gameEnded');
handContainer.hidden = false;
gameButtonsContainer.hidden = false;
if (currentGame.turnNumber > 0) {
currentGame.state = GameState.Ongoing;
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
const scores = board.getScores();
for (let i = 0; i < currentGame.players.length; i++) {
const move = currentReplay.turns[currentGame.turnNumber - 1][i];
if (move.isPass) {
currentGame.players[i].passes--;
currentGame.players[i].specialPoints--;
} else if ((move as PlayMove).isSpecialAttack)
currentGame.players[i].specialPoints += (move as PlayMove).card.specialCost;
updateStats(i, scores);
}
} else {
currentGame.state = GameState.Redraw;
replayPreviousButton.enabled = false;
turnNumberLabel.setTurnNumber(null);
}
replayUpdateHand();
});
/** Undoes the last turn of the current replay, returning the view to the game state before that turn. */
function undoTurn(turn: PlacementResults) {
for (const p of turn.specialSpacesActivated) {
const space = board.grid[p.x][p.y];
const player2 = currentGame!.players[space & 3];
if (player2) {
player2.specialPoints--;
player2.totalSpecialPoints--;
}
board.grid[p.x][p.y] &= ~4;
}
for (let i = turn.placements.length - 1; i >= 0; i--) {
const placement = turn.placements[i];
for (const p of placement.spacesAffected) {
if (p.oldState == undefined) throw new TypeError('oldState missing');
board.grid[p.space.x][p.space.y] = p.oldState;
}
}
board.refresh();
}
function replaySwitchGame(gameNumber: number) {
if (!currentGame || !currentReplay)
throw new Error('Not in a replay.');
if (gameNumber < 0 || gameNumber >= currentReplay.games.length)
throw new RangeError(`No game number ${gameNumber} in replay.`);
replayAnimationAbortController?.abort();
replayAnimationAbortController = null;
for (let i = 0; i < currentGame.players.length; i++) {
currentGame.players[i].specialPoints = 0;
currentGame.players[i].totalSpecialPoints = 0;
currentGame.players[i].passes = 0;
currentGame.players[i].gamesWon = 0;
for (let j = 0; j < gameNumber; j++) {
if (currentReplay.games[j].playerData[i].won)
currentGame.players[i].gamesWon++;
}
playerBars[i].winCounter.wins = currentGame.players[i].gamesWon;
}
currentReplay.gameNumber = gameNumber;
currentReplay.turns = currentReplay.games[gameNumber].turns;
currentReplay.placements.splice(0);
currentGame.state = GameState.Ongoing;
currentGame.turnNumber = 0;
clearPlayContainers();
gamePage.classList.remove('gameEnded');
replayPreviousGameButton.enabled = gameNumber > 0;
replayNextGameButton.enabled = gameNumber + 1 < currentReplay.games.length;
replayPreviousButton.enabled = false;
replayNextButton.enabled = true;
handContainer.hidden = false;
gameButtonsContainer.hidden = false;
turnNumberLabel.setTurnNumber(null);
const stage = currentReplay.games[gameNumber].stage;
board.resize(stage.copyGrid());
const startSpaces = stage.getStartSpaces(currentGame.players.length);
for (let i = 0; i < currentGame.players.length; i++) {
board.grid[startSpaces[i].x][startSpaces[i].y] = Space.SpecialInactive1 | i;
playerBars[i].points = 1;
playerBars[i].pointsDelta = null;
playerBars[i].pointsTo = null;
playerBars[i].specialPoints = 0;
}
board.refresh();
replayUpdateHand();
}
flipButton.addEventListener('click', () => {
if (currentGame == null) return;
if (replayAnimationAbortController) {
replayAnimationAbortController.abort();
replayAnimationAbortController = null;
clearPlayContainers();
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
const scores = board.getScores();
for (let i = 0; i < currentGame.players.length; i++) {
updateStats(i, scores);
}
}
if (currentReplay) {
currentReplay.watchingPlayer++;
if (currentReplay.watchingPlayer >= currentGame.players.length)
currentReplay.watchingPlayer = 0;
gamePage.dataset.myPlayerIndex = currentReplay.watchingPlayer.toString();
gamePage.dataset.uiBaseColourIsSpecialColour = currentGame.players[currentReplay.watchingPlayer].uiBaseColourIsSpecialColour?.toString();
board.flip = currentReplay.watchingPlayer % 2 != 0;
clearShowDeck();
replayUpdateHand();
} else
board.flip = !board.flip;
if (board.flip) gamePage.classList.add('boardFlipped');
else gamePage.classList.remove('boardFlipped');
board.resize();
});
function addTestDeckCard(card: Card) {
card = cardDatabase.get(card.number);
const button = new CardButton(card);
testCardButtonGroup.add(button, card);
testDeckList.appendChild(button.buttonElement);
testDeckCardButtons.push(button);
button.buttonElement.addEventListener('click', () => testCardButton_click(button));
}
function addTestCard(card: Card) {
card = cardDatabase.get(card.number);
const button = new CardButton(card);
testCardButtonGroup.add(button, card);
testAllCardsList.add(button);
button.buttonElement.addEventListener('click', () => {
testCardButton_click(button);
if (!testCardListBackdrop.hidden) {
testCardListBackdrop.hidden = true;
gamePage.removeChild(testAllCardsContainer);
testControls.appendChild(testAllCardsContainer);
setTestListPage(false);
}
});
}
function testCardButton_click(button: CardButton) {
if (!button.enabled) {
cardHint.showError('This card has already been played.');
return;
}
board.autoHighlight = true;
board.cardPlaying = testCardButtonGroup.value!;
for (const button of testCardButtonGroup.buttons)
button.checked = (button as CardButton).card?.number == testCardButtonGroup.value?.number;
if (isNaN(board.highlightX) || isNaN(board.highlightY)) {
board.highlightX = board.startSpaces[board.playerIndex!].x - (board.flip ? 4 : 3);
board.highlightY = board.startSpaces[board.playerIndex!].y - (board.flip ? 4 : 3);
}
board.cardRotation = board.flip ? 2 : 0;
board.refreshHighlight();
board.table.focus();
}
testUndoButton.buttonElement.addEventListener('click', () => {
const turn = testPlacements.pop();
if (turn) {
undoTurn(turn.placementResults);
testPlacementList.removeChild(testPlacementList.firstChild!);
for (const button of testDeckCardButtons.concat(testAllCardsList.cardButtons)) {
if (button.card.number == turn.card.number)
button.enabled = true;
}
testUndoButton.enabled = testPlacements.length > 0;
}
});
testBackButton.addEventListener('click', _ => {
showPage(editingDeck ? 'deckEdit' : 'deckList');
});
testDeckButton.buttonElement.addEventListener('click', _ => {
setTestListPage(false);
});
testAllCardsButton.buttonElement.addEventListener('click', _ => {
setTestListPage(true);
});
function setTestListPage(showAllCards: boolean) {
testDeckButton.checked = !showAllCards;
testAllCardsButton.checked = showAllCards;
testDeckContainer.hidden = showAllCards
testAllCardsContainer.hidden = !showAllCards;
}
document.getElementById('testAllCardsMobileButton')!.addEventListener('click', _ => {
setTestListPage(true);
testControls.removeChild(testAllCardsContainer);
gamePage.appendChild(testAllCardsContainer);
testCardListBackdrop.hidden = false;
});
/** Updates the game view with received player data. */
function loadPlayers(players: Player[]) {
gamePage.dataset.players = players.length.toString();
const scores = board.getScores();
for (let i = 0; i < players.length; i++) {
const player = players[i];
currentGame!.players[i] = players[i];
playerBars[i].name = player.name;
playerBars[i].winCounter.wins = players[i].gamesWon;
updateStats(i, scores);
if (player.colour.r || player.colour.g || player.colour.b) {
document.body.style.setProperty(`--primary-colour-${i + 1}`, `rgb(${player.colour.r}, ${player.colour.g}, ${player.colour.b})`);
document.body.style.setProperty(`--special-colour-${i + 1}`, `rgb(${player.specialColour.r}, ${player.specialColour.g}, ${player.specialColour.b})`);
document.body.style.setProperty(`--special-accent-colour-${i + 1}`, `rgb(${player.specialAccentColour.r}, ${player.specialAccentColour.g}, ${player.specialAccentColour.b})`);
}
}
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].visible = i < players.length;
}
}
function updateStats(playerIndex: number, scores: number[]) {
if (currentGame == null) return;
playerBars[playerIndex].points = scores[playerIndex];
playerBars[playerIndex].pointsDelta = 0;
playerBars[playerIndex].pointsTo = 0;
playerBars[playerIndex].specialPoints = currentGame.players[playerIndex].specialPoints;
playerBars[playerIndex].statSpecialPointsElement.innerText = currentGame.players[playerIndex].totalSpecialPoints.toString();
playerBars[playerIndex].statPassesElement.innerText = currentGame.players[playerIndex].passes.toString();
}
/** Shows the waiting indication for the specified player. */
function showWaiting(playerIndex: number) {
const el = document.createElement('div');
el.className = 'cardBack waiting';
playContainers[playerIndex].appendChild(el);
}
/** Shows the ready indication for the specified player. */
function showReady(playerIndex: number) {
clearChildren(playContainers[playerIndex]);
const el = document.createElement('div');
el.className = 'cardBack';
el.innerText = 'Ready';
playContainers[playerIndex].appendChild(el);
}
function clearPlayContainers() {
for (const container of playContainers) {
clearChildren(container);
}
}
/** Clears the game page controls for a new turn. */
function resetPlayControls() {
passButton.checked = false;
specialButton.checked = false;
board.specialAttack = false;
board.cardPlaying = null;
if (canPlay && currentGame?.me?.hand != null) {
for (let i = 0; i < 4; i++) {
canPlayCard[i] = board.canPlayCard(currentGame.me.playerIndex, currentGame.me.hand[i], false);
canPlayCardAsSpecialAttack[i] = currentGame.players[currentGame.me.playerIndex].specialPoints >= currentGame.me.hand[i].specialCost
&& board.canPlayCard(currentGame.me.playerIndex, currentGame.me.hand[i], true);
handButtons.entries[i].button.enabled = canPlayCard[i];
}
passButton.enabled = true;
specialButton.enabled = canPlayCardAsSpecialAttack.includes(true);
board.autoHighlight = true;
focusFirstEnabledHandCard();
} else {
lockGamePage();
if (currentGame?.me?.move) {
for (const el of handButtons.entries)
el.button.checked = el.value.number == currentGame.me.move.card.number;
passButton.checked = currentGame.me.move.isPass;
specialButton.checked = (currentGame.me.move as PlayMove).isSpecialAttack;
}
}
}
/** Disables controls on the game page after a play is submitted. */
function lockGamePage() {
canPlay = false;
board.autoHighlight = false;
for (const button of handButtons.buttons) button.enabled = false;
passButton.enabled = false;
specialButton.enabled = false;
cardHint.clear();
playHint.clear();
}
async function playInkAnimations(data: {
game: { state: GameState, board: Space[][] | null, turnNumber: number, players: Player[] },
moves: Move[],
placements: Placement[],
specialSpacesActivated: Point[]
}, anySpecialAttacks: boolean, abortSignal?: AbortSignal) {
const inkPlaced = new Set<number>();
const placements = data.placements;
board.clearHighlight();
board.cardPlaying = null;
board.autoHighlight = false;
board.specialAttack = false;
canPlay = false;
timeLabel.faded = true;
await delay(anySpecialAttacks ? 3000 : 1000, abortSignal);
for (let i = 0; i < data.game.players.length; i++) {
if ((data.moves[i] as PlayMove).isSpecialAttack)
playerBars[i].specialPoints -= data.moves[i].card.specialCost;
playerBars[i].highlightSpecialPoints = 0;
}
board.enableInkAnimations();
for (const placement of placements) {
// Skip the delay when cards don't overlap.
if (placement.spacesAffected.find(p => inkPlaced.has(p.space.y * 37 + p.space.x))) {
inkPlaced.clear();
await delay(500, abortSignal);
board.clearInkAnimations();
await delay(500, abortSignal);
board.enableInkAnimations();
}
for (const p of placement.spacesAffected) {
inkPlaced.add(p.space.y * 37 + p.space.x);
board.setDisplayedSpace(p.space.x, p.space.y, p.newState);
board.showInkAnimation(p.space.x, p.space.y);
}
}
await delay(500, abortSignal);
board.clearInkAnimations();
await delay(500, abortSignal);
// Show special spaces.
if (data.game.board)
board.grid = data.game.board;
board.refresh();
if (data.specialSpacesActivated.length > 0)
await delay(1000, abortSignal); // Delay if we expect that this changed the board.
const scores = board.getScores();
for (let i = 0; i < data.game.players.length; i++) {
playerBars[i].specialPoints = data.game.players[i].specialPoints;
playerBars[i].pointsDelta = scores[i] - playerBars[i].points;
}
await delay(1000, abortSignal);
for (let i = 0; i < data.game.players.length; i++) {
updateStats(i, scores);
}
await delay(1000, abortSignal);
}
function showResult() {
if (currentGame == null) return;
turnNumberLabel.setTurnNumber(null);
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
for (let i = 1; i < currentGame.players.length; i++) {
if (playerBars[i].points > maxPoints) {
winners.splice(0);
winners.push(i);
maxPoints = playerBars[i].points;
} else if (playerBars[i].points == maxPoints)
winners.push(i);
}
for (let i = 0; i < currentGame.players.length; i++) {
const el = playerBars[i].resultElement;
if (winners.includes(i)) {
if (winners.length == 1) {
el.classList.add('win');
el.classList.remove('lose');
el.classList.remove('draw');
el.innerText = 'Victory';
if (currentReplay) currentGame.players[i].gamesWon++;
playerBars[i].winCounter.wins++;
} else {
el.classList.remove('win');
el.classList.remove('lose');
el.classList.add('draw');
el.innerText = 'Draw';
}
} else {
el.classList.remove('win');
el.classList.add('lose');
el.classList.remove('draw');
el.innerText = 'Defeat';
}
}
handContainer.hidden = true;
replayNextButton.enabled = false;
if (!currentReplay) {
playRow.hidden = true;
spectatorRow.hidden = false;
replayPreviousGameButton.buttonElement.hidden = true;
replayPreviousButton.buttonElement.hidden = true;
replayNextButton.buttonElement.hidden = true;
replayNextGameButton.buttonElement.hidden = true;
flipButton.hidden = true;
if (currentGame.state == GameState.SetEnded) {
leaveButton.hidden = false;
nextGameButton.buttonElement.hidden = currentGame.goalWinCount != null;
shareReplayLinkButton.hidden = false;
canShareReplay = navigator.canShare && navigator.canShare({ url: window.location.href, title: 'Tableturf Battle Replay' });
shareReplayLinkButton.innerText = canShareReplay ? 'Share replay link' : 'Copy replay link';
} else {
leaveButton.hidden = currentGame.me != null;
nextGameButton.buttonElement.hidden = currentGame.me == null;
}
nextGameButton.enabled = currentGame.me != null && !currentGame.players[currentGame.me.playerIndex].isReady;
nextGameButton.buttonElement.innerHTML = nextGameButton.enabled ? 'Next game' : '<div class="loadingSpinner"></div> Waiting for other player';
}
}
function clearShowDeck() {
showDeckContainer.hidden = true;
testCardListBackdrop.hidden = true;
clearChildren(showDeckListElement);
showDeckButtons.splice(0);
}
function populateShowDeck(deck: Card[]) {
if (showDeckButtons.length == 0) {
for (const card of deck) {
const button = new CardButton(card);
button.buttonElement.disabled = true;
showDeckButtons.push(button);
const li = document.createElement('li');
li.appendChild(button.buttonElement);
showDeckListElement.appendChild(li);
}
}
}
/** Handles an update to the player's hand and/or deck during a game. */
function updateHandAndDeck(playerData: PlayerData) {
handButtons.clear();
populateShowDeck(playerData.deck!);
for (const button of showDeckButtons) {
const li = button.buttonElement.parentElement!;
if (playerData.hand!.find(c => c.number == button.card.number))
li.className = 'inHand';
else if (playerData.cardsUsed.includes(button.card.number))
li.className = 'used';
else
li.className = 'unused';
}
if (!currentGame?.me) return;
currentGame.me.hand = playerData.hand!.map(Card.fromJson);
for (let i = 0; i < currentGame.me.hand.length; i++) {
const card = currentGame.me.hand[i];
const button = new CardButton(card);
handButtons.add(button, card);
button.buttonElement.addEventListener('click', e => {
if (!button.enabled) {
if (specialButton.checked && currentGame!.players[currentGame!.me!.playerIndex].specialPoints < card.specialCost)
cardHint.showError('Not enough special points.');
else if (!(specialButton.checked ? canPlayCardAsSpecialAttack : canPlayCard)[i])
cardHint.showError('No place to play this card.');
e.preventDefault();
return;
}
cardHint.clear();
if (passButton.checked) {
if (canPlay) {
timeLabel.faded = true;
lockGamePage();
sendPlay({ clientToken, cardNumber: card.number.toString(), isPass: 'true' });
}
} else {
board.cardPlaying = card;
if (specialButton.checked)
playerBars[currentGame!.me!.playerIndex].highlightSpecialPoints = card.specialCost;
if (isNaN(board.highlightX) || isNaN(board.highlightY)) {
board.highlightX = board.startSpaces[board.playerIndex!].x - (board.flip ? 4 : 3);
board.highlightY = board.startSpaces[board.playerIndex!].y - (board.flip ? 4 : 3);
}
if (specialButton.checked)
playHint.show('Unleash it next to <div class="playHintSpecial">&nbsp;</div>!', true);
board.cardRotation = board.flip ? 2 : 0;
board.refreshHighlight();
board.table.focus();
}
});
button.buttonElement.addEventListener('keydown', e => {
switch (e.key) {
case 'ArrowUp':
if (i >= 2)
handButtons.entries[i - 2].button.buttonElement.focus();
e.preventDefault();
break;
case 'ArrowDown':
if (i < 2)
handButtons.entries[i + 2].button.buttonElement.focus();
e.preventDefault();
break;
case 'ArrowLeft':
if (i % 2 != 0)
handButtons.entries[i - 1].button.buttonElement.focus();
e.preventDefault();
break;
case 'ArrowRight':
if (i % 2 == 0)
handButtons.entries[i + 1].button.buttonElement.focus();
e.preventDefault();
break;
}
});
}
}
/** Handles an update to the player's hand and/or deck during a replay. */
function replayUpdateHand() {
if (currentGame == null || currentReplay == null) return;
handButtons.clear();
const playerData = currentReplay.games[currentReplay.gameNumber].playerData[currentReplay.watchingPlayer];
populateShowDeck(playerData.deck);
for (const b of showDeckButtons)
b.buttonElement.parentElement!.className = '';
let indices;
if (currentGame.turnNumber == 0) {
indices = playerData.initialDrawOrder;
} else {
indices = playerData.drawOrder.slice(0, 4);
for (let i = 0; i < currentGame.turnNumber - 1; i++) {
const move = currentReplay.turns[i][currentReplay.watchingPlayer];
let j = indices.findIndex(k => playerData.deck[k].number == move.card.number);
if (j < 0) j = indices.findIndex(k => k < 0 || k >= 15);
if (j >= 0) {
showDeckButtons[indices[j]].buttonElement.parentElement!.className = 'used';
if (i < 11)
indices[j] = playerData.drawOrder[4 + i];
}
}
}
for (let i = 0; i < showDeckButtons.length; i++) {
const li = showDeckButtons[i].buttonElement.parentElement!;
if (!li.className)
li.className = indices.includes(i) ? 'inHand' : 'unused';
}
for (let i = 0; i < 4; i++) {
if (indices[i] >= 15) continue; // Accounts for an old bug in the server that corrupted the initialDrawOrder replay fields.
const card = playerData.deck[indices[i]];
const button = new CardButton(card);
button.buttonElement.disabled = true;
handButtons.add(button, card);
handContainer.appendChild(button.buttonElement);
}
}
(document.getElementById('redrawNoButton') as HTMLButtonElement).addEventListener('click', redrawButton_click);
(document.getElementById('redrawYesButton') as HTMLButtonElement).addEventListener('click', redrawButton_click);
function redrawButton_click(e: MouseEvent) {
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/redraw`);
req.addEventListener('error', () => communicationError());
let data = new URLSearchParams();
data.append('clientToken', clientToken);
data.append('redraw', (e.target as HTMLButtonElement).dataset.redraw!);
req.send(data.toString());
redrawModal.hidden = true;
}
function passButton_click(e: Event) {
if (!passButton.enabled) {
e.preventDefault();
return;
}
passButton.checked = !passButton.checked;
board.autoHighlight = !passButton.checked;
if (passButton.checked) {
cardHint.show('Pick a card to discard.', false);
playHint.clear();
specialButton.checked = false;
board.cardPlaying = null;
playerBars[currentGame!.me!.playerIndex].highlightSpecialPoints = 0;
board.specialAttack = false;
board.clearHighlight();
handButtons.deselect();
for (const button of handButtons.buttons)
button.enabled = true;
} else {
cardHint.clear();
for (let i = 0; i < 4; i++) {
handButtons.entries[i].button.enabled = canPlayCard[i];
}
}
}
passButton.buttonElement.addEventListener('click', passButton_click);
function specialButton_click(e: Event) {
if (!specialButton.enabled) {
if (currentGame!.me!.hand!.every(c => currentGame!.players[currentGame!.me!.playerIndex].specialPoints < c.specialCost))
cardHint.showError('Not enough special points.');
else if (!canPlayCardAsSpecialAttack.includes(true))
cardHint.showError('No place to play a special attack.');
e.preventDefault();
return;
}
specialButton.checked = !specialButton.checked;
board.specialAttack = specialButton.checked;
handButtons.deselect();
playerBars[currentGame!.me!.playerIndex].highlightSpecialPoints = 0;
playHint.clear();
if (specialButton.checked) {
cardHint.show('Pick a card to do a Special Attack!', true);
passButton.checked = false;
board.autoHighlight = true;
board.cardPlaying = null;
board.clearHighlight();
for (let i = 0; i < 4; i++)
handButtons.entries[i].button.enabled = canPlayCardAsSpecialAttack[i];
} else {
cardHint.clear();
for (let i = 0; i < 4; i++) {
handButtons.entries[i].button.enabled = canPlayCard[i];
}
}
}
specialButton.buttonElement.addEventListener('click', specialButton_click);
function sendPlay(data: { clientToken: string, [k: string]: string }) {
if (!currentGame) throw new Error('No game in progress.');
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame.id}/play`);
req.addEventListener('load', _ => {
if (req.status != 204) {
alert(`The server rejected the play. This is probably a bug.\n${req.responseText}`);
canPlay = true;
board.autoHighlight = true;
for (let i = 0; i < handButtons.entries.length; i++)
handButtons.entries[i].button.enabled = passButton.checked || (specialButton.checked ? canPlayCardAsSpecialAttack : canPlayCard)[i];
passButton.enabled = true;
specialButton.enabled = canPlayCardAsSpecialAttack.includes(true);
board.clearHighlight();
}
});
req.addEventListener('error', () => communicationError());
req.send(new URLSearchParams(data).toString());
}
board.onsubmit = (x, y) => {
if (board.cardPlaying == null || !currentGame?.me)
return;
const message = board.checkMoveLegality(currentGame.me.playerIndex, board.cardPlaying, x, y, board.cardRotation, board.specialAttack);
if (message != null) {
playHint.showError(message);
return;
}
if (testMode) {
const move: PlayMove = { card: board.cardPlaying, isPass: false, isTimeout: false, x, y, rotation: board.cardRotation, isSpecialAttack: false };
const result = board.makePlacements([ move ]);
testPlacements.push({ card: board.cardPlaying, placementResults: result });
board.enableInkAnimations();
for (const p of result.placements[0].spacesAffected) {
board.setDisplayedSpace(p.space.x, p.space.y, p.newState);
board.showInkAnimation(p.space.x, p.space.y);
}
if (result.specialSpacesActivated.length > 0)
setTimeout(() => board.refresh(), 333);
var li = document.createElement('div');
li.innerText = board.cardPlaying.name;
if (testDeckCardButtons.find(b => b.card.number == board.cardPlaying!.number))
li.classList.add('deckCard');
else
li.classList.add('externalCard');
testPlacementList.insertBefore(li, testPlacementList.firstChild);
for (const button of testDeckCardButtons.concat(testAllCardsList.cardButtons)) {
if (button.card.number == board.cardPlaying.number) {
button.checked = false;
button.enabled = false;
}
}
board.cardPlaying = null;
testUndoButton.enabled = true;
} else if (canPlay) {
board.showSubmitAnimation();
timeLabel.faded = true;
lockGamePage();
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame.id}/play`);
req.addEventListener('load', _ => {
if (req.status != 204) {
alert(req.responseText);
board.clearHighlight();
board.autoHighlight = true;
}
});
sendPlay({
clientToken,
cardNumber: board.cardPlaying.number.toString(),
isSpecialAttack: specialButton.checked.toString(),
x: board.highlightX.toString(),
y: board.highlightY.toString(),
r: board.cardRotation.toString()
});
}
};
board.oncancel = () => {
board.cardPlaying = null;
if (currentGame?.me)
playerBars[currentGame.me.playerIndex].highlightSpecialPoints = 0;
board.clearHighlight();
for (const button of handButtons.buttons) {
if (button.checked) {
button.checked = false;
button.buttonElement.focus();
break;
}
}
};
board.onhighlightchange = dScores => {
if (currentGame == null) return;
const scores = board.getScores();
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].pointsTo = dScores && dScores[i] != 0 ? scores[i] + dScores[i] : null;
}
};
timeLabel.ontimeout = () => {
if (currentGame == null || !canPlay) return;
if (currentGame.turnNumber == 0) {
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/redraw`);
req.addEventListener('error', () => communicationError());
req.send(new URLSearchParams({ clientToken, redraw: 'false', isTimeout: 'true' }).toString());
redrawModal.hidden = true;
} else {
// When time runs out, automatically discard the largest card in the player's hand.
let entry = handButtons.entries[0];
for (let i = 1; i < handButtons.entries.length; i++) {
if (handButtons.entries[i].value.size > entry.value.size)
entry = handButtons.entries[i];
}
handButtons.deselect();
entry.button.checked = true;
lockGamePage();
sendPlay({ clientToken, cardNumber: entry.value.number.toString(), isPass: 'true', isTimeout: 'true' });
}
};
function focusFirstEnabledHandCard() {
const input = document.activeElement as HTMLInputElement;
if (input.name == 'handCard') {
if (input.disabled)
input.checked = false;
else {
// An enabled hand button is already active; don't move the focus.
return;
}
}
const firstEnabledButton = handButtons.buttons.find(b => b.enabled);
if (firstEnabledButton)
firstEnabledButton.buttonElement.focus();
else
passButton.buttonElement.focus();
}
function toggleShowDeck() {
if (showDeckContainer.hidden) {
showDeckContainer.hidden = false;
testCardListBackdrop.hidden = false;
showDeckCloseButton.focus();
} else {
showDeckContainer.hidden = true;
testCardListBackdrop.hidden = true;
focusFirstEnabledHandCard();
}
}
rotateLeftButton.addEventListener('click', () => {
board.cardRotation--;
board.refreshHighlight();
});
rotateRightButton.addEventListener('click', () => {
board.cardRotation++;
board.refreshHighlight();
});
gameDeckButton.addEventListener('click', toggleShowDeck);
showDeckCloseButton.addEventListener('click', toggleShowDeck);
document.addEventListener('keydown', e => {
if (!pages.get('game')!.hidden) {
switch (e.key) {
case 'p':
if (playRow.hidden) return;
passButton_click(e);
if (passButton.enabled)
focusFirstEnabledHandCard();
e.preventDefault();
break;
case 's':
if (playRow.hidden) return;
specialButton_click(e);
if (specialButton.enabled)
focusFirstEnabledHandCard();
e.preventDefault();
break;
case 'd':
if (testMode || gameButtonsContainer.hidden) return;
toggleShowDeck();
e.preventDefault();
break;
}
}
});
replayPreviousGameButton.buttonElement.addEventListener('click', () => {
if (!currentGame || !currentReplay) return;
if (currentReplay.gameNumber > 0)
replaySwitchGame(currentReplay.gameNumber - 1);
});
replayNextGameButton.buttonElement.addEventListener('click', () => {
if (!currentGame || !currentReplay) return;
if (currentReplay.gameNumber < currentReplay.games.length - 1)
replaySwitchGame(currentReplay.gameNumber + 1);
});
nextGameButton.buttonElement.addEventListener('click', () => {
if (!currentGame) return;
nextGameButton.enabled = false;
nextGameButton.buttonElement.innerHTML = '<div class="loadingSpinner"></div> Waiting for other player';
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame.id}/nextGame`);
req.addEventListener('error', () => communicationError());
req.send(new URLSearchParams({ clientToken }).toString());
});
shareReplayLinkButton.addEventListener('click', () => {
shareReplayLinkButton.disabled = true;
let req = new XMLHttpRequest();
req.responseType = "arraybuffer";
req.open('GET', `${config.apiBaseUrl}/games/${currentGame!.id}/replay`);
req.addEventListener('load', _ => {
if (req.status == 200) {
const array = new Uint8Array(req.response as ArrayBuffer);
const base64 = encodeToUrlSafeBase64(array);
const url = new URL(`${canPushState ? '' : '#'}replay/${base64}`, baseUrl);
shareReplayLinkButton.disabled = false;
if (canShareReplay) {
navigator.share({ url: url.href, title: 'Tableturf Battle Replay' });
} else {
navigator.clipboard.writeText(url.href).then(() => shareReplayLinkButton.innerText = 'Copied');
}
}
});
req.send();
});
function leaveButton_click(e: MouseEvent) {
e.preventDefault();
clearGame();
clearPreGameForm(true);
showPage('preGame');
newGameButton.focus();
}
leaveButton.addEventListener('click', leaveButton_click);
const colBoxes = [
[
[ document.getElementById("colH0I") as HTMLInputElement, document.getElementById("colS0I") as HTMLInputElement, document.getElementById("colL0I") as HTMLInputElement, document.getElementById("colRGB0I") as HTMLInputElement ],
[ document.getElementById("colH0S") as HTMLInputElement, document.getElementById("colS0S") as HTMLInputElement, document.getElementById("colL0S") as HTMLInputElement, document.getElementById("colRGB0S") as HTMLInputElement ],
[ document.getElementById("colH0A") as HTMLInputElement, document.getElementById("colS0A") as HTMLInputElement, document.getElementById("colL0A") as HTMLInputElement, document.getElementById("colRGB0A") as HTMLInputElement ]
],
[
[ document.getElementById("colH1I") as HTMLInputElement, document.getElementById("colS1I") as HTMLInputElement, document.getElementById("colL1I") as HTMLInputElement, document.getElementById("colRGB1I") as HTMLInputElement ],
[ document.getElementById("colH1S") as HTMLInputElement, document.getElementById("colS1S") as HTMLInputElement, document.getElementById("colL1S") as HTMLInputElement, document.getElementById("colRGB1S") as HTMLInputElement ],
[ document.getElementById("colH1A") as HTMLInputElement, document.getElementById("colS1A") as HTMLInputElement, document.getElementById("colL1A") as HTMLInputElement, document.getElementById("colRGB1A") as HTMLInputElement ]
],
[
[ document.getElementById("colH2I") as HTMLInputElement, document.getElementById("colS2I") as HTMLInputElement, document.getElementById("colL2I") as HTMLInputElement, document.getElementById("colRGB2I") as HTMLInputElement ],
[ document.getElementById("colH2S") as HTMLInputElement, document.getElementById("colS2S") as HTMLInputElement, document.getElementById("colL2S") as HTMLInputElement, document.getElementById("colRGB2S") as HTMLInputElement ],
[ document.getElementById("colH2A") as HTMLInputElement, document.getElementById("colS2A") as HTMLInputElement, document.getElementById("colL2A") as HTMLInputElement, document.getElementById("colRGB2A") as HTMLInputElement ]
],
[
[ document.getElementById("colH3I") as HTMLInputElement, document.getElementById("colS3I") as HTMLInputElement, document.getElementById("colL3I") as HTMLInputElement, document.getElementById("colRGB3I") as HTMLInputElement ],
[ document.getElementById("colH3S") as HTMLInputElement, document.getElementById("colS3S") as HTMLInputElement, document.getElementById("colL3S") as HTMLInputElement, document.getElementById("colRGB3S") as HTMLInputElement ],
[ document.getElementById("colH3A") as HTMLInputElement, document.getElementById("colS3A") as HTMLInputElement, document.getElementById("colL3A") as HTMLInputElement, document.getElementById("colRGB3A") as HTMLInputElement ]
]
];
function colHSL_change(e: Event) {
var p = parseInt((e.target as HTMLInputElement).dataset.player!);
var c = parseInt((e.target as HTMLInputElement).dataset.index!);
updateRGB(p, c);
}
function colRGB_change(e: Event) {
var p = parseInt((e.target as HTMLInputElement).dataset.player!);
var c = parseInt((e.target as HTMLInputElement).dataset.index!);
updateHSL(p, c);
}
for (const a of colBoxes) {
for (const a2 of a) {
a2[0].addEventListener('change', colHSL_change);
a2[1].addEventListener('change', colHSL_change);
a2[2].addEventListener('change', colHSL_change);
a2[3].addEventListener('change', colRGB_change);
}
}
for (let p = 0; p < 4; p++) {
for (let i = 0; i < 3; i++) {
updateHSL(p, i);
}
}
function updateHSL(playerIndex: number, colourIndex: number) {
let colourStr = colBoxes[playerIndex][colourIndex][3].value;
if (colourStr.startsWith('#')) colourStr = colourStr.substring(1);
if (colourStr.length == 8) colourStr = colourStr.substring(2);
const colour = { r: parseInt(colourStr.substring(0, 2), 16), g: parseInt(colourStr.substring(2, 4), 16), b: parseInt(colourStr.substring(4, 6), 16) };
setColour(playerIndex, colourIndex, colour);
const max = Math.max(colour.r, colour.g, colour.b); // × 1/255
const min = Math.min(colour.r, colour.g, colour.b); // × 1/255
const c = max - min; // × 1/255
const l = Math.round((max + min) * 50 / 255); // %
const h = max == min ? 0
: max == colour.r ? (colour.b > colour.g ? 360 : 0) + 60 * (colour.g - colour.b) / c
: max == colour.g ? 120 + 60 * (colour.b - colour.r) / c
: 240 + 60 * (colour.r - colour.g) / c;
const s = max <= 0 || min >= 255
? 0
: Math.round(c / (255 - Math.abs(2 * max - c - 255)) * 100);
colBoxes[playerIndex][colourIndex][0].value = Math.round(h).toString();
colBoxes[playerIndex][colourIndex][1].value = s.toString();
colBoxes[playerIndex][colourIndex][2].value = l.toString();
}
function updateRGB(playerIndex: number, colourIndex: number) {
const h = (parseInt(colBoxes[playerIndex][colourIndex][0].value) % 360 + 360) % 360; // degrees
const s = Math.min(100, Math.max(0, parseInt(colBoxes[playerIndex][colourIndex][1].value))); // %
const l = Math.min(100, Math.max(0, parseInt(colBoxes[playerIndex][colourIndex][2].value))); // %
const c = (100 - Math.abs(2 * l - 100)) * s; // × 1/10000
const x = c * (60 - Math.abs(h % 120 - 60)); // × 1/600000
const min = (l * 100 - c / 2) * 60; // × 1/600000
const rgb1 =
h < 60 ? { r: c * 60, g: x, b: 0 } :
h < 120 ? { r: x, g: c * 60, b: 0 } :
h < 180 ? { r: 0, g: c * 60, b: x } :
h < 240 ? { r: 0, g: x, b: c * 60 } :
h < 300 ? { r: x, g: 0, b: c * 60 } :
{ r: c * 60, g: 0, b: x }; // × 1/600000
const rgb = { r: Math.round((rgb1.r + min) * 255 / 600000), g: Math.round((rgb1.g + min) * 255 / 600000), b: Math.round((rgb1.b + min) * 255 / 600000) };
colBoxes[playerIndex][colourIndex][3].value = `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
setColour(playerIndex, colourIndex, rgb);
}
function setColour(playerIndex: number, colourIndex: number, colour: Colour) {
if (!currentGame || playerIndex >= currentGame.players.length) return;
if (colourIndex == 0) {
currentGame.players[playerIndex].colour = colour;
document.body.style.setProperty(`--primary-colour-${playerIndex + 1}`, `rgb(${colour.r}, ${colour.g}, ${colour.b})`);
} else if (colourIndex == 1) {
currentGame.players[playerIndex].specialColour = colour;
document.body.style.setProperty(`--special-colour-${playerIndex + 1}`, `rgb(${colour.r}, ${colour.g}, ${colour.b})`);
} else {
currentGame.players[playerIndex].specialAccentColour = colour;
document.body.style.setProperty(`--special-accent-colour-${playerIndex + 1}`, `rgb(${colour.r}, ${colour.g}, ${colour.b})`);
}
}