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,
|
||||
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, '', {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue