mirror of
https://github.com/AndrioCelos/TableturfBattleApp.git
synced 2026-03-21 17:34:28 -05:00
#5 Basic replay implementation
This commit is contained in:
parent
4113b150c4
commit
bb75c238a8
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
<meta charset="UTF-8"/>
|
||||
<base href="https://tableturf.andriocelos.net/tableturf/"/>
|
||||
<script>
|
||||
document.getElementsByTagName('base')[0].href = window.location.toString().replace(/\/(?:index.html|deckeditor|game\/[^/]*)$/, '/');
|
||||
baseUrl = window.location.toString().replace(/\/(?:index.html|deckeditor|game\/[^/]*|replay\/[^/]*)$/, '/');
|
||||
document.getElementsByTagName('base')[0].href = baseUrl;
|
||||
</script>
|
||||
<title>Tableturf Battle</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
|
||||
|
|
@ -49,7 +50,8 @@
|
|||
<a id="preGameBackButton" href="../..">Create or join a different room</a>
|
||||
</div>
|
||||
<p>
|
||||
<a id="preGameDeckEditorButton" href="deckeditor">Edit decks</a>
|
||||
<a id="preGameDeckEditorButton" href="deckeditor">Edit decks</a> |
|
||||
<a id="preGameReplayButton" href="replay">Replay</a>
|
||||
</p>
|
||||
<div id="preGameLoadingSection" class="loadingContainer" hidden>
|
||||
<div class="lds-ripple"><div></div><div></div></div> <span id="preGameLoadingLabel"></span>
|
||||
|
|
@ -148,8 +150,14 @@
|
|||
</label>
|
||||
</div>
|
||||
<div id="resultContainer" hidden>
|
||||
<button id="shareReplayLinkButton">Copy replay link</button>
|
||||
<br/>
|
||||
<a id="resultLeaveButton" href="#">Leave game</a>
|
||||
</div>
|
||||
<div id="replayControls" hidden>
|
||||
<button id="replayNextButton">Next turn</button>
|
||||
<a id="replayLeaveButton" href="#">Exit replay</a>
|
||||
</div>
|
||||
|
||||
<div class="playerBar" data-index="0">
|
||||
<div class="result"></div>
|
||||
|
|
|
|||
90
TableturfBattleClient/src/Base64.ts
Normal file
90
TableturfBattleClient/src/Base64.ts
Normal file
|
|
@ -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 ? "=" : "==")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [ ];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ interface Move {
|
|||
}
|
||||
|
||||
interface PlayMove extends Move {
|
||||
isPass: true;
|
||||
isPass: false;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
|
|
|
|||
|
|
@ -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<number>();
|
||||
const placements = data.placements;
|
||||
board.clearHighlight();
|
||||
|
|
@ -133,13 +201,13 @@ 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.
|
||||
if (data.game.board)
|
||||
board.grid = data.game.board;
|
||||
board.refresh();
|
||||
if (data.specialSpacesActivated.length > 0)
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
|
||||
|
|
|
|||
4
TableturfBattleClient/src/Placement.ts
Normal file
4
TableturfBattleClient/src/Placement.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
interface Placement {
|
||||
players: number[],
|
||||
spacesAffected: { space: Point, newState: Space }[]
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
/// <reference path="Pages/PreGamePage.ts"/>
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ public class Player {
|
|||
_ => this.Move != null
|
||||
};
|
||||
|
||||
[JsonIgnore]
|
||||
internal List<ReplayTurn> 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
9
TableturfBattleServer/ReplayTurn.cs
Normal file
9
TableturfBattleServer/ReplayTurn.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user