fertig-classic-games/public/src/games/roulette/RouletteGame.js

645 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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