feat: refactor Solitaire Tour drag-and-drop and implement Three Shuffles variant

- Extract generic drag-and-drop logic to support both Klondike and new Three Shuffles leg
- Implement "Three Shuffles and a Draw" game rules: build tableau down in suit, shuffle up to 2x, then draw one buried card
- Update Three Shuffles engine to handle pile-based tableau and foundation clearing
- Add UI controls for shuffle and draw actions in the new leg
This commit is contained in:
Brian Fertig 2026-06-02 00:14:40 -06:00
parent efb8842368
commit 6fb924eb23
2 changed files with 295 additions and 109 deletions

View File

@ -38,9 +38,11 @@ export default class SolitaireTourGame extends Phaser.Scene {
// Klondike / Three Shuffles drag-and-drop state. // Klondike / Three Shuffles drag-and-drop state.
this.kSprites = new Map(); // card.id → container (for the current render) this.kSprites = new Map(); // card.id → container (for the current render)
this.dropZones = []; // hit-test zones for the active leg
this.potentialDrag = null; // pointer-down recorded, not yet a drag this.potentialDrag = null; // pointer-down recorded, not yet a drag
this.dragState = null; // an in-progress drag this.dragState = null; // an in-progress drag
this.dropHighlight = null; this.dropHighlight = null;
this.tsDrawMode = false; // Three Shuffles: picking a buried card to free
} }
create() { create() {
@ -74,15 +76,20 @@ export default class SolitaireTourGame extends Phaser.Scene {
{ width: 300, height: 60, fontSize: 24 }); { width: 300, height: 60, fontSize: 24 });
this.noMoreBtn.setDepth(D.ui); this.noMoreBtn.setDepth(D.ui);
// Three Shuffles recovery controls (hidden on the other legs).
this.shuffleBtn = new Button(this, GAME_WIDTH / 2 - 360, 1018, 'Shuffle', () => this.onShuffle(),
{ width: 220, height: 60, fontSize: 24 });
this.shuffleBtn.setDepth(D.ui).setVisible(false);
this.drawBtn = new Button(this, GAME_WIDTH / 2 + 360, 1018, 'Draw', () => this.onDraw(), this.drawBtn = new Button(this, GAME_WIDTH / 2 + 360, 1018, 'Draw', () => this.onDraw(),
{ width: 170, height: 60, fontSize: 24 }); { width: 200, height: 60, fontSize: 24 });
this.drawBtn.setDepth(D.ui).setVisible(false); this.drawBtn.setDepth(D.ui).setVisible(false);
this.leaveBtn = new Button(this, GAME_WIDTH - 110, 1042, 'Leave', () => this.scene.start('GameMenu'), this.leaveBtn = new Button(this, GAME_WIDTH - 110, 1042, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, height: 54, fontSize: 22 }); { variant: 'ghost', width: 160, height: 54, fontSize: 22 });
this.leaveBtn.setDepth(D.ui); this.leaveBtn.setDepth(D.ui);
this.setupKlondikeDrag(); this.setupDrag();
this.startLeg(); this.startLeg();
} }
@ -92,6 +99,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
this.engine = createEngine(this.legType); this.engine = createEngine(this.legType);
this.sel = null; this.sel = null;
this.legEnded = false; this.legEnded = false;
this.tsDrawMode = false;
playSound(this, SFX.CARD_SHUFFLE); playSound(this, SFX.CARD_SHUFFLE);
this.refresh(); this.refresh();
} }
@ -123,12 +131,16 @@ export default class SolitaireTourGame extends Phaser.Scene {
else if (this.legType === 'klondike') status = `Stock: ${e.stock.length} · Waste: ${e.waste.length}`; else if (this.legType === 'klondike') status = `Stock: ${e.stock.length} · Waste: ${e.waste.length}`;
else if (this.legType === 'pyramid') status = `Stock: ${e.stock.length} · Passes left: ${e.passesLeft}`; else if (this.legType === 'pyramid') status = `Stock: ${e.stock.length} · Passes left: ${e.passesLeft}`;
else if (this.legType === 'fourteen') status = 'Remove pairs that total 14'; else if (this.legType === 'fourteen') status = 'Remove pairs that total 14';
else if (this.legType === 'threeshuffles') status = `Shuffles left: ${e.recyclesLeft} · Draw: ${e.drawsLeft}`; else if (this.legType === 'threeshuffles') status = `Shuffles left: ${e.shufflesLeft} · Draw: ${e.drawsLeft}${this.tsDrawMode ? ' · pick a buried card' : ''}`;
this.statusText.setText(status); this.statusText.setText(status);
const showDraw = this.legType === 'threeshuffles'; const ts = this.legType === 'threeshuffles';
this.drawBtn.setVisible(showDraw); this.shuffleBtn.setVisible(ts);
if (showDraw) this.drawBtn.setEnabled(e.drawsLeft > 0 && e.stock.length > 0); this.drawBtn.setVisible(ts);
if (ts) {
this.shuffleBtn.setLabel(`Shuffle (${e.shufflesLeft})`).setEnabled(e.shufflesLeft > 0);
this.drawBtn.setLabel(this.tsDrawMode ? 'Cancel Draw' : 'Draw').setEnabled(this.tsDrawMode || e.canDraw());
}
} }
updateStuck() { updateStuck() {
@ -148,7 +160,8 @@ export default class SolitaireTourGame extends Phaser.Scene {
onDraw() { onDraw() {
if (!this.interactive() || this.legType !== 'threeshuffles') return; if (!this.interactive() || this.legType !== 'threeshuffles') return;
this.applyMove(this.engine.useDraw(), SFX.CARD_SHOW); if (this.tsDrawMode) { this.tsDrawMode = false; this.refresh(); return; }
if (this.engine.canDraw()) { this.tsDrawMode = true; this.sel = null; this.refresh(); }
} }
endLeg() { endLeg() {
@ -341,7 +354,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
case 'klondike': return this.renderKlondike(); case 'klondike': return this.renderKlondike();
case 'pyramid': return this.renderPyramid(); case 'pyramid': return this.renderPyramid();
case 'fourteen': return this.renderFourteen(); case 'fourteen': return this.renderFourteen();
case 'threeshuffles': return this.renderKlondike(); case 'threeshuffles': return this.renderThreeShuffles();
} }
} }
@ -379,6 +392,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
const e = this.engine; const e = this.engine;
const suits = ['s', 'h', 'd', 'c']; const suits = ['s', 'h', 'd', 'c'];
this.kSprites.clear(); this.kSprites.clear();
this.dropZones = [];
// stock // stock
if (e.stock.length) { if (e.stock.length) {
this.card({}, 600, 250, { faceUp: false, onClick: () => this.kStock() }); this.card({}, 600, 250, { faceUp: false, onClick: () => this.kStock() });
@ -405,12 +419,14 @@ export default class SolitaireTourGame extends Phaser.Scene {
const top = e.foundationTop(s); const top = e.foundationTop(s);
if (top) this.card(top, x, 250, { onClick: () => this.kFoundation() }); if (top) this.card(top, x, 250, { onClick: () => this.kFoundation() });
else this.slot(x, 250, { s: '♠', h: '♥', d: '♦', c: '♣' }[s], () => this.kFoundation()); else this.slot(x, 250, { s: '♠', h: '♥', d: '♦', c: '♣' }[s], () => this.kFoundation());
this.dropZones.push({ cx: x, cy: 250, hw: CARD_W * 0.7, hh: CARD_H * 0.8, hlx: x, hly: 250, kind: 'foundation', idx: i });
}); });
// tableau // tableau
const topY = 430; const topY = 430;
for (let c = 0; c < 7; c++) { for (let c = 0; c < 7; c++) {
const pile = e.tableau[c]; const pile = e.tableau[c];
this.dropZones.push({ cx: colX(c), cy: 640, hw: CARD_W * 0.7, hh: 320, hlx: colX(c), hly: topY + pile.length * 32, kind: 'column', idx: c });
if (!pile.length) { this.slot(colX(c), topY, '', () => this.kColumnDrop(c)); continue; } if (!pile.length) { this.slot(colX(c), topY, '', () => this.kColumnDrop(c)); continue; }
let y = topY; let y = topY;
pile.forEach((entry, idx) => { pile.forEach((entry, idx) => {
@ -480,30 +496,97 @@ export default class SolitaireTourGame extends Phaser.Scene {
if (ok) this.applyMove(true); else this.refresh(); if (ok) this.applyMove(true); else this.refresh();
} }
// Klondike drag-and-drop. Pointer-down on a card records a potential drag; // ── Generic drag-and-drop (Klondike + Three Shuffles) ─────────────────────────
// moving past a small threshold promotes it to a real drag (carrying any // Pointer-down on a card records a potential drag; moving past a small
// valid run beneath), and a release without movement falls back to a tap. // threshold promotes it to a real drag, and a release without movement falls
setupKlondikeDrag() { // back to a tap. Each renderer fills this.dropZones; resolveDrop() turns a
// pointer position into a highlight + a commit closure for the active game.
setupDrag() {
this.input.on('pointermove', (pointer) => { this.input.on('pointermove', (pointer) => {
if (!pointer.isDown) return; if (!pointer.isDown) return;
if (this.dragState) { if (this.dragState) {
this.kUpdateDrag(pointer); this.dragUpdate(pointer);
} else if (this.potentialDrag) { } else if (this.potentialDrag) {
const dx = pointer.x - this.potentialDrag.startX; const dx = pointer.x - this.potentialDrag.startX;
const dy = pointer.y - this.potentialDrag.startY; const dy = pointer.y - this.potentialDrag.startY;
if (dx * dx + dy * dy > 80) this.kPromoteDrag(); if (dx * dx + dy * dy > 80) this.dragPromote();
} }
}); });
this.input.on('pointerup', () => { this.input.on('pointerup', () => {
if (this.dragState) this.kEndDrag(); if (this.dragState) this.dragEnd();
else if (this.potentialDrag) { else if (this.potentialDrag) {
const pd = this.potentialDrag; const pd = this.potentialDrag;
this.potentialDrag = null; this.potentialDrag = null;
this.kTap(pd.descriptor); pd.tap();
} }
}); });
} }
beginDrag(objs, pointer, tap, resolve) {
if (!this.interactive() || this.dragState || !objs.length) return;
this.potentialDrag = {
startX: pointer.x, startY: pointer.y, tap, resolve,
sprites: objs.map((obj) => ({ obj, offX: obj.x - pointer.x, offY: obj.y - pointer.y, homeX: obj.x, homeY: obj.y })),
};
}
dragPromote() {
const pd = this.potentialDrag;
this.potentialDrag = null;
pd.sprites.forEach(({ obj }) => {
this.board.bringToTop(obj);
this.tweens.add({ targets: obj, scaleX: 1.05, scaleY: 1.05, duration: 90 });
});
this.dragState = pd;
}
dragUpdate(pointer) {
for (const { obj, offX, offY } of this.dragState.sprites) {
obj.x = pointer.x + offX;
obj.y = pointer.y + offY;
}
const primary = this.dragState.sprites[0].obj;
this.highlightDrop(this.dragState.resolve(primary.x, primary.y));
}
highlightDrop(target) {
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
if (!target) return;
this.dropHighlight = this.add.rectangle(target.pos.x, target.pos.y, CARD_W + 16, CARD_H + 16, target.color, 0.18)
.setStrokeStyle(3, target.color, 0.9).setDepth(D.card - 1);
}
dragEnd() {
const ds = this.dragState;
this.dragState = null;
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
const primary = ds.sprites[0].obj;
const target = ds.resolve(primary.x, primary.y);
if (target && target.commit()) { this.sel = null; this.applyMove(true); return; }
// Rejected drop — slide the cards back where they came from.
ds.sprites.forEach(({ obj, homeX, homeY }) => {
this.tweens.add({ targets: obj, x: homeX, y: homeY, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' });
});
}
resolveDrop(descriptor, x, y) {
const z = this.dropZones.find((zone) => Math.abs(x - zone.cx) < zone.hw && Math.abs(y - zone.cy) < zone.hh);
if (!z) return null;
const pos = { x: z.hlx, y: z.hly };
if (this.legType === 'threeshuffles') {
const commit = z.kind === 'foundation'
? () => this.engine.playToFoundation(descriptor.pile)
: () => this.engine.moveTop(descriptor.pile, z.idx);
return { pos, color: z.kind === 'foundation' ? 0xffd700 : SEL, commit };
}
const commit = z.kind === 'foundation'
? () => this.kCommitFoundation(descriptor)
: () => this.kCommitColumn(descriptor, z.idx);
return { pos, color: z.kind === 'foundation' ? 0xffd700 : SEL, commit };
}
// Klondike drag helpers --------------------------------------------------------
kDragSprites(descriptor) { kDragSprites(descriptor) {
if (descriptor.kind === 'waste') { if (descriptor.kind === 'waste') {
const top = this.engine.waste[this.engine.waste.length - 1]; const top = this.engine.waste[this.engine.waste.length - 1];
@ -516,76 +599,8 @@ export default class SolitaireTourGame extends Phaser.Scene {
} }
kPointerDown(descriptor, pointer) { kPointerDown(descriptor, pointer) {
if (!this.interactive() || this.dragState) return; this.beginDrag(this.kDragSprites(descriptor), pointer,
const objs = this.kDragSprites(descriptor); () => this.kTap(descriptor), (x, y) => this.resolveDrop(descriptor, x, y));
if (!objs.length) return;
this.potentialDrag = {
descriptor,
startX: pointer.x, startY: pointer.y,
sprites: objs.map((obj) => ({ obj, offX: obj.x - pointer.x, offY: obj.y - pointer.y, homeX: obj.x, homeY: obj.y })),
};
}
kPromoteDrag() {
const pd = this.potentialDrag;
this.potentialDrag = null;
pd.sprites.forEach(({ obj }) => {
this.board.bringToTop(obj);
this.tweens.add({ targets: obj, scaleX: 1.05, scaleY: 1.05, duration: 90 });
});
this.dragState = pd;
}
kUpdateDrag(pointer) {
for (const { obj, offX, offY } of this.dragState.sprites) {
obj.x = pointer.x + offX;
obj.y = pointer.y + offY;
}
const primary = this.dragState.sprites[0].obj;
this.kUpdateDropHighlight(this.kDropTargetAt(primary.x, primary.y));
}
kDropTargetAt(x, y) {
for (let i = 0; i < 4; i++) {
if (Math.abs(x - (1090 + i * COL_GAP)) < CARD_W * 0.7 && Math.abs(y - 250) < CARD_H * 0.8) {
return { type: 'foundation', idx: i };
}
}
for (let c = 0; c < 7; c++) {
if (Math.abs(x - colX(c)) < CARD_W * 0.7 && y > 360) return { type: 'column', col: c };
}
return null;
}
kUpdateDropHighlight(target) {
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
if (!target) return;
const pos = target.type === 'foundation'
? { x: 1090 + target.idx * COL_GAP, y: 250 }
: { x: colX(target.col), y: 430 + this.engine.tableau[target.col].length * 32 };
const color = target.type === 'foundation' ? 0xffd700 : SEL;
this.dropHighlight = this.add.rectangle(pos.x, pos.y, CARD_W + 16, CARD_H + 16, color, 0.18)
.setStrokeStyle(3, color, 0.9).setDepth(D.card - 1);
}
kEndDrag() {
const ds = this.dragState;
this.dragState = null;
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
const primary = ds.sprites[0].obj;
const target = this.kDropTargetAt(primary.x, primary.y);
let ok = false;
if (target) {
ok = target.type === 'foundation'
? this.kCommitFoundation(ds.descriptor)
: this.kCommitColumn(ds.descriptor, target.col);
}
if (ok) { this.sel = null; this.applyMove(true); return; }
// Rejected drop — slide the cards back where they came from.
ds.sprites.forEach(({ obj, homeX, homeY }) => {
this.tweens.add({ targets: obj, x: homeX, y: homeY, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' });
});
} }
kCommitColumn(descriptor, destCol) { kCommitColumn(descriptor, destCol) {
@ -605,6 +620,88 @@ export default class SolitaireTourGame extends Phaser.Scene {
else this.kCard(descriptor.col, descriptor.idx); else this.kCard(descriptor.col, descriptor.idx);
} }
// Three Shuffles and a Draw ----------------------------------------------------
renderThreeShuffles() {
const e = this.engine;
this.kSprites.clear();
this.dropZones = [];
const COLS = 6;
const rows = Math.max(3, Math.ceil(e.piles.length / COLS)); // grows if the Draw adds a pile
const rowStep = Math.min(265, Math.floor(720 / rows));
const PILE_X = (col) => 380 + col * 200;
const rowYOf = (row) => 220 + row * rowStep;
const FAN = 20;
const FX = 1820;
const FY = [235, 400, 565, 730];
const suits = ['s', 'h', 'd', 'c'];
const glyph = { s: '♠', h: '♥', d: '♦', c: '♣' };
this.board.add(this.add.text(FX, 150, 'Foundations', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0.5));
suits.forEach((s, i) => {
const top = e.foundationTop(s);
if (top) this.card(top, FX, FY[i], { onClick: () => this.tsFoundation() });
else this.slot(FX, FY[i], glyph[s], () => this.tsFoundation());
this.dropZones.push({ cx: FX, cy: FY[i], hw: CARD_W * 0.7, hh: CARD_H * 0.8, hlx: FX, hly: FY[i], kind: 'foundation', idx: i });
});
e.piles.forEach((pile, p) => {
const x = PILE_X(p % COLS);
const topY = rowYOf(Math.floor(p / COLS));
pile.forEach((card, idx) => {
const isTop = idx === pile.length - 1;
const buriedDrawable = this.tsDrawMode && !isTop;
const cont = this.card(card, x, topY + idx * FAN, {
selected: isTop && this.sel?.kind === 'tspile' && this.sel.pile === p,
hint: buriedDrawable,
onDown: isTop && !this.tsDrawMode ? (_co, pointer) => this.tsPointerDown(p, pointer) : null,
onClick: buriedDrawable ? () => this.tsBuried(card.id) : null,
});
this.kSprites.set(card.id, cont);
});
const len = pile.length;
this.dropZones.push({ cx: x, cy: topY + (len * FAN) / 2, hw: CARD_W * 0.7, hh: (len * FAN) / 2 + CARD_H * 0.6, hlx: x, hly: topY + len * FAN, kind: 'pile', idx: p });
});
}
tsPointerDown(pile, pointer) {
const top = this.engine.pileTop(pile);
const o = top && this.kSprites.get(top.id);
this.beginDrag(o ? [o] : [], pointer,
() => this.tsTap(pile), (x, y) => this.resolveDrop({ kind: 'ts', pile }, x, y));
}
tsTap(pile) {
if (!this.interactive() || this.tsDrawMode) return;
if (this.sel?.kind === 'tspile') {
const src = this.sel.pile;
if (src === pile) { this.sel = null; this.refresh(); return; }
if (this.engine.moveTop(src, pile)) { this.sel = null; this.applyMove(true); return; }
}
this.sel = { kind: 'tspile', pile };
this.refresh();
}
tsFoundation() {
if (!this.interactive() || this.sel?.kind !== 'tspile') return;
const ok = this.engine.playToFoundation(this.sel.pile);
this.sel = null;
if (ok) this.applyMove(true); else this.refresh();
}
tsBuried(cardId) {
if (!this.interactive() || !this.tsDrawMode) return;
const ok = this.engine.drawCard(cardId);
this.tsDrawMode = false;
if (ok) this.applyMove(true, SFX.CARD_SHOW); else this.refresh();
}
onShuffle() {
if (!this.interactive() || this.legType !== 'threeshuffles') return;
this.sel = null;
this.tsDrawMode = false;
this.applyMove(this.engine.redeal(), SFX.CARD_SHUFFLE);
}
// Pyramid ---------------------------------------------------------------------- // Pyramid ----------------------------------------------------------------------
renderPyramid() { renderPyramid() {
const e = this.engine; const e = this.engine;

View File

@ -47,7 +47,7 @@ export const GAME_META = {
klondike: { name: 'Klondike', blurb: 'Build the four foundations up by suit from Ace. Stack the tableau down in alternating colours.' }, klondike: { name: 'Klondike', blurb: 'Build the four foundations up by suit from Ace. Stack the tableau down in alternating colours.' },
pyramid: { name: 'Pyramid', blurb: 'Remove pairs of exposed cards that total 13. Kings clear on their own.' }, pyramid: { name: 'Pyramid', blurb: 'Remove pairs of exposed cards that total 13. Kings clear on their own.' },
fourteen: { name: 'Take Fourteen', blurb: 'Remove pairs of available cards that total 14 until every pile is empty.' }, fourteen: { name: 'Take Fourteen', blurb: 'Remove pairs of available cards that total 14 until every pile is empty.' },
threeshuffles: { name: 'Three Shuffles and a Draw', blurb: 'Klondike-style, but the stock only recycles three times — then take one Draw to pull a buried card.' }, threeshuffles: { name: 'Three Shuffles and a Draw', blurb: 'Foundations up by suit from Ace. Tableau piles build down in suit; only the top card plays and empty spaces stay empty. Shuffle twice, then take one Draw to free a buried card.' },
}; };
// ── Golf ─────────────────────────────────────────────────────────────────────── // ── Golf ───────────────────────────────────────────────────────────────────────
@ -97,13 +97,12 @@ export class GolfEngine {
isWon() { return this.tableau.every((p) => p.length === 0); } isWon() { return this.tableau.every((p) => p.length === 0); }
} }
// ── Klondike (also the base for Three Shuffles and a Draw) ────────────────────── // ── Klondike ────────────────────────────────────────────────────────────────────
export class KlondikeEngine { export class KlondikeEngine {
constructor(deck, opts = {}) { constructor(deck, opts = {}) {
this.type = opts.type ?? 'klondike'; this.type = opts.type ?? 'klondike';
this.recycleLimit = opts.recycleLimit ?? Infinity; // stock passes allowed this.recycleLimit = opts.recycleLimit ?? Infinity; // stock passes allowed
this.recyclesLeft = this.recycleLimit; this.recyclesLeft = this.recycleLimit;
this.drawsLeft = opts.draws ?? 0; // "a Draw" power
this.tableau = Array.from({ length: 7 }, () => []); this.tableau = Array.from({ length: 7 }, () => []);
let k = 0; let k = 0;
@ -156,12 +155,6 @@ export class KlondikeEngine {
if (this.stock.length) { this.waste.push(this.stock.pop()); return true; } if (this.stock.length) { this.waste.push(this.stock.pop()); return true; }
if (!this.waste.length || this.recyclesLeft <= 0) return false; if (!this.waste.length || this.recyclesLeft <= 0) return false;
this.recyclesLeft--; this.recyclesLeft--;
if (this.type === 'threeshuffles') {
for (let i = this.waste.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.waste[i], this.waste[j]] = [this.waste[j], this.waste[i]];
}
}
this.stock = this.waste.reverse(); this.stock = this.waste.reverse();
this.waste = []; this.waste = [];
return true; return true;
@ -200,17 +193,6 @@ export class KlondikeEngine {
return true; return true;
} }
/** The one "Draw": surface the most useful buried stock card onto the waste. */
useDraw() {
if (this.drawsLeft <= 0 || !this.stock.length) return false;
let pick = this.stock.findIndex((c) => this.canPlayFoundation(c));
if (pick < 0) pick = this.stock.findIndex((c) => this.tableau.some((_, col) => this.canStack(c, col)));
if (pick < 0) pick = this.stock.length - 1;
this.waste.push(this.stock.splice(pick, 1)[0]);
this.drawsLeft--;
return true;
}
anyImmediateMove() { anyImmediateMove() {
if (this.canPlayFoundation(this.waste[this.waste.length - 1])) return true; if (this.canPlayFoundation(this.waste[this.waste.length - 1])) return true;
for (let c = 0; c < 7; c++) { for (let c = 0; c < 7; c++) {
@ -233,7 +215,6 @@ export class KlondikeEngine {
if (this.anyImmediateMove()) return true; if (this.anyImmediateMove()) return true;
if (this.stock.length) return true; if (this.stock.length) return true;
if (this.waste.length && this.recyclesLeft > 0) return true; if (this.waste.length && this.recyclesLeft > 0) return true;
if (this.drawsLeft > 0 && this.stock.length) return true;
return false; return false;
} }
@ -248,10 +229,118 @@ export class KlondikeEngine {
} }
} }
export class ThreeShufflesEngine extends KlondikeEngine { // ── Three Shuffles and a Draw (a La Belle Lucie variant) ────────────────────────
// Four foundations build UP in suit from Ace. The tableau is dealt into piles of
// three (the last pile is short); piles build DOWN in suit and only the top card
// is in play. Empty spaces are never refilled. When stuck the player gathers all
// tableau cards, shuffles, and redeals — twice (the initial deal is the first of
// the three "shuffles"). After the redeals are spent, one Draw frees any single
// buried card. Win by clearing every card to the foundations.
export class ThreeShufflesEngine {
constructor(deck) { constructor(deck) {
super(deck, { type: 'threeshuffles', recycleLimit: 3, draws: 1 }); this.type = 'threeshuffles';
this.foundations = { s: [], h: [], d: [], c: [] };
this.shufflesLeft = 2; // two redeals beyond the initial deal
this.drawsLeft = 1; // usable only once the redeals are spent
this.dealPiles(deck);
} }
// Chunk cards into piles of three; the final pile holds the remainder. On the
// first 52-card deal this is 17 piles of three plus one single card (18 piles).
dealPiles(cards) {
this.piles = [];
for (let i = 0; i < cards.length; i += 3) this.piles.push(cards.slice(i, i + 3));
}
prune() { this.piles = this.piles.filter((p) => p.length > 0); }
foundationTop(suit) {
const f = this.foundations[suit];
return f[f.length - 1] ?? null;
}
canPlayFoundation(card) {
if (!card) return false;
const top = this.foundationTop(card.suit);
return top ? card.pval === top.pval + 1 : card.pval === 1;
}
pileTop(p) {
const pile = this.piles[p];
return pile ? pile[pile.length - 1] ?? null : null;
}
// Build down in suit; empty piles can never be filled.
canStack(card, destP) {
const pile = this.piles[destP];
if (!pile || !pile.length) return false;
const top = pile[pile.length - 1];
return top.suit === card.suit && card.pval === top.pval - 1;
}
playToFoundation(p) {
const card = this.pileTop(p);
if (!this.canPlayFoundation(card)) return false;
this.foundations[card.suit].push(this.piles[p].pop());
this.prune();
return true;
}
moveTop(srcP, destP) {
if (srcP === destP) return false;
const card = this.pileTop(srcP);
if (!card || !this.canStack(card, destP)) return false;
this.piles[destP].push(this.piles[srcP].pop());
this.prune();
return true;
}
redeal() {
if (this.shufflesLeft <= 0) return false;
const rest = [];
for (const pile of this.piles) for (const card of pile) rest.push(card);
if (!rest.length) return false;
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[rest[i], rest[j]] = [rest[j], rest[i]];
}
this.dealPiles(rest);
this.shufflesLeft--;
return true;
}
canDraw() { return this.shufflesLeft === 0 && this.drawsLeft > 0; }
/** The Draw: free one buried card by lifting it into its own new pile. */
drawCard(cardId) {
if (!this.canDraw()) return false;
for (const pile of this.piles) {
const idx = pile.findIndex((c) => c.id === cardId);
if (idx < 0) continue;
if (idx === pile.length - 1) return false; // already a top card
const [card] = pile.splice(idx, 1);
this.piles.push([card]);
this.drawsLeft--;
return true;
}
return false;
}
hasMoves() {
for (let p = 0; p < this.piles.length; p++) {
const card = this.pileTop(p);
if (!card) continue;
if (this.canPlayFoundation(card)) return true;
for (let d = 0; d < this.piles.length; d++) if (d !== p && this.canStack(card, d)) return true;
}
if (this.shufflesLeft > 0 && this.piles.some((p) => p.length)) return true;
if (this.canDraw() && this.piles.some((p) => p.length > 1)) return true;
return false;
}
remainingValue() { return this.piles.reduce((t, p) => t + sumVal(p), 0); }
isWon() { return ['s', 'h', 'd', 'c'].every((s) => this.foundations[s].length === 13); }
} }
// ── Pyramid ──────────────────────────────────────────────────────────────────── // ── Pyramid ────────────────────────────────────────────────────────────────────