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)); } }