Compare commits
2 Commits
f8d406ab4e
...
7602da4cbe
| Author | SHA1 | Date |
|---|---|---|
|
|
7602da4cbe | |
|
|
f1fc560cd1 |
Binary file not shown.
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue