feat(web): persist game state across page refreshes with resume dialog
Build and Deploy / build-and-push (push) Successful in 2m54s
Android Release / build-apk (push) Successful in 4m38s

- solitaire_wasm: add SolitaireGame::serialize() and from_saved() so JS
  can round-trip the full GameState through localStorage as JSON
- game.js: save {gameState, elapsedSecs, drawThree} to localStorage
  (key: fs_game_save) on every render(); clear the save on win
- game.js: on bootstrap, check for a saved game and show a resume
  dialog if one exists; Resume restores state + timer, New Game discards
  the save and starts fresh with a random seed
- game.html: add #resume-overlay markup (same pattern as win-overlay)
- game.css: add styles for the resume dialog and its secondary button

localStorage failures (private-browsing quota) are silently ignored so
they never block gameplay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-19 15:38:07 -07:00
parent 76cf41e7a9
commit 90eb5fd207
4 changed files with 185 additions and 6 deletions
+62
View File
@@ -230,6 +230,68 @@ main {
pointer-events: none;
}
/* ── Resume overlay ──────────────────────────────────────────────────── */
#resume-overlay {
position: fixed;
inset: 0;
background: rgba(21, 21, 21, 0.92);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
#resume-overlay.hidden { display: none; }
.resume-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);
max-width: 360px;
}
.resume-title {
font-size: 28px;
font-weight: 700;
color: var(--accent);
}
.resume-detail {
font-size: 14px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.resume-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 8px;
}
.resume-actions button {
padding: 12px 24px;
font-size: 15px;
}
.resume-actions button.secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: var(--text-muted);
}
.resume-actions button.secondary:hover {
background: rgba(255,255,255,0.05);
}
/* ── Win overlay ─────────────────────────────────────────────────────── */
#win-overlay {
+11
View File
@@ -56,6 +56,17 @@
</section>
</main>
<div id="resume-overlay" class="hidden">
<div class="resume-card">
<div class="resume-title">Resume Game?</div>
<p class="resume-detail">You have an unfinished game saved. Would you like to continue where you left off?</p>
<div class="resume-actions">
<button id="btn-resume">↩ Resume</button>
<button id="btn-resume-new" class="secondary">↺ New Game</button>
</div>
</div>
</div>
<div id="win-overlay" class="hidden">
<div class="win-card">
<div class="win-title">You Won!</div>
+92 -5
View File
@@ -69,6 +69,34 @@ function preloadTheme(theme) {
preloadTheme("classic");
preloadTheme("dark");
// ── Persistence ──────────────────────────────────────────────────────────────
const LS_SAVE_KEY = "fs_game_save";
function saveState() {
if (!game) return;
try {
const gameState = game.serialize();
if (typeof gameState !== "string") return;
localStorage.setItem(LS_SAVE_KEY, JSON.stringify({ gameState, elapsedSecs, drawThree }));
} catch (e) {
// localStorage may be unavailable (private browsing quota, etc.) — never block gameplay.
console.warn("fs: save failed", e);
}
}
function clearSave() {
try { localStorage.removeItem(LS_SAVE_KEY); } catch { /* ignore */ }
}
function loadSave() {
try {
const raw = localStorage.getItem(LS_SAVE_KEY);
if (!raw) return null;
const save = JSON.parse(raw);
return save?.gameState ? save : null;
} catch { return null; }
}
// ── State ────────────────────────────────────────────────────────────────────
let game = null;
let snap = null; // last rendered GameSnapshot
@@ -138,16 +166,72 @@ async function bootstrap() {
await init();
syncThemeButton();
buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
attachHandlers();
const saved = loadSave();
if (saved) {
showResumeDialog(saved);
} else {
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();
scaleBoard();
window.addEventListener("resize", scaleBoard);
startGame(urlSeed);
attachHandlers();
}
}
function showResumeDialog(saved) {
const overlay = document.getElementById("resume-overlay");
if (overlay) overlay.classList.remove("hidden");
document.getElementById("btn-resume").onclick = () => {
if (overlay) overlay.classList.add("hidden");
resumeGame(saved);
};
document.getElementById("btn-resume-new").onclick = () => {
clearSave();
if (overlay) overlay.classList.add("hidden");
drawThree = false;
chkDraw3.checked = false;
startGame(randomSeed());
};
}
function resumeGame(saved) {
let restored;
try {
restored = SolitaireGame.from_saved(saved.gameState);
} catch (e) {
console.warn("fs: restore failed, starting new game", e);
clearSave();
startGame(randomSeed());
return;
}
game = restored;
drawThree = !!saved.drawThree;
elapsedSecs = saved.elapsedSecs || 0;
chkDraw3.checked = drawThree;
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());
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);
const s = game.state();
snap = s;
render(s);
if (!s.is_won) startTimer();
}
function randomSeed() {
@@ -304,9 +388,12 @@ function render(s) {
acTimer = setInterval(doAutoCompleteStep, 380);
}
if (s.is_won) {
clearSave();
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
showWin(s);
} else {
saveState();
}
}
+19
View File
@@ -422,6 +422,25 @@ impl SolitaireGame {
}
}
/// Serialise the full game state as a JSON string for `localStorage`.
///
/// Use [`SolitaireGame::from_saved`] to restore it. The returned string is
/// opaque — callers should treat it as a blob and store/restore it verbatim.
pub fn serialize(&self) -> Result<String, JsValue> {
serde_json::to_string(&self.game)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
///
/// Returns an error string if the JSON is malformed or describes a state
/// that can't be deserialised (e.g. from a future schema version).
pub fn from_saved(json: &str) -> Result<SolitaireGame, JsValue> {
serde_json::from_str::<GameState>(json)
.map(|game| SolitaireGame { game })
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply one auto-complete move (only valid when `is_auto_completable`).
///
/// If no card can go directly to a foundation this step, advances the