Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3322fd4250 | |||
| 90eb5fd207 |
@@ -230,6 +230,68 @@ main {
|
|||||||
pointer-events: none;
|
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 ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#win-overlay {
|
#win-overlay {
|
||||||
|
|||||||
@@ -56,6 +56,17 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</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 id="win-overlay" class="hidden">
|
||||||
<div class="win-card">
|
<div class="win-card">
|
||||||
<div class="win-title">You Won!</div>
|
<div class="win-title">You Won!</div>
|
||||||
|
|||||||
@@ -69,6 +69,34 @@ function preloadTheme(theme) {
|
|||||||
preloadTheme("classic");
|
preloadTheme("classic");
|
||||||
preloadTheme("dark");
|
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 ────────────────────────────────────────────────────────────────────
|
// ── State ────────────────────────────────────────────────────────────────────
|
||||||
let game = null;
|
let game = null;
|
||||||
let snap = null; // last rendered GameSnapshot
|
let snap = null; // last rendered GameSnapshot
|
||||||
@@ -138,16 +166,72 @@ async function bootstrap() {
|
|||||||
await init();
|
await init();
|
||||||
syncThemeButton();
|
syncThemeButton();
|
||||||
|
|
||||||
|
buildSlots();
|
||||||
|
scaleBoard();
|
||||||
|
window.addEventListener("resize", scaleBoard);
|
||||||
|
attachHandlers();
|
||||||
|
|
||||||
|
const saved = loadSave();
|
||||||
|
if (saved) {
|
||||||
|
showResumeDialog(saved);
|
||||||
|
} else {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
||||||
drawThree = params.has("draw3");
|
drawThree = params.has("draw3");
|
||||||
chkDraw3.checked = drawThree;
|
chkDraw3.checked = drawThree;
|
||||||
|
|
||||||
buildSlots();
|
|
||||||
scaleBoard();
|
|
||||||
window.addEventListener("resize", scaleBoard);
|
|
||||||
startGame(urlSeed);
|
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() {
|
function randomSeed() {
|
||||||
@@ -304,9 +388,12 @@ function render(s) {
|
|||||||
acTimer = setInterval(doAutoCompleteStep, 380);
|
acTimer = setInterval(doAutoCompleteStep, 380);
|
||||||
}
|
}
|
||||||
if (s.is_won) {
|
if (s.is_won) {
|
||||||
|
clearSave();
|
||||||
stopTimer();
|
stopTimer();
|
||||||
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||||
showWin(s);
|
showWin(s);
|
||||||
|
} else {
|
||||||
|
saveState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,9 +366,10 @@ impl SolitaireGame {
|
|||||||
} else {
|
} else {
|
||||||
DrawMode::DrawOne
|
DrawMode::DrawOne
|
||||||
};
|
};
|
||||||
SolitaireGame {
|
let mut game = GameState::new_with_mode(seed as u64, dm, GameMode::Classic);
|
||||||
game: GameState::new_with_mode(seed as u64, dm, GameMode::Classic),
|
// The web client has no settings layer; enable standard Klondike rule unconditionally.
|
||||||
}
|
game.take_from_foundation = true;
|
||||||
|
SolitaireGame { game }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full pile snapshot as a JS object.
|
/// Full pile snapshot as a JS object.
|
||||||
@@ -422,6 +423,30 @@ 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(|mut game| {
|
||||||
|
// Older saves serialised with take_from_foundation=false (the core default).
|
||||||
|
// The web client has no settings layer, so enforce the standard rule here.
|
||||||
|
game.take_from_foundation = true;
|
||||||
|
SolitaireGame { game }
|
||||||
|
})
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply one auto-complete move (only valid when `is_auto_completable`).
|
/// Apply one auto-complete move (only valid when `is_auto_completable`).
|
||||||
///
|
///
|
||||||
/// If no card can go directly to a foundation this step, advances the
|
/// If no card can go directly to a foundation this step, advances the
|
||||||
|
|||||||
Reference in New Issue
Block a user