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:
parent
371833a0e6
commit
246d60e2a9
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.
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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' },
|
||||||
|
];
|
||||||
|
|
@ -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)); }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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, '', {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue