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