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 @@ Tableturf Battle @@ -49,7 +50,8 @@ Create or join a different room

- Edit decks + Edit decks | + Replay

+
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; +}