feat(wasm): playable browser game at /play
Add `SolitaireGame` WASM binding to `solitaire_wasm` exposing draw(), move_cards(), undo(), auto_complete_step(), and state() — all backed by the real solitaire_core rules engine. Add /play route to solitaire_server serving a full vanilla-JS interactive Klondike game (game.html / game.css / game.js). Features: drag-and-drop card moves (mouse + touch via PointerEvents), click stock to draw, double-click card to auto-move to foundation, undo, draw-1/3 toggle, new game, auto-complete animation, win overlay, seed display. Rebuild solitaire_wasm.js + solitaire_wasm_bg.wasm. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
"/replays/{id}",
|
"/replays/{id}",
|
||||||
get(|| async { Html(include_str!("../web/index.html")) }),
|
get(|| async { Html(include_str!("../web/index.html")) }),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/play",
|
||||||
|
get(|| async { Html(include_str!("../web/game.html")) }),
|
||||||
|
)
|
||||||
.nest_service("/web", ServeDir::new("solitaire_server/web"));
|
.nest_service("/web", ServeDir::new("solitaire_server/web"));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/* Solitaire Quest — interactive game page.
|
||||||
|
Palette and card styles mirror replay.css; adds drag, selection,
|
||||||
|
HUD, and win-overlay layers. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f0a1f;
|
||||||
|
--felt: #0f4c30;
|
||||||
|
--panel: #1a0f2e;
|
||||||
|
--panel-hi: #2d1b69;
|
||||||
|
--text: #f5f0ff;
|
||||||
|
--text-muted: #b5a8d5;
|
||||||
|
--accent: #ffd23f;
|
||||||
|
--red: #cc3344;
|
||||||
|
--black: #1a0f2e;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #ccc;
|
||||||
|
--card-w: 80px;
|
||||||
|
--card-h: 112px;
|
||||||
|
--gap: 12px;
|
||||||
|
--fan: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header / HUD ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
|
||||||
|
.hud-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
.logo { font-size: 16px; font-weight: 700; }
|
||||||
|
.muted { color: var(--text-muted); font-size: 12px; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--panel-hi);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 120ms;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--accent); color: var(--black); }
|
||||||
|
button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.toggle-label:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Board ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#board {
|
||||||
|
position: relative;
|
||||||
|
background: var(--felt);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
/* 7 columns wide */
|
||||||
|
width: calc(7 * var(--card-w) + 6 * var(--gap) + 40px);
|
||||||
|
/* top row + generous fan budget for a 13-card column */
|
||||||
|
height: calc(var(--card-h) + 28px + var(--card-h) + 12 * var(--fan) + 40px);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty-pile slot markers */
|
||||||
|
.slot {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--card-w);
|
||||||
|
height: var(--card-h);
|
||||||
|
border: 2px dashed rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot.drop-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255, 210, 63, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: var(--card-w);
|
||||||
|
height: var(--card-h);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.35);
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 200ms ease,
|
||||||
|
box-shadow 120ms ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.card.face-down {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#482f97 0, #482f97 6px,
|
||||||
|
#2d1b69 6px, #2d1b69 12px
|
||||||
|
);
|
||||||
|
color: transparent;
|
||||||
|
border-color: #4a3a8a;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .corner {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.card .corner.top { top: 4px; left: 6px; }
|
||||||
|
.card .corner.bottom { bottom: 4px; right: 6px; transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.card.red { color: var(--red); }
|
||||||
|
.card.black { color: var(--black); }
|
||||||
|
|
||||||
|
.card .center {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stock pile: pointer cursor since it's a click target, not draggable */
|
||||||
|
.card.stock-card { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Selected / being-dragged state */
|
||||||
|
.card.selected {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
|
||||||
|
z-index: 200;
|
||||||
|
transition: none; /* snap instantly while dragging */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop-target highlight on cards (top of a tableau column) */
|
||||||
|
.card.drop-target {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recycle indicator on empty stock */
|
||||||
|
.recycle-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 26px;
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Win overlay ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#win-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 5, 20, 0.75);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#win-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
.win-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px 48px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-score {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-detail {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-win-new {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Solitaire Quest — Play</title>
|
||||||
|
<link rel="stylesheet" href="/web/game.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="hud-left">
|
||||||
|
<span class="logo">Solitaire Quest</span>
|
||||||
|
<span id="hud-seed" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div class="hud-center">
|
||||||
|
<span id="hud-score">Score: 0</span>
|
||||||
|
<span id="hud-moves">Moves: 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="hud-right">
|
||||||
|
<button id="btn-undo" title="Undo (Z)">↩ Undo</button>
|
||||||
|
<button id="btn-new" title="New game">↺ New</button>
|
||||||
|
<label class="toggle-label" title="Draw one or three cards">
|
||||||
|
<input type="checkbox" id="chk-draw3"> Draw 3
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="board"></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="win-overlay" class="hidden">
|
||||||
|
<div class="win-card">
|
||||||
|
<div class="win-title">You Won!</div>
|
||||||
|
<div id="win-score" class="win-score"></div>
|
||||||
|
<div id="win-moves" class="win-detail"></div>
|
||||||
|
<button id="btn-win-new">Play Again ↺</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/web/game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
// Solitaire Quest — interactive browser game.
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// - `SolitaireGame` (Rust/WASM via solitaire_core) owns all rule logic.
|
||||||
|
// - This file owns the DOM renderer, drag-and-drop input, and the game loop.
|
||||||
|
// - Cards are persistent DOM elements keyed by `card.id`; positions are
|
||||||
|
// updated via `transform: translate(...)` so the browser can animate
|
||||||
|
// flights on the compositor thread.
|
||||||
|
//
|
||||||
|
// Pile name convention (mirrors solitaire_wasm::SolitaireGame::move_cards):
|
||||||
|
// "stock" | "waste" | "foundation-0".."foundation-3" | "tableau-0".."tableau-6"
|
||||||
|
|
||||||
|
import init, { SolitaireGame } from "/web/pkg/solitaire_wasm.js";
|
||||||
|
|
||||||
|
// ── Layout constants (must match game.css --card-w / --card-h / --gap) ──────
|
||||||
|
const CARD_W = 80;
|
||||||
|
const CARD_H = 112;
|
||||||
|
const GAP = 12;
|
||||||
|
const PAD = 20; // board padding
|
||||||
|
const FAN = 28; // vertical offset per fanned tableau card
|
||||||
|
const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan
|
||||||
|
|
||||||
|
// Top-row Y origin (relative to board interior = after padding).
|
||||||
|
const TOP_Y = 0;
|
||||||
|
const BOTTOM_Y = CARD_H + 28; // tableau row
|
||||||
|
|
||||||
|
const colX = (c) => c * (CARD_W + GAP);
|
||||||
|
|
||||||
|
// Absolute position of each pile's origin (top-left of its slot),
|
||||||
|
// relative to the board's padded interior (0, 0).
|
||||||
|
const PILE_ORIGIN = {
|
||||||
|
stock: { x: colX(0), y: TOP_Y },
|
||||||
|
waste: { x: colX(1), y: TOP_Y },
|
||||||
|
"foundation-0": { x: colX(3), y: TOP_Y },
|
||||||
|
"foundation-1": { x: colX(4), y: TOP_Y },
|
||||||
|
"foundation-2": { x: colX(5), y: TOP_Y },
|
||||||
|
"foundation-3": { x: colX(6), y: TOP_Y },
|
||||||
|
"tableau-0": { x: colX(0), y: BOTTOM_Y },
|
||||||
|
"tableau-1": { x: colX(1), y: BOTTOM_Y },
|
||||||
|
"tableau-2": { x: colX(2), y: BOTTOM_Y },
|
||||||
|
"tableau-3": { x: colX(3), y: BOTTOM_Y },
|
||||||
|
"tableau-4": { x: colX(4), y: BOTTOM_Y },
|
||||||
|
"tableau-5": { x: colX(5), y: BOTTOM_Y },
|
||||||
|
"tableau-6": { x: colX(6), y: BOTTOM_Y },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUIT_GLYPH = { clubs: "♣", diamonds: "♦", hearts: "♥", spades: "♠" };
|
||||||
|
const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"];
|
||||||
|
const RED_SUITS = new Set(["diamonds", "hearts"]);
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────────
|
||||||
|
let game = null;
|
||||||
|
let snap = null; // last rendered GameSnapshot
|
||||||
|
let drawThree = false;
|
||||||
|
|
||||||
|
// Persistent card → DOM element map (keyed by card.id).
|
||||||
|
const cardEls = new Map();
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
let drag = null;
|
||||||
|
// drag = {
|
||||||
|
// fromPile: string,
|
||||||
|
// fromIndex: number, // index of bottom card of the dragged run in its pile
|
||||||
|
// cardIds: number[], // ids of cards being dragged (bottom → top)
|
||||||
|
// startX: number, startY: number, // pointer start (board-relative)
|
||||||
|
// offsetX: number, offsetY: number, // cursor offset within the grabbed card
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Auto-complete timer handle
|
||||||
|
let acTimer = null;
|
||||||
|
|
||||||
|
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
||||||
|
const board = document.getElementById("board");
|
||||||
|
const hudScore = document.getElementById("hud-score");
|
||||||
|
const hudMoves = document.getElementById("hud-moves");
|
||||||
|
const hudSeed = document.getElementById("hud-seed");
|
||||||
|
const btnUndo = document.getElementById("btn-undo");
|
||||||
|
const btnNew = document.getElementById("btn-new");
|
||||||
|
const chkDraw3 = document.getElementById("chk-draw3");
|
||||||
|
const winOverlay = document.getElementById("win-overlay");
|
||||||
|
const winScore = document.getElementById("win-score");
|
||||||
|
const winMoves = document.getElementById("win-moves");
|
||||||
|
const btnWinNew = document.getElementById("btn-win-new");
|
||||||
|
|
||||||
|
// ── Bootstrap ────────────────────────────────────────────────────────────────
|
||||||
|
async function bootstrap() {
|
||||||
|
await init();
|
||||||
|
|
||||||
|
// Seed from URL param ?seed=N, otherwise random.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
||||||
|
drawThree = params.has("draw3");
|
||||||
|
chkDraw3.checked = drawThree;
|
||||||
|
|
||||||
|
buildSlots();
|
||||||
|
startGame(urlSeed);
|
||||||
|
attachHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomSeed() {
|
||||||
|
// Math.random gives a float in [0,1); multiply to get a large integer.
|
||||||
|
return Math.floor(Math.random() * 9007199254740991);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame(seed) {
|
||||||
|
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||||
|
game = new SolitaireGame(seed, drawThree);
|
||||||
|
snap = game.state();
|
||||||
|
hudSeed.textContent = `seed ${Math.round(game.seed())}`;
|
||||||
|
winOverlay.classList.add("hidden");
|
||||||
|
cardEls.clear();
|
||||||
|
board.querySelectorAll(".card").forEach(el => el.remove());
|
||||||
|
render(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slot placeholders ────────────────────────────────────────────────────────
|
||||||
|
function buildSlots() {
|
||||||
|
for (const [pile, origin] of Object.entries(PILE_ORIGIN)) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "slot";
|
||||||
|
el.dataset.pile = pile;
|
||||||
|
el.style.transform = `translate(${origin.x}px, ${origin.y}px)`;
|
||||||
|
board.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card position math ────────────────────────────────────────────────────────
|
||||||
|
function cardPos(pileName, indexInPile, pileLength, pileCards) {
|
||||||
|
const origin = PILE_ORIGIN[pileName];
|
||||||
|
let x = origin.x;
|
||||||
|
let y = origin.y;
|
||||||
|
|
||||||
|
if (pileName === "waste" && drawThree && pileLength >= 2) {
|
||||||
|
// Show top-3 of waste fanned horizontally.
|
||||||
|
const fanStart = Math.max(0, pileLength - 3);
|
||||||
|
const fanPos = indexInPile - fanStart;
|
||||||
|
if (fanPos >= 0) {
|
||||||
|
x += fanPos * WASTE_FAN;
|
||||||
|
} else {
|
||||||
|
// Cards below the fan window are stacked at origin.
|
||||||
|
}
|
||||||
|
} else if (pileName.startsWith("tableau-")) {
|
||||||
|
y += indexInPile * FAN;
|
||||||
|
}
|
||||||
|
// Stock, foundations: stack (no offset).
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Z-index: higher index in pile = drawn on top.
|
||||||
|
function cardZ(pileName, indexInPile, total) {
|
||||||
|
if (pileName === "stock") return 10 + indexInPile;
|
||||||
|
if (pileName === "waste") return 10 + indexInPile;
|
||||||
|
if (pileName.startsWith("found")) return 10 + indexInPile;
|
||||||
|
return 10 + indexInPile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Renderer ─────────────────────────────────────────────────────────────────
|
||||||
|
function render(s) {
|
||||||
|
snap = s;
|
||||||
|
|
||||||
|
// Update HUD
|
||||||
|
hudScore.textContent = `Score: ${s.score}`;
|
||||||
|
hudMoves.textContent = `Moves: ${s.move_count}`;
|
||||||
|
btnUndo.disabled = s.move_count === 0;
|
||||||
|
|
||||||
|
// Collect all cards visible in this snapshot, keyed by id → {pile, idx}.
|
||||||
|
const visible = new Map();
|
||||||
|
const addPile = (pileName, cards) => {
|
||||||
|
cards.forEach((c, i) => visible.set(c.id, { pile: pileName, idx: i, card: c, total: cards.length }));
|
||||||
|
};
|
||||||
|
addPile("stock", s.stock);
|
||||||
|
addPile("waste", s.waste);
|
||||||
|
s.foundations.forEach((f, i) => addPile(`foundation-${i}`, f));
|
||||||
|
s.tableaus.forEach((t, i) => addPile(`tableau-${i}`, t));
|
||||||
|
|
||||||
|
// Create or update card elements.
|
||||||
|
for (const [id, info] of visible) {
|
||||||
|
let el = cardEls.get(id);
|
||||||
|
if (!el) {
|
||||||
|
el = createCardEl(info.card);
|
||||||
|
cardEls.set(id, el);
|
||||||
|
board.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCardEl(el, info.card, info.pile, info.idx, info.total, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove cards no longer in the snapshot (shouldn't happen in solitaire
|
||||||
|
// but guards against stale state after undo).
|
||||||
|
for (const [id, el] of cardEls) {
|
||||||
|
if (!visible.has(id)) {
|
||||||
|
el.remove();
|
||||||
|
cardEls.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update slot drop-active highlights (cleared on every render).
|
||||||
|
board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active"));
|
||||||
|
board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target"));
|
||||||
|
|
||||||
|
// Show recycle indicator on empty stock.
|
||||||
|
let recycleEl = board.querySelector(".recycle-label");
|
||||||
|
if (s.stock.length === 0 && s.waste.length > 0) {
|
||||||
|
if (!recycleEl) {
|
||||||
|
recycleEl = document.createElement("div");
|
||||||
|
recycleEl.className = "recycle-label";
|
||||||
|
recycleEl.textContent = "↺";
|
||||||
|
board.appendChild(recycleEl);
|
||||||
|
}
|
||||||
|
const o = PILE_ORIGIN.stock;
|
||||||
|
recycleEl.style.left = `${o.x + CARD_W / 2}px`;
|
||||||
|
recycleEl.style.top = `${o.y + CARD_H / 2}px`;
|
||||||
|
} else if (recycleEl) {
|
||||||
|
recycleEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger auto-complete if applicable.
|
||||||
|
if (s.is_auto_completable && !s.is_won && !acTimer) {
|
||||||
|
acTimer = setInterval(doAutoCompleteStep, 400);
|
||||||
|
}
|
||||||
|
if (s.is_won) {
|
||||||
|
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||||
|
showWin(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCardEl(card) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.dataset.cardId = card.id;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardEl(el, card, pileName, idx, total, s) {
|
||||||
|
const pos = cardPos(pileName, idx, total, null);
|
||||||
|
const z = cardZ(pileName, idx, total);
|
||||||
|
|
||||||
|
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
||||||
|
el.style.zIndex = z;
|
||||||
|
|
||||||
|
const isTop = idx === total - 1;
|
||||||
|
|
||||||
|
if (!card.face_up) {
|
||||||
|
el.className = "card face-down";
|
||||||
|
if (pileName === "stock") el.classList.add("stock-card");
|
||||||
|
el.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const isRed = RED_SUITS.has(card.suit);
|
||||||
|
el.className = `card ${isRed ? "red" : "black"}`;
|
||||||
|
if (pileName === "stock") el.classList.add("stock-card");
|
||||||
|
|
||||||
|
const rankLabel = RANK_LABELS[card.rank];
|
||||||
|
const suit = SUIT_GLYPH[card.suit];
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="corner top">${rankLabel}<br>${suit}</div>
|
||||||
|
<div class="center">${suit}</div>
|
||||||
|
<div class="corner bottom">${rankLabel}<br>${suit}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Win overlay ───────────────────────────────────────────────────────────────
|
||||||
|
function showWin(s) {
|
||||||
|
winScore.textContent = `Score: ${s.score}`;
|
||||||
|
winMoves.textContent = `${s.move_count} moves`;
|
||||||
|
winOverlay.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-complete ─────────────────────────────────────────────────────────────
|
||||||
|
function doAutoCompleteStep() {
|
||||||
|
if (!game || !snap || !snap.is_auto_completable) {
|
||||||
|
clearInterval(acTimer);
|
||||||
|
acTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = game.auto_complete_step();
|
||||||
|
if (result && result.ok) {
|
||||||
|
render(result.snapshot);
|
||||||
|
} else {
|
||||||
|
clearInterval(acTimer);
|
||||||
|
acTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input handling ────────────────────────────────────────────────────────────
|
||||||
|
function attachHandlers() {
|
||||||
|
// Buttons
|
||||||
|
btnUndo.addEventListener("click", () => {
|
||||||
|
const r = game.undo();
|
||||||
|
if (r.ok) render(r.snapshot);
|
||||||
|
});
|
||||||
|
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
||||||
|
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
||||||
|
chkDraw3.addEventListener("change", () => {
|
||||||
|
drawThree = chkDraw3.checked;
|
||||||
|
startGame(randomSeed());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "z" || e.key === "Z") {
|
||||||
|
const r = game.undo();
|
||||||
|
if (r.ok) render(r.snapshot);
|
||||||
|
}
|
||||||
|
if (e.key === "n" || e.key === "N") {
|
||||||
|
startGame(randomSeed());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Board pointer events (handles both mouse and touch via PointerEvents API)
|
||||||
|
board.addEventListener("pointerdown", onPointerDown);
|
||||||
|
board.addEventListener("pointermove", onPointerMove);
|
||||||
|
board.addEventListener("pointerup", onPointerUp);
|
||||||
|
board.addEventListener("pointercancel", onPointerCancel);
|
||||||
|
board.addEventListener("click", onBoardClick);
|
||||||
|
board.addEventListener("dblclick", onBoardDblClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Coordinate helpers ────────────────────────────────────────────────────────
|
||||||
|
function boardRelative(clientX, clientY) {
|
||||||
|
const rect = board.getBoundingClientRect();
|
||||||
|
// Subtract board padding to get interior coordinates.
|
||||||
|
return {
|
||||||
|
x: clientX - rect.left - PAD,
|
||||||
|
y: clientY - rect.top - PAD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestCard(bx, by) {
|
||||||
|
// Walk all visible piles, find the topmost card at (bx, by).
|
||||||
|
// Returns { pileName, cardIndex, cardId } or null.
|
||||||
|
const pileOrder = [
|
||||||
|
"waste",
|
||||||
|
"foundation-0","foundation-1","foundation-2","foundation-3",
|
||||||
|
"tableau-0","tableau-1","tableau-2","tableau-3","tableau-4","tableau-5","tableau-6",
|
||||||
|
"stock",
|
||||||
|
];
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
let bestZ = -1;
|
||||||
|
|
||||||
|
for (const pileName of pileOrder) {
|
||||||
|
const cards = getPileCards(pileName);
|
||||||
|
if (!cards) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const pos = cardPos(pileName, i, cards.length, cards);
|
||||||
|
if (bx >= pos.x && bx <= pos.x + CARD_W &&
|
||||||
|
by >= pos.y && by <= pos.y + CARD_H) {
|
||||||
|
const z = cardZ(pileName, i, cards.length);
|
||||||
|
if (z > bestZ) {
|
||||||
|
bestZ = z;
|
||||||
|
best = { pileName, cardIndex: i, cardId: cards[i].id, card: cards[i] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPileCards(pileName) {
|
||||||
|
if (!snap) return null;
|
||||||
|
if (pileName === "stock") return snap.stock;
|
||||||
|
if (pileName === "waste") return snap.waste;
|
||||||
|
if (pileName.startsWith("foundation-")) {
|
||||||
|
const slot = parseInt(pileName.split("-")[1]);
|
||||||
|
return snap.foundations[slot];
|
||||||
|
}
|
||||||
|
if (pileName.startsWith("tableau-")) {
|
||||||
|
const col = parseInt(pileName.split("-")[1]);
|
||||||
|
return snap.tableaus[col];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestSlot(bx, by) {
|
||||||
|
// Returns the pile name whose slot rect contains (bx, by), favouring
|
||||||
|
// tableau column slots over top-row slots when both overlap.
|
||||||
|
for (const [pile, origin] of Object.entries(PILE_ORIGIN)) {
|
||||||
|
if (bx >= origin.x && bx <= origin.x + CARD_W &&
|
||||||
|
by >= origin.y && by <= origin.y + CARD_H) {
|
||||||
|
return pile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a tableau pile, hit-test for drop: the drop zone extends to the last
|
||||||
|
// card's bottom edge (or the slot if empty).
|
||||||
|
function findDropTarget(bx, by) {
|
||||||
|
// Check tableau columns first (they have tall hit areas).
|
||||||
|
for (let c = 0; c < 7; c++) {
|
||||||
|
const pile = `tableau-${c}`;
|
||||||
|
const cards = snap.tableaus[c];
|
||||||
|
const origin = PILE_ORIGIN[pile];
|
||||||
|
// Top boundary: origin.y. Bottom boundary: last card bottom or empty slot.
|
||||||
|
const bottomY = cards.length > 0
|
||||||
|
? origin.y + (cards.length - 1) * FAN + CARD_H
|
||||||
|
: origin.y + CARD_H;
|
||||||
|
if (bx >= origin.x && bx <= origin.x + CARD_W &&
|
||||||
|
by >= origin.y && by <= bottomY) {
|
||||||
|
return pile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Foundation slots (top row).
|
||||||
|
for (let s = 0; s < 4; s++) {
|
||||||
|
const pile = `foundation-${s}`;
|
||||||
|
const origin = PILE_ORIGIN[pile];
|
||||||
|
if (bx >= origin.x && bx <= origin.x + CARD_W &&
|
||||||
|
by >= origin.y && by <= origin.y + CARD_H) {
|
||||||
|
return pile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pointer event handlers ────────────────────────────────────────────────────
|
||||||
|
function onPointerDown(e) {
|
||||||
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
|
if (drag) return; // ignore second finger
|
||||||
|
|
||||||
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
|
const hit = hitTestCard(bx, by);
|
||||||
|
if (!hit) return;
|
||||||
|
if (!hit.card.face_up) return; // can't drag face-down cards
|
||||||
|
|
||||||
|
const cards = getPileCards(hit.pileName);
|
||||||
|
if (!cards) return;
|
||||||
|
|
||||||
|
// For tableau, allow dragging a run from any face-up card.
|
||||||
|
// For waste/foundation, only the top card.
|
||||||
|
let fromIndex = hit.cardIndex;
|
||||||
|
if (!hit.pileName.startsWith("tableau-")) {
|
||||||
|
fromIndex = cards.length - 1; // only top card
|
||||||
|
if (hit.cardIndex !== fromIndex) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedCards = cards.slice(fromIndex);
|
||||||
|
if (draggedCards.some(c => !c.face_up)) return; // face-down in run — blocked
|
||||||
|
|
||||||
|
const cardOriginPos = cardPos(hit.pileName, fromIndex, cards.length, cards);
|
||||||
|
|
||||||
|
drag = {
|
||||||
|
fromPile: hit.pileName,
|
||||||
|
fromIndex,
|
||||||
|
cardIds: draggedCards.map(c => c.id),
|
||||||
|
startX: bx,
|
||||||
|
startY: by,
|
||||||
|
offsetX: bx - cardOriginPos.x,
|
||||||
|
offsetY: by - cardOriginPos.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lift the dragged cards visually.
|
||||||
|
drag.cardIds.forEach((id, i) => {
|
||||||
|
const el = cardEls.get(id);
|
||||||
|
if (el) {
|
||||||
|
el.classList.add("selected");
|
||||||
|
el.style.zIndex = 500 + i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
board.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!drag) return;
|
||||||
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
|
const dx = bx - drag.startX;
|
||||||
|
const dy = by - drag.startY;
|
||||||
|
|
||||||
|
const cards = getPileCards(drag.fromPile);
|
||||||
|
drag.cardIds.forEach((id, i) => {
|
||||||
|
const el = cardEls.get(id);
|
||||||
|
if (!el) return;
|
||||||
|
const basePos = cardPos(drag.fromPile, drag.fromIndex + i, (cards ? cards.length : 1), null);
|
||||||
|
el.style.transform = `translate(${basePos.x + dx}px, ${basePos.y + dy}px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight drop target.
|
||||||
|
board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active"));
|
||||||
|
board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target"));
|
||||||
|
const targetPile = findDropTarget(bx, by);
|
||||||
|
if (targetPile) {
|
||||||
|
const slotEl = board.querySelector(`.slot[data-pile="${targetPile}"]`);
|
||||||
|
if (slotEl) slotEl.classList.add("drop-active");
|
||||||
|
const targetCards = getPileCards(targetPile);
|
||||||
|
if (targetCards && targetCards.length > 0) {
|
||||||
|
const topCard = cardEls.get(targetCards[targetCards.length - 1].id);
|
||||||
|
if (topCard) topCard.classList.add("drop-target");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e) {
|
||||||
|
if (!drag) return;
|
||||||
|
board.querySelectorAll(".slot.drop-active").forEach(el => el.classList.remove("drop-active"));
|
||||||
|
board.querySelectorAll(".card.drop-target").forEach(el => el.classList.remove("drop-target"));
|
||||||
|
|
||||||
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
|
const targetPile = findDropTarget(bx, by);
|
||||||
|
|
||||||
|
let moved = false;
|
||||||
|
if (targetPile && targetPile !== drag.fromPile) {
|
||||||
|
const count = drag.cardIds.length;
|
||||||
|
const r = game.move_cards(drag.fromPile, targetPile, count);
|
||||||
|
if (r.ok) {
|
||||||
|
render(r.snapshot);
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
// Snap cards back to their original positions.
|
||||||
|
drag.cardIds.forEach(id => {
|
||||||
|
const el = cardEls.get(id);
|
||||||
|
if (el) el.classList.remove("selected");
|
||||||
|
});
|
||||||
|
render(snap); // re-render restores transforms
|
||||||
|
}
|
||||||
|
|
||||||
|
drag = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel() {
|
||||||
|
if (drag) {
|
||||||
|
drag.cardIds.forEach(id => {
|
||||||
|
const el = cardEls.get(id);
|
||||||
|
if (el) el.classList.remove("selected");
|
||||||
|
});
|
||||||
|
render(snap);
|
||||||
|
drag = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Click handlers ────────────────────────────────────────────────────────────
|
||||||
|
function onBoardClick(e) {
|
||||||
|
if (drag) return; // swallowed by pointer-up
|
||||||
|
|
||||||
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
|
|
||||||
|
// Stock click → draw.
|
||||||
|
const stockOrigin = PILE_ORIGIN.stock;
|
||||||
|
if (bx >= stockOrigin.x && bx <= stockOrigin.x + CARD_W &&
|
||||||
|
by >= stockOrigin.y && by <= stockOrigin.y + CARD_H) {
|
||||||
|
const r = game.draw();
|
||||||
|
if (r.ok) render(r.snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBoardDblClick(e) {
|
||||||
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
|
const hit = hitTestCard(bx, by);
|
||||||
|
if (!hit || !hit.card.face_up) return;
|
||||||
|
|
||||||
|
// Only try to move the top card of its pile.
|
||||||
|
const cards = getPileCards(hit.pileName);
|
||||||
|
if (!cards || hit.cardIndex !== cards.length - 1) return;
|
||||||
|
|
||||||
|
// Try each foundation slot.
|
||||||
|
for (let s = 0; s < 4; s++) {
|
||||||
|
const r = game.move_cards(hit.pileName, `foundation-${s}`, 1);
|
||||||
|
if (r.ok) { render(r.snapshot); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
bootstrap().catch(console.error);
|
||||||
@@ -71,6 +71,103 @@ export class ReplayPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
||||||
|
*
|
||||||
|
* Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
||||||
|
* `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
|
||||||
|
* full pile snapshot at any time without mutating state.
|
||||||
|
*/
|
||||||
|
export class SolitaireGame {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
SolitaireGameFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_solitairegame_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Apply one auto-complete move (only valid when `is_auto_completable`).
|
||||||
|
* Returns the post-move snapshot or `null` when auto-complete is unavailable.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
auto_complete_step() {
|
||||||
|
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
||||||
|
* Returns `{ok, error?, snapshot?}`.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
draw() {
|
||||||
|
const ret = wasm.solitairegame_draw(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Move `count` cards from pile `from` to pile `to`.
|
||||||
|
*
|
||||||
|
* Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
|
||||||
|
* `"tableau-0"` .. `"tableau-6"`.
|
||||||
|
*
|
||||||
|
* Returns `{ok, error?, snapshot?}`.
|
||||||
|
* @param {string} from
|
||||||
|
* @param {string} to
|
||||||
|
* @param {number} count
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
move_cards(from, to, count) {
|
||||||
|
const ptr0 = passStringToWasm0(from, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passStringToWasm0(to, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.solitairegame_move_cards(this.__wbg_ptr, ptr0, len0, ptr1, len1, count);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a new DrawOne or DrawThree Classic game from the given seed.
|
||||||
|
*
|
||||||
|
* `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
|
||||||
|
* Pass `Date.now()` or a random integer from JS for variety.
|
||||||
|
* @param {number} seed
|
||||||
|
* @param {boolean} draw_three
|
||||||
|
*/
|
||||||
|
constructor(seed, draw_three) {
|
||||||
|
const ret = wasm.solitairegame_new(seed, draw_three);
|
||||||
|
this.__wbg_ptr = ret;
|
||||||
|
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The seed used to deal this game.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
seed() {
|
||||||
|
const ret = wasm.solitairegame_seed(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Full pile snapshot as a JS object.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
state() {
|
||||||
|
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
undo() {
|
||||||
|
const ret = wasm.solitairegame_undo(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) SolitaireGame.prototype[Symbol.dispose] = SolitaireGame.prototype.free;
|
||||||
function __wbg_get_imports() {
|
function __wbg_get_imports() {
|
||||||
const import0 = {
|
const import0 = {
|
||||||
__proto__: null,
|
__proto__: null,
|
||||||
@@ -151,6 +248,9 @@ function __wbg_get_imports() {
|
|||||||
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
? { register: () => {}, unregister: () => {} }
|
? { register: () => {}, unregister: () => {} }
|
||||||
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
||||||
|
const SolitaireGameFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_solitairegame_free(ptr, 1));
|
||||||
|
|
||||||
let cachedDataViewMemory0 = null;
|
let cachedDataViewMemory0 = null;
|
||||||
function getDataViewMemory0() {
|
function getDataViewMemory0() {
|
||||||
|
|||||||
Binary file not shown.
+203
-1
@@ -1,4 +1,4 @@
|
|||||||
//! WebAssembly bindings for browser-side replay playback.
|
//! WebAssembly bindings for browser-side replay playback and interactive gameplay.
|
||||||
//!
|
//!
|
||||||
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
|
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
|
||||||
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
|
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
|
||||||
@@ -222,6 +222,208 @@ impl ReplayPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interactive game surface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct GameSnapshot {
|
||||||
|
pub score: i32,
|
||||||
|
pub move_count: u32,
|
||||||
|
pub is_won: bool,
|
||||||
|
pub is_auto_completable: bool,
|
||||||
|
pub undo_count: u32,
|
||||||
|
pub stock: Vec<CardSnapshot>,
|
||||||
|
pub waste: Vec<CardSnapshot>,
|
||||||
|
pub foundations: [Vec<CardSnapshot>; 4],
|
||||||
|
pub tableaus: [Vec<CardSnapshot>; 7],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result returned to JS from every mutating game action.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ActionResult {
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub snapshot: Option<GameSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
||||||
|
///
|
||||||
|
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
||||||
|
/// `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
|
||||||
|
/// full pile snapshot at any time without mutating state.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct SolitaireGame {
|
||||||
|
game: GameState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolitaireGame {
|
||||||
|
fn snap(&self) -> GameSnapshot {
|
||||||
|
let cards = |t: PileType| -> Vec<CardSnapshot> {
|
||||||
|
self.game
|
||||||
|
.piles
|
||||||
|
.get(&t)
|
||||||
|
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
GameSnapshot {
|
||||||
|
score: self.game.score,
|
||||||
|
move_count: self.game.move_count,
|
||||||
|
is_won: self.game.is_won,
|
||||||
|
is_auto_completable: self.game.is_auto_completable,
|
||||||
|
undo_count: self.game.undo_count,
|
||||||
|
stock: cards(PileType::Stock),
|
||||||
|
waste: cards(PileType::Waste),
|
||||||
|
foundations: [
|
||||||
|
cards(PileType::Foundation(0)),
|
||||||
|
cards(PileType::Foundation(1)),
|
||||||
|
cards(PileType::Foundation(2)),
|
||||||
|
cards(PileType::Foundation(3)),
|
||||||
|
],
|
||||||
|
tableaus: [
|
||||||
|
cards(PileType::Tableau(0)),
|
||||||
|
cards(PileType::Tableau(1)),
|
||||||
|
cards(PileType::Tableau(2)),
|
||||||
|
cards(PileType::Tableau(3)),
|
||||||
|
cards(PileType::Tableau(4)),
|
||||||
|
cards(PileType::Tableau(5)),
|
||||||
|
cards(PileType::Tableau(6)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pile_from_str(s: &str) -> Result<PileType, String> {
|
||||||
|
match s {
|
||||||
|
"stock" => Ok(PileType::Stock),
|
||||||
|
"waste" => Ok(PileType::Waste),
|
||||||
|
_ if s.starts_with("foundation-") => {
|
||||||
|
let slot: u8 = s["foundation-".len()..]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("bad pile: {s}"))?;
|
||||||
|
if slot >= 4 {
|
||||||
|
return Err(format!("foundation slot out of range: {slot}"));
|
||||||
|
}
|
||||||
|
Ok(PileType::Foundation(slot))
|
||||||
|
}
|
||||||
|
_ if s.starts_with("tableau-") => {
|
||||||
|
let col: usize = s["tableau-".len()..]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("bad pile: {s}"))?;
|
||||||
|
if col >= 7 {
|
||||||
|
return Err(format!("tableau col out of range: {col}"));
|
||||||
|
}
|
||||||
|
Ok(PileType::Tableau(col))
|
||||||
|
}
|
||||||
|
_ => Err(format!("unknown pile: {s}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok_js(&self) -> JsValue {
|
||||||
|
serde_wasm_bindgen::to_value(&ActionResult {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
snapshot: Some(self.snap()),
|
||||||
|
})
|
||||||
|
.unwrap_or(JsValue::NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err_js(msg: impl std::fmt::Display) -> JsValue {
|
||||||
|
serde_wasm_bindgen::to_value(&ActionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some(msg.to_string()),
|
||||||
|
snapshot: None,
|
||||||
|
})
|
||||||
|
.unwrap_or(JsValue::NULL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl SolitaireGame {
|
||||||
|
/// Create a new DrawOne or DrawThree Classic game from the given seed.
|
||||||
|
///
|
||||||
|
/// `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
|
||||||
|
/// Pass `Date.now()` or a random integer from JS for variety.
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(seed: f64, draw_three: bool) -> SolitaireGame {
|
||||||
|
#[cfg(feature = "console_error_panic_hook")]
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let dm = if draw_three {
|
||||||
|
DrawMode::DrawThree
|
||||||
|
} else {
|
||||||
|
DrawMode::DrawOne
|
||||||
|
};
|
||||||
|
SolitaireGame {
|
||||||
|
game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full pile snapshot as a JS object.
|
||||||
|
pub fn state(&self) -> JsValue {
|
||||||
|
serde_wasm_bindgen::to_value(&self.snap()).unwrap_or(JsValue::NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seed used to deal this game.
|
||||||
|
pub fn seed(&self) -> f64 {
|
||||||
|
self.game.seed as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw from stock to waste (or recycle waste → stock when stock is empty).
|
||||||
|
/// Returns `{ok, error?, snapshot?}`.
|
||||||
|
pub fn draw(&mut self) -> JsValue {
|
||||||
|
match self.game.draw() {
|
||||||
|
Ok(()) => self.ok_js(),
|
||||||
|
Err(e) => Self::err_js(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `count` cards from pile `from` to pile `to`.
|
||||||
|
///
|
||||||
|
/// Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
|
||||||
|
/// `"tableau-0"` .. `"tableau-6"`.
|
||||||
|
///
|
||||||
|
/// Returns `{ok, error?, snapshot?}`.
|
||||||
|
pub fn move_cards(&mut self, from: &str, to: &str, count: usize) -> JsValue {
|
||||||
|
let from_pile = match Self::pile_from_str(from) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return Self::err_js(e),
|
||||||
|
};
|
||||||
|
let to_pile = match Self::pile_from_str(to) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return Self::err_js(e),
|
||||||
|
};
|
||||||
|
match self.game.move_cards(from_pile, to_pile, count) {
|
||||||
|
Ok(()) => self.ok_js(),
|
||||||
|
Err(e) => Self::err_js(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the last move. Returns `{ok, error?, snapshot?}`.
|
||||||
|
pub fn undo(&mut self) -> JsValue {
|
||||||
|
match self.game.undo() {
|
||||||
|
Ok(()) => self.ok_js(),
|
||||||
|
Err(e) => Self::err_js(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one auto-complete move (only valid when `is_auto_completable`).
|
||||||
|
/// Returns the post-move snapshot or `null` when auto-complete is unavailable.
|
||||||
|
pub fn auto_complete_step(&mut self) -> JsValue {
|
||||||
|
if !self.game.is_auto_completable {
|
||||||
|
return JsValue::NULL;
|
||||||
|
}
|
||||||
|
match self.game.next_auto_complete_move() {
|
||||||
|
Some((from, to)) => {
|
||||||
|
let _ = self.game.move_cards(from, to, 1);
|
||||||
|
self.ok_js()
|
||||||
|
}
|
||||||
|
None => JsValue::NULL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user