diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 56c1268..10b9568 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 24bb994..c437413 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/freecell/FreecellGame.js b/public/src/games/freecell/FreecellGame.js new file mode 100644 index 0000000..f24733d --- /dev/null +++ b/public/src/games/freecell/FreecellGame.js @@ -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 */ }); + } +} diff --git a/public/src/games/freecell/FreecellLogic.js b/public/src/games/freecell/FreecellLogic.js new file mode 100644 index 0000000..2a7f9f1 --- /dev/null +++ b/public/src/games/freecell/FreecellLogic.js @@ -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; + } +} diff --git a/public/src/games/triominoes/TriominoesGame.js b/public/src/games/triominoes/TriominoesGame.js index 66ba21f..d937cf3 100644 --- a/public/src/games/triominoes/TriominoesGame.js +++ b/public/src/games/triominoes/TriominoesGame.js @@ -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; diff --git a/public/src/main.js b/public/src/main.js index ef452c0..642e2a3 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 3c2d278..d67d66b 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/server/games/registry.js b/server/games/registry.js index 88efabb..e366196 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 });