Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Fertig 7602da4cbe feat: improve Mexican Train UI with boneyard interaction and readability
- Add interactive boneyard pile with click-to-draw functionality and animations
- Display opponent hand counts as mini-domino dots next to portraits
- Apply background padding to all UI text elements for improved readability
- Refactor draw animations to support both human and AI tile drawing from the boneyard
2026-05-21 20:09:15 -06:00
Brian Fertig f1fc560cd1 feat: add Mexican Train game with AI and enhanced roulette effects
- Implement Mexican Train game logic, AI opponent, and Phaser UI scene
- Register Mexican Train in server game registry and client dispatch tables
- Add roulette spin sound effect and improved win/lose animations with fireworks
- Update preload scene and sound registry to support new audio assets
2026-05-21 19:43:54 -06:00
10 changed files with 1224 additions and 9 deletions

Binary file not shown.

View File

@ -0,0 +1,45 @@
// Heuristic Mexican Train AI. Stateless; consumed by MexicanTrainGame.
//
// chooseMove(state, idx) -> { tileIndex, train } | null
// Picks among the legal moves the engine reports. The scene handles the
// draw/pass flow when no legal move exists.
import { getLegalMoves, isDoubleTile, tilePips, MEXICAN } from './MexicanTrainLogic.js';
export function chooseMove(state, idx) {
const moves = getLegalMoves(state, idx);
if (moves.length === 0) return null;
const hand = state.players[idx].hand;
const ownKey = String(idx);
const ownMarked = state.trains[ownKey]?.marker;
let best = null;
let bestScore = -Infinity;
for (const m of moves) {
const score = scoreMove(state, m, hand[m.tileIndex], ownKey, ownMarked);
if (score > bestScore) { bestScore = score; best = m; }
}
return best;
}
function scoreMove(state, move, tile, ownKey, ownMarked) {
let score = tilePips(tile); // shed heavy tiles first to cut penalty risk
// Doubles are risky to hold and heavy — favor unloading them when we can.
if (isDoubleTile(tile)) score += 6;
// Covering an open double is forced; prefer doing it with a non-double so we
// don't immediately re-open another one we might not be able to cover.
if (state.openDouble) {
if (isDoubleTile(tile)) score -= 4;
return score;
}
// Reclaim our own train (clears the marker so opponents lose access).
if (move.train === ownKey) score += ownMarked ? 8 : 3;
else if (move.train === MEXICAN) score += 1;
// else: playing on an opponent's open train — neutral.
return score;
}

View File

@ -0,0 +1,874 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
MEXICAN, createInitialState, getLegalMoves,
playTile, drawTile, canDraw, passTurn, startNextRound, getWinners,
} from './MexicanTrainLogic.js';
import { chooseMove } from './MexicanTrainAI.js';
const TARGET_SCORE = 100;
// ─── Layout ──────────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const PORTRAIT_X = 84;
const PORTRAIT_R = 46;
const LABEL_X = 150;
const HUB_X = 320;
const TRACKS_LEFT = 392;
const TRACKS_RIGHT = GAME_WIDTH - 40;
const TRACKS_TOP = 170;
const TRACKS_BOTTOM = 812;
const TRACK_HALF = 38; // square half-cell for laid tiles
const TRACK_TILE_W = TRACK_HALF * 2;
const TRACK_PITCH = TRACK_TILE_W + 6;
const HAND_Y = 942;
const HAND_HALF = 50; // square half-cell for hand tiles
const HAND_PITCH = HAND_HALF + 14;
const BONEYARD_CX = 1600;
const BONEYARD_CY = 950;
const BONEYARD_HALF = 40; // half-cell for face-down pile tiles
// Fixed offsets so the pile looks like a casual scatter (deterministic)
const PILE_OFFSETS = [
{ dx: 0, dy: 0, angle: 2 },
{ dx: -11, dy: 7, angle: 13 },
{ dx: 13, dy: -6, angle: -9 },
{ dx: -15, dy: -5, angle: 20 },
{ dx: 10, dy: 12, angle:-16 },
{ dx: -5, dy:-13, angle: 7 },
{ dx: 19, dy: 4, angle:-24 },
{ dx:-20, dy: 9, angle: 26 },
];
const DEPTH = {
bg: -1, band: 0, spine: 1, tile: 2, tileFx: 5,
hub: 6, portrait: 10, ui: 20, hand: 22, toast: 50, modal: 60,
};
// pip offsets (col,row in {-1,0,1}) per face value 0-6
const PIPS = {
0: [],
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 MexicanTrainGame extends Phaser.Scene {
constructor() { super('MexicanTrainGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.inputLocked = true;
this.humanDrew = false;
this.gameOverShown = false;
this.portraitCtrls = []; // [{ ring, controller, x, y }]
this.rowZones = {}; // trainKey -> Zone
this.rowMeta = []; // [{ key, y }]
this.handTileObjs = []; // [{ container, tileIndex }]
this.selectTileIndex = null;
this.labelTexts = {}; // trainKey -> Text
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
if (this.playfield?.key && this.textures.exists(this.playfield.key)) {
this.add.image(CX, GAME_HEIGHT / 2, this.playfield.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
}
const playerNames = [
{ name: auth.user?.username ?? 'You', isAI: false },
...this.opponents.map((o) => ({ name: o.name ?? o.id ?? 'Bot', isAI: true, avatar: o })),
];
this.gs = createInitialState({ playerNames, target: TARGET_SCORE });
this.trackGfx = this.add.graphics().setDepth(DEPTH.tile);
this.bandGfx = this.add.graphics().setDepth(DEPTH.band);
this.spineGfx = this.add.graphics().setDepth(DEPTH.spine);
this.hubGfx = this.add.graphics().setDepth(DEPTH.hub);
this.markerGfx = this.add.graphics().setDepth(DEPTH.tileFx);
this.buildHeader();
this.buildPortraitsAndLabels();
this.buildRowZones();
this.buildBoneyard();
new Button(this, 86, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 150, fontSize: 20,
}).setDepth(DEPTH.ui);
this.refresh();
this.time.delayedCall(500, () => this.nextTurn());
}
// ─── Static UI ────────────────────────────────────────────────────────
buildHeader() {
this.add.text(CX, 44, 'Mexican Train', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(14, 7);
this.roundText = this.add.text(CX, 86, '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(12, 4);
this.statusText = this.add.text(CX, 118, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(12, 5);
this.boneText = this.add.text(BONEYARD_CX, BONEYARD_CY + 76, '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4);
}
rowY(i) {
const rows = this.gs.players.length + 1;
const gap = (TRACKS_BOTTOM - TRACKS_TOP) / rows;
return TRACKS_TOP + gap * (i + 0.5);
}
rowKeyFor(i) {
return i < this.gs.players.length ? String(i) : MEXICAN;
}
buildPortraitsAndLabels() {
const n = this.gs.players.length;
this._handDotGfx = [];
for (let i = 0; i < n; i++) {
const y = this.rowY(i);
const ring = this.add.graphics().setDepth(DEPTH.portrait);
let controller;
if (i === 0) {
controller = createPlayerPortrait(this, PORTRAIT_X, y, PORTRAIT_R, DEPTH.portrait, 'MexicanTrainGame');
} else {
const opp = this.opponents[i - 1] ?? { id: 'bot', spriteIndex: 0 };
controller = createOpponentPortrait(this, opp, PORTRAIT_X, y, PORTRAIT_R, DEPTH.portrait);
}
this.portraitCtrls.push({ ring, controller, x: PORTRAIT_X, y });
const label = this.add.text(LABEL_X, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
align: 'left', lineSpacing: 2,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5);
this.labelTexts[String(i)] = label;
this._handDotGfx.push(this.add.graphics().setDepth(DEPTH.ui));
}
// Mexican train row label
const my = this.rowY(n);
const mlabel = this.add.text(LABEL_X - 60, my, '', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.goldHex,
align: 'left',
}).setOrigin(0, 0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5);
this.labelTexts[MEXICAN] = mlabel;
}
buildRowZones() {
const rows = this.gs.players.length + 1;
const gap = (TRACKS_BOTTOM - TRACKS_TOP) / rows;
for (let i = 0; i < rows; i++) {
const key = this.rowKeyFor(i);
const y = this.rowY(i);
const zone = this.add.zone(TRACKS_LEFT, y, TRACKS_RIGHT - TRACKS_LEFT, gap - 6)
.setOrigin(0, 0.5).setDepth(DEPTH.tileFx)
.setInteractive({ useHandCursor: true });
zone.on('pointerup', () => this.onRowClick(key));
this.rowZones[key] = zone;
this.rowMeta.push({ key, y });
}
}
// ─── Render ───────────────────────────────────────────────────────────
refresh() {
this.paintBands();
this.paintSpineAndHub();
this.paintTracks();
this.paintMarkers();
this.paintHand();
this.updatePortraitRings();
this.updateLabels();
this.updateStatus();
const hub = this.gs.hub.value;
this.roundText.setText(
`Round ${this.gs.round + 1} • Engine ${hub}-${hub} • First to ${this.gs.target} ends it — lowest score wins`,
);
this.updateBoneyard();
}
paintBands() {
const g = this.bandGfx;
g.clear();
const rows = this.gs.players.length + 1;
const gap = (TRACKS_BOTTOM - TRACKS_TOP) / rows;
for (let i = 0; i < rows; i++) {
const y = this.rowY(i);
const active = i < this.gs.players.length && i === this.gs.current;
g.fillStyle(active ? COLORS.accent : COLORS.panel, active ? 0.16 : 0.5);
g.fillRoundedRect(TRACKS_LEFT - 8, y - gap / 2 + 4, TRACKS_RIGHT - TRACKS_LEFT + 16, gap - 8, 10);
}
}
paintSpineAndHub() {
const g = this.spineGfx;
g.clear();
const rows = this.gs.players.length + 1;
const hubY = (this.rowY(0) + this.rowY(rows - 1)) / 2;
g.lineStyle(3, COLORS.muted, 0.7);
for (let i = 0; i < rows; i++) {
g.lineBetween(HUB_X, hubY, TRACKS_LEFT - 6, this.rowY(i));
}
this.hubGfx.clear();
const v = this.gs.hub.value;
this.paintDomino(this.hubGfx, HUB_X, hubY, 34, v, v, false, COLORS.gold);
if (!this._hubLabel) {
this._hubLabel = this.add.text(HUB_X, hubY - 58, 'ENGINE', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.hub)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 3);
} else {
this._hubLabel.setY(hubY - 58);
}
}
paintTracks() {
const g = this.trackGfx;
g.clear();
const rows = this.gs.players.length + 1;
const maxTiles = Math.floor((TRACKS_RIGHT - TRACKS_LEFT) / TRACK_PITCH);
for (let i = 0; i < rows; i++) {
const key = this.rowKeyFor(i);
const y = this.rowY(i);
const tiles = this.gs.trains[key].tiles;
const start = Math.max(0, tiles.length - maxTiles);
let drawX = TRACKS_LEFT + TRACK_HALF;
if (start > 0) {
this.maybeTruncMark(i, start);
drawX += 8;
} else {
this.clearTruncMark(i);
}
for (let t = start; t < tiles.length; t++) {
const tile = tiles[t];
const isOpenDouble = this.gs.openDouble?.train === key && t === tiles.length - 1;
const border = isOpenDouble ? COLORS.danger : (tile.left === tile.right ? COLORS.gold : COLORS.accent);
this.paintDomino(g, drawX, y, TRACK_HALF, tile.left, tile.right, false, border);
drawX += TRACK_PITCH;
}
}
}
maybeTruncMark(i, hidden) {
this._truncMarks = this._truncMarks ?? {};
const y = this.rowY(i);
if (!this._truncMarks[i]) {
this._truncMarks[i] = this.add.text(TRACKS_LEFT - 2, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.tileFx)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(5, 2);
}
this._truncMarks[i].setText(`+${hidden}«`).setVisible(true);
}
clearTruncMark(i) {
if (this._truncMarks?.[i]) this._truncMarks[i].setVisible(false);
}
paintMarkers() {
const g = this.markerGfx;
g.clear();
const rows = this.gs.players.length + 1;
for (let i = 0; i < rows; i++) {
const key = this.rowKeyFor(i);
if (key === MEXICAN) continue;
if (!this.gs.trains[key].marker) continue;
const y = this.rowY(i);
const tiles = this.gs.trains[key].tiles;
const x = TRACKS_LEFT + TRACK_HALF + Math.min(tiles.length, Math.floor((TRACKS_RIGHT - TRACKS_LEFT) / TRACK_PITCH)) * TRACK_PITCH + 6;
// little open-train flag
g.fillStyle(COLORS.danger, 1);
g.fillCircle(x, y, 9);
g.fillStyle(COLORS.text, 1);
g.fillRect(x - 1, y - 14, 2, 14);
}
}
paintHand() {
for (const o of this.handTileObjs) o.container.destroy();
this.handTileObjs = [];
const hand = this.gs.players[0].hand;
const legal = this.gs.players[0].isAI ? [] : getLegalMoves(this.gs, 0);
const legalByTile = new Map();
for (const m of legal) {
if (!legalByTile.has(m.tileIndex)) legalByTile.set(m.tileIndex, []);
legalByTile.get(m.tileIndex).push(m.train);
}
const total = hand.length;
const startX = CX - ((total - 1) * HAND_PITCH) / 2;
const humanTurn = this.gs.current === 0 && this.gs.phase === 'playing';
hand.forEach((tile, idx) => {
const x = startX + idx * HAND_PITCH;
const playable = humanTurn && legalByTile.has(idx);
const container = this.add.container(x, HAND_Y).setDepth(DEPTH.hand);
const g = this.add.graphics();
const selected = this.selectTileIndex === idx;
const border = selected ? COLORS.gold : (playable ? COLORS.accent : COLORS.muted);
this.paintDomino(g, 0, 0, HAND_HALF, tile.a, tile.b, true, border);
container.add(g);
container.setAlpha(playable || !humanTurn ? 1 : 0.45);
if (playable) {
const zone = this.add.zone(0, 0, HAND_HALF, HAND_HALF * 2).setOrigin(0.5)
.setInteractive({ useHandCursor: true });
zone.on('pointerup', () => this.onHandTileClick(idx, legalByTile.get(idx)));
container.add(zone);
if (selected) container.setY(HAND_Y - 16);
}
this.handTileObjs.push({ container, tileIndex: idx });
});
}
updatePortraitRings() {
for (let i = 0; i < this.portraitCtrls.length; i++) {
const { ring, x, y } = this.portraitCtrls[i];
ring.clear();
if (i === this.gs.current && this.gs.phase === 'playing') {
ring.lineStyle(4, COLORS.gold, 1);
ring.strokeCircle(x, y, PORTRAIT_R + 6);
}
}
}
updateLabels() {
const n = this.gs.players.length;
for (let i = 0; i < n; i++) {
const p = this.gs.players[i];
const marked = this.gs.trains[String(i)].marker;
this.labelTexts[String(i)].setText(`${p.name}\n${p.hand.length} tiles · ${p.score} pts${marked ? ' · OPEN' : ''}`);
this.labelTexts[String(i)].setColor(marked ? COLORS.dangerHex : COLORS.textHex);
}
this.labelTexts[MEXICAN].setText('Mexican Train');
this.updateHandDots();
}
updateHandDots() {
if (!this._handDotGfx) return;
const n = this.gs.players.length;
const half = 7;
const normalPitch = half * 2 + 3;
const maxWidth = TRACKS_LEFT - LABEL_X - half; // drawable x range
for (let i = 0; i < n; i++) {
const g = this._handDotGfx[i];
g.clear();
const count = this.gs.players[i].hand.length;
if (count === 0) continue;
const y = this.rowY(i) + 30;
// Compress pitch if tiles would overflow the label area
const pitch = count > 1 ? Math.min(normalPitch, (maxWidth - half) / (count - 1)) : normalPitch;
for (let t = 0; t < count; t++) {
this.paintMiniDomino(g, LABEL_X + half + t * pitch, y, half, true);
}
}
}
paintMiniDomino(g, cx, cy, half, faceUp = false) {
const w = half * 2;
const h = half;
g.fillStyle(faceUp ? COLORS.text : COLORS.panel, 1);
g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 2);
g.lineStyle(1, faceUp ? COLORS.accent : COLORS.muted, faceUp ? 1 : 0.7);
g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 2);
g.lineStyle(1, COLORS.muted, faceUp ? 0.5 : 0.3);
g.lineBetween(cx, cy - h / 2 + 1, cx, cy + h / 2 - 1);
}
updateStatus() {
const cur = this.gs.players[this.gs.current];
if (this.gs.phase === 'gameover') { this.statusText.setText('Match over'); return; }
if (this.gs.phase === 'roundover') { this.statusText.setText('Round over'); return; }
if (this.gs.openDouble) {
const who = this.gs.current === 0 ? 'You' : cur.name;
this.statusText.setText(this.gs.current === 0 ? 'Cover the double!' : `${who} must cover the double`);
return;
}
this.statusText.setText(this.gs.current === 0 ? 'Your turn' : `${cur.name}'s turn`);
}
// ─── Domino painter ───────────────────────────────────────────────────
paintDomino(g, cx, cy, half, vA, vB, vertical, borderColor) {
const w = vertical ? half : half * 2;
const h = vertical ? half * 2 : half;
g.fillStyle(COLORS.text, 1);
g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 6);
g.lineStyle(2.5, borderColor, 1);
g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 6);
// divider
g.lineStyle(2, COLORS.muted, 0.8);
if (vertical) g.lineBetween(cx - w / 2 + 4, cy, cx + w / 2 - 4, cy);
else g.lineBetween(cx, cy - h / 2 + 4, cx, cy + h / 2 - 4);
if (vertical) {
this.paintPips(g, cx, cy - half / 2, half, vA);
this.paintPips(g, cx, cy + half / 2, half, vB);
} else {
this.paintPips(g, cx - half / 2, cy, half, vA);
this.paintPips(g, cx + half / 2, cy, half, vB);
}
}
paintPips(g, cx, cy, half, value) {
const d = half * 0.27;
const r = Math.max(2, half * 0.11);
g.fillStyle(COLORS.textDark, 1);
for (const [col, row] of (PIPS[value] ?? [])) {
g.fillCircle(cx + col * d, cy + row * d, r);
}
}
// ─── Turn flow ────────────────────────────────────────────────────────
nextTurn() {
this.showBoneyardClickable(false);
if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; }
if (this.gs.phase === 'roundover') { this.showRoundOverModal(); return; }
this.refresh();
if (this.gs.players[this.gs.current].isAI) this.runAITurn();
else this.beginHumanTurn();
}
beginHumanTurn() {
this.humanDrew = false;
this.selectTileIndex = null;
this.promptHuman();
}
promptHuman() {
this.refresh();
const moves = getLegalMoves(this.gs, 0);
if (moves.length > 0) {
this.inputLocked = false;
return;
}
// No legal play — draw one (once), else pass.
this.inputLocked = true;
if (!this.humanDrew && canDraw(this.gs)) {
this.statusText.setText('No tiles to play — click the boneyard to draw');
this.showBoneyardClickable(true);
} else {
this.statusText.setText('No play — passing');
this.time.delayedCall(750, () => {
this.gs = passTurn(this.gs);
this.afterAction();
});
}
}
onHandTileClick(tileIndex, trains) {
if (this.inputLocked) return;
if (trains.length === 1) {
this.applyMove({ tileIndex, train: trains[0] });
return;
}
// Multiple destinations: enter selection mode, highlight candidate rows.
this.selectTileIndex = tileIndex;
this._selectTrains = new Set(trains);
this.refresh();
this.statusText.setText('Choose a train for this tile');
this.highlightSelectableRows(true);
}
highlightSelectableRows(on) {
const rows = this.gs.players.length + 1;
const gap = (TRACKS_BOTTOM - TRACKS_TOP) / rows;
this._selectGfx?.clear();
if (!this._selectGfx) this._selectGfx = this.add.graphics().setDepth(DEPTH.tileFx);
if (!on) return;
for (let i = 0; i < rows; i++) {
const key = this.rowKeyFor(i);
if (!this._selectTrains.has(key)) continue;
const y = this.rowY(i);
this._selectGfx.lineStyle(3, COLORS.gold, 1);
this._selectGfx.strokeRoundedRect(TRACKS_LEFT - 8, y - gap / 2 + 4, TRACKS_RIGHT - TRACKS_LEFT + 16, gap - 8, 10);
}
}
onRowClick(key) {
if (this.inputLocked || this.selectTileIndex === null) return;
if (!this._selectTrains?.has(key)) return;
const tileIndex = this.selectTileIndex;
this.selectTileIndex = null;
this.highlightSelectableRows(false);
this.applyMove({ tileIndex, train: key });
}
applyMove(move) {
this.inputLocked = true;
this.selectTileIndex = null;
this.highlightSelectableRows(false);
const key = move.train;
const tile = this.gs.players[0].hand[move.tileIndex];
const obj = this.handTileObjs.find((o) => o.tileIndex === move.tileIndex);
const srcX = obj ? obj.container.x : CX;
const srcY = obj ? obj.container.y : HAND_Y;
if (obj) obj.container.setVisible(false);
const { x: destX, y: destY } = this.calcTrackEnd(key);
this.animateTilePlay(srcX, srcY, destX, destY, tile.a, tile.b, () => {
this.gs = playTile(this.gs, move);
playSound(this, SFX.PIECE_CLICK);
this.refresh();
this.flashTrainEnd(key);
this.humanDrew = false;
this.time.delayedCall(280, () => this.afterAction());
});
}
afterAction() {
if (this.gs.phase !== 'playing') { this.nextTurn(); return; }
if (this.gs.current === 0) { this.beginHumanTurnContinue(); return; }
this.nextTurn();
}
// Continue the human's turn (e.g. must cover a double they just laid).
beginHumanTurnContinue() {
this.humanDrew = false;
this.promptHuman();
}
async runAITurn() {
this.inputLocked = true;
let drew = false;
// Safety bound so a logic edge case can never hang the turn loop.
for (let guard = 0; guard < 60; guard++) {
if (this.gs.phase !== 'playing') break;
const startPlayer = this.gs.current;
const moves = getLegalMoves(this.gs, startPlayer);
if (moves.length > 0) {
await this.delay(350);
const move = chooseMove(this.gs, startPlayer);
const key = move.train;
const tile = this.gs.players[startPlayer].hand[move.tileIndex];
const src = this.portraitCtrls[startPlayer] ?? { x: PORTRAIT_X, y: this.rowY(startPlayer) };
const { x: destX, y: destY } = this.calcTrackEnd(key);
await new Promise((resolve) => this.animateTilePlay(src.x, src.y, destX, destY, tile.a, tile.b, resolve));
this.gs = playTile(this.gs, move);
playSound(this, SFX.PIECE_CLICK);
this.refresh();
this.flashTrainEnd(key);
drew = false;
if (this.gs.phase !== 'playing') break;
if (this.gs.current === startPlayer) continue; // laid a double, must cover
break;
}
if (!drew && canDraw(this.gs)) {
await this.delay(300);
const portraitSrc = this.portraitCtrls[startPlayer] ?? { x: PORTRAIT_X, y: this.rowY(startPlayer) };
await this.animateDrawFromPile(portraitSrc.x, portraitSrc.y);
this.gs = drawTile(this.gs);
this.refresh();
drew = true;
continue;
}
await this.delay(420);
this.gs = passTurn(this.gs);
break;
}
await this.delay(360);
this.nextTurn();
}
// ─── Effects ──────────────────────────────────────────────────────────
flashTrainEnd(key) {
const rows = this.gs.players.length + 1;
let i = key === MEXICAN ? rows - 1 : Number(key);
const y = this.rowY(i);
const tiles = this.gs.trains[key].tiles;
const maxTiles = Math.floor((TRACKS_RIGHT - TRACKS_LEFT) / TRACK_PITCH);
const shown = Math.min(tiles.length, maxTiles);
const x = TRACKS_LEFT + TRACK_HALF + (shown - 1) * TRACK_PITCH;
const fx = this.add.graphics().setDepth(DEPTH.tileFx);
fx.lineStyle(4, COLORS.gold, 1);
fx.strokeRoundedRect(x - TRACK_TILE_W / 2 - 3, y - TRACK_HALF / 2 - 3, TRACK_TILE_W + 6, TRACK_HALF + 6, 6);
this.tweens.add({ targets: fx, alpha: { from: 1, to: 0 }, duration: 500, onComplete: () => fx.destroy() });
}
calcTrackEnd(key) {
const rows = this.gs.players.length + 1;
const i = key === MEXICAN ? rows - 1 : Number(key);
const y = this.rowY(i);
const tiles = this.gs.trains[key].tiles;
const maxTiles = Math.floor((TRACKS_RIGHT - TRACKS_LEFT) / TRACK_PITCH);
const newIdx = Math.min(tiles.length, maxTiles - 1);
return { x: TRACKS_LEFT + TRACK_HALF + newIdx * TRACK_PITCH, y };
}
animateTilePlay(fromX, fromY, toX, toY, tileA, tileB, onComplete) {
const g = this.add.graphics().setDepth(DEPTH.tileFx + 2);
this.paintDomino(g, 0, 0, TRACK_HALF, tileA, tileB, false, COLORS.accent);
g.setPosition(fromX, fromY);
this.tweens.add({
targets: g,
x: toX,
y: toY,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => { g.destroy(); if (onComplete) onComplete(); },
});
}
// ─── Boneyard pile ───────────────────────────────────────────────────
buildBoneyard() {
// Pre-create 8 face-down tile containers; show/hide based on boneyard count.
this._boneyardTiles = PILE_OFFSETS.map(({ dx, dy, angle }) => {
const g = this.add.graphics();
this.paintFaceDownDomino(g, 0, 0, BONEYARD_HALF);
const c = this.add.container(BONEYARD_CX + dx, BONEYARD_CY + dy, [g]);
c.setAngle(angle).setDepth(DEPTH.tile);
return c;
});
this.add.text(BONEYARD_CX, BONEYARD_CY - 72, 'BONEYARD', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4);
this._drawToast = this.add.text(BONEYARD_CX, BONEYARD_CY - 100, 'DRAW', {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.goldHex,
}).setOrigin(0.5).setAlpha(0).setDepth(DEPTH.toast);
this._boneyardZone = this.add.zone(BONEYARD_CX, BONEYARD_CY, BONEYARD_HALF * 2 + 60, BONEYARD_HALF * 2 + 30)
.setOrigin(0.5).setDepth(DEPTH.tileFx);
this._boneyardZoneActive = false;
this._boneyardZone.on('pointerup', () => this.onBoneyardClick());
}
updateBoneyard() {
if (!this._boneyardTiles) return;
const count = this.gs.boneyard.length;
const shown = Math.min(count, 8);
for (let i = 0; i < 8; i++) this._boneyardTiles[i].setVisible(i < shown);
this.boneText.setText(count > 0 ? `${count} tiles` : 'Empty');
}
paintFaceDownDomino(g, cx, cy, half) {
const w = half * 2;
const h = half;
g.fillStyle(COLORS.text, 1);
g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 5);
g.lineStyle(2, COLORS.accent, 1);
g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 5);
g.lineStyle(1.5, COLORS.muted, 0.5);
g.lineBetween(cx, cy - h / 2 + 3, cx, cy + h / 2 - 3);
g.fillStyle(COLORS.muted, 0.4);
g.fillCircle(cx - half / 2, cy, 3);
g.fillCircle(cx + half / 2, cy, 3);
}
showBoneyardClickable(on) {
if (!this._boneyardZone) return;
this._boneyardZoneActive = on;
if (on) {
this._boneyardZone.setInteractive({ useHandCursor: true });
for (const c of this._boneyardTiles) {
if (c.visible) {
this.tweens.add({ targets: c, scale: 1.1, duration: 320, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
}
} else {
this._boneyardZone.disableInteractive();
for (const c of this._boneyardTiles) {
this.tweens.killTweensOf(c);
c.setScale(1);
}
}
}
onBoneyardClick() {
if (!this._boneyardZoneActive) return;
this.showBoneyardClickable(false);
const hand = this.gs.players[0].hand;
const destX = CX + (hand.length * HAND_PITCH) / 2;
this.animateDrawFromPile(Math.min(destX, GAME_WIDTH - 150), HAND_Y).then(() => {
this.gs = drawTile(this.gs);
this.humanDrew = true;
this.promptHuman();
});
}
animateDrawFromPile(toX, toY) {
this.flashDrawToast();
playSound(this, SFX.CARD_DEAL);
return new Promise((resolve) => {
const g = this.add.graphics().setDepth(DEPTH.tileFx + 2);
this.paintFaceDownDomino(g, 0, 0, BONEYARD_HALF);
g.setPosition(BONEYARD_CX, BONEYARD_CY);
this.tweens.add({
targets: g,
x: toX,
y: toY,
duration: 420,
ease: 'Cubic.easeOut',
onComplete: () => { g.destroy(); resolve(); },
});
});
}
flashDrawToast() {
this.tweens.killTweensOf(this._drawToast);
this._drawToast.setAlpha(1).setY(BONEYARD_CY - 100);
this.tweens.add({
targets: this._drawToast,
alpha: 0,
y: BONEYARD_CY - 140,
duration: 900,
ease: 'Cubic.easeOut',
});
}
showToast(msg) {
const t = this.add.text(CX, 158, msg, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.toast);
this.tweens.add({ targets: t, alpha: { from: 1, to: 0 }, y: 132, duration: 1500, onComplete: () => t.destroy() });
}
// ─── Round over ───────────────────────────────────────────────────────
showRoundOverModal() {
this.refresh();
const n = this.gs.players.length;
const winnerName = this.gs.players[this.gs.roundWinner]?.name ?? '';
if (this.gs.roundWinner > 0) this.portraitCtrls[this.gs.roundWinner]?.controller?.playEmotion?.('happy');
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setInteractive().setDepth(DEPTH.modal);
const panelW = 720;
const panelH = 200 + n * 52;
const panel = this.add.rectangle(CX, GAME_HEIGHT / 2, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal);
const top = GAME_HEIGHT / 2 - panelH / 2;
const els = [overlay, panel];
els.push(this.add.text(CX, top + 46, `Round ${this.gs.round + 1}${winnerName} went out`, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.modal));
els.push(this.add.text(CX, top + 84, 'round pips / total', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.modal));
let rowY = top + 124;
for (let p = 0; p < n; p++) {
els.push(this.add.text(CX - panelW / 2 + 40, rowY, this.gs.players[p].name, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.modal));
els.push(this.add.text(CX + panelW / 2 - 40, rowY, `+${this.gs.lastRoundScores[p]} / ${this.gs.players[p].score}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(DEPTH.modal));
rowY += 48;
}
const btn = new Button(this, CX, GAME_HEIGHT / 2 + panelH / 2 - 44, 'Next Round', () => {
els.forEach((e) => e.destroy());
btn.destroy();
this.gs = startNextRound(this.gs);
this.refresh();
this.time.delayedCall(400, () => this.nextTurn());
}, { width: 260, fontSize: 24 }).setDepth(DEPTH.modal);
}
// ─── Game over ────────────────────────────────────────────────────────
showGameOverModal() {
if (this.gameOverShown) return;
this.gameOverShown = true;
this.refresh();
this.postHistory().catch(() => {});
const winners = new Set(getWinners(this.gs));
const humanWon = winners.has(0);
if (winners.size === 1 && winners.has(0)) playSound(this, SFX.CASINO_WIN);
else if (!humanWon) playSound(this, SFX.CASINO_LOSE);
for (let i = 1; i < this.gs.players.length; i++) {
this.portraitCtrls[i]?.controller?.playEmotion?.(winners.has(i) ? 'happy' : 'upset');
}
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.68)
.setInteractive().setDepth(DEPTH.modal);
const n = this.gs.players.length;
const panelW = 720;
const panelH = 230 + n * 52;
this.add.rectangle(CX, GAME_HEIGHT / 2, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal);
const top = GAME_HEIGHT / 2 - panelH / 2;
const heading = winners.size === 1 && humanWon ? 'You win!'
: humanWon ? 'Tied for the win' : `${this.gs.players[[...winners][0]].name} wins`;
this.add.text(CX, top + 50, heading, {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.modal);
this.add.text(CX, top + 92, 'Final totals (lowest wins)', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.modal);
const order = this.gs.players.map((p, i) => ({ i, p })).sort((a, b) => a.p.score - b.p.score);
let rowY = top + 134;
for (const { i, p } of order) {
const win = winners.has(i);
const color = win ? COLORS.goldHex : COLORS.textHex;
this.add.text(CX - panelW / 2 + 40, rowY, `${win ? '★ ' : ' '}${p.name}`, {
fontFamily: 'Righteous', fontSize: '24px', color,
}).setOrigin(0, 0.5).setDepth(DEPTH.modal);
this.add.text(CX + panelW / 2 - 40, rowY, String(p.score), {
fontFamily: 'Righteous', fontSize: '26px', color,
}).setOrigin(1, 0.5).setDepth(DEPTH.modal);
rowY += 48;
}
new Button(this, CX, GAME_HEIGHT / 2 + panelH / 2 - 46, 'Back to Menu',
() => this.scene.start('GameMenu'), { width: 280, fontSize: 24 }).setDepth(DEPTH.modal);
}
async postHistory() {
const totals = this.gs.players.map((p) => p.score);
const winners = new Set(getWinners(this.gs));
let result;
if (winners.has(0) && winners.size === 1) result = 'win';
else if (winners.has(0)) result = 'draw';
else result = 'loss';
await api.post('/history/single-player', {
slug: 'mexicantrain',
score: totals[0],
opponentScores: totals.slice(1),
result,
});
}
delay(ms) {
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
}
}

View File

@ -0,0 +1,227 @@
// Pure Mexican Train rules (double-six set). No Phaser dependency.
//
// Match structure:
// - Successive rounds. Each round pulls an "engine" double into the central
// hub: round 0 -> 6-6, round 1 -> 5-5, ... round 6 -> 0-0, then wraps.
// - Players play tiles onto trains radiating from the hub. Each player owns
// one personal train; there is also one communal "Mexican" train.
// - A round ends when a player empties their hand (scores 0 that round) or
// the round is blocked (boneyard empty + everyone passed in a row). Each
// remaining hand's pip total is added to that player's cumulative score.
// - The match ends when any player's cumulative score reaches the target.
// LOWEST cumulative score wins.
//
// Train access:
// - You may always play on your own train and the Mexican train.
// - You may play on another player's train only while it is "open" (marked,
// because that player couldn't play on their turn).
// - Playing on your own train removes your marker.
//
// Doubles (house rule: must be covered immediately):
// - Placing a double opens it; the next play (by anyone, in turn) must cover
// that double before any other play is allowed. The player who laid it
// keeps the turn to try to cover it themselves.
export const SET_MAX = 6; // double-six
export const MEXICAN = 'mexican';
export function makeTileSet() {
const tiles = [];
for (let a = 0; a <= SET_MAX; a++) {
for (let b = a; b <= SET_MAX; b++) tiles.push({ a, b });
}
return tiles; // 28 tiles
}
export const tilePips = (t) => t.a + t.b;
export const isDoubleTile = (t) => t.a === t.b;
export function handSizeFor(n) {
if (n === 2) return 7;
if (n === 3) return 6;
return 5; // 4 players
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Engine double value for a given round index.
export function hubValueForRound(roundIndex) {
return SET_MAX - (roundIndex % (SET_MAX + 1));
}
export function createInitialState({ playerNames, target = 100 }) {
const players = playerNames.map((p) => ({
name: p.name,
isAI: !!p.isAI,
avatar: p.avatar ?? null,
hand: [],
score: 0,
}));
const state = {
players,
current: 0,
round: 0,
target,
hub: { value: SET_MAX },
trains: {}, // key -> { tiles: [{left, right}], marker }
boneyard: [],
openDouble: null, // { train, value } when a double awaits covering
phase: 'playing', // playing -> roundover -> (next round) | gameover
consecutivePasses: 0,
startPlayer: 0,
roundWinner: null,
lastRoundScores: null,
};
dealRound(state, 0, 0);
return state;
}
// Mutates `s` to set up a fresh round. Used internally.
function dealRound(s, roundIndex, startPlayer) {
const n = s.players.length;
const hubVal = hubValueForRound(roundIndex);
s.hub = { value: hubVal };
const tiles = shuffle(makeTileSet().filter((t) => !(t.a === hubVal && t.b === hubVal)));
const hs = handSizeFor(n);
s.players.forEach((p) => { p.hand = tiles.splice(0, hs); });
s.boneyard = tiles;
s.trains = {};
for (let i = 0; i < n; i++) s.trains[String(i)] = { tiles: [], marker: false };
s.trains[MEXICAN] = { tiles: [], marker: false };
s.openDouble = null;
s.consecutivePasses = 0;
s.current = startPlayer ?? 0;
s.phase = 'playing';
s.roundWinner = null;
s.lastRoundScores = null;
}
export function startNextRound(state) {
const s = cloneState(state);
s.round += 1;
dealRound(s, s.round, s.startPlayer ?? 0);
return s;
}
// The currently exposed end value of a train (hub value if empty).
export function trainOpenEnd(state, key) {
const tr = state.trains[key];
if (!tr || tr.tiles.length === 0) return state.hub.value;
return tr.tiles[tr.tiles.length - 1].right;
}
// Train keys the given player may currently play on.
export function playableTrainKeys(state, idx) {
if (state.openDouble) return [state.openDouble.train];
const keys = [String(idx), MEXICAN];
for (let j = 0; j < state.players.length; j++) {
if (j !== idx && state.trains[String(j)]?.marker) keys.push(String(j));
}
return keys;
}
// All legal moves for a player: [{ tileIndex, train }].
export function getLegalMoves(state, idx) {
if (state.phase !== 'playing') return [];
const moves = [];
const hand = state.players[idx].hand;
for (const key of playableTrainKeys(state, idx)) {
const end = trainOpenEnd(state, key);
hand.forEach((t, i) => {
if (t.a === end || t.b === end) moves.push({ tileIndex: i, train: key });
});
}
return moves;
}
// Place a tile. Returns new state. Advances the turn unless the placed tile is
// a double (the player keeps the turn to cover it). Ends/score the round if the
// player goes out.
export function playTile(state, move) {
if (state.phase !== 'playing') return state;
const s = cloneState(state);
const player = s.players[s.current];
const tile = player.hand[move.tileIndex];
if (!tile) return state;
const key = move.train;
const end = trainOpenEnd(s, key);
if (tile.a !== end && tile.b !== end) return state; // illegal
const left = tile.a === end ? tile.a : tile.b;
const right = tile.a === end ? tile.b : tile.a;
s.trains[key].tiles.push({ left, right });
player.hand.splice(move.tileIndex, 1);
if (key === String(s.current)) s.trains[key].marker = false; // played own train
s.consecutivePasses = 0;
const placedDouble = left === right;
s.openDouble = placedDouble ? { train: key, value: right } : null;
if (player.hand.length === 0) {
endRound(s);
return s;
}
if (!placedDouble) advance(s);
return s;
}
// Draw one tile from the boneyard into the current player's hand.
export function drawTile(state) {
if (state.phase !== 'playing' || state.boneyard.length === 0) return state;
const s = cloneState(state);
s.players[s.current].hand.push(s.boneyard.pop());
return s;
}
export const canDraw = (state) => state.boneyard.length > 0;
// Current player gives up their turn: marks their own train and advances.
export function passTurn(state) {
if (state.phase !== 'playing') return state;
const s = cloneState(state);
s.trains[String(s.current)].marker = true;
s.consecutivePasses += 1;
advance(s);
if (s.boneyard.length === 0 && s.consecutivePasses >= s.players.length) {
endRound(s); // blocked
}
return s;
}
function advance(s) {
s.current = (s.current + 1) % s.players.length;
}
function endRound(s) {
const roundPts = s.players.map((p) => p.hand.reduce((a, t) => a + t.a + t.b, 0));
let winner = s.players.findIndex((p) => p.hand.length === 0);
if (winner === -1) winner = roundPts.indexOf(Math.min(...roundPts)); // blocked
s.players.forEach((p, i) => { p.score += roundPts[i]; });
s.lastRoundScores = roundPts;
s.roundWinner = winner;
s.startPlayer = winner;
s.phase = s.players.some((p) => p.score >= s.target) ? 'gameover' : 'roundover';
}
export const isMatchOver = (state) => state.phase === 'gameover';
// Match winners: indices tied for the lowest cumulative score.
export function getWinners(state) {
const min = Math.min(...state.players.map((p) => p.score));
return state.players.map((p, i) => ({ i, s: p.score })).filter((x) => x.s === min).map((x) => x.i);
}

View File

@ -257,6 +257,7 @@ export default class RouletteGame extends Phaser.Scene {
} }
animateSpin(index) { animateSpin(index) {
playSound(this, SFX.ROULETTE);
return new Promise((resolve) => { return new Promise((resolve) => {
const r = WHEEL.r; const r = WHEEL.r;
const winT = index * STEP; const winT = index * STEP;
@ -499,18 +500,22 @@ export default class RouletteGame extends Phaser.Scene {
const { value, color } = out; const { value, color } = out;
this.renderBets(); // bets cleared by resolveSpin → felt clears this.renderBets(); // bets cleared by resolveSpin → felt clears
for (let i = 0; i < this.gs.players.length; i++) { const entries = this.gs.players
const delta = this.gs.players[i].lastDelta; .map((p, i) => ({ i, delta: p.lastDelta }))
if (delta > 0) { this.animateChips(i, true, delta); this.portraits[i]?.playEmotion?.('happy'); } .filter(e => e.delta !== 0)
else if (delta < 0) { this.animateChips(i, false, -delta); this.portraits[i]?.playEmotion?.('upset'); } .sort((a, b) => SEAT_X[a.i] - SEAT_X[b.i]);
}
const hd = this.gs.players[0].lastDelta; entries.forEach(({ i, delta }, idx) => {
if (hd > 0) playSound(this, SFX.CASINO_WIN); this.time.delayedCall(idx * 420, () => {
else if (hd < 0) playSound(this, SFX.CASINO_LOSE); this.portraits[i]?.playEmotion?.(delta > 0 ? 'happy' : 'upset');
this.animatePlayerResult(i, delta);
});
this.animateChips(i, delta > 0, Math.abs(delta));
});
this.showResult(value, color); this.showResult(value, color);
this.highlightWinners(value, color, out.index); this.highlightWinners(value, color, out.index);
const hd = this.gs.players[0].lastDelta;
const tail = hd > 0 ? `You win $${hd}!` : hd < 0 ? `You lose $${-hd}.` : 'No win this time.'; 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.setStatus(`${this.displayNum(value)} ${color.toUpperCase()}${tail}`);
this.updateBalances(); this.updateBalances();
@ -564,6 +569,65 @@ export default class RouletteGame extends Phaser.Scene {
if (this.pocketGlow) { this.tweens.killTweensOf(this.pocketGlow); this.pocketGlow.clear(); } if (this.pocketGlow) { this.tweens.killTweensOf(this.pocketGlow); this.pocketGlow.clear(); }
} }
// ── Win / lose result badges ───────────────────────────────────────────────
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('RouletteGame')) 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(),
});
}
});
}
}
// ── Win/loss chip animation ──────────────────────────────────────────────── // ── Win/loss chip animation ────────────────────────────────────────────────
animateChips(playerIndex, toPlayer, amount) { animateChips(playerIndex, toPlayer, amount) {
const seatX = SEAT_X[playerIndex]; const seatX = SEAT_X[playerIndex];

View File

@ -22,6 +22,7 @@ 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'; import CrapsGame from './games/craps/CrapsGame.js';
import RouletteGame from './games/roulette/RouletteGame.js'; import RouletteGame from './games/roulette/RouletteGame.js';
import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -57,6 +58,7 @@ const config = {
UnoGame, UnoGame,
CrapsGame, CrapsGame,
RouletteGame, RouletteGame,
MexicanTrainGame,
], ],
}; };

View File

@ -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', craps: 'CrapsGame', roulette: 'RouletteGame' }; 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', mexicantrain: 'MexicanTrainGame' };
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,

View File

@ -49,6 +49,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3'); this.load.audio('sfx-dice-roll', '/assets/fx/dice-roll.mp3');
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3'); this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3'); this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
} }
async create() { async create() {

View File

@ -11,6 +11,7 @@ export const SFX = {
DICE_ROLL: 'sfx-dice-roll', DICE_ROLL: 'sfx-dice-roll',
PENCIL_WRITE: 'sfx-pencil-write', PENCIL_WRITE: 'sfx-pencil-write',
PIECE_CLICK: 'sfx-piece-click', PIECE_CLICK: 'sfx-piece-click',
ROULETTE: 'sfx-roulette',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {

View File

@ -35,3 +35,4 @@ registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: tru
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 }); 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 }); registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });