diff --git a/public/assets/fx/8bit-card.mp3 b/public/assets/fx/8bit-card.mp3 new file mode 100644 index 0000000..115c672 Binary files /dev/null and b/public/assets/fx/8bit-card.mp3 differ diff --git a/public/assets/fx/8bit-lose.mp3 b/public/assets/fx/8bit-lose.mp3 new file mode 100644 index 0000000..684e6e0 Binary files /dev/null and b/public/assets/fx/8bit-lose.mp3 differ diff --git a/public/assets/fx/8bit-select.mp3 b/public/assets/fx/8bit-select.mp3 new file mode 100644 index 0000000..0c718f1 Binary files /dev/null and b/public/assets/fx/8bit-select.mp3 differ diff --git a/public/assets/fx/8bit-win.mp3 b/public/assets/fx/8bit-win.mp3 new file mode 100644 index 0000000..971ebb4 Binary files /dev/null and b/public/assets/fx/8bit-win.mp3 differ diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index cc7fd41..90f7edd 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 1b9ffdf..3bdc0cf 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/farkel/FarkelAI.js b/public/src/games/farkel/FarkelAI.js new file mode 100644 index 0000000..21a92e3 --- /dev/null +++ b/public/src/games/farkel/FarkelAI.js @@ -0,0 +1,55 @@ +// Farkel — heuristic opponent. No Phaser, no timers, no state mutation. After a +// roll the AI always takes the greedy best-scoring set (FarkelLogic.bestScoring); +// the only real decision is reroll vs. bank, made with an expected-value model +// over the standard Farkle odds, shaded by a 1-5 skill profile for human-like +// pacing and the occasional greedy blunder. + +import { WIN_TARGET, ON_BOARD_MIN } from './FarkelData.js'; + +// Probability a roll of N dice scores nothing (standard Farkle figures). +const FARKLE_PROB = { 1: 0.667, 2: 0.444, 3: 0.278, 4: 0.157, 5: 0.077, 6: 0.023 }; +// Rough expected points added by scoring dice in a roll of N. +const AVG_GAIN = { 1: 25, 2: 50, 3: 75, 4: 113, 5: 150, 6: 200 }; + +const SKILL_PROFILES = { + 1: { greed: 1.6, noise: 0.5, delay: [700, 1200] }, + 2: { greed: 1.3, noise: 0.35, delay: [650, 1100] }, + 3: { greed: 1.1, noise: 0.2, delay: [600, 1000] }, + 4: { greed: 1.0, noise: 0.1, delay: [520, 900] }, + 5: { greed: 1.0, noise: 0.0, delay: [440, 820] }, +}; +function profileFor(skill) { + return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; +} + +export function nextThinkDelay(skill) { + const [lo, hi] = profileFor(skill).delay; + return lo + Math.random() * (hi - lo); +} + +// Decide whether to reroll the remaining dice (true) or bank (false). Called +// after the AI has already set aside its scoring dice for the current roll. +export function decideReroll(state, skill = 3) { + const prof = profileFor(skill); + const t = state.turn; + const me = state.players[state.current]; + + // Below the on-board minimum, banking would score nothing — so the kept total + // is worthless until it reaches 500. Always push on (there's nothing to lose). + if (!me.onBoard && t.kept < ON_BOARD_MIN) return true; + + // If banking now wins the game, take it. + if (me.score + t.kept >= WIN_TARGET) return false; + + const prob = FARKLE_PROB[t.available] ?? 0.5; + const gain = (AVG_GAIN[t.available] ?? 50) * prof.greed; + let ev = (1 - prob) * gain - prob * t.kept; + ev += (Math.random() * 2 - 1) * prof.noise * 100; + + // If an opponent is on the brink, gamble harder to keep pace. + let oppMax = 0; + state.players.forEach((p) => { if (p.seat !== me.seat && p.score > oppMax) oppMax = p.score; }); + if (oppMax >= WIN_TARGET * 0.8 && me.score + t.kept < oppMax) ev += 150; + + return ev > 0; +} diff --git a/public/src/games/farkel/FarkelData.js b/public/src/games/farkel/FarkelData.js new file mode 100644 index 0000000..8d2f025 --- /dev/null +++ b/public/src/games/farkel/FarkelData.js @@ -0,0 +1,38 @@ +// Farkel — static catalog. No Phaser, no game state. Constants, the die-pip +// layout (ported from Yatzi), player colours, and the scoring-reference rows +// shown in the info panel. + +export const DICE = 6; +export const WIN_TARGET = 10000; // first to 10k triggers a final round +export const ON_BOARD_MIN = 500; // single-turn total needed to start banking + +// 3x3 pip layout (col, row offsets in {-1, 0, 1}) per face. Mirrors Yatzi. +export const PIP_POS = { + 1: [[0, 0]], + 2: [[-1, -1], [1, 1]], + 3: [[-1, -1], [0, 0], [1, 1]], + 4: [[-1, -1], [1, -1], [-1, 1], [1, 1]], + 5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]], + 6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]], +}; + +// Seat colours (shared visual language with the other tabletop games). +export const PLAYER_COLORS = [0xd0473a, 0x4a90d9, 0x49a25a, 0xe2b53c]; +export const PLAYER_COLOR_HEX = ['#d0473a', '#4a90d9', '#49a25a', '#e2b53c']; + +// Ordered rows for the "Scoring" reference panel. +export const SCORING_REFERENCE = [ + { label: 'Single 1', value: '100' }, + { label: 'Single 5', value: '50' }, + { label: 'Three 1s', value: '1000' }, + { label: 'Three 2s', value: '200' }, + { label: 'Three 3s', value: '300' }, + { label: 'Three 4s', value: '400' }, + { label: 'Three 5s', value: '500' }, + { label: 'Three 6s', value: '600' }, + { label: 'Four of a kind', value: '1000' }, + { label: 'Five of a kind', value: '2000' }, + { label: 'Six of a kind', value: '3000' }, + { label: 'Straight 1-6', value: '1500' }, + { label: 'Three pairs', value: '1500' }, +]; diff --git a/public/src/games/farkel/FarkelGame.js b/public/src/games/farkel/FarkelGame.js new file mode 100644 index 0000000..ac19b58 --- /dev/null +++ b/public/src/games/farkel/FarkelGame.js @@ -0,0 +1,577 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { auth } from '../../services/auth.js'; +import { api } from '../../services/api.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { DICE, PIP_POS, PLAYER_COLOR_HEX, SCORING_REFERENCE } from './FarkelData.js'; +import { + createInitialState, rollDice, applySetAside, bank, farkleTurn, + scoreSelection, bestScoring, isGameOver, getWinners, +} from './FarkelLogic.js'; +import { decideReroll, nextThinkDelay } from './FarkelAI.js'; + +// ─── Layout ─────────────────────────────────────────────────────────────── +const PORTRAIT_X = 90; +const PORTRAIT_R = 46; +const PORTRAIT_TOP = 210; +const PORTRAIT_GAP = 132; + +const TRAY_CX = 620; +const TRAY_CY = 600; +const TRAY_W = 760; +const TRAY_H = 280; + +const DIE = 96; +const DIE_GAP = 18; +const DICE_ROW_W = DICE * DIE + (DICE - 1) * DIE_GAP; +const DICE_LEFT = TRAY_CX - DICE_ROW_W / 2 + DIE / 2; + +const SHELF_Y = TRAY_CY - TRAY_H / 2 - 64; +const SDIE = 46; +const SDIE_GAP = 12; + +// Right-hand panels +const SCORE_X = 1080; // scoring reference panel +const SCORE_W = 360; +const PAPER_X = 1480; // scratch-paper score sheet +const PAPER_W = 400; +const PANEL_TOP = 110; + +const DEPTH = { + bg: -1, panel: 0, paper: 1, grid: 2, text: 3, + die: 10, dieSel: 11, ui: 20, toast: 60, modal: 70, +}; + +export default class FarkelGame extends Phaser.Scene { + constructor() { super('FarkelGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; + + this.humanSeat = 0; + this.busy = false; + this.gameOverShown = false; + + this.dieEls = []; // [{ g, hit, cx, cy }] + this.selected = new Set(); // indices into turn.rolled + this.scratchRows = []; // [{ name, score, star }] + this.portraitCtrls = []; + } + + create() { + try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ } + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg); + this.buildBackground(); + + // Players: human seat 0 (skill 5), then AI opponents. + const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length)); + const names = []; + const skills = {}; + for (let seat = 0; seat < playerCount; seat++) { + if (seat === this.humanSeat) { + names.push(auth.user?.username ?? 'You'); + skills[seat] = 5; + } else { + const opp = this.opponents[seat - 1]; + names.push(opp?.name ?? `Player ${seat + 1}`); + skills[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3)); + } + } + this.gs = createInitialState({ playerCount, names, skills }); + + this.add.text(TRAY_CX, 56, 'Farkel', { + fontFamily: 'Righteous', fontSize: '60px', color: COLORS.textHex, + }).setOrigin(0.5); + this.statusText = this.add.text(TRAY_CX, 128, '', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.accentHex, + }).setOrigin(0.5); + + this.buildTray(); + this.buildDice(); + this.buildShelf(); + this.buildScoringPanel(); + this.buildScratchPaper(); + this.buildButtons(); + this.buildPortraits(); + + new Button(this, 80, GAME_HEIGHT - 50, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 140, fontSize: 20, + }); + + this.render(); + this.advance(); + } + + // ── static chrome ────────────────────────────────────────────────────────── + buildBackground() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg + 1); + } + } + + buildTray() { + // Felt throwing area. + const g = this.add.graphics().setDepth(DEPTH.panel); + g.fillStyle(0x14532d, 1); + g.fillRoundedRect(TRAY_CX - TRAY_W / 2, TRAY_CY - TRAY_H / 2, TRAY_W, TRAY_H, 24); + g.lineStyle(4, COLORS.accent, 1); + g.strokeRoundedRect(TRAY_CX - TRAY_W / 2, TRAY_CY - TRAY_H / 2, TRAY_W, TRAY_H, 24); + + this.turnTotalText = this.add.text(TRAY_CX, TRAY_CY + TRAY_H / 2 + 28, '', { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0.5); + } + + buildDice() { + for (let i = 0; i < DICE; i++) { + const x = DICE_LEFT + i * (DIE + DIE_GAP); + const y = TRAY_CY; + const g = this.add.graphics().setDepth(DEPTH.die); + const hit = this.add.zone(x, y, DIE, DIE).setOrigin(0.5).setDepth(DEPTH.dieSel); + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerdown', () => this.onDieClick(i)); + this.dieEls.push({ g, hit, cx: x, cy: y }); + } + } + + buildShelf() { + this.shelfGfx = this.add.graphics().setDepth(DEPTH.die); + this.add.text(TRAY_CX - TRAY_W / 2, SHELF_Y - SDIE / 2 - 24, 'Set aside this turn', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5); + } + + buildScoringPanel() { + const rows = SCORING_REFERENCE.length; + const rowH = 40; + const h = 64 + rows * rowH + 16; + this.add.rectangle(SCORE_X + SCORE_W / 2, PANEL_TOP + h / 2, SCORE_W, h, COLORS.panel) + .setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.panel); + this.add.text(SCORE_X + SCORE_W / 2, PANEL_TOP + 34, 'Scoring', { + fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.text); + + let y = PANEL_TOP + 76; + for (const row of SCORING_REFERENCE) { + this.add.text(SCORE_X + 22, y, row.label, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(DEPTH.text); + this.add.text(SCORE_X + SCORE_W - 22, y, row.value, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.accentHex, + }).setOrigin(1, 0.5).setDepth(DEPTH.text); + y += rowH; + } + } + + buildScratchPaper() { + const N = this.gs.players.length; + const headH = 70; + const rowH = 96; + const h = headH + N * rowH + 24; + const top = PANEL_TOP; + + // Paper sheet. + const g = this.add.graphics().setDepth(DEPTH.paper); + g.fillStyle(0xf3ecd2, 1); + g.fillRoundedRect(PAPER_X, top, PAPER_W, h, 10); + g.lineStyle(2, 0xbfae82, 1); + g.strokeRoundedRect(PAPER_X, top, PAPER_W, h, 10); + // Ruled lines + red margin. + g.lineStyle(1, 0x9bb7d4, 0.5); + for (let ly = top + headH; ly < top + h - 10; ly += 32) { + g.beginPath(); g.moveTo(PAPER_X + 12, ly); g.lineTo(PAPER_X + PAPER_W - 12, ly); g.strokePath(); + } + g.lineStyle(2, 0xcf6b5e, 0.7); + g.beginPath(); g.moveTo(PAPER_X + 56, top + 10); g.lineTo(PAPER_X + 56, top + h - 10); g.strokePath(); + + this.add.text(PAPER_X + PAPER_W / 2, top + 36, 'Score Pad', { + fontFamily: 'YummyCupcakes', fontSize: '40px', color: '#2a2418', + }).setOrigin(0.5).setDepth(DEPTH.text); + + let y = top + headH + 30; + for (let seat = 0; seat < N; seat++) { + const colorHex = PLAYER_COLOR_HEX[seat] ?? '#2a2418'; + const name = this.add.text(PAPER_X + 72, y, this.gs.players[seat].name, { + fontFamily: 'YummyCupcakes', fontSize: '34px', color: colorHex, + }).setOrigin(0, 0.5).setDepth(DEPTH.text); + const star = this.add.text(PAPER_X + 70, y - 34, '★', { + fontFamily: 'YummyCupcakes', fontSize: '24px', color: '#d4a017', + }).setOrigin(0, 0.5).setDepth(DEPTH.text).setVisible(false); + const score = this.add.text(PAPER_X + PAPER_W - 22, y, '0', { + fontFamily: 'YummyCupcakes', fontSize: '40px', color: '#2a2418', + }).setOrigin(1, 0.5).setDepth(DEPTH.text); + this.scratchRows.push({ name, score, star, y }); + y += rowH; + } + this.scratchUnderline = this.add.graphics().setDepth(DEPTH.grid); + this._paperTop = top; + this._paperHeadH = headH; + this._paperRowH = rowH; + } + + buildButtons() { + const y = TRAY_CY + TRAY_H / 2 + 86; + this.rollBtn = new Button(this, TRAY_CX - 220, y, 'Roll', () => this.onRoll(), { width: 220, fontSize: 26 }); + this.scoreAllBtn = new Button(this, TRAY_CX, y, 'Score All', () => this.onScoreAll(), { width: 200, fontSize: 24, variant: 'ghost' }); + this.bankBtn = new Button(this, TRAY_CX + 220, y, 'Bank', () => this.onBank(), { width: 220, fontSize: 26 }); + } + + buildPortraits() { + const N = this.gs.players.length; + for (let seat = 0; seat < N; seat++) { + const py = PORTRAIT_TOP + seat * PORTRAIT_GAP; + const ring = this.add.graphics().setDepth(DEPTH.ui); + let ctrl; + if (seat === this.humanSeat) { + ctrl = createPlayerPortrait(this, PORTRAIT_X, py, PORTRAIT_R, DEPTH.ui, 'FarkelGame'); + } else { + const opp = this.opponents[seat - 1] ?? { id: 'bot', spriteIndex: 0 }; + ctrl = createOpponentPortrait(this, opp, PORTRAIT_X, py, PORTRAIT_R, DEPTH.ui); + } + this.portraitCtrls.push({ ring, controller: ctrl, x: PORTRAIT_X, y: py }); + this.add.text(PORTRAIT_X, py + PORTRAIT_R + 16, this.gs.players[seat].name, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex, + }).setOrigin(0.5); + } + } + + // ── rendering ──────────────────────────────────────────────────────────────── + render() { + this.renderDice(); + this.renderShelf(); + this.renderScratch(); + this.updatePortraitRing(); + this.updateStatus(); + this.updateTurnTotal(); + this.updateControls(); + } + + drawDie(g, x, y, size, face, { selected = false, locked = false } = {}) { + g.clear(); + this.paintDie(g, x, y, size, face, { selected, locked }); + } + + paintDie(g, x, y, size, face, { selected = false, locked = false } = {}) { + const half = size / 2; + const r = size * 0.14; + g.fillStyle(locked ? 0xe6dcc2 : 0xf2ead8, 1); + g.fillRoundedRect(x - half, y - half, size, size, r); + let bc = COLORS.muted, bw = 2; + if (selected) { bc = COLORS.gold; bw = 5; } + else if (locked) { bc = COLORS.accent; bw = 2; } + g.lineStyle(bw, bc, 1); + g.strokeRoundedRect(x - half, y - half, size, size, r); + g.fillStyle(0x1a1208, 1); + const pipR = Math.max(4, size * 0.094); + const off = size * 0.28; + for (const [cx, cy] of (PIP_POS[face] ?? [])) { + g.fillCircle(x + cx * off, y + cy * off, pipR); + } + } + + renderDice() { + const rolled = this.gs.turn.rolled; + for (let i = 0; i < DICE; i++) { + const el = this.dieEls[i]; + if (i < rolled.length) { + this.drawDie(el.g, el.cx, el.cy, DIE, rolled[i], { selected: this.selected.has(i) }); + el.hit.setInteractive({ useHandCursor: true }); + } else { + el.g.clear(); + el.hit.disableInteractive(); + } + } + } + + renderShelf() { + const g = this.shelfGfx; + g.clear(); + const dice = this.gs.turn.setAsideDice; + const startX = TRAY_CX - TRAY_W / 2 + SDIE / 2 + 4; + for (let i = 0; i < dice.length; i++) { + const x = startX + i * (SDIE + SDIE_GAP); + this.paintDie(g, x, SHELF_Y, SDIE, dice[i], { locked: true }); + } + } + + renderScratch() { + const cur = this.gs.current; + for (let seat = 0; seat < this.scratchRows.length; seat++) { + const p = this.gs.players[seat]; + const row = this.scratchRows[seat]; + const showTurn = (seat === cur && !isGameOver(this.gs) && this.gs.turn.kept > 0); + row.score.setText(showTurn ? `${p.score} +${this.gs.turn.kept}` : String(p.score)); + row.star.setVisible(p.onBoard); + } + // Underline the active player's row. + const u = this.scratchUnderline; + u.clear(); + if (isGameOver(this.gs)) return; + const row = this.scratchRows[cur]; + u.lineStyle(3, 0xcf6b5e, 0.9); + u.beginPath(); + u.moveTo(PAPER_X + 70, row.y + 24); + u.lineTo(PAPER_X + PAPER_W - 18, row.y + 24); + u.strokePath(); + } + + updatePortraitRing() { + for (let i = 0; i < this.portraitCtrls.length; i++) { + const { ring, x, y } = this.portraitCtrls[i]; + ring.clear(); + if (i === this.gs.current && !isGameOver(this.gs)) { + ring.lineStyle(4, COLORS.gold, 1); + ring.strokeCircle(x, y, PORTRAIT_R + 6); + } + } + } + + updateStatus() { + const gs = this.gs; + if (isGameOver(gs)) { this.statusText.setText('Game over'); return; } + const cur = gs.players[gs.current]; + if (cur.isAI) { this.statusText.setText(`${cur.name} is rolling…`); return; } + + if (gs.phase === 'awaitRoll') { + this.statusText.setText(cur.onBoard ? 'Your turn — roll the dice' : 'Your turn — reach 500 to get on the board'); + } else if (gs.phase === 'awaitPick') { + const score = this.selectionScore(); + if (this.selected.size === 0) this.statusText.setText('Select scoring dice to set aside'); + else if (score > 0) this.statusText.setText(`Selection: +${score}`); + else this.statusText.setText('That selection has non-scoring dice'); + } else { + this.statusText.setText('Roll again or bank'); + } + } + + updateTurnTotal() { + const gs = this.gs; + const kept = gs.turn.kept; + const sel = (gs.phase === 'awaitPick') ? this.selectionScore() : 0; + const total = kept + sel; + this.turnTotalText.setText(total > 0 ? `Turn total: ${total}` : ''); + } + + updateControls() { + const gs = this.gs; + const human = !gs.players[gs.current].isAI && !isGameOver(gs) && !this.busy; + const validSel = this.selected.size > 0 && this.selectionValid(); + + const canRoll = human && (gs.phase === 'awaitRoll' || (gs.phase === 'awaitPick' && validSel)); + const canBank = human && gs.phase === 'awaitPick' && validSel; + const canScoreAll = human && gs.phase === 'awaitPick'; + + this.rollBtn?.setEnabled(canRoll); + this.rollBtn?.setLabel(gs.phase === 'awaitPick' ? 'Set Aside & Roll' : 'Roll'); + this.bankBtn?.setEnabled(canBank); + this.scoreAllBtn?.setEnabled(canScoreAll); + } + + // ── selection helpers ───────────────────────────────────────────────────────── + selectionValues() { return [...this.selected].map((i) => this.gs.turn.rolled[i]); } + selectionValid() { return scoreSelection(this.selectionValues()).valid; } + selectionScore() { const r = scoreSelection(this.selectionValues()); return r.valid ? r.points : 0; } + + // ── input ────────────────────────────────────────────────────────────────────── + onDieClick(i) { + if (this.busy || !this.isHumanTurn() || this.gs.phase !== 'awaitPick') return; + if (i >= this.gs.turn.rolled.length) return; + if (this.selected.has(i)) this.selected.delete(i); else this.selected.add(i); + playSound(this, SFX.PIECE_CLICK); + this.render(); + } + + onScoreAll() { + if (this.busy || !this.isHumanTurn() || this.gs.phase !== 'awaitPick') return; + const best = bestScoring(this.gs.turn.rolled); + this.selected = new Set(best.indices); + playSound(this, SFX.PIECE_CLICK); + this.render(); + } + + async onRoll() { + if (this.busy || !this.isHumanTurn()) return; + if (this.gs.phase === 'awaitPick' && !this.commitSelection()) return; + if (this.gs.phase === 'gameover') return; + await this.rollAnimated(); + await this.afterRoll(); + } + + async onBank() { + if (this.busy || !this.isHumanTurn()) return; + if (this.gs.phase === 'awaitPick' && !this.commitSelection()) return; + if (this.gs.phase !== 'awaitDecision') return; + bank(this.gs); + playSound(this, SFX.PENCIL_WRITE); + this.render(); + this.advance(); + } + + commitSelection() { + const idx = [...this.selected]; + if (idx.length === 0) return false; + if (!this.selectionValid()) return false; + applySetAside(this.gs, idx); + this.selected.clear(); + return true; + } + + // ── turn driver ────────────────────────────────────────────────────────────── + isHumanTurn() { return !this.gs.players[this.gs.current].isAI && !isGameOver(this.gs); } + + advance() { + this.render(); + if (isGameOver(this.gs)) { this.showGameOver(); return; } + if (this.isHumanTurn()) { this.updateControls(); return; } + this.aiTurn(); + } + + async rollAnimated() { + this.busy = true; + this.updateControls(); + playSound(this, SFX.DICE_ROLL); + rollDice(this.gs); + await this.tumble(); + this.render(); + this.busy = false; + } + + tumble() { + const n = this.gs.turn.rolled.length; + return new Promise((resolve) => { + this.tweens.addCounter({ + from: 0, to: 1, duration: 520, ease: 'Quad.Out', + onUpdate: () => { + for (let i = 0; i < n; i++) { + const el = this.dieEls[i]; + this.drawDie(el.g, el.cx, el.cy, DIE, 1 + Math.floor(Math.random() * 6), {}); + } + }, + onComplete: resolve, + }); + }); + } + + async afterRoll() { + if (this.gs.phase === 'farkled') { + await this.farkleFx(); + farkleTurn(this.gs); + this.advance(); + } else { + this.advance(); + } + } + + async aiTurn() { + this.busy = true; + this.updateControls(); + const skill = this.gs.players[this.gs.current].skill; + + while (true) { + await this.delay(nextThinkDelay(skill)); + await this.rollAnimated(); + if (this.gs.phase === 'farkled') { + await this.farkleFx(); + farkleTurn(this.gs); + break; + } + const best = bestScoring(this.gs.turn.rolled); + this.selected = new Set(best.indices); + this.render(); + await this.delay(480); + applySetAside(this.gs, best.indices); + this.selected.clear(); + this.render(); + await this.delay(360); + if (decideReroll(this.gs, skill)) continue; + bank(this.gs); + playSound(this, SFX.PENCIL_WRITE); + break; + } + + this.busy = false; + this.render(); + this.advance(); + } + + // ── effects ────────────────────────────────────────────────────────────────── + farkleFx() { + playSound(this, SFX.CASINO_LOSE); + const toast = this.add.text(TRAY_CX, TRAY_CY, 'FARKLE!', { + fontFamily: 'Righteous', fontSize: '72px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(DEPTH.toast).setAngle(-8); + this.tweens.add({ targets: toast, scale: { from: 0.6, to: 1.1 }, duration: 260, ease: 'Back.Out' }); + return new Promise((resolve) => { + this.time.delayedCall(1100, () => { + this.tweens.add({ + targets: toast, alpha: 0, duration: 250, + onComplete: () => { toast.destroy(); resolve(); }, + }); + }); + }); + } + + // ── game over ────────────────────────────────────────────────────────────────── + showGameOver() { + if (this.gameOverShown) return; + this.gameOverShown = true; + playSound(this, SFX.VICTORY_SHORT); + this.postHistory().catch(() => {}); + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65) + .setInteractive().setDepth(DEPTH.modal); + + const N = this.gs.players.length; + const panelW = 720; + const panelH = 220 + N * 56; + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + this.add.rectangle(cx, cy, panelW, panelH, COLORS.panel, 1) + .setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal); + this.add.text(cx, cy - panelH / 2 + 48, 'Final Scores', { + fontFamily: 'Righteous', fontSize: '42px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(DEPTH.modal); + + const winners = new Set(getWinners(this.gs)); + const order = [...this.gs.players].sort((a, b) => b.score - a.score); + let rowY = cy - panelH / 2 + 110; + for (const p of order) { + const isWin = winners.has(p.seat); + const color = isWin ? COLORS.goldHex : COLORS.textHex; + this.add.text(cx - panelW / 2 + 40, rowY, `${isWin ? '★ ' : ' '}${p.name}`, { + fontFamily: 'Righteous', fontSize: '24px', color, + }).setOrigin(0, 0.5).setDepth(DEPTH.modal); + this.add.text(cx + panelW / 2 - 40, rowY, String(p.score), { + fontFamily: 'Righteous', fontSize: '28px', color, + }).setOrigin(1, 0.5).setDepth(DEPTH.modal); + rowY += 52; + } + + new Button(this, cx, cy + panelH / 2 - 48, 'Back to Menu', + () => this.scene.start('GameMenu'), { width: 280, fontSize: 24 }, + ).setDepth(DEPTH.modal); + } + + async postHistory() { + const totals = this.gs.players.map((p) => p.score); + const winners = new Set(getWinners(this.gs)); + let result; + if (winners.has(this.humanSeat) && winners.size === 1) result = 'win'; + else if (winners.has(this.humanSeat)) result = 'draw'; + else result = 'loss'; + await api.post('/history/single-player', { + slug: 'farkel', + score: totals[this.humanSeat], + opponentScores: totals.filter((_, i) => i !== this.humanSeat), + result, + }); + } + + delay(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); } +} diff --git a/public/src/games/farkel/FarkelLogic.js b/public/src/games/farkel/FarkelLogic.js new file mode 100644 index 0000000..6d76924 --- /dev/null +++ b/public/src/games/farkel/FarkelLogic.js @@ -0,0 +1,236 @@ +// Farkel — pure game engine. No Phaser, no timers. Deterministic given a seed so +// the AI self-play test is reproducible and lookahead is side-effect free. +// +// Scoring rules (confirmed): single 1 = 100, single 5 = 50; three-of-a-kind = +// face×100 (three 1s = 1000); four = 1000, five = 2000, six = 3000; straight +// 1-6 = 1500; three pairs = 1500. Hot dice: set aside all six and roll again, +// keeping the turn total. Must bank 500+ in one turn before any points count. + +import { DICE, WIN_TARGET, ON_BOARD_MIN } from './FarkelData.js'; + +// ── seeded RNG (mulberry32) ────────────────────────────────────────────────── +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ── scoring helpers ────────────────────────────────────────────────────────── +function countsOf(dice) { + const c = [0, 0, 0, 0, 0, 0, 0]; + for (const v of dice) c[v]++; + return c; +} + +// Value of an N-of-a-kind (n >= 3) of face v. +function nKindValue(v, n) { + if (n === 3) return v === 1 ? 1000 : v * 100; + if (n === 4) return 1000; + if (n === 5) return 2000; + return 3000; // n === 6 +} + +// For c (>=3) dice of face v, the best of "flat N-of-a-kind" vs +// "three-of-a-kind + the rest as singles" (only ever better for 1s/5s). +function bestGroupValue(v, c) { + const flat = nKindValue(v, c); + let alt = -1; + if (v === 1) alt = 1000 + (c - 3) * 100; + else if (v === 5) alt = 500 + (c - 3) * 50; + return Math.max(flat, alt); +} + +// Score a chosen multiset of dice, requiring that EVERY die contributes to a +// combo. Returns { valid, points }. +export function scoreSelection(dice) { + if (!dice || dice.length === 0) return { valid: false, points: 0 }; + const counts = countsOf(dice); + const total = dice.length; + let best = null; + const note = (p) => { best = best === null ? p : Math.max(best, p); }; + + // Whole-set six-dice patterns. + if (total === 6) { + if ([1, 2, 3, 4, 5, 6].every((v) => counts[v] === 1)) note(1500); // straight + if ([1, 2, 3, 4, 5, 6].every((v) => counts[v] % 2 === 0)) note(1500); // three pairs + } + + // Greedy decomposition — valid only if no die is left non-scoring. + let g = 0, ok = true; + for (let v = 1; v <= 6; v++) { + const c = counts[v]; + if (c === 0) continue; + if (c >= 3) { g += bestGroupValue(v, c); } + else if (v === 1) g += c * 100; + else if (v === 5) g += c * 50; + else { ok = false; } + } + if (ok) note(g); + + if (best === null) return { valid: false, points: 0 }; + return { valid: true, points: best }; +} + +// Greedy "take everything that scores" — returns the indices into `dice` to set +// aside and the points earned. Used by the AI and the human "Score all" helper. +export function bestScoring(dice) { + const counts = countsOf(dice); + const total = dice.length; + const candidates = []; + + // Greedy: all 1s/5s plus every N-of-a-kind. + { + const take = [0, 0, 0, 0, 0, 0, 0]; + let pts = 0; + for (let v = 1; v <= 6; v++) { + const c = counts[v]; + if (c >= 3) { take[v] = c; pts += bestGroupValue(v, c); } + else if (v === 1) { take[v] = c; pts += c * 100; } + else if (v === 5) { take[v] = c; pts += c * 50; } + } + candidates.push({ take, pts }); + } + // Straight / three pairs use all six dice. + if (total === 6 && [1, 2, 3, 4, 5, 6].every((v) => counts[v] === 1)) { + candidates.push({ take: [0, 1, 1, 1, 1, 1, 1], pts: 1500 }); + } + if (total === 6 && [1, 2, 3, 4, 5, 6].every((v) => counts[v] % 2 === 0)) { + candidates.push({ take: counts.slice(), pts: 1500 }); + } + + const best = candidates.reduce((a, b) => (b.pts > a.pts ? b : a)); + const need = best.take.slice(); + const indices = []; + dice.forEach((v, i) => { if (need[v] > 0) { indices.push(i); need[v]--; } }); + return { indices, points: best.pts }; +} + +export function hasScoring(dice) { + return bestScoring(dice).points > 0; +} + +// ── state ──────────────────────────────────────────────────────────────────── +export function createInitialState({ playerCount = 4, names = [], skills = {}, seed } = {}) { + const rng = mulberry32((seed ?? (Date.now() & 0x7fffffff)) | 0); + const players = []; + for (let seat = 0; seat < playerCount; seat++) { + players.push({ + name: names[seat] ?? `Player ${seat + 1}`, + seat, + score: 0, + onBoard: false, + skill: skills[seat] ?? 3, + isAI: seat !== 0, + }); + } + const state = { + players, + current: 0, + phase: 'awaitRoll', // awaitRoll | awaitPick | awaitDecision | farkled | gameover + winner: null, + winners: [], + finalRound: false, + finalFrom: null, + _rng: rng, + }; + resetTurn(state); + return state; +} + +function resetTurn(state) { + state.turn = { rolled: [], available: DICE, kept: 0, setAsideDice: [], hotDice: false }; + state.phase = 'awaitRoll'; +} + +export function cloneState(s) { + const rngState = s._rng; // functions don't clone; lookahead must not roll dice + const copy = JSON.parse(JSON.stringify({ ...s, _rng: undefined })); + copy._rng = rngState; + return copy; +} + +// Roll the available dice into turn.rolled. Sets phase to awaitPick, or farkled +// when the roll scores nothing. +export function rollDice(state) { + const t = state.turn; + const rng = state._rng; + const out = []; + for (let i = 0; i < t.available; i++) out.push(1 + Math.floor(rng() * 6)); + t.rolled = out; + t.hotDice = false; + state.phase = hasScoring(out) ? 'awaitPick' : 'farkled'; + return state; +} + +// Set aside the dice at the given indices (into turn.rolled). Adds their points +// to the turn total. Triggers hot dice when all six are used. Returns true on a +// valid selection. +export function applySetAside(state, indices) { + const t = state.turn; + const values = indices.map((i) => t.rolled[i]); + const res = scoreSelection(values); + if (!res.valid) return false; + + t.kept += res.points; + t.setAsideDice.push(...values); + t.available -= values.length; + t.rolled = []; + + if (t.available === 0) { + // Hot dice — every die scored, so roll all six again. + t.available = DICE; + t.setAsideDice = []; + t.hotDice = true; + } + state.phase = 'awaitDecision'; + return true; +} + +// Bank the turn total (subject to the on-board minimum) and pass play. +export function bank(state) { + const p = state.players[state.current]; + const t = state.turn; + const bankable = (p.onBoard || t.kept >= ON_BOARD_MIN) ? t.kept : 0; + if (bankable > 0) { p.score += bankable; p.onBoard = true; } + advanceTurn(state); + return state; +} + +// Forfeit the turn total and pass play. +export function farkleTurn(state) { + advanceTurn(state); + return state; +} + +function advanceTurn(state) { + const N = state.players.length; + const p = state.players[state.current]; + + if (!state.finalRound && p.score >= WIN_TARGET) { + state.finalRound = true; + state.finalFrom = state.current; + } + + state.current = (state.current + 1) % N; + + if (state.finalRound && state.current === state.finalFrom) { + state.phase = 'gameover'; + state.winners = computeWinners(state); + state.winner = state.winners[0] ?? null; + return; + } + resetTurn(state); +} + +function computeWinners(state) { + let max = -Infinity; + state.players.forEach((p) => { if (p.score > max) max = p.score; }); + return state.players.filter((p) => p.score === max).map((p) => p.seat); +} + +export function isGameOver(state) { return state.phase === 'gameover'; } +export function getWinners(state) { return state.winners; } diff --git a/public/src/games/videopoker/VideoPokerGame.js b/public/src/games/videopoker/VideoPokerGame.js index 2e14c26..f95a643 100644 --- a/public/src/games/videopoker/VideoPokerGame.js +++ b/public/src/games/videopoker/VideoPokerGame.js @@ -25,8 +25,9 @@ const CRT = { cabinet: 0x2a2d33, cabinetHi: 0x444851, cabinetLo: 0x14161a, - screenBg: 0x041014, - screenTop: 0x06222a, + screenBg: 0x0a1456, // classic video-poker royal blue (lower) + screenTop: 0x16278f, // royal blue (upper) + screenEdge: 0x4a6cff, // bright blue screen-edge glow phosphor: 0x39ff9e, // green phosphor glow phosphorHex: '#39ff9e', amber: 0xffcf4a, @@ -158,21 +159,22 @@ export default class VideoPokerGame extends Phaser.Scene { maskG.fillRoundedRect(SCR_X, SCR_Y, SCR_W, SCR_H, 16); this.screenMask = maskG.createGeometryMask(); - // Scrolling scanlines (generated 1×4 strip tiled across the screen). + // CRT scanlines: dark horizontal lines tiled across the screen. An 8px strip + // with a 4px dark line + 4px gap gives thick, clearly visible scanlines. + // Drawn above the cards (D.card + 2) so the lines sweep over them too. const strip = this.make.graphics({ x: 0, y: 0, add: false }); - strip.fillStyle(CRT.scan, 0.10); - strip.fillRect(0, 0, SCR_W, 2); - strip.generateTexture('vpScan', SCR_W, 4); + strip.fillStyle(0x000000, 0.45); + strip.fillRect(0, 0, SCR_W, 4); + strip.generateTexture('vpScan', SCR_W, 8); const scan = this.add.tileSprite(SCR_X, SCR_Y, SCR_W, SCR_H, 'vpScan') - .setOrigin(0, 0).setDepth(D.scan).setAlpha(0.5) - .setBlendMode(Phaser.BlendModes.ADD).setMask(this.screenMask); - this.tweens.add({ targets: scan, tilePositionY: SCR_H, duration: 8000, repeat: -1, ease: 'Linear' }); + .setOrigin(0, 0).setDepth(D.card + 2).setAlpha(0.55).setMask(this.screenMask); + // Gentle vertical drift for a live-CRT shimmer. + this.tweens.add({ targets: scan, tilePositionY: SCR_H, duration: 9000, repeat: -1, ease: 'Linear' }); - // Curved-corner vignette + screen edge glow. + // Curved-corner vignette + blue screen edge glow. const vg = this.add.graphics().setDepth(D.glow); - vg.lineStyle(3, CRT.phosphor, 0.18); + vg.lineStyle(3, CRT.screenEdge, 0.25); vg.strokeRoundedRect(SCR_X + 2, SCR_Y + 2, SCR_W - 4, SCR_H - 4, 14); - vg.fillStyle(0x000000, 0.0); // Status line that shows messages on the screen (e.g. GAME OVER / payout). this.statusText = this.add.text(SCR_X + SCR_W / 2, SCR_Y + 40, '', { diff --git a/public/src/main.js b/public/src/main.js index 6b85579..0505953 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -55,6 +55,7 @@ import SplendorGame from './games/splendor/SplendorGame.js'; import TectonicGame from './games/tectonic/TectonicGame.js'; import LabyrinthGame from './games/labyrinth/LabyrinthGame.js'; import VideoPokerGame from './games/videopoker/VideoPokerGame.js'; +import FarkelGame from './games/farkel/FarkelGame.js'; const config = { type: Phaser.AUTO, @@ -123,6 +124,7 @@ const config = { TectonicGame, LabyrinthGame, VideoPokerGame, + FarkelGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 24884bb..cd803f9 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' }; + 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' }; 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 4b2fea6..3e4373d 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -70,3 +70,4 @@ registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'c registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 }); registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 }); registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 }); +registerGame({ slug: 'farkel', name: 'Farkel', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });