diff --git a/Cargo.lock b/Cargo.lock index 188d9d1..5cf6f68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,12 +427,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bevy" version = "0.20.1" @@ -2780,17 +2774,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -3022,22 +3005,6 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "icu_collections" version = "2.4.0" @@ -4411,7 +4378,8 @@ dependencies = [ "parlance", "semver", "serde_json", - "ureq", + "web-sys", + "web-time", "x11rb", ] @@ -4686,20 +4654,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rodio" version = "0.22.2" @@ -4775,41 +4729,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -5061,12 +4980,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swash" version = "0.2.10" @@ -5457,46 +5370,6 @@ dependencies = [ "rustc-hash 2.1.3", ] -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" -dependencies = [ - "base64", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.1.0" @@ -5568,12 +5441,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5789,15 +5656,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "wesl" version = "0.6.0" @@ -6573,12 +6431,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" - [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 294c3d6..fd18f77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,12 @@ categories = ["games"] exclude = ["docs/", ".github/", "site/", "tools/"] [dependencies] -bevy = { version = "0.20.1", default-features = false, features = ["2d", "ui", "audio", "wav", "system_clipboard"] } +bevy = { version = "0.20.1", default-features = false, features = ["2d", "ui", "audio", "wav"] } include_dir = "0.7.4" +# Wall clock that also works in the browser (std::time panics there); the same types as std elsewhere. +web-time = "1.1.0" semver = "1.0.28" serde_json = "1.0.54" -ureq = { version = "3.5.0", default-features = false, features = ["rustls"] } # One type, `Script`, for one call: telling the text stack which face to # reach for when a line is in a script the UI font has no glyphs for # (`app::boot`, Japanese). This is the crate that type actually lives in @@ -43,6 +44,10 @@ icu_provider = { version = "2.3.1", features = ["logging"] } # nothing here needs: 0.14 was tried, and the keymap's `connect`, `setup` # and `get_keyboard_mapping` are all unchanged across it. Move when winit # and arboard do, and the tree keeps one copy. +# The browser build keeps the player's files (settings, progress, trophies) in the page's local storage. +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["Window", "Storage"] } + [target.'cfg(target_os = "linux")'.dependencies] x11rb = { version = "0.13.2", default-features = false } diff --git a/assets/shaders/water.wgsl b/assets/shaders/water.wgsl index 789ca70..d05bcf8 100644 --- a/assets/shaders/water.wgsl +++ b/assets/shaders/water.wgsl @@ -17,6 +17,8 @@ struct Globals { time: f32, delta_time: f32, frame_count: u32, + // The browser (WebGL2) build wants uniforms in 16-byte multiples; the engine's own struct carries this pad there. + _webgl2_padding_8b: f32, } @group(0) @binding(1) var globals: Globals; diff --git a/src/app/achievements/save.rs b/src/app/achievements/save.rs index a7c0089..6ba00d7 100644 --- a/src/app/achievements/save.rs +++ b/src/app/achievements/save.rs @@ -182,7 +182,7 @@ pub(super) fn read(text: &str) -> (Stats, Unlocked, Unknown) { } pub fn load(mut commands: Commands) { - let (stats, unlocked) = std::fs::read_to_string(save_path()) + let (stats, unlocked) = crate::app::paths::read_to_string(&save_path()) .map(|text| parse(&text)) .unwrap_or_default(); commands.insert_resource(stats); @@ -209,7 +209,7 @@ pub(super) fn save_in( stats: &mut Stats, unlocked: &mut Unlocked, ) -> std::io::Result<()> { - let on_disk = std::fs::read_to_string(path).unwrap_or_default(); + let on_disk = crate::app::paths::read_to_string(path).unwrap_or_default(); let (disk_stats, disk_unlocked, unknown) = read(&on_disk); merge(stats, &disk_stats); unlocked.0.extend(disk_unlocked.0); diff --git a/src/app/campaign.rs b/src/app/campaign.rs index 62acce0..42a4142 100644 --- a/src/app/campaign.rs +++ b/src/app/campaign.rs @@ -394,10 +394,9 @@ pub(crate) fn filed_path(level: &Level) -> Option { /// without racing every other test in the binary. pub fn load_custom_levels_in(legacy: &std::path::Path, custom: &std::path::Path) -> Vec { let mut paths = vec![legacy.to_path_buf()]; - if let Ok(dir) = std::fs::read_dir(custom) { - let mut extra: Vec<_> = dir - .filter_map(Result::ok) - .map(|e| e.path()) + { + let mut extra: Vec<_> = crate::app::paths::list_dir(custom) + .into_iter() .filter(|p| p.extension().is_some_and(|ext| ext == "txt")) .collect(); extra.sort(); @@ -405,7 +404,7 @@ pub fn load_custom_levels_in(legacy: &std::path::Path, custom: &std::path::Path) } let mut levels = Vec::new(); for path in paths { - let Ok(text) = std::fs::read_to_string(&path) else { + let Ok(text) = crate::app::paths::read_to_string(&path) else { continue; // absent is normal (nothing saved yet) }; match Level::parse(&text) { diff --git a/src/app/clock.rs b/src/app/clock.rs index e56d8f5..51d2c09 100644 --- a/src/app/clock.rs +++ b/src/app/clock.rs @@ -3,7 +3,7 @@ //! before 1970 is one policy rather than a scattering of fallbacks. use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use web_time::{SystemTime, UNIX_EPOCH}; /// Seconds since the epoch. A clock from before 1970 reads as zero: still /// a stamp, still sorts, just not a believable date. diff --git a/src/app/dev.rs b/src/app/dev.rs index 2972546..c47a172 100644 --- a/src/app/dev.rs +++ b/src/app/dev.rs @@ -1882,7 +1882,7 @@ fn profile_hide( struct ProfileSamples { started: Option<(f32, f64)>, threads: std::collections::BTreeMap, - seen: std::collections::HashMap, + seen: std::collections::HashMap, values: std::collections::BTreeMap>, frames: u32, } @@ -1956,7 +1956,7 @@ fn profile_sample( for diagnostic in store.iter() { let path = diagnostic.path().as_str().to_owned(); let last = samples.seen.get(&path).copied(); - let mut per_frame: Vec<(std::time::Instant, f64)> = Vec::new(); + let mut per_frame: Vec<(bevy::platform::time::Instant, f64)> = Vec::new(); for m in diagnostic.measurements() { if last.is_some_and(|l| m.time <= l) { continue; diff --git a/src/app/editor/levels.rs b/src/app/editor/levels.rs index 6eeb53c..a729409 100644 --- a/src/app/editor/levels.rs +++ b/src/app/editor/levels.rs @@ -236,7 +236,7 @@ pub fn editor_levels_input( state.feedback = fill(tr.replay_delete_confirm, &[("k", &key), ("r", &level.name)]); return; } - state.feedback = match std::fs::remove_file(&path) { + state.feedback = match crate::app::paths::remove_file(&path) { Ok(()) => { forget_clear(&level, &ledger.0, &mut ledger.1); fill(tr.ed_level_deleted, &[("name", &level.name)]) diff --git a/src/app/editor/mod.rs b/src/app/editor/mod.rs index ae9a869..16d92f1 100644 --- a/src/app/editor/mod.rs +++ b/src/app/editor/mod.rs @@ -398,7 +398,7 @@ pub struct Opening { /// from a namesake (`campaign::disambiguate`). `None` when the file is /// gone or no longer reads. pub fn opening_from_file(path: &std::path::Path) -> Option { - let text = std::fs::read_to_string(path).ok()?; + let text = crate::app::paths::read_to_string(path).ok()?; let level = Level::parse(&text).ok()?; Some(Opening { level, diff --git a/src/app/editor/shelf.rs b/src/app/editor/shelf.rs index 9dcb730..1ce8113 100644 --- a/src/app/editor/shelf.rs +++ b/src/app/editor/shelf.rs @@ -63,12 +63,12 @@ pub fn custom_dir() -> std::path::PathBuf { /// Whether no level is filed under this path yet. Read before a write, /// which would make it true either way. fn is_new_here(path: &std::path::Path) -> bool { - !path.exists() + !crate::app::paths::exists(path) } /// The name the level filed at `path` goes by, read off its `name:` line. fn filed_name(path: &std::path::Path) -> Option { - let text = std::fs::read_to_string(path).ok()?; + let text = crate::app::paths::read_to_string(path).ok()?; text.lines() .find_map(|line| line.strip_prefix("name:")) .map(|name| name.trim().to_string()) @@ -86,7 +86,7 @@ fn holds_this_level(state: &EditorState, legacy: &std::path::Path, text: &str) - .as_ref() .is_some_and(|(path, _)| path == legacy); opened_there - || std::fs::read_to_string(legacy) + || crate::app::paths::read_to_string(legacy) .ok() .and_then(|old| crate::sim::Level::parse(&old).ok()) .is_some_and(|old| old.to_text() == text) diff --git a/src/app/editor/validate.rs b/src/app/editor/validate.rs index 2d6b5b4..23315e3 100644 --- a/src/app/editor/validate.rs +++ b/src/app/editor/validate.rs @@ -372,7 +372,7 @@ pub(super) fn start_validation(state: &mut EditorState, level: Level, wider: Opt let slot: SolverSlot = Arc::new(Mutex::new(None)); let thread_slot = Arc::clone(&slot); let text = level.to_text(); - std::thread::spawn(move || { + spawn_or_run(move || { let outcome = solve(&level); let needs = match (&outcome, wider) { // The first search proved no count up to the grant wins, so @@ -414,6 +414,17 @@ pub(super) fn start_validation(state: &mut EditorState, level: Level, wider: Opt }); } +/// The work on a background thread where there are threads; in the browser build, which has none, it +/// runs right here and the editor waits for the verdict. +fn spawn_or_run(work: impl FnOnce() + Send + 'static) { + #[cfg(target_arch = "wasm32")] + work(); + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::spawn(work); + } +} + /// The way something on the bench faces, as an arrow, in the readout of /// what the cursor stands on: read in all eight languages, where "Up" and /// "Left" would be English to every one of them. The UI face the cards diff --git a/src/app/effects/particle.rs b/src/app/effects/particle.rs index 502c914..95d1bdb 100644 --- a/src/app/effects/particle.rs +++ b/src/app/effects/particle.rs @@ -25,8 +25,8 @@ impl VisualRng { /// Seeded from the wall clock, for variety that should differ from one /// launch to the next rather than open the same way every time. pub(crate) fn from_clock() -> Self { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let nanos = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) .map_or(0, |since| since.subsec_nanos() ^ since.as_secs() as u32); VisualRng(nanos | 1) } diff --git a/src/app/i18n/de.rs b/src/app/i18n/de.rs index abbd44f..cf7d534 100644 --- a/src/app/i18n/de.rs +++ b/src/app/i18n/de.rs @@ -25,7 +25,7 @@ pub static DE: Tr = Tr { "die Karte des Tages, für alle gleich", ], menu_daily_blurb: "{d}.{m}. - für alle dieselbe Karte", - menu_prompt: "W/S: wählen | Enter: spielen | V: einfügen | M: stumm | Esc: beenden", + menu_prompt: "W/S: wählen | Enter: spielen | M: stumm", title_achievements: "Erfolge", ach_names: [ "Erste Krabbe daheim", diff --git a/src/app/i18n/en.rs b/src/app/i18n/en.rs index f81ef0e..ebd8087 100644 --- a/src/app/i18n/en.rs +++ b/src/app/i18n/en.rs @@ -25,7 +25,7 @@ pub static EN: Tr = Tr { "today's map, same for everyone", ], menu_daily_blurb: "{d}/{m} - the same map for everyone", - menu_prompt: "W/S: choose | Enter: play | V: paste a code | M: mute | Esc: quit", + menu_prompt: "W/S: choose | Enter: play | M: mute", title_achievements: "Achievements", ach_names: [ "First Crab Home", diff --git a/src/app/i18n/es.rs b/src/app/i18n/es.rs index 651b460..1b9e813 100644 --- a/src/app/i18n/es.rs +++ b/src/app/i18n/es.rs @@ -25,7 +25,7 @@ pub static ES: Tr = Tr { "el mapa de hoy, el mismo para todos", ], menu_daily_blurb: "{d}/{m} - el mismo mapa para todos", - menu_prompt: "W/S: elegir | Intro: jugar | V: pegar un código | M: mudo | Esc: salir", + menu_prompt: "W/S: elegir | Intro: jugar | M: mudo", title_achievements: "Trofeos", ach_names: [ "Primer cangrejo", diff --git a/src/app/i18n/fr.rs b/src/app/i18n/fr.rs index dad4624..d5000ba 100644 --- a/src/app/i18n/fr.rs +++ b/src/app/i18n/fr.rs @@ -25,7 +25,7 @@ pub static FR: Tr = Tr { "la carte du jour, la même pour tous", ], menu_daily_blurb: "{d}/{m} - la même carte pour tous", - menu_prompt: "W/S : choisir | Entrée : jouer | V : coller | M : muet | Échap : quitter", + menu_prompt: "W/S : choisir | Entrée : jouer | M : muet", title_achievements: "Trophées", ach_names: [ "Premier crabe rentré", diff --git a/src/app/i18n/it.rs b/src/app/i18n/it.rs index 473c011..b650089 100644 --- a/src/app/i18n/it.rs +++ b/src/app/i18n/it.rs @@ -25,7 +25,7 @@ pub static IT: Tr = Tr { "la mappa di oggi, uguale per tutti", ], menu_daily_blurb: "{d}/{m} - la stessa mappa per tutti", - menu_prompt: "W/S: scegli | Invio: gioca | V: incolla un codice | M: muto | Esc: esci", + menu_prompt: "W/S: scegli | Invio: gioca | M: muto", title_achievements: "Trofei", ach_names: [ "Primo granchio a casa", diff --git a/src/app/i18n/ja.rs b/src/app/i18n/ja.rs index b7db77b..2f57abb 100644 --- a/src/app/i18n/ja.rs +++ b/src/app/i18n/ja.rs @@ -25,7 +25,7 @@ pub static JA: Tr = Tr { "今日のマップ、みんな同じ", ], menu_daily_blurb: "{m}月{d}日 - みんな同じマップ", - menu_prompt: "W/S: 選ぶ | Enter: 遊ぶ | V: 合言葉を貼る | M: 消音 | Esc: おわり", + menu_prompt: "W/S: 選ぶ | Enter: 遊ぶ | M: 消音", title_achievements: "実績", ach_names: [ "はじめの一匹", diff --git a/src/app/i18n/nl.rs b/src/app/i18n/nl.rs index d384e7c..df55a52 100644 --- a/src/app/i18n/nl.rs +++ b/src/app/i18n/nl.rs @@ -25,7 +25,7 @@ pub static NL: Tr = Tr { "de kaart van vandaag, voor iedereen gelijk", ], menu_daily_blurb: "{d}/{m} - dezelfde kaart voor iedereen", - menu_prompt: "W/S: kiezen | Enter: spelen | V: code plakken | M: dempen | Esc: stoppen", + menu_prompt: "W/S: kiezen | Enter: spelen | M: dempen", title_achievements: "Trofeeën", ach_names: [ "Eerste krab thuis", diff --git a/src/app/i18n/ru.rs b/src/app/i18n/ru.rs index 443bb0c..d07c755 100644 --- a/src/app/i18n/ru.rs +++ b/src/app/i18n/ru.rs @@ -25,7 +25,7 @@ pub static RU: Tr = Tr { "сегодняшняя карта, одна для всех", ], menu_daily_blurb: "{d}.{m} - одна карта для всех", - menu_prompt: "W/S: выбор | Enter: играть | V: вставить код | M: тишина | Esc: выход", + menu_prompt: "W/S: выбор | Enter: играть | M: тишина", title_achievements: "Достижения", ach_names: [ "Первый краб дома", diff --git a/src/app/match_setup/preview.rs b/src/app/match_setup/preview.rs index b6781f4..ba8228f 100644 --- a/src/app/match_setup/preview.rs +++ b/src/app/match_setup/preview.rs @@ -393,6 +393,15 @@ fn draw_card(world: &mut World, index: usize, card: Pic, shows: Shows, board: &c image }; let image = image.unwrap_or_else(|| { + // WebGL2 has no texture view formats: in the browser build the target is the sRGB texture itself. + #[cfg(target_arch = "wasm32")] + let mut target = Image::new_target_texture( + PREVIEW_PX.x, + PREVIEW_PX.y, + TextureFormat::Rgba8UnormSrgb, + None, + ); + #[cfg(not(target_arch = "wasm32"))] let mut target = Image::new_target_texture( PREVIEW_PX.x, PREVIEW_PX.y, diff --git a/src/app/match_setup/ui/map_words.rs b/src/app/match_setup/ui/map_words.rs index 97e1f6c..62de944 100644 --- a/src/app/match_setup/ui/map_words.rs +++ b/src/app/match_setup/ui/map_words.rs @@ -173,7 +173,7 @@ pub(in crate::app::match_setup) fn slot_note( let editor = MenuEntry::ALL .iter() .position(|entry| *entry == MenuEntry::Driftwood) - .map_or("", |entry| tr.menu_names[entry]); + .map_or("", |_| tr.menu_names[MenuEntry::Driftwood.slot()]); fill( tr.setup_make_one_note, &[("e", editor), ("l", tr.ed_levels_title)], diff --git a/src/app/menu_scene/mod.rs b/src/app/menu_scene/mod.rs index f601a69..de84d46 100644 --- a/src/app/menu_scene/mod.rs +++ b/src/app/menu_scene/mod.rs @@ -38,6 +38,32 @@ pub enum MenuEntry { } impl MenuEntry { + /// The entry's place in the i18n name and blurb tables, which keep the full order whatever the menu shows. + pub const fn slot(self) -> usize { + match self { + MenuEntry::TidePool => 0, + MenuEntry::TurfWar => 1, + MenuEntry::Driftwood => 2, + MenuEntry::Replay => 3, + MenuEntry::BeachLobby => 4, + MenuEntry::Settings => 5, + MenuEntry::Achievements => 6, + MenuEntry::DailyChallenge => 7, + } + } + + /// The browser build has no network (no beach lobby) and no files to keep replays in (no replay library). + #[cfg(target_arch = "wasm32")] + pub const ALL: [MenuEntry; 6] = [ + MenuEntry::TidePool, + MenuEntry::TurfWar, + MenuEntry::Driftwood, + MenuEntry::Settings, + MenuEntry::Achievements, + MenuEntry::DailyChallenge, + ]; + + #[cfg(not(target_arch = "wasm32"))] pub const ALL: [MenuEntry; 8] = [ MenuEntry::TidePool, MenuEntry::TurfWar, @@ -266,7 +292,7 @@ pub fn update_menu_rows( let picked = cell.0 == list.selected; let line = { match cell.1 { - MenuCol::Name => tr.menu_names[cell.0].to_string(), + MenuCol::Name => tr.menu_names[MenuEntry::ALL[cell.0].slot()].to_string(), // The daily is the one row whose blurb is not the same // every day, and the date is the whole point of it: you // want to know whether you have already played today's. @@ -277,7 +303,7 @@ pub fn update_menu_rows( &[("d", &format!("{day:02}")), ("m", &format!("{month:02}"))], ) } - MenuCol::Blurb => tr.menu_blurbs[cell.0].to_string(), + MenuCol::Blurb => tr.menu_blurbs[MenuEntry::ALL[cell.0].slot()].to_string(), } }; // Two inks, and the row you are on brightens both: the name leads @@ -484,7 +510,8 @@ pub fn menu_input( // nowhere left to go: it offers to leave the game. Without this the // only way out is the window's own close button, which a fullscreen // player has not got. - if keys.just_pressed(KeyCode::Escape) || pads.pressed(Does::Quit) { + // (Not in the browser build: a game that exits there leaves a dead page.) + if !cfg!(target_arch = "wasm32") && (keys.just_pressed(KeyCode::Escape) || pads.pressed(Does::Quit)) { quit.open = true; return; } diff --git a/src/app/paths.rs b/src/app/paths.rs index 8c3bd71..7b42951 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -157,9 +157,102 @@ pub fn ensure_parent(path: &Path) { /// truncated file this exists to avoid. A failed write takes its /// half-finished `.tmp` with it. pub fn write_atomic(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> { + #[cfg(target_arch = "wasm32")] + return web::set(path, contents.as_ref()); + #[cfg(not(target_arch = "wasm32"))] + write_atomic_file(path, contents.as_ref()) +} + +/// A player file as text. In the browser build the "files" of the player (settings, progress, trophies) live in the +/// page's local storage, under the path they would have on a desktop. +pub fn read_to_string(path: &Path) -> std::io::Result { + #[cfg(target_arch = "wasm32")] + return web::get(path); + #[cfg(not(target_arch = "wasm32"))] + std::fs::read_to_string(path) +} + +/// The files directly inside `dir`, in no particular order; none when the folder is not there. +pub fn list_dir(dir: &Path) -> Vec { + #[cfg(target_arch = "wasm32")] + return web::list(dir); + #[cfg(not(target_arch = "wasm32"))] + std::fs::read_dir(dir) + .map(|entries| entries.filter_map(Result::ok).map(|e| e.path()).collect()) + .unwrap_or_default() +} + +/// Delete a player file. +pub fn remove_file(path: &Path) -> std::io::Result<()> { + #[cfg(target_arch = "wasm32")] + return web::remove(path); + #[cfg(not(target_arch = "wasm32"))] + std::fs::remove_file(path) +} + +/// Whether a player file is there. +pub fn exists(path: &Path) -> bool { + #[cfg(target_arch = "wasm32")] + return web::get(path).is_ok(); + #[cfg(not(target_arch = "wasm32"))] + path.exists() +} + +#[cfg(target_arch = "wasm32")] +mod web { + use std::io::{Error, ErrorKind}; + use std::path::Path; + + fn key(path: &Path) -> String { + format!("pinch-points:{}", path.display()) + } + + fn storage() -> std::io::Result { + web_sys::window() + .and_then(|w| w.local_storage().ok().flatten()) + .ok_or_else(|| Error::new(ErrorKind::Unsupported, "no local storage")) + } + + pub(super) fn get(path: &Path) -> std::io::Result { + match storage()?.get_item(&key(path)) { + Ok(Some(text)) => Ok(text), + Ok(None) => Err(Error::from(ErrorKind::NotFound)), + Err(_) => Err(Error::new(ErrorKind::Other, "local storage refused the read")), + } + } + + pub(super) fn list(dir: &Path) -> Vec { + let Ok(storage) = storage() else { + return Vec::new(); + }; + let prefix = format!("{}/", key(dir)); + let count = storage.length().unwrap_or(0); + (0..count) + .filter_map(|i| storage.key(i).ok().flatten()) + .filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))) + .map(|k| std::path::PathBuf::from(k.trim_start_matches("pinch-points:"))) + .collect() + } + + pub(super) fn remove(path: &Path) -> std::io::Result<()> { + storage()? + .remove_item(&key(path)) + .map_err(|_| Error::new(ErrorKind::Other, "local storage refused the delete")) + } + + pub(super) fn set(path: &Path, contents: &[u8]) -> std::io::Result<()> { + let text = std::str::from_utf8(contents).map_err(|e| Error::new(ErrorKind::InvalidData, e))?; + storage()? + .set_item(&key(path), text) + .map_err(|_| Error::new(ErrorKind::Other, "local storage refused the write")) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_atomic_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { ensure_parent(path); let tmp = tmp_sibling(path); - let landed = spill(&tmp, contents.as_ref()).and_then(|()| std::fs::rename(&tmp, path)); + let landed = spill(&tmp, contents).and_then(|()| std::fs::rename(&tmp, path)); if landed.is_err() { // Nothing half-written is left to be found later, whichever step // gave out. @@ -174,6 +267,9 @@ pub fn write_atomic(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result< /// first's between its flush and its rename, publishing half a file. fn tmp_sibling(path: &Path) -> PathBuf { let mut tmp = path.as_os_str().to_owned(); + #[cfg(target_arch = "wasm32")] + tmp.push(".tmp"); + #[cfg(not(target_arch = "wasm32"))] tmp.push(format!(".{}.tmp", std::process::id())); PathBuf::from(tmp) } diff --git a/src/app/progress.rs b/src/app/progress.rs index 1116225..208c893 100644 --- a/src/app/progress.rs +++ b/src/app/progress.rs @@ -309,7 +309,7 @@ pub fn load(mut commands: Commands) { /// The record read back from a file named outright; a missing or /// unreadable one is a fresh start, as it is on a first run. pub fn load_in(path: &std::path::Path) -> Progress { - std::fs::read_to_string(path) + crate::app::paths::read_to_string(path) .map(|text| Progress::parse(&text)) .unwrap_or_default() } diff --git a/src/app/schedule.rs b/src/app/schedule.rs index 82dde06..c747ecb 100644 --- a/src/app/schedule.rs +++ b/src/app/schedule.rs @@ -38,6 +38,8 @@ pub(super) fn run_with(watching: watch::Watch) { default() }, present_mode: dev::present_mode().unwrap_or_default(), + // The browser build fills the frame it is in. + fit_canvas_to_parent: true, ..default() }), // Closed by `editor::close_when_asked` instead, which asks diff --git a/src/app/session/versus.rs b/src/app/session/versus.rs index de32dd8..7665199 100644 --- a/src/app/session/versus.rs +++ b/src/app/session/versus.rs @@ -696,6 +696,10 @@ pub(in crate::app) fn check_versus_over( let reel = highlight_path(); let answer = Arc::new(OnceLock::new()); reel_thread.0 = Some(Arc::clone(&answer)); + // No threads in the browser build, and nowhere to write a GIF: no reel there. + #[cfg(target_arch = "wasm32")] + let _ = answer.set(None); + #[cfg(not(target_arch = "wasm32"))] std::thread::spawn(move || { let saved = match crate::highlight::reel(&replay) { Some(bytes) => match paths::write_atomic(&reel, bytes) { diff --git a/src/app/settings/file.rs b/src/app/settings/file.rs index 08e9cf4..80ae61b 100644 --- a/src/app/settings/file.rs +++ b/src/app/settings/file.rs @@ -260,7 +260,7 @@ impl GameSettings { /// /// [`Path::exists`]: std::path::Path::exists pub fn load_saved() -> Option<(GameSettings, crate::app::keycaps::KeyCaps)> { - std::fs::read_to_string(settings_path()) + crate::app::paths::read_to_string(&settings_path()) .ok() .map(|text| GameSettings::parse(&text)) } @@ -288,7 +288,7 @@ impl GameSettings { path: &std::path::Path, caps: &crate::app::keycaps::KeyCaps, ) -> std::io::Result<()> { - let on_disk = std::fs::read_to_string(path).unwrap_or_default(); + let on_disk = crate::app::paths::read_to_string(path).unwrap_or_default(); let (_, _, unknown) = GameSettings::read(&on_disk); let mut text = self.to_text(caps); unknown.append_to(&mut text); diff --git a/src/app/settings/mod.rs b/src/app/settings/mod.rs index de83c88..0b2ad50 100644 --- a/src/app/settings/mod.rs +++ b/src/app/settings/mod.rs @@ -502,7 +502,7 @@ impl Default for GameSettings { rumble: true, pad_deadzone: 50, colorblind: false, - fullscreen: true, + fullscreen: !cfg!(target_arch = "wasm32"), ui_scale: 100, reduced_motion: false, antialiasing: false, diff --git a/src/app/update/github.rs b/src/app/update/github.rs index 5a811e9..8d912d9 100644 --- a/src/app/update/github.rs +++ b/src/app/update/github.rs @@ -141,6 +141,7 @@ pub fn parse_release(json: &str) -> Option { /// no: offline, too slow, no release yet, or an answer that is not a /// release. Each is logged at a level nobody is shown, and none is the /// player's problem. +#[cfg(not(target_arch = "wasm32"))] pub fn fetch_latest(urls: &Where) -> Option { let agent = ureq::Agent::config_builder() .timeout_global(Some(TIMEOUT)) @@ -172,6 +173,7 @@ pub fn fetch_latest(urls: &Where) -> Option { /// The API's answer, notes and all. `Ok(None)` for a reply that is not a /// release; the error for a reply that is not a 2xx. +#[cfg(not(target_arch = "wasm32"))] fn ask_api(agent: &ureq::Agent, url: &str) -> Result, ureq::Error> { let body = agent .get(url) @@ -183,6 +185,7 @@ fn ask_api(agent: &ureq::Agent, url: &str) -> Result, ureq::Erro } /// The page's answer: the tag in the redirect, and no notes. +#[cfg(not(target_arch = "wasm32"))] fn ask_page(agent: &ureq::Agent, url: &str) -> Option { let response = match agent.get(url).call() { Ok(response) => response, @@ -199,6 +202,12 @@ fn ask_page(agent: &ureq::Agent, url: &str) -> Option { release_from_redirect(location) } +/// In the browser build there is no HTTP client: the game never looks for a newer release. +#[cfg(target_arch = "wasm32")] +pub fn fetch_latest(_urls: &Where) -> Option { + None +} + /// A release read off where the releases page sends the browser: /// `.../releases/tag/v1.2.0`. Notes are not to be had this way. pub fn release_from_redirect(location: &str) -> Option {