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:
parent
6fb924eb23
commit
7858263c22
|
|
@ -33,6 +33,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
this.sel = null;
|
this.sel = null;
|
||||||
this.legEnded = false;
|
this.legEnded = false;
|
||||||
this.overlayUp = false;
|
this.overlayUp = false;
|
||||||
|
this.animating = false;
|
||||||
this.overlayObjs = [];
|
this.overlayObjs = [];
|
||||||
this.pulse = null;
|
this.pulse = null;
|
||||||
|
|
||||||
|
|
@ -102,9 +103,53 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
this.tsDrawMode = false;
|
this.tsDrawMode = false;
|
||||||
playSound(this, SFX.CARD_SHUFFLE);
|
playSound(this, SFX.CARD_SHUFFLE);
|
||||||
this.refresh();
|
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) {
|
applyMove(ok, sfx = SFX.CARD_PLACE) {
|
||||||
if (ok) { playSound(this, sfx); this.refresh(); }
|
if (ok) { playSound(this, sfx); this.refresh(); }
|
||||||
|
|
@ -115,6 +160,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
this.renderBoard();
|
this.renderBoard();
|
||||||
this.updateHud();
|
this.updateHud();
|
||||||
if (!this.legEnded && this.engine.isWon()) { this.endLeg(); return; }
|
if (!this.legEnded && this.engine.isWon()) { this.endLeg(); return; }
|
||||||
|
if (this.kAutoReady()) { this.kAutoFinish(); return; }
|
||||||
this.updateStuck();
|
this.updateStuck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,6 +364,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
card(cardObj, x, y, opts = {}) {
|
card(cardObj, x, y, opts = {}) {
|
||||||
const { faceUp = true, selected = false, hint = false, dim = false, onClick = null, onDown = null } = 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);
|
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);
|
this.drawFace(c, cardObj, faceUp, selected, hint);
|
||||||
if (dim) c.setAlpha(0.4);
|
if (dim) c.setAlpha(0.4);
|
||||||
if (onClick || onDown) {
|
if (onClick || onDown) {
|
||||||
|
|
@ -377,7 +424,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
const top = e.foundationTop();
|
const top = e.foundationTop();
|
||||||
if (top) this.card(top, 880, 800, {});
|
if (top) this.card(top, 880, 800, {});
|
||||||
if (e.stock.length) {
|
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));
|
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 {
|
} else {
|
||||||
this.slot(1040, 800, 'empty');
|
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); }
|
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 ----------------------------------------------------
|
// Klondike / Three Shuffles ----------------------------------------------------
|
||||||
renderKlondike() {
|
renderKlondike() {
|
||||||
const e = this.engine;
|
const e = this.engine;
|
||||||
|
|
@ -445,8 +499,12 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
kStock() {
|
kStock() {
|
||||||
if (!this.interactive()) return;
|
if (!this.interactive()) return;
|
||||||
this.sel = null;
|
this.sel = null;
|
||||||
const recycle = this.engine.stock.length === 0;
|
const e = this.engine;
|
||||||
this.applyMove(this.engine.dealStock(), recycle ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
|
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() {
|
kWaste() {
|
||||||
|
|
@ -496,6 +554,85 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
||||||
if (ok) this.applyMove(true); else this.refresh();
|
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) ─────────────────────────
|
// ── Generic drag-and-drop (Klondike + Three Shuffles) ─────────────────────────
|
||||||
// Pointer-down on a card records a potential drag; moving past a small
|
// 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
|
// 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() {
|
pStock() {
|
||||||
if (!this.interactive()) return;
|
if (!this.interactive()) return;
|
||||||
this.sel = null;
|
this.sel = null;
|
||||||
const recycle = this.engine.stock.length === 0;
|
const e = this.engine;
|
||||||
this.applyMove(this.engine.dealStock(), recycle ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
|
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) {
|
pPick(loc) {
|
||||||
|
|
|
||||||
|
|
@ -194,18 +194,17 @@ export default class SudokuGame extends Phaser.Scene {
|
||||||
g.lineStyle(2, PAPER_EDGE, 1);
|
g.lineStyle(2, PAPER_EDGE, 1);
|
||||||
g.strokeRoundedRect(PX, PY, PW, PH, 16);
|
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 spiralG = this.add.graphics().setDepth(DEPTH.paper + 1);
|
||||||
const spiralCnt = 32;
|
const spiralCnt = 32;
|
||||||
const step = PW / (spiralCnt + 1);
|
const step = PW / (spiralCnt + 1);
|
||||||
for (let i = 1; i <= spiralCnt; i++) {
|
for (let i = 1; i <= spiralCnt; i++) {
|
||||||
const sx = PX + step * i;
|
const sx = PX + step * i;
|
||||||
for (const sy of [PY + 10, PY + PH - 10]) {
|
const sy = PY + 40;
|
||||||
spiralG.fillStyle(SPIRAL_CLR, 0.75);
|
spiralG.fillStyle(SPIRAL_CLR, 0.75);
|
||||||
spiralG.fillCircle(sx, sy, 18);
|
spiralG.fillCircle(sx, sy, 18);
|
||||||
spiralG.fillStyle(PAPER, 1);
|
spiralG.fillStyle(0x000000, 1);
|
||||||
spiralG.fillCircle(sx, sy, 12);
|
spiralG.fillCircle(sx, sy, 12);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,13 +223,14 @@ export default class SudokuGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTitle() {
|
buildTitle() {
|
||||||
this.add.text(TITLE_CX, 118, 'Sudoku', {
|
const titleTxt = this.add.text(TITLE_CX, 132, 'Sudoku', {
|
||||||
fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BROWN,
|
fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BROWN,
|
||||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
}).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,
|
fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED,
|
||||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Grid ──────────────────────────────────────────────────────────────────────
|
// ── Grid ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue