--- a/Cargo.lock +++ b/Cargo.lock @@ -429,6 +429,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] name = "lewton" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -553,6 +559,21 @@ ] [[package]] +name = "nanoserde" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de9cf844ab1e25a0353525bd74cb889843a6215fa4a0d156fd446f4857a1b99" +dependencies = [ + "nanoserde-derive", +] + +[[package]] +name = "nanoserde-derive" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e943b2c21337b7e3ec6678500687cdc741b7639ad457f234693352075c082204" + +[[package]] name = "ndk-sys" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -654,6 +675,26 @@ ] [[package]] +name = "quad-storage" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73edba8c792f3b5a45c68a1086f6f8c1bbda34ab9399a7faabd7dc7d4e14a0e" +dependencies = [ + "lazy_static", + "nanoserde", + "quad-storage-sys", +] + +[[package]] +name = "quad-storage-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1187c77ced2cc6ae79bd6e35cc09e22159164a05c4d867e299bfbf1f801e9157" +dependencies = [ + "sapp-jsutils", +] + +[[package]] name = "quote" version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -747,6 +788,12 @@ ] [[package]] +name = "sapp-jsutils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673c9c245fda02a1e826e199133d1c3d1b8e4a392c9dfe55e4f90a2489b149" + +[[package]] name = "scorch" version = "0.4.1" dependencies = [ @@ -754,6 +801,7 @@ "fluent-bundle", "macroquad", "minisign-verify", + "quad-storage", "self-replace", "serde", "serde_json", --- a/Cargo.toml 2026-11-08 09:53:42.000000000 -0300 +++ b/Cargo.toml 2026-11-08 17:35:50.948834124 -0300 @@ -13,7 +13,6 @@ self-update = ["dep:minisign-verify", "dep:self-replace", "dep:sha2", "dep:ureq"] [dependencies] -dirs = "7.0.0" fluent-bundle = "0.16.0" macroquad = { version = "0.4.16", features = ["audio"] } minisign-verify = { version = "0.3.0", optional = true } @@ -21,10 +20,18 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" sha2 = { version = "0.11.1", optional = true } -sys-locale = "0.3.2" unic-langid = "0.9.6" ureq = { version = "3.5.0", optional = true } +# Desktop only: the data folder and the OS language have no meaning in a browser. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +dirs = "7.0.0" +sys-locale = "0.3.2" + +# Browser build (FredyGames): saves go to localStorage. +[target.'cfg(target_arch = "wasm32")'.dependencies] +quad-storage = "0.1.3" + # Puts the icon into scorch.exe (build.rs). [target.'cfg(windows)'.build-dependencies] winresource = "0.1.34" --- a/src/crash.rs +++ b/src/crash.rs @@ -27,6 +27,10 @@ /// Writes a crash report on any panic, then lets the default handler run. pub fn install() { + // Browser build: no data folder, and SystemTime::now() panics on wasm32-unknown-unknown. + if cfg!(target_arch = "wasm32") { + return; + } let default = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { // No working sound device: the audio thread gives up and the game carries on silently. Not a crash. @@ -78,10 +82,16 @@ ) } +#[cfg(not(target_arch = "wasm32"))] fn dir() -> Option { dirs::data_dir().map(|d| d.join("scorch")) } +#[cfg(target_arch = "wasm32")] +fn dir() -> Option { + None +} + fn write(text: &str) -> Option { let dir = dir()?; fs::create_dir_all(&dir).ok()?; --- a/src/i18n.rs +++ b/src/i18n.rs @@ -57,7 +57,11 @@ /// The operating system's language, if the game has it, else English. Used /// once, on the first run; after that the player's choice is saved. pub fn detect() -> Language { - match sys_locale::get_locale() { + #[cfg(target_arch = "wasm32")] + let locale: Option = None; + #[cfg(not(target_arch = "wasm32"))] + let locale = sys_locale::get_locale(); + match locale { Some(l) if l.to_lowercase().starts_with("uk") => Language::Ukrainian, _ => Language::English, } --- a/src/main.rs +++ b/src/main.rs @@ -412,7 +412,7 @@ } Some(PauseAction::SaveAndQuit) => { self.save_battle(&battle); - Screen::Exit + exit_screen() } Some(PauseAction::BattleLog) => { self.log_open = true; @@ -534,7 +534,7 @@ } } else if item("menu-about") { Screen::About - } else if item("menu-quit") { + } else if !cfg!(target_arch = "wasm32") && item("menu-quit") { Screen::Exit } else { Screen::Menu @@ -1012,6 +1012,15 @@ } } +/// What "quit" does: leave the loop on desktop; in the browser there is no window to close, so go back to the menu. +fn exit_screen() -> Screen { + if cfg!(target_arch = "wasm32") { + Screen::Menu + } else { + Screen::Exit + } +} + /// `--crash-test`: a deliberate panic, to try the crash report. fn crash_test() -> ! { panic!("crash test requested with --crash-test"); --- a/src/save.rs +++ b/src/save.rs @@ -5,8 +5,6 @@ //! Rewritten at the start of every turn, deleted when the game ends. //! - `profile.json`: settings and all-time stats. -use std::fs; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; /// Set by `--screen` (a developer aid): nothing is written or deleted. @@ -86,7 +84,7 @@ } pub fn run_exists() -> bool { - path(RUN).is_some_and(|p| p.exists()) + backend::exists(RUN) } pub fn store_run(run: &RunOut<'_>) { @@ -101,9 +99,7 @@ if READ_ONLY.load(Ordering::Relaxed) { return; } - if let Some(p) = path(RUN) { - let _ = fs::remove_file(p); - } + backend::remove(RUN); } pub fn store_profile(profile: &Profile) { @@ -116,12 +112,73 @@ profile } -fn path(name: &str) -> Option { - dirs::data_dir().map(|d| d.join("scorch").join(name)) +/// Where the files live. Desktop: the platform data folder. Browser build (FredyGames): localStorage, under keys +/// prefixed with "scorch.". +#[cfg(not(target_arch = "wasm32"))] +mod backend { + use std::fs; + use std::path::PathBuf; + + fn path(name: &str) -> Option { + dirs::data_dir().map(|d| d.join("scorch").join(name)) + } + + pub fn exists(name: &str) -> bool { + path(name).is_some_and(|p| p.exists()) + } + + pub fn remove(name: &str) { + if let Some(p) = path(name) { + let _ = fs::remove_file(p); + } + } + + pub fn read(name: &str) -> Option { + fs::read_to_string(path(name)?).ok() + } + + /// Writes to a temporary file first, then renames it over the old one, + /// so a crash mid-write never leaves a half-written save. + pub fn write(name: &str, text: &str) -> Result<(), String> { + let path = path(name).ok_or("no data folder")?; + (|| -> std::io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + fs::write(&tmp, text)?; + fs::rename(tmp, &path) + })() + .map_err(|e| e.to_string()) + } +} + +#[cfg(target_arch = "wasm32")] +mod backend { + fn key(name: &str) -> String { + format!("scorch.{name}") + } + + pub fn exists(name: &str) -> bool { + read(name).is_some() + } + + pub fn remove(name: &str) { + quad_storage::STORAGE.lock().unwrap().remove(&key(name)); + } + + pub fn read(name: &str) -> Option { + quad_storage::STORAGE.lock().unwrap().get(&key(name)) + } + + pub fn write(name: &str, text: &str) -> Result<(), String> { + quad_storage::STORAGE.lock().unwrap().set(&key(name), text); + Ok(()) + } } fn load(name: &str) -> Option { - let text = fs::read_to_string(path(name)?).ok()?; + let text = backend::read(name)?; match serde_json::from_str(&text) { Ok(value) => Some(value), Err(e) => { @@ -131,23 +188,13 @@ } } -/// Writes to a temporary file first, then renames it over the old one, -/// so a crash mid-write never leaves a half-written save. fn store(name: &str, value: &T) { - let Some(path) = path(name) else { - return; - }; if READ_ONLY.load(Ordering::Relaxed) { return; } - let result = (|| -> std::io::Result<()> { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir)?; - } - let tmp = path.with_extension("tmp"); - fs::write(&tmp, serde_json::to_string(value)?)?; - fs::rename(tmp, &path) - })(); + let result = serde_json::to_string(value) + .map_err(|e| e.to_string()) + .and_then(|text| backend::write(name, &text)); if let Err(e) = result { eprintln!("Could not save {name}: {e}"); }