feat: add single-player Craps game with AI opponents
Introduce a complete Craps implementation featuring a Phaser-based UI, deterministic game logic, and heuristic AI players. Key changes: - CrapsLogic.js: Pure-state rules engine handling Pass/Don't Pass, Place, Come/Don't Come, Field bets, odds, and full resolution logic. - CrapsAI.js: Stateless AI personalities (conservative, balanced, aggressive, gambler) derived from player names to drive betting behavior. - CrapsGame.js: Phaser scene with animated dice rolls, chip tray, dynamic bet zones, portrait-based opponents, and persistence via profile API. - Wire Craps into the game registry, main scene loader, and GameRoom dispatcher.
This commit is contained in:
parent
cc6544f44e
commit
2ee763fb7a
|
|
@ -0,0 +1,92 @@
|
||||||
|
// Heuristic Craps AI. Stateless helpers consumed by CrapsGame.
|
||||||
|
//
|
||||||
|
// Each opponent gets a stable "personality" derived from their name, which
|
||||||
|
// shapes what they bet and how big. Functions return plain bet specs / odds
|
||||||
|
// requests; the scene applies them through CrapsLogic (which guards affordability).
|
||||||
|
|
||||||
|
import { BET, POINT_NUMBERS, legalBetTypes, oddsEligibleBets, maxOddsFor } from './CrapsLogic.js';
|
||||||
|
|
||||||
|
const PROFILES = {
|
||||||
|
// unitPct: flat-bet size as a fraction of bankroll
|
||||||
|
// oddsMult: odds as a multiple of the flat bet
|
||||||
|
// fieldChance: probability of tossing a field bet on a come-out
|
||||||
|
// place: which numbers they like to place during the point phase
|
||||||
|
// dontChance: probability of betting the Don't side instead of Pass
|
||||||
|
conservative: { unitPct: 0.03, oddsMult: 1, fieldChance: 0.10, place: [], dontChance: 0.18, comeChance: 0.10 },
|
||||||
|
balanced: { unitPct: 0.05, oddsMult: 2, fieldChance: 0.25, place: [6, 8], dontChance: 0.10, comeChance: 0.25 },
|
||||||
|
aggressive: { unitPct: 0.08, oddsMult: 3, fieldChance: 0.55, place: [6, 8, 5, 9], dontChance: 0.05, comeChance: 0.45 },
|
||||||
|
gambler: { unitPct: 0.11, oddsMult: 3, fieldChance: 0.80, place: [6, 8, 4, 10],dontChance: 0.00, comeChance: 0.60 },
|
||||||
|
};
|
||||||
|
const PROFILE_KEYS = Object.keys(PROFILES);
|
||||||
|
|
||||||
|
function profileFor(player) {
|
||||||
|
const name = player.name ?? player.avatar?.id ?? 'bot';
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||||
|
return PROFILES[PROFILE_KEYS[h % 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place-bet sizing must match the payout increment (6/8 → ×6, others → ×5).
|
||||||
|
function placeSize(player, prof, number) {
|
||||||
|
const step = number === 6 || number === 8 ? 6 : 5;
|
||||||
|
return Math.min(120, snap(player.chips * prof.unitPct, step));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat bets to open for the current phase. Returns an array of bet specs.
|
||||||
|
export function chooseBets(player, state) {
|
||||||
|
const prof = profileFor(player);
|
||||||
|
const legal = legalBetTypes(state);
|
||||||
|
const specs = [];
|
||||||
|
let budget = player.chips;
|
||||||
|
const afford = (amt) => amt > 0 && amt <= budget;
|
||||||
|
|
||||||
|
if (state.point === null) {
|
||||||
|
// Come-out: take a line bet, maybe a field flyer.
|
||||||
|
const unit = unitFor(player, prof);
|
||||||
|
const hasLine = player.bets.some((b) => b.type === BET.PASS || b.type === BET.DONT_PASS);
|
||||||
|
if (!hasLine && afford(unit)) {
|
||||||
|
const dont = Math.random() < prof.dontChance && legal.has(BET.DONT_PASS);
|
||||||
|
specs.push({ type: dont ? BET.DONT_PASS : BET.PASS, amount: unit });
|
||||||
|
budget -= unit;
|
||||||
|
}
|
||||||
|
if (Math.random() < prof.fieldChance) {
|
||||||
|
const f = snap(unit / 2, 5);
|
||||||
|
if (afford(f)) { specs.push({ type: BET.FIELD, amount: f }); budget -= f; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Point phase: maybe a come bet, plus favourite place numbers.
|
||||||
|
if (Math.random() < prof.comeChance) {
|
||||||
|
const unit = unitFor(player, prof);
|
||||||
|
if (afford(unit)) { specs.push({ type: BET.COME, amount: unit }); budget -= unit; }
|
||||||
|
}
|
||||||
|
const placed = new Set(player.bets.filter((b) => b.type === BET.PLACE).map((b) => b.number));
|
||||||
|
for (const num of prof.place) {
|
||||||
|
if (placed.has(num) || num === state.point) continue;
|
||||||
|
if (!POINT_NUMBERS.includes(num)) continue;
|
||||||
|
const amt = placeSize(player, prof, num);
|
||||||
|
if (afford(amt)) { specs.push({ type: BET.PLACE, number: num, amount: amt }); budget -= amt; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odds to lay behind eligible line/come bets. Returns [{ betId, amount }].
|
||||||
|
export function chooseOdds(player, state) {
|
||||||
|
const prof = profileFor(player);
|
||||||
|
if (prof.oddsMult <= 0) return [];
|
||||||
|
const out = [];
|
||||||
|
let budget = player.chips;
|
||||||
|
for (const bet of oddsEligibleBets(player, state)) {
|
||||||
|
if (bet.oddsAmount > 0) continue;
|
||||||
|
const want = Math.min(bet.amount * prof.oddsMult, maxOddsFor(bet));
|
||||||
|
const amt = snap(want, 5);
|
||||||
|
if (amt > 0 && amt <= budget) { out.push({ betId: bet.id, amount: amt }); budget -= amt; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,698 @@
|
||||||
|
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 {
|
||||||
|
BET, POINT_NUMBERS, CHIP_AMOUNTS,
|
||||||
|
createInitialState, placeBet, addOdds, refundBets, rollDice, resolveRoll,
|
||||||
|
clearLastDeltas, legalBetTypes, oddsEligibleBets, totalAtRisk, getNetResult,
|
||||||
|
} from './CrapsLogic.js';
|
||||||
|
import { chooseBets, chooseOdds } from './CrapsAI.js';
|
||||||
|
|
||||||
|
// ─── Layout ────────────────────────────────────────────────────────────────--
|
||||||
|
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 NUM_TO_BOX = { 4: 0, 5: 1, 6: 2, 8: 3, 9: 4, 10: 5 };
|
||||||
|
const BOX_NUMS = [4, 5, 6, 8, 9, 10];
|
||||||
|
|
||||||
|
// Seat slots (x) 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;
|
||||||
|
|
||||||
|
const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3×3 pip layout per die face.
|
||||||
|
const PIP_POS = {
|
||||||
|
1: [[0, 0]],
|
||||||
|
2: [[-1, -1], [1, 1]],
|
||||||
|
3: [[-1, -1], [0, 0], [1, 1]],
|
||||||
|
4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
|
||||||
|
5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
|
||||||
|
6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class CrapsGame extends Phaser.Scene {
|
||||||
|
constructor() { super('CrapsGame'); }
|
||||||
|
|
||||||
|
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 = {}; // key → { rect:{x,y,w,h}, center:{x,y}, type, number, label }
|
||||||
|
this.zoneGfx = null;
|
||||||
|
this.humanChipObjs = [];
|
||||||
|
this.aiWagerObjs = {}; // seat → { container }
|
||||||
|
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.defineZones();
|
||||||
|
this.buildTable();
|
||||||
|
this.buildPuck();
|
||||||
|
this.buildDice();
|
||||||
|
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.updateShooterIndicator();
|
||||||
|
this.beginComeOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background ─────────────────────────────────────────────────────────────
|
||||||
|
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.bg + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTitle() {
|
||||||
|
this.add.text(CX, 52, 'Craps', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '50px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
|
||||||
|
this.statusText = this.add.text(CX, 96, '', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bet zones ──────────────────────────────────────────────────────────────
|
||||||
|
defineZones() {
|
||||||
|
const add = (key, type, number, label, x, y, w, h, sub) => {
|
||||||
|
this.zones[key] = { type, number, label, sub, rect: { x, y, w, h }, center: { x: x + w / 2, y: y + h / 2 } };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top: point/place number boxes
|
||||||
|
const boxW = 150, boxH = 90, gap = 14;
|
||||||
|
const totalW = BOX_NUMS.length * boxW + (BOX_NUMS.length - 1) * gap;
|
||||||
|
const startX = CX - totalW / 2;
|
||||||
|
const boxY = 150;
|
||||||
|
BOX_NUMS.forEach((n, i) => {
|
||||||
|
add(`box${n}`, BET.PLACE, n, String(n), startX + i * (boxW + gap), boxY, boxW, boxH);
|
||||||
|
});
|
||||||
|
this._boxY = boxY; this._boxH = boxH;
|
||||||
|
|
||||||
|
// Don't Come (left of the boxes)
|
||||||
|
add('dontcome', BET.DONT_COME, null, "DON'T\nCOME", TABLE.x + 24, boxY, startX - TABLE.x - 44, boxH * 2 + 14);
|
||||||
|
|
||||||
|
// Come
|
||||||
|
add('come', BET.COME, null, 'COME', startX, 268, totalW, 88);
|
||||||
|
|
||||||
|
// Field
|
||||||
|
add('field', BET.FIELD, null, 'FIELD 2 3 4 9 10 11 12', TABLE.x + 60, 372, TABLE.w - 120, 86,
|
||||||
|
'2 pays double · 12 pays triple');
|
||||||
|
|
||||||
|
// Don't Pass bar
|
||||||
|
add('dontpass', BET.DONT_PASS, null, "DON'T PASS BAR", TABLE.x + 140, 476, TABLE.w - 280, 60);
|
||||||
|
|
||||||
|
// Pass line
|
||||||
|
add('pass', BET.PASS, null, 'PASS LINE', TABLE.x + 100, 552, TABLE.w - 200, 76);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTable() {
|
||||||
|
const g = this.add.graphics().setDepth(D.felt);
|
||||||
|
g.fillStyle(0x14532d, 0.92);
|
||||||
|
g.fillRoundedRect(TABLE.x, TABLE.y, TABLE.w, TABLE.h, 26);
|
||||||
|
g.lineStyle(8, COLORS.accent, 0.9);
|
||||||
|
g.strokeRoundedRect(TABLE.x, TABLE.y, TABLE.w, TABLE.h, 26);
|
||||||
|
g.lineStyle(2, 0x3c8a52, 0.6);
|
||||||
|
g.strokeRoundedRect(TABLE.x + 12, TABLE.y + 12, TABLE.w - 24, TABLE.h - 24, 20);
|
||||||
|
|
||||||
|
this.zoneGfx = this.add.graphics().setDepth(D.zone);
|
||||||
|
this.drawZones();
|
||||||
|
|
||||||
|
for (const [key, z] of Object.entries(this.zones)) {
|
||||||
|
const { w, h } = z.rect;
|
||||||
|
const zone = this.add.zone(z.center.x, z.center.y, w, h).setDepth(D.zone + 1);
|
||||||
|
zone.setInteractive({ useHandCursor: true });
|
||||||
|
zone.on('pointerover', () => this.hoverZone(key, true));
|
||||||
|
zone.on('pointerout', () => this.hoverZone(key, false));
|
||||||
|
zone.on('pointerup', () => this.onZoneClick(key));
|
||||||
|
|
||||||
|
const big = z.number != null;
|
||||||
|
this.add.text(z.center.x, z.center.y, z.label, {
|
||||||
|
fontFamily: big ? 'Righteous' : '"Julius Sans One"',
|
||||||
|
fontSize: big ? '40px' : '22px',
|
||||||
|
color: COLORS.textHex, align: 'center',
|
||||||
|
}).setOrigin(0.5).setDepth(D.zoneLabel);
|
||||||
|
if (z.sub) {
|
||||||
|
this.add.text(z.center.x, z.rect.y + z.rect.h - 16, z.sub, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.zoneLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawZones(hoverKey = null) {
|
||||||
|
const g = this.zoneGfx;
|
||||||
|
g.clear();
|
||||||
|
const legal = this.gs ? legalBetTypes(this.gs) : new Set();
|
||||||
|
for (const [key, z] of Object.entries(this.zones)) {
|
||||||
|
const { x, y, w, h } = z.rect;
|
||||||
|
const isLegal = !this.gs || legal.has(z.type);
|
||||||
|
g.fillStyle(0x0c2e19, isLegal ? 0.5 : 0.18);
|
||||||
|
g.fillRoundedRect(x, y, w, h, 10);
|
||||||
|
g.lineStyle(key === hoverKey && isLegal ? 4 : 2, key === hoverKey && isLegal ? COLORS.gold : 0x3c8a52, isLegal ? 0.9 : 0.4);
|
||||||
|
g.strokeRoundedRect(x, y, w, h, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverZone(key, on) {
|
||||||
|
if (this.animating) { this.drawZones(null); return; }
|
||||||
|
this.drawZones(on ? key : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Puck (ON / OFF) ──────────────────────────────────────────────────────--
|
||||||
|
buildPuck() {
|
||||||
|
this.puckOff = { x: TABLE.x + 70, y: 470 };
|
||||||
|
this.puck = this.add.container(this.puckOff.x, this.puckOff.y).setDepth(D.puck);
|
||||||
|
const g = this.add.graphics();
|
||||||
|
this.puck.add(g);
|
||||||
|
this.puckGfx = g;
|
||||||
|
this.puckText = this.add.text(0, 0, 'OFF', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.puck.add(this.puckText);
|
||||||
|
this.drawPuck(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPuck(on) {
|
||||||
|
const g = this.puckGfx;
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(on ? 0xffffff : 0x1a1208, 1);
|
||||||
|
g.fillCircle(0, 0, 26);
|
||||||
|
g.lineStyle(4, on ? 0x2e7d32 : 0x000000, 1);
|
||||||
|
g.strokeCircle(0, 0, 26);
|
||||||
|
this.puckText.setText(on ? 'ON' : 'OFF');
|
||||||
|
this.puckText.setColor(on ? '#2e7d32' : '#f2ead8');
|
||||||
|
}
|
||||||
|
|
||||||
|
movePuck(point) {
|
||||||
|
if (point == null) {
|
||||||
|
this.drawPuck(false);
|
||||||
|
this.tweens.add({ targets: this.puck, x: this.puckOff.x, y: this.puckOff.y, duration: 300, ease: 'Quad.Out' });
|
||||||
|
} else {
|
||||||
|
const i = NUM_TO_BOX[point];
|
||||||
|
const z = this.zones[`box${point}`];
|
||||||
|
this.drawPuck(true);
|
||||||
|
this.tweens.add({ targets: this.puck, x: z.center.x, y: this.zones[`box${point}`].rect.y - 14, duration: 360, ease: 'Back.Out' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dice ───────────────────────────────────────────────────────────────────
|
||||||
|
buildDice() {
|
||||||
|
this.diceGfx = [this.add.graphics(), this.add.graphics()];
|
||||||
|
this.diceGfx.forEach((g) => { g.setDepth(D.dice); g.setVisible(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDieFace(g, face) {
|
||||||
|
const s = DIE_SIZE, h = s / 2, r = s * 0.18;
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(0xf2ead8, 1);
|
||||||
|
g.fillRoundedRect(-h, -h, s, s, r);
|
||||||
|
g.lineStyle(3, 0x1a1208, 0.3);
|
||||||
|
g.strokeRoundedRect(-h, -h, s, s, r);
|
||||||
|
g.fillStyle(0x1a1208, 1);
|
||||||
|
const off = s * 0.28, pr = s * 0.085;
|
||||||
|
for (const [px, py] of PIP_POS[face]) g.fillCircle(px * off, py * off, pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
animateThrow(finalDice) {
|
||||||
|
playSound(this, SFX.DICE_ROLL);
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
throwOneDie(g, face, ox, oy, idx) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rnd6 = () => 1 + Math.floor(Math.random() * 6);
|
||||||
|
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 outMs = 320 + idx * 28;
|
||||||
|
const backMs = 540 + idx * 44;
|
||||||
|
|
||||||
|
const cycler = this.time.addEvent({ delay: 55, loop: true, callback: () => this.drawDieFace(g, rnd6()) });
|
||||||
|
|
||||||
|
this.tweens.chain({ targets: g, tweens: [
|
||||||
|
{ x: wallX, duration: outMs, ease: 'Quad.Out' },
|
||||||
|
{ x: landX, duration: backMs, ease: 'Quad.Out' },
|
||||||
|
] });
|
||||||
|
this.tweens.chain({ targets: g, tweens: [
|
||||||
|
{ y: wallY, duration: outMs, ease: 'Quad.Out' },
|
||||||
|
{ y: landY, duration: backMs, ease: 'Bounce.Out' },
|
||||||
|
] });
|
||||||
|
this.tweens.add({ targets: g, scale: 1, duration: outMs, ease: 'Quad.Out' });
|
||||||
|
this.tweens.add({ targets: g, angle: g.angle + 720 + Math.random() * 360, duration: outMs + backMs, ease: 'Quad.Out' });
|
||||||
|
|
||||||
|
this.time.delayedCall(outMs + backMs, () => {
|
||||||
|
cycler.remove();
|
||||||
|
this.drawDieFace(g, face);
|
||||||
|
const upright = Math.round(g.angle / 90) * 90 + (Math.random() * 14 - 7);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: g, angle: upright, duration: 140, ease: 'Back.Out',
|
||||||
|
onComplete: () => this.tweens.add({
|
||||||
|
targets: g, scaleX: { from: 1.14, to: 1 }, scaleY: { from: 0.88, to: 1 },
|
||||||
|
duration: 130, onComplete: resolve,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chip tray ──────────────────────────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buttons ────────────────────────────────────────────────────────────────
|
||||||
|
buildButtons() {
|
||||||
|
const y = GAME_HEIGHT - 78;
|
||||||
|
this.clearBtn = new Button(this, CX + 420, y, 'Clear', () => this.onClear(), {
|
||||||
|
width: 130, height: 56, fontSize: 20, variant: 'ghost',
|
||||||
|
});
|
||||||
|
this.clearBtn.setDepth(D.ui);
|
||||||
|
this.oddsBtn = new Button(this, CX + 580, y, 'Take Odds', () => this.onTakeOdds(), {
|
||||||
|
width: 170, height: 56, fontSize: 20, variant: 'ghost',
|
||||||
|
});
|
||||||
|
this.oddsBtn.setDepth(D.ui);
|
||||||
|
this.shootBtn = new Button(this, CX + 760, y, 'Shoot', () => this.onShoot(), {
|
||||||
|
width: 180, height: 64, fontSize: 26,
|
||||||
|
});
|
||||||
|
this.shootBtn.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, '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,
|
||||||
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
}
|
||||||
|
this.shooterRingGfx = this.add.graphics().setDepth(D.shooterRing);
|
||||||
|
this.renderAiWagers();
|
||||||
|
this.updateBalances();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShooterIndicator() {
|
||||||
|
const g = this.shooterRingGfx;
|
||||||
|
if (!g) return;
|
||||||
|
g.clear();
|
||||||
|
const x = SEAT_X[this.gs.shooterIndex];
|
||||||
|
g.lineStyle(4, COLORS.gold, 1);
|
||||||
|
g.strokeCircle(x, SEAT_Y, PORTRAIT_R + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render human bets on the layout ──────────────────────────────────────--
|
||||||
|
betTokenPos(bet) {
|
||||||
|
switch (bet.type) {
|
||||||
|
case BET.PASS: return { x: this.zones.pass.center.x - 220, y: this.zones.pass.center.y };
|
||||||
|
case BET.DONT_PASS: return { x: this.zones.dontpass.center.x - 180, y: this.zones.dontpass.center.y };
|
||||||
|
case BET.FIELD: return { x: this.zones.field.rect.x + 70, y: this.zones.field.center.y };
|
||||||
|
case BET.PLACE: return this.boxChipPos(bet.number, 0);
|
||||||
|
case BET.COME:
|
||||||
|
return bet.comePoint == null
|
||||||
|
? { x: this.zones.come.center.x - 260, y: this.zones.come.center.y }
|
||||||
|
: this.boxChipPos(bet.comePoint, 1);
|
||||||
|
case BET.DONT_COME:
|
||||||
|
return bet.comePoint == null
|
||||||
|
? { x: this.zones.dontcome.center.x, y: this.zones.dontcome.center.y + 30 }
|
||||||
|
: this.boxChipPos(bet.comePoint, 2);
|
||||||
|
default: return { x: CX, y: LAND.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boxChipPos(number, slot) {
|
||||||
|
const z = this.zones[`box${number}`];
|
||||||
|
const offs = [{ dx: -40, dy: 24 }, { dx: 0, dy: 24 }, { dx: 40, dy: 24 }];
|
||||||
|
const o = offs[slot] ?? offs[0];
|
||||||
|
return { x: z.center.x + o.dx, y: z.rect.y + z.rect.h + 4 + o.dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHumanBets() {
|
||||||
|
for (const o of this.humanChipObjs) o.destroy();
|
||||||
|
this.humanChipObjs = [];
|
||||||
|
const human = this.gs.players[0];
|
||||||
|
for (const bet of human.bets) {
|
||||||
|
const pos = this.betTokenPos(bet);
|
||||||
|
const label = bet.oddsAmount > 0 ? `$${bet.amount}+${bet.oddsAmount}` : `$${bet.amount}`;
|
||||||
|
this.humanChipObjs.push(this.makeChipToken(pos.x, pos.y, label, this.selectedChipColorFor(bet.amount)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedChipColorFor(amount) {
|
||||||
|
if (amount >= 100) return CHIP_COLORS[100];
|
||||||
|
if (amount >= 50) return CHIP_COLORS[50];
|
||||||
|
if (amount >= 25) return CHIP_COLORS[25];
|
||||||
|
return CHIP_COLORS[5];
|
||||||
|
}
|
||||||
|
|
||||||
|
makeChipToken(x, y, label, color) {
|
||||||
|
const c = this.add.container(x, y).setDepth(D.betChip);
|
||||||
|
const g = this.add.graphics();
|
||||||
|
g.fillStyle(color, 1); g.fillCircle(0, 0, 19);
|
||||||
|
g.lineStyle(3, 0xffffff, 0.5); g.strokeCircle(0, 0, 19);
|
||||||
|
const t = this.add.text(0, 0, label, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#ffffff', fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
c.add([g, t]);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAiWagers() {
|
||||||
|
for (let i = 1; i < this.gs.players.length; i++) {
|
||||||
|
if (this.aiWagerObjs[i]) { this.aiWagerObjs[i].destroy(); delete this.aiWagerObjs[i]; }
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
if (!legalBetTypes(this.gs).has(z.type)) {
|
||||||
|
this.setStatus(this.notAvailableMsg(z.type));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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; // placement rejected
|
||||||
|
playChipBet(this);
|
||||||
|
this.renderHumanBets();
|
||||||
|
this.updateBalances();
|
||||||
|
this.refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
notAvailableMsg(type) {
|
||||||
|
if (type === BET.PASS || type === BET.DONT_PASS) return 'Pass / Don\'t Pass only on the come-out.';
|
||||||
|
if (type === BET.COME || type === BET.DONT_COME) return 'Come bets only after a point is set.';
|
||||||
|
return 'That bet is not available right now.';
|
||||||
|
}
|
||||||
|
|
||||||
|
onClear() {
|
||||||
|
if (!this.bettingEnabled() || this.gs.point !== null) return;
|
||||||
|
this.gs = refundBets(this.gs, 0);
|
||||||
|
this.renderHumanBets();
|
||||||
|
this.updateBalances();
|
||||||
|
this.refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTakeOdds() {
|
||||||
|
if (!this.bettingEnabled() || this.gs.point === null) return;
|
||||||
|
const eligible = oddsEligibleBets(this.gs.players[0], this.gs);
|
||||||
|
if (eligible.length === 0) { this.setStatus('No line bet to back with odds.'); return; }
|
||||||
|
const target = eligible.find((b) => b.type === BET.PASS || b.type === BET.DONT_PASS) ?? eligible[0];
|
||||||
|
const before = this.gs.players[0].chips;
|
||||||
|
this.gs = addOdds(this.gs, 0, target.id, this.selectedChip);
|
||||||
|
if (this.gs.players[0].chips === before) { this.setStatus('Odds maxed out (3× the line).'); return; }
|
||||||
|
playChipBet(this);
|
||||||
|
this.renderHumanBets();
|
||||||
|
this.updateBalances();
|
||||||
|
this.refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshControls() {
|
||||||
|
const canBet = this.bettingEnabled();
|
||||||
|
this.shootBtn.setEnabled(canBet);
|
||||||
|
this.clearBtn.setEnabled(canBet && this.gs.point === null && this.gs.players[0].bets.length > 0);
|
||||||
|
const eligible = canBet && this.gs.point !== null && oddsEligibleBets(this.gs.players[0], this.gs).length > 0;
|
||||||
|
this.oddsBtn.setEnabled(eligible);
|
||||||
|
this.drawZones();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round flow ─────────────────────────────────────────────────────────────
|
||||||
|
beginComeOut() {
|
||||||
|
this.gs = clearLastDeltas(this.gs);
|
||||||
|
this.aiBet(false);
|
||||||
|
this.setStatus(this.gs.shooterIndex === 0
|
||||||
|
? 'Come-out roll — place your bets, then Shoot.'
|
||||||
|
: `Come-out roll — ${this.gs.players[this.gs.shooterIndex].name} is shooting. Place your bets.`);
|
||||||
|
this.renderHumanBets();
|
||||||
|
this.renderAiWagers();
|
||||||
|
this.updateBalances();
|
||||||
|
this.refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
aiBet(pointPhase) {
|
||||||
|
for (let i = 1; i < this.gs.players.length; i++) {
|
||||||
|
const specs = chooseBets(this.gs.players[i], this.gs);
|
||||||
|
for (const spec of specs) this.gs = placeBet(this.gs, i, spec);
|
||||||
|
if (pointPhase) {
|
||||||
|
for (const o of chooseOdds(this.gs.players[i], this.gs)) this.gs = addOdds(this.gs, i, o.betId, o.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.renderAiWagers();
|
||||||
|
}
|
||||||
|
|
||||||
|
onShoot() {
|
||||||
|
if (!this.bettingEnabled()) return;
|
||||||
|
this.animating = true;
|
||||||
|
this.refreshControls();
|
||||||
|
this.setStatus('Rolling…');
|
||||||
|
this.gs = clearLastDeltas(this.gs);
|
||||||
|
this.gs = rollDice(this.gs);
|
||||||
|
const finalDice = [...this.gs.dice];
|
||||||
|
this.animateThrow(finalDice).then(() => {
|
||||||
|
const res = resolveRoll(this.gs);
|
||||||
|
this.gs = res.state;
|
||||||
|
this.handleResolution(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResolution(res) {
|
||||||
|
const total = res.total;
|
||||||
|
|
||||||
|
// Move puck for point changes.
|
||||||
|
if (res.pointEstablished) this.movePuck(res.pointEstablished);
|
||||||
|
else if (res.newComeOut) this.movePuck(null);
|
||||||
|
|
||||||
|
// Animate per-player net outcome.
|
||||||
|
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 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.updateBalances();
|
||||||
|
this.persistChips();
|
||||||
|
|
||||||
|
this.time.delayedCall(1400, () => {
|
||||||
|
this.animating = false;
|
||||||
|
this.diceGfx.forEach((g) => g.setVisible(true)); // leave dice resting on the felt
|
||||||
|
|
||||||
|
if (this.checkGameOver()) return;
|
||||||
|
|
||||||
|
if (res.sevenOut) {
|
||||||
|
this.updateShooterIndicator();
|
||||||
|
this.beginComeOut();
|
||||||
|
} else if (res.pointMade) {
|
||||||
|
this.beginComeOut();
|
||||||
|
} else if (res.pointEstablished) {
|
||||||
|
this.aiBet(true); // opponents back the point once
|
||||||
|
this.setStatus(`Point is ${res.pointEstablished}. Add bets or Shoot.`);
|
||||||
|
this.renderAiWagers();
|
||||||
|
this.updateBalances();
|
||||||
|
this.refreshControls();
|
||||||
|
} else {
|
||||||
|
// No-decision roll during the point.
|
||||||
|
this.setStatus(`Point is ${this.gs.point}. Add bets or Shoot.`);
|
||||||
|
this.refreshControls();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
outcomeMessage(res, total) {
|
||||||
|
if (res.sevenOut) {
|
||||||
|
const next = this.gs.players[this.gs.shooterIndex].name;
|
||||||
|
return `Seven out! Dice pass to ${next}.`;
|
||||||
|
}
|
||||||
|
if (res.pointMade) return `${total} — Point made! Pass line wins.`;
|
||||||
|
if (res.pointEstablished) return `Point is ${res.pointEstablished}.`;
|
||||||
|
if (this.gs.point === null) {
|
||||||
|
if (total === 7 || total === 11) return `${total} — a natural! Pass wins.`;
|
||||||
|
if (total === 2 || total === 3 || total === 12) return `${total} — craps.`;
|
||||||
|
}
|
||||||
|
return `Rolled ${total}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chip win/loss animation (aggregated per player) ─────────────────────────
|
||||||
|
animateChips(playerIndex, toPlayer, amount) {
|
||||||
|
const seatX = SEAT_X[playerIndex];
|
||||||
|
const count = Math.min(7, Math.max(2, Math.ceil(amount / 50)));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const chip = this.add.graphics().setDepth(D.fx);
|
||||||
|
const color = toPlayer ? CHIP_COLORS[25] : CHIP_COLORS[100];
|
||||||
|
chip.fillStyle(color, 1); chip.fillCircle(0, 0, 13);
|
||||||
|
chip.lineStyle(2, 0xffffff, 0.4); chip.strokeCircle(0, 0, 13);
|
||||||
|
const fromX = toPlayer ? LAND.x : seatX;
|
||||||
|
const fromY = toPlayer ? LAND.y : SEAT_Y;
|
||||||
|
const toX = toPlayer ? seatX : LAND.x;
|
||||||
|
const toY = toPlayer ? SEAT_Y : LAND.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: 420 + i * 45, ease: 'Quad.InOut', onComplete: () => chip.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toPlayer) this.floatText(seatX, SEAT_Y - PORTRAIT_R - 40, `+$${amount}`, '#5cb85c');
|
||||||
|
else this.floatText(seatX, SEAT_Y - PORTRAIT_R - 40, `-$${amount}`, '#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: 'craps',
|
||||||
|
score: human.chips,
|
||||||
|
opponentScores: this.gs.players.slice(1).map((p) => p.chips),
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
leave() {
|
||||||
|
if (this.gs && this.gs.phase !== 'gameover') {
|
||||||
|
// Only resolved deltas are ever posted to the server, so refunding the
|
||||||
|
// un-resolved stakes locally makes the bankroll match the server balance
|
||||||
|
// (no extra post needed). postHistory then records the true final equity.
|
||||||
|
this.gs = refundBets(this.gs, 0);
|
||||||
|
this.postHistory();
|
||||||
|
}
|
||||||
|
this.scene.start('GameMenu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
// Pure Craps rules. No Phaser dependency.
|
||||||
|
//
|
||||||
|
// Scope: Pass Line, Don't Pass (bar 12), Field, Place (4·5·6·8·9·10),
|
||||||
|
// Come / Don't Come, and free Odds behind Pass/Don't and Come/Don't Come.
|
||||||
|
//
|
||||||
|
// A "round" is one shooter sequence. The shooter rolls a come-out; if a point
|
||||||
|
// is set the game enters the point phase and the shooter keeps rolling until
|
||||||
|
// the point repeats (pass wins) or a 7 shows (seven-out → shooter rotates).
|
||||||
|
//
|
||||||
|
// State is treated as immutable: every mutating helper returns a fresh object.
|
||||||
|
|
||||||
|
export const POINT_NUMBERS = [4, 5, 6, 8, 9, 10];
|
||||||
|
|
||||||
|
// Bet types and where they live on the layout.
|
||||||
|
export const BET = {
|
||||||
|
PASS: 'pass',
|
||||||
|
DONT_PASS: 'dontpass',
|
||||||
|
FIELD: 'field',
|
||||||
|
PLACE: 'place',
|
||||||
|
COME: 'come',
|
||||||
|
DONT_COME: 'dontcome',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chip denominations offered in the UI.
|
||||||
|
export const CHIP_AMOUNTS = [5, 25, 50, 100];
|
||||||
|
|
||||||
|
// ─── 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,
|
||||||
|
shooterIndex: 0,
|
||||||
|
phase: 'betting', // betting → comeout → point → (betting | gameover)
|
||||||
|
point: null,
|
||||||
|
dice: [1, 1],
|
||||||
|
rollCount: 0,
|
||||||
|
_nextBetId: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneState(state) {
|
||||||
|
return JSON.parse(JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bet placement ─────────────────────────────────────────────────────────--
|
||||||
|
|
||||||
|
// Which base bet types may be opened right now.
|
||||||
|
export function legalBetTypes(state) {
|
||||||
|
const out = new Set([BET.FIELD, BET.PLACE]);
|
||||||
|
if (state.point === null) {
|
||||||
|
out.add(BET.PASS);
|
||||||
|
out.add(BET.DONT_PASS);
|
||||||
|
} else {
|
||||||
|
out.add(BET.COME);
|
||||||
|
out.add(BET.DONT_COME);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line/come bet that can still receive odds (point/come-point established).
|
||||||
|
export function oddsEligibleBets(player, state) {
|
||||||
|
return player.bets.filter((b) => {
|
||||||
|
if (b.type === BET.PASS || b.type === BET.DONT_PASS) return state.point !== null;
|
||||||
|
if (b.type === BET.COME || b.type === BET.DONT_COME) return b.comePoint != null;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxOddsFor(bet) {
|
||||||
|
// Keep it simple: allow up to 3× the flat bet behind the line.
|
||||||
|
return bet.amount * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place a flat bet. Stake is removed from the player's 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 (!legalBetTypes(state).has(type)) return state;
|
||||||
|
if (type === BET.PLACE && !POINT_NUMBERS.includes(number)) return state;
|
||||||
|
|
||||||
|
const s = cloneState(state);
|
||||||
|
const p = s.players[playerIndex];
|
||||||
|
|
||||||
|
// Stack onto an existing matching flat bet rather than duplicating it.
|
||||||
|
const existing = p.bets.find((b) =>
|
||||||
|
b.type === type &&
|
||||||
|
(type === BET.PLACE ? b.number === number : b.comePoint == null && b.number == null));
|
||||||
|
if (existing && type !== BET.COME && type !== BET.DONT_COME) {
|
||||||
|
existing.amount += amount;
|
||||||
|
} else {
|
||||||
|
p.bets.push({
|
||||||
|
id: s._nextBetId++,
|
||||||
|
type,
|
||||||
|
number: type === BET.PLACE ? number : null,
|
||||||
|
comePoint: null,
|
||||||
|
amount,
|
||||||
|
oddsAmount: 0,
|
||||||
|
// Place bets sit "off" during a come-out; everything else is working.
|
||||||
|
working: type === BET.PLACE ? state.point !== null : true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
p.chips -= amount;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add odds behind an eligible line/come bet.
|
||||||
|
export function addOdds(state, playerIndex, betId, amount) {
|
||||||
|
const s = cloneState(state);
|
||||||
|
const p = s.players[playerIndex];
|
||||||
|
const bet = p.bets.find((b) => b.id === betId);
|
||||||
|
if (!bet) return state;
|
||||||
|
const cap = maxOddsFor(bet) - bet.oddsAmount;
|
||||||
|
const add = Math.min(amount, cap, p.chips);
|
||||||
|
if (add <= 0) return state;
|
||||||
|
bet.oddsAmount += add;
|
||||||
|
p.chips -= add;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return un-resolved stakes to the bankroll (only sensible during a betting
|
||||||
|
// window). Used by "Clear" and when leaving the table.
|
||||||
|
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 + b.oddsAmount;
|
||||||
|
else kept.push(b);
|
||||||
|
}
|
||||||
|
p.bets = kept;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dice ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function rollDice(state) {
|
||||||
|
const s = cloneState(state);
|
||||||
|
s.dice = [1 + Math.floor(Math.random() * 6), 1 + Math.floor(Math.random() * 6)];
|
||||||
|
s.rollCount += 1;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force specific dice (testing / scripted demos).
|
||||||
|
export function setDice(state, dice) {
|
||||||
|
const s = cloneState(state);
|
||||||
|
s.dice = [...dice];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Payout tables (return winnings only, not the returned stake) ─────────────
|
||||||
|
|
||||||
|
function floor(n) { return Math.floor(n); }
|
||||||
|
|
||||||
|
function passOddsWin(amount, number) {
|
||||||
|
if (number === 4 || number === 10) return amount * 2; // 2:1
|
||||||
|
if (number === 5 || number === 9) return floor(amount * 3 / 2); // 3:2
|
||||||
|
if (number === 6 || number === 8) return floor(amount * 6 / 5); // 6:5
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dontOddsWin(amount, number) {
|
||||||
|
if (number === 4 || number === 10) return floor(amount / 2); // 1:2
|
||||||
|
if (number === 5 || number === 9) return floor(amount * 2 / 3); // 2:3
|
||||||
|
if (number === 6 || number === 8) return floor(amount * 5 / 6); // 5:6
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeWin(amount, number) {
|
||||||
|
if (number === 4 || number === 10) return floor(amount * 9 / 5);
|
||||||
|
if (number === 5 || number === 9) return floor(amount * 7 / 5);
|
||||||
|
if (number === 6 || number === 8) return floor(amount * 7 / 6);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldWin(amount, total) {
|
||||||
|
if (total === 2) return amount * 2; // 2 pays double
|
||||||
|
if (total === 12) return amount * 3; // 12 pays triple
|
||||||
|
return amount; // 3,4,9,10,11 pay 1:1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolution ────────────────────────────────────────────────────────────--
|
||||||
|
|
||||||
|
// Resolve every bet against the dice already set on `state`.
|
||||||
|
// Returns { state, total, payouts, pointEstablished, pointMade, sevenOut, newComeOut }.
|
||||||
|
//
|
||||||
|
// payouts entry: { playerIndex, betType, number, result, amount, delta }
|
||||||
|
// result ∈ 'win' | 'lose' | 'push'; delta is the net bankroll change for the bet.
|
||||||
|
export function resolveRoll(state) {
|
||||||
|
const s = cloneState(state);
|
||||||
|
const total = s.dice[0] + s.dice[1];
|
||||||
|
const point = s.point;
|
||||||
|
const isComeOut = point === null;
|
||||||
|
const payouts = [];
|
||||||
|
|
||||||
|
let pointEstablished = null;
|
||||||
|
let pointMade = false;
|
||||||
|
let sevenOut = false;
|
||||||
|
|
||||||
|
for (let pi = 0; pi < s.players.length; pi++) {
|
||||||
|
const p = s.players[pi];
|
||||||
|
const kept = [];
|
||||||
|
|
||||||
|
for (const bet of p.bets) {
|
||||||
|
const resolved = resolveBet(bet, { total, point, isComeOut });
|
||||||
|
if (resolved.result === 'open') {
|
||||||
|
// Bet survives (possibly mutated, e.g. a come bet that travelled).
|
||||||
|
kept.push(resolved.bet ?? bet);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Credit returned stake + winnings; delta is net of the staked amount.
|
||||||
|
const stake = bet.amount + bet.oddsAmount;
|
||||||
|
let credited = 0;
|
||||||
|
if (resolved.result === 'win') credited = stake + resolved.winnings;
|
||||||
|
else if (resolved.result === 'push') credited = stake;
|
||||||
|
p.chips += credited;
|
||||||
|
const delta = credited - stake;
|
||||||
|
p.lastDelta += delta;
|
||||||
|
payouts.push({
|
||||||
|
playerIndex: pi,
|
||||||
|
betType: bet.type,
|
||||||
|
number: bet.number ?? bet.comePoint ?? null,
|
||||||
|
result: resolved.result,
|
||||||
|
amount: stake,
|
||||||
|
delta,
|
||||||
|
});
|
||||||
|
// A winning Place bet stays up and working; everything else clears.
|
||||||
|
if (resolved.keep) kept.push(resolved.bet ?? bet);
|
||||||
|
}
|
||||||
|
p.bets = kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase / point transitions driven by the line outcome.
|
||||||
|
if (isComeOut) {
|
||||||
|
if (POINT_NUMBERS.includes(total)) {
|
||||||
|
pointEstablished = total;
|
||||||
|
s.point = total;
|
||||||
|
s.phase = 'point';
|
||||||
|
// Place bets switch on once a point exists.
|
||||||
|
for (const p of s.players) for (const b of p.bets) {
|
||||||
|
if (b.type === BET.PLACE) b.working = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.phase = 'comeout';
|
||||||
|
}
|
||||||
|
} else if (total === point) {
|
||||||
|
pointMade = true;
|
||||||
|
s.point = null;
|
||||||
|
s.phase = 'comeout';
|
||||||
|
for (const p of s.players) for (const b of p.bets) {
|
||||||
|
if (b.type === BET.PLACE) b.working = false;
|
||||||
|
}
|
||||||
|
} else if (total === 7) {
|
||||||
|
sevenOut = true;
|
||||||
|
s.point = null;
|
||||||
|
s.phase = 'comeout';
|
||||||
|
for (const p of s.players) for (const b of p.bets) {
|
||||||
|
if (b.type === BET.PLACE) b.working = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComeOut = pointMade || sevenOut;
|
||||||
|
if (sevenOut) {
|
||||||
|
s.shooterIndex = (s.shooterIndex + 1) % s.players.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: s, total, payouts, pointEstablished, pointMade, sevenOut, newComeOut };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a single bet. Returns one of:
|
||||||
|
// { result:'open', bet } — unresolved (bet may have mutated)
|
||||||
|
// { result:'win', winnings, keep } — paid; keep=true leaves it on the felt
|
||||||
|
// { result:'lose' } | { result:'push' }
|
||||||
|
function resolveBet(bet, { total, point, isComeOut }) {
|
||||||
|
switch (bet.type) {
|
||||||
|
case BET.FIELD: {
|
||||||
|
if ([2, 3, 4, 9, 10, 11, 12].includes(total)) {
|
||||||
|
return { result: 'win', winnings: fieldWin(bet.amount, total) };
|
||||||
|
}
|
||||||
|
return { result: 'lose' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case BET.PASS: {
|
||||||
|
if (isComeOut) {
|
||||||
|
if (total === 7 || total === 11) return { result: 'win', winnings: bet.amount };
|
||||||
|
if (total === 2 || total === 3 || total === 12) return { result: 'lose' };
|
||||||
|
return { result: 'open' }; // point established, ride it
|
||||||
|
}
|
||||||
|
if (total === point) {
|
||||||
|
const winnings = bet.amount + passOddsWin(bet.oddsAmount, point);
|
||||||
|
return { result: 'win', winnings };
|
||||||
|
}
|
||||||
|
if (total === 7) return { result: 'lose' };
|
||||||
|
return { result: 'open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case BET.DONT_PASS: {
|
||||||
|
if (isComeOut) {
|
||||||
|
if (total === 2 || total === 3) return { result: 'win', winnings: bet.amount };
|
||||||
|
if (total === 12) return { result: 'push' }; // bar 12
|
||||||
|
if (total === 7 || total === 11) return { result: 'lose' };
|
||||||
|
return { result: 'open' };
|
||||||
|
}
|
||||||
|
if (total === 7) {
|
||||||
|
const winnings = bet.amount + dontOddsWin(bet.oddsAmount, point);
|
||||||
|
return { result: 'win', winnings };
|
||||||
|
}
|
||||||
|
if (total === point) return { result: 'lose' };
|
||||||
|
return { result: 'open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case BET.PLACE: {
|
||||||
|
if (!bet.working) return { result: 'open' };
|
||||||
|
if (total === bet.number) {
|
||||||
|
return { result: 'win', winnings: placeWin(bet.amount, bet.number), keep: true };
|
||||||
|
}
|
||||||
|
if (total === 7) return { result: 'lose' };
|
||||||
|
return { result: 'open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case BET.COME:
|
||||||
|
case BET.DONT_COME: {
|
||||||
|
const isDont = bet.type === BET.DONT_COME;
|
||||||
|
if (bet.comePoint == null) {
|
||||||
|
// This roll is the come bet's own come-out.
|
||||||
|
if (total === 7 || total === 11) return isDont ? { result: 'lose' } : { result: 'win', winnings: bet.amount };
|
||||||
|
if (total === 2 || total === 3) return isDont ? { result: 'win', winnings: bet.amount } : { result: 'lose' };
|
||||||
|
if (total === 12) return isDont ? { result: 'push' } : { result: 'lose' };
|
||||||
|
// Travels to its number.
|
||||||
|
return { result: 'open', bet: { ...bet, comePoint: total } };
|
||||||
|
}
|
||||||
|
// Come point established.
|
||||||
|
if (total === bet.comePoint) {
|
||||||
|
if (isDont) return { result: 'lose' };
|
||||||
|
return { result: 'win', winnings: bet.amount + passOddsWin(bet.oddsAmount, bet.comePoint) };
|
||||||
|
}
|
||||||
|
if (total === 7) {
|
||||||
|
if (isDont) return { result: 'win', winnings: bet.amount + dontOddsWin(bet.oddsAmount, bet.comePoint) };
|
||||||
|
return { result: 'lose' };
|
||||||
|
}
|
||||||
|
return { result: 'open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { result: 'open' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 + b.oddsAmount, 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';
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import Phase10Game from './games/phase10/Phase10Game.js';
|
||||||
import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js';
|
import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js';
|
||||||
import GoFishGame from './games/gofish/GoFishGame.js';
|
import GoFishGame from './games/gofish/GoFishGame.js';
|
||||||
import UnoGame from './games/uno/UnoGame.js';
|
import UnoGame from './games/uno/UnoGame.js';
|
||||||
|
import CrapsGame from './games/craps/CrapsGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -53,6 +54,7 @@ const config = {
|
||||||
ChineseCheckersGame,
|
ChineseCheckersGame,
|
||||||
GoFishGame,
|
GoFishGame,
|
||||||
UnoGame,
|
UnoGame,
|
||||||
|
CrapsGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,4 @@ registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: t
|
||||||
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5 });
|
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5 });
|
||||||
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
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: '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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue