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:
parent
6705f6bd15
commit
efb8842368
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.
|
|
@ -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; });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (1–13). */
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue