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:
parent
f1fc560cd1
commit
7602da4cbe
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue