feat: add Farkel game and update Video Poker CRT theme

- Add new Farkel dice game (2-4 players, tabletop category)
- Update Video Poker CRT screen from green phosphor to royal blue theme
- Refresh game-icons sprite sheet for new game assets
This commit is contained in:
Brian Fertig 2026-06-06 18:01:50 -06:00
parent 371833a0e6
commit 246d60e2a9
14 changed files with 924 additions and 13 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

View File

@ -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;
}

View File

@ -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' },
];

View File

@ -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)); }
}

View File

@ -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; }

View File

@ -25,8 +25,9 @@ const CRT = {
cabinet: 0x2a2d33, cabinet: 0x2a2d33,
cabinetHi: 0x444851, cabinetHi: 0x444851,
cabinetLo: 0x14161a, cabinetLo: 0x14161a,
screenBg: 0x041014, screenBg: 0x0a1456, // classic video-poker royal blue (lower)
screenTop: 0x06222a, screenTop: 0x16278f, // royal blue (upper)
screenEdge: 0x4a6cff, // bright blue screen-edge glow
phosphor: 0x39ff9e, // green phosphor glow phosphor: 0x39ff9e, // green phosphor glow
phosphorHex: '#39ff9e', phosphorHex: '#39ff9e',
amber: 0xffcf4a, amber: 0xffcf4a,
@ -158,21 +159,22 @@ export default class VideoPokerGame extends Phaser.Scene {
maskG.fillRoundedRect(SCR_X, SCR_Y, SCR_W, SCR_H, 16); maskG.fillRoundedRect(SCR_X, SCR_Y, SCR_W, SCR_H, 16);
this.screenMask = maskG.createGeometryMask(); 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 }); const strip = this.make.graphics({ x: 0, y: 0, add: false });
strip.fillStyle(CRT.scan, 0.10); strip.fillStyle(0x000000, 0.45);
strip.fillRect(0, 0, SCR_W, 2); strip.fillRect(0, 0, SCR_W, 4);
strip.generateTexture('vpScan', SCR_W, 4); strip.generateTexture('vpScan', SCR_W, 8);
const scan = this.add.tileSprite(SCR_X, SCR_Y, SCR_W, SCR_H, 'vpScan') const scan = this.add.tileSprite(SCR_X, SCR_Y, SCR_W, SCR_H, 'vpScan')
.setOrigin(0, 0).setDepth(D.scan).setAlpha(0.5) .setOrigin(0, 0).setDepth(D.card + 2).setAlpha(0.55).setMask(this.screenMask);
.setBlendMode(Phaser.BlendModes.ADD).setMask(this.screenMask); // Gentle vertical drift for a live-CRT shimmer.
this.tweens.add({ targets: scan, tilePositionY: SCR_H, duration: 8000, repeat: -1, ease: 'Linear' }); 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); 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.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). // 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, '', { this.statusText = this.add.text(SCR_X + SCR_W / 2, SCR_Y + 40, '', {

View File

@ -55,6 +55,7 @@ import SplendorGame from './games/splendor/SplendorGame.js';
import TectonicGame from './games/tectonic/TectonicGame.js'; import TectonicGame from './games/tectonic/TectonicGame.js';
import LabyrinthGame from './games/labyrinth/LabyrinthGame.js'; import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
import VideoPokerGame from './games/videopoker/VideoPokerGame.js'; import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
import FarkelGame from './games/farkel/FarkelGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -123,6 +124,7 @@ const config = {
TectonicGame, TectonicGame,
LabyrinthGame, LabyrinthGame,
VideoPokerGame, VideoPokerGame,
FarkelGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -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: '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: '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: '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 });