Multiple-game sets

This allows any number of games to be played in a room.
Each player bar will show the number of games that player has won. If a goal win count is selected in More Options, the set will end when someone reaches that many wins. Otherwise, players can start a new game after the game ends.
This commit is contained in:
Andrio Celos
2023-08-13 22:20:23 +10:00
parent 91044db4c1
commit dcb669490c
17 changed files with 724 additions and 270 deletions

View File

@@ -55,7 +55,7 @@
<a id="preGameHelpButton" href="help">Help</a>
</p>
<div id="preGameLoadingSection" class="loadingContainer" hidden>
<div class="loadingSpinner"><div></div><div></div></div> <span id="preGameLoadingLabel"></span>
<div class="loadingSpinner"></div> <span id="preGameLoadingLabel"></span>
</div>
</form>
<footer>
@@ -79,13 +79,13 @@
<div class="submitButtonContainer">
<button type="submit" id="submitStageButton">Submit</button>
<div class="loadingContainer">
<div class="loadingSpinner"><div></div><div></div></div>
<div class="loadingSpinner"></div>
</div>
</div>
<div id="stageList">
<button type="button" id="stageRandomButton" class="stageRandom">Random</button>
</div>
<div id="stageListLoadingSection"><div class="loadingSpinner"><div></div><div></div></div> Loading stages...</div>
<div id="stageListLoadingSection"><div class="loadingSpinner"></div> Loading stages...</div>
</form>
</section>
<div id="lobbyDeckSection" hidden>
@@ -94,7 +94,7 @@
<div class="submitButtonContainer">
<button type="submit" id="submitDeckButton" disabled>Submit</button>
<div class="loadingContainer">
<div class="loadingSpinner"><div></div><div></div></div>
<div class="loadingSpinner"></div>
</div>
</div>
<div id="lobbyDeckList" class="deckList"></div>
@@ -111,6 +111,7 @@
<div id="gamePage" hidden>
<div class="playerBar" data-index="1">
<div class="result"></div>
<div class="wins" title="Battles won">0</div>
<div class="name">Player 2</div>
<div class="specialPoints">&nbsp;</div>
<div class="playerStats">
@@ -126,6 +127,7 @@
</div>
<div class="playerBar" data-index="3">
<div class="result"></div>
<div class="wins" title="Battles won">0</div>
<div class="name">Player 4</div>
<div class="specialPoints">&nbsp;</div>
<div class="playerStats">
@@ -243,20 +245,51 @@
<div id="handContainer"></div>
<div id="playRow">
<button type="button" id="passButton">Pass</button>
<button type="button" id="specialButton">Special Attack</button>
<button type="button" id="passButton" class="gameButton">Pass</button>
<button type="button" id="specialButton" class="gameButton">Special Attack</button>
</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>
<button id="flipButton">Change view</button>
<a id="leaveButton" href="#">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/>
</svg>
Exit
</a>
<button id="replayPreviousGameButton" class="gameButton replayButton">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-backward-fill" viewBox="0 0 16 16">
<path d="M.5 3.5A.5.5 0 0 0 0 4v8a.5.5 0 0 0 1 0V8.753l6.267 3.636c.54.313 1.233-.066 1.233-.697v-2.94l6.267 3.636c.54.314 1.233-.065 1.233-.696V4.308c0-.63-.693-1.01-1.233-.696L8.5 7.248v-2.94c0-.63-.692-1.01-1.233-.696L1 7.248V4a.5.5 0 0 0-.5-.5z"/>
</svg>
Previous game
</button>
<button id="replayPreviousButton" class="gameButton replayButton">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rewind-fill" viewBox="0 0 16 16">
<path d="M8.404 7.304a.802.802 0 0 0 0 1.392l6.363 3.692c.52.302 1.233-.043 1.233-.696V4.308c0-.653-.713-.998-1.233-.696L8.404 7.304Z"/>
<path d="M.404 7.304a.802.802 0 0 0 0 1.392l6.363 3.692c.52.302 1.233-.043 1.233-.696V4.308c0-.653-.713-.998-1.233-.696L.404 7.304Z"/>
</svg>
Previous turn
</button>
<button id="replayNextButton" class="gameButton replayButton">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-fast-forward-fill" viewBox="0 0 16 16">
<path d="M7.596 7.304a.802.802 0 0 1 0 1.392l-6.363 3.692C.713 12.69 0 12.345 0 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692Z"/>
<path d="M15.596 7.304a.802.802 0 0 1 0 1.392l-6.363 3.692C8.713 12.69 8 12.345 8 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692Z"/>
</svg>
Next turn
</button>
<button id="replayNextGameButton" class="gameButton replayButton">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-forward-fill" viewBox="0 0 16 16">
<path d="M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.753l-6.267 3.636c-.54.313-1.233-.066-1.233-.697v-2.94l-6.267 3.636C.693 12.703 0 12.324 0 11.693V4.308c0-.63.693-1.01 1.233-.696L7.5 7.248v-2.94c0-.63.693-1.01 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5z"/>
</svg>
Next game
</button>
<button id="shareReplayLinkButton" class="gameButton">Copy replay link</button>
<button id="flipButton" class="gameButton">Change view</button>
</div>
<button id="nextGameButton" class="gameButton">Next game</button>
</div>
<div class="playerBar" data-index="2">
<div class="result"></div>
<div class="wins" title="Battles won">0</div>
<div class="name">Player 3</div>
<div class="specialPoints">&nbsp;</div>
<div class="playerStats">
@@ -272,6 +305,7 @@
</div>
<div class="playerBar" data-index="0">
<div class="result"></div>
<div class="wins" title="Battles won">0</div>
<div class="name">Player 1</div>
<div class="specialPoints">&nbsp;</div>
<div class="playerStats">
@@ -290,15 +324,15 @@
<div id="showDeck">
<ul id="showDeckList"></ul>
<div>
<button type="button" id="showDeckCloseButton">Close</button>
<button type="button" id="showDeckCloseButton" class="gameButton">Close</button>
</div>
</div>
</div>
<div id="testControls">
<div id="testControlsHeader" class="testControls">
<button type="button" id="testDeckButton">Deck</button>
<button type="button" id="testAllCardsButton">All cards</button>
<button type="button" id="testDeckButton" class="gameButton">Deck</button>
<button type="button" id="testAllCardsButton" class="gameButton">All cards</button>
</div>
<div id="testDeckContainer" class="testControls">
<button id="testAllCardsMobileButton">All cards</button>
@@ -315,8 +349,8 @@
<div id="testAllCardsList" class="cardListGrid"></div>
</div>
<div id="testControlsFooter" class="testControls">
<button type="button" id="testUndoButton">Undo</button>
<button type="button" id="testBackButton">End test</button>
<button type="button" id="testUndoButton" class="gameButton">Undo</button>
<button type="button" id="testBackButton" class="gameButton">End test</button>
</div>
</div>
<div id="testCardListBackdrop" hidden></div>
@@ -508,6 +542,18 @@
<span id="turnTimeLimitUnit">seconds</span>
</label>
</p>
<p>
<label for="goalWinCountBox">
Format:
<select id="goalWinCountBox">
<option value="">Single game</option>
<option value="1">First to 1 (replay draws)</option>
<option value="2">First to 2</option>
<option value="3">First to 3</option>
<option value="4">First to 4</option>
</select>
</label>
</p>
<p>
<button type="submit" id="gameSetupSubmitButton">Create room</button>
<button type="submit">Back</button>

View File

@@ -1,7 +1,9 @@
enum GameState {
WaitingForPlayers,
Preparing,
ChoosingStage,
ChoosingDeck,
Redraw,
Ongoing,
Ended
GameEnded,
SetEnded
}

View File

@@ -13,15 +13,26 @@ let currentGame: {
turnNumber: number,
turnTimeLimit: number | null,
turnTimeLeft: number | null,
goalWinCount: number | null,
/** The WebSocket used for receiving game events, or null if not yet connected. */
webSocket: WebSocket | null
} | null = null;
let enterGameTimeout: number | null = null;
let currentReplay: {
gameNumber: number,
games: {
stage: Stage,
playerData: {
deck: Card[],
initialDrawOrder: number[],
drawOrder: number[],
won: boolean
}[],
turns: Move[][],
}[],
turns: Move[][],
placements: PlacementResults[],
replayPlayerData: ReplayPlayerData[],
watchingPlayer: number
} | null = null;

View File

@@ -19,8 +19,12 @@ const redrawModal = document.getElementById('redrawModal')!;
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 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;
@@ -83,8 +87,12 @@ function clear() {
handContainer.hidden = true;
playRow.hidden = true;
spectatorRow.hidden = true;
replayPreviousButton.hidden = true;
replayNextButton.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;
@@ -124,16 +132,20 @@ function initReplay() {
gameControls.hidden = false;
handContainer.hidden = false;
spectatorRow.hidden = false;
replayPreviousButton.hidden = false;
replayNextButton.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);
replayUpdateHand();
replaySwitchGame(0);
}
/** Shows the game page for deck editor testing. */
@@ -141,7 +153,7 @@ 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, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ] } };
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;
@@ -169,8 +181,9 @@ function initTest(stage: Stage) {
testAllCardsList.clearFilter();
}
replayNextButton.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null || currentGame.turnNumber > 12) return;
replayNextButton.buttonElement.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null || currentGame.state == GameState.GameEnded || currentGame.state == GameState.SetEnded)
return;
if (replayAnimationAbortController) {
replayUpdateHand();
@@ -187,9 +200,16 @@ replayNextButton.addEventListener('click', _ => {
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);
@@ -234,9 +254,8 @@ replayNextButton.addEventListener('click', _ => {
turnNumberLabel.setTurnNumber(currentGame.turnNumber);
clearPlayContainers();
if (currentGame.turnNumber > 12) {
currentGame.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
gameButtonsContainer.hidden = true;
passButton.enabled = false;
specialButton.enabled = false;
gamePage.classList.add('gameEnded');
showResult();
} else
@@ -245,12 +264,20 @@ replayNextButton.addEventListener('click', _ => {
}
});
replayPreviousButton.addEventListener('click', _ => {
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].wins = currentGame.players[i].gamesWon;
}
}
if (currentGame.turnNumber > 1) {
const result = currentReplay.placements.pop();
if (!result) return;
@@ -264,11 +291,13 @@ replayPreviousButton.addEventListener('click', _ => {
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++) {
@@ -280,8 +309,11 @@ replayPreviousButton.addEventListener('click', _ => {
currentGame.players[i].specialPoints += (move as PlayMove).card.specialCost;
updateStats(i, scores);
}
} else
} else {
currentGame.state = GameState.Redraw;
replayPreviousButton.enabled = false;
turnNumberLabel.setTurnNumber(null);
}
replayUpdateHand();
});
@@ -309,6 +341,57 @@ function undoTurn(turn: PlacementResults) {
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].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) {
@@ -326,6 +409,7 @@ flipButton.addEventListener('click', () => {
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();
@@ -426,6 +510,7 @@ function loadPlayers(players: Player[]) {
const player = players[i];
currentGame!.players[i] = players[i];
playerBars[i].name = player.name;
playerBars[i].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})`);
@@ -448,8 +533,16 @@ function updateStats(playerIndex: number, scores: number[]) {
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';
@@ -583,6 +676,7 @@ function showResult() {
el.classList.remove('lose');
el.classList.remove('draw');
el.innerText = 'Victory';
playerBars[i].wins++;
} else {
el.classList.remove('win');
el.classList.remove('lose');
@@ -598,15 +692,27 @@ function showResult() {
}
handContainer.hidden = true;
replayNextButton.enabled = false;
if (!currentReplay) {
playRow.hidden = true;
spectatorRow.hidden = false;
replayPreviousButton.hidden = true;
replayNextButton.hidden = true;
shareReplayLinkButton.hidden = false;
replayPreviousGameButton.buttonElement.hidden = true;
replayPreviousButton.buttonElement.hidden = true;
replayNextButton.buttonElement.hidden = true;
replayNextGameButton.buttonElement.hidden = true;
flipButton.hidden = true;
canShareReplay = navigator.canShare && navigator.canShare({ url: window.location.href, title: 'Tableturf Battle Replay' });
shareReplayLinkButton.innerText = canShareReplay ? 'Share replay link' : 'Copy replay link';
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';
}
}
@@ -717,7 +823,7 @@ function replayUpdateHand() {
handButtons.clear();
const playerData = currentReplay.replayPlayerData[currentReplay.watchingPlayer];
const playerData = currentReplay.games[currentReplay.gameNumber].playerData[currentReplay.watchingPlayer];
populateShowDeck(playerData.deck);
for (const b of showDeckButtons)
b.buttonElement.parentElement!.className = '';
@@ -1018,7 +1124,28 @@ document.addEventListener('keydown', e => {
}
});
shareReplayLinkButton.addEventListener('click', _ => {
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();
@@ -1048,7 +1175,7 @@ function leaveButton_click(e: MouseEvent) {
newGameButton.focus();
}
document.getElementById('leaveButton')!.addEventListener('click', leaveButton_click);
leaveButton.addEventListener('click', leaveButton_click);
const colBoxes = [
[

View File

@@ -15,6 +15,7 @@ const gameSetupDialog = document.getElementById('gameSetupDialog') as HTMLDialog
const gameSetupForm = document.getElementById('gameSetupForm') as HTMLFormElement;
const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElement;
const turnTimeLimitBox = document.getElementById('turnTimeLimitBox') as HTMLInputElement;
const goalWinCountBox = document.getElementById('goalWinCountBox') as HTMLSelectElement;
let shownMaxPlayersWarning = false;
@@ -107,6 +108,8 @@ function createRoom(useOptionsForm: boolean) {
data.append('maxPlayers', maxPlayersBox.value);
if (turnTimeLimitBox.value)
data.append('turnTimeLimit', turnTimeLimitBox.value);
if (goalWinCountBox.value)
data.append('goalWinCount', goalWinCountBox.value);
}
request.send(data.toString());
setLoadingMessage('Creating a room...');

View File

@@ -8,6 +8,7 @@ interface Player {
uiBaseColourIsSpecialColour?: boolean;
totalSpecialPoints: number;
passes: number;
gamesWon: number;
}
interface Colour {

View File

@@ -4,6 +4,7 @@ class PlayerBar {
readonly element: HTMLElement;
private nameElement: HTMLElement;
private winsElement: HTMLElement;
private specialPointsContainer: HTMLElement;
private pointsElement: HTMLElement;
private pointsContainer: HTMLElement;
@@ -15,12 +16,15 @@ class PlayerBar {
statSpecialPointsElement: HTMLElement;
statPassesElement: HTMLElement;
private _wins: number = 0;
constructor(element: HTMLDivElement) {
this.element = element;
this.playerIndex = parseInt(element.dataset.index!);
if (isNaN(this.playerIndex))
throw new Error('Missing player index');
this.nameElement = element.getElementsByClassName('name')[0] as HTMLElement;
this.winsElement = element.getElementsByClassName('wins')[0] as HTMLElement;
this.specialPointsContainer = element.getElementsByClassName('specialPoints')[0] as HTMLElement;
let pointsContainer: HTMLElement | null = null;
@@ -47,6 +51,23 @@ class PlayerBar {
get name() { return this.nameElement.innerText; }
set name(value: string) { this.nameElement.innerText = value; }
get wins() { return this._wins; }
set wins(value: number) {
this._wins = value;
clearChildren(this.winsElement);
if (value) {
if (value < 5) {
for (let i = 0; i < value; i++) {
this.winsElement.appendChild(document.createElement('div'));
}
}
const el = document.createElement('div');
el.classList.add('winCount');
el.innerText = value.toString();
this.winsElement.appendChild(el);
}
}
get points() { return parseInt(this.pointsElement.innerText); }
set points(value: number) { this.pointsElement.innerText = value.toString(); }

View File

@@ -5,9 +5,3 @@ interface PlayerData {
cardsUsed: number[];
move: Move | null;
}
interface ReplayPlayerData {
deck: Card[],
initialDrawOrder: number[],
drawOrder: number[]
}

View File

@@ -0,0 +1,180 @@
function loadReplay(base64: string) {
if (stageDatabase.stages == null)
throw new Error('Game data not loaded');
base64 = base64.replaceAll('-', '+');
base64 = base64.replaceAll('_', '/');
const bytes = Base64.base64DecToArr(base64);
const dataView = new DataView(bytes.buffer);
const version = dataView.getUint8(0);
const players = [ ];
let goalWinCount = null;
switch (version) {
case 1: {
const n = dataView.getUint8(1);
const stage = stageDatabase.stages[n & 0x1F];
const numPlayers = n >> 5;
let pos = 2;
const playerData = [ ];
currentReplay = { gameNumber: 0, games: [ ], turns: [ ], placements: [ ], watchingPlayer: 0 };
for (let i = 0; i < numPlayers; i++) {
const len = dataView.getUint8(pos + 34);
const player = {
name: new TextDecoder().decode(new DataView(bytes.buffer, pos + 35, len)),
specialPoints: 0,
isReady: false,
colour: { r: dataView.getUint8(pos + 0), g: dataView.getUint8(pos + 1), b: dataView.getUint8(pos + 2) },
specialColour: { r: dataView.getUint8(pos + 3), g: dataView.getUint8(pos + 4), b: dataView.getUint8(pos + 5) },
specialAccentColour: { r: dataView.getUint8(pos + 6), g: dataView.getUint8(pos + 7), b: dataView.getUint8(pos + 8) },
uiBaseColourIsSpecialColour: false,
totalSpecialPoints: 0,
passes: 0,
gamesWon: 0
};
players.push(player);
const deck = [ ];
const initialDrawOrder = [ ];
const drawOrder = [ ];
for (let j = 0; j < 15; j++) {
deck.push(cardDatabase.get(dataView.getUint8(pos + 9 + j)));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) & 0xF);
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) >> 4 & 0xF);
}
for (let j = 0; j < 8; j++) {
drawOrder.push(dataView.getUint8(pos + 26 + j) & 0xF);
if (j == 7)
player.uiBaseColourIsSpecialColour = (dataView.getUint8(pos + 26 + j) & 0x80) != 0;
else
drawOrder.push(dataView.getUint8(pos + 26 + j) >> 4 & 0xF);
}
playerData.push({ deck, initialDrawOrder, drawOrder, won: false });
pos += 35 + len;
}
const turns = [ ];
for (let i = 0; i < 12; i++) {
const turn = [ ];
for (let j = 0; j < numPlayers; j++) {
const cardNumber = dataView.getUint8(pos);
const b = dataView.getUint8(pos + 1);
const x = dataView.getInt8(pos + 2);
const y = dataView.getInt8(pos + 3);
if (b & 0x80)
turn.push({ card: cardDatabase.get(cardNumber), isPass: true, isTimeout: (b & 0x20) != 0 });
else {
const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
turn.push(move);
}
pos += 4;
}
turns.push(turn);
}
currentReplay.games.push({ stage, playerData, turns });
break;
}
case 2: {
const n = dataView.getUint8(1);
const numPlayers = n & 0x0F;
goalWinCount = n >> 4;
if (goalWinCount == 0) goalWinCount = null;
let pos = 2;
currentReplay = { gameNumber: 0, games: [ ], turns: [ ], placements: [ ], watchingPlayer: 0 };
for (let i = 0; i < numPlayers; i++) {
const n2 = dataView.getUint8(pos + 9);
const len = n2 & 0x7F;
const player = {
name: new TextDecoder().decode(new DataView(bytes.buffer, pos + 10, len)),
specialPoints: 0,
isReady: false,
colour: { r: dataView.getUint8(pos + 0), g: dataView.getUint8(pos + 1), b: dataView.getUint8(pos + 2) },
specialColour: { r: dataView.getUint8(pos + 3), g: dataView.getUint8(pos + 4), b: dataView.getUint8(pos + 5) },
specialAccentColour: { r: dataView.getUint8(pos + 6), g: dataView.getUint8(pos + 7), b: dataView.getUint8(pos + 8) },
uiBaseColourIsSpecialColour: (n2 & 0x80) != 0,
totalSpecialPoints: 0,
passes: 0,
gamesWon: 0
};
players.push(player);
pos += 10 + len;
}
while (pos < dataView.byteLength) {
const stage = stageDatabase.stages[dataView.getUint8(pos + 0)];
const playerData = [ ];
pos++;
for (let i = 0; i < numPlayers; i++) {
const deck = [ ];
const initialDrawOrder = [ ];
const drawOrder = [ ];
let won = false;
for (let j = 0; j < 15; j++) {
deck.push(cardDatabase.get(dataView.getUint8(pos + j)));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 15 + j) & 0xF);
initialDrawOrder.push(dataView.getUint8(pos + 15 + j) >> 4 & 0xF);
}
for (let j = 0; j < 8; j++) {
drawOrder.push(dataView.getUint8(pos + 17 + j) & 0xF);
if (j == 7)
won = (dataView.getUint8(pos + 17 + j) & 0x80) != 0;
else
drawOrder.push(dataView.getUint8(pos + 17 + j) >> 4 & 0xF);
}
playerData.push({ deck, initialDrawOrder, drawOrder, won });
pos += 25;
}
const turns = replayLoadTurns(dataView, numPlayers, pos);
pos += 48 * numPlayers;
currentReplay.games.push({ stage, playerData, turns });
}
break;
}
default:
throw new RangeError('Unknown replay data version');
}
currentGame = {
id: 'replay',
state: GameState.Redraw,
me: null,
players: players,
maxPlayers: players.length,
turnNumber: 0,
turnTimeLimit: null,
turnTimeLeft: null,
goalWinCount: goalWinCount,
webSocket: null
};
loadPlayers(players);
setUrl(`replay/${encodeToUrlSafeBase64(bytes)}`)
initReplay();
}
function replayLoadTurns(dataView: DataView, numPlayers: number, pos: number) {
const turns = [ ];
for (let i = 0; i < 12; i++) {
const turn = [ ];
for (let j = 0; j < numPlayers; j++) {
const cardNumber = dataView.getUint8(pos);
const b = dataView.getUint8(pos + 1);
const x = dataView.getInt8(pos + 2);
const y = dataView.getInt8(pos + 3);
if (b & 0x80)
turn.push({ card: cardDatabase.get(cardNumber), isPass: true, isTimeout: (b & 0x20) != 0 });
else {
const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
turn.push(move);
}
pos += 4;
}
turns.push(turn);
}
return turns;
}

View File

@@ -117,12 +117,14 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
gamePage.classList.remove('gameEnded');
switch (game.state) {
case GameState.WaitingForPlayers:
case GameState.ChoosingStage:
initLobbyPage(window.location.toString());
showPage('lobby');
clearConfirmLeavingGame();
lobbySelectedStageSection.hidden = true;
lobbyStageSection.hidden = !playerData || game.players[playerData.playerIndex]?.isReady;
break;
case GameState.Preparing:
case GameState.ChoosingDeck:
showPage('lobby');
if (currentGame.me) setConfirmLeavingGame();
if (selectedStageIndicator)
@@ -138,7 +140,8 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
break;
case GameState.Redraw:
case GameState.Ongoing:
case GameState.Ended:
case GameState.GameEnded:
case GameState.SetEnded:
board.autoHighlight = false;
redrawModal.hidden = true;
if (playerData) {
@@ -148,7 +151,7 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
initSpectator();
currentGame.turnNumber = game.turnNumber;
gameButtonsContainer.hidden = currentGame.me == null || game.state == GameState.Ended;
gameButtonsContainer.hidden = currentGame.me == null || game.state == GameState.GameEnded || game.state == GameState.SetEnded;
switch (game.state) {
case GameState.Redraw:
@@ -160,6 +163,8 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
timeLabel.paused = false;
break;
case GameState.Ongoing:
for (let i = 0; i < currentGame.players.length; i++)
showWaiting(i);
if (currentGame.me) setConfirmLeavingGame();
turnNumberLabel.setTurnNumber(game.turnNumber);
board.autoHighlight = true;
@@ -168,7 +173,8 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
timeLabel.paused = false;
resetPlayControls();
break;
case GameState.Ended:
case GameState.GameEnded:
case GameState.SetEnded:
clearConfirmLeavingGame();
gamePage.classList.add('gameEnded');
turnNumberLabel.setTurnNumber(null);
@@ -239,6 +245,7 @@ function setupWebSocket(gameID: string) {
turnNumber: payload.data.turnNumber,
turnTimeLimit: payload.data.turnTimeLimit,
turnTimeLeft: payload.data.turnTimeLeft,
goalWinCount: payload.data.goalWinCount,
webSocket: webSocket
};
@@ -265,8 +272,7 @@ function setupWebSocket(gameID: string) {
onGameStateChange(payload.data, payload.playerData);
for (let i = 0; i < currentGame.players.length; i++) {
if (currentGame.players[i].isReady)
showReady(i);
if (currentGame.players[i].isReady) showReady(i);
}
if (currentGame.me) {
@@ -317,9 +323,7 @@ function setupWebSocket(gameID: string) {
lobbyDeckSection.hidden = true;
}
if (playContainers[payload.data.playerIndex].getElementsByTagName('div').length == 0) {
showReady(payload.data.playerIndex);
}
showReady(payload.data.playerIndex);
break;
case 'stateChange':
clearReady();
@@ -345,6 +349,8 @@ function setupWebSocket(gameID: string) {
player.specialPoints = payload.data.game.players[i].specialPoints;
player.totalSpecialPoints = payload.data.game.players[i].totalSpecialPoints;
player.passes = payload.data.game.players[i].passes;
player.gamesWon = payload.data.game.players[i].gamesWon;
player.isReady = payload.data.game.players[i].isReady;
const move = payload.data.moves[i];
const button = new CardButton(move.card);
@@ -370,7 +376,7 @@ function setupWebSocket(gameID: string) {
turnNumberLabel.setTurnNumber(payload.data.game.turnNumber);
clearPlayContainers();
if (payload.event == 'gameEnd') {
currentGame.state = GameState.Ended;
currentGame.state = payload.data.game.state;
clearConfirmLeavingGame();
gameButtonsContainer.hidden = true;
passButton.enabled = false;
@@ -386,6 +392,8 @@ function setupWebSocket(gameID: string) {
timeLabel.paused = false;
if (payload.data.game.turnTimeLeft)
timeLabel.show();
for (let i = 0; i < currentGame.players.length; i++)
showWaiting(i);
}
})();
break;
@@ -399,101 +407,6 @@ function webSocket_close() {
communicationError();
}
function loadReplay(base64: string) {
if (stageDatabase.stages == null)
throw new Error('Game data not loaded');
base64 = base64.replaceAll('-', '+');
base64 = base64.replaceAll('_', '/');
const bytes = Base64.base64DecToArr(base64);
const dataView = new DataView(bytes.buffer);
if (dataView.getUint8(0) != 1) {
alert('Unknown replay data version');
return;
}
const n = dataView.getUint8(1);
const stage = stageDatabase.stages[n & 0x1F];
const numPlayers = n >> 5;
let pos = 2;
const players = [ ];
currentReplay = { turns: [ ], placements: [ ], replayPlayerData: [ ], watchingPlayer: 0 };
for (let i = 0; i < numPlayers; i++) {
const len = dataView.getUint8(pos + 34);
const player: Player = {
name: new TextDecoder().decode(new DataView(bytes.buffer, pos + 35, len)),
specialPoints: 0,
isReady: false,
colour: { r: dataView.getUint8(pos + 0), g: dataView.getUint8(pos + 1), b: dataView.getUint8(pos + 2) },
specialColour: { r: dataView.getUint8(pos + 3), g: dataView.getUint8(pos + 4), b: dataView.getUint8(pos + 5) },
specialAccentColour: { r: dataView.getUint8(pos + 6), g: dataView.getUint8(pos + 7), b: dataView.getUint8(pos + 8) },
totalSpecialPoints: 0,
passes: 0
};
players.push(player);
const deck = [ ];
const initialDrawOrder = [ ];
const drawOrder = [ ];
for (let j = 0; j < 15; j++) {
deck.push(cardDatabase.get(dataView.getUint8(pos + 9 + j)));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) & 0xF);
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) >> 4 & 0xF);
}
for (let j = 0; j < 8; j++) {
drawOrder.push(dataView.getUint8(pos + 26 + j) & 0xF);
if (j == 7)
player.uiBaseColourIsSpecialColour = (dataView.getUint8(pos + 26 + j) & 0x80) != 0;
else
drawOrder.push(dataView.getUint8(pos + 26 + j) >> 4 & 0xF);
}
currentReplay.replayPlayerData.push({ deck, initialDrawOrder, drawOrder });
pos += 35 + len;
}
for (let i = 0; i < 12; i++) {
const turn = [ ];
for (let j = 0; j < numPlayers; j++) {
const cardNumber = dataView.getUint8(pos);
const b = dataView.getUint8(pos + 1);
const x = dataView.getInt8(pos + 2);
const y = dataView.getInt8(pos + 3);
if (b & 0x80)
turn.push({ card: cardDatabase.get(cardNumber), isPass: true, isTimeout: (b & 0x20) != 0 });
else {
const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, isTimeout: (b & 0x20) != 0, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
turn.push(move);
}
pos += 4;
}
currentReplay.turns.push(turn);
}
currentGame = {
id: 'replay',
state: GameState.Redraw,
me: null,
players: players,
maxPlayers: numPlayers,
turnNumber: 0,
turnTimeLimit: null,
turnTimeLeft: null,
webSocket: null
};
board.resize(stage.copyGrid());
const startSpaces = stage.getStartSpaces(numPlayers);
for (let i = 0; i < numPlayers; i++)
board.grid[startSpaces[i].x][startSpaces[i].y] = Space.SpecialInactive1 | i;
board.refresh();
loadPlayers(players);
setUrl(`replay/${encodeToUrlSafeBase64(bytes)}`)
initReplay();
}
function setConfirmLeavingGame() {
if (!shouldConfirmLeavingGame) {
shouldConfirmLeavingGame = true;

View File

@@ -597,6 +597,11 @@ dialog::backdrop {
padding-right: 10px;
}
#gamePage:not(.boardFlipped):not([data-players="2"]) .playerBar:is([data-index="1"], [data-index="2"]) .wins,
#gamePage.boardFlipped:not([data-players="2"]) .playerBar:is([data-index="0"], [data-index="3"]) .wins {
float: left;
}
#gamePage.deckTest #scoreSection { display: none; }
#scoreSection:not([hidden]) {
grid-column: score-column;
@@ -624,7 +629,7 @@ dialog::backdrop {
justify-content: center;
}
#handContainer {
#handContainer:not([hidden]) {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
@@ -638,26 +643,44 @@ dialog::backdrop {
grid-template-columns: 1fr 1fr;
}
:is(#playRow, #testControlsHeader) > button {
grid-row: button-row;
.gameButton {
border: none;
margin: 0.5em;
padding: 0 0.5em;
margin: 0.25rem;
padding: 0.25rem 0.5rem;
font: inherit;
text-align: center;
background: var(--player-ui-base-colour);
text-shadow: 0 0 4px black;
border-radius: 0.5em;
border-radius: 0.5rem;
line-height: 1.5em;
}
.gameButton:hover { background: var(--player-ui-highlight-colour); }
.gameButton:focus-within { outline: 2px solid var(--player-ui-highlight2-colour); }
.gameButton:is(:active, .checked) { background: var(--player-ui-highlight2-colour); }
.gameButton.disabled:not(.checked) { background: grey; }
:is(#playRow, #testControlsHeader, #spectatorRow) > button.replayButton:not([hidden]) {
font-size: 60%;
flex-basis: 7em;
display: flex;
flex-flow: column;
align-items: center;
gap: 0.5em;
}
:is(#playRow, #testControlsHeader, #testControlsFooter) > button:hover { background: var(--player-ui-highlight-colour); }
:is(#playRow, #testControlsHeader, #testControlsFooter) > button:focus-within { outline: 2px solid var(--player-ui-highlight2-colour); }
:is(#playRow, #testControlsHeader, #testControlsFooter) > button:is(:active, .checked) { background: var(--player-ui-highlight2-colour); }
:is(#playRow, #testControlsHeader, #testControlsFooter) > button.disabled:not(.checked) { background: grey; }
#spectatorRow:not([hidden]) {
display: grid;
grid-template-columns: auto 1fr 1fr auto;
gap: 0.5em;
display: flex;
gap: 0.25em;
}
#spectatorRow > * {
flex-grow: 1;
}
#nextGameButton:not([hidden]) {
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
}
#debugColour input {
@@ -703,21 +726,8 @@ dialog::backdrop {
#showDeckList > li.used {
opacity: 0.35;
}
#showDeckCloseButton, #testControlsFooter button {
border: none;
font-family: inherit;
color: inherit;
margin: 0.5em;
padding: 0 0.5em;
text-align: center;
background: var(--player-ui-base-colour);
text-shadow: 0 0 4px black;
border-radius: 0.5em;
font-size: inherit;
}
#showDeckCloseButton {
width: 40%;
grid-row: button-row;
}
#testControls:not([hidden]) {
@@ -981,6 +991,24 @@ dialog::backdrop {
to { left: 50%; top: 50%; right: 50%; bottom: 50%; background: var(--special-accent-colour); visibility: hidden; }
}
.wins {
float: right;
display: flex;
margin-top: 1.25em;
}
.wins > div {
display: inline-block;
background: url('assets/external/IconMedal_00.webp') center/cover;
width: 2em;
height: 2em;
}
.winCount {
font-size: larger;
text-align: center;
text-shadow: 0.1em 0.1em 0 black;
}
.winCount:not(:first-child) { display: none; }
.result {
font-family: 'Splatoon 1';
color: var(--colour);
@@ -1172,7 +1200,7 @@ dialog::backdrop {
top: 0;
right: -50vw;
bottom: 0;
padding-right: 50vw; /* Centre the dialog over the board but extend the backdrop all the way to the right of the page. */
padding-right: 50vw; /* Centre the dialog over the board but extend the backdrop all the way to the right of the page. */
background: #000000C0;
z-index: 1;
}
@@ -1441,18 +1469,21 @@ dialog::backdrop {
left: -3em;
top: 3em;
}
.playerBar[data-index="0"] {
grid-row: player-row-0;
}
.playerBar[data-index="1"] {
grid-row: player-row-1;
}
.playerBar[data-index="2"] {
grid-row: player-row-0;
}
.playerBar[data-index="3"] {
grid-row: player-row-1;
}
.playerBar[data-index="0"] { grid-row: player-row-0; }
.playerBar[data-index="1"] { grid-row: player-row-1; }
.playerBar[data-index="2"] { grid-row: player-row-0; }
.playerBar[data-index="3"] { grid-row: player-row-1; }
#gamePage.boardFlipped .playerBar[data-index="0"] { grid-row: player-row-1; }
#gamePage.boardFlipped .playerBar[data-index="1"] { grid-row: player-row-0; }
#gamePage.boardFlipped .playerBar[data-index="2"] { grid-row: player-row-1; }
#gamePage.boardFlipped .playerBar[data-index="3"] { grid-row: player-row-0; }
#gamePage:not(.boardFlipped):not([data-players="2"]) .playerBar:is([data-index="1"], [data-index="2"]) .wins,
#gamePage.boardFlipped:not([data-players="2"]) .playerBar:is([data-index="0"], [data-index="3"]) .wins {
float: initial;
justify-content: end;
}
}
@media (max-width: 40rem) {
@@ -1501,6 +1532,8 @@ dialog::backdrop {
.playerBar[data-index="1"] { grid-column: 1 / span 4; grid-row: 1; }
.playerBar[data-index="2"] { grid-column: 1 / span 4; grid-row: 3; }
.playerBar[data-index="3"] { grid-column: 1 / span 4; grid-row: 1; }
.wins { margin-right: 8em; }
#gamePage.boardFlipped .playerBar[data-index="3"] { grid-column: 1 / span 4; grid-row: 3; }
#gamePage.boardFlipped .playerBar[data-index="2"] { grid-column: 1 / span 4; grid-row: 1; }