diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 22a50a8..1eac740 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -205,6 +205,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { "/replays/{id}", 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")); Router::new() diff --git a/solitaire_server/web/game.css b/solitaire_server/web/game.css new file mode 100644 index 0000000..964065b --- /dev/null +++ b/solitaire_server/web/game.css @@ -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; +} diff --git a/solitaire_server/web/game.html b/solitaire_server/web/game.html new file mode 100644 index 0000000..1914594 --- /dev/null +++ b/solitaire_server/web/game.html @@ -0,0 +1,43 @@ + + + + + + Solitaire Quest — Play + + + +
+
+ + +
+
+ Score: 0 + Moves: 0 +
+
+ + + +
+
+ +
+
+
+ + + + + + diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js new file mode 100644 index 0000000..b82f9c7 --- /dev/null +++ b/solitaire_server/web/game.js @@ -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 = ` +
${rankLabel}
${suit}
+
${suit}
+
${rankLabel}
${suit}
`; + } +} + +// ── 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); diff --git a/solitaire_server/web/pkg/solitaire_wasm.js b/solitaire_server/web/pkg/solitaire_wasm.js index 7f477ce..cbf30e5 100644 --- a/solitaire_server/web/pkg/solitaire_wasm.js +++ b/solitaire_server/web/pkg/solitaire_wasm.js @@ -71,6 +71,103 @@ export class ReplayPlayer { } } 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() { const import0 = { __proto__: null, @@ -151,6 +248,9 @@ function __wbg_get_imports() { const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : 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; function getDataViewMemory0() { diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm index a03c60d..7ddcbff 100644 Binary files a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm and b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm differ diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 1c27d0c..0fb7ffd 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -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 `/replays/` fetches a [`Replay`] //! 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, + pub waste: Vec, + pub foundations: [Vec; 4], + pub tableaus: [Vec; 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot: Option, +} + +/// 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 { + 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 { + 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)] mod tests { use super::*;