+
0
Player 1
@@ -315,8 +349,8 @@
@@ -508,6 +542,18 @@
seconds
+
+
+
diff --git a/TableturfBattleClient/src/GameState.ts b/TableturfBattleClient/src/GameState.ts
index 57ba924..4a3e244 100644
--- a/TableturfBattleClient/src/GameState.ts
+++ b/TableturfBattleClient/src/GameState.ts
@@ -1,7 +1,9 @@
enum GameState {
WaitingForPlayers,
- Preparing,
+ ChoosingStage,
+ ChoosingDeck,
Redraw,
Ongoing,
- Ended
+ GameEnded,
+ SetEnded
}
diff --git a/TableturfBattleClient/src/GameVariables.ts b/TableturfBattleClient/src/GameVariables.ts
index defd90f..5d0147e 100644
--- a/TableturfBattleClient/src/GameVariables.ts
+++ b/TableturfBattleClient/src/GameVariables.ts
@@ -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;
diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts
index 4d10432..3a0ce57 100644
--- a/TableturfBattleClient/src/Pages/GamePage.ts
+++ b/TableturfBattleClient/src/Pages/GamePage.ts
@@ -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' : '
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 = '
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 = [
[
diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts
index d5f300c..685fce6 100644
--- a/TableturfBattleClient/src/Pages/PreGamePage.ts
+++ b/TableturfBattleClient/src/Pages/PreGamePage.ts
@@ -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...');
diff --git a/TableturfBattleClient/src/Player.ts b/TableturfBattleClient/src/Player.ts
index 776e4e3..8d1f9fc 100644
--- a/TableturfBattleClient/src/Player.ts
+++ b/TableturfBattleClient/src/Player.ts
@@ -8,6 +8,7 @@ interface Player {
uiBaseColourIsSpecialColour?: boolean;
totalSpecialPoints: number;
passes: number;
+ gamesWon: number;
}
interface Colour {
diff --git a/TableturfBattleClient/src/PlayerBar.ts b/TableturfBattleClient/src/PlayerBar.ts
index 1055480..c4d4aa3 100644
--- a/TableturfBattleClient/src/PlayerBar.ts
+++ b/TableturfBattleClient/src/PlayerBar.ts
@@ -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(); }
diff --git a/TableturfBattleClient/src/PlayerData.ts b/TableturfBattleClient/src/PlayerData.ts
index b622108..b5a1843 100644
--- a/TableturfBattleClient/src/PlayerData.ts
+++ b/TableturfBattleClient/src/PlayerData.ts
@@ -5,9 +5,3 @@ interface PlayerData {
cardsUsed: number[];
move: Move | null;
}
-
-interface ReplayPlayerData {
- deck: Card[],
- initialDrawOrder: number[],
- drawOrder: number[]
-}
diff --git a/TableturfBattleClient/src/ReplayLoader.ts b/TableturfBattleClient/src/ReplayLoader.ts
new file mode 100644
index 0000000..90251ad
--- /dev/null
+++ b/TableturfBattleClient/src/ReplayLoader.ts
@@ -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;
+}
diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts
index 8e2e4cb..fa695bd 100644
--- a/TableturfBattleClient/src/app.ts
+++ b/TableturfBattleClient/src/app.ts
@@ -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;
diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css
index aff8abd..98d61f7 100644
--- a/TableturfBattleClient/tableturf.css
+++ b/TableturfBattleClient/tableturf.css
@@ -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; }
diff --git a/TableturfBattleServer/DataStructures.cs b/TableturfBattleServer/DataStructures.cs
index 542d21d..95ef9cd 100644
--- a/TableturfBattleServer/DataStructures.cs
+++ b/TableturfBattleServer/DataStructures.cs
@@ -40,5 +40,5 @@ public class PlayerData {
this.Move = move;
this.CardsUsed = cardsUsed;
}
- public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.Deck, player.Move, player.CardsUsed) { }
+ public PlayerData(int playerIndex, Player player) : this(playerIndex, player.Hand, player.CurrentGameData.Deck, player.Move, player.CardsUsed) { }
}
diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs
index 27fcd25..d186d05 100644
--- a/TableturfBattleServer/Game.cs
+++ b/TableturfBattleServer/Game.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
+using System.Numerics;
using System.Text;
using Newtonsoft.Json;
@@ -24,6 +25,9 @@ public class Game {
[JsonProperty("startSpaces")]
public Point[]? StartSpaces;
+ [JsonProperty("goalWinCount")]
+ public int? GoalWinCount { get; set; }
+
[JsonProperty("turnTimeLimit")]
public int? TurnTimeLimit { get; set; }
[JsonProperty("turnTimeLeft")]
@@ -31,6 +35,8 @@ public class Game {
[JsonIgnore]
internal DateTime abandonedSince = DateTime.UtcNow;
+ internal List
setStages = new();
+
public Game(int maxPlayers) => this.MaxPlayers = maxPlayers;
public bool TryAddPlayer(Player player, out int playerIndex, out Error error) {
@@ -73,9 +79,9 @@ public class Game {
public bool CanPlay(int playerIndex, Card card, int x, int y, int rotation, bool isSpecialAttack) {
if (card is null) throw new ArgumentNullException(nameof(card));
- if (this.Board == null) return false;
+ if (this.Board is null || this.Players[playerIndex].CurrentGameData is not SingleGameData gameData) return false;
- if (isSpecialAttack && (this.Players[playerIndex].SpecialPoints < card.SpecialCost))
+ if (isSpecialAttack && (gameData.SpecialPoints < card.SpecialCost))
return false;
var isAnchored = false;
@@ -124,7 +130,7 @@ public class Game {
}
internal void Tick() {
- if (this.State == GameState.WaitingForPlayers && this.Players.Count >= 2 && this.Players.All(p => p.selectedStageIndex != null)) {
+ if (this.State is GameState.WaitingForPlayers or GameState.ChoosingStage && this.Players.Count >= 2 && this.Players.All(p => p.selectedStageIndex != null)) {
// Choose colours.
for (int i = 0; i < this.Players.Count; i++) {
this.Players[i].Colour = i switch {
@@ -154,6 +160,7 @@ public class Game {
if (stageIndex < 0) stageIndex = random.Next(StageDatabase.Stages.Count);
var stage = StageDatabase.Stages[stageIndex];
this.StageName = stage.Name;
+ this.setStages.Add(stage.Name);
this.Board = (Space[,]) stage.grid.Clone();
// Place starting positions.
@@ -162,9 +169,9 @@ public class Game {
for (int i = 0; i < this.Players.Count; i++)
this.Board[list[i].X, list[i].Y] = Space.SpecialInactive1 | (Space) i;
- this.State = GameState.Preparing;
+ this.State = GameState.ChoosingDeck;
this.SendEvent("stateChange", this, true);
- } else if (this.State == GameState.Preparing && this.Players.All(p => p.Deck != null)) {
+ } else if (this.State == GameState.ChoosingDeck && this.Players.All(p => p.CurrentGameData.Deck != null)) {
// Draw cards.
var random = new Random();
foreach (var player in this.Players)
@@ -196,7 +203,7 @@ public class Game {
foreach (var player in this.Players) {
var move = player.Move!;
- player.turns.Add(new() { CardNumber = move.Card.Number, X = move.X, Y = move.Y, Rotation = move.Rotation, IsPass = move.IsPass, IsTimeout = move.IsTimeout, IsSpecialAttack = move.IsSpecialAttack });
+ player.CurrentGameData.turns.Add(new() { CardNumber = move.Card.Number, X = move.X, Y = move.Y, Rotation = move.Rotation, IsPass = move.IsPass, IsTimeout = move.IsTimeout, IsSpecialAttack = move.IsSpecialAttack });
}
// Place the ink.
@@ -210,12 +217,12 @@ public class Game {
player.CardsUsed.Add(move.Card.Number);
if (move.IsPass) {
- player.Passes++;
- player.SpecialPoints++;
+ player.CurrentGameData.Passes++;
+ player.CurrentGameData.SpecialPoints++;
} else {
if (move.IsSpecialAttack) {
anySpecialAttacks = true;
- player.SpecialPoints -= move.Card.SpecialCost;
+ player.CurrentGameData.SpecialPoints -= move.Card.SpecialCost;
}
if (placementData == null || move.Card.Size != placementData.Value.cardSize) {
if (placementData != null)
@@ -280,19 +287,42 @@ public class Game {
if (!anyEmptySpace) {
var player = this.Players[(int) this.Board[x, y] & 3]!;
this.Board[x, y] |= Space.SpecialActive1;
- player.SpecialPoints++;
- player.TotalSpecialPoints++;
+ player.CurrentGameData.SpecialPoints++;
+ player.CurrentGameData.TotalSpecialPoints++;
specialSpacesActivated.Add(new(x, y));
}
}
}
}
- if (this.TurnNumber == 12) {
- this.State = GameState.Ended;
+ if (this.TurnNumber >= 12) {
this.TurnTimeLeft = null;
- this.SendEvent("gameEnd", new { game = this, moves, placements, specialSpacesActivated }, true);
+ this.State = this.GoalWinCount is not null ? GameState.GameEnded : GameState.SetEnded;
+
+ // Determine the winner and check whether a player has won the set yet.
+ var scores = new int[this.Players.Count]; int? winner = null; int maxScore = 0;
+ for (var x = 0; x < this.Board.GetLength(0); x++) {
+ for (var y = 0; y < this.Board.GetLength(1); y++) {
+ if (((int) this.Board[x, y] & 0xC) != 0)
+ scores[(int) this.Board[x, y] & 0x3]++;
+ }
+ }
+ for (var i = 0; i < scores.Length; i++) {
+ if (scores[i] > maxScore) {
+ winner = i;
+ maxScore = scores[i];
+ } else if (scores[i] == maxScore)
+ winner = null;
+ }
+ if (winner is not null) {
+ this.Players[winner.Value].CurrentGameData.won = true;
+ this.Players[winner.Value].GamesWon++;
+ if (this.Players[winner.Value].GamesWon >= this.GoalWinCount)
+ this.State = GameState.SetEnded;
+ }
+
foreach (var player in this.Players) player.ClearMoves();
+ this.SendEvent("gameEnd", new { game = this, moves, placements, specialSpacesActivated }, true);
} else {
this.TurnNumber++;
this.TurnTimeLeft = this.TurnTimeLimit + (anySpecialAttacks ? 6 : 4) + coveringMoves + (specialSpacesActivated.Count > 0 ? 1 : 0); // Extra seconds for the animations.
@@ -300,8 +330,8 @@ public class Game {
// Draw cards.
foreach (var player in this.Players) {
var index = player.GetHandIndex(player.Move!.Card.Number);
- var draw = player.drawOrder![this.TurnNumber + 2];
- player.Hand![index] = player.Deck![draw];
+ var draw = player.CurrentGameData.drawOrder![this.TurnNumber + 2];
+ player.Hand![index] = player.CurrentGameData.Deck![draw];
player.ClearMoves();
}
this.SendEvent("turn", new { game = this, moves, placements, specialSpacesActivated }, true);
@@ -328,6 +358,17 @@ public class Game {
}
}
}
+ } else if (this.State is GameState.GameEnded or GameState.SetEnded && this.Players.All(p => p.Move != null)) {
+ foreach (var player in this.Players) {
+ player.selectedStageIndex = null;
+ player.Hand = null;
+ player.CardsUsed.Clear();
+ player.Games.Add(new());
+ player.ClearMoves();
+ }
+ this.State = GameState.ChoosingStage;
+ this.TurnTimeLeft = this.TurnTimeLimit;
+ this.SendEvent("stateChange", this, true);
}
}
@@ -356,16 +397,14 @@ public class Game {
}
public void WriteReplayData(Stream stream) {
- const int VERSION = 1;
+ const int VERSION = 2;
- if (this.State < GameState.Ended)
- throw new InvalidOperationException("Can't save a replay until the game has ended.");
+ if (this.State < GameState.SetEnded)
+ throw new InvalidOperationException("Can't save a replay until the set has ended.");
using var writer = new BinaryWriter(stream, Encoding.UTF8, true);
writer.Write((byte) VERSION);
-
- var stageNumber = Enumerable.Range(0, StageDatabase.Stages.Count).First(i => this.StageName == StageDatabase.Stages[i].Name);
- writer.Write((byte) (stageNumber | (this.Players.Count << 5)));
+ writer.Write((byte) (this.Players.Count | (this.GoalWinCount ?? 0) << 4));
foreach (var player in this.Players) {
writer.Write((byte) player.Colour.R);
writer.Write((byte) player.Colour.G);
@@ -376,21 +415,32 @@ public class Game {
writer.Write((byte) player.SpecialAccentColour.R);
writer.Write((byte) player.SpecialAccentColour.G);
writer.Write((byte) player.SpecialAccentColour.B);
- foreach (var card in player.Deck!)
- writer.Write((byte) card.Number);
- for (int i = 0; i < 4; i += 2)
- writer.Write((byte) (player.initialDrawOrder![i] | player.initialDrawOrder[i + 1] << 4));
- for (int i = 0; i < 15; i += 2)
- writer.Write((byte) (player.drawOrder![i] | (i < 14 ? player.drawOrder[i + 1] << 4 : player.UIBaseColourIsSpecialColour ? 0x80 : 0)));
- writer.Write(player.Name);
+
+ var nameBytes = Encoding.UTF8.GetBytes(player.Name);
+ writer.Write((byte) (nameBytes.Length | (player.UIBaseColourIsSpecialColour ? 0x80 : 0)));
+ writer.Write(nameBytes);
}
- for (int i = 0; i < 12; i++) {
+ for (int i = 0; i < this.Players[0].Games.Count; i++) {
+ var stageNumber = Enumerable.Range(0, StageDatabase.Stages.Count).First(j => this.setStages[i] == StageDatabase.Stages[j].Name);
+ writer.Write((byte) stageNumber);
+
foreach (var player in this.Players) {
- var move = player.turns[i];
- writer.Write((byte) move.CardNumber);
- writer.Write((byte) ((move.Rotation & 3) | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0) | (move.IsTimeout ? 0x20 : 0)));
- writer.Write((sbyte) move.X);
- writer.Write((sbyte) move.Y);
+ var gameData = player.Games[i];
+ foreach (var card in gameData.Deck!)
+ writer.Write((byte) card.Number);
+ for (int j = 0; j < 4; j += 2)
+ writer.Write((byte) (gameData.initialDrawOrder![j] | gameData.initialDrawOrder[j + 1] << 4));
+ for (int j = 0; j < 15; j += 2)
+ writer.Write((byte) (gameData.drawOrder![j] | (j < 14 ? gameData.drawOrder[j + 1] << 4 : gameData.won ? 0x80 : 0)));
+ }
+ for (int j = 0; j < 12; j++) {
+ foreach (var player in this.Players) {
+ var move = player.Games[i].turns[j];
+ writer.Write((byte) move.CardNumber);
+ writer.Write((byte) ((move.Rotation & 3) | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0) | (move.IsTimeout ? 0x20 : 0)));
+ writer.Write((sbyte) move.X);
+ writer.Write((sbyte) move.Y);
+ }
}
}
}
diff --git a/TableturfBattleServer/GameState.cs b/TableturfBattleServer/GameState.cs
index 4210247..5c2e495 100644
--- a/TableturfBattleServer/GameState.cs
+++ b/TableturfBattleServer/GameState.cs
@@ -2,8 +2,10 @@
public enum GameState {
WaitingForPlayers,
- Preparing,
+ ChoosingStage,
+ ChoosingDeck,
Redraw,
Ongoing,
- Ended
-}
\ No newline at end of file
+ GameEnded,
+ SetEnded
+}
diff --git a/TableturfBattleServer/Player.cs b/TableturfBattleServer/Player.cs
index c46a799..b8e3229 100644
--- a/TableturfBattleServer/Player.cs
+++ b/TableturfBattleServer/Player.cs
@@ -1,6 +1,4 @@
-using System.Diagnostics.CodeAnalysis;
-
-using Newtonsoft.Json;
+using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Player {
@@ -16,29 +14,10 @@ public class Player {
public Colour SpecialAccentColour { get; set; }
[JsonProperty("uiBaseColourIsSpecialColour")]
public bool UIBaseColourIsSpecialColour { get; set; }
- [JsonProperty("specialPoints")]
- public int SpecialPoints { get; set; }
-
- [JsonProperty("totalSpecialPoints")]
- public int TotalSpecialPoints { get; set; }
- [JsonProperty("passes")]
- public int Passes { get; set; }
-
- [JsonProperty("isReady")]
- public bool IsReady => this.game.State switch {
- GameState.WaitingForPlayers => this.selectedStageIndex != null,
- GameState.Preparing => this.Deck != null,
- _ => this.Move != null
- };
-
- [JsonIgnore]
- internal List turns = new(12);
[JsonIgnore]
private readonly Game game;
[JsonIgnore]
- internal Card[]? Deck;
- [JsonIgnore]
internal readonly List CardsUsed = new(12);
[JsonIgnore]
internal Card[]? Hand;
@@ -46,10 +25,31 @@ public class Player {
internal Move? Move;
[JsonIgnore]
internal Move? ProvisionalMove;
+
+ [JsonProperty("gamesWon")]
+ public int GamesWon { get; set; }
+
[JsonIgnore]
- internal int[]? initialDrawOrder;
+ internal List Games { get; } = new() { new() };
+
[JsonIgnore]
- internal int[]? drawOrder;
+ public SingleGameData CurrentGameData => this.Games[^1];
+
+ [JsonProperty("specialPoints")]
+ public int SpecialPoints => this.CurrentGameData.SpecialPoints;
+
+ [JsonProperty("totalSpecialPoints")]
+ public int TotalSpecialPoints => this.CurrentGameData.TotalSpecialPoints;
+ [JsonProperty("passes")]
+ public int Passes => this.CurrentGameData.Passes;
+
+ [JsonProperty("isReady")]
+ public bool IsReady => this.game.State switch {
+ GameState.WaitingForPlayers or GameState.ChoosingStage => this.selectedStageIndex != null,
+ GameState.ChoosingDeck => this.CurrentGameData.Deck != null,
+ _ => this.Move != null
+ };
+
[JsonIgnore]
internal int? selectedStageIndex;
@@ -64,19 +64,18 @@ public class Player {
this.ProvisionalMove = null;
}
- [MemberNotNull(nameof(drawOrder))]
internal void Shuffle(Random random) {
- this.drawOrder = new int[15];
- this.initialDrawOrder ??= this.drawOrder;
- for (int i = 0; i < 15; i++) this.drawOrder[i] = i;
+ this.CurrentGameData.drawOrder = new int[15];
+ this.CurrentGameData.initialDrawOrder ??= this.CurrentGameData.drawOrder;
+ for (int i = 0; i < 15; i++) this.CurrentGameData.drawOrder[i] = i;
for (int i = 14; i > 0; i--) {
var j = random.Next(i);
- (this.drawOrder[i], this.drawOrder[j]) = (this.drawOrder[j], this.drawOrder[i]);
+ (this.CurrentGameData.drawOrder[i], this.CurrentGameData.drawOrder[j]) = (this.CurrentGameData.drawOrder[j], this.CurrentGameData.drawOrder[i]);
}
- if (this.Deck != null) {
+ if (this.CurrentGameData.Deck != null) {
this.Hand = new Card[4];
for (int i = 0; i < 4; i++) {
- this.Hand[i] = this.Deck[this.drawOrder[i]];
+ this.Hand[i] = this.CurrentGameData.Deck[this.CurrentGameData.drawOrder[i]];
}
}
}
diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs
index 05e9594..a309592 100644
--- a/TableturfBattleServer/Program.cs
+++ b/TableturfBattleServer/Program.cs
@@ -6,13 +6,9 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Timers;
using System.Web;
-
using Newtonsoft.Json;
-
using TableturfBattleServer.DTO;
-
using WebSocketSharp.Server;
-
using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest;
using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse;
using Timer = System.Timers.Timer;
@@ -128,6 +124,10 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
return;
}
+ if (name.Length > 32) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
+ return;
+ }
var maxPlayers = 2;
if (d.TryGetValue("maxPlayers", out var maxPlayersString)) {
if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) {
@@ -143,6 +143,14 @@ internal class Program {
}
turnTimeLimit = turnTimeLimit2;
}
+ int? goalWinCount = null;
+ if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") {
+ if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count."));
+ return;
+ }
+ goalWinCount = goalWinCount2;
+ }
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token."));
@@ -150,7 +158,7 @@ internal class Program {
}
} else
clientToken = Guid.NewGuid();
- var game = new Game(maxPlayers) { TurnTimeLimit = turnTimeLimit };
+ var game = new Game(maxPlayers) { GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit };
game.Players.Add(new(game, name, clientToken));
games.Add(game.ID, game);
timer.Start();
@@ -221,6 +229,10 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name."));
return;
}
+ if (name.Length > 32) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long."));
+ return;
+ }
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
@@ -317,7 +329,7 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game."));
return;
}
- if (player.Deck != null) {
+ if (player.CurrentGameData.Deck != null) {
SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck."));
return;
}
@@ -357,7 +369,7 @@ internal class Program {
}
}
- player.Deck = cards.Select(CardDatabase.GetCard).ToArray();
+ player.CurrentGameData.Deck = cards.Select(CardDatabase.GetCard).ToArray();
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
timer.Start();
@@ -473,13 +485,48 @@ internal class Program {
}
break;
}
+ case "nextGame": {
+ if (e.Request.HttpMethod != "POST") {
+ e.Response.AddHeader("Allow", "POST");
+ SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
+ } else if (e.Request.ContentLength64 >= 65536) {
+ e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
+ } else {
+ try {
+ if (game.State is not (GameState.GameEnded or GameState.SetEnded)) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state."));
+ return;
+ }
+
+ var d = DecodeFormData(e.Request.InputStream);
+ if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token."));
+ return;
+ }
+ if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game."));
+ return;
+ }
+
+ if (player.Move == null) {
+ player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready.
+ game.SendPlayerReadyEvent(playerIndex, false);
+ }
+ e.Response.StatusCode = (int) HttpStatusCode.NoContent;
+ timer.Start();
+ } catch (ArgumentException) {
+ e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
+ }
+ }
+ break;
+ }
case "replay": {
if (e.Request.HttpMethod != "GET") {
e.Response.AddHeader("Allow", "GET");
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
} else {
- if (game.State != GameState.Ended) {
- SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the game has ended."));
+ if (game.State != GameState.SetEnded) {
+ SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended."));
return;
}
var ms = new MemoryStream();
diff --git a/TableturfBattleServer/SingleGameData.cs b/TableturfBattleServer/SingleGameData.cs
new file mode 100644
index 0000000..a95df36
--- /dev/null
+++ b/TableturfBattleServer/SingleGameData.cs
@@ -0,0 +1,25 @@
+using Newtonsoft.Json;
+
+namespace TableturfBattleServer;
+public class SingleGameData {
+ [JsonProperty("specialPoints")]
+ public int SpecialPoints { get; set; }
+
+ [JsonProperty("totalSpecialPoints")]
+ public int TotalSpecialPoints { get; set; }
+ [JsonProperty("passes")]
+ public int Passes { get; set; }
+
+ [JsonIgnore]
+ internal bool won = false;
+
+ [JsonIgnore]
+ internal List turns = new(12);
+
+ [JsonIgnore]
+ internal Card[]? Deck;
+ [JsonIgnore]
+ internal int[]? initialDrawOrder;
+ [JsonIgnore]
+ internal int[]? drawOrder;
+}