diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..8218f79
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,14 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "typescript",
+ "tsconfig": "TableturfBattleClient/tsconfig.json",
+ "problemMatcher": [
+ "$tsc"
+ ],
+ "group": "build",
+ "label": "tsc: build - TableturfBattleClient/tsconfig.json"
+ }
+ ]
+}
diff --git a/TableturfBattleClient/index.html b/TableturfBattleClient/index.html
index f1b3c7d..7792bf9 100644
--- a/TableturfBattleClient/index.html
+++ b/TableturfBattleClient/index.html
@@ -4,7 +4,8 @@
diff --git a/TableturfBattleClient/src/Base64.ts b/TableturfBattleClient/src/Base64.ts
new file mode 100644
index 0000000..6455180
--- /dev/null
+++ b/TableturfBattleClient/src/Base64.ts
@@ -0,0 +1,90 @@
+// Adapted from https://github.com/mdn/content/blob/main/files/en-us/glossary/base64/index.md
+class Base64 {
+ // Array of bytes to Base64 string decoding
+ private static b64ToUint6(nChr: number) {
+ return nChr > 64 && nChr < 91
+ ? nChr - 65
+ : nChr > 96 && nChr < 123
+ ? nChr - 71
+ : nChr > 47 && nChr < 58
+ ? nChr + 4
+ : nChr === 43
+ ? 62
+ : nChr === 47
+ ? 63
+ : 0;
+ }
+
+ static base64DecToArr(sBase64: string, nBlocksSize?: number) {
+ const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, "");
+ const nInLen = sB64Enc.length;
+ const nOutLen = nBlocksSize
+ ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
+ : (nInLen * 3 + 1) >> 2;
+ const taBytes = new Uint8Array(nOutLen);
+
+ let nMod3;
+ let nMod4;
+ let nUint24 = 0;
+ let nOutIdx = 0;
+ for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) {
+ nMod4 = nInIdx & 3;
+ nUint24 |= Base64.b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4));
+ if (nMod4 === 3 || nInLen - nInIdx === 1) {
+ nMod3 = 0;
+ while (nMod3 < 3 && nOutIdx < nOutLen) {
+ taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255;
+ nMod3++;
+ nOutIdx++;
+ }
+ nUint24 = 0;
+ }
+ }
+
+ return taBytes;
+ }
+
+ /* Base64 string to array encoding */
+ private static uint6ToB64(nUint6: number) {
+ return nUint6 < 26
+ ? nUint6 + 65
+ : nUint6 < 52
+ ? nUint6 + 71
+ : nUint6 < 62
+ ? nUint6 - 4
+ : nUint6 === 62
+ ? 43
+ : nUint6 === 63
+ ? 47
+ : 65;
+ }
+
+ static base64EncArr(aBytes: Uint8Array) {
+ let nMod3 = 2;
+ let sB64Enc = "";
+
+ const nLen = aBytes.length;
+ let nUint24 = 0;
+ for (let nIdx = 0; nIdx < nLen; nIdx++) {
+ nMod3 = nIdx % 3;
+ if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) {
+ sB64Enc += "\r\n";
+ }
+
+ nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24);
+ if (nMod3 === 2 || aBytes.length - nIdx === 1) {
+ sB64Enc += String.fromCodePoint(
+ Base64.uint6ToB64((nUint24 >>> 18) & 63),
+ Base64.uint6ToB64((nUint24 >>> 12) & 63),
+ Base64.uint6ToB64((nUint24 >>> 6) & 63),
+ Base64.uint6ToB64(nUint24 & 63)
+ );
+ nUint24 = 0;
+ }
+ }
+ return (
+ sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) +
+ (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==")
+ );
+ }
+}
diff --git a/TableturfBattleClient/src/Board.ts b/TableturfBattleClient/src/Board.ts
index 63b033f..857248b 100644
--- a/TableturfBattleClient/src/Board.ts
+++ b/TableturfBattleClient/src/Board.ts
@@ -274,6 +274,10 @@ class Board {
}
}
+ setDisplayedSpace(x: number, y: number, newState: Space) {
+ this.cells[x][y].setAttribute('class', Space[newState]);
+ }
+
getScore(playerIndex: number) {
let count = 0;
for (let x = 0; x < this.grid.length; x++) {
@@ -300,4 +304,94 @@ class Board {
}
return false;
}
+
+ /**
+ * Calculates and makes moves resulting from players playing the specified cards, for replays and test placements.
+ */
+ makePlacements(moves: Move[]) {
+ var playerIndices = Array.from({ length: moves.length }, (_, i) => i);
+ playerIndices.sort((a, b) => moves[b].card.size - moves[a].card.size);
+
+ const placements = [ ];
+ let placementData: { placement: Placement, cardSize: number } | null = null;
+ for (const i of playerIndices) {
+ if (!moves[i] || moves[i].isPass) continue;
+
+ const move = moves[i] as PlayMove;
+ if (placementData == null) {
+ placementData = { placement: { players: [ ], spacesAffected: [ ] }, cardSize: move.card.size };
+ } else if (move.card.size != placementData.cardSize) {
+ placements.push(placementData.placement);
+ placementData = { placement: { players: [ ], spacesAffected: [ ] }, cardSize: move.card.size };
+ }
+
+ const placement = placementData.placement;
+ placement.players.push(i);
+ for (let dy = 0; dy < 8; dy++) {
+ const y = move.y + dy;
+ for (let dx = 0; dx < 8; dx++) {
+ const x = move.x + dx;
+ const point = { x, y }
+ switch (move.card.getSpace(dx, dy, move.rotation)) {
+ case Space.Ink1: {
+ const e = placement.spacesAffected.find(s => s.space.x == point.x && s.space.y == point.y)
+ if (e) {
+ if (e.newState < Space.SpecialInactive1) {
+ // Two ink spaces overlapped; create a wall there.
+ e.newState = this.grid[x][y] = Space.Wall;
+ }
+ } else {
+ if (this.grid[x][y] < Space.SpecialInactive1) // Ink spaces can't overlap special spaces from larger cards.
+ placement.spacesAffected.push({ space: point, newState: this.grid[x][y] = (Space.Ink1 | i) });
+ }
+ break;
+ }
+ case Space.SpecialInactive1: {
+ const e = placement.spacesAffected.find(s => s.space.x == point.x && s.space.y == point.y)
+ if (e) {
+ if (e.newState >= Space.SpecialInactive1)
+ // Two special spaces overlapped; create a wall there.
+ e.newState = this.grid[x][y] = Space.Wall;
+ else
+ // If a special space overlaps an ink space, overwrite it.
+ e.newState = this.grid[x][y] = (Space.SpecialInactive1 | i);
+ } else
+ placement.spacesAffected.push({ space: point, newState: this.grid[x][y] = (Space.SpecialInactive1 | i) });
+ break;
+ }
+ }
+ }
+ }
+ }
+ if (placementData != null)
+ placements.push(placementData.placement);
+
+ // Activate special spaces.
+ const specialSpacesActivated: Point[] = [ ];
+ for (let x = 0; x < this.grid.length; x++) {
+ for (let y = 0; y < this.grid[x].length; y++) {
+ const space = this.grid[x][y];
+ if ((space & Space.SpecialActive1) == Space.SpecialInactive1) {
+ let anyEmptySpace = false;
+ for (let dy = -1; !anyEmptySpace && dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const x2 = x + dx;
+ const y2 = y + dy;
+ if (x2 >= 0 && x2 < this.grid.length && y2 >= 0 && y2 < this.grid[x2].length
+ && this.grid[x2][y2] == Space.Empty) {
+ anyEmptySpace = true;
+ break;
+ }
+ }
+ }
+ if (!anyEmptySpace) {
+ this.grid[x][y] |= Space.SpecialActive1;
+ specialSpacesActivated.push({ x, y });
+ }
+ }
+ }
+ }
+
+ return { placements, specialSpacesActivated };
+ }
}
diff --git a/TableturfBattleClient/src/GameVariables.ts b/TableturfBattleClient/src/GameVariables.ts
index f47dfbf..72bdaf7 100644
--- a/TableturfBattleClient/src/GameVariables.ts
+++ b/TableturfBattleClient/src/GameVariables.ts
@@ -9,11 +9,17 @@ let currentGame: {
maxPlayers: number,
/** The user's player data, or null if they are spectating. */
me: PlayerData | null,
+ turnNumber: number,
/** The WebSocket used for receiving game events, or null if not yet connected. */
- webSocket: WebSocket
-} | null;
+ webSocket: WebSocket | null
+} | null = null;
let enterGameTimeout: number | null = null;
+let currentReplay: {
+ turns: Move[][],
+ initialDrawOrder: number[][],
+ drawOrder: number[][]
+} | null = null;
const playerList = document.getElementById('playerList')!;
const playerListItems: HTMLElement[] = [ ];
diff --git a/TableturfBattleClient/src/Move.ts b/TableturfBattleClient/src/Move.ts
index cc765f3..1f24b9f 100644
--- a/TableturfBattleClient/src/Move.ts
+++ b/TableturfBattleClient/src/Move.ts
@@ -4,7 +4,7 @@ interface Move {
}
interface PlayMove extends Move {
- isPass: true;
+ isPass: false;
x: number;
y: number;
rotation: number;
diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts
index a7f292a..cdf2576 100644
--- a/TableturfBattleClient/src/Pages/GamePage.ts
+++ b/TableturfBattleClient/src/Pages/GamePage.ts
@@ -18,6 +18,11 @@ const redrawModal = document.getElementById('redrawModal')!;
const playControls = document.getElementById('playControls')!;
const resultContainer = document.getElementById('resultContainer')!;
const resultElement = document.getElementById('result')!;
+const replayControls = document.getElementById('replayControls')!;
+const replayNextButton = document.getElementById('replayNextButton')!;
+
+const shareReplayLinkButton = document.getElementById('shareReplayLinkButton') as HTMLButtonElement;
+let canShareReplay = false;
const playerBars = Array.from(document.getElementsByClassName('playerBar'), el => new PlayerBar(el as HTMLDivElement));
playerBars.sort((a, b) => a.playerIndex - b.playerIndex);
@@ -46,6 +51,68 @@ cols[4][21] = Space.SpecialInactive1;
cols[4][4] = Space.SpecialInactive2;
board.resize(cols);
+function initReplay() {
+ playControls.hidden = true;
+ resultContainer.hidden = true;
+ replayControls.hidden = false;
+ gameButtonsContainer.hidden = true;
+ canPlay = false;
+ showPage('game');
+ turnNumberLabel.setTurnNumber(1);
+}
+
+replayNextButton.addEventListener('click', _ => {
+ if (currentGame == null || currentReplay == null) return;
+
+ const moves = currentReplay.turns[currentGame.turnNumber - 1];
+ const result = board.makePlacements(moves);
+
+ let anySpecialAttacks = false;
+ // Show the cards that were played.
+ clearPlayContainers();
+ for (let i = 0; i < currentGame.players.length; i++) {
+ const player = currentGame.players[i];
+
+ const move = moves[i];
+ const button = new CardButton('checkbox', move.card);
+ if ((move as PlayMove).isSpecialAttack) {
+ anySpecialAttacks = true;
+ button.element.classList.add('specialAttack');
+ } else if (move.isPass) {
+ player.passes++;
+ player.specialPoints++;
+ player.totalSpecialPoints++;
+ const el = document.createElement('div');
+ el.className = 'passLabel';
+ el.innerText = 'Pass';
+ button.element.appendChild(el);
+ }
+ button.inputElement.hidden = true;
+ playContainers[i].append(button.element);
+ }
+
+ for (const p of result.specialSpacesActivated) {
+ const space = board.grid[p.x][p.y];
+ const player2 = currentGame.players[space & 3];
+ player2.specialPoints++;
+ player2.totalSpecialPoints++;
+ }
+ currentGame.turnNumber++;
+
+ (async () => {
+ await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.turnNumber, players: currentGame.players }, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, anySpecialAttacks);
+ turnNumberLabel.setTurnNumber(currentGame.turnNumber);
+ clearPlayContainers();
+ if (currentGame.turnNumber > 12) {
+ gameButtonsContainer.hidden = true;
+ passButton.enabled = false;
+ specialButton.enabled = false;
+ gamePage.classList.add('gameEnded');
+ showResult();
+ }
+ })();
+});
+
function loadPlayers(players: Player[]) {
gamePage.dataset.players = players.length.toString();
for (let i = 0; i < players.length; i++) {
@@ -59,6 +126,9 @@ function loadPlayers(players: Player[]) {
document.body.style.setProperty(`--special-accent-colour-${i + 1}`, `rgb(${player.specialAccentColour.r}, ${player.specialAccentColour.g}, ${player.specialAccentColour.b})`);
}
}
+ for (let i = 0; i < playerBars.length; i++) {
+ playerBars[i].visible = i < players.length;
+ }
}
function updateStats(playerIndex: number) {
@@ -112,11 +182,9 @@ function setupControlsForPlay() {
async function playInkAnimations(data: {
game: { state: GameState, board: Space[][] | null, turnNumber: number, players: Player[] },
- placements: { cards: { playerIndex: number, card: Card }[], spacesAffected: { space: { x: number, y: number }, newState: Space }[] }[],
- specialSpacesActivated: { x: number, y: number }[]
+ placements: Placement[],
+ specialSpacesActivated: Point[]
}, anySpecialAttacks: boolean) {
- if (!data.game.board) throw new Error("Board is null during game");
-
const inkPlaced = new Set
();
const placements = data.placements;
board.clearHighlight();
@@ -133,14 +201,14 @@ async function playInkAnimations(data: {
for (const p of placement.spacesAffected) {
inkPlaced.add(p.space.y * 37 + p.space.x);
- board.grid[p.space.x][p.space.y] = p.newState;
+ board.setDisplayedSpace(p.space.x, p.space.y, p.newState);
}
- board.refresh();
}
await delay(1000);
// Show special spaces.
- board.grid = data.game.board;
+ if (data.game.board)
+ board.grid = data.game.board;
board.refresh();
if (data.specialSpacesActivated.length > 0)
await delay(1000); // Delay if we expect that this changed the board.
@@ -157,8 +225,6 @@ async function playInkAnimations(data: {
function showResult() {
if (currentGame == null) return;
- playControls.hidden = true;
- resultContainer.hidden = false;
turnNumberLabel.setTurnNumber(null);
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
@@ -192,6 +258,13 @@ function showResult() {
el.innerText = 'Defeat';
}
}
+
+ if (!currentReplay) {
+ playControls.hidden = true;
+ resultContainer.hidden = false;
+ canShareReplay = navigator.canShare && navigator.canShare({ url: window.location.href, title: 'Tableturf Battle Replay' });
+ shareReplayLinkButton.innerText = canShareReplay ? 'Share replay link' : 'Copy replay link';
+ }
}
function clearShowDeck() {
@@ -357,14 +430,8 @@ board.onclick = (x, y) => {
return;
}
if (testMode) {
- for (let dy = 0; dy < 8; dy++) {
- for (let dx = 0; dx < 8; dx++) {
- let space = board.cardPlaying.getSpace(dx, dy, board.cardRotation);
- if (space != Space.Empty) {
- board.grid[x + dx][y + dy] = space;
- }
- }
- }
+ const move: PlayMove = { card: board.cardPlaying, isPass: false, x, y, rotation: board.cardRotation, isSpecialAttack: false };
+ const r = board.makePlacements([ move ]);
board.refresh();
} else if (canPlay) {
canPlay = false;
@@ -468,9 +535,41 @@ document.addEventListener('keydown', e => {
}
});
+shareReplayLinkButton.addEventListener('click', _ => {
+ shareReplayLinkButton.disabled = true;
+
+ let req = new XMLHttpRequest();
+ req.responseType = "arraybuffer";
+ req.open('GET', `${config.apiBaseUrl}/games/${currentGame!.id}/replay`);
+ req.addEventListener('load', _ => {
+ if (req.status == 200) {
+ const array = new Uint8Array(req.response as ArrayBuffer);
+ let base64 = Base64.base64EncArr(array);
+ base64 = base64.replaceAll('+', '-');
+ base64 = base64.replaceAll('/', '_');
+ const url = new URL(`${canPushState ? '' : '#'}replay/${base64}`, baseUrl);
+ shareReplayLinkButton.disabled = false;
+ if (canShareReplay) {
+ navigator.share({ url: url.href, title: 'Tableturf Battle Replay' });
+ } else {
+ navigator.clipboard.writeText(window.location.toString()).then(() => shareLinkButton.innerText = 'Copied');
+ }
+ }
+ });
+ req.send();
+});
+
document.getElementById('resultLeaveButton')!.addEventListener('click', e => {
e.preventDefault();
clearPreGameForm(true);
showPage('preGame');
newGameButton.focus();
});
+
+document.getElementById('replayLeaveButton')!.addEventListener('click', e => {
+ e.preventDefault();
+ clearPreGameForm(true);
+ showPage('preGame');
+ newGameButton.focus();
+ currentReplay = null;
+});
diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts
index 3067848..0748b2b 100644
--- a/TableturfBattleClient/src/Pages/PreGamePage.ts
+++ b/TableturfBattleClient/src/Pages/PreGamePage.ts
@@ -7,6 +7,7 @@ const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElem
const preGameDeckEditorButton = document.getElementById('preGameDeckEditorButton') as HTMLLinkElement;
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
+const preGameReplayButton = document.getElementById('preGameReplayButton') as HTMLLinkElement;
let shownMaxPlayersWarning = false;
@@ -159,6 +160,103 @@ preGameDeckEditorButton.addEventListener('click', e => {
setUrl('deckeditor');
});
+preGameReplayButton.addEventListener('click', e => {
+ e.preventDefault();
+
+ if (stageDatabase.stages == null)
+ throw new Error('Game data not loaded');
+
+ const s = prompt('Enter a replay link or code.');
+ if (!s) return;
+ const m = /(?:^|replay\/)([A-Za-z0-9+/=\-_]+)$/i.exec(s);
+ if (!m) {
+ alert('Not a valid replay code');
+ return;
+ }
+
+ let base64 = m[1];
+ 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: [ ], drawOrder: [ ], initialDrawOrder: [ ] };
+ 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 initialDrawOrder = [ ];
+ const drawOrder = [ ];
+ 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);
+ drawOrder.push(dataView.getUint8(pos + 26 + j) >> 4 & 0xF);
+ }
+ currentReplay.initialDrawOrder.push(initialDrawOrder);
+ currentReplay.drawOrder.push(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 });
+ else {
+ const move: PlayMove = { card: cardDatabase.get(cardNumber), isPass: false, x, y, rotation: b & 0x03, isSpecialAttack: (b & 0x40) != 0 };
+ turn.push(move);
+ }
+ pos += 4;
+ }
+ currentReplay.turns.push(turn);
+ }
+
+ currentGame = {
+ id: 'replay',
+ me: null,
+ players: players,
+ maxPlayers: numPlayers,
+ turnNumber: 1,
+ webSocket: null
+ };
+
+ board.resize(stage.grid);
+ 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);
+ initReplay();
+});
+
let playerName = localStorage.getItem('name');
(document.getElementById('nameBox') as HTMLInputElement).value = playerName || '';
diff --git a/TableturfBattleClient/src/Placement.ts b/TableturfBattleClient/src/Placement.ts
new file mode 100644
index 0000000..25d210f
--- /dev/null
+++ b/TableturfBattleClient/src/Placement.ts
@@ -0,0 +1,4 @@
+interface Placement {
+ players: number[],
+ spacesAffected: { space: Point, newState: Space }[]
+}
diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts
index 1777511..8917ab3 100644
--- a/TableturfBattleClient/src/app.ts
+++ b/TableturfBattleClient/src/app.ts
@@ -1,5 +1,7 @@
///
+declare var baseUrl: string;
+
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
const errorMessage = document.getElementById('errorMessage')!;
const errorDialogForm = document.getElementById('errorDialogForm') as HTMLFormElement;
@@ -190,6 +192,7 @@ function setupWebSocket(gameID: string, myPlayerIndex: number | null) {
me: payload.playerData,
players: payload.data.players,
maxPlayers: payload.data.maxPlayers,
+ turnNumber: payload.data.turnNumber,
webSocket: webSocket
};
diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css
index e4a42f3..4f55c52 100644
--- a/TableturfBattleClient/tableturf.css
+++ b/TableturfBattleClient/tableturf.css
@@ -468,7 +468,7 @@ dialog::backdrop {
align-items: center;
}
-#playControls, #resultContainer {
+#playControls, #resultContainer, #replayControls {
grid-column: 1 / span 2;
grid-row: hand-row;
align-self: center;
@@ -1054,7 +1054,7 @@ dialog::backdrop {
grid-template-rows: [button-row] auto [hand-row] auto;
}
- #playControls, #resultContainer {
+ #playControls, #resultContainer, #replayControls {
grid-column: 1 / -1;
grid-row: 5;
width: initial;
diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs
index 246d963..178b79b 100644
--- a/TableturfBattleServer/Game.cs
+++ b/TableturfBattleServer/Game.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
+using System.Text;
using Newtonsoft.Json;
@@ -180,6 +181,11 @@ public class Game {
if (this.Board == null) throw new InvalidOperationException("No board?!");
+ 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, IsSpecialAttack = move.IsSpecialAttack });
+ }
+
// Place the ink.
(Placement placement, int cardSize)? placementData = null;
foreach (var i in Enumerable.Range(0, this.Players.Count).Where(i => this.Players[i] != null).OrderByDescending(i => this.Players[i]!.Move!.Card.Size)) {
@@ -306,4 +312,44 @@ public class Game {
}
}
}
+
+ public void WriteReplayData(Stream stream) {
+ const int VERSION = 1;
+
+ if (this.State < GameState.Ended)
+ throw new InvalidOperationException("Can't save a replay until the game 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)));
+ foreach (var player in this.Players) {
+ writer.Write((byte) player.Colour.R);
+ writer.Write((byte) player.Colour.G);
+ writer.Write((byte) player.Colour.B);
+ writer.Write((byte) player.SpecialColour.R);
+ writer.Write((byte) player.SpecialColour.G);
+ writer.Write((byte) player.SpecialColour.B);
+ 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]));
+ for (int i = 0; i < 15; i += 2)
+ writer.Write((byte) (player.drawOrder![i] | (i < 14 ? player.drawOrder[i + 1] << 4 : 0)));
+ writer.Write(player.Name);
+ }
+ for (int i = 0; i < 12; i++) {
+ foreach (var player in this.Players) {
+ var move = player.turns[i];
+ writer.Write((byte) move.CardNumber);
+ writer.Write((byte) (move.Rotation | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0)));
+ writer.Write((sbyte) move.X);
+ writer.Write((sbyte) move.Y);
+ }
+ }
+ }
}
diff --git a/TableturfBattleServer/Player.cs b/TableturfBattleServer/Player.cs
index ccc1e76..45f02ec 100644
--- a/TableturfBattleServer/Player.cs
+++ b/TableturfBattleServer/Player.cs
@@ -31,6 +31,9 @@ public class Player {
_ => this.Move != null
};
+ [JsonIgnore]
+ internal List turns = new(12);
+
[JsonIgnore]
private readonly Game game;
[JsonIgnore]
@@ -42,6 +45,8 @@ public class Player {
[JsonIgnore]
internal Move? Move;
[JsonIgnore]
+ internal int[]? initialDrawOrder;
+ [JsonIgnore]
internal int[]? drawOrder;
[JsonIgnore]
internal int? selectedStageIndex;
@@ -55,6 +60,7 @@ public class Player {
[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;
for (int i = 14; i > 0; i--) {
var j = random.Next(i);
diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs
index 1258b29..182e200 100644
--- a/TableturfBattleServer/Program.cs
+++ b/TableturfBattleServer/Program.cs
@@ -426,6 +426,21 @@ internal class Program {
}
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."));
+ return;
+ }
+ var ms = new MemoryStream();
+ game.WriteReplayData(ms);
+ SetResponse(e.Response, HttpStatusCode.OK, "application/octet-stream", ms.ToArray());
+ }
+ break;
+ }
default:
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
break;
diff --git a/TableturfBattleServer/ReplayTurn.cs b/TableturfBattleServer/ReplayTurn.cs
new file mode 100644
index 0000000..470bce0
--- /dev/null
+++ b/TableturfBattleServer/ReplayTurn.cs
@@ -0,0 +1,9 @@
+namespace TableturfBattleServer;
+public struct ReplayTurn {
+ public int CardNumber;
+ public int X;
+ public int Y;
+ public int Rotation;
+ public bool IsPass;
+ public bool IsSpecialAttack;
+}