feat(solitaire): add card deal/flip animations and auto-finish for Klondike

- Animate initial card deal with staggered fly-in effect and block input during playback
- Replace instant card placement with flip-and-fly animation for stock draws across variants
- Add automatic foundation completion when Klondike is effectively won (stock empty, all face-up, foundation-only path exists)
- Adjust Sudoku notebook spiral binding to top-only and refine title positioning
This commit is contained in:
Brian Fertig 2026-06-02 11:32:13 -06:00
parent 6fb924eb23
commit 7858263c22
2 changed files with 156 additions and 16 deletions

View File

@ -33,6 +33,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
this.sel = null;
this.legEnded = false;
this.overlayUp = false;
this.animating = false;
this.overlayObjs = [];
this.pulse = null;
@ -102,9 +103,53 @@ export default class SolitaireTourGame extends Phaser.Scene {
this.tsDrawMode = false;
playSound(this, SFX.CARD_SHUFFLE);
this.refresh();
this.dealAnimation();
}
interactive() { return !this.legEnded && !this.overlayUp; }
// Fling the freshly-rendered cards from an off-screen deck into their dealt
// positions with a quick stagger. Runs on the real board sprites, so it needs
// no per-game layout math; input stays blocked (via `animating`) until it ends.
dealAnimation() {
const cards = this.board.list.filter((o) => o.isCard);
if (!cards.length) return;
const deckX = GAME_WIDTH / 2, deckY = GAME_HEIGHT + 90;
const homes = cards.map((c) => ({ c, x: c.x, y: c.y, alpha: c.alpha }));
this.animating = true;
for (const { c } of homes) { c.setPosition(deckX, deckY).setScale(0.7).setAlpha(0); }
homes.forEach(({ c, x, y, alpha }, i) => {
const last = i === homes.length - 1;
this.tweens.add({
targets: c, x, y, scaleX: 1, scaleY: 1, alpha,
delay: i * 12, duration: 200, ease: 'Quad.easeOut',
onComplete: last ? () => { this.animating = false; this.updateStuck(); } : undefined,
});
});
}
interactive() { return !this.legEnded && !this.overlayUp && !this.animating; }
// Animate a face-down stock card flipping face-up as it flies to its
// destination, then refresh the board with the post-move state. The engine
// move must already be applied; input is blocked until the flip lands.
animateDraw(card, fromX, fromY, toX, toY, sfx = SFX.CARD_SHOW) {
this.animating = true;
const fly = this.add.container(fromX, fromY).setDepth(D.banner);
this.drawFace(fly, {}, false, false, false);
const half = 170;
this.tweens.add({ targets: fly, x: toX, y: toY, duration: half * 2, ease: 'Quad.easeInOut' });
this.tweens.add({
targets: fly, scaleX: 0, duration: half, ease: 'Sine.easeIn',
onComplete: () => {
fly.removeAll(true); // edge-on: swap the back for the face
this.drawFace(fly, card, true, false, false);
playSound(this, sfx);
this.tweens.add({
targets: fly, scaleX: 1, duration: half, ease: 'Sine.easeOut',
onComplete: () => { fly.destroy(); this.animating = false; this.refresh(); },
});
},
});
}
applyMove(ok, sfx = SFX.CARD_PLACE) {
if (ok) { playSound(this, sfx); this.refresh(); }
@ -115,6 +160,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
this.renderBoard();
this.updateHud();
if (!this.legEnded && this.engine.isWon()) { this.endLeg(); return; }
if (this.kAutoReady()) { this.kAutoFinish(); return; }
this.updateStuck();
}
@ -318,6 +364,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
card(cardObj, x, y, opts = {}) {
const { faceUp = true, selected = false, hint = false, dim = false, onClick = null, onDown = null } = opts;
const c = this.add.container(x, selected ? y - 10 : y);
c.isCard = true; // marks it for the start-of-leg deal animation
this.drawFace(c, cardObj, faceUp, selected, hint);
if (dim) c.setAlpha(0.4);
if (onClick || onDown) {
@ -377,7 +424,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
const top = e.foundationTop();
if (top) this.card(top, 880, 800, {});
if (e.stock.length) {
this.card({}, 1040, 800, { faceUp: false, onClick: () => this.gAct(() => e.dealStock(), SFX.CARD_SHOW) });
this.card({}, 1040, 800, { faceUp: false, onClick: () => this.gStock() });
this.board.add(this.add.text(1040, 884, `Stock ×${e.stock.length}`, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0.5));
} else {
this.slot(1040, 800, 'empty');
@ -387,6 +434,13 @@ export default class SolitaireTourGame extends Phaser.Scene {
gAct(fn, sfx = SFX.CARD_PLACE) { if (!this.interactive()) return; this.applyMove(fn(), sfx); }
gStock() {
if (!this.interactive() || !this.engine.stock.length) return;
const card = this.engine.stock[this.engine.stock.length - 1];
this.engine.dealStock();
this.animateDraw(card, 1040, 800, 880, 800); // stock → foundation
}
// Klondike / Three Shuffles ----------------------------------------------------
renderKlondike() {
const e = this.engine;
@ -445,8 +499,12 @@ export default class SolitaireTourGame extends Phaser.Scene {
kStock() {
if (!this.interactive()) return;
this.sel = null;
const recycle = this.engine.stock.length === 0;
this.applyMove(this.engine.dealStock(), recycle ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
const e = this.engine;
if (!e.stock.length) { this.applyMove(e.dealStock(), SFX.CARD_SHUFFLE); return; } // recycle
const card = e.stock[e.stock.length - 1];
e.dealStock();
const shown = Math.min(e.waste.length, 3); // waste fans the last three
this.animateDraw(card, 600, 250, 735 + (shown - 1) * 26, 250);
}
kWaste() {
@ -496,6 +554,85 @@ export default class SolitaireTourGame extends Phaser.Scene {
if (ok) this.applyMove(true); else this.refresh();
}
// ── Klondike auto-finish ──────────────────────────────────────────────────────
// Once the stock is spent and every tableau card is face-up, the leg is often a
// foregone win — all that's left is sending cards to the foundations. We detect
// that exact state and fly the cards home automatically. The face-up requirement
// alone isn't enough (a low card can sit buried under a higher one of its suit
// and stall a foundation-only finish), so we first simulate the run and only
// take over when playing to foundations actually clears the board.
kAutoReady() {
const e = this.engine;
if (this.legType !== 'klondike' || this.legEnded || this.animating) return false;
if (e.stock.length || e.isWon()) return false;
if (!e.tableau.every((pile) => pile.every((entry) => entry.faceUp))) return false;
return this.kFoundationOnlyWins();
}
// Dry-run of foundation-only play over copies of the piles/waste tops.
kFoundationOnlyWins() {
const e = this.engine;
const cols = e.tableau.map((pile) => pile.map((entry) => entry.card));
const waste = e.waste.slice();
const need = {};
for (const s of ['s', 'h', 'd', 'c']) need[s] = (e.foundationTop(s)?.pval ?? 0) + 1;
let remaining = waste.length + cols.reduce((t, c) => t + c.length, 0);
let moved = true;
while (remaining > 0 && moved) {
moved = false;
const wt = waste[waste.length - 1];
if (wt && wt.pval === need[wt.suit]) { waste.pop(); need[wt.suit]++; remaining--; moved = true; }
for (const col of cols) {
const top = col[col.length - 1];
if (top && top.pval === need[top.suit]) { col.pop(); need[top.suit]++; remaining--; moved = true; }
}
}
return remaining === 0;
}
kAutoFinish() {
if (this.animating) return;
this.animating = true;
this.sel = null;
this.stuckBanner.setVisible(false);
if (this.pulse) { this.pulse.stop(); this.pulse = null; this.noMoreBtn.setScale(1); }
this.kAutoStep();
}
kAutoStep() {
const e = this.engine;
const suits = ['s', 'h', 'd', 'c'];
// Pick the next card that can go home: waste top first, then any column top.
let next = null;
const wt = e.waste[e.waste.length - 1];
if (e.canPlayFoundation(wt)) next = { card: wt, apply: () => e.playWasteToFoundation() };
else for (let c = 0; c < 7; c++) {
const pile = e.tableau[c];
const top = pile[pile.length - 1];
if (top && top.faceUp && e.canPlayFoundation(top.card)) { next = { card: top.card, apply: () => e.playColumnToFoundation(c) }; break; }
}
if (!next) { this.animating = false; this.refresh(); return; }
const src = this.kSprites.get(next.card.id);
const fromX = src ? src.x : 735, fromY = src ? src.y : 250;
if (src) src.setVisible(false); // hand the card off to the flyer
const toX = 1090 + suits.indexOf(next.card.suit) * COL_GAP, toY = 250;
next.apply();
const fly = this.add.container(fromX, fromY).setDepth(D.banner);
this.drawFace(fly, next.card, true, false, false);
this.tweens.add({
targets: fly, x: toX, y: toY, duration: 120, ease: 'Quad.easeIn',
onComplete: () => {
fly.destroy();
playSound(this, SFX.CARD_PLACE);
this.renderBoard();
this.updateHud();
this.kAutoStep();
},
});
}
// ── Generic drag-and-drop (Klondike + Three Shuffles) ─────────────────────────
// Pointer-down on a card records a potential drag; moving past a small
// threshold promotes it to a real drag, and a release without movement falls
@ -735,8 +872,11 @@ export default class SolitaireTourGame extends Phaser.Scene {
pStock() {
if (!this.interactive()) return;
this.sel = null;
const recycle = this.engine.stock.length === 0;
this.applyMove(this.engine.dealStock(), recycle ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
const e = this.engine;
if (!e.stock.length) { this.applyMove(e.dealStock(), SFX.CARD_SHUFFLE); return; } // recycle
const card = e.stock[e.stock.length - 1];
e.dealStock();
this.animateDraw(card, 800, 840, 960, 840); // stock → waste
}
pPick(loc) {

View File

@ -194,18 +194,17 @@ export default class SudokuGame extends Phaser.Scene {
g.lineStyle(2, PAPER_EDGE, 1);
g.strokeRoundedRect(PX, PY, PW, PH, 16);
// Spiral binding — top and bottom
// Spiral binding — top only
const spiralG = this.add.graphics().setDepth(DEPTH.paper + 1);
const spiralCnt = 32;
const step = PW / (spiralCnt + 1);
for (let i = 1; i <= spiralCnt; i++) {
const sx = PX + step * i;
for (const sy of [PY + 10, PY + PH - 10]) {
spiralG.fillStyle(SPIRAL_CLR, 0.75);
spiralG.fillCircle(sx, sy, 18);
spiralG.fillStyle(PAPER, 1);
spiralG.fillCircle(sx, sy, 12);
}
const sy = PY + 40;
spiralG.fillStyle(SPIRAL_CLR, 0.75);
spiralG.fillCircle(sx, sy, 18);
spiralG.fillStyle(0x000000, 1);
spiralG.fillCircle(sx, sy, 12);
}
}
@ -224,13 +223,14 @@ export default class SudokuGame extends Phaser.Scene {
}
buildTitle() {
this.add.text(TITLE_CX, 118, 'Sudoku', {
const titleTxt = this.add.text(TITLE_CX, 132, 'Sudoku', {
fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BROWN,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.add.text(TITLE_CX, 155, DIFF_LABELS[this.difficulty] ?? this.difficulty, {
this.add.text(titleTxt.x + titleTxt.width / 2 + 24, titleTxt.y + 14,
DIFF_LABELS[this.difficulty] ?? this.difficulty, {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
}
// ── Grid ──────────────────────────────────────────────────────────────────────