feat: add Freecell card game and improve Triominoes tile visibility

- Register Freecell game in server registry with card game configuration
- Import and configure FreecellGame in main.js and GameRoomScene
- Update game-icons sprite sheet with new icon frame for Freecell
- Fix Triominoes to dim all tiles when no legal moves are available
This commit is contained in:
Brian Fertig 2026-06-07 20:31:46 -06:00
parent 74d5470d11
commit 4fbc868305
8 changed files with 833 additions and 2 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

View File

@ -0,0 +1,609 @@
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 { FreecellEngine, freshDeck } from './FreecellLogic.js';
const CARD_W = 100;
const CARD_H = 140;
const CARD_R = 10;
const COL_GAP = 132;
const START_X = 448;
const colX = (c) => START_X + c * COL_GAP;
const FC_Y = 100; // freecell row centre-y
const HOME_Y = 100; // foundation row centre-y
const TAB_Y = 280; // tableau top card centre-y
const FAN = 28; // px per fanned card in a column
const FELT = 0x0e3b2a;
const SEL = 0x2ec28a;
const HINT = 0xc8a84b;
const SUITS_ORDER = ['s', 'h', 'd', 'c'];
const SUIT_GLYPH = { s: '♠', h: '♥', d: '♦', c: '♣' };
const D = { felt: -2, table: -1, card: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 };
export default class FreecellGame extends Phaser.Scene {
constructor() { super('FreecellGame'); }
init(data) {
this.gameDef = data.game;
this.engine = null;
this.sel = null; // { kind:'tableau', col, fromIdx } | { kind:'freecell', fcIdx }
this.animating = false;
this.autoRunning = false;
this.recorded = false;
this.overlayObjs = [];
this.overlayUp = false;
this.pulse = null;
this.board = null;
this.cardSprites = new Map();
this.dropZones = [];
this.potentialDrag = null;
this.dragState = null;
this.dropHighlight = null;
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* 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, 'FREECELL', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
}).setDepth(D.ui);
this.movesText = this.add.text(24, GAME_HEIGHT - 24, 'Moves: 0', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0, 1).setDepth(D.ui);
this.stuckBanner = this.add.text(GAME_WIDTH / 2, 200, 'No more moves!', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.banner).setVisible(false);
this.board = this.add.container(0, 0).setDepth(D.card);
this.autoCompleteBtn = new Button(this, GAME_WIDTH / 2, 1018, 'Auto-Complete', () => this.onAutoComplete(),
{ width: 280, height: 60, fontSize: 24 });
this.autoCompleteBtn.setDepth(D.ui).setVisible(false);
this.newGameBtn = new Button(this, GAME_WIDTH / 2 - 180, 1018, 'New Game', () => this.startGame(),
{ width: 240, height: 60, fontSize: 24 });
this.newGameBtn.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.setupDrag();
this.startGame();
}
startGame() {
this.sel = null;
this.animating = false;
this.autoRunning = false;
this.recorded = false;
this.overlayUp = false;
this.clearOverlay();
this.stuckBanner.setVisible(false);
this.newGameBtn.setVisible(false);
if (this.pulse) { this.pulse.stop(); this.pulse = null; }
this.engine = new FreecellEngine(freshDeck());
playSound(this, SFX.CARD_SHUFFLE);
this.refresh();
this.dealAnimation();
}
// ── card rendering helpers (same as SolitaireTourGame) ────────────────────
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);
c.isCard = true;
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 ───────────────────────────────────────────────────────
renderBoard() {
this.board.removeAll(true);
this.cardSprites.clear();
this.dropZones = [];
const e = this.engine;
// Labels
this.board.add(this.add.text(colX(0), FC_Y - CARD_H / 2 - 18, 'Free Cells', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1));
this.board.add(this.add.text(colX(4) + (colX(7) - colX(4)) / 2, HOME_Y - CARD_H / 2 - 18, 'Foundations', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1));
// Freecells (cols 0-3)
for (let fc = 0; fc < 4; fc++) {
const x = colX(fc);
const card = e.freecells[fc];
const isSel = this.sel?.kind === 'freecell' && this.sel.fcIdx === fc;
this.dropZones.push({ cx: x, cy: FC_Y, hw: CARD_W * 0.7, hh: CARD_H * 0.8, hlx: x, hly: FC_Y, kind: 'freecell', idx: fc });
if (card) {
const sprite = this.card(card, x, FC_Y, {
selected: isSel,
onDown: (_c, pointer) => this.onCardDown({ kind: 'freecell', fcIdx: fc }, pointer),
});
this.cardSprites.set(card.id, sprite);
} else {
this.slot(x, FC_Y, 'Free', () => this.onSlotClick('freecell', fc));
}
}
// Foundations (cols 4-7)
for (let i = 0; i < 4; i++) {
const x = colX(4 + i);
const suit = SUITS_ORDER[i];
const pile = e.foundations[suit];
const top = pile[pile.length - 1];
this.dropZones.push({ cx: x, cy: HOME_Y, hw: CARD_W * 0.7, hh: CARD_H * 0.8, hlx: x, hly: HOME_Y, kind: 'foundation', suit });
if (top) {
this.card(top, x, HOME_Y, { onClick: () => this.onSlotClick('foundation', i) });
} else {
this.slot(x, HOME_Y, SUIT_GLYPH[suit], () => this.onSlotClick('foundation', i));
}
}
// Tableau (cols 0-7)
for (let c = 0; c < 8; c++) {
const pile = e.tableau[c];
const x = colX(c);
const colH = Math.max(CARD_H, pile.length * FAN + CARD_H);
const midY = TAB_Y + (pile.length * FAN) / 2;
this.dropZones.push({ cx: x, cy: midY, hw: CARD_W * 0.7, hh: colH / 2 + 20, hlx: x, hly: TAB_Y + pile.length * FAN, kind: 'column', idx: c });
if (!pile.length) {
this.slot(x, TAB_Y, '', () => this.onSlotClick('column', c));
continue;
}
pile.forEach((cardObj, idx) => {
const isTop = idx === pile.length - 1;
const inSel = this.sel?.kind === 'tableau' && this.sel.col === c && idx >= this.sel.fromIdx;
const isHint = isTop && e.canMoveToFoundation(cardObj);
// A card is draggable if the entire run from here passes _validRun
const draggable = e._validRun(c, idx) !== null;
const sprite = this.card(cardObj, x, TAB_Y + idx * FAN, {
selected: inSel,
hint: isHint && !inSel,
onDown: draggable ? (_co, pointer) => this.onCardDown({ kind: 'tableau', col: c, fromIdx: idx }, pointer) : null,
onClick: draggable ? null : null, // non-draggable interior cards are inaccessible
});
this.cardSprites.set(cardObj.id, sprite);
});
}
}
// ── interaction ──────────────────────────────────────────────────────────
interactive() { return !this.animating && !this.autoRunning && !this.overlayUp; }
onCardDown(descriptor, pointer) {
if (!this.interactive()) return;
this.beginDrag(
this.dragSpritesFor(descriptor),
pointer,
() => this.onTap(descriptor),
(x, y) => this.resolveDrop(descriptor, x, y),
);
}
dragSpritesFor(descriptor) {
if (descriptor.kind === 'freecell') {
const card = this.engine.freecells[descriptor.fcIdx];
const o = card && this.cardSprites.get(card.id);
return o ? [o] : [];
}
const run = this.engine._validRun(descriptor.col, descriptor.fromIdx);
if (!run) return [];
return run.map((c) => this.cardSprites.get(c.id)).filter(Boolean);
}
onTap(descriptor) {
if (!this.interactive()) return;
if (this.sel) {
const sameSpot = this.sel.kind === descriptor.kind &&
(descriptor.kind === 'freecell' ? this.sel.fcIdx === descriptor.fcIdx
: this.sel.col === descriptor.col && this.sel.fromIdx === descriptor.fromIdx);
if (sameSpot) { this.sel = null; this.refresh(); return; }
// Try to complete a move from sel → descriptor location
if (this.commitMove(descriptor)) return;
}
this.sel = descriptor;
this.refresh();
}
onSlotClick(kind, idx) {
if (!this.interactive() || !this.sel) return;
if (kind === 'foundation') {
this.commitToFoundation(this.sel);
} else if (kind === 'freecell') {
if (this.sel.kind === 'tableau') {
const col = this.sel.col;
const pile = this.engine.tableau[col];
const isTop = this.sel.fromIdx === pile.length - 1;
if (isTop && this.applyMove(this.engine.moveToFreecell(col))) return;
}
} else if (kind === 'column') {
this.commitToColumn(this.sel, idx);
}
}
// Try to move sel → the tapped card's location (used when tapping a card while another is selected)
commitMove(descriptor) {
const e = this.engine;
// Try foundation
if (this.commitToFoundation(this.sel)) return true;
// Try freecell (only top tableau card)
if (descriptor.kind === 'freecell' && this.sel.kind === 'tableau') {
const isTop = this.sel.fromIdx === e.tableau[this.sel.col].length - 1;
if (isTop && this.applyMove(e.moveToFreecell(this.sel.col))) return true;
}
// Try column (tapped on a card in destCol → use that col as destination)
if (descriptor.kind === 'tableau' && descriptor.col !== (this.sel.col ?? -1)) {
if (this.commitToColumn(this.sel, descriptor.col)) return true;
}
if (descriptor.kind === 'freecell' && this.sel.kind === 'freecell') {
// freecell → freecell not a standard move; ignore
}
return false;
}
commitToFoundation(sel) {
let ok = false;
if (sel.kind === 'tableau') {
const pile = this.engine.tableau[sel.col];
if (sel.fromIdx === pile.length - 1) ok = this.engine.moveToFoundation('tableau', sel.col);
} else {
ok = this.engine.moveFromFreecellToFoundation(sel.fcIdx);
}
if (ok) { this.sel = null; this.applyMove(true); return true; }
return false;
}
commitToColumn(sel, destCol) {
let ok = false;
if (sel.kind === 'freecell') {
ok = this.engine.moveFromFreecellToColumn(sel.fcIdx, destCol);
} else {
ok = this.engine.moveRun(sel.col, sel.fromIdx, destCol);
}
if (ok) { this.sel = null; this.applyMove(true); return true; }
return false;
}
resolveDrop(descriptor, x, y) {
const z = this.dropZones.find((zone) => Math.abs(x - zone.cx) < zone.hw && Math.abs(y - zone.cy) < zone.hh);
if (!z) return null;
const pos = { x: z.hlx, y: z.hly };
const e = this.engine;
if (z.kind === 'foundation') {
if (descriptor.kind === 'tableau') {
const pile = e.tableau[descriptor.col];
if (descriptor.fromIdx !== pile.length - 1) return null;
if (!e.canMoveToFoundation(pile[descriptor.fromIdx])) return null;
} else {
const card = e.freecells[descriptor.fcIdx];
if (!card || !e.canMoveToFoundation(card)) return null;
}
const commit = () => {
if (descriptor.kind === 'tableau') return e.moveToFoundation('tableau', descriptor.col);
return e.moveFromFreecellToFoundation(descriptor.fcIdx);
};
return { pos, color: 0xffd700, commit };
}
if (z.kind === 'freecell') {
if (descriptor.kind !== 'tableau') return null;
const pile = e.tableau[descriptor.col];
if (descriptor.fromIdx !== pile.length - 1) return null;
if (e._emptyFreecells() === 0) return null;
const commit = () => e.moveToFreecell(descriptor.col);
return { pos, color: HINT, commit };
}
if (z.kind === 'column') {
if (descriptor.kind === 'tableau') {
if (!e.canMoveRun(descriptor.col, descriptor.fromIdx, z.idx)) return null;
} else {
const card = e.freecells[descriptor.fcIdx];
if (!card || !e.canMoveToColumn(card, z.idx)) return null;
}
const commit = () => {
if (descriptor.kind === 'freecell') return e.moveFromFreecellToColumn(descriptor.fcIdx, z.idx);
return e.moveRun(descriptor.col, descriptor.fromIdx, z.idx);
};
return { pos, color: SEL, commit };
}
return null;
}
applyMove(ok, sfx = SFX.CARD_PLACE) {
if (ok) { playSound(this, sfx); this.refresh(); }
return ok;
}
refresh() {
this.renderBoard();
this.updateHud();
if (this.engine.isWon()) { this.endGame(); return; }
this.updateAutoComplete();
this.updateStuck();
}
updateHud() {
this.movesText.setText(`Moves: ${this.engine.moveCount}`);
}
updateAutoComplete() {
const show = !this.animating && !this.autoRunning && !this.overlayUp && this.engine.canAutoComplete() && !this.engine.isWon();
this.autoCompleteBtn.setVisible(show);
}
updateStuck() {
const stuck = this.interactive() && !this.engine.hasMoves() && !this.engine.isWon();
this.stuckBanner.setVisible(stuck);
this.newGameBtn.setVisible(stuck);
if (stuck && !this.pulse) {
this.pulse = this.tweens.add({ targets: this.newGameBtn, 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.newGameBtn.setScale(1);
}
}
// ── auto-complete ─────────────────────────────────────────────────────────
onAutoComplete() {
if (!this.interactive()) return;
this.autoRunning = true;
this.animating = true;
this.sel = null;
this.autoCompleteBtn.setVisible(false);
this.doAutoStep();
}
doAutoStep() {
const next = this.engine.autoCompleteStep();
if (!next) {
this.autoRunning = false;
this.animating = false;
this.refresh();
return;
}
const suitIdx = SUITS_ORDER.indexOf(next.card.suit);
const toX = colX(4 + suitIdx);
const toY = HOME_Y;
const src = this.cardSprites.get(next.card.id);
const fromX = src ? src.x : GAME_WIDTH / 2;
const fromY = src ? src.y : GAME_HEIGHT / 2;
if (src) src.setVisible(false);
this.engine.moveToFoundation(next.srcKind, next.srcIdx);
const fly = this.add.container(fromX, fromY).setDepth(D.banner);
this.drawFace(fly, next.card, true, false, false);
this.tweens.add({
targets: fly, x: toX, y: toY, duration: 110, ease: 'Quad.easeIn',
onComplete: () => {
fly.destroy();
playSound(this, SFX.CARD_PLACE);
this.renderBoard();
this.updateHud();
if (this.engine.isWon()) { this.autoRunning = false; this.animating = false; this.endGame(); return; }
this.doAutoStep();
},
});
}
// ── deal animation (same as SolitaireTourGame) ────────────────────────────
dealAnimation() {
const cards = this.board.list.filter((o) => o.isCard);
if (!cards.length) return;
const deckX = GAME_WIDTH / 2, deckY = GAME_HEIGHT + 90;
const homes = cards.map((c) => ({ c, x: c.x, y: c.y, alpha: c.alpha }));
this.animating = true;
for (const { c } of homes) { c.setPosition(deckX, deckY).setScale(0.7).setAlpha(0); }
homes.forEach(({ c, x, y, alpha }, i) => {
const last = i === homes.length - 1;
this.tweens.add({
targets: c, x, y, scaleX: 1, scaleY: 1, alpha,
delay: i * 12, duration: 200, ease: 'Quad.easeOut',
onComplete: last ? () => { this.animating = false; this.updateStuck(); } : undefined,
});
});
}
// ── drag-and-drop (verbatim from SolitaireTourGame) ───────────────────────
setupDrag() {
this.input.on('pointermove', (pointer) => {
if (!pointer.isDown) return;
if (this.dragState) {
this.dragUpdate(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.dragPromote();
}
});
this.input.on('pointerup', () => {
if (this.dragState) this.dragEnd();
else if (this.potentialDrag) {
const pd = this.potentialDrag;
this.potentialDrag = null;
pd.tap();
}
});
}
beginDrag(objs, pointer, tap, resolve) {
if (!this.interactive() || this.dragState || !objs.length) return;
this.potentialDrag = {
startX: pointer.x, startY: pointer.y, tap, resolve,
sprites: objs.map((obj) => ({ obj, offX: obj.x - pointer.x, offY: obj.y - pointer.y, homeX: obj.x, homeY: obj.y })),
};
}
dragPromote() {
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;
}
dragUpdate(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.highlightDrop(this.dragState.resolve(primary.x, primary.y));
}
highlightDrop(target) {
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
if (!target) return;
this.dropHighlight = this.add.rectangle(target.pos.x, target.pos.y, CARD_W + 16, CARD_H + 16, target.color, 0.18)
.setStrokeStyle(3, target.color, 0.9).setDepth(D.card - 1);
}
dragEnd() {
const ds = this.dragState;
this.dragState = null;
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
const primary = ds.sprites[0].obj;
const target = ds.resolve(primary.x, primary.y);
if (target && target.commit()) { this.sel = null; this.applyMove(true); return; }
ds.sprites.forEach(({ obj, homeX, homeY }) => {
this.tweens.add({ targets: obj, x: homeX, y: homeY, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' });
});
}
// ── win / end ─────────────────────────────────────────────────────────────
endGame() {
this.animating = false;
this.autoRunning = false;
this.sel = null;
this.stuckBanner.setVisible(false);
this.newGameBtn.setVisible(false);
this.autoCompleteBtn.setVisible(false);
if (this.pulse) { this.pulse.stop(); this.pulse = null; }
playSound(this, SFX.CASINO_WIN);
this.recordResult();
this.showWinOverlay();
}
showWinOverlay() {
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 - 380, cy - 200, 760, 400, 20);
g.lineStyle(3, COLORS.accent, 1); g.strokeRoundedRect(cx - 380, cy - 200, 760, 400, 20);
this.overlayObjs.push(dim, g);
this.overlayObjs.push(this.add.text(cx, cy - 130, 'You Win!', {
fontFamily: 'Righteous', fontSize: '62px', color: '#5fd29a',
}).setOrigin(0.5).setDepth(D.overlayUI));
this.overlayObjs.push(this.add.text(cx, cy - 30, `Completed in ${this.engine.moveCount} moves`, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
const again = new Button(this, cx - 130, cy + 110, 'Play Again', () => {
this.clearOverlay(); this.overlayUp = false; this.startGame();
}, { width: 220, height: 60, fontSize: 24 });
again.setDepth(D.overlayUI);
const leave = new Button(this, cx + 130, cy + 110, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 220, height: 60, fontSize: 24 });
leave.setDepth(D.overlayUI);
this.overlayObjs.push(again, leave);
}
clearOverlay() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
}
recordResult() {
if (this.recorded) return;
this.recorded = true;
api.post('/history/single-player', {
slug: 'freecell', score: this.engine.moveCount, opponentScores: [], result: 'win',
}).catch(() => { /* best effort */ });
}
}

View File

@ -0,0 +1,218 @@
import { Card, SUITS, RANKS } from '../cards/Deck.js';
const PVAL = { A: 1, T: 10, J: 11, Q: 12, K: 13 };
for (let n = 2; n <= 9; n++) PVAL[String(n)] = n;
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;
}
export class FreecellEngine {
constructor(deck) {
this.tableau = Array.from({ length: 8 }, () => []);
this.freecells = [null, null, null, null];
this.foundations = { s: [], h: [], d: [], c: [] };
this.moveCount = 0;
// Deal: cols 0-3 get 7 cards, cols 4-7 get 6 cards (round-robin)
let k = 0;
for (let row = 0; row < 7; row++) {
const cols = row < 6 ? 8 : 4; // first 6 rows fill all 8; row 6 fills only cols 0-3
for (let c = 0; c < cols; c++) this.tableau[c].push(deck[k++]);
}
}
// ── internal helpers ───────────────────────────────────────────────────────
_foundationNext(suit) {
const pile = this.foundations[suit];
return pile.length === 0 ? 1 : pile[pile.length - 1].pval + 1;
}
_emptyFreecells() {
return this.freecells.filter((s) => s === null).length;
}
_emptyColumns() {
return this.tableau.filter((col) => col.length === 0).length;
}
// Returns the valid alternating-color descending run from fromIdx to bottom,
// or null if it doesn't form one.
_validRun(col, fromIdx) {
const pile = this.tableau[col];
if (fromIdx < 0 || fromIdx >= pile.length) return null;
const run = [pile[fromIdx]];
for (let i = fromIdx + 1; i < pile.length; i++) {
const prev = pile[i - 1], cur = pile[i];
if (cur.isRed === prev.isRed || cur.pval !== prev.pval - 1) return null;
run.push(cur);
}
return run;
}
// ── move validation ────────────────────────────────────────────────────────
canMoveToFoundation(card) {
return card.pval === this._foundationNext(card.suit);
}
canMoveToColumn(card, destCol) {
const dest = this.tableau[destCol];
if (dest.length === 0) return true;
const top = dest[dest.length - 1];
return card.isRed !== top.isRed && card.pval === top.pval - 1;
}
canMoveRun(srcCol, fromIdx, destCol) {
if (srcCol === destCol) return false;
const run = this._validRun(srcCol, fromIdx);
if (!run) return false;
return this.canMoveToColumn(run[0], destCol);
}
// ── mutations (all return boolean) ────────────────────────────────────────
moveToFoundation(srcKind, srcIdx) {
let card;
if (srcKind === 'tableau') {
const pile = this.tableau[srcIdx];
if (!pile.length) return false;
card = pile[pile.length - 1];
} else {
card = this.freecells[srcIdx];
if (!card) return false;
}
if (!this.canMoveToFoundation(card)) return false;
this.foundations[card.suit].push(card);
if (srcKind === 'tableau') this.tableau[srcIdx].pop();
else this.freecells[srcIdx] = null;
this.moveCount++;
return true;
}
moveToFreecell(srcCol) {
const pile = this.tableau[srcCol];
if (!pile.length) return false;
const fcIdx = this.freecells.indexOf(null);
if (fcIdx === -1) return false;
this.freecells[fcIdx] = pile.pop();
this.moveCount++;
return true;
}
moveFromFreecellToFoundation(fcIdx) {
return this.moveToFoundation('freecell', fcIdx);
}
moveFromFreecellToColumn(fcIdx, destCol) {
const card = this.freecells[fcIdx];
if (!card) return false;
if (!this.canMoveToColumn(card, destCol)) return false;
this.tableau[destCol].push(card);
this.freecells[fcIdx] = null;
this.moveCount++;
return true;
}
moveRun(srcCol, fromIdx, destCol) {
if (!this.canMoveRun(srcCol, fromIdx, destCol)) return false;
const run = this.tableau[srcCol].splice(fromIdx);
this.tableau[destCol].push(...run);
this.moveCount++;
return true;
}
// ── queries ────────────────────────────────────────────────────────────────
hasMoves() {
const allCards = [
...this.freecells.filter(Boolean),
...this.tableau.map((col) => col[col.length - 1]).filter(Boolean),
];
// Any card to foundation?
if (allCards.some((c) => this.canMoveToFoundation(c))) return true;
// Any card to freecell?
if (this._emptyFreecells() > 0 && this.tableau.some((col) => col.length > 0)) return true;
// Any card/run to any column?
for (let dest = 0; dest < 8; dest++) {
for (const card of allCards) {
if (this.canMoveToColumn(card, dest)) return true;
}
// Runs from tableau to any column
for (let src = 0; src < 8; src++) {
if (src === dest) continue;
const pile = this.tableau[src];
for (let idx = 0; idx < pile.length; idx++) {
if (this.canMoveRun(src, idx, dest)) return true;
}
}
}
return false;
}
isWon() {
return SUITS.every((s) => this.foundations[s].length === 13);
}
// Simulate foundation-only play; returns true if all 52 cards would clear.
canAutoComplete() {
const need = {};
for (const s of SUITS) need[s] = this._foundationNext(s);
const cols = this.tableau.map((col) => col.slice());
const fcs = this.freecells.slice();
let remaining = cols.reduce((t, c) => t + c.length, 0) + fcs.filter(Boolean).length;
if (remaining === 0) return true;
let moved = true;
while (remaining > 0 && moved) {
moved = false;
for (let i = 0; i < 4; i++) {
if (fcs[i] && fcs[i].pval === need[fcs[i].suit]) {
need[fcs[i].suit]++;
fcs[i] = null;
remaining--;
moved = true;
}
}
for (const col of cols) {
const top = col[col.length - 1];
if (top && top.pval === need[top.suit]) {
col.pop();
need[top.suit]++;
remaining--;
moved = true;
}
}
}
return remaining === 0;
}
// Returns the next card that can go to a foundation, or null.
// Checks freecells first, then tableau tops.
autoCompleteStep() {
for (let i = 0; i < 4; i++) {
const card = this.freecells[i];
if (card && this.canMoveToFoundation(card)) return { card, srcKind: 'freecell', srcIdx: i };
}
for (let c = 0; c < 8; c++) {
const pile = this.tableau[c];
const top = pile[pile.length - 1];
if (top && this.canMoveToFoundation(top)) return { card: top, srcKind: 'tableau', srcIdx: c };
}
return null;
}
}

View File

@ -351,13 +351,14 @@ export default class TriominoesGame extends Phaser.Scene {
const startX = CX - ((hand.length - 1) * pitch) / 2;
const pts = this.handPts();
const anyPlayable = legalTiles.size > 0;
hand.forEach((tile, idx) => {
const x = startX + idx * pitch;
const playable = humanTurn && legalTiles.has(idx);
const container = this.add.container(x, HAND_Y).setDepth(DEPTH.hand);
const border = playable ? COLORS.accent : COLORS.muted;
this.paintTriangle(container, pts, tile.v, { fill: COLORS.text, border, lineW: 3, fontSize: 30 });
container.setAlpha(humanTurn ? (playable ? 1 : 0.42) : 0.7);
container.setAlpha(humanTurn ? (anyPlayable ? 1 : 0.42) : 0.7);
container._tileIndex = idx;
container._homeX = x;

View File

@ -60,6 +60,7 @@ import StrategoGame from './games/stratego/StrategoGame.js';
import KiitosGame from './games/kiitos/KiitosGame.js';
import MonopolyGame from './games/monopoly/MonopolyGame.js';
import TriominoesGame from './games/triominoes/TriominoesGame.js';
import FreecellGame from './games/freecell/FreecellGame.js';
const config = {
type: Phaser.AUTO,
@ -133,6 +134,7 @@ const config = {
KiitosGame,
MonopolyGame,
TriominoesGame,
FreecellGame,
],
};

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', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame' };
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', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -75,3 +75,4 @@ registerGame({ slug: 'stratego', name: 'Stratego', category: '
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });
registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 49 });
registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 });