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:
parent
efb8842368
commit
6fb924eb23
|
|
@ -38,9 +38,11 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
|
||||
// Klondike / Three Shuffles drag-and-drop state.
|
||||
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.dragState = null; // an in-progress drag
|
||||
this.dropHighlight = null;
|
||||
this.tsDrawMode = false; // Three Shuffles: picking a buried card to free
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -74,15 +76,20 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
{ width: 300, height: 60, fontSize: 24 });
|
||||
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(),
|
||||
{ width: 170, height: 60, fontSize: 24 });
|
||||
{ width: 200, height: 60, fontSize: 24 });
|
||||
this.drawBtn.setDepth(D.ui).setVisible(false);
|
||||
|
||||
this.leaveBtn = new Button(this, GAME_WIDTH - 110, 1042, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 160, height: 54, fontSize: 22 });
|
||||
this.leaveBtn.setDepth(D.ui);
|
||||
|
||||
this.setupKlondikeDrag();
|
||||
this.setupDrag();
|
||||
this.startLeg();
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +99,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
this.engine = createEngine(this.legType);
|
||||
this.sel = null;
|
||||
this.legEnded = false;
|
||||
this.tsDrawMode = false;
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
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 === '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 === '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);
|
||||
|
||||
const showDraw = this.legType === 'threeshuffles';
|
||||
this.drawBtn.setVisible(showDraw);
|
||||
if (showDraw) this.drawBtn.setEnabled(e.drawsLeft > 0 && e.stock.length > 0);
|
||||
const ts = this.legType === 'threeshuffles';
|
||||
this.shuffleBtn.setVisible(ts);
|
||||
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() {
|
||||
|
|
@ -148,7 +160,8 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
|
||||
onDraw() {
|
||||
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() {
|
||||
|
|
@ -341,7 +354,7 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
case 'klondike': return this.renderKlondike();
|
||||
case 'pyramid': return this.renderPyramid();
|
||||
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 suits = ['s', 'h', 'd', 'c'];
|
||||
this.kSprites.clear();
|
||||
this.dropZones = [];
|
||||
// stock
|
||||
if (e.stock.length) {
|
||||
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);
|
||||
if (top) this.card(top, x, 250, { onClick: () => 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
|
||||
const topY = 430;
|
||||
for (let c = 0; c < 7; 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; }
|
||||
let y = topY;
|
||||
pile.forEach((entry, idx) => {
|
||||
|
|
@ -480,30 +496,97 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
if (ok) this.applyMove(true); else this.refresh();
|
||||
}
|
||||
|
||||
// Klondike drag-and-drop. Pointer-down on a card records a potential drag;
|
||||
// moving past a small threshold promotes it to a real drag (carrying any
|
||||
// valid run beneath), and a release without movement falls back to a tap.
|
||||
setupKlondikeDrag() {
|
||||
// ── 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
|
||||
// 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) => {
|
||||
if (!pointer.isDown) return;
|
||||
if (this.dragState) {
|
||||
this.kUpdateDrag(pointer);
|
||||
this.dragUpdate(pointer);
|
||||
} else if (this.potentialDrag) {
|
||||
const dx = pointer.x - this.potentialDrag.startX;
|
||||
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', () => {
|
||||
if (this.dragState) this.kEndDrag();
|
||||
if (this.dragState) this.dragEnd();
|
||||
else if (this.potentialDrag) {
|
||||
const pd = this.potentialDrag;
|
||||
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) {
|
||||
if (descriptor.kind === 'waste') {
|
||||
const top = this.engine.waste[this.engine.waste.length - 1];
|
||||
|
|
@ -516,76 +599,8 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
kPointerDown(descriptor, pointer) {
|
||||
if (!this.interactive() || this.dragState) return;
|
||||
const objs = this.kDragSprites(descriptor);
|
||||
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' });
|
||||
});
|
||||
this.beginDrag(this.kDragSprites(descriptor), pointer,
|
||||
() => this.kTap(descriptor), (x, y) => this.resolveDrop(descriptor, x, y));
|
||||
}
|
||||
|
||||
kCommitColumn(descriptor, destCol) {
|
||||
|
|
@ -605,6 +620,88 @@ export default class SolitaireTourGame extends Phaser.Scene {
|
|||
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 ----------------------------------------------------------------------
|
||||
renderPyramid() {
|
||||
const e = this.engine;
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
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.' },
|
||||
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 ───────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -97,13 +97,12 @@ export class GolfEngine {
|
|||
isWon() { return this.tableau.every((p) => p.length === 0); }
|
||||
}
|
||||
|
||||
// ── Klondike (also the base for Three Shuffles and a Draw) ──────────────────────
|
||||
// ── Klondike ────────────────────────────────────────────────────────────────────
|
||||
export class KlondikeEngine {
|
||||
constructor(deck, opts = {}) {
|
||||
this.type = opts.type ?? 'klondike';
|
||||
this.recycleLimit = opts.recycleLimit ?? Infinity; // stock passes allowed
|
||||
this.recyclesLeft = this.recycleLimit;
|
||||
this.drawsLeft = opts.draws ?? 0; // "a Draw" power
|
||||
|
||||
this.tableau = Array.from({ length: 7 }, () => []);
|
||||
let k = 0;
|
||||
|
|
@ -156,12 +155,6 @@ export class KlondikeEngine {
|
|||
if (this.stock.length) { this.waste.push(this.stock.pop()); return true; }
|
||||
if (!this.waste.length || this.recyclesLeft <= 0) return false;
|
||||
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.waste = [];
|
||||
return true;
|
||||
|
|
@ -200,17 +193,6 @@ export class KlondikeEngine {
|
|||
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() {
|
||||
if (this.canPlayFoundation(this.waste[this.waste.length - 1])) return true;
|
||||
for (let c = 0; c < 7; c++) {
|
||||
|
|
@ -233,7 +215,6 @@ export class KlondikeEngine {
|
|||
if (this.anyImmediateMove()) return true;
|
||||
if (this.stock.length) return true;
|
||||
if (this.waste.length && this.recyclesLeft > 0) return true;
|
||||
if (this.drawsLeft > 0 && this.stock.length) return true;
|
||||
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) {
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue