Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e7de1093 | |||
| b5a780ddf4 | |||
| 3322fd4250 | |||
| 90eb5fd207 | |||
| 76cf41e7a9 | |||
| fae5933d29 | |||
| 6cd8c6c013 |
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: ea9dd848
|
||||
newTag: 90eb5fd2
|
||||
|
||||
@@ -193,7 +193,7 @@ impl GameState {
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
take_from_foundation: false,
|
||||
take_from_foundation: true,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
undo_stack: VecDeque::new(),
|
||||
}
|
||||
@@ -1408,9 +1408,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_disabled_by_default() {
|
||||
fn take_from_foundation_enabled_by_default() {
|
||||
let g = setup_take_from_foundation_game();
|
||||
assert!(!g.take_from_foundation, "take_from_foundation is off by default (non-standard rule)");
|
||||
assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1147,7 +1147,7 @@ fn add_android_corner_label(
|
||||
let bg_w = font_size * 2.0;
|
||||
let bg_h = font_size * 1.25;
|
||||
|
||||
// Background covers the PNG's baked-in small corner text.
|
||||
// Background covers the PNG's baked-in small corner text (top-left).
|
||||
// Classic PNG cards have a white face, so the background must be white too.
|
||||
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||
parent.spawn((
|
||||
@@ -1163,6 +1163,20 @@ fn add_android_corner_label(
|
||||
0.015,
|
||||
),
|
||||
));
|
||||
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(
|
||||
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||
0.015,
|
||||
),
|
||||
));
|
||||
|
||||
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||
|
||||
@@ -202,6 +202,8 @@ impl Plugin for GamePlugin {
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<AppLifecycle>()
|
||||
// add_message is idempotent; SettingsPlugin also registers this.
|
||||
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
poll_pending_new_game_seed.before(GameMutation),
|
||||
@@ -228,6 +230,7 @@ impl Plugin for GamePlugin {
|
||||
// GameMutation flow.
|
||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
||||
.add_systems(Update, sync_settings_to_game.before(GameMutation))
|
||||
.init_resource::<AutoSaveTimer>()
|
||||
.add_systems(Update, tick_elapsed_time)
|
||||
.add_systems(Update, auto_save_game_state)
|
||||
@@ -235,6 +238,23 @@ impl Plugin for GamePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards `take_from_foundation` from [`SettingsResource`] to the live
|
||||
/// [`GameStateResource`] every time [`SettingsChangedEvent`] fires.
|
||||
///
|
||||
/// This covers two cases that the new-game path misses:
|
||||
/// 1. The initial settings load at startup: saves on disk default to `false`
|
||||
/// but `Settings` defaults to `true`; the event fires once when the
|
||||
/// settings file is first read.
|
||||
/// 2. A user toggling the setting mid-session in the Settings panel.
|
||||
fn sync_settings_to_game(
|
||||
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
game.0.take_from_foundation = ev.0.take_from_foundation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, testable helper. Updates `elapsed_seconds` and drains the
|
||||
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
|
||||
pub fn advance_elapsed(
|
||||
@@ -614,6 +634,7 @@ fn handle_restore_prompt(
|
||||
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
|
||||
mut pending: ResMut<PendingRestoredGame>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
|
||||
@@ -639,6 +660,10 @@ fn handle_restore_prompt(
|
||||
let resolved = if key_continue || click_continue {
|
||||
if let Some(restored) = pending.0.take() {
|
||||
game.0 = restored;
|
||||
// Patch setting that serialized with the old core default of `false`.
|
||||
if let Some(s) = settings.as_ref() {
|
||||
game.0.take_from_foundation = s.0.take_from_foundation;
|
||||
}
|
||||
changed.write(StateChangedEvent);
|
||||
}
|
||||
for entity in &screens {
|
||||
|
||||
@@ -989,7 +989,7 @@ fn spawn_action_button<M: Component>(
|
||||
// centred with room to breathe. On desktop, keep the comfortable 48 dp
|
||||
// floor and 8 dp side padding.
|
||||
#[cfg(target_os = "android")]
|
||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0));
|
||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ pub const HUD_BAND_HEIGHT: f32 = 112.0;
|
||||
///
|
||||
/// Derivation (Android): `min_height 44 px` buttons
|
||||
/// + `padding.top 8 px` + `padding.bottom 8 px` outer bar padding = **60 px**.
|
||||
///
|
||||
/// Desktop: no persistent bottom bar, so 0.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const BOTTOM_BAR_HEIGHT: f32 = 0.0;
|
||||
|
||||
@@ -473,8 +473,11 @@ fn radial_open_on_long_press(
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
) {
|
||||
// Guard: only count while a touch is down, uncommitted, and radial is idle.
|
||||
let active_id = drag.active_touch_id;
|
||||
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
||||
let Some(active_id) = drag.active_touch_id else {
|
||||
*hold_timer = 0.0;
|
||||
return;
|
||||
};
|
||||
if drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
||||
*hold_timer = 0.0;
|
||||
return;
|
||||
}
|
||||
@@ -487,7 +490,7 @@ fn radial_open_on_long_press(
|
||||
|
||||
// Resolve current touch world position.
|
||||
let Some(touches) = touches else { return };
|
||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
|
||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
|
||||
return;
|
||||
};
|
||||
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
||||
|
||||
@@ -94,7 +94,7 @@ pub struct SettingsChangedEvent(pub Settings);
|
||||
|
||||
/// Marker on the root Settings panel entity.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanel;
|
||||
pub struct SettingsPanel;
|
||||
|
||||
/// Marks the `Text` node showing the live SFX volume value.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -1137,6 +1137,7 @@ fn handle_sync_buttons(
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
@@ -1144,7 +1145,12 @@ fn handle_sync_buttons(
|
||||
}
|
||||
match button {
|
||||
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
|
||||
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
|
||||
SettingsButton::ConnectSync => {
|
||||
// Close settings before the sync-setup modal opens so the
|
||||
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||
screen.0 = false;
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
|
||||
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
|
||||
_ => {}
|
||||
|
||||
@@ -52,7 +52,7 @@ use crate::events::{
|
||||
SyncLogoutRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
use crate::resources::TokioRuntimeResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{spawn_modal, ModalScrim};
|
||||
@@ -205,10 +205,14 @@ impl Plugin for SyncSetupPlugin {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn open_sync_setup_modal(
|
||||
mut events: MessageReader<SyncConfigureRequestEvent>,
|
||||
existing: Query<(), With<SyncSetupScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>)>,
|
||||
// Exclude SettingsPanel: the Connect button closes settings in the same
|
||||
// frame it fires SyncConfigureRequestEvent, but Bevy despawns are deferred
|
||||
// so the settings scrim still exists in the world during this system.
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>, Without<SettingsPanel>)>,
|
||||
mut commands: Commands,
|
||||
mut focused: ResMut<SyncFocusedField>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
startGame(urlSeed);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +422,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`).
|
||||
///
|
||||
/// If no card can go directly to a foundation this step, advances the
|
||||
|
||||
Reference in New Issue
Block a user