feat: improve Mexican Train UI with boneyard interaction and readability

- Add interactive boneyard pile with click-to-draw functionality and animations
- Display opponent hand counts as mini-domino dots next to portraits
- Apply background padding to all UI text elements for improved readability
- Refactor draw animations to support both human and AI tile drawing from the boneyard
This commit is contained in:
Brian Fertig 2026-05-21 20:09:15 -06:00
parent f1fc560cd1
commit 7602da4cbe
1 changed files with 183 additions and 24 deletions

View File

@ -31,6 +31,21 @@ const HAND_Y = 942;
const HAND_HALF = 50; // square half-cell for hand tiles const HAND_HALF = 50; // square half-cell for hand tiles
const HAND_PITCH = HAND_HALF + 14; 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 = { const DEPTH = {
bg: -1, band: 0, spine: 1, tile: 2, tileFx: 5, bg: -1, band: 0, spine: 1, tile: 2, tileFx: 5,
hub: 6, portrait: 10, ui: 20, hand: 22, toast: 50, modal: 60, hub: 6, portrait: 10, ui: 20, hand: 22, toast: 50, modal: 60,
@ -91,6 +106,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
this.buildHeader(); this.buildHeader();
this.buildPortraitsAndLabels(); this.buildPortraitsAndLabels();
this.buildRowZones(); this.buildRowZones();
this.buildBoneyard();
new Button(this, 86, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), { new Button(this, 86, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 150, fontSize: 20, variant: 'ghost', width: 150, fontSize: 20,
@ -104,19 +120,23 @@ export default class MexicanTrainGame extends Phaser.Scene {
buildHeader() { buildHeader() {
this.add.text(CX, 44, 'Mexican Train', { this.add.text(CX, 44, 'Mexican Train', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui); }).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(14, 7);
this.roundText = this.add.text(CX, 86, '', { this.roundText = this.add.text(CX, 86, '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui); }).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(12, 4);
this.statusText = this.add.text(CX, 118, '', { this.statusText = this.add.text(CX, 118, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex, fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui); }).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(12, 5);
this.boneText = this.add.text(HUB_X, TRACKS_BOTTOM + 18, '', { this.boneText = this.add.text(BONEYARD_CX, BONEYARD_CY + 76, '', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui); }).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4);
} }
rowY(i) { rowY(i) {
@ -131,6 +151,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
buildPortraitsAndLabels() { buildPortraitsAndLabels() {
const n = this.gs.players.length; const n = this.gs.players.length;
this._handDotGfx = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const y = this.rowY(i); const y = this.rowY(i);
const ring = this.add.graphics().setDepth(DEPTH.portrait); const ring = this.add.graphics().setDepth(DEPTH.portrait);
@ -146,8 +167,11 @@ export default class MexicanTrainGame extends Phaser.Scene {
const label = this.add.text(LABEL_X, y, '', { const label = this.add.text(LABEL_X, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
align: 'left', lineSpacing: 2, align: 'left', lineSpacing: 2,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui); }).setOrigin(0, 0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5);
this.labelTexts[String(i)] = label; this.labelTexts[String(i)] = label;
this._handDotGfx.push(this.add.graphics().setDepth(DEPTH.ui));
} }
// Mexican train row label // Mexican train row label
@ -155,7 +179,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
const mlabel = this.add.text(LABEL_X - 60, my, '', { const mlabel = this.add.text(LABEL_X - 60, my, '', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.goldHex, fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.goldHex,
align: 'left', align: 'left',
}).setOrigin(0, 0.5).setDepth(DEPTH.ui); }).setOrigin(0, 0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5);
this.labelTexts[MEXICAN] = mlabel; this.labelTexts[MEXICAN] = mlabel;
} }
@ -188,7 +213,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
this.roundText.setText( this.roundText.setText(
`Round ${this.gs.round + 1} • Engine ${hub}-${hub} • First to ${this.gs.target} ends it — lowest score wins`, `Round ${this.gs.round + 1} • Engine ${hub}-${hub} • First to ${this.gs.target} ends it — lowest score wins`,
); );
this.boneText.setText(`Boneyard: ${this.gs.boneyard.length}`); this.updateBoneyard();
} }
paintBands() { paintBands() {
@ -220,7 +245,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
if (!this._hubLabel) { if (!this._hubLabel) {
this._hubLabel = this.add.text(HUB_X, hubY - 58, 'ENGINE', { this._hubLabel = this.add.text(HUB_X, hubY - 58, 'ENGINE', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.goldHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.hub); }).setOrigin(0.5).setDepth(DEPTH.hub)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 3);
} else { } else {
this._hubLabel.setY(hubY - 58); this._hubLabel.setY(hubY - 58);
} }
@ -259,7 +285,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
if (!this._truncMarks[i]) { if (!this._truncMarks[i]) {
this._truncMarks[i] = this.add.text(TRACKS_LEFT - 2, y, '', { this._truncMarks[i] = this.add.text(TRACKS_LEFT - 2, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.tileFx); }).setOrigin(0, 0.5).setDepth(DEPTH.tileFx)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(5, 2);
} }
this._truncMarks[i].setText(`+${hidden}«`).setVisible(true); this._truncMarks[i].setText(`+${hidden}«`).setVisible(true);
} }
@ -347,6 +374,38 @@ export default class MexicanTrainGame extends Phaser.Scene {
this.labelTexts[String(i)].setColor(marked ? COLORS.dangerHex : COLORS.textHex); this.labelTexts[String(i)].setColor(marked ? COLORS.dangerHex : COLORS.textHex);
} }
this.labelTexts[MEXICAN].setText('Mexican Train'); 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() { updateStatus() {
@ -395,6 +454,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
// ─── Turn flow ──────────────────────────────────────────────────────── // ─── Turn flow ────────────────────────────────────────────────────────
nextTurn() { nextTurn() {
this.showBoneyardClickable(false);
if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; } if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; }
if (this.gs.phase === 'roundover') { this.showRoundOverModal(); return; } if (this.gs.phase === 'roundover') { this.showRoundOverModal(); return; }
this.refresh(); this.refresh();
@ -418,13 +478,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
// No legal play — draw one (once), else pass. // No legal play — draw one (once), else pass.
this.inputLocked = true; this.inputLocked = true;
if (!this.humanDrew && canDraw(this.gs)) { if (!this.humanDrew && canDraw(this.gs)) {
this.statusText.setText('No play — drawing a tile…'); this.statusText.setText('No tiles to play — click the boneyard to draw');
this.time.delayedCall(650, async () => { this.showBoneyardClickable(true);
await this.animateDraw();
this.gs = drawTile(this.gs);
this.humanDrew = true;
this.promptHuman();
});
} else { } else {
this.statusText.setText('No play — passing'); this.statusText.setText('No play — passing');
this.time.delayedCall(750, () => { this.time.delayedCall(750, () => {
@ -533,9 +588,11 @@ export default class MexicanTrainGame extends Phaser.Scene {
break; break;
} }
if (!drew && canDraw(this.gs)) { if (!drew && canDraw(this.gs)) {
await this.delay(420); await this.delay(300);
await this.animateDraw(); 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.gs = drawTile(this.gs);
this.refresh();
drew = true; drew = true;
continue; continue;
} }
@ -586,11 +643,113 @@ export default class MexicanTrainGame extends Phaser.Scene {
}); });
} }
async animateDraw() { // ─── 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); playSound(this, SFX.CARD_DEAL);
this.boneText.setScale(1.25); return new Promise((resolve) => {
this.tweens.add({ targets: this.boneText, scale: 1, duration: 300 }); const g = this.add.graphics().setDepth(DEPTH.tileFx + 2);
await this.delay(180); 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) { showToast(msg) {