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.
|
// 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;
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue