feat: enhance GoFish and Parchisi game UX with initial pair animations and pawn movement hints

- GoFish: Display animated banners when players are dealt starting pairs at the beginning of the game.
- Parchisi: Add pulsing glow and expanding ripple visual cues to indicate movable pawns.
- Parchisi: Implement click-outside dismissal for pawn selection highlights to improve interaction clarity.
This commit is contained in:
Brian Fertig 2026-05-20 18:57:58 -06:00
parent 9c3774fb1f
commit fcee81a9d6
4 changed files with 73 additions and 3 deletions

Binary file not shown.

View File

@ -356,10 +356,34 @@ export default class GoFishGame extends Phaser.Scene {
this.time.delayedCall(doneAt, () => { this.time.delayedCall(doneAt, () => {
this.scatterPool(finalState.pool); this.scatterPool(finalState.pool);
this.gs = finalState; this.gs = finalState;
this.animating = false;
this.renderAll(); this.renderAll();
this.updateStatus(); this.updateStatus();
this.maybeStartAITurn(); const initialPairs = (finalState.initialDealPairs ?? []).filter((e) => e.pairedCards.length >= 2);
if (initialPairs.length > 0) {
this.playInitialDealPairs(initialPairs, 0, () => {
this.animating = false;
this.maybeStartAITurn();
});
} else {
this.animating = false;
this.maybeStartAITurn();
}
});
}
playInitialDealPairs(pairs, idx, onComplete) {
if (idx >= pairs.length) { onComplete(); return; }
const { seat, pairedCards } = pairs[idx];
const name = this.opponentName(seat);
const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank;
const pairCount = pairedCards.length / 2;
const label = pairCount === 1
? `${name} dealt a starting pair of ${rank}s!`
: `${name} dealt ${pairCount} starting pairs!`;
this.showBanner(label);
this.animatePairedCards(seat, pairedCards, () => {
this.hideBanner();
this.time.delayedCall(250, () => this.playInitialDealPairs(pairs, idx + 1, onComplete));
}); });
} }

View File

@ -103,9 +103,13 @@ export function createInitialState({ playerCount = 4, seed } = {}) {
winnerSeats: [], winnerSeats: [],
seed: seed ?? null, seed: seed ?? null,
turnCount: 0, turnCount: 0,
initialDealPairs: [],
}; };
// Any starting pairs are scored immediately. // Any starting pairs are scored immediately.
for (const p of state.players) collectPairs(p, state); for (const p of state.players) {
const { count, pairedCards } = collectPairs(p, state);
if (count > 0) state.initialDealPairs.push({ seat: p.seat, pairedCards });
}
// Edge: if a player was dealt all duplicates and now has 0 cards, refill. // Edge: if a player was dealt all duplicates and now has 0 cards, refill.
for (const p of state.players) ensureHasCards(state, p.seat); for (const p of state.players) ensureHasCards(state, p.seat);
checkGameOver(state); checkGameOver(state);

View File

@ -164,6 +164,7 @@ export default class ParchisiGame extends Phaser.Scene {
this.buildTurnIndicator(); this.buildTurnIndicator();
this.buildPawns(); this.buildPawns();
this.initGame(); this.initGame();
this.buildDismissHandler();
} }
buildPlayfield() { buildPlayfield() {
@ -594,6 +595,20 @@ export default class ParchisiGame extends Phaser.Scene {
return this.add.container(x, y, [g]); return this.add.container(x, y, [g]);
} }
buildDismissHandler() {
this.input.on('pointerdown', (_ptr, gameObjects) => {
if (this.selectedPawnIdx === null || this.animating) return;
const hitRelevant = gameObjects.some((o) =>
this.highlightObjs.includes(o) ||
PCOLORS.some((c) => this.pawnObjs[c]?.includes(o))
);
if (!hitRelevant) {
this.clearHighlights();
this.showMovablePawnHints();
}
});
}
// ── Game flow ───────────────────────────────────────────────────────────── // ── Game flow ─────────────────────────────────────────────────────────────
initGame() { initGame() {
this.clearHighlights(); this.clearHighlights();
@ -695,6 +710,7 @@ export default class ParchisiGame extends Phaser.Scene {
this.time.delayedCall(600, () => this.runAITurnMoves()); this.time.delayedCall(600, () => this.runAITurnMoves());
} else { } else {
this.setStatus('Choose a pawn to move'); this.setStatus('Choose a pawn to move');
this.showMovablePawnHints();
} }
}); });
} }
@ -794,6 +810,7 @@ export default class ParchisiGame extends Phaser.Scene {
} }
this.setStatus('Choose a pawn'); this.setStatus('Choose a pawn');
this.updateButtons(); this.updateButtons();
this.showMovablePawnHints();
return; return;
} }
if (this.gs.phase === 'roll') { if (this.gs.phase === 'roll') {
@ -962,6 +979,31 @@ export default class ParchisiGame extends Phaser.Scene {
this.selectedPawnIdx = null; this.selectedPawnIdx = null;
} }
showMovablePawnHints() {
const moves = getValidMoves(this.gs);
const movable = [...new Set(moves.map((m) => m.pawnIdx))];
for (const pawnIdx of movable) {
const obj = this.pawnObjs['red'][pawnIdx];
const px = obj.x;
const py = obj.y;
// Static glow ring — pulses alpha
const glow = this.add.graphics().setDepth(DEPTH.highlight);
glow.lineStyle(2.5, 0x00e5b0, 0.9);
glow.strokeCircle(px, py, PAWN_R + 6);
this.tweens.add({ targets: glow, alpha: { from: 0.9, to: 0.25 }, duration: 550, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
// Expanding ripple ring
const ripple = this.add.graphics().setDepth(DEPTH.highlight);
ripple.setPosition(px, py);
ripple.lineStyle(2, 0x00e5b0, 0.75);
ripple.strokeCircle(0, 0, PAWN_R + 6);
this.tweens.add({ targets: ripple, scaleX: 2.0, scaleY: 2.0, alpha: 0, duration: 950, repeat: -1, ease: 'Cubic.easeOut' });
this.highlightObjs.push(glow, ripple);
}
}
// ── UI updates ──────────────────────────────────────────────────────────── // ── UI updates ────────────────────────────────────────────────────────────
updateButtons() { updateButtons() {
const canRoll = !this.animating const canRoll = !this.animating