645 lines
26 KiB
JavaScript
645 lines
26 KiB
JavaScript
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');
|
||
}
|
||
}
|