// 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 / --pad) const CARD_W = 80; const CARD_H = 112; const GAP = 12; const PAD = 20; // board inner padding — cards start at (PAD, PAD) const FAN = 28; // vertical offset per fanned tableau card const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan // Pile origins in board-element coordinates (include PAD so (0,0) = board edge). const TOP_Y = PAD; const BOTTOM_Y = PAD + CARD_H + 28; const colX = (c) => PAD + c * (CARD_W + GAP); 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 }, }; // Foundation suit hints shown when the slot is empty. const FOUND_SUIT_HINT = ["♠", "♥", "♦", "♣"]; 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-id → DOM element map. const cardEls = new Map(); // Drag state let drag = null; // drag = { // fromPile: string, // fromIndex: number, // index of bottom dragged card in its pile // cardIds: number[], // ids bottom→top // startX: number, startY: number, // board-relative pointer start // } // Timer let timerInterval = null; let elapsedSecs = 0; // Auto-complete let acTimer = null; // ── DOM refs ───────────────────────────────────────────────────────────────── const board = document.getElementById("board"); const hudScore = document.getElementById("hud-score"); const hudMoves = document.getElementById("hud-moves"); const hudTimer = document.getElementById("hud-timer"); const hudStock = document.getElementById("hud-stock"); 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 winTime = document.getElementById("win-time"); const btnWinNew = document.getElementById("btn-win-new"); // ── Bootstrap ──────────────────────────────────────────────────────────────── async function bootstrap() { await init(); 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() { return Math.floor(Math.random() * 9007199254740991); } function startGame(seed) { if (acTimer) { clearInterval(acTimer); acTimer = null; } if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } elapsedSecs = 0; updateTimerDisplay(); game = new SolitaireGame(seed, drawThree); snap = game.state(); const displaySeed = Math.round(game.seed()); hudSeed.textContent = `seed ${displaySeed}`; winOverlay.classList.add("hidden"); cardEls.clear(); board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove()); // Persist seed in URL so the game can be shared / refreshed. const url = new URL(window.location); url.searchParams.set("seed", displaySeed); if (drawThree) url.searchParams.set("draw3", ""); else url.searchParams.delete("draw3"); history.replaceState(null, "", url); render(snap); startTimer(); } // ── Timer ──────────────────────────────────────────────────────────────────── function startTimer() { timerInterval = setInterval(() => { elapsedSecs++; updateTimerDisplay(); }, 1000); } function stopTimer() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } } function updateTimerDisplay() { const m = Math.floor(elapsedSecs / 60); const s = elapsedSecs % 60; if (hudTimer) hudTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`; } // ── 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)`; if (pile.startsWith("foundation-")) { const slot = parseInt(pile.split("-")[1]); const hint = document.createElement("div"); hint.className = "slot-hint"; hint.textContent = FOUND_SUIT_HINT[slot]; el.appendChild(hint); } board.appendChild(el); } } // ── Card position math ──────────────────────────────────────────────────────── function cardPos(pileName, indexInPile, pileLength) { const origin = PILE_ORIGIN[pileName]; let x = origin.x; let y = origin.y; if (pileName === "waste" && drawThree && pileLength >= 2) { const fanStart = Math.max(0, pileLength - 3); const fanPos = indexInPile - fanStart; if (fanPos > 0) x += fanPos * WASTE_FAN; } else if (pileName.startsWith("tableau-")) { y += indexInPile * FAN; } return { x, y }; } function cardZ(pileName, indexInPile) { return 10 + indexInPile; } // ── Renderer ────────────────────────────────────────────────────────────────── function render(s) { snap = s; hudScore.textContent = `Score: ${s.score}`; hudMoves.textContent = `Moves: ${s.move_count}`; if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`; btnUndo.disabled = s.undo_stack_len === 0; const visible = new Map(); const addPile = (name, cards) => cards.forEach((c, i) => visible.set(c.id, { pile: name, 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)); for (const [id, info] of visible) { let el = cardEls.get(id); if (!el) { el = document.createElement("div"); el.dataset.cardId = id; cardEls.set(id, el); board.appendChild(el); } updateCardEl(el, info.card, info.pile, info.idx, info.total); } for (const [id, el] of cardEls) { if (!visible.has(id)) { el.remove(); cardEls.delete(id); } } // Foundation suit hints: hide when pile has cards. s.foundations.forEach((f, i) => { const slotEl = board.querySelector(`.slot[data-pile="foundation-${i}"]`); if (slotEl) { const hint = slotEl.querySelector(".slot-hint"); if (hint) hint.style.visibility = f.length > 0 ? "hidden" : ""; } }); // 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.transform = `translate(${o.x + CARD_W / 2}px, ${o.y + CARD_H / 2}px)`; } else if (recycleEl) { recycleEl.remove(); } // Clear drag highlights left from pointer-move. board.querySelectorAll(".slot.drop-active").forEach(e => e.classList.remove("drop-active")); board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target")); if (s.is_auto_completable && !s.is_won && !acTimer) { acTimer = setInterval(doAutoCompleteStep, 380); } if (s.is_won) { stopTimer(); if (acTimer) { clearInterval(acTimer); acTimer = null; } showWin(s); } } function updateCardEl(el, card, pileName, idx, total) { const pos = cardPos(pileName, idx, total); el.style.transform = `translate(${pos.x}px, ${pos.y}px)`; el.style.zIndex = cardZ(pileName, idx); if (!card.face_up) { el.className = "card face-down"; el.innerHTML = ""; } else { const isRed = RED_SUITS.has(card.suit); el.className = `card ${isRed ? "red" : "black"}`; const r = RANK_LABELS[card.rank]; const s = SUIT_GLYPH[card.suit]; el.innerHTML = `