Compare commits
3 Commits
8a801b0f05
...
f8d406ab4e
| Author | SHA1 | Date |
|---|---|---|
|
|
f8d406ab4e | |
|
|
327d3d7019 | |
|
|
73f50e2106 |
|
|
@ -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;
|
||||
|
|
@ -253,7 +259,7 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
const ox = SEAT_X[this.gs.shooterIndex];
|
||||
const oy = SEAT_Y - 60;
|
||||
return Promise.all(this.diceGfx.map((g, i) =>
|
||||
this.throwOneDie(g, finalDice[i], ox + (i ? 34 : -34), oy, i)));
|
||||
this.throwOneDie(g, finalDice[i], ox + (i ? 44 : -44), oy, i)));
|
||||
}
|
||||
|
||||
throwOneDie(g, face, ox, oy, idx) {
|
||||
|
|
@ -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 ? 50 : -50) + (Math.random() * 14 - 7);
|
||||
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,10 +457,133 @@ 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(animate = false) {
|
||||
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 finalX = base.x + dx;
|
||||
const finalY = base.y;
|
||||
const label = `$${e.bet.amount}`;
|
||||
const shortN = this.gs.players[e.pi]
|
||||
? (this.gs.players[e.pi].name.length > 6 ? this.gs.players[e.pi].name.slice(0, 5) + '…' : this.gs.players[e.pi].name)
|
||||
: '';
|
||||
const chip = this.drawAiChip(finalX, finalY, e.pi, label, shortN);
|
||||
if (animate) this.animateChipFromSeat(chip, e.pi, finalX, finalY, idx);
|
||||
this.aiBetObjs.push(chip);
|
||||
});
|
||||
} else {
|
||||
const z = this.zones[key];
|
||||
if (!z) continue;
|
||||
entries.forEach((e, idx) => {
|
||||
const { x: finalX, y: finalY } = this.chipSidePos(z, key, idx, n, e.pi);
|
||||
const label = `$${e.bet.amount}`;
|
||||
const shortN = this.gs.players[e.pi]
|
||||
? (this.gs.players[e.pi].name.length > 6 ? this.gs.players[e.pi].name.slice(0, 5) + '…' : this.gs.players[e.pi].name)
|
||||
: '';
|
||||
const chip = this.drawAiChip(finalX, finalY, e.pi, label, shortN);
|
||||
if (animate) this.animateChipFromSeat(chip, e.pi, finalX, finalY, idx);
|
||||
this.aiBetObjs.push(chip);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animateChipFromSeat(chip, pi, finalX, finalY, betIdx) {
|
||||
const delay = (pi - 1) * 90 + betIdx * 35;
|
||||
chip.setPosition(SEAT_X[pi] ?? CX, SEAT_Y).setAlpha(0).setScale(0.4);
|
||||
if (betIdx === 0) this.time.delayedCall(delay, () => playChipBet(this));
|
||||
this.tweens.add({
|
||||
targets: chip, x: finalX, y: finalY,
|
||||
alpha: 1, scaleX: 1, scaleY: 1,
|
||||
duration: 400, ease: 'Quad.Out', delay,
|
||||
});
|
||||
}
|
||||
|
||||
updateBalances() {
|
||||
const human = this.gs.players[0];
|
||||
this.balanceText.setText(`Balance $${human.chips.toLocaleString()}`);
|
||||
|
|
@ -535,6 +671,7 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
this.renderAiWagers();
|
||||
this.renderAiBets(true);
|
||||
}
|
||||
|
||||
onShoot() {
|
||||
|
|
@ -559,22 +696,22 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
if (res.pointEstablished) this.movePuck(res.pointEstablished);
|
||||
else if (res.newComeOut) this.movePuck(null);
|
||||
|
||||
// Animate per-player net outcome.
|
||||
// Chip animations fire immediately for all players.
|
||||
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'); }
|
||||
if (delta > 0) this.animateChips(i, true, delta);
|
||||
else if (delta < 0) this.animateChips(i, false, -delta);
|
||||
}
|
||||
const humanDelta = this.gs.players[0].lastDelta;
|
||||
if (humanDelta > 0) playSound(this, SFX.CASINO_WIN);
|
||||
else if (humanDelta < 0) playSound(this, SFX.CASINO_LOSE);
|
||||
|
||||
this.setStatus(this.outcomeMessage(res, total));
|
||||
this.renderHumanBets();
|
||||
this.renderAiWagers();
|
||||
this.renderAiBets();
|
||||
this.updateBalances();
|
||||
this.persistChips();
|
||||
|
||||
// Win/lose badges + emotions play sequentially left to right.
|
||||
this.showRollResults();
|
||||
|
||||
this.time.delayedCall(1400, () => {
|
||||
this.animating = false;
|
||||
this.diceGfx.forEach((g) => g.setVisible(true)); // leave dice resting on the felt
|
||||
|
|
@ -614,6 +751,79 @@ export default class CrapsGame extends Phaser.Scene {
|
|||
return `Rolled ${total}.`;
|
||||
}
|
||||
|
||||
// ── Win / lose result badges (sequential left → right) ────────────────────
|
||||
showRollResults() {
|
||||
const entries = this.gs.players
|
||||
.map((p, i) => ({ i, delta: p.lastDelta }))
|
||||
.filter(e => e.delta !== 0)
|
||||
.sort((a, b) => SEAT_X[a.i] - SEAT_X[b.i]);
|
||||
|
||||
entries.forEach(({ i, delta }, idx) => {
|
||||
this.time.delayedCall(idx * 420, () => {
|
||||
this.portraits[i]?.playEmotion?.(delta > 0 ? 'happy' : 'upset');
|
||||
this.animatePlayerResult(i, delta);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
animatePlayerResult(pi, delta) {
|
||||
const isWin = delta > 0;
|
||||
const x = SEAT_X[pi];
|
||||
const textY = SEAT_Y - PORTRAIT_R - 58;
|
||||
|
||||
const badge = this.add.text(x, textY, isWin ? 'Win!' : 'Lose', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: isWin ? '52px' : '44px',
|
||||
color: isWin ? '#f5d020' : '#e05c5c',
|
||||
fontStyle: 'bold',
|
||||
stroke: '#000000', strokeThickness: 5,
|
||||
shadow: isWin ? { offsetX: 0, offsetY: 0, color: '#f5d020', blur: 24, fill: true } : undefined,
|
||||
}).setOrigin(0.5).setAlpha(0).setScale(1.4).setDepth(D.modal);
|
||||
|
||||
this.tweens.add({ targets: badge, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.Out' });
|
||||
|
||||
playSound(this, isWin ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
|
||||
if (isWin) this.animateFireworks(x, textY);
|
||||
|
||||
this.time.delayedCall(isWin ? 1200 : 900, () => {
|
||||
this.tweens.add({
|
||||
targets: badge, alpha: 0, y: textY - 30,
|
||||
duration: 350, ease: 'Power2',
|
||||
onComplete: () => badge.destroy(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
animateFireworks(cx, cy) {
|
||||
const palette = [0xf5d020, 0xff8c00, 0x5cb85c, 0x4a90d9, 0xff69b4, 0xffffff, 0x00e5ff];
|
||||
for (let burst = 0; burst < 3; burst++) {
|
||||
this.time.delayedCall(burst * 380, () => {
|
||||
if (!this.scene.isActive('CrapsGame')) return;
|
||||
const bx = cx + (Math.random() - 0.5) * 140;
|
||||
const by = cy - 10 + (Math.random() - 0.5) * 80;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const angle = (i / 10) * Math.PI * 2 + Math.random() * 0.3;
|
||||
const dist = 65 + Math.random() * 65;
|
||||
const color = palette[Math.floor(Math.random() * palette.length)];
|
||||
const dot = this.add.graphics().setDepth(D.modal + 5);
|
||||
dot.fillStyle(color, 1);
|
||||
dot.fillCircle(0, 0, 4 + Math.random() * 3);
|
||||
dot.x = bx; dot.y = by;
|
||||
this.tweens.add({
|
||||
targets: dot,
|
||||
x: bx + Math.cos(angle) * dist,
|
||||
y: by + Math.sin(angle) * dist,
|
||||
alpha: 0, scaleX: 0.1, scaleY: 0.1,
|
||||
duration: 700 + Math.random() * 400,
|
||||
delay: Math.random() * 100,
|
||||
ease: 'Power2',
|
||||
onComplete: () => dot.destroy(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chip win/loss animation (aggregated per player) ─────────────────────────
|
||||
animateChips(playerIndex, toPlayer, amount) {
|
||||
const seatX = SEAT_X[playerIndex];
|
||||
|
|
|
|||
|
|
@ -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 (1–36) 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
// Pure American-roulette rules. No Phaser dependency.
|
||||
//
|
||||
// Scope: a double-zero wheel (0, 00, 1–36) 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 1–36 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', // 1–18
|
||||
HIGH: 'high', // 19–36
|
||||
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); // 1–12=1, 13–24=2, 25–36=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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue