feat: add Craps AI improvements and new Roulette game

Implements a fully playable Roulette game with American double-zero rules, a spinning wheel animation, and a heuristic AI that assigns distinct betting personalities to opponents. Adds colored identity rings to player portraits in both Craps and Roulette for better visual distinction. Updates Craps to render AI bets directly on the felt with color-coded chips and names, and adjusts dice landing positions.
This commit is contained in:
Brian Fertig 2026-05-20 23:59:43 -06:00
parent 8a801b0f05
commit 73f50e2106
7 changed files with 1075 additions and 9 deletions

View File

@ -18,7 +18,7 @@ import { chooseBets, chooseOdds } from './CrapsAI.js';
const CX = GAME_WIDTH / 2; // 960
const DIE_SIZE = 76;
const TABLE = { x: 220, y: 110, w: 1480, h: 610 };
const LAND = { x: CX, y: 430 }; // where thrown dice settle
const LAND = { x: 1555, y: 218 }; // upper-right open area, right of come and number boxes
const NUM_TO_BOX = { 4: 0, 5: 1, 6: 2, 8: 3, 9: 4, 10: 5 };
const BOX_NUMS = [4, 5, 6, 8, 9, 10];
@ -30,6 +30,10 @@ const PORTRAIT_R = 46;
const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
// Per-player identity colors: seat 0 (human) is gold, opponents get distinct hues.
const SEAT_COLORS = [0xd4a017, 0x4aa3df, 0xe06c75, 0x5cb85c, 0xb07cd6, 0xe09a3c, 0x3cc6c0];
const hexStr = (n) => `#${n.toString(16).padStart(6, '0')}`;
const D = {
bg: -1, felt: 0, zone: 1, zoneLabel: 2, betChip: 6, puck: 8,
portrait: 20, shooterRing: 21, dice: 30, fx: 40, ui: 50, prompt: 55, modal: 60,
@ -64,6 +68,8 @@ export default class CrapsGame extends Phaser.Scene {
this.zoneGfx = null;
this.humanChipObjs = [];
this.aiWagerObjs = {}; // seat → { container }
this.aiBetObjs = []; // chips on the felt for AI bets
this.seatJitter = Array.from({ length: 7 }, () => ({ dx: (Math.random() - 0.5) * 18, dy: (Math.random() - 0.5) * 8 }));
this.portraits = [];
this.chipBtns = [];
this.startingChips = 2000;
@ -262,10 +268,10 @@ export default class CrapsGame extends Phaser.Scene {
g.setVisible(true).setPosition(ox, oy).setScale(0.55).setAngle(Math.random() * 360);
this.drawDieFace(g, rnd6());
const wallX = CX + (idx ? 80 : -80) + (Math.random() * 40 - 20);
const wallY = TABLE.y + 52 + Math.random() * 26;
const landX = LAND.x + (idx ? 58 : -58) + (Math.random() * 24 - 12);
const landY = LAND.y + (Math.random() * 30 - 12);
const wallX = TABLE.x + TABLE.w - 38 + (Math.random() * 24 - 12); // upper-right corner wall
const wallY = TABLE.y + 44 + Math.random() * 22;
const landX = LAND.x + (idx ? 32 : -32) + (Math.random() * 20 - 10);
const landY = LAND.y + (Math.random() * 24 - 12);
const outMs = 320 + idx * 28;
const backMs = 540 + idx * 44;
@ -359,17 +365,24 @@ export default class CrapsGame extends Phaser.Scene {
for (let i = 0; i < this.gs.players.length; i++) {
const x = SEAT_X[i];
const p = this.gs.players[i];
const seatColor = SEAT_COLORS[i % SEAT_COLORS.length];
let ctrl;
if (i === 0) ctrl = createPlayerPortrait(this, x, SEAT_Y, PORTRAIT_R, D.portrait, 'CrapsGame');
else ctrl = createOpponentPortrait(this, p.avatar, x, SEAT_Y, PORTRAIT_R, D.portrait);
this.portraits.push(ctrl);
this.add.text(x, SEAT_Y + PORTRAIT_R + 16, p.name.length > 12 ? p.name.slice(0, 11) + '…' : p.name, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
const ring = this.add.graphics().setDepth(D.portrait + 1);
ring.lineStyle(4, seatColor, 1);
ring.strokeCircle(x, SEAT_Y, PORTRAIT_R + 5);
const shortName = p.name.length > 10 ? p.name.slice(0, 9) + '…' : p.name;
this.add.text(x, SEAT_Y + PORTRAIT_R + 16, shortName, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: hexStr(seatColor),
}).setOrigin(0.5).setDepth(D.ui);
}
this.shooterRingGfx = this.add.graphics().setDepth(D.shooterRing);
this.renderAiWagers();
this.renderAiBets();
this.updateBalances();
}
@ -444,7 +457,113 @@ export default class CrapsGame extends Phaser.Scene {
const risk = totalAtRisk(this.gs.players[i]);
if (risk <= 0) continue;
const x = SEAT_X[i];
this.aiWagerObjs[i] = this.makeChipToken(x, SEAT_Y - PORTRAIT_R - 18, `$${risk}`, CHIP_COLORS[25]);
this.aiWagerObjs[i] = this.makeChipToken(x, SEAT_Y - PORTRAIT_R - 18, `$${risk}`, SEAT_COLORS[i % SEAT_COLORS.length]);
}
}
betZoneKey(bet) {
switch (bet.type) {
case BET.PASS: return 'pass';
case BET.DONT_PASS: return 'dontpass';
case BET.FIELD: return 'field';
case BET.PLACE: return `box${bet.number}-place`;
case BET.COME: return bet.comePoint == null ? 'come' : `box${bet.comePoint}-come`;
case BET.DONT_COME: return bet.comePoint == null ? 'dontcome' : `box${bet.comePoint}-dontcome`;
default: return null;
}
}
drawAiChip(x, y, playerIndex, label, name) {
const color = SEAT_COLORS[playerIndex % SEAT_COLORS.length];
const c = this.add.container(x, y).setDepth(D.betChip);
const g = this.add.graphics();
g.fillStyle(color, 1); g.fillCircle(0, 0, 16);
g.lineStyle(3, 0xffffff, 0.5); g.strokeCircle(0, 0, 16);
const t = this.add.text(0, 0, label, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
const n = this.add.text(0, 24, name, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: hexStr(color), fontStyle: 'bold',
stroke: '#000000', strokeThickness: 2,
}).setOrigin(0.5);
c.add([g, t, n]);
return c;
}
// Place a chip in the left/right side of a zone, skirting the center text.
chipSidePos(z, zoneKey, i, n, pi) {
// Approximate widths of each zone's center text label.
const avoidW = { pass: 250, dontpass: 310, field: 550, come: 170, dontcome: 60 }[zoneKey] ?? 240;
const margin = 28;
const jitter = this.seatJitter[pi % this.seatJitter.length];
const leftBound = z.rect.x + margin;
const rightBound = z.rect.x + z.rect.w - margin;
const leftEnd = z.center.x - avoidW / 2;
const rightStart = z.center.x + avoidW / 2;
const leftW = Math.max(0, leftEnd - leftBound);
const rightW = Math.max(0, rightBound - rightStart);
let x;
if (leftW <= 0 && rightW <= 0) {
// Narrow zone with no side room — fall back to even spread.
x = leftBound + ((i + 1) / (n + 1)) * (rightBound - leftBound) + jitter.dx * 0.5;
} else {
// Alternate chips left/right so they spread across both sides.
const leftCount = Math.ceil(n / 2);
const rightCount = Math.floor(n / 2);
if (i % 2 === 0) {
const si = i / 2;
x = leftBound + (si + 1) * (leftW / (leftCount + 1)) + jitter.dx * 0.6;
} else {
const si = (i - 1) / 2;
x = rightStart + (si + 1) * (rightW / (Math.max(rightCount, 1) + 1)) + jitter.dx * 0.6;
}
}
x = Math.max(leftBound, Math.min(rightBound, x));
return { x, y: z.center.y + jitter.dy };
}
renderAiBets() {
for (const o of this.aiBetObjs) o.destroy();
this.aiBetObjs = [];
const groups = new Map();
for (let pi = 1; pi < this.gs.players.length; pi++) {
for (const bet of this.gs.players[pi].bets) {
const key = this.betZoneKey(bet);
if (!key) continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ pi, bet });
}
}
for (const [key, entries] of groups.entries()) {
const n = entries.length;
if (key.includes('-')) {
const [boxPart, slotType] = key.split('-');
const number = parseInt(boxPart.slice(3));
const slot = slotType === 'place' ? 0 : slotType === 'come' ? 1 : 2;
const base = this.boxChipPos(number, slot);
entries.forEach((e, idx) => {
const dx = n > 1 ? (idx - (n - 1) / 2) * 22 : 0;
const label = `$${e.bet.amount}`;
const shortN = e.pi < this.gs.players.length
? (this.gs.players[e.pi].name.length > 6 ? this.gs.players[e.pi].name.slice(0, 5) + '…' : this.gs.players[e.pi].name)
: '';
this.aiBetObjs.push(this.drawAiChip(base.x + dx, base.y, e.pi, label, shortN));
});
} else {
const z = this.zones[key];
if (!z) continue;
entries.forEach((e, idx) => {
const { x, y } = this.chipSidePos(z, key, idx, n, e.pi);
const label = `$${e.bet.amount}`;
const shortN = e.pi < this.gs.players.length
? (this.gs.players[e.pi].name.length > 6 ? this.gs.players[e.pi].name.slice(0, 5) + '…' : this.gs.players[e.pi].name)
: '';
this.aiBetObjs.push(this.drawAiChip(x, y, e.pi, label, shortN));
});
}
}
}
@ -522,6 +641,7 @@ export default class CrapsGame extends Phaser.Scene {
: `Come-out roll — ${this.gs.players[this.gs.shooterIndex].name} is shooting. Place your bets.`);
this.renderHumanBets();
this.renderAiWagers();
this.renderAiBets();
this.updateBalances();
this.refreshControls();
}
@ -535,6 +655,7 @@ export default class CrapsGame extends Phaser.Scene {
}
}
this.renderAiWagers();
this.renderAiBets();
}
onShoot() {
@ -572,6 +693,7 @@ export default class CrapsGame extends Phaser.Scene {
this.setStatus(this.outcomeMessage(res, total));
this.renderHumanBets();
this.renderAiWagers();
this.renderAiBets();
this.updateBalances();
this.persistChips();

View File

@ -0,0 +1,80 @@
// Heuristic Roulette AI. Stateless helpers consumed by RouletteGame.
//
// Each opponent gets a stable "personality" derived from their name, which
// shapes which bets they favour and how big. chooseBets returns plain bet specs
// the scene applies through RouletteLogic.placeBet (which guards affordability).
import { BET } from './RouletteLogic.js';
const EVEN_MONEY = [BET.RED, BET.BLACK, BET.ODD, BET.EVEN, BET.LOW, BET.HIGH];
const PROFILES = {
// unitPct: flat-bet size as a fraction of bankroll
// evenChance: probability of taking an even-money outside bet
// groupChance: probability of taking a dozen or column (2:1) bet
// straightCount: how many favourite straight-up numbers they sprinkle on
conservative: { unitPct: 0.03, evenChance: 0.92, groupChance: 0.12, straightCount: 0 },
balanced: { unitPct: 0.05, evenChance: 0.70, groupChance: 0.55, straightCount: 0 },
aggressive: { unitPct: 0.07, evenChance: 0.45, groupChance: 0.70, straightCount: 1 },
gambler: { unitPct: 0.10, evenChance: 0.50, groupChance: 0.35, straightCount: 3 },
};
const PROFILE_KEYS = Object.keys(PROFILES);
function hash(str) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return h;
}
function profileFor(player) {
const name = player.name ?? player.avatar?.id ?? 'bot';
return PROFILES[PROFILE_KEYS[hash(name) % PROFILE_KEYS.length]];
}
const snap = (n, step) => Math.max(step, Math.round(n / step) * step);
function unitFor(player, prof) {
return Math.min(100, snap(player.chips * prof.unitPct, 5));
}
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Deterministic favourite numbers (136) for an opponent, so a "gambler" keeps
// chasing the same lucky spots round after round.
function luckyNumbers(player, count) {
if (count <= 0) return [];
const seed = hash(player.name ?? player.avatar?.id ?? 'bot');
const out = [];
let x = seed || 1;
while (out.length < count) {
x = (x * 1103515245 + 12345) >>> 0;
const n = 1 + (x % 36);
if (!out.includes(n)) out.push(n);
}
return out;
}
// Bets to place for the current round. Returns an array of bet specs.
export function chooseBets(player, state) {
const prof = profileFor(player);
const specs = [];
let budget = player.chips;
const unit = unitFor(player, prof);
const afford = (amt) => amt > 0 && amt <= budget;
const add = (spec) => { if (afford(spec.amount)) { specs.push(spec); budget -= spec.amount; } };
if (Math.random() < prof.evenChance) {
add({ type: pick(EVEN_MONEY), amount: unit });
}
if (Math.random() < prof.groupChance) {
const type = Math.random() < 0.5 ? BET.DOZEN : BET.COLUMN;
add({ type, number: 1 + Math.floor(Math.random() * 3), amount: snap(unit, 5) });
}
for (const n of luckyNumbers(player, prof.straightCount)) {
add({ type: BET.STRAIGHT, number: n, amount: Math.max(5, snap(unit / 2, 5)) });
}
// Never sit a round out entirely.
if (specs.length === 0 && afford(unit)) add({ type: pick(EVEN_MONEY), amount: unit });
return specs;
}

View File

@ -0,0 +1,644 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { Modal } from '../../ui/Modal.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { api } from '../../services/api.js';
import { auth } from '../../services/auth.js';
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
POCKETS, GRID_ROWS, CHIP_AMOUNTS, BET, colorOf, betWins,
createInitialState, placeBet, refundBets, clearLastDeltas,
totalAtRisk, hasActiveBets, getNetResult, spin, resolveSpin,
} from './RouletteLogic.js';
import { chooseBets } from './RouletteAI.js';
// ─── Layout ────────────────────────────────────────────────────────────────--
const CX = GAME_WIDTH / 2;
const WHEEL = { x: 430, y: 415, r: 185 };
// Felt geometry (right side of the screen).
const CELL_W = 84, CELL_H = 70;
const GRID_X = 760, GRID_TOP = 205;
const ZERO_X = 706, ZERO_W = 54;
const COL_X = GRID_X + 12 * CELL_W; // "2 to 1" column boxes
const COL_W = 76;
const DOZ_TOP = GRID_TOP + 3 * CELL_H;
const DOZ_H = 58, DOZ_W = (12 * CELL_W) / 3;
const EVEN_TOP = DOZ_TOP + DOZ_H;
const EVEN_H = 58, EVEN_W = (12 * CELL_W) / 6;
const POT = { x: 1280, y: 350 }; // where win/loss chips fly to/from
// Seat slots for up to 7 players — human centred, opponents fanned out.
const SEAT_X = [960, 700, 1220, 440, 1480, 200, 1740];
const SEAT_Y = 838;
const PORTRAIT_R = 46;
// Chip-tray denomination colors (shared look with Craps).
const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
// Per-player chip identity: seat 0 (you) is gold, opponents get distinct hues.
const SEAT_COLORS = [0xd4a017, 0x4aa3df, 0xe06c75, 0x5cb85c, 0xb07cd6, 0xe09a3c, 0x3cc6c0];
const hexStr = (n) => `#${n.toString(16).padStart(6, '0')}`;
const D = {
bg: -2, bgImg: -1, feltBg: 0, cell: 1, cellLabel: 2, zone: 3, hover: 4,
betChip: 6, marker: 7, wheelRim: 8, wheel: 9, ball: 12,
portrait: 20, hl: 25, fx: 40, ui: 50, modal: 60,
};
const TWO_PI = Math.PI * 2;
const STEP = TWO_PI / POCKETS.length;
export default class RouletteGame extends Phaser.Scene {
constructor() { super('RouletteGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
}
async create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.gs = null;
this.animating = false;
this.selectedChip = 25;
this.zones = {};
this.betObjs = [];
this.highlightObjs = [];
this.portraits = [];
this.chipBtns = [];
this.startingChips = 2000;
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg);
this.buildPlayfield();
this.buildTitle();
this.buildFelt();
this.buildWheel();
this.buildChipTray();
this.buildButtons();
new Button(this, 110, GAME_HEIGHT - 48, 'Leave', () => this.leave(), {
variant: 'ghost', width: 150, fontSize: 20,
}).setDepth(D.ui);
await this.loadChips();
this.gs = createInitialState(this.opponents, this.startingChips, auth.user?.username ?? 'You');
this.buildPortraits();
this.beginRound();
}
// ── Background & title ───────────────────────────────────────────────────--
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(CX, GAME_HEIGHT / 2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bgImg);
}
}
buildTitle() {
this.add.text(CX, 52, 'Roulette', {
fontFamily: 'Righteous', fontSize: '50px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.statusText = this.add.text(CX, 104, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.ui);
}
// ── Betting felt ───────────────────────────────────────────────────────────
defineZones() {
const add = (key, type, number, x, y, w, h) => {
this.zones[key] = { type, number, rect: { x, y, w, h }, center: { x: x + w / 2, y: y + h / 2 } };
};
// Number grid (3 rows × 12 cols).
GRID_ROWS.forEach((row, r) => {
row.forEach((n, c) => add(`n${n}`, BET.STRAIGHT, n, GRID_X + c * CELL_W, GRID_TOP + r * CELL_H, CELL_W, CELL_H));
});
// Zeros.
add('n0', BET.STRAIGHT, 0, ZERO_X, GRID_TOP, ZERO_W, CELL_H * 1.5);
add('n00', BET.STRAIGHT, '00', ZERO_X, GRID_TOP + CELL_H * 1.5, ZERO_W, CELL_H * 1.5);
// Column (2 to 1) boxes — top row is column 3, bottom is column 1.
[3, 2, 1].forEach((colNum, r) => add(`col${colNum}`, BET.COLUMN, colNum, COL_X, GRID_TOP + r * CELL_H, COL_W, CELL_H));
// Dozens.
[1, 2, 3].forEach((k, i) => add(`doz${k}`, BET.DOZEN, k, GRID_X + i * DOZ_W, DOZ_TOP, DOZ_W, DOZ_H));
// Even-money row.
const ev = [BET.LOW, BET.EVEN, BET.RED, BET.BLACK, BET.ODD, BET.HIGH];
ev.forEach((type, i) => add(type, type, null, GRID_X + i * EVEN_W, EVEN_TOP, EVEN_W, EVEN_H));
}
buildFelt() {
this.defineZones();
// Felt panel behind the layout.
const px = ZERO_X - 8, py = GRID_TOP - 10;
const pw = (COL_X + COL_W) - px + 8, ph = (EVEN_TOP + EVEN_H) - py + 8;
const panel = this.add.graphics().setDepth(D.feltBg);
panel.fillStyle(0x14532d, 0.95);
panel.fillRoundedRect(px, py, pw, ph, 18);
panel.lineStyle(6, COLORS.accent, 0.9);
panel.strokeRoundedRect(px, py, pw, ph, 18);
const g = this.add.graphics().setDepth(D.cell);
const label = (cx, cy, text, size, color, font = '"Julius Sans One"') =>
this.add.text(cx, cy, text, { fontFamily: font, fontSize: `${size}px`, color, fontStyle: 'bold', align: 'center' })
.setOrigin(0.5).setDepth(D.cellLabel);
const EVEN_LABEL = { [BET.LOW]: '1 to 18', [BET.EVEN]: 'EVEN', [BET.RED]: 'RED', [BET.BLACK]: 'BLACK', [BET.ODD]: 'ODD', [BET.HIGH]: '19 to 36' };
for (const [key, z] of Object.entries(this.zones)) {
const { x, y, w, h } = z.rect;
let fill = 0x0c3b21, alpha = 0.6; // default felt cell
if (z.type === BET.STRAIGHT) {
const col = colorOf(z.number);
fill = col === 'green' ? 0x1b7a3d : col === 'red' ? 0xb23636 : 0x1c1c1c;
alpha = 0.95;
} else if (z.type === BET.RED) { fill = 0xb23636; alpha = 0.95; }
else if (z.type === BET.BLACK) { fill = 0x1c1c1c; alpha = 0.95; }
g.fillStyle(fill, alpha);
g.fillRoundedRect(x + 2, y + 2, w - 4, h - 4, 6);
g.lineStyle(2, COLORS.accent, 0.55);
g.strokeRoundedRect(x + 2, y + 2, w - 4, h - 4, 6);
if (z.type === BET.STRAIGHT) {
label(z.center.x, z.center.y, z.number === '00' ? '00' : String(z.number), z.number === 0 || z.number === '00' ? 26 : 24, '#ffffff', 'Righteous');
} else if (z.type === BET.COLUMN) {
label(z.center.x, z.center.y, '2 to 1', 18, COLORS.textHex);
} else if (z.type === BET.DOZEN) {
label(z.center.x, z.center.y, ['1st 12', '2nd 12', '3rd 12'][z.number - 1], 22, COLORS.textHex);
} else {
const txt = EVEN_LABEL[z.type];
const c = (z.type === BET.RED || z.type === BET.BLACK) ? '#ffffff' : COLORS.textHex;
label(z.center.x, z.center.y, txt, 22, c);
}
const zone = this.add.zone(z.center.x, z.center.y, w, h).setDepth(D.zone);
zone.setInteractive({ useHandCursor: true });
zone.on('pointerover', () => this.hoverZone(key, true));
zone.on('pointerout', () => this.hoverZone(key, false));
zone.on('pointerup', () => this.onZoneClick(key));
}
this.hoverG = this.add.graphics().setDepth(D.hover);
}
hoverZone(key, on) {
this.hoverG.clear();
if (!on || !this.bettingEnabled()) return;
const z = this.zones[key];
this.hoverG.lineStyle(4, COLORS.gold, 1);
this.hoverG.strokeRoundedRect(z.rect.x + 2, z.rect.y + 2, z.rect.w - 4, z.rect.h - 4, 6);
}
// ── Wheel & ball ─────────────────────────────────────────────────────────--
buildWheel() {
const r = WHEEL.r;
this.wheel = this.add.container(WHEEL.x, WHEEL.y).setDepth(D.wheel);
const g = this.add.graphics();
this.wheel.add(g);
// Rim.
g.fillStyle(0x2a1d10, 1); g.fillCircle(0, 0, r + 24);
g.lineStyle(6, COLORS.gold, 1); g.strokeCircle(0, 0, r + 24);
// Pocket wedges.
const dir = (t) => [Math.sin(t) * r, -Math.cos(t) * r];
for (let i = 0; i < POCKETS.length; i++) {
const col = colorOf(POCKETS[i]);
const fill = col === 'green' ? 0x1b7a3d : col === 'red' ? 0xc0392b : 0x161616;
const a0 = (i - 0.5) * STEP, a1 = (i + 0.5) * STEP;
g.fillStyle(fill, 1);
g.beginPath(); g.moveTo(0, 0);
const seg = 5;
for (let j = 0; j <= seg; j++) { const [dx, dy] = dir(a0 + (a1 - a0) * (j / seg)); g.lineTo(dx, dy); }
g.closePath(); g.fillPath();
// Pocket separator.
g.lineStyle(1, 0x0a0a0a, 0.7);
const [sx, sy] = dir(a0); g.beginPath(); g.moveTo(0, 0); g.lineTo(sx, sy); g.strokePath();
}
// Inner fret ring + hub.
g.lineStyle(3, COLORS.gold, 0.85); g.strokeCircle(0, 0, r * 0.55);
g.fillStyle(0x6b4e1f, 1); g.fillCircle(0, 0, r * 0.30);
g.lineStyle(3, COLORS.gold, 1); g.strokeCircle(0, 0, r * 0.30);
g.fillStyle(0x2a1d10, 1); g.fillCircle(0, 0, r * 0.12);
// Number labels (children → spin with the wheel).
for (let i = 0; i < POCKETS.length; i++) {
const t = i * STEP;
const lx = Math.sin(t) * r * 0.82, ly = -Math.cos(t) * r * 0.82;
const txt = this.add.text(lx, ly, POCKETS[i] === '00' ? '00' : String(POCKETS[i]), {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setRotation(t);
this.wheel.add(txt);
}
// Decorative top marker (ball deflector).
const marker = this.add.graphics().setDepth(D.marker);
marker.fillStyle(COLORS.gold, 1);
marker.fillTriangle(WHEEL.x - 12, WHEEL.y - r - 26, WHEEL.x + 12, WHEEL.y - r - 26, WHEEL.x, WHEEL.y - r - 6);
// Ball.
this.ball = this.add.graphics().setDepth(D.ball);
this.ball.fillStyle(0xffffff, 1); this.ball.fillCircle(0, 0, 7);
this.ball.lineStyle(2, 0xcccccc, 0.8); this.ball.strokeCircle(0, 0, 7);
this.ball.setPosition(WHEEL.x, WHEEL.y - r * 0.66);
// Result readout under the wheel.
this.resultText = this.add.text(WHEEL.x, WHEEL.y + r + 56, '', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
// Glow for the winning pocket (drawn on resolution).
this.pocketGlow = this.add.graphics().setDepth(D.ball - 1);
}
animateSpin(index) {
return new Promise((resolve) => {
const r = WHEEL.r;
const winT = index * STEP;
const start = this.wheel.rotation;
const end = start + 5 * TWO_PI + Math.random() * TWO_PI;
const DUR = 4200;
this.tweens.add({ targets: this.wheel, rotation: end, duration: DUR, ease: 'Cubic.easeOut' });
const finalAngle = winT + end; // screen angle of the winning pocket at rest
const span = 8 * TWO_PI; // ball orbits the opposite way and decelerates
const rTrack = r * 0.93, rRest = r * 0.66;
const prog = { p: 0 };
this.ball.setVisible(true);
this.tweens.add({
targets: prog, p: 1, duration: DUR, ease: 'Quart.easeOut',
onUpdate: () => {
const p = prog.p;
const a = finalAngle + span * (1 - p);
const t = Phaser.Math.Clamp((p - 0.5) / 0.5, 0, 1);
const radius = rTrack - (rTrack - rRest) * (t * t * (3 - 2 * t));
const hop = p > 0.82 ? Math.sin((p - 0.82) / 0.18 * Math.PI) * -5 : 0;
this.ball.setPosition(WHEEL.x + Math.sin(a) * radius, WHEEL.y - Math.cos(a) * radius + hop);
},
onComplete: () => {
this.ball.setPosition(WHEEL.x + Math.sin(finalAngle) * rRest, WHEEL.y - Math.cos(finalAngle) * rRest);
resolve();
},
});
});
}
// ── Chip tray & buttons ────────────────────────────────────────────────────
buildChipTray() {
const y = GAME_HEIGHT - 78;
const startX = CX - 240;
this.add.text(startX - 70, y, 'Chip', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
CHIP_AMOUNTS.forEach((amt, i) => {
const x = startX + i * 86;
const c = this.add.container(x, y).setDepth(D.ui);
const g = this.add.graphics();
g.fillStyle(CHIP_COLORS[amt], 1); g.fillCircle(0, 0, 30);
g.lineStyle(3, 0xffffff, 0.45); g.strokeCircle(0, 0, 30);
const t = this.add.text(0, 0, `$${amt}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
c.add([g, t]);
c.setInteractive(new Phaser.Geom.Circle(0, 0, 30), Phaser.Geom.Circle.Contains);
c.on('pointerup', () => this.selectChip(amt));
this.chipBtns.push({ amt, container: c });
});
this.chipRing = this.add.graphics().setDepth(D.ui + 1);
this.balanceText = this.add.text(startX + 4 * 86 + 30, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.selectChip(25);
}
selectChip(amt) {
this.selectedChip = amt;
const hit = this.chipBtns.find((c) => c.amt === amt);
this.chipRing.clear();
if (hit) {
this.chipRing.lineStyle(4, COLORS.gold, 1);
this.chipRing.strokeCircle(hit.container.x, hit.container.y, 35);
}
}
buildButtons() {
const y = GAME_HEIGHT - 78;
this.clearBtn = new Button(this, CX + 470, y, 'Clear', () => this.onClear(), {
width: 150, height: 56, fontSize: 20, variant: 'ghost',
});
this.clearBtn.setDepth(D.ui);
this.spinBtn = new Button(this, CX + 680, y, 'Spin', () => this.onSpin(), {
width: 200, height: 64, fontSize: 26,
});
this.spinBtn.setDepth(D.ui);
}
// ── Portraits ──────────────────────────────────────────────────────────────
buildPortraits() {
for (let i = 0; i < this.gs.players.length; i++) {
const x = SEAT_X[i];
const p = this.gs.players[i];
let ctrl;
if (i === 0) ctrl = createPlayerPortrait(this, x, SEAT_Y, PORTRAIT_R, D.portrait, 'RouletteGame');
else ctrl = createOpponentPortrait(this, p.avatar, x, SEAT_Y, PORTRAIT_R, D.portrait);
this.portraits.push(ctrl);
// Color swatch ring matching this player's chip identity.
const ring = this.add.graphics().setDepth(D.portrait + 1);
ring.lineStyle(4, SEAT_COLORS[i % SEAT_COLORS.length], 1);
ring.strokeCircle(x, SEAT_Y, PORTRAIT_R + 5);
this.add.text(x, SEAT_Y + PORTRAIT_R + 16, this.shortName(p.name), {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: hexStr(SEAT_COLORS[i % SEAT_COLORS.length]),
}).setOrigin(0.5).setDepth(D.ui);
}
this.updateBalances();
}
shortName(name) {
return name.length > 10 ? name.slice(0, 9) + '…' : name;
}
// ── Bet rendering (every player's chips, color-coded with names) ───────────--
zoneKeyForBet(bet) {
switch (bet.type) {
case BET.STRAIGHT: return `n${bet.number}`;
case BET.DOZEN: return `doz${bet.number}`;
case BET.COLUMN: return `col${bet.number}`;
default: return bet.type;
}
}
renderBets() {
for (const o of this.betObjs) o.destroy();
this.betObjs = [];
const groups = new Map();
for (let pi = 0; pi < this.gs.players.length; pi++) {
for (const bet of this.gs.players[pi].bets) {
const key = this.zoneKeyForBet(bet);
const z = this.zones[key];
if (!z) continue;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ pi, bet, z });
}
}
for (const entries of groups.values()) {
const z = entries[0].z;
const n = entries.length;
const spacing = Math.min(30, Math.max(20, (z.rect.w - 12) / n));
const nameBelow = z.rect.y < EVEN_TOP - 1; // names below, except bottom rows → above
entries.forEach((e, idx) => {
const x = z.center.x + (idx - (n - 1) / 2) * spacing;
const y = z.center.y - 2;
this.drawBetChip(x, y, e.pi, e.bet.amount, this.shortName(this.gs.players[e.pi].name), nameBelow);
});
}
}
drawBetChip(x, y, playerIndex, amount, name, nameBelow) {
const color = SEAT_COLORS[playerIndex % SEAT_COLORS.length];
const c = this.add.container(x, y).setDepth(D.betChip);
const g = this.add.graphics();
g.fillStyle(color, 1); g.fillCircle(0, 0, 15);
g.lineStyle(3, 0xffffff, 0.6); g.strokeCircle(0, 0, 15);
const amt = this.add.text(0, 0, `${amount}`, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
const label = this.add.text(0, nameBelow ? 24 : -24, name, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: hexStr(color), fontStyle: 'bold',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0.5);
c.add([g, amt, label]);
this.betObjs.push(c);
}
updateBalances() {
const human = this.gs.players[0];
this.balanceText.setText(`Balance $${human.chips.toLocaleString()}`);
}
// ── Human betting ────────────────────────────────────────────────────────--
bettingEnabled() {
return !!this.gs && !this.animating && this.gs.phase !== 'gameover';
}
onZoneClick(key) {
if (!this.bettingEnabled()) return;
const z = this.zones[key];
const amt = this.selectedChip;
if (amt > this.gs.players[0].chips) { this.setStatus('Not enough chips for that bet.'); return; }
const before = this.gs.players[0].chips;
this.gs = placeBet(this.gs, 0, { type: z.type, number: z.number, amount: amt });
if (this.gs.players[0].chips === before) return;
playChipBet(this);
this.renderBets();
this.updateBalances();
this.refreshControls();
}
onClear() {
if (!this.bettingEnabled()) return;
this.gs = refundBets(this.gs, 0);
this.renderBets();
this.updateBalances();
this.refreshControls();
}
refreshControls() {
const canBet = this.bettingEnabled();
this.spinBtn.setEnabled(canBet);
this.clearBtn.setEnabled(canBet && hasActiveBets(this.gs.players[0]));
}
// ── Round flow ─────────────────────────────────────────────────────────────
beginRound() {
this.clearHighlights();
this.gs = clearLastDeltas(this.gs);
this.aiBet();
this.renderBets();
this.updateBalances();
this.setStatus('Place your bets, then Spin.');
this.refreshControls();
}
aiBet() {
for (let i = 1; i < this.gs.players.length; i++) {
for (const spec of chooseBets(this.gs.players[i], this.gs)) {
this.gs = placeBet(this.gs, i, spec);
}
}
}
onSpin() {
if (!this.bettingEnabled()) return;
this.animating = true;
this.refreshControls();
this.hoverG.clear();
this.setStatus('No more bets — spinning…');
const res = spin(this.gs);
this.gs = res.state;
this.animateSpin(res.index).then(() => {
const out = resolveSpin(this.gs, res.index);
this.gs = out.state;
this.handleResolution(out);
});
}
handleResolution(out) {
const { value, color } = out;
this.renderBets(); // bets cleared by resolveSpin → felt clears
for (let i = 0; i < this.gs.players.length; i++) {
const delta = this.gs.players[i].lastDelta;
if (delta > 0) { this.animateChips(i, true, delta); this.portraits[i]?.playEmotion?.('happy'); }
else if (delta < 0) { this.animateChips(i, false, -delta); this.portraits[i]?.playEmotion?.('upset'); }
}
const hd = this.gs.players[0].lastDelta;
if (hd > 0) playSound(this, SFX.CASINO_WIN);
else if (hd < 0) playSound(this, SFX.CASINO_LOSE);
this.showResult(value, color);
this.highlightWinners(value, color, out.index);
const tail = hd > 0 ? `You win $${hd}!` : hd < 0 ? `You lose $${-hd}.` : 'No win this time.';
this.setStatus(`${this.displayNum(value)} ${color.toUpperCase()}${tail}`);
this.updateBalances();
this.persistChips();
this.time.delayedCall(2000, () => {
this.animating = false;
if (this.checkGameOver()) return;
this.beginRound();
});
}
displayNum(value) { return value === '00' ? '00' : String(value); }
showResult(value, color) {
const c = color === 'red' ? '#c0392b' : color === 'green' ? '#39c06a' : '#f2ead8';
this.resultText.setColor(c).setText(`${this.displayNum(value)} ${color.toUpperCase()}`);
}
// ── Highlights ─────────────────────────────────────────────────────────────
highlightWinners(value, color, index) {
this.clearHighlights();
// Felt cells/boxes that win this spin.
for (const z of Object.values(this.zones)) {
if (!betWins({ type: z.type, number: z.number }, value, color)) continue;
const ring = this.add.graphics().setDepth(D.hl);
ring.lineStyle(4, COLORS.gold, 1);
ring.strokeRoundedRect(z.rect.x + 2, z.rect.y + 2, z.rect.w - 4, z.rect.h - 4, 6);
this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.3 }, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.highlightObjs.push(ring);
}
// Glow on the winning pocket.
const a = index * STEP + this.wheel.rotation;
const gx = WHEEL.x + Math.sin(a) * WHEEL.r * 0.66;
const gy = WHEEL.y - Math.cos(a) * WHEEL.r * 0.66;
this.pocketGlow.clear();
this.pocketGlow.lineStyle(3, COLORS.gold, 1);
this.pocketGlow.strokeCircle(gx, gy, 14);
this.pocketGlow.setAlpha(1);
this.tweens.add({ targets: this.pocketGlow, alpha: { from: 1, to: 0.25 }, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
clearHighlights() {
for (const o of this.highlightObjs) {
this.tweens.killTweensOf(o);
o.destroy();
}
this.highlightObjs = [];
if (this.pocketGlow) { this.tweens.killTweensOf(this.pocketGlow); this.pocketGlow.clear(); }
}
// ── Win/loss chip animation ────────────────────────────────────────────────
animateChips(playerIndex, toPlayer, amount) {
const seatX = SEAT_X[playerIndex];
const count = Math.min(8, Math.max(2, Math.ceil(amount / 50)));
const color = SEAT_COLORS[playerIndex % SEAT_COLORS.length];
for (let i = 0; i < count; i++) {
const chip = this.add.graphics().setDepth(D.fx);
chip.fillStyle(toPlayer ? color : 0x2c2c2c, 1); chip.fillCircle(0, 0, 12);
chip.lineStyle(2, 0xffffff, 0.45); chip.strokeCircle(0, 0, 12);
const fromX = toPlayer ? POT.x : seatX;
const fromY = toPlayer ? POT.y : SEAT_Y;
const toX = toPlayer ? seatX : POT.x;
const toY = toPlayer ? SEAT_Y : POT.y;
chip.setPosition(fromX + (Math.random() * 40 - 20), fromY + (Math.random() * 20 - 10));
this.tweens.add({
targets: chip, x: toX + (Math.random() * 30 - 15), y: toY + (Math.random() * 20 - 10),
duration: 440 + i * 45, ease: 'Quad.InOut', onComplete: () => chip.destroy(),
});
}
this.floatText(seatX, SEAT_Y - PORTRAIT_R - 40, toPlayer ? `+$${amount}` : `-$${amount}`, toPlayer ? '#5cb85c' : '#e05c5c');
}
floatText(x, y, label, color) {
const t = this.add.text(x, y, label, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color, fontStyle: 'bold',
stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5).setDepth(D.fx + 1);
this.tweens.add({ targets: t, y: y - 36, alpha: 0, duration: 1200, ease: 'Quad.Out', onComplete: () => t.destroy() });
}
setStatus(msg) { this.statusText.setText(msg); }
// ── Persistence & game over ─────────────────────────────────────────────────
async loadChips() {
try {
const { profile } = await api.get('/profile');
this.startingChips = profile?.chips ?? 2000;
} catch { this.startingChips = 2000; }
}
async persistChips() {
const delta = this.gs.players[0].lastDelta;
if (!delta) return;
try { await api.post('/profile/chips/adjust', { delta }); } catch { /* resync on next load */ }
}
checkGameOver() {
const human = this.gs.players[0];
if (human.chips > 0 || totalAtRisk(human) > 0) return false;
this.gs.phase = 'gameover';
this.refreshControls();
this.postHistory();
new Modal(this, 'Out of chips! Visit your profile to request a reset.', { autoCloseMs: 4200 });
this.time.delayedCall(4400, () => this.scene.start('GameMenu'));
return true;
}
async postHistory() {
const human = this.gs.players[0];
const result = getNetResult(human, this.startingChips);
try {
await api.post('/history/single-player', {
slug: 'roulette',
score: human.chips,
opponentScores: this.gs.players.slice(1).map((p) => p.chips),
result,
});
} catch { /* ignore */ }
}
leave() {
if (this.gs && this.gs.phase !== 'gameover') {
this.gs = refundBets(this.gs, 0);
this.postHistory();
}
this.scene.start('GameMenu');
}
}

View File

@ -0,0 +1,217 @@
// Pure American-roulette rules. No Phaser dependency.
//
// Scope: a double-zero wheel (0, 00, 136) with straight-up bets (35:1) and
// outside bets — Red/Black, Odd/Even, 1-18/19-36 (1:1), Dozens and Columns (2:1).
//
// A "round" is one spin: players place bets, the wheel is spun, every bet is
// resolved against the winning pocket and cleared. Players re-bet each round.
//
// State is treated as immutable: every mutating helper returns a fresh object.
// Wheel pocket order, clockwise from 0 (standard American double-zero wheel).
// '00' is stored as a string so it stays distinct from 0.
export const POCKETS = [
0, 28, 9, 26, 30, 11, 7, 20, 32, 17, 5, 22, 34, 15, 3, 24, 36, 13, 1, '00',
27, 10, 25, 29, 12, 8, 19, 31, 18, 6, 21, 33, 16, 4, 23, 35, 14, 2,
];
// Red numbers; every other 136 is black; 0 and 00 are green.
export const RED = new Set([1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]);
// Felt number grid, top row to bottom row (each column of three = {3k,3k-1,3k-2}).
// The "2 to 1" box at the right of each row bets on that row (a column bet).
export const GRID_ROWS = [
[3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36], // column 3 (n % 3 === 0)
[2, 5, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35], // column 2 (n % 3 === 2)
[1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34], // column 1 (n % 3 === 1)
];
// Chip denominations offered in the UI (shared with the chip-color map in the scene).
export const CHIP_AMOUNTS = [5, 25, 50, 100];
// Bet types and the payout (winnings as a multiple of the stake).
export const BET = {
STRAIGHT: 'straight', // a single pocket, incl. 0 / 00
RED: 'red',
BLACK: 'black',
ODD: 'odd',
EVEN: 'even',
LOW: 'low', // 118
HIGH: 'high', // 1936
DOZEN: 'dozen', // number = 1 | 2 | 3
COLUMN: 'column', // number = 1 | 2 | 3 (felt rows bottom→top)
};
export const PAYOUT = {
[BET.STRAIGHT]: 35,
[BET.DOZEN]: 2,
[BET.COLUMN]: 2,
[BET.RED]: 1, [BET.BLACK]: 1, [BET.ODD]: 1, [BET.EVEN]: 1, [BET.LOW]: 1, [BET.HIGH]: 1,
};
export function colorOf(value) {
if (value === 0 || value === '00') return 'green';
return RED.has(value) ? 'red' : 'black';
}
function columnOf(n) {
if (typeof n !== 'number' || n < 1) return 0;
const r = n % 3;
return r === 0 ? 3 : r; // {3,6,…}=3, {2,5,…}=2, {1,4,…}=1
}
function dozenOf(n) {
if (typeof n !== 'number' || n < 1) return 0;
return Math.ceil(n / 12); // 112=1, 1324=2, 2536=3
}
// ─── Setup ───────────────────────────────────────────────────────────────────
export function createInitialState(opponents = [], humanChips = 2000, humanName = 'You') {
const players = [
{ name: humanName, isAI: false, avatar: null, chips: humanChips, bets: [], lastDelta: 0 },
...opponents.map((o) => ({
name: o.name ?? o.id ?? 'Bot',
isAI: true,
avatar: o,
chips: 2000,
bets: [],
lastDelta: 0,
})),
];
return {
players,
phase: 'betting', // betting → spinning → betting (or gameover)
lastSpin: null, // { index, value } of the most recent spin
_nextBetId: 1,
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
// ─── Bet placement ─────────────────────────────────────────────────────────--
// Stable identity for a bet so repeat clicks stack instead of duplicating.
function betKey(type, number) {
return number == null ? type : `${type}:${number}`;
}
// Place a flat bet. Stake is removed from the bankroll immediately.
export function placeBet(state, playerIndex, { type, number = null, amount }) {
const player = state.players[playerIndex];
if (amount <= 0 || amount > player.chips) return state;
if (!Object.values(BET).includes(type)) return state;
const s = cloneState(state);
const p = s.players[playerIndex];
const key = betKey(type, number);
const existing = p.bets.find((b) => betKey(b.type, b.number) === key);
if (existing) {
existing.amount += amount;
} else {
p.bets.push({ id: s._nextBetId++, type, number: number ?? null, amount });
}
p.chips -= amount;
return s;
}
// Return staked chips to the bankroll (only sensible during a betting window).
export function refundBets(state, playerIndex, predicate = () => true) {
const s = cloneState(state);
const p = s.players[playerIndex];
const kept = [];
for (const b of p.bets) {
if (predicate(b)) p.chips += b.amount;
else kept.push(b);
}
p.bets = kept;
return s;
}
// ─── Spin & resolution ───────────────────────────────────────────────────────
export function spin(state) {
const s = cloneState(state);
const index = Math.floor(Math.random() * POCKETS.length);
s.lastSpin = { index, value: POCKETS[index] };
return { state: s, index, value: POCKETS[index] };
}
// Force a specific pocket (testing / scripted demos).
export function setSpin(state, index) {
const s = cloneState(state);
s.lastSpin = { index, value: POCKETS[index] };
return { state: s, index, value: POCKETS[index] };
}
export function betWins(bet, value, color) {
if (color === 'green') return bet.type === BET.STRAIGHT && bet.number === value;
switch (bet.type) {
case BET.STRAIGHT: return bet.number === value;
case BET.RED: return color === 'red';
case BET.BLACK: return color === 'black';
case BET.ODD: return value % 2 === 1;
case BET.EVEN: return value % 2 === 0;
case BET.LOW: return value >= 1 && value <= 18;
case BET.HIGH: return value >= 19 && value <= 36;
case BET.DOZEN: return dozenOf(value) === bet.number;
case BET.COLUMN: return columnOf(value) === bet.number;
default: return false;
}
}
// Resolve every bet against pocket `index`. Clears all bets afterward.
// Returns { state, index, value, color, payouts }.
// payouts entry: { playerIndex, type, number, result:'win'|'lose', amount, delta }.
export function resolveSpin(state, index) {
const s = cloneState(state);
const value = POCKETS[index];
const color = colorOf(value);
const payouts = [];
for (let pi = 0; pi < s.players.length; pi++) {
const p = s.players[pi];
for (const bet of p.bets) {
const win = betWins(bet, value, color);
const stake = bet.amount;
let delta;
if (win) {
const credited = stake + stake * PAYOUT[bet.type];
p.chips += credited;
delta = credited - stake; // = winnings
} else {
delta = -stake; // stake was already removed at placement
}
p.lastDelta += delta;
payouts.push({ playerIndex: pi, type: bet.type, number: bet.number, result: win ? 'win' : 'lose', amount: stake, delta });
}
p.bets = [];
}
s.lastSpin = { index, value };
return { state: s, index, value, color, payouts };
}
// ─── Round / table helpers ─────────────────────────────────────────────────--
export function clearLastDeltas(state) {
const s = cloneState(state);
for (const p of s.players) p.lastDelta = 0;
return s;
}
export function totalAtRisk(player) {
return player.bets.reduce((sum, b) => sum + b.amount, 0);
}
export function hasActiveBets(player) {
return player.bets.length > 0;
}
// Human net result vs. the chips they walked in with, for match history.
export function getNetResult(player, startingChips) {
if (player.chips > startingChips) return 'win';
if (player.chips < startingChips) return 'loss';
return 'draw';
}

View File

@ -21,6 +21,7 @@ import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js'
import GoFishGame from './games/gofish/GoFishGame.js';
import UnoGame from './games/uno/UnoGame.js';
import CrapsGame from './games/craps/CrapsGame.js';
import RouletteGame from './games/roulette/RouletteGame.js';
const config = {
type: Phaser.AUTO,
@ -55,6 +56,7 @@ const config = {
GoFishGame,
UnoGame,
CrapsGame,
RouletteGame,
],
};

View File

@ -15,7 +15,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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -34,3 +34,4 @@ registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tab
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });