feat: add Forbidden Island card spritesheet and register Solitaire Tour game

- Implement visual card rendering for Forbidden Island using a new spritesheet with procedural fallback
- Register and wire up the new "Solitaire Tour" game across client, server, and preload scenes
- Update game icon and card asset bundles
This commit is contained in:
Brian Fertig 2026-06-02 00:02:12 -06:00
parent 6705f6bd15
commit efb8842368
12 changed files with 1127 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

View File

@ -9,7 +9,7 @@ import {
} from './IslandLogic.js';
import {
TREASURES, TREASURE_KEYS, ROLES, ROLE_KEYS, SPECIAL, MAX_WATER, DIFFICULTY,
floodDrawCount, GRID, CARDS_TO_CAPTURE, HAND_LIMIT, TILE_FRAME_ROW,
floodDrawCount, GRID, CARDS_TO_CAPTURE, HAND_LIMIT, TILE_FRAME_ROW, cardFrame,
} from './IslandData.js';
import { chooseAction, chooseFreeCard, chooseDiscard, describeIntent, nextThinkDelay } from './IslandAI.js';
import { lineForIntent, lineForEvent, lineForAck, roleEmoji, roleName, roleColorHex } from './IslandChat.js';
@ -334,18 +334,27 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
this.handLayer.removeAll(true);
const me = this.gs.players[this.humanSeat];
const cardW = 92, cardH = 124, gap = 10;
const hasArt = this.textures.exists('forbiddenisland-cards');
me.hand.forEach((card, i) => {
const x = RAIL_X + 8 + i * (cardW + gap) + cardW / 2;
const y = 884 + cardH / 2;
const cont = this.add.container(x, y);
const g = this.add.graphics();
const info = cardInfo(card);
g.fillStyle(info.color, 1); g.fillRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
g.lineStyle(2, 0xffffff, 0.6); g.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
const label = this.add.text(0, 0, info.label, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: info.text, align: 'center', wordWrap: { width: cardW - 12 },
}).setOrigin(0.5);
cont.add([g, label]);
const frame = hasArt ? cardFrame(card) : null;
if (frame != null) {
const img = this.add.image(0, 0, 'forbiddenisland-cards', frame).setDisplaySize(cardW, cardH);
const border = this.add.graphics();
border.lineStyle(2, 0xffffff, 0.6); border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
cont.add([img, border]);
} else {
const g = this.add.graphics();
const info = cardInfo(card);
g.fillStyle(info.color, 1); g.fillRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
g.lineStyle(2, 0xffffff, 0.6); g.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 10);
const label = this.add.text(0, 0, info.label, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: info.text, align: 'center', wordWrap: { width: cardW - 12 },
}).setOrigin(0.5);
cont.add([g, label]);
}
cont.setSize(cardW, cardH).setInteractive(new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH), Phaser.Geom.Rectangle.Contains);
cont.on('pointerup', () => this.onCardClick(card, i));
cont.on('pointerover', () => { if (!this.busy) cont.y = y - 8; });

View File

@ -129,3 +129,20 @@ export function tileById(id) {
// Tile id → its row in the `forbiddenisland-tiles` spritesheet. Dry side is
// frame 2·row, flooded side is frame 2·row+1.
export const TILE_FRAME_ROW = Object.fromEntries(TILES.map((t, i) => [t.id, i]));
// Card id → frame in the `forbiddenisland-cards` spritesheet (320×420 cells, a
// 4-col × 2-row grid). Top row: the four treasure cards. Bottom row: the three
// special cards (frame 7 is unused / a card back).
export const CARD_FRAME = {
'treasure:earth': 0,
'treasure:wind': 1,
'treasure:fire': 2,
'treasure:ocean': 3,
[SPECIAL.WATERS_RISE]: 4,
[SPECIAL.HELICOPTER]: 5,
[SPECIAL.SANDBAGS]: 6,
};
export function cardFrame(card) {
return CARD_FRAME[card] ?? null;
}

View File

@ -0,0 +1,690 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { TOUR, GAME_META, createEngine } from './SolitaireTourLogic.js';
const CARD_W = 100;
const CARD_H = 140;
const CARD_R = 10;
const COL_GAP = 132;
const colX = (c) => 564 + c * COL_GAP; // 7 columns, centred on 960
const FELT = 0x0e3b2a;
const SEL = 0x2ec28a; // selection rim (green)
const HINT = 0xc8a84b; // "playable now" rim (gold)
const D = { felt: -2, table: -1, card: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 };
export default class SolitaireTourGame extends Phaser.Scene {
constructor() { super('SolitaireTourGame'); }
init(data) {
this.gameDef = data.game;
this.tourIndex = 0;
this.total = 0;
this.results = [];
this.recorded = false;
this.engine = null;
this.legType = null;
this.sel = null;
this.legEnded = false;
this.overlayUp = false;
this.overlayObjs = [];
this.pulse = null;
// Klondike / Three Shuffles drag-and-drop state.
this.kSprites = new Map(); // card.id → container (for the current render)
this.potentialDrag = null; // pointer-down recorded, not yet a drag
this.dragState = null; // an in-progress drag
this.dropHighlight = null;
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* music optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH - 24, GAME_HEIGHT - 24, FELT)
.setStrokeStyle(3, COLORS.accent, 0.35).setDepth(D.table);
this.add.text(40, 28, 'SOLITAIRE TOUR', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
}).setDepth(D.ui);
this.legText = this.add.text(40, 84, '', { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex }).setDepth(D.ui);
this.blurbText = this.add.text(40, 120, '', { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, wordWrap: { width: 1020 } }).setDepth(D.ui);
this.totalText = this.add.text(GAME_WIDTH - 40, 28, '', { fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex }).setOrigin(1, 0).setDepth(D.ui);
this.liveText = this.add.text(GAME_WIDTH - 40, 72, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(1, 0).setDepth(D.ui);
this.statusText = this.add.text(GAME_WIDTH - 40, 102, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex }).setOrigin(1, 0).setDepth(D.ui);
this.stuckBanner = this.add.text(GAME_WIDTH / 2, 156, 'No moves left — tap “No More Moves” to bank the remaining cards.', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.banner).setVisible(false);
this.board = this.add.container(0, 0).setDepth(D.card);
this.noMoreBtn = new Button(this, GAME_WIDTH / 2, 1018, 'No More Moves', () => this.onNoMore(),
{ width: 300, height: 60, fontSize: 24 });
this.noMoreBtn.setDepth(D.ui);
this.drawBtn = new Button(this, GAME_WIDTH / 2 + 360, 1018, 'Draw', () => this.onDraw(),
{ width: 170, height: 60, fontSize: 24 });
this.drawBtn.setDepth(D.ui).setVisible(false);
this.leaveBtn = new Button(this, GAME_WIDTH - 110, 1042, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, height: 54, fontSize: 22 });
this.leaveBtn.setDepth(D.ui);
this.setupKlondikeDrag();
this.startLeg();
}
// ── tour flow ───────────────────────────────────────────────────────────────
startLeg() {
this.legType = TOUR[this.tourIndex];
this.engine = createEngine(this.legType);
this.sel = null;
this.legEnded = false;
playSound(this, SFX.CARD_SHUFFLE);
this.refresh();
}
interactive() { return !this.legEnded && !this.overlayUp; }
applyMove(ok, sfx = SFX.CARD_PLACE) {
if (ok) { playSound(this, sfx); this.refresh(); }
return ok;
}
refresh() {
this.renderBoard();
this.updateHud();
if (!this.legEnded && this.engine.isWon()) { this.endLeg(); return; }
this.updateStuck();
}
updateHud() {
const meta = GAME_META[this.legType];
this.legText.setText(`Leg ${this.tourIndex + 1} / ${TOUR.length} · ${meta.name}`);
this.blurbText.setText(meta.blurb);
this.totalText.setText(`Tour total: ${this.total}`);
this.liveText.setText(`Stop now → +${this.engine.remainingValue()}`);
const e = this.engine;
let status = '';
if (this.legType === 'golf') status = `Stock: ${e.stock.length}`;
else if (this.legType === 'klondike') status = `Stock: ${e.stock.length} · Waste: ${e.waste.length}`;
else if (this.legType === 'pyramid') status = `Stock: ${e.stock.length} · Passes left: ${e.passesLeft}`;
else if (this.legType === 'fourteen') status = 'Remove pairs that total 14';
else if (this.legType === 'threeshuffles') status = `Shuffles left: ${e.recyclesLeft} · Draw: ${e.drawsLeft}`;
this.statusText.setText(status);
const showDraw = this.legType === 'threeshuffles';
this.drawBtn.setVisible(showDraw);
if (showDraw) this.drawBtn.setEnabled(e.drawsLeft > 0 && e.stock.length > 0);
}
updateStuck() {
const stuck = this.interactive() && !this.engine.hasMoves();
this.stuckBanner.setVisible(stuck);
if (stuck && !this.pulse) {
this.pulse = this.tweens.add({ targets: this.noMoreBtn, scaleX: 1.06, scaleY: 1.06, yoyo: true, repeat: -1, duration: 520, ease: 'Sine.easeInOut' });
} else if (!stuck && this.pulse) {
this.pulse.stop(); this.pulse = null; this.noMoreBtn.setScale(1);
}
}
onNoMore() {
if (!this.interactive()) return;
this.endLeg();
}
onDraw() {
if (!this.interactive() || this.legType !== 'threeshuffles') return;
this.applyMove(this.engine.useDraw(), SFX.CARD_SHOW);
}
endLeg() {
if (this.legEnded) return;
this.legEnded = true;
this.sel = null;
if (this.pulse) { this.pulse.stop(); this.pulse = null; this.noMoreBtn.setScale(1); }
this.stuckBanner.setVisible(false);
const won = this.engine.isWon();
const pts = won ? 0 : this.engine.remainingValue();
this.total += pts;
this.results.push({ type: this.legType, pts, won });
playSound(this, won ? SFX.CASINO_WIN : SFX.CARD_PLACE);
this.showRecap(pts, won);
}
// ── overlays ─────────────────────────────────────────────────────────────────
clearOverlay() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
}
overlayPanel(w, h) {
this.overlayUp = true;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
const g = this.add.graphics().setDepth(D.overlay);
g.fillStyle(COLORS.panel, 1); g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 20);
g.lineStyle(3, COLORS.accent, 1); g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 20);
this.overlayObjs.push(dim, g);
return { cx, cy };
}
showRecap(pts, won) {
const { cx, cy } = this.overlayPanel(820, 460);
const meta = GAME_META[this.legType];
const last = this.tourIndex === TOUR.length - 1;
this.overlayObjs.push(this.add.text(cx, cy - 165, won ? 'Board Cleared!' : 'No More Moves', {
fontFamily: 'Righteous', fontSize: '54px', color: won ? '#5fd29a' : COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx, cy - 92, meta.name, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx, cy - 24, won ? '+0 — perfect, nothing left behind!' : `Cards left this leg → +${pts} points`, {
fontFamily: 'Righteous', fontSize: '40px', color: won ? '#5fd29a' : COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx, cy + 48, `Tour total: ${this.total}`, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
const nextName = last ? null : GAME_META[TOUR[this.tourIndex + 1]].name;
const cont = new Button(this, cx, cy + 150, last ? 'See Final Score' : `Next: ${nextName}`, () => {
this.clearOverlay(); this.overlayUp = false; this.advance();
}, { width: 420, height: 64, fontSize: 26 });
cont.setDepth(D.overlayUI);
this.overlayObjs.push(cont);
}
advance() {
this.tourIndex++;
if (this.tourIndex < TOUR.length) this.startLeg();
else this.showFinal();
}
showFinal() {
const { cx, cy } = this.overlayPanel(900, 760);
this.recordResult();
this.overlayObjs.push(this.add.text(cx, cy - 320, 'TOUR COMPLETE', {
fontFamily: 'Righteous', fontSize: '56px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx, cy - 238, `${this.total}`, {
fontFamily: 'Righteous', fontSize: '96px', color: this.total === 0 ? '#5fd29a' : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx, cy - 168, 'total points · closest to zero wins', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
this.results.forEach((r, i) => {
const y = cy - 96 + i * 46;
this.overlayObjs.push(this.add.text(cx - 320, y, GAME_META[r.type].name, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx + 320, y, r.won ? 'cleared · +0' : `+${r.pts}`, {
fontFamily: 'Righteous', fontSize: '24px', color: r.won ? '#5fd29a' : COLORS.dangerHex,
}).setOrigin(1, 0.5).setDepth(D.overlayUI));
});
this.overlayObjs.push(this.add.text(cx, cy + 192, this.resultBlurb(), {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
const again = new Button(this, cx - 170, cy + 290, 'Play Again', () => {
this.clearOverlay(); this.overlayUp = false;
this.tourIndex = 0; this.total = 0; this.results = []; this.recorded = false;
this.startLeg();
}, { width: 280, height: 64, fontSize: 26 });
again.setDepth(D.overlayUI);
const leave = new Button(this, cx + 170, cy + 290, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 280, height: 64, fontSize: 26 });
leave.setDepth(D.overlayUI);
this.overlayObjs.push(again, leave);
}
resultBlurb() {
const t = this.total;
if (t === 0) return 'Flawless tour — a perfect zero!';
if (t <= 40) return 'Outstanding — right on the edge of zero!';
if (t <= 90) return 'Solid run around the course.';
if (t <= 160) return 'Not bad — a few cards got away.';
return 'Plenty of room to improve next tour.';
}
recordResult() {
if (this.recorded) return;
this.recorded = true;
const result = this.total <= 40 ? 'win' : this.total <= 120 ? 'draw' : 'loss';
api.post('/history/single-player', {
slug: 'solitairetour', score: this.total, opponentScores: [], result,
}).catch(() => { /* best effort */ });
}
// ── card drawing helpers ─────────────────────────────────────────────────────
drawFace(container, card, faceUp, selected, hint) {
const x = -CARD_W / 2, y = -CARD_H / 2;
const g = this.add.graphics();
if (!faceUp) {
g.fillStyle(0x16324f, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, COLORS.accent, 0.7); g.strokeRoundedRect(x + 5, y + 5, CARD_W - 10, CARD_H - 10, CARD_R - 2);
container.add(g);
container.add(this.add.text(0, 0, '✦', { fontFamily: 'serif', fontSize: '40px', color: '#caa84b' }).setOrigin(0.5).setAlpha(0.55));
return;
}
g.fillStyle(0xfbf6e7, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
const rimColor = selected ? SEL : hint ? HINT : 0xcc803a;
const rimW = selected ? 5 : hint ? 4 : 2;
g.lineStyle(rimW, rimColor, selected || hint ? 1 : 0.5);
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
container.add(g);
const col = card.isRed ? '#c0392b' : '#1a1208';
container.add(this.add.text(x + 9, y + 6, card.label, { fontFamily: 'Righteous', fontSize: '24px', color: col }));
container.add(this.add.text(x + 11, y + 35, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '22px', color: col }));
container.add(this.add.text(0, 6, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '48px', color: col }).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 9, y + CARD_H - 6, card.label, { fontFamily: 'Righteous', fontSize: '24px', color: col }).setOrigin(1, 1));
}
card(cardObj, x, y, opts = {}) {
const { faceUp = true, selected = false, hint = false, dim = false, onClick = null, onDown = null } = opts;
const c = this.add.container(x, selected ? y - 10 : y);
this.drawFace(c, cardObj, faceUp, selected, hint);
if (dim) c.setAlpha(0.4);
if (onClick || onDown) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = onDown ? 'grab' : 'pointer';
if (onClick) c.on('pointerdown', onClick);
if (onDown) c.on('pointerdown', (pointer) => onDown(c, pointer));
}
this.board.add(c);
return c;
}
slot(x, y, label, onClick = null) {
const c = this.add.container(x, y);
const g = this.add.graphics();
g.lineStyle(2, COLORS.accent, 0.4);
g.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, CARD_R);
c.add(g);
if (label) c.add(this.add.text(0, 0, label, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, align: 'center' }).setOrigin(0.5));
if (onClick) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', onClick);
}
this.board.add(c);
return c;
}
// ── board rendering / input, per game ─────────────────────────────────────────
renderBoard() {
this.board.removeAll(true);
switch (this.legType) {
case 'golf': return this.renderGolf();
case 'klondike': return this.renderKlondike();
case 'pyramid': return this.renderPyramid();
case 'fourteen': return this.renderFourteen();
case 'threeshuffles': return this.renderKlondike();
}
}
// Golf -------------------------------------------------------------------------
renderGolf() {
const e = this.engine;
const topY = 250, fan = 30;
for (let c = 0; c < 7; c++) {
const pile = e.tableau[c];
if (!pile.length) { this.slot(colX(c), topY, ''); continue; }
pile.forEach((card, idx) => {
const last = idx === pile.length - 1;
this.card(card, colX(c), topY + idx * fan, {
hint: last && e.canPlay(card),
onClick: last ? () => this.gAct(() => e.playColumn(c)) : null,
});
});
}
// foundation + stock
const top = e.foundationTop();
if (top) this.card(top, 880, 800, {});
if (e.stock.length) {
this.card({}, 1040, 800, { faceUp: false, onClick: () => this.gAct(() => e.dealStock(), SFX.CARD_SHOW) });
this.board.add(this.add.text(1040, 884, `Stock ×${e.stock.length}`, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0.5));
} else {
this.slot(1040, 800, 'empty');
}
this.board.add(this.add.text(880, 884, 'Foundation', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0.5));
}
gAct(fn, sfx = SFX.CARD_PLACE) { if (!this.interactive()) return; this.applyMove(fn(), sfx); }
// Klondike / Three Shuffles ----------------------------------------------------
renderKlondike() {
const e = this.engine;
const suits = ['s', 'h', 'd', 'c'];
this.kSprites.clear();
// stock
if (e.stock.length) {
this.card({}, 600, 250, { faceUp: false, onClick: () => this.kStock() });
} else {
this.slot(600, 250, e.recyclesLeft > 0 ? '↻ recycle' : 'empty', e.waste.length && e.recyclesLeft > 0 ? () => this.kStock() : null);
}
this.board.add(this.add.text(600, 332, `Stock ×${e.stock.length}`, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5));
// waste (fan last 3)
const wShown = e.waste.slice(-3);
if (!wShown.length) this.slot(735, 250, '');
wShown.forEach((card, i) => {
const isTop = i === wShown.length - 1;
const c = this.card(card, 735 + i * 26, 250, {
selected: isTop && this.sel?.kind === 'waste',
onDown: isTop ? (_co, pointer) => this.kPointerDown({ kind: 'waste' }, pointer) : null,
});
this.kSprites.set(card.id, c);
});
// foundations
suits.forEach((s, i) => {
const x = 1090 + i * COL_GAP;
const top = e.foundationTop(s);
if (top) this.card(top, x, 250, { onClick: () => this.kFoundation() });
else this.slot(x, 250, { s: '♠', h: '♥', d: '♦', c: '♣' }[s], () => this.kFoundation());
});
// tableau
const topY = 430;
for (let c = 0; c < 7; c++) {
const pile = e.tableau[c];
if (!pile.length) { this.slot(colX(c), topY, '', () => this.kColumnDrop(c)); continue; }
let y = topY;
pile.forEach((entry, idx) => {
const selected = this.sel?.kind === 'run' && this.sel.col === c && idx >= this.sel.idx;
const cont = this.card(entry.card, colX(c), y, {
faceUp: entry.faceUp,
selected,
onDown: entry.faceUp ? (_co, pointer) => this.kPointerDown({ kind: 'tableau', col: c, idx }, pointer) : null,
});
if (entry.faceUp) this.kSprites.set(entry.card.id, cont);
y += entry.faceUp ? 32 : 14;
});
}
}
kStock() {
if (!this.interactive()) return;
this.sel = null;
const recycle = this.engine.stock.length === 0;
this.applyMove(this.engine.dealStock(), recycle ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
}
kWaste() {
if (!this.interactive()) return;
if (this.sel?.kind === 'waste') this.sel = null;
else this.sel = { kind: 'waste' };
this.refresh();
}
kCard(col, idx) {
if (!this.interactive()) return;
if (this.sel && this.kDrop(col)) return;
const run = this.engine.faceUpRun(col, idx);
if (run) {
if (this.sel?.kind === 'run' && this.sel.col === col && this.sel.idx === idx) this.sel = null;
else this.sel = { kind: 'run', col, idx };
} else {
this.sel = null;
}
this.refresh();
}
kColumnDrop(col) {
if (!this.interactive() || !this.sel) return;
if (!this.kDrop(col)) { this.sel = null; this.refresh(); }
}
kDrop(destCol) {
const e = this.engine;
let ok = false;
if (this.sel.kind === 'waste') ok = e.moveWasteToColumn(destCol);
else ok = e.moveRun(this.sel.col, this.sel.idx, destCol);
if (ok) { this.sel = null; this.applyMove(true); return true; }
return false;
}
kFoundation() {
if (!this.interactive() || !this.sel) return;
const e = this.engine;
let ok = false;
if (this.sel.kind === 'waste') ok = e.playWasteToFoundation();
else {
const run = e.faceUpRun(this.sel.col, this.sel.idx);
if (run && run.length === 1) ok = e.playColumnToFoundation(this.sel.col);
}
this.sel = null;
if (ok) this.applyMove(true); else this.refresh();
}
// Klondike drag-and-drop. Pointer-down on a card records a potential drag;
// moving past a small threshold promotes it to a real drag (carrying any
// valid run beneath), and a release without movement falls back to a tap.
setupKlondikeDrag() {
this.input.on('pointermove', (pointer) => {
if (!pointer.isDown) return;
if (this.dragState) {
this.kUpdateDrag(pointer);
} else if (this.potentialDrag) {
const dx = pointer.x - this.potentialDrag.startX;
const dy = pointer.y - this.potentialDrag.startY;
if (dx * dx + dy * dy > 80) this.kPromoteDrag();
}
});
this.input.on('pointerup', () => {
if (this.dragState) this.kEndDrag();
else if (this.potentialDrag) {
const pd = this.potentialDrag;
this.potentialDrag = null;
this.kTap(pd.descriptor);
}
});
}
kDragSprites(descriptor) {
if (descriptor.kind === 'waste') {
const top = this.engine.waste[this.engine.waste.length - 1];
const o = top && this.kSprites.get(top.id);
return o ? [o] : [];
}
const run = this.engine.faceUpRun(descriptor.col, descriptor.idx);
if (!run) return [];
return run.map((card) => this.kSprites.get(card.id)).filter(Boolean);
}
kPointerDown(descriptor, pointer) {
if (!this.interactive() || this.dragState) return;
const objs = this.kDragSprites(descriptor);
if (!objs.length) return;
this.potentialDrag = {
descriptor,
startX: pointer.x, startY: pointer.y,
sprites: objs.map((obj) => ({ obj, offX: obj.x - pointer.x, offY: obj.y - pointer.y, homeX: obj.x, homeY: obj.y })),
};
}
kPromoteDrag() {
const pd = this.potentialDrag;
this.potentialDrag = null;
pd.sprites.forEach(({ obj }) => {
this.board.bringToTop(obj);
this.tweens.add({ targets: obj, scaleX: 1.05, scaleY: 1.05, duration: 90 });
});
this.dragState = pd;
}
kUpdateDrag(pointer) {
for (const { obj, offX, offY } of this.dragState.sprites) {
obj.x = pointer.x + offX;
obj.y = pointer.y + offY;
}
const primary = this.dragState.sprites[0].obj;
this.kUpdateDropHighlight(this.kDropTargetAt(primary.x, primary.y));
}
kDropTargetAt(x, y) {
for (let i = 0; i < 4; i++) {
if (Math.abs(x - (1090 + i * COL_GAP)) < CARD_W * 0.7 && Math.abs(y - 250) < CARD_H * 0.8) {
return { type: 'foundation', idx: i };
}
}
for (let c = 0; c < 7; c++) {
if (Math.abs(x - colX(c)) < CARD_W * 0.7 && y > 360) return { type: 'column', col: c };
}
return null;
}
kUpdateDropHighlight(target) {
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
if (!target) return;
const pos = target.type === 'foundation'
? { x: 1090 + target.idx * COL_GAP, y: 250 }
: { x: colX(target.col), y: 430 + this.engine.tableau[target.col].length * 32 };
const color = target.type === 'foundation' ? 0xffd700 : SEL;
this.dropHighlight = this.add.rectangle(pos.x, pos.y, CARD_W + 16, CARD_H + 16, color, 0.18)
.setStrokeStyle(3, color, 0.9).setDepth(D.card - 1);
}
kEndDrag() {
const ds = this.dragState;
this.dragState = null;
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
const primary = ds.sprites[0].obj;
const target = this.kDropTargetAt(primary.x, primary.y);
let ok = false;
if (target) {
ok = target.type === 'foundation'
? this.kCommitFoundation(ds.descriptor)
: this.kCommitColumn(ds.descriptor, target.col);
}
if (ok) { this.sel = null; this.applyMove(true); return; }
// Rejected drop — slide the cards back where they came from.
ds.sprites.forEach(({ obj, homeX, homeY }) => {
this.tweens.add({ targets: obj, x: homeX, y: homeY, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' });
});
}
kCommitColumn(descriptor, destCol) {
if (descriptor.kind === 'waste') return this.engine.moveWasteToColumn(destCol);
return this.engine.moveRun(descriptor.col, descriptor.idx, destCol);
}
kCommitFoundation(descriptor) {
const e = this.engine;
if (descriptor.kind === 'waste') return e.playWasteToFoundation();
const run = e.faceUpRun(descriptor.col, descriptor.idx);
return run && run.length === 1 ? e.playColumnToFoundation(descriptor.col) : false;
}
kTap(descriptor) {
if (descriptor.kind === 'waste') this.kWaste();
else this.kCard(descriptor.col, descriptor.idx);
}
// Pyramid ----------------------------------------------------------------------
renderPyramid() {
const e = this.engine;
const dx = 104, dy = 64, topY = 176, cxx = 960;
for (let r = 0; r < 7; r++) {
for (let i = 0; i <= r; i++) {
const card = e.rows[r][i];
if (!card) continue;
const x = cxx + (i - r / 2) * dx;
const y = topY + r * dy;
const avail = e.available(r, i);
const selected = this.sel?.area === 'pyramid' && this.sel.r === r && this.sel.i === i;
this.card(card, x, y, {
dim: !avail,
selected,
hint: avail && card.pval === 13,
onClick: avail ? () => this.pPick({ area: 'pyramid', r, i }) : null,
});
}
}
// stock + waste
if (e.stock.length) this.card({}, 800, 840, { faceUp: false, onClick: () => this.pStock() });
else this.slot(800, 840, e.passesLeft > 0 ? '↻ recycle' : 'empty', e.waste.length && e.passesLeft > 0 ? () => this.pStock() : null);
this.board.add(this.add.text(800, 924, `Stock ×${e.stock.length}`, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5));
const wt = e.wasteTop();
if (wt) this.card(wt, 960, 840, { selected: this.sel?.area === 'waste', hint: wt.pval === 13, onClick: () => this.pPick({ area: 'waste' }) });
else this.slot(960, 840, 'waste');
}
pStock() {
if (!this.interactive()) return;
this.sel = null;
const recycle = this.engine.stock.length === 0;
this.applyMove(this.engine.dealStock(), recycle ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
}
pPick(loc) {
if (!this.interactive()) return;
const e = this.engine;
const card = e.cardAt(loc);
if (!card) return;
if (card.pval === 13) { this.sel = null; this.applyMove(e.removeKing(loc)); return; }
if (this.sel) {
if (this.sameLoc(this.sel, loc)) { this.sel = null; this.refresh(); return; }
if (e.removePair(this.sel, loc)) { this.sel = null; this.applyMove(true); return; }
}
this.sel = loc;
this.refresh();
}
sameLoc(a, b) { return a.area === b.area && (a.area === 'waste' || (a.r === b.r && a.i === b.i)); }
// Take Fourteen ----------------------------------------------------------------
renderFourteen() {
const e = this.engine;
const fan = 26;
for (let p = 0; p < 12; p++) {
const x = 585 + (p % 6) * 150;
const topY = p < 6 ? 250 : 560;
const pile = e.piles[p];
if (!pile.length) { this.slot(x, topY, ''); continue; }
pile.forEach((card, idx) => {
const isTop = idx === pile.length - 1;
this.card(card, x, topY + idx * fan, {
selected: isTop && this.sel === p,
onClick: isTop ? () => this.fPick(p) : null,
});
});
}
}
fPick(p) {
if (!this.interactive()) return;
const e = this.engine;
if (this.sel != null) {
if (this.sel === p) { this.sel = null; this.refresh(); return; }
if (e.removePair(this.sel, p)) { this.sel = null; this.applyMove(true); return; }
}
this.sel = p;
this.refresh();
}
}

View File

@ -0,0 +1,392 @@
// Solitaire Tour — pure game logic for the five-game tour.
//
// Each of the five solitaires is a self-contained engine exposing a common
// surface the scene relies on:
// • mutation methods that return true when they changed state
// • hasMoves() → false when the player is stuck (drives the
// "No More Moves" prompt)
// • remainingValue() → sum of the point value of every card NOT yet cleared
// (this is what gets added to the tour score)
// • isWon() → true when every card has been cleared
//
// Card point values are A=1 … 10=10, J=11, Q=12, K=13 — the same scale Pyramid
// and Take Fourteen use for their pairing rules, applied everywhere for scoring.
import { Card, SUITS, RANKS } from '../cards/Deck.js';
// A=1 … K=13 (RANKS is ['2'…'A']; 'T' is the ten).
const PVAL = { A: 1, T: 10, J: 11, Q: 12, K: 13 };
for (let n = 2; n <= 9; n++) PVAL[String(n)] = n;
/** Build a shuffled 52-card deck; every card carries a stable id + pval (113). */
export function freshDeck() {
const cards = [];
let id = 0;
for (const suit of SUITS) {
for (const rank of RANKS) {
const c = new Card(rank, suit);
c.id = id++;
c.pval = PVAL[rank];
cards.push(c);
}
}
for (let i = cards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[cards[i], cards[j]] = [cards[j], cards[i]];
}
return cards;
}
const sumVal = (cards) => cards.reduce((t, c) => t + c.pval, 0);
// The five legs of the tour, in play order.
export const TOUR = ['golf', 'klondike', 'pyramid', 'fourteen', 'threeshuffles'];
export const GAME_META = {
golf: { name: 'Golf', blurb: 'Clear the tableau onto the foundation, one rank up or down. No wrapping (Aces are low).' },
klondike: { name: 'Klondike', blurb: 'Build the four foundations up by suit from Ace. Stack the tableau down in alternating colours.' },
pyramid: { name: 'Pyramid', blurb: 'Remove pairs of exposed cards that total 13. Kings clear on their own.' },
fourteen: { name: 'Take Fourteen', blurb: 'Remove pairs of available cards that total 14 until every pile is empty.' },
threeshuffles: { name: 'Three Shuffles and a Draw', blurb: 'Klondike-style, but the stock only recycles three times — then take one Draw to pull a buried card.' },
};
// ── Golf ───────────────────────────────────────────────────────────────────────
export class GolfEngine {
constructor(deck) {
this.type = 'golf';
this.tableau = Array.from({ length: 7 }, () => []);
let k = 0;
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 7; col++) this.tableau[col].push(deck[k++]);
}
this.foundation = [deck[k++]]; // one card seeded face-up
this.stock = deck.slice(k); // remaining 16
}
foundationTop() { return this.foundation[this.foundation.length - 1] ?? null; }
/** Classic Golf: play onto the foundation if exactly one rank away, no wrap. */
canPlay(card) {
const top = this.foundationTop();
return !!top && Math.abs(card.pval - top.pval) === 1;
}
playColumn(col) {
const pile = this.tableau[col];
const card = pile[pile.length - 1];
if (!card || !this.canPlay(card)) return false;
this.foundation.push(pile.pop());
return true;
}
dealStock() {
if (!this.stock.length) return false;
this.foundation.push(this.stock.pop());
return true;
}
hasMoves() {
if (this.stock.length) return true;
return this.tableau.some((p) => p.length && this.canPlay(p[p.length - 1]));
}
remainingValue() {
return sumVal(this.stock) + this.tableau.reduce((t, p) => t + sumVal(p), 0);
}
isWon() { return this.tableau.every((p) => p.length === 0); }
}
// ── Klondike (also the base for Three Shuffles and a Draw) ──────────────────────
export class KlondikeEngine {
constructor(deck, opts = {}) {
this.type = opts.type ?? 'klondike';
this.recycleLimit = opts.recycleLimit ?? Infinity; // stock passes allowed
this.recyclesLeft = this.recycleLimit;
this.drawsLeft = opts.draws ?? 0; // "a Draw" power
this.tableau = Array.from({ length: 7 }, () => []);
let k = 0;
for (let col = 0; col < 7; col++) {
for (let row = 0; row <= col; row++) {
this.tableau[col].push({ card: deck[k++], faceUp: row === col });
}
}
this.stock = deck.slice(k); // 24
this.waste = [];
this.foundations = { s: [], h: [], d: [], c: [] };
}
foundationTop(suit) {
const f = this.foundations[suit];
return f[f.length - 1] ?? null;
}
canPlayFoundation(card) {
if (!card) return false;
const top = this.foundationTop(card.suit);
return top ? card.pval === top.pval + 1 : card.pval === 1;
}
// A face-up, properly-sequenced (down, alternating colour) run from `idx` down.
faceUpRun(col, idx) {
const pile = this.tableau[col];
if (idx < 0 || idx >= pile.length || !pile[idx].faceUp) return null;
for (let i = idx; i < pile.length - 1; i++) {
const a = pile[i].card, b = pile[i + 1].card;
if (!(b.pval === a.pval - 1 && b.isRed !== a.isRed)) return null;
}
return pile.slice(idx).map((e) => e.card);
}
canStack(card, destCol) {
const pile = this.tableau[destCol];
if (!pile.length) return card.pval === 13; // only a King to an empty column
const top = pile[pile.length - 1];
return top.faceUp && card.pval === top.card.pval - 1 && card.isRed !== top.card.isRed;
}
flipColumnTop(col) {
const p = this.tableau[col];
if (p.length && !p[p.length - 1].faceUp) p[p.length - 1].faceUp = true;
}
// ── moves ──
dealStock() {
if (this.stock.length) { this.waste.push(this.stock.pop()); return true; }
if (!this.waste.length || this.recyclesLeft <= 0) return false;
this.recyclesLeft--;
if (this.type === 'threeshuffles') {
for (let i = this.waste.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.waste[i], this.waste[j]] = [this.waste[j], this.waste[i]];
}
}
this.stock = this.waste.reverse();
this.waste = [];
return true;
}
playWasteToFoundation() {
const card = this.waste[this.waste.length - 1];
if (!this.canPlayFoundation(card)) return false;
this.foundations[card.suit].push(this.waste.pop());
return true;
}
playColumnToFoundation(col) {
const pile = this.tableau[col];
const top = pile[pile.length - 1];
if (!top || !top.faceUp || !this.canPlayFoundation(top.card)) return false;
this.foundations[top.card.suit].push(pile.pop().card);
this.flipColumnTop(col);
return true;
}
moveWasteToColumn(destCol) {
const card = this.waste[this.waste.length - 1];
if (!card || !this.canStack(card, destCol)) return false;
this.tableau[destCol].push({ card: this.waste.pop(), faceUp: true });
return true;
}
moveRun(srcCol, idx, destCol) {
if (srcCol === destCol) return false;
const run = this.faceUpRun(srcCol, idx);
if (!run || !this.canStack(run[0], destCol)) return false;
const moving = this.tableau[srcCol].splice(idx);
for (const e of moving) this.tableau[destCol].push(e);
this.flipColumnTop(srcCol);
return true;
}
/** The one "Draw": surface the most useful buried stock card onto the waste. */
useDraw() {
if (this.drawsLeft <= 0 || !this.stock.length) return false;
let pick = this.stock.findIndex((c) => this.canPlayFoundation(c));
if (pick < 0) pick = this.stock.findIndex((c) => this.tableau.some((_, col) => this.canStack(c, col)));
if (pick < 0) pick = this.stock.length - 1;
this.waste.push(this.stock.splice(pick, 1)[0]);
this.drawsLeft--;
return true;
}
anyImmediateMove() {
if (this.canPlayFoundation(this.waste[this.waste.length - 1])) return true;
for (let c = 0; c < 7; c++) {
const pile = this.tableau[c];
const top = pile[pile.length - 1];
if (top && this.canPlayFoundation(top.card)) return true;
// any face-up run head that can move to another column
const firstUp = pile.findIndex((e) => e.faceUp);
if (firstUp >= 0) {
const run = this.faceUpRun(c, firstUp);
if (run && this.tableau.some((_, d) => d !== c && this.canStack(run[0], d))) return true;
}
}
const w = this.waste[this.waste.length - 1];
if (w && this.tableau.some((_, d) => this.canStack(w, d))) return true;
return false;
}
hasMoves() {
if (this.anyImmediateMove()) return true;
if (this.stock.length) return true;
if (this.waste.length && this.recyclesLeft > 0) return true;
if (this.drawsLeft > 0 && this.stock.length) return true;
return false;
}
remainingValue() {
let total = sumVal(this.stock) + sumVal(this.waste);
for (const p of this.tableau) total += p.reduce((t, e) => t + e.card.pval, 0);
return total;
}
isWon() {
return ['s', 'h', 'd', 'c'].every((s) => this.foundations[s].length === 13);
}
}
export class ThreeShufflesEngine extends KlondikeEngine {
constructor(deck) {
super(deck, { type: 'threeshuffles', recycleLimit: 3, draws: 1 });
}
}
// ── Pyramid ────────────────────────────────────────────────────────────────────
export class PyramidEngine {
constructor(deck) {
this.type = 'pyramid';
this.rows = []; // rows[r][i] = card | null (null once removed)
let k = 0;
for (let r = 0; r < 7; r++) {
const row = [];
for (let i = 0; i <= r; i++) row.push(deck[k++]);
this.rows.push(row);
}
this.stock = deck.slice(k); // 24
this.waste = [];
this.passesLeft = 3;
}
/** Exposed = present and not covered by either card in the row below. */
available(r, i) {
if (!this.rows[r][i]) return false;
if (r === 6) return true;
return !this.rows[r + 1][i] && !this.rows[r + 1][i + 1];
}
wasteTop() { return this.waste[this.waste.length - 1] ?? null; }
removeKing(loc) {
const card = this.cardAt(loc);
if (!card || card.pval !== 13 || !this.locAvailable(loc)) return false;
this.clear(loc);
return true;
}
removePair(a, b) {
const ca = this.cardAt(a), cb = this.cardAt(b);
if (!ca || !cb || a === b) return false;
if (!this.locAvailable(a) || !this.locAvailable(b)) return false;
if (ca.pval + cb.pval !== 13) return false;
this.clear(a);
this.clear(b);
return true;
}
// Locations are { area:'pyramid', r, i } or { area:'waste' }.
cardAt(loc) { return loc.area === 'waste' ? this.wasteTop() : this.rows[loc.r][loc.i]; }
locAvailable(loc) { return loc.area === 'waste' ? !!this.wasteTop() : this.available(loc.r, loc.i); }
clear(loc) {
if (loc.area === 'waste') this.waste.pop();
else this.rows[loc.r][loc.i] = null;
}
dealStock() {
if (this.stock.length) { this.waste.push(this.stock.pop()); return true; }
if (!this.waste.length || this.passesLeft <= 0) return false;
this.passesLeft--;
this.stock = this.waste.reverse();
this.waste = [];
return true;
}
availableCards() {
const out = [];
for (let r = 0; r < 7; r++) for (let i = 0; i <= r; i++) {
if (this.available(r, i)) out.push(this.rows[r][i]);
}
if (this.wasteTop()) out.push(this.wasteTop());
return out;
}
hasMoves() {
const cards = this.availableCards();
if (cards.some((c) => c.pval === 13)) return true;
for (let a = 0; a < cards.length; a++)
for (let b = a + 1; b < cards.length; b++)
if (cards[a].pval + cards[b].pval === 13) return true;
if (this.stock.length) return true;
if (this.waste.length && this.passesLeft > 0) return true;
return false;
}
remainingValue() {
let total = sumVal(this.stock) + sumVal(this.waste);
for (const row of this.rows) for (const c of row) if (c) total += c.pval;
return total;
}
isWon() { return this.rows.every((row) => row.every((c) => !c)); }
}
// ── Take Fourteen ───────────────────────────────────────────────────────────────
export class FourteenEngine {
constructor(deck) {
this.type = 'fourteen';
// 12 piles: the first four hold 5 cards, the rest 4 (4×5 + 8×4 = 52).
this.piles = Array.from({ length: 12 }, () => []);
let k = 0;
for (let p = 0; p < 12; p++) {
const size = p < 4 ? 5 : 4;
for (let n = 0; n < size; n++) this.piles[p].push(deck[k++]);
}
}
top(p) { return this.piles[p][this.piles[p].length - 1] ?? null; }
removePair(p1, p2) {
if (p1 === p2) return false;
const a = this.top(p1), b = this.top(p2);
if (!a || !b || a.pval + b.pval !== 14) return false;
this.piles[p1].pop();
this.piles[p2].pop();
return true;
}
hasMoves() {
const tops = this.piles.map((_, p) => this.top(p)).filter(Boolean);
for (let a = 0; a < tops.length; a++)
for (let b = a + 1; b < tops.length; b++)
if (tops[a].pval + tops[b].pval === 14) return true;
return false;
}
remainingValue() { return this.piles.reduce((t, p) => t + sumVal(p), 0); }
isWon() { return this.piles.every((p) => p.length === 0); }
}
export function createEngine(type) {
const deck = freshDeck();
switch (type) {
case 'golf': return new GolfEngine(deck);
case 'klondike': return new KlondikeEngine(deck);
case 'pyramid': return new PyramidEngine(deck);
case 'fourteen': return new FourteenEngine(deck);
case 'threeshuffles': return new ThreeShufflesEngine(deck);
default: throw new Error(`Unknown solitaire: ${type}`);
}
}

View File

@ -50,6 +50,7 @@ import BlokusGame from './games/blokus/BlokusGame.js';
import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
const config = {
type: Phaser.AUTO,
@ -113,6 +114,7 @@ const config = {
SpellingBeeGame,
MiniCrosswordGame,
ForbiddenIslandGame,
SolitaireTourGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -32,6 +32,12 @@ export default class PreloadScene extends Phaser.Scene {
frameWidth: 200,
frameHeight: 200,
});
// Forbidden Island Treasure-deck cards: 4 cols × 2 rows of 320×420 cells.
// Frame order documented in IslandData.CARD_FRAME.
this.load.spritesheet('forbiddenisland-cards', '/assets/images/forbiddenisland-cards.png', {
frameWidth: 320,
frameHeight: 420,
});
this.load.spritesheet('cardbacks', '/assets/images/cardbacks.png', {
frameWidth: 320,
frameHeight: 420,

View File

@ -65,3 +65,4 @@ registerGame({ slug: 'blokus', name: 'Blokus', category: 'ta
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });