mirror of
https://github.com/AndrioCelos/TableturfBattleApp.git
synced 2026-04-26 09:59:59 -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"/>
|
<meta charset="UTF-8"/>
|
||||||
<base href="https://tableturf.andriocelos.net/tableturf/"/>
|
<base href="https://tableturf.andriocelos.net/tableturf/"/>
|
||||||
<script>
|
<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>
|
</script>
|
||||||
<title>Tableturf Battle</title>
|
<title>Tableturf Battle</title>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
|
<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>
|
<a id="preGameBackButton" href="../..">Create or join a different room</a>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
<div id="preGameLoadingSection" class="loadingContainer" hidden>
|
<div id="preGameLoadingSection" class="loadingContainer" hidden>
|
||||||
<div class="lds-ripple"><div></div><div></div></div> <span id="preGameLoadingLabel"></span>
|
<div class="lds-ripple"><div></div><div></div></div> <span id="preGameLoadingLabel"></span>
|
||||||
|
|
@ -148,8 +150,14 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultContainer" hidden>
|
<div id="resultContainer" hidden>
|
||||||
|
<button id="shareReplayLinkButton">Copy replay link</button>
|
||||||
|
<br/>
|
||||||
<a id="resultLeaveButton" href="#">Leave game</a>
|
<a id="resultLeaveButton" href="#">Leave game</a>
|
||||||
</div>
|
</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="playerBar" data-index="0">
|
||||||
<div class="result"></div>
|
<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) {
|
getScore(playerIndex: number) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let x = 0; x < this.grid.length; x++) {
|
for (let x = 0; x < this.grid.length; x++) {
|
||||||
|
|
@ -300,4 +304,94 @@ class Board {
|
||||||
}
|
}
|
||||||
return false;
|
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,
|
maxPlayers: number,
|
||||||
/** The user's player data, or null if they are spectating. */
|
/** The user's player data, or null if they are spectating. */
|
||||||
me: PlayerData | null,
|
me: PlayerData | null,
|
||||||
|
turnNumber: number,
|
||||||
/** The WebSocket used for receiving game events, or null if not yet connected. */
|
/** The WebSocket used for receiving game events, or null if not yet connected. */
|
||||||
webSocket: WebSocket
|
webSocket: WebSocket | null
|
||||||
} | null;
|
} | null = null;
|
||||||
|
|
||||||
let enterGameTimeout: number | null = null;
|
let enterGameTimeout: number | null = null;
|
||||||
|
let currentReplay: {
|
||||||
|
turns: Move[][],
|
||||||
|
initialDrawOrder: number[][],
|
||||||
|
drawOrder: number[][]
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
const playerList = document.getElementById('playerList')!;
|
const playerList = document.getElementById('playerList')!;
|
||||||
const playerListItems: HTMLElement[] = [ ];
|
const playerListItems: HTMLElement[] = [ ];
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ interface Move {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayMove extends Move {
|
interface PlayMove extends Move {
|
||||||
isPass: true;
|
isPass: false;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ const redrawModal = document.getElementById('redrawModal')!;
|
||||||
const playControls = document.getElementById('playControls')!;
|
const playControls = document.getElementById('playControls')!;
|
||||||
const resultContainer = document.getElementById('resultContainer')!;
|
const resultContainer = document.getElementById('resultContainer')!;
|
||||||
const resultElement = document.getElementById('result')!;
|
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));
|
const playerBars = Array.from(document.getElementsByClassName('playerBar'), el => new PlayerBar(el as HTMLDivElement));
|
||||||
playerBars.sort((a, b) => a.playerIndex - b.playerIndex);
|
playerBars.sort((a, b) => a.playerIndex - b.playerIndex);
|
||||||
|
|
@ -46,6 +51,68 @@ cols[4][21] = Space.SpecialInactive1;
|
||||||
cols[4][4] = Space.SpecialInactive2;
|
cols[4][4] = Space.SpecialInactive2;
|
||||||
board.resize(cols);
|
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[]) {
|
function loadPlayers(players: Player[]) {
|
||||||
gamePage.dataset.players = players.length.toString();
|
gamePage.dataset.players = players.length.toString();
|
||||||
for (let i = 0; i < players.length; i++) {
|
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})`);
|
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) {
|
function updateStats(playerIndex: number) {
|
||||||
|
|
@ -112,11 +182,9 @@ function setupControlsForPlay() {
|
||||||
|
|
||||||
async function playInkAnimations(data: {
|
async function playInkAnimations(data: {
|
||||||
game: { state: GameState, board: Space[][] | null, turnNumber: number, players: Player[] },
|
game: { state: GameState, board: Space[][] | null, turnNumber: number, players: Player[] },
|
||||||
placements: { cards: { playerIndex: number, card: Card }[], spacesAffected: { space: { x: number, y: number }, newState: Space }[] }[],
|
placements: Placement[],
|
||||||
specialSpacesActivated: { x: number, y: number }[]
|
specialSpacesActivated: Point[]
|
||||||
}, anySpecialAttacks: boolean) {
|
}, anySpecialAttacks: boolean) {
|
||||||
if (!data.game.board) throw new Error("Board is null during game");
|
|
||||||
|
|
||||||
const inkPlaced = new Set<number>();
|
const inkPlaced = new Set<number>();
|
||||||
const placements = data.placements;
|
const placements = data.placements;
|
||||||
board.clearHighlight();
|
board.clearHighlight();
|
||||||
|
|
@ -133,14 +201,14 @@ async function playInkAnimations(data: {
|
||||||
|
|
||||||
for (const p of placement.spacesAffected) {
|
for (const p of placement.spacesAffected) {
|
||||||
inkPlaced.add(p.space.y * 37 + p.space.x);
|
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);
|
await delay(1000);
|
||||||
|
|
||||||
// Show special spaces.
|
// Show special spaces.
|
||||||
board.grid = data.game.board;
|
if (data.game.board)
|
||||||
|
board.grid = data.game.board;
|
||||||
board.refresh();
|
board.refresh();
|
||||||
if (data.specialSpacesActivated.length > 0)
|
if (data.specialSpacesActivated.length > 0)
|
||||||
await delay(1000); // Delay if we expect that this changed the board.
|
await delay(1000); // Delay if we expect that this changed the board.
|
||||||
|
|
@ -157,8 +225,6 @@ async function playInkAnimations(data: {
|
||||||
|
|
||||||
function showResult() {
|
function showResult() {
|
||||||
if (currentGame == null) return;
|
if (currentGame == null) return;
|
||||||
playControls.hidden = true;
|
|
||||||
resultContainer.hidden = false;
|
|
||||||
turnNumberLabel.setTurnNumber(null);
|
turnNumberLabel.setTurnNumber(null);
|
||||||
|
|
||||||
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
|
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
|
||||||
|
|
@ -192,6 +258,13 @@ function showResult() {
|
||||||
el.innerText = 'Defeat';
|
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() {
|
function clearShowDeck() {
|
||||||
|
|
@ -357,14 +430,8 @@ board.onclick = (x, y) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (testMode) {
|
if (testMode) {
|
||||||
for (let dy = 0; dy < 8; dy++) {
|
const move: PlayMove = { card: board.cardPlaying, isPass: false, x, y, rotation: board.cardRotation, isSpecialAttack: false };
|
||||||
for (let dx = 0; dx < 8; dx++) {
|
const r = board.makePlacements([ move ]);
|
||||||
let space = board.cardPlaying.getSpace(dx, dy, board.cardRotation);
|
|
||||||
if (space != Space.Empty) {
|
|
||||||
board.grid[x + dx][y + dy] = space;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
board.refresh();
|
board.refresh();
|
||||||
} else if (canPlay) {
|
} else if (canPlay) {
|
||||||
canPlay = false;
|
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 => {
|
document.getElementById('resultLeaveButton')!.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearPreGameForm(true);
|
clearPreGameForm(true);
|
||||||
showPage('preGame');
|
showPage('preGame');
|
||||||
newGameButton.focus();
|
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 preGameDeckEditorButton = document.getElementById('preGameDeckEditorButton') as HTMLLinkElement;
|
||||||
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
|
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
|
||||||
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
|
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
|
||||||
|
const preGameReplayButton = document.getElementById('preGameReplayButton') as HTMLLinkElement;
|
||||||
|
|
||||||
let shownMaxPlayersWarning = false;
|
let shownMaxPlayersWarning = false;
|
||||||
|
|
||||||
|
|
@ -159,6 +160,103 @@ preGameDeckEditorButton.addEventListener('click', e => {
|
||||||
setUrl('deckeditor');
|
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');
|
let playerName = localStorage.getItem('name');
|
||||||
(document.getElementById('nameBox') as HTMLInputElement).value = playerName || '';
|
(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"/>
|
/// <reference path="Pages/PreGamePage.ts"/>
|
||||||
|
|
||||||
|
declare var baseUrl: string;
|
||||||
|
|
||||||
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
|
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
|
||||||
const errorMessage = document.getElementById('errorMessage')!;
|
const errorMessage = document.getElementById('errorMessage')!;
|
||||||
const errorDialogForm = document.getElementById('errorDialogForm') as HTMLFormElement;
|
const errorDialogForm = document.getElementById('errorDialogForm') as HTMLFormElement;
|
||||||
|
|
@ -190,6 +192,7 @@ function setupWebSocket(gameID: string, myPlayerIndex: number | null) {
|
||||||
me: payload.playerData,
|
me: payload.playerData,
|
||||||
players: payload.data.players,
|
players: payload.data.players,
|
||||||
maxPlayers: payload.data.maxPlayers,
|
maxPlayers: payload.data.maxPlayers,
|
||||||
|
turnNumber: payload.data.turnNumber,
|
||||||
webSocket: webSocket
|
webSocket: webSocket
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -468,7 +468,7 @@ dialog::backdrop {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playControls, #resultContainer {
|
#playControls, #resultContainer, #replayControls {
|
||||||
grid-column: 1 / span 2;
|
grid-column: 1 / span 2;
|
||||||
grid-row: hand-row;
|
grid-row: hand-row;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
@ -1054,7 +1054,7 @@ dialog::backdrop {
|
||||||
grid-template-rows: [button-row] auto [hand-row] auto;
|
grid-template-rows: [button-row] auto [hand-row] auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#playControls, #resultContainer {
|
#playControls, #resultContainer, #replayControls {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 5;
|
grid-row: 5;
|
||||||
width: initial;
|
width: initial;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
|
@ -180,6 +181,11 @@ public class Game {
|
||||||
|
|
||||||
if (this.Board == null) throw new InvalidOperationException("No board?!");
|
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.
|
// Place the ink.
|
||||||
(Placement placement, int cardSize)? placementData = null;
|
(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)) {
|
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
|
_ => this.Move != null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal List<ReplayTurn> turns = new(12);
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
private readonly Game game;
|
private readonly Game game;
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
|
@ -42,6 +45,8 @@ public class Player {
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal Move? Move;
|
internal Move? Move;
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
internal int[]? initialDrawOrder;
|
||||||
|
[JsonIgnore]
|
||||||
internal int[]? drawOrder;
|
internal int[]? drawOrder;
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
internal int? selectedStageIndex;
|
internal int? selectedStageIndex;
|
||||||
|
|
@ -55,6 +60,7 @@ public class Player {
|
||||||
[MemberNotNull(nameof(drawOrder))]
|
[MemberNotNull(nameof(drawOrder))]
|
||||||
internal void Shuffle(Random random) {
|
internal void Shuffle(Random random) {
|
||||||
this.drawOrder = new int[15];
|
this.drawOrder = new int[15];
|
||||||
|
this.initialDrawOrder ??= this.drawOrder;
|
||||||
for (int i = 0; i < 15; i++) this.drawOrder[i] = i;
|
for (int i = 0; i < 15; i++) this.drawOrder[i] = i;
|
||||||
for (int i = 14; i > 0; i--) {
|
for (int i = 14; i > 0; i--) {
|
||||||
var j = random.Next(i);
|
var j = random.Next(i);
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,21 @@ internal class Program {
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
|
SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found."));
|
||||||
break;
|
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