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_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,
@ -91,6 +106,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
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,
@ -104,19 +120,23 @@ export default class MexicanTrainGame extends Phaser.Scene {
buildHeader() {
this.add.text(CX, 44, 'Mexican Train', {
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, '', {
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, '', {
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, '', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
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) {
@ -131,6 +151,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
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);
@ -146,8 +167,11 @@ export default class MexicanTrainGame extends Phaser.Scene {
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);
}).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
@ -155,7 +179,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
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);
}).setOrigin(0, 0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5);
this.labelTexts[MEXICAN] = mlabel;
}
@ -188,7 +213,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
this.roundText.setText(
`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() {
@ -220,7 +245,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
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);
}).setOrigin(0.5).setDepth(DEPTH.hub)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 3);
} else {
this._hubLabel.setY(hubY - 58);
}
@ -259,7 +285,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
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);
}).setOrigin(0, 0.5).setDepth(DEPTH.tileFx)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(5, 2);
}
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[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() {
@ -395,6 +454,7 @@ export default class MexicanTrainGame extends Phaser.Scene {
// ─── Turn flow ────────────────────────────────────────────────────────
nextTurn() {
this.showBoneyardClickable(false);
if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; }
if (this.gs.phase === 'roundover') { this.showRoundOverModal(); return; }
this.refresh();
@ -418,13 +478,8 @@ export default class MexicanTrainGame extends Phaser.Scene {
// No legal play — draw one (once), else pass.
this.inputLocked = true;
if (!this.humanDrew && canDraw(this.gs)) {
this.statusText.setText('No play — drawing a tile…');
this.time.delayedCall(650, async () => {
await this.animateDraw();
this.gs = drawTile(this.gs);
this.humanDrew = true;
this.promptHuman();
});
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, () => {
@ -533,9 +588,11 @@ export default class MexicanTrainGame extends Phaser.Scene {
break;
}
if (!drew && canDraw(this.gs)) {
await this.delay(420);
await this.animateDraw();
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;
}
@ -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);
this.boneText.setScale(1.25);
this.tweens.add({ targets: this.boneText, scale: 1, duration: 300 });
await this.delay(180);
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) {