fertig-classic-games/public/src/games/mexicantrain/MexicanTrainGame.js

949 lines
36 KiB
JavaScript

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.railGfx = this.add.graphics().setDepth(DEPTH.spine);
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.paintRails();
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();
this.updateScoreBadges();
}
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(0x0c1a10, active ? 0.55 : 0.92);
g.fillRoundedRect(TRACKS_LEFT - 8, y - gap / 2 + 4, TRACKS_RIGHT - TRACKS_LEFT + 16, gap - 8, 10);
}
}
paintRails() {
const g = this.railGfx;
g.clear();
const rows = this.gs.players.length + 1;
const railOff = 11; // px from lane center to each rail
const tieHalf = 15; // tie extends 4px beyond each rail
const tieStep = 22; // horizontal spacing between ties
for (let i = 0; i < rows; i++) {
const y = this.rowY(i);
// Ties (wood) — drawn first so rails appear on top
g.lineStyle(4, 0x4a3018, 0.55);
for (let x = TRACKS_LEFT; x <= TRACKS_RIGHT; x += tieStep) {
g.lineBetween(x, y - tieHalf, x, y + tieHalf);
}
// Rails (metal)
g.lineStyle(2.5, 0x908060, 0.7);
g.lineBetween(TRACKS_LEFT, y - railOff, TRACKS_RIGHT, y - railOff);
g.lineBetween(TRACKS_LEFT, y + railOff, TRACKS_RIGHT, y + railOff);
}
}
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 isDouble = tile.left === tile.right;
const isOpenDouble = this.gs.openDouble?.train === key && t === tiles.length - 1;
const border = isOpenDouble ? COLORS.danger : (isDouble ? COLORS.gold : COLORS.accent);
this.paintDomino(g, drawX, y, TRACK_HALF, tile.left, tile.right, isDouble, 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();
}
updateScoreBadges() {
const scores = this.gs.players.map(p => p.score);
if (scores.every(s => s === 0)) {
this._scoreBadgeCtrs?.forEach(c => c.destroy());
this._scoreBadgeCtrs = [];
return;
}
const changed = !this._lastBadgeScores || scores.some((s, i) => s !== this._lastBadgeScores[i]);
if (!changed) return;
this._lastBadgeScores = [...scores];
this._scoreBadgeCtrs?.forEach(c => c.destroy());
this._scoreBadgeCtrs = [];
for (let i = 0; i < this.gs.players.length; i++) {
const score = scores[i];
const x = PORTRAIT_X;
const y = this.rowY(i) - PORTRAIT_R - 20;
const borderColor = score > 75 ? COLORS.danger
: score > 50 ? 0xe07030
: score > 25 ? COLORS.accent
: COLORS.gold;
const c = this.add.container(x, y).setDepth(DEPTH.portrait + 2).setScale(0.1);
const g = this.add.graphics();
g.fillStyle(0x1e1a30, 1);
g.fillCircle(0, 0, 22);
g.lineStyle(2.5, borderColor, 1);
g.strokeCircle(0, 0, 22);
g.lineStyle(1.5, 0xffffff, 0.25);
g.strokeCircle(0, 0, 17);
const fontSize = score >= 100 ? '11px' : '14px';
const t = this.add.text(0, 0, String(score), {
fontFamily: '"Julius Sans One"', fontSize, color: COLORS.textHex, fontStyle: 'bold',
}).setOrigin(0.5);
c.add([g, t]);
this._scoreBadgeCtrs.push(c);
this.tweens.add({ targets: c, scale: 1, duration: 260, ease: 'Back.Out' });
}
}
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 lastTile = tiles[tiles.length - 1];
const isLastDouble = lastTile && lastTile.left === lastTile.right;
const fw = isLastDouble ? TRACK_HALF : TRACK_TILE_W;
const fh = isLastDouble ? TRACK_TILE_W : TRACK_HALF;
const fx = this.add.graphics().setDepth(DEPTH.tileFx);
fx.lineStyle(4, COLORS.gold, 1);
fx.strokeRoundedRect(x - fw / 2 - 3, y - fh / 2 - 3, fw + 6, fh + 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 isDouble = tileA === tileB;
const g = this.add.graphics().setDepth(DEPTH.tileFx + 2);
this.paintDomino(g, 0, 0, TRACK_HALF, tileA, tileB, isDouble, 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));
}
}