Card art and sleeves update

This commit is contained in:
Andrio Celos 2023-10-10 09:31:50 +11:00
parent a9a7249f59
commit deb8a673ab
43 changed files with 1314 additions and 681 deletions

3
.gitmodules vendored
View File

@ -4,3 +4,6 @@
[submodule "TableturfBattleClient/mobile-drag-drop"]
path = TableturfBattleClient/mobile-drag-drop
url = https://github.com/timruffles/mobile-drag-drop.git
[submodule "TableturfBattleClient/js-untar"]
path = TableturfBattleClient/js-untar
url = https://github.com/InvokIT/js-untar

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -16,6 +16,7 @@
<script src="config/config.js"></script>
<script src="qrcodejs/qrcode.js"></script>
<script src="mobile-drag-drop/release/index.min.js"></script>
<script src="js-untar/build/dist/untar.js"></script>
<script>
// Enable a polyfill for drag/drop support on mobile Firefox.
MobileDragDrop.polyfill({ dragImageTranslateOverride: MobileDragDrop.scrollBehaviourDragImageTranslateOverride });
@ -429,6 +430,7 @@
<button type="button" id="deckListTestButton" disabled>Test</button>
<button type="button" id="deckExportButton" disabled>Export</button>
<button type="button" id="deckCopyButton" disabled>Copy</button>
<button type="button" id="deckSleevesButton" disabled>Sleeves</button>
<button type="button" id="deckEditButton" disabled>Edit</button>
<button type="button" id="deckRenameButton" disabled>Rename</button>
<button type="button" id="deckDeleteButton" class="danger" disabled>Delete</button>
@ -437,6 +439,41 @@
<div id="deckCardListView">
</div>
</section>
<dialog id="deckSleevesDialog">
<form id="deckSleevesForm" method="dialog">
<section id="deckSleevesList">
<input type="radio" name="deckSleeves" id="deckSleeve0" value="0"/><label for="deckSleeve0"></label>
<input type="radio" name="deckSleeves" id="deckSleeve1" value="1"/><label for="deckSleeve1"></label>
<input type="radio" name="deckSleeves" id="deckSleeve2" value="2"/><label for="deckSleeve2"></label>
<input type="radio" name="deckSleeves" id="deckSleeve3" value="3"/><label for="deckSleeve3"></label>
<input type="radio" name="deckSleeves" id="deckSleeve4" value="4"/><label for="deckSleeve4"></label>
<input type="radio" name="deckSleeves" id="deckSleeve5" value="5"/><label for="deckSleeve5"></label>
<input type="radio" name="deckSleeves" id="deckSleeve6" value="6"/><label for="deckSleeve6"></label>
<input type="radio" name="deckSleeves" id="deckSleeve7" value="7"/><label for="deckSleeve7"></label>
<input type="radio" name="deckSleeves" id="deckSleeve8" value="8"/><label for="deckSleeve8"></label>
<input type="radio" name="deckSleeves" id="deckSleeve9" value="9"/><label for="deckSleeve9"></label>
<input type="radio" name="deckSleeves" id="deckSleeve10" value="10"/><label for="deckSleeve10"></label>
<input type="radio" name="deckSleeves" id="deckSleeve11" value="11"/><label for="deckSleeve11"></label>
<input type="radio" name="deckSleeves" id="deckSleeve12" value="12"/><label for="deckSleeve12"></label>
<input type="radio" name="deckSleeves" id="deckSleeve13" value="13"/><label for="deckSleeve13"></label>
<input type="radio" name="deckSleeves" id="deckSleeve14" value="14"/><label for="deckSleeve14"></label>
<input type="radio" name="deckSleeves" id="deckSleeve15" value="15"/><label for="deckSleeve15"></label>
<input type="radio" name="deckSleeves" id="deckSleeve16" value="16"/><label for="deckSleeve16"></label>
<input type="radio" name="deckSleeves" id="deckSleeve17" value="17"/><label for="deckSleeve17"></label>
<input type="radio" name="deckSleeves" id="deckSleeve18" value="18"/><label for="deckSleeve18"></label>
<input type="radio" name="deckSleeves" id="deckSleeve19" value="19"/><label for="deckSleeve19"></label>
<input type="radio" name="deckSleeves" id="deckSleeve20" value="20"/><label for="deckSleeve20"></label>
<input type="radio" name="deckSleeves" id="deckSleeve21" value="21"/><label for="deckSleeve21"></label>
<input type="radio" name="deckSleeves" id="deckSleeve22" value="22"/><label for="deckSleeve22"></label>
<input type="radio" name="deckSleeves" id="deckSleeve23" value="23"/><label for="deckSleeve23"></label>
<input type="radio" name="deckSleeves" id="deckSleeve24" value="24"/><label for="deckSleeve24"></label>
</section>
<section id="deckSleevesFormButtons">
<button type="submit" id="deckSleevesOkButton">Select</button>
<button type="submit" id="deckSleevesCancelButton">Cancel</button>
</section>
</form>
</dialog>
<dialog id="deckImportDialog">
<form id="deckImportForm" method="dialog">
<label for="deckImportTextButton">

@ -0,0 +1 @@
Subproject commit 49e639cf82e8d58dccb3458cbd08768afee8b41c

View File

@ -196,7 +196,7 @@ class Board {
if (space == Space.Wall || space == Space.OutOfBounds)
return 'It cannot be over a wall.';
if (space >= Space.SpecialInactive1)
return `It cannot be over${' <div class="playHintSpecial" aria-label="Special space">&nbsp;</div>'.repeat(Math.max(currentGame?.players?.length ?? 0, 2))}.`;
return `It cannot be over${' <div class="playHintSpecial" aria-label="Special space">&nbsp;</div>'.repeat(Math.max(currentGame?.game.players.length ?? 0, 2))}.`;
if (space != Space.Empty && !isSpecialAttack)
return 'It cannot be over inked spaces.';
if (!isAnchored) {

View File

@ -2,6 +2,13 @@ class Card {
number: number;
altNumber: number | null;
name: string;
line1: string | null;
line2: string | null;
artFileName: string | null;
imageUrl?: string;
textScale: number;
inkColour1: Colour;
inkColour2: Colour;
rarity: Rarity;
specialCost: number;
grid: readonly (readonly Space[])[];
@ -12,10 +19,19 @@ class Card {
private maxX: number;
private maxY: number;
constructor(number: number, altNumber: number | null, name: string, rarity: Rarity, specialCost: number, grid: Space[][]) {
private static DEFAULT_INK_COLOUR_1: Colour = { r: 116, g: 96, b: 240 };
private static DEFAULT_INK_COLOUR_2: Colour = { r: 224, g: 242, b: 104 };
constructor(number: number, altNumber: number | null, name: string, line1: string | null, line2: string | null, artFileName: string | null, textScale: number, inkColour1: Colour, inkColour2: Colour, rarity: Rarity, specialCost: number, grid: Space[][]) {
this.number = number;
this.altNumber = altNumber;
this.name = name;
this.line1 = line1;
this.line2 = line2;
this.artFileName = artFileName;
this.textScale = textScale;
this.inkColour1 = inkColour1;
this.inkColour2 = inkColour2;
this.rarity = rarity;
this.specialCost = specialCost;
this.grid = grid;
@ -40,7 +56,9 @@ class Card {
}
static fromJson(obj: any) {
return new Card(obj.number, obj.altNumber ?? null, obj.name, obj.rarity, obj.specialCost, obj.grid);
return cardDatabase.cards && cardDatabase.isValidCardNumber(obj.number)
? cardDatabase.get(obj.number)
: new Card(obj.number, obj.altNumber ?? null, obj.name, obj.line1 ?? null, obj.line2 ?? null, obj.artFileName ?? null, obj.textScale ?? 1, obj.inkColour1 ?? this.DEFAULT_INK_COLOUR_1, obj.inkColour2 ?? this.DEFAULT_INK_COLOUR_2, obj.rarity, obj.specialCost, obj.grid);
}
get isUpcoming() { return this.number < 0; }

View File

@ -8,7 +8,7 @@ class CardButton extends CheckButton {
constructor(card: Card) {
let button = document.createElement('button');
button.type = 'button';
button.classList.add('card');
button.classList.add('cardButton');
button.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
if (card.number < 0) button.classList.add('upcoming');
button.dataset.cardNumber = card.number.toString();
@ -35,6 +35,13 @@ class CardButton extends CheckButton {
}
}
if (card.imageUrl) {
const bgDiv = document.createElement('div');
bgDiv.setAttribute('class', 'cardArt');
bgDiv.style.backgroundImage = `url(${card.imageUrl})`;
button.appendChild(bgDiv);
}
let row = document.createElement('div');
row.className = 'cardHeader';
button.appendChild(row);

View File

@ -28,7 +28,7 @@ const cardDatabase = {
const cardListRequest = new XMLHttpRequest();
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
cardListRequest.addEventListener('load', e => {
const cards = [ ];
const cards: Card[] = [ ];
if (cardListRequest.status == 200) {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
@ -40,7 +40,38 @@ const cardDatabase = {
else if (card.altNumber != null && card.altNumber < 0) cardDatabase._byAltNumber[-card.altNumber] = card;
}
cardDatabase.cards = cards;
resolve(cards);
if (window.location.protocol == 'file:') {
// If debugging locally, just read the files from the assets directory.
for (const card of cardDatabase.cards!) {
card.imageUrl = `assets/external/card/${card.artFileName}.webp`
}
resolve(cards);
return;
}
// Otherwise, download and extract card images from a .tar package.
const imagesRequest = new XMLHttpRequest();
imagesRequest.responseType = 'arraybuffer';
imagesRequest.open('GET', 'assets/external/card.tar');
imagesRequest.addEventListener('load', () => {
if (imagesRequest.status == 200 && imagesRequest.response) {
const buffer = imagesRequest.response as ArrayBuffer;
untar(buffer).then(files => {
for (const tarFile of files) {
const card = cardDatabase.cards!.find(c => tarFile.name == `${c.artFileName}.webp`);
if (!card) continue;
card.imageUrl = tarFile.getBlobUrl();
}
resolve(cards);
});
} else {
reject(new Error(`Error downloading card images: response was ${imagesRequest.status}`));
}
});
imagesRequest.addEventListener('error', () => {
reject(new Error('Error downloading card images: no further information.'))
});
imagesRequest.send();
} else {
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
}

View File

@ -0,0 +1,165 @@
class CardDisplay {
readonly card: Card;
readonly element: SVGSVGElement;
constructor(card: Card, level: number) {
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 635 885');
svg.setAttribute('alt', card.name);
this.element = svg;
svg.classList.add('card');
svg.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
if (card.number < 0) svg.classList.add('upcoming');
svg.dataset.cardNumber = card.number.toString();
svg.style.setProperty("--number", card.number.toString());
// Background
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', `assets/CardBackground-${card.rarity}-${level > 0 ? '1' : '0'}.webp`);
image.setAttribute('width', '100%');
image.setAttribute('height', '100%');
svg.appendChild(image);
if (level == 0) {
svg.insertAdjacentHTML('beforeend', `<image href="assets/external/CardInk.webp" width="635" height="885" clip-path="url(#myClip)"/>`);
} else {
svg.insertAdjacentHTML('beforeend', `
<filter id="ink1-${card.number}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${card.inkColour1.r / 255} 0 0 0 0 0 ${card.inkColour1.g / 255} 0 0 0 0 0 ${card.inkColour1.b / 255} 0 0 0 0 0 0.88 0"/></filter>
<image href="assets/external/CardInk-1.webp" width="635" height="885" clip-path="url(#myClip)" filter="url(#ink1-${card.number})"/>
<filter id="ink2-${card.number}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${card.inkColour2.r / 255} 0 0 0 0 0 ${card.inkColour2.g / 255} 0 0 0 0 0 ${card.inkColour2.b / 255} 0 0 0 0 0 0.88 0"/></filter>
<image href="assets/external/CardInk-2.webp" width="635" height="885" clip-path="url(#myClip)" filter="url(#ink2-${card.number})"/>
`);
}
// Art
if (card.imageUrl) {
const image2 = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image2.setAttribute('class', 'cardArt');
image2.setAttribute('href', card.imageUrl);
image2.setAttribute('width', '100%');
image2.setAttribute('height', '100%');
svg.appendChild(image2);
}
// Grid
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', 'translate(380 604) rotate(6.5) scale(0.283)');
svg.appendChild(g);
let size = 0;
for (var y = 0; y < 8; y++) {
for (var x = 0; x < 8; x++) {
if (card.grid[x][y] == Space.Empty) {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.classList.add('empty');
rect.setAttribute('x', (100 * x + 3).toString());
rect.setAttribute('y', (100 * y + 3).toString());
rect.setAttribute('width', '94');
rect.setAttribute('height', '94');
g.appendChild(rect);
} else {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
const elements: Element[] = [ rect ];
size++;
rect.classList.add(card.grid[x][y] == Space.SpecialInactive1 ? 'special' : 'ink');
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', card.grid[x][y] == Space.SpecialInactive1 ? 'assets/SpecialOverlay.png' : 'assets/InkOverlay.png');
elements.push(image);
for (const el of elements) {
el.setAttribute('x', (100 * x).toString());
el.setAttribute('y', (100 * y).toString());
el.setAttribute('width', '100');
el.setAttribute('height', '100');
g.appendChild(el);
}
}
}
}
// Name
const text1 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text1.setAttribute('class', 'cardDisplayName');
text1.setAttribute('x', '50%');
text1.setAttribute('y', '168');
text1.setAttribute('font-size', '76');
text1.setAttribute('font-weight', 'bold');
text1.setAttribute('stroke', 'black');
text1.setAttribute('stroke-width', '15');
text1.setAttribute('stroke-linejoin', 'round');
text1.setAttribute('paint-order', 'stroke');
text1.setAttribute('word-spacing', '-10');
text1.setAttribute('transform-origin', 'center');
text1.setAttribute('transform', `scale(${card.textScale} 1)`);
switch (card.rarity) {
case Rarity.Common:
text1.setAttribute('fill', '#6038FF');
break;
case Rarity.Rare:
svg.insertAdjacentHTML('beforeend', `
<linearGradient id='rareGradient' y1='25%' spreadMethod='reflect'>
<stop offset='0%' stop-color='#FEF9C6'/>
<stop offset='50%' stop-color='#DFAF17'/>
<stop offset='100%' stop-color='#FEF9C6'/>
</linearGradient>
`);
text1.setAttribute('fill', 'url("#rareGradient")');
break;
case Rarity.Fresh:
svg.insertAdjacentHTML('beforeend', `
<linearGradient id='freshGradient' y2='25%'>
<stop offset='0%' stop-color='#FF8EDD'/>
<stop offset='25%' stop-color='#FFEC9F'/>
<stop offset='50%' stop-color='#B84386'/>
<stop offset='75%' stop-color='#2BEFC8'/>
<stop offset='100%' stop-color='#FF8EDD'/>
</linearGradient>
`);
text1.setAttribute('fill', 'url("#freshGradient")');
break;
}
if (card.line1 && card.line2) {
const tspan1 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan1.setAttribute('y', '122');
tspan1.appendChild(document.createTextNode(card.line1));
text1.appendChild(tspan1);
const tspan2 = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan2.setAttribute('x', '50%');
tspan2.setAttribute('y', '216');
tspan2.appendChild(document.createTextNode(card.line2));
text1.appendChild(tspan2);
} else
text1.innerHTML = card.name;
svg.appendChild(text1);
// Size
svg.insertAdjacentHTML('beforeend', `<image href='assets/external/Game Assets/CardCost_0${card.rarity}.png' width='80' height='80' transform='translate(12 798) rotate(-45) scale(1.33)'/>`);
svg.insertAdjacentHTML('beforeend', `<text fill='white' stroke='${card.rarity == Rarity.Common ? '#482BB4' : card.rarity == Rarity.Rare ? '#8B7E25' : '#481EF9'}' paint-order='stroke' stroke-width='5' font-size='48' y='816' x='87'>${size}</text>`);
// Special cost
const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g2.setAttribute('class', 'specialCost');
g2.setAttribute('transform', 'translate(170 806) scale(0.32)');
svg.appendChild(g2);
for (let i = 0; i < card.specialCost; i++) {
let rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
image.setAttribute('href', 'assets/SpecialOverlay.png');
for (const el of [ rect, image ]) {
el.setAttribute('x', (110 * (i % 5)).toString());
el.setAttribute('y', (-125 * Math.floor(i / 5)).toString());
el.setAttribute('width', '95');
el.setAttribute('height', '95');
g2.appendChild(el);
}
}
this.card = card;
}
}

View File

@ -1,11 +1,15 @@
class Deck {
class SavedDeck {
name: string;
sleeves: number;
cards: number[];
isReadOnly: boolean;
upgrades: number[];
isReadOnly?: boolean;
constructor(name: string, cards: number[], isReadOnly: boolean) {
constructor(name: string, sleeves: number, cards: number[], upgrades: number[], isReadOnly: boolean) {
this.name = name;
this.sleeves = sleeves;
this.cards = cards;
this.upgrades = upgrades;
this.isReadOnly = isReadOnly;
}
@ -19,3 +23,17 @@ class Deck {
return true;
}
}
class Deck {
name: string;
sleeves: number;
cards: Card[];
upgrades: number[];
constructor(name: string, sleeves: number, cards: Card[], upgrades: number[]) {
this.name = name;
this.sleeves = sleeves;
this.cards = cards;
this.upgrades = upgrades;
}
}

View File

@ -1,19 +1,27 @@
/** A UUID used to identify the client. */
let clientToken = window.localStorage.getItem('clientToken') || '';
/** The data of the current game, or null if not in a game. */
let currentGame: {
id: string,
interface Game {
state: GameState,
/** The list of players in the current game. */
players: Player[],
/** The maximum number of players in the game. */
maxPlayers: number,
/** The current one-based turn number, or 0 if redraw decisions are being made. */
turnNumber: number,
/** The total turn time limit in seconds. */
turnTimeLimit: number | null,
/** The time remaining in the current turn in seconds. */
turnTimeLeft: number | null,
/** The number of game wins needed to win the set, or null if no goal win count is set. */
goalWinCount: number | null,
}
/** A UUID used to identify the client. */
let clientToken = window.localStorage.getItem('clientToken') || '';
/** The data of the current game, or null if not in a game. */
let currentGame: {
id: string,
game: Game,
/** The user's player data, or null if they are spectating. */
me: PlayerData | null,
turnNumber: number,
turnTimeLimit: number | null,
turnTimeLeft: number | null,
goalWinCount: number | null,
/** The WebSocket used for receiving game events, or null if not yet connected. */
webSocket: WebSocket | null
} | null = null;
@ -24,7 +32,7 @@ let currentReplay: {
games: {
stage: Stage,
playerData: {
deck: Card[],
deck: Deck,
initialDrawOrder: number[],
drawOrder: number[],
won: boolean

View File

@ -9,6 +9,7 @@ const deckCardListView = document.getElementById('deckCardListView')!;
const addDeckControls = document.getElementById('addDeckControls')!;
const newDeckButton = document.getElementById('newDeckButton') as HTMLButtonElement;
const importDeckButton = document.getElementById('importDeckButton') as HTMLButtonElement;
const deckSleevesButton = document.getElementById('deckSleevesButton') as HTMLButtonElement;
const deckEditButton = document.getElementById('deckEditButton') as HTMLButtonElement;
const deckListTestButton = document.getElementById('deckListTestButton') as HTMLButtonElement;
const deckExportButton = document.getElementById('deckExportButton') as HTMLButtonElement;
@ -22,6 +23,11 @@ const deckExportTextBox = document.getElementById('deckExportTextBox') as HTMLTe
const deckExportAllButton = document.getElementById('deckExportAllButton') as HTMLButtonElement;
const deckSleevesDialog = document.getElementById('deckSleevesDialog') as HTMLDialogElement;
const deckSleevesForm = document.getElementById('deckSleevesForm') as HTMLFormElement;
const deckSleevesButtons = deckSleevesForm.getElementsByTagName('input');
const deckSleevesOkButton = document.getElementById('deckSleevesOkButton') as HTMLButtonElement;
const deckImportDialog = document.getElementById('deckImportDialog') as HTMLDialogElement;
const deckImportForm = document.getElementById('deckImportForm') as HTMLFormElement;
const deckImportTextBox = document.getElementById('deckImportTextBox') as HTMLTextAreaElement;
@ -39,7 +45,7 @@ const deckImportFileBox = document.getElementById('deckImportFileBox') as HTMLIn
const deckImportErrorBox = document.getElementById('deckImportErrorBox')!;
const deckImportOkButton = document.getElementById('deckImportOkButton') as HTMLButtonElement;
const deckButtons = new CheckButtonGroup<Deck>(deckList);
const deckButtons = new CheckButtonGroup<SavedDeck>(deckList);
let deckListTouchMode = false;
let draggingDeckButton: Element | null = null;
@ -85,7 +91,7 @@ deckViewBackButton.addEventListener('click', e => {
});
function saveDecks() {
const json = JSON.stringify(decks.filter(d => !d.isReadOnly), [ 'name', 'cards' ]);
const json = JSON.stringify(decks.filter(d => !d.isReadOnly), [ 'name', 'cards', 'sleeves' ]);
localStorage.setItem('decks', json);
}
@ -93,13 +99,13 @@ function saveDecks() {
const decksString = localStorage.getItem('decks');
if (decksString) {
for (const deck of JSON.parse(decksString)) {
decks.push(new Deck(deck.name, deck.cards, false));
decks.push(new SavedDeck(deck.name, deck.sleeves ?? 0, deck.cards, deck.upgrades ?? new Array(15), false));
}
} else {
const lastDeckString = localStorage.getItem('lastDeck');
const lastDeck = lastDeckString?.split(/\+/)?.map(s => parseInt(s));
if (lastDeck && lastDeck.length == 15) {
decks.push(new Deck('Custom Deck', lastDeck, false));
decks.push(new SavedDeck('Custom Deck', 0, lastDeck, new Array(15), false));
saveDecks();
}
localStorage.removeItem('lastDeck');
@ -110,9 +116,11 @@ function saveDecks() {
}
}
function createDeckButton(deck: Deck) {
function createDeckButton(deck: SavedDeck) {
const buttonElement = document.createElement('button');
buttonElement.className = 'deckButton';
buttonElement.type = 'button';
buttonElement.dataset.sleeves = deck.sleeves.toString();
const button = new CheckButton(buttonElement);
deckButtons.add(button, deck);
buttonElement.addEventListener('click', () => {
@ -199,14 +207,17 @@ function deckButton_drop(e: DragEvent) {
}
}
function importDecks(decksToImport: (Deck | number[])[]) {
let newSelectedDeck: Deck | null = null;
function importDecks(decksToImport: (SavedDeck | number[])[]) {
let newSelectedDeck: SavedDeck | null = null;
for (const el of decksToImport) {
let deck;
if (el instanceof Array)
deck = new Deck(`Imported Deck ${decks.length + 1}`, el, false);
deck = new SavedDeck(`Imported Deck ${decks.length + 1}`, 0, el, new Array(15), false);
else {
deck = el;
deck.sleeves ??= 0;
deck.upgrades ??= new Array(15);
deck.isReadOnly = false;
if (!deck.name) deck.name = `Imported Deck ${decks.length + 1}`;
}
createDeckButton(deck);
@ -223,7 +234,7 @@ function importDecks(decksToImport: (Deck | number[])[]) {
}
newDeckButton.addEventListener('click', () => {
selectedDeck = new Deck(`Deck ${decks.length + 1}`, [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ], false);
selectedDeck = new SavedDeck(`Deck ${decks.length + 1}`, 0, new Array(15), new Array(15), false);
createDeckButton(selectedDeck);
decks.push(selectedDeck);
editDeck();
@ -287,9 +298,29 @@ function parseDecksForImport(s: string) {
// TODO: add support for tblturf.ink
}
deckSleevesButton.addEventListener('click', () => {
if (selectedDeck == null) return;
deckSleevesButtons[selectedDeck.sleeves].checked = true;
deckSleevesDialog.showModal();
});
deckSleevesForm.addEventListener('submit', e => {
if (e.submitter == deckSleevesOkButton && selectedDeck != null) {
let i = 0;
for (const button of deckSleevesButtons) {
if (button.checked) {
selectedDeck.sleeves = i;
deckButtons.entries[decks.indexOf(selectedDeck)].button.buttonElement.dataset.sleeves = i.toString();
saveDecks();
return;
}
i++;
}
}
});
deckEditButton.addEventListener('click', editDeck);
deckListTestButton.addEventListener('click', _ => {
deckListTestButton.addEventListener('click', () => {
testStageButtons.deselect();
testStageSelectionDialog.showModal();
});
@ -315,9 +346,10 @@ function selectDeck() {
deckListTestButton.disabled = false;
deckExportButton.disabled = false;
deckCopyButton.disabled = false;
deckEditButton.disabled = selectedDeck.isReadOnly;
deckRenameButton.disabled = selectedDeck.isReadOnly;
deckDeleteButton.disabled = selectedDeck.isReadOnly;
deckSleevesButton.disabled = selectedDeck.isReadOnly ?? false;
deckEditButton.disabled = selectedDeck.isReadOnly ?? false;
deckRenameButton.disabled = selectedDeck.isReadOnly ?? false;
deckDeleteButton.disabled = selectedDeck.isReadOnly ?? false;
deckViewSize.innerText = size.toString();
deckListPage.classList.add('showingDeck');
}
@ -331,6 +363,7 @@ function deselectDeck() {
deckListTestButton.disabled = true;
deckExportButton.disabled = true;
deckCopyButton.disabled = true;
deckSleevesButton.disabled = true;
deckEditButton.disabled = true;
deckRenameButton.disabled = true;
deckDeleteButton.disabled = true;
@ -362,7 +395,7 @@ deckRenameButton.addEventListener('click', () => {
deckCopyButton.addEventListener('click', () => {
if (selectedDeck == null) return;
importDecks([ new Deck(`${selectedDeck.name} - Copy`, Array.from(selectedDeck.cards), false) ]);
importDecks([ new SavedDeck(`${selectedDeck.name} - Copy`, selectedDeck.sleeves, Array.from(selectedDeck.cards), Array.from(selectedDeck.upgrades), false) ]);
});
deckDeleteButton.addEventListener('click', () => {

View File

@ -153,7 +153,7 @@ function initTest(stage: Stage) {
clear();
testMode = true;
gamePage.classList.add('deckTest');
currentGame = { id: 'test', state: GameState.Ongoing, maxPlayers: 2, players: [ ], webSocket: null, turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ] } };
currentGame = { id: 'test', game: { state: GameState.Ongoing, maxPlayers: 2, players: [ ], turnNumber: 1, turnTimeLimit: null, turnTimeLeft: null, goalWinCount: null }, me: { playerIndex: 0, move: null, deck: null, hand: null, cardsUsed: [ ] }, webSocket: null };
board.resize(stage.copyGrid());
const startSpaces = stage.getStartSpaces(2);
board.startSpaces = startSpaces;
@ -163,6 +163,8 @@ function initTest(stage: Stage) {
for (var o of playerBars)
o.element.hidden = true;
for (var el of playContainers)
el.hidden = true;
testPlacements.splice(0);
testUndoButton.enabled = false;
clearChildren(testPlacementList);
@ -184,79 +186,60 @@ function initTest(stage: Stage) {
}
replayNextButton.buttonElement.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null || currentGame.state == GameState.GameEnded || currentGame.state == GameState.SetEnded)
if (currentGame == null || currentReplay == null || currentGame.game.state == GameState.GameEnded || currentGame.game.state == GameState.SetEnded)
return;
if (replayAnimationAbortController) {
replayUpdateHand();
replayAnimationAbortController.abort();
replayAnimationAbortController = null;
turnNumberLabel.turnNumber = currentGame.turnNumber;
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
board.refresh();
const scores = board.getScores();
for (let i = 0; i < currentGame.players.length; i++) {
for (let i = 0; i < currentGame.game.players.length; i++) {
updateStats(i, scores);
}
}
clearPlayContainers();
if (currentGame.turnNumber == 0) {
if (currentGame.game.turnNumber == 0) {
// Show redraw decisions.
replayPreviousButton.enabled = true;
currentGame.state = GameState.Ongoing;
currentGame.turnNumber++;
turnNumberLabel.turnNumber = currentGame.turnNumber;
currentGame.game.state = GameState.Ongoing;
currentGame.game.turnNumber++;
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
replayUpdateHand();
} else if (currentGame.turnNumber > 12) {
currentGame.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
} else if (currentGame.game.turnNumber > 12) {
currentGame.game.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
gameButtonsContainer.hidden = true;
gamePage.classList.add('gameEnded');
showResult();
} else {
const moves = currentReplay.turns[currentGame.turnNumber - 1];
const moves = currentReplay.turns[currentGame.game.turnNumber - 1];
const result = board.makePlacements(moves);
currentReplay.placements.push(result);
let anySpecialAttacks = false;
// Show the cards that were played.
const entry = handButtons.entries.find(b => b.value.number == moves[currentReplay!.watchingPlayer].card.number);
if (entry) entry.button.checked = true;
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.players[i];
const move = moves[i];
const button = new CardButton(move.card);
button.buttonElement.disabled = true;
if ((move as PlayMove).isSpecialAttack) {
anySpecialAttacks = true;
player.specialPoints -= (move as PlayMove).card.specialCost;
button.buttonElement.classList.add('specialAttack');
} else if (move.isPass) {
player.passes++;
player.specialPoints++;
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
button.buttonElement.appendChild(el);
}
playContainers[i].append(button.buttonElement);
}
for (const p of result.specialSpacesActivated) {
const space = board.grid[p.x][p.y];
const player2 = currentGame.players[space & 3];
const player2 = currentGame.game.players[space & 3];
player2.specialPoints++;
player2.totalSpecialPoints++;
}
currentGame.turnNumber++;
currentGame.game.turnNumber++;
for (let i = 0; i < currentGame.game.players.length; i++)
showReady(i);
replayAnimationAbortController = new AbortController();
(async () => {
await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.turnNumber, players: currentGame.players }, moves, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, anySpecialAttacks, replayAnimationAbortController.signal);
turnNumberLabel.turnNumber = currentGame.turnNumber;
await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.game.turnNumber, players: currentGame.game.players }, moves, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, replayAnimationAbortController.signal);
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
clearPlayContainers();
if (currentGame.turnNumber > 12) {
currentGame.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
if (currentGame.game.turnNumber > 12) {
currentGame.game.state = currentReplay.gameNumber + 1 >= currentReplay.games.length ? GameState.SetEnded : GameState.GameEnded;
gameButtonsContainer.hidden = true;
gamePage.classList.add('gameEnded');
showResult();
@ -267,52 +250,52 @@ replayNextButton.buttonElement.addEventListener('click', _ => {
});
replayPreviousButton.buttonElement.addEventListener('click', _ => {
if (currentGame == null || currentReplay == null || currentGame.turnNumber == 0) return;
if (currentGame == null || currentReplay == null || currentGame.game.turnNumber == 0) return;
replayAnimationAbortController?.abort();
replayAnimationAbortController = null;
if (currentGame.state == GameState.GameEnded || currentGame.state == GameState.SetEnded) {
for (let i = 0; i < currentGame.players.length; i++) {
if (currentGame.game.state == GameState.GameEnded || currentGame.game.state == GameState.SetEnded) {
for (let i = 0; i < currentGame.game.players.length; i++) {
if (currentReplay.games[currentReplay.gameNumber].playerData[i].won)
currentGame.players[i].gamesWon--;
playerBars[i].winCounter.wins = currentGame.players[i].gamesWon;
currentGame.game.players[i].gamesWon--;
playerBars[i].winCounter.wins = currentGame.game.players[i].gamesWon;
}
}
if (currentGame.turnNumber > 1) {
if (currentGame.game.turnNumber > 1) {
const result = currentReplay.placements.pop();
if (!result) return;
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
for (let i = 0; i < currentGame.game.players.length; i++) {
const el = playerBars[i].resultElement;
el.innerText = '';
}
undoTurn(result);
}
currentGame.turnNumber--;
currentGame.game.turnNumber--;
replayNextButton.enabled = true;
gamePage.classList.remove('gameEnded');
handContainer.hidden = false;
gameButtonsContainer.hidden = false;
if (currentGame.turnNumber > 0) {
currentGame.state = GameState.Ongoing;
turnNumberLabel.turnNumber = currentGame.turnNumber;
if (currentGame.game.turnNumber > 0) {
currentGame.game.state = GameState.Ongoing;
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
const scores = board.getScores();
for (let i = 0; i < currentGame.players.length; i++) {
const move = currentReplay.turns[currentGame.turnNumber - 1][i];
for (let i = 0; i < currentGame.game.players.length; i++) {
const move = currentReplay.turns[currentGame.game.turnNumber - 1][i];
if (move.isPass) {
currentGame.players[i].passes--;
currentGame.players[i].specialPoints--;
currentGame.game.players[i].passes--;
currentGame.game.players[i].specialPoints--;
} else if ((move as PlayMove).isSpecialAttack)
currentGame.players[i].specialPoints += (move as PlayMove).card.specialCost;
currentGame.game.players[i].specialPoints += (move as PlayMove).card.specialCost;
updateStats(i, scores);
}
} else {
currentGame.state = GameState.Redraw;
currentGame.game.state = GameState.Redraw;
replayPreviousButton.enabled = false;
turnNumberLabel.turnNumber = null;
}
@ -324,7 +307,7 @@ replayPreviousButton.buttonElement.addEventListener('click', _ => {
function undoTurn(turn: PlacementResults) {
for (const p of turn.specialSpacesActivated) {
const space = board.grid[p.x][p.y];
const player2 = currentGame!.players[space & 3];
const player2 = currentGame!.game.players[space & 3];
if (player2) {
player2.specialPoints--;
player2.totalSpecialPoints--;
@ -352,23 +335,23 @@ function replaySwitchGame(gameNumber: number) {
replayAnimationAbortController?.abort();
replayAnimationAbortController = null;
for (let i = 0; i < currentGame.players.length; i++) {
currentGame.players[i].specialPoints = 0;
currentGame.players[i].totalSpecialPoints = 0;
currentGame.players[i].passes = 0;
currentGame.players[i].gamesWon = 0;
for (let i = 0; i < currentGame.game.players.length; i++) {
currentGame.game.players[i].specialPoints = 0;
currentGame.game.players[i].totalSpecialPoints = 0;
currentGame.game.players[i].passes = 0;
currentGame.game.players[i].gamesWon = 0;
for (let j = 0; j < gameNumber; j++) {
if (currentReplay.games[j].playerData[i].won)
currentGame.players[i].gamesWon++;
currentGame.game.players[i].gamesWon++;
}
playerBars[i].winCounter.wins = currentGame.players[i].gamesWon;
playerBars[i].winCounter.wins = currentGame.game.players[i].gamesWon;
}
currentReplay.gameNumber = gameNumber;
currentReplay.turns = currentReplay.games[gameNumber].turns;
currentReplay.placements.splice(0);
currentGame.state = GameState.Ongoing;
currentGame.turnNumber = 0;
currentGame.game.state = GameState.Ongoing;
currentGame.game.turnNumber = 0;
clearPlayContainers();
gamePage.classList.remove('gameEnded');
replayPreviousGameButton.enabled = gameNumber > 0;
@ -381,8 +364,8 @@ function replaySwitchGame(gameNumber: number) {
const stage = currentReplay.games[gameNumber].stage;
board.resize(stage.copyGrid());
const startSpaces = stage.getStartSpaces(currentGame.players.length);
for (let i = 0; i < currentGame.players.length; i++) {
const startSpaces = stage.getStartSpaces(currentGame.game.players.length);
for (let i = 0; i < currentGame.game.players.length; i++) {
board.grid[startSpaces[i].x][startSpaces[i].y] = Space.SpecialInactive1 | i;
playerBars[i].points = 1;
playerBars[i].pointsDelta = null;
@ -400,18 +383,18 @@ flipButton.addEventListener('click', () => {
replayAnimationAbortController.abort();
replayAnimationAbortController = null;
clearPlayContainers();
turnNumberLabel.turnNumber = currentGame.turnNumber;
turnNumberLabel.turnNumber = currentGame.game.turnNumber;
const scores = board.getScores();
for (let i = 0; i < currentGame.players.length; i++) {
for (let i = 0; i < currentGame.game.players.length; i++) {
updateStats(i, scores);
}
}
if (currentReplay) {
currentReplay.watchingPlayer++;
if (currentReplay.watchingPlayer >= currentGame.players.length)
if (currentReplay.watchingPlayer >= currentGame.game.players.length)
currentReplay.watchingPlayer = 0;
gamePage.dataset.myPlayerIndex = currentReplay.watchingPlayer.toString();
gamePage.dataset.uiBaseColourIsSpecialColour = currentGame.players[currentReplay.watchingPlayer].uiBaseColourIsSpecialColour?.toString();
gamePage.dataset.uiBaseColourIsSpecialColour = currentGame.game.players[currentReplay.watchingPlayer].uiBaseColourIsSpecialColour?.toString();
board.flip = currentReplay.watchingPlayer % 2 != 0;
clearShowDeck();
replayUpdateHand();
@ -510,7 +493,7 @@ function loadPlayers(players: Player[]) {
const scores = board.getScores();
for (let i = 0; i < players.length; i++) {
const player = players[i];
currentGame!.players[i] = players[i];
currentGame!.game.players[i] = players[i];
playerBars[i].name = player.name;
playerBars[i].winCounter.wins = players[i].gamesWon;
updateStats(i, scores);
@ -522,6 +505,7 @@ function loadPlayers(players: Player[]) {
}
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].visible = i < players.length;
playContainers[i].hidden = i >= players.length;
}
}
@ -530,15 +514,15 @@ function updateStats(playerIndex: number, scores: number[]) {
playerBars[playerIndex].points = scores[playerIndex];
playerBars[playerIndex].pointsDelta = 0;
playerBars[playerIndex].pointsTo = 0;
playerBars[playerIndex].specialPoints = currentGame.players[playerIndex].specialPoints;
playerBars[playerIndex].statSpecialPointsElement.innerText = currentGame.players[playerIndex].totalSpecialPoints.toString();
playerBars[playerIndex].statPassesElement.innerText = currentGame.players[playerIndex].passes.toString();
playerBars[playerIndex].specialPoints = currentGame.game.players[playerIndex].specialPoints;
playerBars[playerIndex].statSpecialPointsElement.innerText = currentGame.game.players[playerIndex].totalSpecialPoints.toString();
playerBars[playerIndex].statPassesElement.innerText = currentGame.game.players[playerIndex].passes.toString();
}
/** Shows the waiting indication for the specified player. */
function showWaiting(playerIndex: number) {
const el = document.createElement('div');
el.className = 'cardBack waiting';
el.className = 'waiting';
playContainers[playerIndex].appendChild(el);
}
@ -547,7 +531,7 @@ function showReady(playerIndex: number) {
clearChildren(playContainers[playerIndex]);
const el = document.createElement('div');
el.className = 'cardBack';
el.innerText = 'Ready';
el.dataset.sleeves = (currentGame?.game.players[playerIndex].sleeves ?? 0).toString();
playContainers[playerIndex].appendChild(el);
}
@ -566,7 +550,7 @@ function resetPlayControls() {
if (canPlay && currentGame?.me?.hand != null) {
for (let i = 0; i < 4; i++) {
canPlayCard[i] = board.canPlayCard(currentGame.me.playerIndex, currentGame.me.hand[i], false);
canPlayCardAsSpecialAttack[i] = currentGame.players[currentGame.me.playerIndex].specialPoints >= currentGame.me.hand[i].specialCost
canPlayCardAsSpecialAttack[i] = currentGame.game.players[currentGame.me.playerIndex].specialPoints >= currentGame.me.hand[i].specialCost
&& board.canPlayCard(currentGame.me.playerIndex, currentGame.me.hand[i], true);
handButtons.entries[i].button.enabled = canPlayCard[i];
}
@ -602,7 +586,9 @@ async function playInkAnimations(data: {
moves: Move[],
placements: Placement[],
specialSpacesActivated: Point[]
}, anySpecialAttacks: boolean, abortSignal?: AbortSignal) {
}, abortSignal?: AbortSignal) {
if (!currentGame) return;
const inkPlaced = new Set<number>();
const placements = data.placements;
board.clearHighlight();
@ -611,6 +597,46 @@ async function playInkAnimations(data: {
board.specialAttack = false;
canPlay = false;
timeLabel.faded = true;
// Show the cards that were played.
let anySpecialAttacks = false;
for (let i = 0; i < currentGame.game.players.length; i++) {
const player = currentGame.game.players[i];
const move = data.moves[i];
if ((move as PlayMove).isSpecialAttack)
anySpecialAttacks = true;
function addCardDisplay() {
clearChildren(playContainers[i]);
const display = new CardDisplay(move.card, 1);
playContainers[i].append(display.element);
if ((move as PlayMove).isSpecialAttack) {
if (currentReplay) player.specialPoints -= (move as PlayMove).card.specialCost;
const el = document.createElement('div');
el.className = 'specialAttackLabel';
el.innerText = 'Special Attack!';
playContainers[i].appendChild(el);
display.element.classList.add('specialAttack');
} else if (move.isPass) {
if (currentReplay) {
player.passes++;
player.specialPoints++;
}
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
playContainers[i].appendChild(el);
}
}
const back = playContainers[i].firstElementChild as HTMLElement;
if (back) {
back.style.setProperty('animation', '0.1s ease-in forwards flipCardOut');
back.addEventListener('animationend', addCardDisplay);
} else
addCardDisplay();
}
await delay(anySpecialAttacks ? 3000 : 1000, abortSignal);
for (let i = 0; i < data.game.players.length; i++) {
if ((data.moves[i] as PlayMove).isSpecialAttack)
@ -661,7 +687,7 @@ function showResult() {
turnNumberLabel.turnNumber = null;
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
for (let i = 1; i < currentGame.players.length; i++) {
for (let i = 1; i < currentGame.game.players.length; i++) {
if (playerBars[i].points > maxPoints) {
winners.splice(0);
winners.push(i);
@ -670,7 +696,7 @@ function showResult() {
winners.push(i);
}
for (let i = 0; i < currentGame.players.length; i++) {
for (let i = 0; i < currentGame.game.players.length; i++) {
const el = playerBars[i].resultElement;
if (winners.includes(i)) {
if (winners.length == 1) {
@ -678,8 +704,10 @@ function showResult() {
el.classList.remove('lose');
el.classList.remove('draw');
el.innerText = 'Victory';
if (currentReplay) currentGame.players[i].gamesWon++;
playerBars[i].winCounter.wins++;
if (currentReplay) {
currentGame.game.players[i].gamesWon++;
playerBars[i].winCounter.wins++;
}
} else {
el.classList.remove('win');
el.classList.remove('lose');
@ -704,9 +732,9 @@ function showResult() {
replayNextButton.buttonElement.hidden = true;
replayNextGameButton.buttonElement.hidden = true;
flipButton.hidden = true;
if (currentGame.state == GameState.SetEnded) {
if (currentGame.game.state == GameState.SetEnded) {
leaveButton.hidden = false;
nextGameButton.buttonElement.hidden = currentGame.goalWinCount != null;
nextGameButton.buttonElement.hidden = currentGame.game.goalWinCount != null;
shareReplayLinkButton.hidden = false;
canShareReplay = navigator.canShare && navigator.canShare({ url: window.location.href, title: 'Tableturf Battle Replay' });
shareReplayLinkButton.innerText = canShareReplay ? 'Share replay link' : 'Copy replay link';
@ -714,7 +742,7 @@ function showResult() {
leaveButton.hidden = currentGame.me != null;
nextGameButton.buttonElement.hidden = currentGame.me == null;
}
nextGameButton.enabled = currentGame.me != null && !currentGame.players[currentGame.me.playerIndex].isReady;
nextGameButton.enabled = currentGame.me != null && !currentGame.game.players[currentGame.me.playerIndex].isReady;
nextGameButton.buttonElement.innerHTML = nextGameButton.enabled ? 'Next game' : '<div class="loadingSpinner"></div> Waiting for other player';
}
}
@ -726,9 +754,9 @@ function clearShowDeck() {
showDeckButtons.splice(0);
}
function populateShowDeck(deck: Card[]) {
function populateShowDeck(deck: Deck) {
if (showDeckButtons.length == 0) {
for (const card of deck) {
for (const card of deck.cards) {
const button = new CardButton(card);
button.buttonElement.disabled = true;
showDeckButtons.push(button);
@ -764,7 +792,7 @@ function updateHandAndDeck(playerData: PlayerData) {
handButtons.add(button, card);
button.buttonElement.addEventListener('click', e => {
if (!button.enabled) {
if (specialButton.checked && currentGame!.players[currentGame!.me!.playerIndex].specialPoints < card.specialCost)
if (specialButton.checked && currentGame!.game.players[currentGame!.me!.playerIndex].specialPoints < card.specialCost)
cardHint.showError('Not enough special points.');
else if (!(specialButton.checked ? canPlayCardAsSpecialAttack : canPlayCard)[i])
cardHint.showError('No place to play this card.');
@ -832,13 +860,13 @@ function replayUpdateHand() {
b.buttonElement.parentElement!.className = '';
let indices;
if (currentGame.turnNumber == 0) {
if (currentGame.game.turnNumber == 0) {
indices = playerData.initialDrawOrder;
} else {
indices = playerData.drawOrder.slice(0, 4);
for (let i = 0; i < currentGame.turnNumber - 1; i++) {
for (let i = 0; i < currentGame.game.turnNumber - 1; i++) {
const move = currentReplay.turns[i][currentReplay.watchingPlayer];
let j = indices.findIndex(k => playerData.deck[k].number == move.card.number);
let j = indices.findIndex(k => playerData.deck.cards[k].number == move.card.number);
if (j < 0) j = indices.findIndex(k => k < 0 || k >= 15);
if (j >= 0) {
showDeckButtons[indices[j]].buttonElement.parentElement!.className = 'used';
@ -856,7 +884,7 @@ function replayUpdateHand() {
for (let i = 0; i < 4; i++) {
if (indices[i] >= 15) continue; // Accounts for an old bug in the server that corrupted the initialDrawOrder replay fields.
const card = playerData.deck[indices[i]];
const card = playerData.deck.cards[indices[i]];
const button = new CardButton(card);
button.buttonElement.disabled = true;
handButtons.add(button, card);
@ -906,7 +934,7 @@ passButton.buttonElement.addEventListener('click', passButton_click);
function specialButton_click(e: Event) {
if (!specialButton.enabled) {
if (currentGame!.me!.hand!.every(c => currentGame!.players[currentGame!.me!.playerIndex].specialPoints < c.specialCost))
if (currentGame!.me!.hand!.every(c => currentGame!.game.players[currentGame!.me!.playerIndex].specialPoints < c.specialCost))
cardHint.showError('Not enough special points.');
else if (!canPlayCardAsSpecialAttack.includes(true))
cardHint.showError('No place to play a special attack.');
@ -1041,7 +1069,7 @@ board.onhighlightchange = dScores => {
timeLabel.ontimeout = () => {
if (currentGame == null || !canPlay) return;
if (currentGame.turnNumber == 0) {
if (currentGame.game.turnNumber == 0) {
let req = new XMLHttpRequest();
req.open('POST', `${config.apiBaseUrl}/games/${currentGame!.id}/redraw`);
req.addEventListener('error', () => communicationError());
@ -1263,15 +1291,15 @@ function updateRGB(playerIndex: number, colourIndex: number) {
setColour(playerIndex, colourIndex, rgb);
}
function setColour(playerIndex: number, colourIndex: number, colour: Colour) {
if (!currentGame || playerIndex >= currentGame.players.length) return;
if (!currentGame || playerIndex >= currentGame.game.players.length) return;
if (colourIndex == 0) {
currentGame.players[playerIndex].colour = colour;
currentGame.game.players[playerIndex].colour = colour;
document.body.style.setProperty(`--primary-colour-${playerIndex + 1}`, `rgb(${colour.r}, ${colour.g}, ${colour.b})`);
} else if (colourIndex == 1) {
currentGame.players[playerIndex].specialColour = colour;
currentGame.game.players[playerIndex].specialColour = colour;
document.body.style.setProperty(`--special-colour-${playerIndex + 1}`, `rgb(${colour.r}, ${colour.g}, ${colour.b})`);
} else {
currentGame.players[playerIndex].specialAccentColour = colour;
currentGame.game.players[playerIndex].specialAccentColour = colour;
document.body.style.setProperty(`--special-accent-colour-${playerIndex + 1}`, `rgb(${colour.r}, ${colour.g}, ${colour.b})`);
}
}

View File

@ -15,7 +15,7 @@ const lobbyStageSection = document.getElementById('lobbyStageSection')!;
const lobbyStageSubmitButton = document.getElementById('submitStageButton') as HTMLButtonElement;
const lobbyDeckSection = document.getElementById('lobbyDeckSection')!;
const lobbyDeckList = document.getElementById('lobbyDeckList')!;
const lobbyDeckButtons = new CheckButtonGroup<Deck>(lobbyDeckList);
const lobbyDeckButtons = new CheckButtonGroup<SavedDeck>(lobbyDeckList);
const lobbyDeckSubmitButton = document.getElementById('submitDeckButton') as HTMLButtonElement;
const lobbyTimeLimitBox = document.getElementById('lobbyTimeLimitBox') as HTMLInputElement;
@ -94,7 +94,7 @@ function lobbyResetSlots() {
playerListItems.splice(0);
lobbyWinCounters.splice(0);
for (let i = 0; i < currentGame.maxPlayers; i++) {
for (let i = 0; i < currentGame.game.maxPlayers; i++) {
var el = document.createElement('li');
el.className = 'empty';
el.innerText = 'Waiting...';
@ -113,8 +113,8 @@ function clearReady() {
if (!currentGame) throw new Error('No current game');
stageSelectionFormSubmitButton.disabled = false;
stageSelectionFormLoadingSection.hidden = true;
for (var i = 0; i < currentGame.players.length; i++) {
currentGame.players[i].isReady = false;
for (var i = 0; i < currentGame.game.players.length; i++) {
currentGame.game.players[i].isReady = false;
playerListItems[i].className = 'filled';
}
}
@ -122,7 +122,7 @@ function clearReady() {
function lobbyAddPlayer(playerIndex: number) {
if (!currentGame) throw new Error('No current game');
const listItem = playerListItems[playerIndex];
const player = currentGame.players[playerIndex];
const player = currentGame.game.players[playerIndex];
listItem.innerText = player.name;
listItem.className = player.isReady ? 'filled ready' : 'filled';
@ -131,7 +131,7 @@ function lobbyAddPlayer(playerIndex: number) {
el.title = 'Battles won';
listItem.appendChild(el);
const winCounter = new WinCounter(el);
winCounter.wins = currentGame.players[playerIndex].gamesWon;
winCounter.wins = currentGame.game.players[playerIndex].gamesWon;
lobbyWinCounters.push(winCounter);
}
@ -151,7 +151,9 @@ function initDeckSelection() {
const buttonElement = document.createElement('button');
buttonElement.type = 'button';
buttonElement.className = 'deckButton';
buttonElement.innerText = deck.name;
buttonElement.dataset.sleeves = deck.sleeves.toString();
const button = new CheckButton(buttonElement);
lobbyDeckButtons.add(button, deck);
@ -202,6 +204,7 @@ deckSelectionForm.addEventListener('submit', e => {
data.append('clientToken', clientToken);
data.append('deckName', selectedDeck.name);
data.append('deckCards', selectedDeck.cards.join('+'));
data.append('deckSleeves', selectedDeck.sleeves.toString());
req.send(data.toString());
localStorage.setItem('lastDeckName', selectedDeck.name);

View File

@ -6,6 +6,7 @@ interface Player {
specialColour: Colour;
specialAccentColour: Colour;
uiBaseColourIsSpecialColour?: boolean;
sleeves: number;
totalSpecialPoints: number;
passes: number;
gamesWon: number;

View File

@ -55,7 +55,7 @@ class PlayerBar {
get specialPoints() { return this.specialPointsContainer.getElementsByClassName('specialPoint').length; }
set specialPoints(value: number) {
const oldList = this.specialPointsContainer.getElementsByClassName('specialPoint');
const oldList = Array.from(this.specialPointsContainer.getElementsByClassName('specialPoint'));
if (value < oldList.length) {
for (let i = oldList.length - 1; i >= value; i--)
this.specialPointsContainer.removeChild(oldList[i]);

View File

@ -1,7 +1,7 @@
interface PlayerData {
playerIndex: number;
hand: Card[] | null;
deck: Card[] | null;
deck: Deck | null;
cardsUsed: number[];
move: Move | null;
}

View File

@ -28,17 +28,18 @@ function loadReplay(base64: string) {
specialColour: { r: dataView.getUint8(pos + 3), g: dataView.getUint8(pos + 4), b: dataView.getUint8(pos + 5) },
specialAccentColour: { r: dataView.getUint8(pos + 6), g: dataView.getUint8(pos + 7), b: dataView.getUint8(pos + 8) },
uiBaseColourIsSpecialColour: false,
sleeves: 0,
totalSpecialPoints: 0,
passes: 0,
gamesWon: 0
};
players.push(player);
const deck = [ ];
const cards = [ ];
const initialDrawOrder = [ ];
const drawOrder = [ ];
for (let j = 0; j < 15; j++) {
deck.push(loadCardFromReplay(dataView, pos + 9 + j));
cards.push(loadCardFromReplay(dataView, pos + 9 + j));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 24 + j) & 0xF);
@ -51,7 +52,7 @@ function loadReplay(base64: string) {
else
drawOrder.push(dataView.getUint8(pos + 26 + j) >> 4 & 0xF);
}
playerData.push({ deck, initialDrawOrder, drawOrder, won: false });
playerData.push({ deck: new Deck("Deck", 0, cards, new Array(15)), initialDrawOrder, drawOrder, won: false });
pos += 35 + len;
}
@ -95,6 +96,7 @@ function loadReplay(base64: string) {
specialColour: { r: dataView.getUint8(pos + 3), g: dataView.getUint8(pos + 4), b: dataView.getUint8(pos + 5) },
specialAccentColour: { r: dataView.getUint8(pos + 6), g: dataView.getUint8(pos + 7), b: dataView.getUint8(pos + 8) },
uiBaseColourIsSpecialColour: (n2 & 0x80) != 0,
sleeves: 0,
totalSpecialPoints: 0,
passes: 0,
gamesWon: 0
@ -108,12 +110,12 @@ function loadReplay(base64: string) {
const playerData = [ ];
pos++;
for (let i = 0; i < numPlayers; i++) {
const deck = [ ];
const cards = [ ];
const initialDrawOrder = [ ];
const drawOrder = [ ];
let won = false;
for (let j = 0; j < 15; j++) {
deck.push(loadCardFromReplay(dataView, pos + j));
cards.push(loadCardFromReplay(dataView, pos + j));
}
for (let j = 0; j < 2; j++) {
initialDrawOrder.push(dataView.getUint8(pos + 15 + j) & 0xF);
@ -126,7 +128,7 @@ function loadReplay(base64: string) {
else
drawOrder.push(dataView.getUint8(pos + 17 + j) >> 4 & 0xF);
}
playerData.push({ deck, initialDrawOrder, drawOrder, won });
playerData.push({ deck: new Deck("Deck", 0, cards, new Array(15)), initialDrawOrder, drawOrder, won });
pos += 25;
}
const turns = replayLoadTurns(dataView, numPlayers, pos);
@ -141,14 +143,16 @@ function loadReplay(base64: string) {
currentGame = {
id: 'replay',
state: GameState.Redraw,
game: {
state: GameState.Redraw,
players: players,
maxPlayers: players.length,
turnNumber: 0,
turnTimeLimit: null,
turnTimeLeft: null,
goalWinCount: goalWinCount
},
me: null,
players: players,
maxPlayers: players.length,
turnNumber: 0,
turnTimeLimit: null,
turnTimeLeft: null,
goalWinCount: goalWinCount,
webSocket: null
};

View File

@ -8,8 +8,8 @@ let initialised = false;
let initialiseCallback: (() => void) | null = null;
let canPushState = isSecureContext && location.protocol != 'file:';
const decks: Deck[] = [ new Deck('Starter Deck', [ 6, 34, 159, 13, 45, 137, 22, 52, 141, 28, 55, 103, 40, 56, 92 ], true) ];
let selectedDeck: Deck | null = null;
const decks = [ new SavedDeck('Starter Deck', 0, [ 6, 34, 159, 13, 45, 137, 22, 52, 141, 28, 55, 103, 40, 56, 92 ], new Array(15), true) ];
let selectedDeck: SavedDeck | null = null;
let editingDeck = false;
let deckModified = false;
let shouldConfirmLeavingGame = false;
@ -97,15 +97,15 @@ function clearUrlFromGame() {
function onGameSettingsChange() {
if (currentGame == null) return;
if (lobbyTimeLimitBox.value != currentGame.turnTimeLimit?.toString() ?? '')
lobbyTimeLimitBox.value = currentGame.turnTimeLimit?.toString() ?? '';
if (lobbyTimeLimitBox.value != currentGame.game.turnTimeLimit?.toString() ?? '')
lobbyTimeLimitBox.value = currentGame.game.turnTimeLimit?.toString() ?? '';
}
function onGameStateChange(game: any, playerData: PlayerData | null) {
if (currentGame == null)
throw new Error('currentGame is null');
clearPlayContainers();
currentGame.state = game.state;
currentGame.game.state = game.state;
if (game.board) {
board.flip = playerData != null && playerData.playerIndex % 2 != 0;
@ -119,7 +119,7 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
gamePage.dataset.myPlayerIndex = playerData ? playerData.playerIndex.toString() : '';
gamePage.dataset.uiBaseColourIsSpecialColour = playerData && game.players[playerData.playerIndex].uiBaseColourIsSpecialColour ? 'true' : 'false';
if (game.stage != GameState.WaitingForPlayers)
if (game.state != GameState.WaitingForPlayers)
lobbyLockSettings(true);
redrawModal.hidden = true;
@ -159,25 +159,25 @@ function onGameStateChange(game: any, playerData: PlayerData | null) {
} else
initSpectator();
currentGame.turnNumber = game.turnNumber;
currentGame.game.turnNumber = game.turnNumber;
gameButtonsContainer.hidden = currentGame.me == null || game.state == GameState.GameEnded || game.state == GameState.SetEnded;
switch (game.state) {
case GameState.Redraw:
if (currentGame.me) setConfirmLeavingGame();
redrawModal.hidden = currentGame.me == null || currentGame.players[currentGame.me.playerIndex].isReady;
redrawModal.hidden = currentGame.me == null || currentGame.game.players[currentGame.me.playerIndex].isReady;
turnNumberLabel.turnNumber = null;
canPlay = false;
timeLabel.faded = redrawModal.hidden;
timeLabel.paused = false;
break;
case GameState.Ongoing:
for (let i = 0; i < currentGame.players.length; i++)
for (let i = 0; i < currentGame.game.players.length; i++)
showWaiting(i);
if (currentGame.me) setConfirmLeavingGame();
turnNumberLabel.turnNumber = game.turnNumber;
board.autoHighlight = true;
canPlay = currentGame.me != null && !currentGame.players[currentGame.me.playerIndex].isReady;
canPlay = currentGame.me != null && !currentGame.game.players[currentGame.me.playerIndex].isReady;
timeLabel.faded = !canPlay;
timeLabel.paused = false;
resetPlayControls();
@ -214,7 +214,7 @@ errorDialog.addEventListener('close', e => {
function playerDataReviver(key: string, value: any) {
return !value ? value
: key == 'hand' || key == 'deck'
: key == 'hand' || key == 'cards'
? (value as (Card | number)[]).map(v => typeof v == 'number' ? cardDatabase.get(v) : Card.fromJson(v))
: key == 'card'
? typeof value == 'number' ? cardDatabase.get(value) : Card.fromJson(value)
@ -247,33 +247,35 @@ function setupWebSocket(gameID: string) {
} else {
currentGame = {
id: gameID,
state: payload.data.state,
game: {
state: payload.data.state,
players: payload.data.players,
maxPlayers: payload.data.maxPlayers,
turnNumber: payload.data.turnNumber,
turnTimeLimit: payload.data.turnTimeLimit,
turnTimeLeft: payload.data.turnTimeLeft,
goalWinCount: payload.data.goalWinCount,
},
me: payload.playerData,
players: payload.data.players,
maxPlayers: payload.data.maxPlayers,
turnNumber: payload.data.turnNumber,
turnTimeLimit: payload.data.turnTimeLimit,
turnTimeLeft: payload.data.turnTimeLeft,
goalWinCount: payload.data.goalWinCount,
webSocket: webSocket
};
lobbyResetSlots();
for (let i = 0; i < currentGame.players.length; i++)
for (let i = 0; i < currentGame.game.players.length; i++)
lobbyAddPlayer(i);
onGameSettingsChange();
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].visible = i < currentGame.maxPlayers;
playerBars[i].visible = i < currentGame.game.maxPlayers;
}
for (const button of stageButtons.buttons)
(button as StageButton).setStartSpaces(currentGame.maxPlayers);
(button as StageButton).setStartSpaces(currentGame.game.maxPlayers);
onGameStateChange(payload.data, payload.playerData);
for (let i = 0; i < currentGame.players.length; i++) {
if (currentGame.players[i].isReady) showReady(i);
for (let i = 0; i < currentGame.game.players.length; i++) {
if (currentGame.game.players[i].isReady) showReady(i);
}
if (currentGame.me) {
@ -294,8 +296,8 @@ function setupWebSocket(gameID: string) {
}
timeLabel.paused = false;
if (currentGame.turnTimeLeft) {
timeLabel.setTime(currentGame.turnTimeLeft);
if (currentGame.game.turnTimeLeft) {
timeLabel.setTime(currentGame.game.turnTimeLeft);
timeLabel.show();
} else
timeLabel.hide();
@ -307,19 +309,19 @@ function setupWebSocket(gameID: string) {
}
switch (payload.event) {
case 'settingsChange':
currentGame.turnTimeLimit = payload.data.turnTimeLimit;
currentGame.game.turnTimeLimit = payload.data.turnTimeLimit;
onGameSettingsChange();
break;
case 'join':
if (payload.data.playerIndex == currentGame.players.length) {
currentGame.players.push(payload.data.player);
if (payload.data.playerIndex == currentGame.game.players.length) {
currentGame.game.players.push(payload.data.player);
lobbyAddPlayer(payload.data.playerIndex);
}
else
communicationError();
break;
case 'playerReady':
currentGame.players[payload.data.playerIndex].isReady = true;
currentGame.game.players[payload.data.playerIndex].isReady = true;
lobbySetReady(payload.data.playerIndex);
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
@ -343,46 +345,28 @@ function setupWebSocket(gameID: string) {
clearReady();
board.autoHighlight = false;
showPage('game');
currentGame.turnNumber = payload.data.game.turnNumber;
currentGame.game.turnNumber = payload.data.game.turnNumber;
let anySpecialAttacks = false;
// Show the cards that were played.
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.players[i];
for (let i = 0; i < currentGame.game.players.length; i++) {
const player = currentGame.game.players[i];
player.specialPoints = payload.data.game.players[i].specialPoints;
player.totalSpecialPoints = payload.data.game.players[i].totalSpecialPoints;
player.passes = payload.data.game.players[i].passes;
player.gamesWon = payload.data.game.players[i].gamesWon;
player.isReady = payload.data.game.players[i].isReady;
lobbyWinCounters[i].wins = player.gamesWon;
const move = payload.data.moves[i];
const button = new CardButton(move.card);
button.buttonElement.disabled = true;
if (move.isSpecialAttack) {
anySpecialAttacks = true;
button.buttonElement.classList.add('specialAttack');
} else if (move.isPass) {
const el = document.createElement('div');
el.className = move.isTimeout ? 'passLabel timeout' : 'passLabel';
el.innerText = 'Pass';
button.buttonElement.appendChild(el);
}
button.buttonElement.disabled = false;
playContainers[i].append(button.buttonElement);
}
timeLabel.paused = true;
if (payload.data.game.turnTimeLeft)
timeLabel.setTime(payload.data.game.turnTimeLeft);
(async () => {
await playInkAnimations(payload.data, anySpecialAttacks);
await playInkAnimations(payload.data);
if (payload.playerData) updateHandAndDeck(payload.playerData);
turnNumberLabel.turnNumber = payload.data.game.turnNumber;
clearPlayContainers();
if (payload.event == 'gameEnd') {
currentGame.state = payload.data.game.state;
currentGame.game.state = payload.data.game.state;
clearConfirmLeavingGame();
gameButtonsContainer.hidden = true;
passButton.enabled = false;
@ -398,7 +382,7 @@ function setupWebSocket(gameID: string) {
timeLabel.paused = false;
if (payload.data.game.turnTimeLeft)
timeLabel.show();
for (let i = 0; i < currentGame.players.length; i++)
for (let i = 0; i < currentGame.game.players.length; i++)
showWaiting(i);
}
})();
@ -553,7 +537,6 @@ function resetAnimation(el: HTMLElement) {
el.style.animation = '';
}
document.getElementById('noJSPage')!.innerText = 'Loading client...';
if (config.discordUrl) {

36
TableturfBattleClient/src/js-untar.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
declare class TarFile {
name: string;
mode: string;
uid: number;
gid: number;
size: number;
mtime: number;
checksum: number;
type: string;
linkname: string;
ustarFormat: string;
version?: string;
uname?: string;
gname?: string;
devmajor?: number;
devminor?: number;
namePrefix?: string;
buffer: ArrayBuffer;
blob: Blob;
getBlobUrl(): string;
readAsString(): string;
readAsJSON(): object;
}
declare class ProgressivePromise<TResult, TProgress> extends Promise<TResult> {
progress(cb: ((value: TProgress) => void)): ProgressivePromise<TResult, TProgress>;
then<TResult1 = TResult, TResult2 = never>(
onfulfilled?: ((value: TResult) => TResult1 | PromiseLike<TResult1>) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
onProgress?: ((value: TProgress) => void) | undefined | null)
: ProgressivePromise<TResult1 | TResult2, TProgress>;
}
declare function untar(arrayBuffer: ArrayBuffer): ProgressivePromise<TarFile[], TarFile>;

View File

@ -212,6 +212,73 @@ dialog::backdrop {
border: 1em solid white;
}
.deckList {
display: flex;
flex-flow: column;
}
.deckList button, #addDeckControls button {
display: flex;
justify-content: center;
align-items: center;
font: inherit;
color: inherit;
border: inherit;
text-shadow: 0 0 4px black;
}
.deckButton, #addDeckControls {
width: 20rem;
height: 4rem;
margin: 0.5em;
}
.deckButton {
position: relative;
background: linear-gradient(#00000040, #00000040), url('assets/external/sleeves.webp') 0 11%/800%;
outline: 0.25em solid grey;
border-radius: 0.5em;
outline-offset: -0.2em;
}
.deckButton[data-sleeves="0"] { background-position: 0 11%; }
.deckButton[data-sleeves="1"] { background-position: 14.3% 11%; }
.deckButton[data-sleeves="2"] { background-position: 28.6% 11%; }
.deckButton[data-sleeves="3"] { background-position: 42.9% 11%; }
.deckButton[data-sleeves="4"] { background-position: 57.1% 11%; }
.deckButton[data-sleeves="5"] { background-position: 71.4% 11%; }
.deckButton[data-sleeves="6"] { background-position: 85.7% 11%; }
.deckButton[data-sleeves="7"] { background-position: 100% 11%; }
.deckButton[data-sleeves="8"] { background-position: 0 37%; }
.deckButton[data-sleeves="9"] { background-position: 14.3% 37%; }
.deckButton[data-sleeves="10"] { background-position: 28.6% 37%; }
.deckButton[data-sleeves="11"] { background-position: 42.9% 37%; }
.deckButton[data-sleeves="12"] { background-position: 57.1% 37%; }
.deckButton[data-sleeves="13"] { background-position: 71.4% 37%; }
.deckButton[data-sleeves="14"] { background-position: 85.7% 37%; }
.deckButton[data-sleeves="15"] { background-position: 100% 37%; }
.deckButton[data-sleeves="16"] { background-position: 0 63%; }
.deckButton[data-sleeves="17"] { background-position: 14.3% 63%; }
.deckButton[data-sleeves="18"] { background-position: 28.6% 63%; }
.deckButton[data-sleeves="19"] { background-position: 42.9% 63%; }
.deckButton[data-sleeves="20"] { background-position: 57.1% 63%; }
.deckButton[data-sleeves="21"] { background-position: 71.4% 63%; }
.deckButton[data-sleeves="22"] { background-position: 85.7% 63%; }
.deckButton[data-sleeves="23"] { background-position: 100% 63%; }
.deckButton[data-sleeves="24"] { background-position: 0 89%; }
.deckButton:is(:active, .checked) {
outline-color: lightgrey;
}
.deckButton:hover {
outline-color: white;
}
.deckButton:focus-within {
outline-color: white;
}
.deckButton.disabled {
opacity: 0.5;
}
/* Stages */
#stageList {
@ -268,7 +335,7 @@ dialog::backdrop {
/* Cards */
.card, .cardBack {
.cardButton, .cardBack {
box-sizing: border-box;
width: 10em;
height: 12em;
@ -278,22 +345,51 @@ dialog::backdrop {
.cardBack {
--colour: grey;
background: #000000C0;
background: url(assets/external/sleeves.webp) 0 0/800%;
display: flex;
justify-content: center;
align-items: center;
height: 14em;
animation: 0.25s ease-out forwards cardBackFadeIn;
}
.cardBack.waiting::before {
content: '';
width: 2em;
height: 2em;
background: url(assets/external/IconUp_00.webp) center/cover;
animation: 2s infinite linear loadingSpinner;
opacity: 0.333;
.cardBack[data-sleeves="0"] { background-position: 0% 0%; }
.cardBack[data-sleeves="1"] { background-position: 14.3% 0%; }
.cardBack[data-sleeves="2"] { background-position: 28.6% 0%; }
.cardBack[data-sleeves="3"] { background-position: 42.9% 0%; }
.cardBack[data-sleeves="4"] { background-position: 57.1% 0%; }
.cardBack[data-sleeves="5"] { background-position: 71.4% 0%; }
.cardBack[data-sleeves="6"] { background-position: 85.7% 0%; }
.cardBack[data-sleeves="7"] { background-position: 100% 0%; }
.cardBack[data-sleeves="8"] { background-position: 0% 33.3% }
.cardBack[data-sleeves="9"] { background-position: 14.3% 33.3% }
.cardBack[data-sleeves="10"] { background-position: 28.6% 33.3% }
.cardBack[data-sleeves="11"] { background-position: 42.9% 33.3% }
.cardBack[data-sleeves="12"] { background-position: 57.1% 33.3% }
.cardBack[data-sleeves="13"] { background-position: 71.4% 33.3% }
.cardBack[data-sleeves="14"] { background-position: 85.7% 33.3% }
.cardBack[data-sleeves="15"] { background-position: 100% 33.3% }
.cardBack[data-sleeves="16"] { background-position: 0% 66.7% }
.cardBack[data-sleeves="17"] { background-position: 14.3% 66.7% }
.cardBack[data-sleeves="18"] { background-position: 28.6% 66.7% }
.cardBack[data-sleeves="19"] { background-position: 42.9% 66.7% }
.cardBack[data-sleeves="20"] { background-position: 57.1% 66.7% }
.cardBack[data-sleeves="21"] { background-position: 71.4% 66.7% }
.cardBack[data-sleeves="22"] { background-position: 85.7% 66.7% }
.cardBack[data-sleeves="23"] { background-position: 100% 66.7% }
.cardBack[data-sleeves="24"] { background-position: 0% 100%; }
@keyframes cardBackFadeIn {
from {
opacity: 0;
transform: translateY(-4rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
.cardButton {
position: relative;
color: currentColor; /* Override disabled colour */
background: black;
@ -302,15 +398,27 @@ dialog::backdrop {
font-weight: 599;
margin: 0.5em;
}
.card:not([hidden]) {
.cardButton:not([hidden]) {
display: inline-block;
}
:is(#deckCardListView, #deckCardListEdit, .cardListGrid) .card.upcoming { background: midnightblue; }
:is(#deckCardListView, #deckCardListEdit, .cardListGrid) .cardButton.upcoming { background: midnightblue; }
.card.common { --colour: rgb(89, 49, 255); }
.card.rare { --colour: rgb(231, 180, 39); }
.card.fresh { --colour: white; }
.cardButton.common { --colour: rgb(89, 49, 255); }
.cardButton.rare { --colour: rgb(231, 180, 39); }
.cardButton.fresh { --colour: white; }
.cardButton .cardArt {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
opacity: 0.333;
}
.cardHeader {
height: 2.5em;
@ -325,7 +433,7 @@ dialog::backdrop {
display: none;
}
.cardListGrid .card:hover .cardNumber {
.cardListGrid .cardButton:hover .cardNumber {
display: block;
position: absolute;
background: grey;
@ -342,25 +450,25 @@ dialog::backdrop {
line-height: 1.25em;
flex-grow: 1;
}
.card:is([data-card-number="163"], [data-card-number="166"], [data-card-number="196"], [data-card-number="197"], [data-card-number="199"],
[data-card-number="202"], [data-card-number="-3"], [data-card-number="-5"], [data-card-number="-12"]) .cardName {
.cardButton:is([data-card-number="163"], [data-card-number="166"], [data-card-number="196"], [data-card-number="197"], [data-card-number="199"],
[data-card-number="202"], [data-card-number="216"]) .cardName {
position: absolute;
left: -1em;
right: -1em;
transform: scaleX(0.8);
}
.card.common .cardName {
.cardButton.common .cardName {
color: var(--colour);
}
.card.rare .cardName {
.cardButton.rare .cardName {
background: var(--colour);
background: linear-gradient(90deg, rgb(255, 242, 129) 0%, rgb(255, 255, 224) 15%, rgb(231, 180, 39) 50%, rgb(255, 255, 224) 85%, rgb(255, 242, 129) 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.card.fresh .cardName {
.cardButton.fresh .cardName {
background: var(--colour);
background: linear-gradient(120deg, rgba(253, 217, 169, 1) 0%, rgba(200, 58, 141, 1) 50%, rgba(55, 233, 207, 1) 100%);
background-clip: text;
@ -368,8 +476,7 @@ dialog::backdrop {
color: transparent;
flex-grow: 0;
}
.cardGrid {
.cardButton .cardGrid {
position: relative;
margin: 0 auto;
border-spacing: 0;
@ -380,6 +487,7 @@ dialog::backdrop {
outline: 1px solid #80808080;
outline-offset: -1px;
}
.cardGrid td { background: #00000080; }
.cardGrid td.ink { background: url('assets/InkOverlay.png') center/cover, var(--player-primary-colour); outline: none; }
.cardGrid td.special { background: url('assets/SpecialOverlay.png') center/cover, var(--player-special-colour); outline: none; }
@ -423,7 +531,55 @@ dialog::backdrop {
.playHintSpecial:nth-of-type(3) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-3); }
.playHintSpecial:nth-of-type(4) { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-4); }
:is(.card, .stage, .stageRandom)::before {
.card {
position: relative;
color: currentColor; /* Override disabled colour */
font-family: 'Splatoon 1', 'Arial Black', sans-serif;
font-size: 2rem;
font-weight: 599;
aspect-ratio: 5/7;
border-radius: 1rem;
}
.playContainer .card {
animation: 0.1s ease-out forwards flipCardIn;
}
@keyframes flipCardOut {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@keyframes flipCardIn {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
svg.card {
display: block;
}
svg.card text {
text-anchor: middle;
}
svg.card text.cardDisplayName {
transform: scaleX(var(--scale));
}
rect.empty {
fill: #00000080;
stroke: #60606080;
stroke-width: 6;
}
rect.ink {
fill: var(--player-primary-colour);
}
rect.special, g.specialCost rect {
fill: var(--player-special-colour);
}
:is(.cardButton, .stage, .stageRandom)::before {
/* If it exists */
position: absolute;
left: 0;
@ -434,19 +590,19 @@ dialog::backdrop {
opacity: 0.25;
z-index: 0;
}
.card.checked, .card:is(:hover, :focus-within):not([disabled]) {
.cardButton.checked, .cardButton:is(:hover, :focus-within):not([disabled]) {
transform: rotate(-2deg);
}
.card:is(:hover, :focus-within):not(.checked, .disabled, [disabled])::before {
.cardButton:is(:hover, :focus-within):not(.checked, .disabled, [disabled])::before {
content: '';
background: grey;
}
.card.checked::before {
.cardButton.checked::before {
content: '';
background: var(--colour);
}
.card.disabled {
.cardButton.disabled {
opacity: 0.5;
}
@ -828,7 +984,25 @@ dialog::backdrop {
bottom: 0;
}
.playContainer { margin: 8em 0; }
.playContainer:not([hidden]) {
position: relative;
margin: 8em 0;
width: 10em;
height: 14em;
border: 1px solid grey;
border-radius: 1em;
background: #00000080;
display: flex;
justify-content: center;
align-items: center;
}
.playContainer .waiting {
width: 2em;
height: 2em;
background: url(assets/external/IconUp_00.webp) center/cover;
animation: 2s infinite linear loadingSpinner;
opacity: 0.333;
}
.playContainer[data-index="2"] { grid-column: play-column-2; grid-row: 2 / -1; align-self: end; }
.playContainer[data-index="1"] { grid-column: play-column-2; grid-row: 1 / 4; align-self: start; }
@ -840,7 +1014,7 @@ dialog::backdrop {
#gamePage.boardFlipped .playContainer[data-index="1"] { grid-column: play-column-1; grid-row: 2 / -1; align-self: end; }
#gamePage.boardFlipped .playContainer[data-index="2"] { grid-column: play-column-1; grid-row: 1 / 4; align-self: start; }
.playContainer .card {
.playContainer .cardButton {
margin: 0;
}
@ -1264,13 +1438,13 @@ dialog::backdrop {
}
.specialAttack, .specialAttack::after {
.specialAttack, .specialAttackLabel {
box-shadow: 0px 0 8px 4px var(--player-colour);
}
.specialAttack::after {
content: 'Special Attack!';
.specialAttackLabel {
position: absolute;
font-family: 'Splatoon 1', sans-serif;
left: -2em;
top: 4em;
right: -2em;
@ -1333,33 +1507,10 @@ dialog::backdrop {
gap: 3em;
}
.deckList {
display: flex;
flex-flow: column;
}
.deckList button, #addDeckControls button {
display: flex;
justify-content: center;
align-items: center;
font: inherit;
color: inherit;
border: inherit;
text-shadow: 0 0 4px black;
}
button.dragging {
opacity: 0.5;
}
.deckList > button, #addDeckControls {
width: 20rem;
height: 3rem;
margin: 0.5em;
}
.deckList > button { background: var(--primary-colour-2); position: relative;}
.deckList > button:hover { background: var(--special-colour-2); }
.deckList > button:focus-within { outline: 2px solid var(--special-accent-colour-2); }
.deckList > button:is(:active, .checked) { background: var(--special-accent-colour-2); }
.deckList > button.disabled { background: grey; }
#addDeckControls {
display: flex;
align-items: stretch;
@ -1388,7 +1539,7 @@ button.dragging {
.touchmode .deckList .handle {
width: 2em;
}
.card .handle {
.cardButton .handle {
position: absolute;
left: 0;
right: 0;
@ -1396,7 +1547,7 @@ button.dragging {
height: 3em;
}
.card.emptySlot {
.cardButton.emptySlot {
--colour: dimgrey;
background: url('assets/plus-circle.svg') center/2em no-repeat, black;
}
@ -1501,6 +1652,60 @@ button.dragging {
text-align: start;
}
#deckSleevesList {
display: flex;
flex-flow: row wrap;
justify-content: center;
max-width: 80em;
gap: 1em;
}
#deckSleevesList input {
visibility: hidden;
position: absolute;
}
#deckSleevesList label {
display: inline-block;
width: 10em;
height: 14em;
border-radius: 0.75em;
background: url('assets/external/sleeves.webp') 0px 0px/800%;
}
#deckSleevesList input:hover+label {
outline: 0.25em solid grey;
}
#deckSleevesList input:focus+label {
outline: 0.25em solid grey;
}
#deckSleevesList input:checked+label {
outline: 0.25em solid yellow;
}
#deckSleevesList label:nth-of-type(1) { background-position: 0% 0%; }
#deckSleevesList label:nth-of-type(2) { background-position: -100% 0%; }
#deckSleevesList label:nth-of-type(3) { background-position: -200% 0%; }
#deckSleevesList label:nth-of-type(4) { background-position: -300% 0%; }
#deckSleevesList label:nth-of-type(5) { background-position: -400% 0%; }
#deckSleevesList label:nth-of-type(6) { background-position: -500% 0%; }
#deckSleevesList label:nth-of-type(7) { background-position: -600% 0%; }
#deckSleevesList label:nth-of-type(8) { background-position: -700% 0%; }
#deckSleevesList label:nth-of-type(9) { background-position: 0% -100%; }
#deckSleevesList label:nth-of-type(10) { background-position: -100% -100%; }
#deckSleevesList label:nth-of-type(11) { background-position: -200% -100%; }
#deckSleevesList label:nth-of-type(12) { background-position: -300% -100%; }
#deckSleevesList label:nth-of-type(13) { background-position: -400% -100%; }
#deckSleevesList label:nth-of-type(14) { background-position: -500% -100%; }
#deckSleevesList label:nth-of-type(15) { background-position: -600% -100%; }
#deckSleevesList label:nth-of-type(16) { background-position: -700% -100%; }
#deckSleevesList label:nth-of-type(17) { background-position: 0% -200%; }
#deckSleevesList label:nth-of-type(18) { background-position: -100% -200%; }
#deckSleevesList label:nth-of-type(19) { background-position: -200% -200%; }
#deckSleevesList label:nth-of-type(20) { background-position: -300% -200%; }
#deckSleevesList label:nth-of-type(21) { background-position: -400% -200%; }
#deckSleevesList label:nth-of-type(22) { background-position: -500% -200%; }
#deckSleevesList label:nth-of-type(23) { background-position: -600% -200%; }
#deckSleevesList label:nth-of-type(24) { background-position: -700% -200%; }
#deckSleevesList label:nth-of-type(25) { background-position: 0% -300%; }
/* Help */
#helpControls {
@ -1549,22 +1754,22 @@ button.dragging {
#gamePage.boardFlipped .playerBar[data-index="3"] { grid-row: player-row-0; }
#gamePage:not(.boardFlipped):not([data-players="2"]) .playerBar:is([data-index="1"], [data-index="2"]) .wins,
#gamePage.boardFlipped:not([data-players="2"]) .playerBar:is([data-index="0"], [data-index="3"]) .wins {
float: initial;
#gamePage.boardFlipped:not([data-players="2"]) .playerBar:is([data-index="0"], [data-index="3"]) .wins {
float: initial;
justify-content: end;
}
}
}
@media (max-width: 40rem) {
.card {
.cardButton {
margin: 0;
}
.cardListGrid:not([hidden]) {
justify-items: stretch;
}
.cardListGrid .card {
width: auto;
justify-self: stretch;
.cardListGrid .cardButton {
width: auto;
justify-self: stretch;
}
/* Lobby - Mobile layout */
@ -1676,7 +1881,7 @@ button.dragging {
grid-column: 1 / -1;
grid-row: 2 / -1;
}
#showDeckList .card {
#showDeckList .cardButton {
width: 30vw;
}
@ -1724,17 +1929,17 @@ button.dragging {
display: block;
}
:is(#deckListPage, #deckEditPage) section {
:is(#deckListPage, #deckEditPage) > section {
position: absolute;
left: 0;
right: 0;
top: 0;
}
#deckListPage:not(.showingDeck) #deckEditorDeckViewSection {
#deckListPage:not(.showingDeck) #deckEditorDeckViewSection {
display: none;
}
#deckListPage.showingDeck #deckEditorDeckListSection {
#deckListPage.showingDeck #deckEditorDeckListSection {
display: none;
}
@ -1804,7 +2009,7 @@ button.dragging {
font-size: 55%;
}
#cardList .card {
#cardList .cardButton {
height: 13em;
}
@ -1846,6 +2051,12 @@ button.dragging {
#testPlacementList div {
padding: 0 0.5em;
}
#deckSleevesList label {
width: 20%;
height: auto;
aspect-ratio: 5 / 7;
}
}
/* Loading spinner */

View File

@ -2,32 +2,44 @@
namespace TableturfBattleServer;
public class Card {
[JsonProperty("number")]
public int Number { get; }
[JsonProperty("altNumber")]
public int? AltNumber { get; }
[JsonProperty("name")]
public int? AltNumber { get; init; }
public string Name { get; }
[JsonProperty("rarity")]
public Rarity Rarity { get; }
[JsonProperty("specialCost")]
public int SpecialCost { get; }
[JsonIgnore]
public int Size { get; }
[JsonProperty("grid")]
public string? Line1 { get; init; }
public string? Line2 { get; init; }
public string? ArtFileName { get; init; }
public float TextScale { get; init; }
public Colour? InkColour1 { get; init; }
public Colour? InkColour2 { get; init; }
[JsonProperty]
private readonly Space[,] grid;
internal Card(int number, string name, Rarity rarity, Space[,] grid) : this(number, null, name, rarity, null, grid) { }
internal Card(int number, int? altNumber, string name, Rarity rarity, Space[,] grid) : this(number, altNumber, name, rarity, null, grid) { }
internal Card(int number, string name, Rarity rarity, int? specialCost, Space[,] grid) : this(number, null, name, rarity, specialCost, grid) { }
internal Card(int number, int? altNumber, string name, Rarity rarity, int? specialCost, Space[,] grid) {
internal Card(int number, string name, Rarity rarity, float textScale, string? artFileName, Space[,] grid) : this(number, null, name, rarity, null, textScale, artFileName, grid) { }
internal Card(int number, int? altNumber, string name, Rarity rarity, float textScale, string? artFileName, Space[,] grid) : this(number, altNumber, name, rarity, null, textScale, artFileName, grid) { }
internal Card(int number, string name, Rarity rarity, int? specialCost, float textScale, string? artFileName, Space[,] grid) : this(number, null, name, rarity, specialCost, textScale, artFileName, grid) { }
internal Card(int number, int? altNumber, string name, Rarity rarity, int? specialCost, float textScale, string? artFileName, Space[,] grid) {
this.Number = number;
this.AltNumber = altNumber;
this.Name = name ?? throw new ArgumentNullException(nameof(name));
this.Rarity = rarity;
this.TextScale = textScale;
this.ArtFileName = artFileName;
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));
var pos = (name ?? throw new ArgumentNullException(nameof(name))).IndexOf('\n');
if (pos < 0)
this.Name = name;
else {
this.Name = name[pos - 1] == '-' ? name.Remove(pos, 1) : name.Replace('\n', ' ');
this.Line1 = name[0..pos];
this.Line2 = name[(pos + 1)..];
}
var size = 0;
if (grid.GetUpperBound(0) != 7 || grid.GetUpperBound(1) != 7)
throw new ArgumentException("Grid must be 8 × 8.", nameof(grid));

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,7 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
namespace TableturfBattleServer;
public struct Colour {
[JsonProperty("r")]
public int R;
[JsonProperty("g")]
public int G;
[JsonProperty("b")]
public int B;
public Colour(int r, int g, int b) {

View File

@ -14,7 +14,6 @@ internal class WebSocketPayload<T> {
}
}
internal class WebSocketPayloadWithPlayerData<T> : WebSocketPayload<T> {
[JsonProperty("playerData")]
public PlayerData? PlayerData;
public WebSocketPayloadWithPlayerData(string eventName, T payload, PlayerData? playerData) : base(eventName, payload)
@ -22,18 +21,13 @@ internal class WebSocketPayloadWithPlayerData<T> : WebSocketPayload<T> {
}
public class PlayerData {
[JsonProperty("playerIndex")]
public int PlayerIndex;
[JsonProperty("hand")]
public Card[]? Hand;
[JsonProperty("deck")]
public Card[]? Deck;
[JsonProperty("move")]
public Deck? Deck;
public Move? Move;
[JsonProperty("cardsUsed")]
public List<int>? CardsUsed;
public PlayerData(int playerIndex, Card[]? hand, Card[]? deck, Move? move, List<int>? cardsUsed) {
public PlayerData(int playerIndex, Card[]? hand, Deck? deck, Move? move, List<int>? cardsUsed) {
this.PlayerIndex = playerIndex;
this.Hand = hand;
this.Deck = deck;

View File

@ -0,0 +1,35 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Deck : IEquatable<Deck> {
[JsonProperty]
internal string Name;
[JsonProperty]
internal int Sleeves;
[JsonProperty]
internal Card[] Cards;
[JsonProperty]
internal int[] Upgrades;
public Deck(string name, int sleeves, Card[] cards, int[] levels) {
this.Name = name ?? throw new ArgumentNullException(nameof(name));
this.Sleeves = sleeves;
this.Cards = cards ?? throw new ArgumentNullException(nameof(cards));
this.Upgrades = levels ?? throw new ArgumentNullException(nameof(levels));
}
public bool Equals(Deck? other)
=> other is not null && this.Name == other.Name && this.Sleeves == other.Sleeves && this.Cards.SequenceEqual(other.Cards) && this.Upgrades.SequenceEqual(other.Upgrades);
public override bool Equals(object? other) => other is Deck deck && this.Equals(deck);
public override int GetHashCode() {
var hashCode = new HashCode();
hashCode.Add(this.Name);
hashCode.Add(this.Sleeves);
foreach (var card in this.Cards)
hashCode.Add(card.Number);
foreach (var n in this.Upgrades)
hashCode.Add(n);
return hashCode.ToHashCode();
}
}

View File

@ -6,9 +6,7 @@ namespace TableturfBattleServer;
public struct Error {
[JsonIgnore]
public HttpStatusCode HttpStatusCode { get; }
[JsonProperty("code")]
public string Code { get; }
[JsonProperty("description")]
public string Description { get; }
public Error(HttpStatusCode httpStatusCode, string code, string description) {

View File

@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Numerics;
using System.Text;
using Newtonsoft.Json;
@ -10,31 +9,25 @@ public class Game {
[JsonIgnore]
public Guid ID { get; } = Guid.NewGuid();
[JsonProperty("state")]
public GameState State { get; set; }
[JsonProperty("turnNumber")]
public int TurnNumber { get; set; }
[JsonProperty("players")]
public List<Player> Players { get; } = new(4);
[JsonProperty("maxPlayers")]
public int MaxPlayers { get; set; }
[JsonProperty("stage")]
public string? StageName { get; private set; }
[JsonProperty("board")]
public Space[,]? Board { get; private set; }
[JsonProperty("startSpaces")]
public Point[]? StartSpaces;
[JsonProperty("goalWinCount")]
public int? GoalWinCount { get; set; }
[JsonProperty("turnTimeLimit")]
public int? TurnTimeLimit { get; set; }
[JsonProperty("turnTimeLeft")]
public int? TurnTimeLeft { get; set; }
[JsonIgnore]
internal DateTime abandonedSince = DateTime.UtcNow;
[JsonIgnore]
internal List<Deck> deckCache = new();
[JsonIgnore]
internal List<string> setStages = new();
public Game(int maxPlayers) => this.MaxPlayers = maxPlayers;
@ -77,6 +70,10 @@ public class Game {
return false;
}
public Deck GetDeck(string name, int sleeves, IEnumerable<int> cardNumbers, IEnumerable<int> cardUpgrades)
=> this.deckCache.FirstOrDefault(d => d.Name == name && d.Sleeves == sleeves && cardNumbers.SequenceEqual(from c in d.Cards select c.Number) && cardUpgrades.SequenceEqual(d.Upgrades))
?? new(name, sleeves, (from i in cardNumbers select CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray());
public bool CanPlay(int playerIndex, Card card, int x, int y, int rotation, bool isSpecialAttack) {
if (card is null) throw new ArgumentNullException(nameof(card));
if (this.Board is null || this.Players[playerIndex].CurrentGameData is not SingleGameData gameData) return false;
@ -331,7 +328,7 @@ public class Game {
foreach (var player in this.Players) {
var index = player.GetHandIndex(player.Move!.Card.Number);
var draw = player.CurrentGameData.drawOrder![this.TurnNumber + 2];
player.Hand![index] = player.CurrentGameData.Deck![draw];
player.Hand![index] = player.CurrentGameData.Deck!.Cards[draw];
player.ClearMoves();
}
this.SendEvent("turn", new { game = this, moves, placements, specialSpacesActivated }, true);
@ -372,9 +369,7 @@ public class Game {
}
}
internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) {
this.SendEvent("playerReady", new { playerIndex, isTimeout }, false);
}
internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) => this.SendEvent("playerReady", new { playerIndex, isTimeout }, false);
internal void SendEvent<T>(string eventType, T data, bool includePlayerData) {
foreach (var session in Program.httpServer!.WebSocketServices.Hosts.First().Sessions.Sessions) {
@ -388,22 +383,24 @@ public class Game {
break;
}
}
behaviour.SendInternal(JsonConvert.SerializeObject(new DTO.WebSocketPayloadWithPlayerData<T>(eventType, data, playerData)));
behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<T>(eventType, data, playerData)));
} else {
behaviour.SendInternal(JsonConvert.SerializeObject(new DTO.WebSocketPayload<T>(eventType, data)));
behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayload<T>(eventType, data)));
}
}
}
}
public void WriteReplayData(Stream stream) {
const int VERSION = 2;
const int VERSION = 3;
if (this.State < GameState.SetEnded)
throw new InvalidOperationException("Can't save a replay until the set has ended.");
using var writer = new BinaryWriter(stream, Encoding.UTF8, true);
writer.Write((byte) VERSION);
// Players
writer.Write((byte) (this.Players.Count | (this.GoalWinCount ?? 0) << 4));
foreach (var player in this.Players) {
writer.Write((byte) player.Colour.R);
@ -420,14 +417,29 @@ public class Game {
writer.Write((byte) (nameBytes.Length | (player.UIBaseColourIsSpecialColour ? 0x80 : 0)));
writer.Write(nameBytes);
}
// Deck cache
writer.Write7BitEncodedInt(this.deckCache.Count);
foreach (var deck in this.deckCache) {
writer.Write(deck.Name);
writer.Write((byte) deck.Sleeves);
foreach (var card in deck.Cards)
writer.Write((byte) card.Number);
int upgradesPacked = 0;
for (var i = 0; i < 15; i++)
upgradesPacked |= deck.Upgrades[i] << (i * 2);
writer.Write(upgradesPacked);
}
// Games
for (int i = 0; i < this.Players[0].Games.Count; i++) {
var stageNumber = Enumerable.Range(0, StageDatabase.Stages.Count).First(j => this.setStages[i] == StageDatabase.Stages[j].Name);
writer.Write((byte) stageNumber);
foreach (var player in this.Players) {
var gameData = player.Games[i];
foreach (var card in gameData.Deck!)
writer.Write((byte) card.Number);
writer.Write7BitEncodedInt(this.deckCache.IndexOf(gameData.Deck!));
for (int j = 0; j < 4; j += 2)
writer.Write((byte) (gameData.initialDrawOrder![j] | gameData.initialDrawOrder[j + 1] << 4));
for (int j = 0; j < 15; j += 2)

View File

@ -0,0 +1,9 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace TableturfBattleServer;
internal class JsonUtils {
private static readonly JsonSerializerSettings serializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
internal static string Serialise(object? o) => JsonConvert.SerializeObject(o, serializerSettings);
}

View File

@ -1,22 +1,13 @@
using System.ComponentModel;
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Move {
[JsonProperty("card")]
public Card Card { get; }
[JsonProperty("isPass")]
public bool IsPass { get; }
[JsonProperty("x")]
public int X { get; }
[JsonProperty("y")]
public int Y { get; }
[JsonProperty("rotation")]
public int Rotation { get; }
[JsonProperty("isSpecialAttack")]
public bool IsSpecialAttack { get; }
[JsonProperty("isTimeout")]
public bool IsTimeout { get; }
public Move(Card card, bool isPass, int x, int y, int rotation, bool isSpecialAttack, bool isTimeout) {

View File

@ -2,9 +2,8 @@
namespace TableturfBattleServer;
public class Placement {
[JsonProperty("players")]
public List<int> Players { get; } = new();
[JsonProperty("spacesAffected"), JsonConverter(typeof(SpacesAffectedDictionaryConverter))]
[JsonConverter(typeof(SpacesAffectedDictionaryConverter))]
public Dictionary<Point, Space> SpacesAffected { get; } = new();
internal class SpacesAffectedDictionaryConverter : JsonConverter<Dictionary<Point, Space>> {

View File

@ -2,17 +2,12 @@
namespace TableturfBattleServer;
public class Player {
[JsonProperty("name")]
public string Name { get; }
[JsonIgnore]
public Guid Token { get; }
[JsonProperty("colour")]
public Colour Colour { get; set; }
[JsonProperty("specialColour")]
public Colour SpecialColour { get; set; }
[JsonProperty("specialAccentColour")]
public Colour SpecialAccentColour { get; set; }
[JsonProperty("uiBaseColourIsSpecialColour")]
public bool UIBaseColourIsSpecialColour { get; set; }
[JsonIgnore]
@ -26,7 +21,6 @@ public class Player {
[JsonIgnore]
internal Move? ProvisionalMove;
[JsonProperty("gamesWon")]
public int GamesWon { get; set; }
[JsonIgnore]
@ -35,15 +29,12 @@ public class Player {
[JsonIgnore]
public SingleGameData CurrentGameData => this.Games[^1];
[JsonProperty("specialPoints")]
public int SpecialPoints => this.CurrentGameData.SpecialPoints;
[JsonProperty("totalSpecialPoints")]
public int TotalSpecialPoints => this.CurrentGameData.TotalSpecialPoints;
[JsonProperty("passes")]
public int Passes => this.CurrentGameData.Passes;
public int? Sleeves => this.CurrentGameData.Deck?.Sleeves;
[JsonProperty("isReady")]
public bool IsReady => this.game.State switch {
GameState.WaitingForPlayers or GameState.ChoosingStage => this.selectedStageIndex != null,
GameState.ChoosingDeck => this.CurrentGameData.Deck != null,
@ -75,7 +66,7 @@ public class Player {
if (this.CurrentGameData.Deck != null) {
this.Hand = new Card[4];
for (int i = 0; i < 4; i++) {
this.Hand[i] = this.CurrentGameData.Deck[this.CurrentGameData.drawOrder[i]];
this.Hand[i] = this.CurrentGameData.Deck.Cards[this.CurrentGameData.drawOrder[i]];
}
}
}

View File

@ -1,10 +1,6 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
namespace TableturfBattleServer;
public struct Point {
[JsonProperty("x")]
public int X;
[JsonProperty("y")]
public int Y;
public Point(int x, int y) {

View File

@ -94,7 +94,7 @@ internal class Program {
if (!e.Request.RawUrl.StartsWith("/api/")) {
var path = e.Request.RawUrl == "/" || e.Request.RawUrl.StartsWith("/deckeditor") || e.Request.RawUrl.StartsWith("/game/") || e.Request.RawUrl.StartsWith("/replay/")
? "index.html"
: e.Request.RawUrl[1..];
: HttpUtility.UrlDecode(e.Request.RawUrl[1..]);
if (e.TryReadFile(path, out var bytes))
SetResponse(e.Response, HttpStatusCode.OK,
Path.GetExtension(path) switch {
@ -102,6 +102,7 @@ internal class Program {
".css" => "text/css",
".js" => "text/javascript",
".png" => "image/png",
".webp" => "image/webp",
".woff" or ".woff2" => "font/woff",
_ => "application/octet-stream"
}, bytes);
@ -163,7 +164,7 @@ internal class Program {
games.Add(game.ID, game);
timer.Start();
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(new { gameID = game.ID, clientToken, maxPlayers }));
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers }));
Console.WriteLine($"New game started: {game.ID}; {games.Count} games active; {inactiveGames.Count} inactive");
} catch (ArgumentException) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
@ -193,7 +194,7 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint."));
return;
}
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(game));
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game));
break;
}
case "playerData": {
@ -206,7 +207,7 @@ internal class Program {
if (!Guid.TryParse(m.Groups[3].Value, out var clientToken))
clientToken = Guid.Empty;
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(new {
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new {
game,
playerData = game.GetPlayer(clientToken, out var playerIndex, out var player)
? new PlayerData(playerIndex, player)
@ -256,7 +257,7 @@ internal class Program {
game.SendEvent("join", new { playerIndex, player }, false);
}
// If they're already in the game, resend the original join response instead of an error.
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(new { playerIndex, clientToken }));
SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken }));
timer.Start();
} catch (ArgumentException) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data"));
@ -375,6 +376,11 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name."));
return;
}
var deckSleeves = 0;
if (d.TryGetValue("deckSleeves", out var deckSleevesString) && (!int.TryParse(deckSleevesString, out deckSleeves) || deckSleeves is < 0 or >= 25)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves."));
return;
}
if (!d.TryGetValue("deckCards", out var deckString)) {
SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards."));
return;
@ -384,8 +390,21 @@ internal class Program {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
}
int[]? upgrades = null;
if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) {
upgrades = new int[15];
var array2 = deckUpgradesString.Split(new[] { ',', '+', ' ' }, 15);
for (var i = 0; i < 15; i++) {
if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2)
upgrades[i] = j;
else {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list."));
return;
}
}
}
var cards = new int[15];
for (int i = 0; i < 15; i++) {
for (var i = 0; i < 15; i++) {
if (!int.TryParse(array[i], out var cardNumber) || !CardDatabase.IsValidCardNumber(cardNumber)) {
SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list."));
return;
@ -406,7 +425,7 @@ internal class Program {
}
}
player.CurrentGameData.Deck = cards.Select(CardDatabase.GetCard).ToArray();
player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15));
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex, false);
timer.Start();
@ -584,7 +603,7 @@ internal class Program {
}
private static void SetErrorResponse(HttpListenerResponse response, Error error) {
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(error));
var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error));
SetResponse(response, error.HttpStatusCode, "application/json", bytes);
}
private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) {

View File

@ -2,12 +2,8 @@
namespace TableturfBattleServer;
public class SingleGameData {
[JsonProperty("specialPoints")]
public int SpecialPoints { get; set; }
[JsonProperty("totalSpecialPoints")]
public int TotalSpecialPoints { get; set; }
[JsonProperty("passes")]
public int Passes { get; set; }
[JsonIgnore]
@ -17,7 +13,7 @@ public class SingleGameData {
internal List<ReplayTurn> turns = new(12);
[JsonIgnore]
internal Card[]? Deck;
internal Deck? Deck;
[JsonIgnore]
internal int[]? initialDrawOrder;
[JsonIgnore]

View File

@ -2,9 +2,8 @@
namespace TableturfBattleServer;
public class Stage {
[JsonProperty("name")]
public string Name { get; }
[JsonProperty("grid")]
[JsonProperty]
internal readonly Space[,] grid;
/// <summary>
/// The lists of starting spaces on this stage.
@ -13,7 +12,7 @@ public class Stage {
/// The smallest list with at least as many spaces as players in the game will be used.
/// For example, if there is a list of 3 and a list of 4, the list of 3 will be used for 2 or 3 players, and the list of 4 will be used for 4 players.
/// </remarks>
[JsonProperty("startSpaces")]
[JsonProperty]
internal readonly Point[][] startSpaces;
public Stage(string name, Space[,] grid, Point[][] startSpaces) {

View File

@ -127,6 +127,6 @@ internal class StageDatabase {
static StageDatabase() {
Stages = Array.AsReadOnly(stages);
JSON = JsonConvert.SerializeObject(stages);
JSON = JsonUtils.Serialise(stages);
}
}

View File

@ -30,9 +30,9 @@ internal class TableturfWebSocketBehaviour : WebSocketBehavior {
break;
}
}
this.Send(JsonConvert.SerializeObject(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", game, playerData)));
this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", game, playerData)));
} else
this.Send(JsonConvert.SerializeObject(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", null, null)));
this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData<Game?>("sync", null, null)));
}
internal void SendInternal(string data) => this.Send(data);