diff --git a/.cargo/config.toml b/.cargo/config.toml index 326e199..daeaa6c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [registries.Quaternions] index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" + +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/Cargo.lock b/Cargo.lock index 0e8ce05..2515e74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,28 @@ dependencies = [ "android-activity", ] +[[package]] +name = "bevy_anti_alias" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "tracing", +] + [[package]] name = "bevy_app" version = "0.18.1" @@ -884,6 +906,35 @@ dependencies = [ "syn", ] +[[package]] +name = "bevy_dev_tools" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_state", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_window", + "tracing", +] + [[package]] name = "bevy_diagnostic" version = "0.18.1" @@ -951,6 +1002,36 @@ dependencies = [ "encase_derive_impl", ] +[[package]] +name = "bevy_feathers" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_text", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_window", + "smol_str", +] + [[package]] name = "bevy_gizmos" version = "0.18.1" @@ -1073,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754" dependencies = [ "bevy_a11y", "bevy_android", + "bevy_anti_alias", "bevy_app", "bevy_asset", "bevy_camera", "bevy_color", "bevy_core_pipeline", "bevy_derive", + "bevy_dev_tools", "bevy_diagnostic", "bevy_ecs", + "bevy_feathers", "bevy_gizmos_render", "bevy_image", "bevy_input", @@ -1088,6 +1172,7 @@ dependencies = [ "bevy_log", "bevy_math", "bevy_mesh", + "bevy_pbr", "bevy_platform", "bevy_ptr", "bevy_reflect", @@ -1107,6 +1192,27 @@ dependencies = [ "bevy_winit", ] +[[package]] +name = "bevy_light" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "tracing", +] + [[package]] name = "bevy_log" version = "0.18.1" @@ -1167,7 +1273,9 @@ dependencies = [ "bevy_asset", "bevy_derive", "bevy_ecs", + "bevy_image", "bevy_math", + "bevy_mikktspace", "bevy_platform", "bevy_reflect", "bevy_transform", @@ -1180,6 +1288,71 @@ dependencies = [ "wgpu-types", ] +[[package]] +name = "bevy_mikktspace" +version = "0.17.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59" + +[[package]] +name = "bevy_pbr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bitflags 2.11.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bevy_picking" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_window", + "tracing", + "uuid", +] + [[package]] name = "bevy_platform" version = "0.18.1" @@ -1506,6 +1679,7 @@ dependencies = [ "bevy_input", "bevy_input_focus", "bevy_math", + "bevy_picking", "bevy_platform", "bevy_reflect", "bevy_sprite", @@ -1518,6 +1692,7 @@ dependencies = [ "taffy", "thiserror 2.0.18", "tracing", + "uuid", ] [[package]] @@ -1551,6 +1726,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "bevy_ui_widgets" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_ui", +] + [[package]] name = "bevy_utils" version = "0.18.1" @@ -1678,6 +1873,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ + "bytemuck", "serde_core", ] @@ -3483,6 +3679,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glam" version = "0.30.10" @@ -3511,6 +3718,27 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "governor" version = "0.10.4" @@ -4335,6 +4563,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kira" version = "0.12.0" @@ -7146,6 +7391,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "solitaire_web" +version = "0.1.0" +dependencies = [ + "bevy", + "console_error_panic_hook", + "getrandom 0.3.4", + "solitaire_data", + "solitaire_engine", + "wasm-bindgen", +] + [[package]] name = "spin" version = "0.9.8" @@ -9106,6 +9363,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.16.1", + "js-sys", "log", "naga", "portable-atomic", @@ -9113,6 +9371,8 @@ dependencies = [ "raw-window-handle", "smallvec", "static_assertions", + "wasm-bindgen", + "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -9144,6 +9404,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", @@ -9158,6 +9419,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "27.0.0" @@ -9183,15 +9453,20 @@ dependencies = [ "cfg-if", "cfg_aliases", "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", "hashbrown 0.16.1", + "js-sys", + "khronos-egl", "libc", "libloading", "log", "metal", "naga", + "ndk-sys", "objc", "once_cell", "ordered-float", @@ -9204,6 +9479,8 @@ dependencies = [ "renderdoc-sys", "smallvec", "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", "wgpu-types", "windows 0.58.0", "windows-core 0.58.0", @@ -10086,6 +10363,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5b6f6c2..1cbb01f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "solitaire_app", "solitaire_assetgen", "solitaire_wasm", + "solitaire_web", ] resolver = "2" diff --git a/build_wasm.sh b/build_wasm.sh index c826544..36dba96 100755 --- a/build_wasm.sh +++ b/build_wasm.sh @@ -1,18 +1,21 @@ #!/usr/bin/env bash -# Rebuild the solitaire_wasm crate and install the output into -# solitaire_server/web/pkg/ so the server can serve the replay viewer. +# Rebuild WASM artifacts and install them into solitaire_server/web/pkg/. +# +# Two artifacts are produced: +# solitaire_wasm.* — thin replay-viewer + interactive JS API (wasm-pack) +# canvas.* — full Bevy WASM app for play.html (cargo + wasm-bindgen) # # Prerequisites: -# cargo install wasm-pack +# cargo install wasm-pack wasm-bindgen-cli # rustup target add wasm32-unknown-unknown +# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm # # Run from the repo root: # ./build_wasm.sh # -# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are -# committed to git so self-hosters who don't touch the WASM crate can -# skip this step. Regenerate after any change to solitaire_wasm/ or -# solitaire_core/. +# The generated pkg/ files are committed to git so self-hosters who don't +# touch the WASM crates can skip this step. Regenerate after any change to +# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/. set -euo pipefail @@ -36,5 +39,38 @@ wasm-pack build \ # Remove them — we manage the output directory ourselves. rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore" +# --------------------------------------------------------------------------- +# Bevy WASM app (solitaire_web → canvas.js + canvas_bg.wasm) +# --------------------------------------------------------------------------- + +if ! command -v wasm-bindgen &> /dev/null; then + echo "error: wasm-bindgen not found." >&2 + echo " Install with: cargo install wasm-bindgen-cli" >&2 + echo " The CLI version must match the wasm-bindgen crate dep." >&2 + exit 1 +fi + +echo "Building solitaire_web (Bevy WASM app)..." +cargo build --release --target wasm32-unknown-unknown -p solitaire_web + +echo "Running wasm-bindgen for solitaire_web..." +wasm-bindgen \ + --out-dir "$OUT_DIR" \ + --out-name canvas \ + --target web \ + "$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm" + +# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed). +# wasm-opt passes are skipped silently when the tool is not installed. +if command -v wasm-opt &> /dev/null; then + echo "Running wasm-opt on canvas_bg.wasm..." + wasm-opt -Oz \ + -o "$OUT_DIR/canvas_bg.wasm" \ + "$OUT_DIR/canvas_bg.wasm" +else + echo "note: wasm-opt not found; skipping size optimisation." + echo " Install with: cargo install wasm-opt (or via binaryen)" +fi + echo "Done. Output:" ls -lh "$OUT_DIR" diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index f0ad3ee..cdd2e44 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -12,11 +12,17 @@ serde_json = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +uuid = { workspace = true } +klondike = { workspace = true } + +# These deps are not available / not needed on wasm32: +# dirs — platform data directories (no filesystem on browser) +# reqwest — native HTTP client (sync/analytics gated out on wasm32) +# tokio — OS-threaded async runtime (mio doesn't compile on wasm32) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] dirs = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } -uuid = { workspace = true } -klondike = { workspace = true } # `keyring-core` is the typed Entry/Error API used by # `auth_tokens`. The crate's own dependency tree pulls in @@ -25,7 +31,7 @@ klondike = { workspace = true } # on bionic). On Android `auth_tokens` falls back to a stub # implementation that always returns `KeychainUnavailable`; the # real backend lands when we wire Android Keystore via JNI. -[target.'cfg(not(target_os = "android"))'.dependencies] +[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies] keyring-core = { workspace = true } [target.'cfg(target_os = "android")'.dependencies] diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 88fbf9a..fdae73d 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -146,13 +146,17 @@ pub use settings::{ #[cfg(target_os = "android")] mod android_keystore; +#[cfg(not(target_arch = "wasm32"))] pub mod auth_tokens; +#[cfg(not(target_arch = "wasm32"))] pub use auth_tokens::{ TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, }; pub mod sync_client; -pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend}; +pub use sync_client::LocalOnlyProvider; +#[cfg(not(target_arch = "wasm32"))] +pub use sync_client::{SolitaireServerClient, provider_for_backend}; pub mod replay; pub use replay::{ @@ -163,7 +167,9 @@ pub use replay::{ #[allow(deprecated)] pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; +#[cfg(not(target_arch = "wasm32"))] pub mod matomo_client; +#[cfg(not(target_arch = "wasm32"))] pub use matomo_client::MatomoClient; pub mod platform; diff --git a/solitaire_data/src/platform.rs b/solitaire_data/src/platform.rs index fdb69d5..2b28a23 100644 --- a/solitaire_data/src/platform.rs +++ b/solitaire_data/src/platform.rs @@ -55,7 +55,15 @@ pub fn data_dir() -> Option { { Some(PathBuf::from(ANDROID_APP_FILES_DIR)) } - #[cfg(not(target_os = "android"))] + #[cfg(target_arch = "wasm32")] + { + // No filesystem on the browser; all persistence goes through + // WasmStorage (localStorage-backed). Return None so every caller + // degrades gracefully (the same path they take on a + // misconfigured desktop environment). + None + } + #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] { dirs::data_dir() } diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 75f4126..bd08ebe 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -14,8 +14,10 @@ use async_trait::async_trait; use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; +use crate::{SyncError, SyncProvider}; + +#[cfg(not(target_arch = "wasm32"))] use crate::{ - SyncError, SyncProvider, auth_tokens::{load_access_token, load_refresh_token, store_tokens}, replay::Replay, settings::SyncBackend, @@ -54,12 +56,17 @@ impl SyncProvider for LocalOnlyProvider { // --------------------------------------------------------------------------- // SolitaireServerClient // --------------------------------------------------------------------------- +// Native-only: HTTP sync client and factory function. +// On wasm32 these are gated out because reqwest uses native OS networking +// (mio + hyper) which does not compile for wasm32-unknown-unknown. +// --------------------------------------------------------------------------- /// HTTP sync client for the self-hosted Ferrous Solitaire server. /// /// Authenticates via JWT stored in the OS keychain. On a 401 response the /// client automatically attempts a token refresh and retries the request once /// before returning an error. +#[cfg(not(target_arch = "wasm32"))] pub struct SolitaireServerClient { /// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Trailing slashes are stripped on construction. @@ -70,6 +77,7 @@ pub struct SolitaireServerClient { client: reqwest::Client, } +#[cfg(not(target_arch = "wasm32"))] impl SolitaireServerClient { /// Construct a new client for the given server URL and username. /// @@ -201,6 +209,7 @@ impl SolitaireServerClient { } } +#[cfg(not(target_arch = "wasm32"))] #[async_trait] impl SyncProvider for SolitaireServerClient { /// Fetch the latest sync payload from the server. @@ -486,6 +495,7 @@ impl SyncProvider for SolitaireServerClient { } } +#[cfg(not(target_arch = "wasm32"))] impl SolitaireServerClient { /// Pulled out of `push_replay` so both the first attempt and the /// post-401-retry attempt go through the same parse path. @@ -581,9 +591,10 @@ impl SolitaireServerClient { } // --------------------------------------------------------------------------- -// Response extraction helpers +// Response extraction helpers (native-only, use reqwest::Response) // --------------------------------------------------------------------------- +#[cfg(not(target_arch = "wasm32"))] /// Deserialize a pull response body as [`SyncResponse`] and return its /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// @@ -607,6 +618,7 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result`. async fn extract_leaderboard_body( resp: reqwest::Response, @@ -621,6 +633,7 @@ async fn extract_leaderboard_body( } } +#[cfg(not(target_arch = "wasm32"))] /// Deserialize a push response body as [`SyncResponse`], or map non-200 /// statuses to the appropriate [`SyncError`]. /// @@ -652,6 +665,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result` /// and remains backend-agnostic. +#[cfg(not(target_arch = "wasm32"))] pub fn provider_for_backend(backend: &SyncBackend) -> Box { match backend { SyncBackend::Local => Box::new(LocalOnlyProvider), diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 707977e..757456b 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -7,15 +7,12 @@ edition.workspace = true [dependencies] bevy = { workspace = true } image = { workspace = true } -reqwest = { workspace = true } -kira = { workspace = true } solitaire_core = { workspace = true } solitaire_data = { workspace = true } solitaire_sync = { workspace = true } klondike = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } -tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -23,17 +20,24 @@ usvg = { workspace = true } resvg = { workspace = true } tiny-skia = { workspace = true } ron = { workspace = true } + +# These deps are not available / not needed on wasm32: +# reqwest — uses mio/hyper native networking (sync plugin is gated out) +# kira — uses cpal OS audio (audio plugin is gated out) +# tokio — multi-threaded runtime (TokioRuntimeResource is gated out) +# dirs — platform data directories (storage uses WasmStorage instead) +# zip — theme ZIP importer (importer is gated out on wasm32) +# arboard — clipboard (no wasm backend; stats copy-link uses localStorage) +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { workspace = true } +kira = { workspace = true } +tokio = { workspace = true } dirs = { workspace = true } zip = { workspace = true } -# `arboard` provides clipboard access for the Stats overlay's -# "Copy share link" button. The crate has no Android backend -# (its `platform::Clipboard` module is unimplemented for the -# android target — `cargo apk build` fails with E0433 if this is -# left unconditional). On Android the same button surfaces an -# informational toast instead; see -# `stats_plugin::handle_copy_share_link_button`. -[target.'cfg(not(target_os = "android"))'.dependencies] +# `arboard` has no Android backend and no wasm32 backend. Gate it out for +# both; the copy-share-link button surfaces an informational toast instead. +[target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies] arboard = { workspace = true } [target.'cfg(target_os = "android")'.dependencies] diff --git a/solitaire_engine/src/assets/sources.rs b/solitaire_engine/src/assets/sources.rs index 61c2ab0..b5c35ec 100644 --- a/solitaire_engine/src/assets/sources.rs +++ b/solitaire_engine/src/assets/sources.rs @@ -50,9 +50,11 @@ use bevy::asset::AssetApp; use bevy::asset::io::AssetSourceBuilder; use bevy::asset::io::embedded::EmbeddedAssetRegistry; +#[cfg(not(target_arch = "wasm32"))] use bevy::asset::io::file::FileAssetReader; use bevy::prelude::*; +#[cfg(not(target_arch = "wasm32"))] use crate::assets::user_dir::user_theme_dir; /// `AssetSourceId` of the user-themes asset source. Use it as @@ -235,11 +237,16 @@ const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[ /// Returns the `&mut App` so the call can be chained from the binary /// entry point. pub fn register_theme_asset_sources(app: &mut App) -> &mut App { - let root = user_theme_dir(); - app.register_asset_source( - USER_THEMES, - AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))), - ); + // User themes are stored on the filesystem; wasm32 has no filesystem and + // `FileAssetReader` is not available on that target. + #[cfg(not(target_arch = "wasm32"))] + { + let root = user_theme_dir(); + app.register_asset_source( + USER_THEMES, + AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))), + ); + } app } diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index b845e7c..f1a708c 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -11,6 +11,7 @@ use bevy::prelude::*; use bevy::window::RequestRedraw; +#[cfg(not(target_arch = "wasm32"))] use crate::audio_plugin::{AudioState, SoundLibrary}; use crate::events::{MoveRequestEvent, StateChangedEvent}; use crate::game_plugin::GameMutation; @@ -97,6 +98,7 @@ fn detect_auto_complete( /// exactly once on the `false → true` edge. The win fanfare is played at half /// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does /// not overwhelm the card-place sounds that follow immediately. +#[cfg(not(target_arch = "wasm32"))] fn on_auto_complete_start( state: Res, mut was_active: Local, @@ -117,6 +119,12 @@ fn on_auto_complete_start( audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME); } +// No audio on wasm — stub keeps the system registration unconditional. +#[cfg(target_arch = "wasm32")] +fn on_auto_complete_start(state: Res, mut was_active: Local) { + *was_active = state.active; +} + /// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active. fn drive_auto_complete( mut state: ResMut, diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 053af60..683dc08 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -13,16 +13,18 @@ use crate::platform::{ default_storage_backend, }; use crate::{ - AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, - AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, - CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, - FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, - OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, - RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, - SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider, - SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, - TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, - WinSummaryPlugin, + AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, + CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, + DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, + HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, + ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, + SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider, + TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, + UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, +}; +#[cfg(not(target_arch = "wasm32"))] +use crate::{ + AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin, }; /// Groups all Ferrous Solitaire gameplay plugins. @@ -45,6 +47,7 @@ impl Plugin for CoreGamePlugin { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; + #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] let sync_provider = sync_provider .take() .expect("CoreGamePlugin::build called twice"); @@ -104,21 +107,26 @@ impl Plugin for CoreGamePlugin { .add_plugins(HudPlugin) .add_plugins(HelpPlugin) .add_plugins(HomePlugin::default()) - .add_plugins(AvatarPlugin) .add_plugins(ProfilePlugin) .add_plugins(PausePlugin) .add_plugins(SettingsPlugin::default()) - .add_plugins(AudioPlugin) .add_plugins(OnboardingPlugin) - .add_plugins(SyncPlugin::new(sync_provider)) - .add_plugins(SyncSetupPlugin) - .add_plugins(AnalyticsPlugin) - .add_plugins(LeaderboardPlugin) .add_plugins(WinSummaryPlugin) .add_plugins(UiModalPlugin) .add_plugins(UiFocusPlugin) .add_plugins(UiTooltipPlugin) .add_plugins(SplashPlugin) .add_plugins(DiagnosticsHudPlugin); + + // Plugins that use kira/cpal audio or multi-threaded Tokio are not + // compatible with the single-threaded wasm32 runtime. Gate them out + // so the browser build boots silently and without a sync backend. + #[cfg(not(target_arch = "wasm32"))] + app.add_plugins(AvatarPlugin) + .add_plugins(AudioPlugin) + .add_plugins(SyncPlugin::new(sync_provider)) + .add_plugins(SyncSetupPlugin) + .add_plugins(AnalyticsPlugin) + .add_plugins(LeaderboardPlugin); } } diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 0f1916b..80b8951 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -25,6 +25,7 @@ use crate::events::{ use crate::game_plugin::GameMutation; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::resources::GameStateResource; +#[cfg(not(target_arch = "wasm32"))] use crate::sync_plugin::SyncProviderResource; /// Bonus XP awarded for completing today's daily challenge. @@ -116,17 +117,21 @@ impl Plugin for DailyChallengePlugin { .add_message::() .add_message::() .add_message::() - .add_systems(Startup, fetch_server_challenge) - .add_systems(Update, poll_server_challenge) // record/award after the base ProgressUpdate so we don't fight // ProgressPlugin's add_xp on the same frame. .add_systems(Update, handle_daily_completion.after(ProgressUpdate)) .add_systems(Update, handle_start_daily_request.before(GameMutation)) .add_systems(Update, check_daily_expiry_warning) .add_systems(Update, check_date_rollover); + + // Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm. + #[cfg(not(target_arch = "wasm32"))] + app.add_systems(Startup, fetch_server_challenge) + .add_systems(Update, poll_server_challenge); } } +#[cfg(not(target_arch = "wasm32"))] /// Startup system: spawns an async task to fetch the server's daily challenge. /// /// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is @@ -142,6 +147,7 @@ fn fetch_server_challenge( task_res.0 = Some(task); } +#[cfg(not(target_arch = "wasm32"))] /// Update system: polls the server-challenge fetch task. /// /// On success, replaces the locally-computed seed in `DailyChallengeResource` diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 0745a6f..aa91f5f 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -1512,6 +1512,7 @@ mod tests { use solitaire_data::load_game_state_from; let path = tmp_gs_path("exit_save"); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); let mut app = test_app(7); @@ -1527,6 +1528,7 @@ mod tests { let loaded = load_game_state_from(&path).expect("file should exist after exit"); assert_eq!(loaded.seed, 7654); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); } @@ -1571,6 +1573,7 @@ mod tests { use solitaire_data::load_game_state_from; let path = tmp_gs_path("auto_save_30s"); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); let mut app = test_app(42); @@ -1601,6 +1604,7 @@ mod tests { let loaded = load_game_state_from(&path).expect("file must be loadable"); assert_eq!(loaded.seed, 42); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); } @@ -1608,6 +1612,7 @@ mod tests { #[test] fn auto_save_skips_when_no_moves() { let path = tmp_gs_path("auto_save_skip"); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); let mut app = test_app(99); @@ -2165,6 +2170,7 @@ mod tests { use solitaire_data::load_replay_history_from; let path = std::env::temp_dir().join("engine_test_replay_freeze.json"); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); let mut app = test_app(7654); @@ -2223,6 +2229,7 @@ mod tests { other => panic!("second entry must be a Move, got {other:?}"), } + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); } @@ -2234,6 +2241,7 @@ mod tests { use solitaire_data::load_replay_history_from; let path = std::env::temp_dir().join("engine_test_replay_history_append.json"); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); let mut app = test_app(11); @@ -2270,6 +2278,7 @@ mod tests { assert_eq!(history.replays[0].final_score, 200); assert_eq!(history.replays[1].final_score, 100); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); } @@ -2281,6 +2290,7 @@ mod tests { #[test] fn replay_with_empty_recording_skips_save() { let path = std::env::temp_dir().join("engine_test_replay_empty_skip.json"); + #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(&path); let mut app = test_app(1); diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 8d87ca9..ad91040 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -13,7 +13,14 @@ use solitaire_core::card::Suit; use solitaire_core::game_state::{DrawMode, GameMode}; use crate::auto_complete_plugin::AutoCompleteState; +#[cfg(not(target_arch = "wasm32"))] use crate::avatar_plugin::AvatarResource; +// On wasm32 AvatarPlugin is gated out; define a placeholder type so the +// Option> parameters below compile without changes. +// The resource is never inserted on wasm, so every call resolves to None. +#[cfg(target_arch = "wasm32")] +#[derive(bevy::prelude::Resource)] +struct AvatarResource(Option>); use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::daily_challenge_plugin::DailyChallengeResource; use crate::events::{ diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 8d09a5b..baa0178 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -1,13 +1,16 @@ //! Bevy integration layer for Ferrous Solitaire. pub mod achievement_plugin; +#[cfg(not(target_arch = "wasm32"))] pub mod analytics_plugin; #[cfg(target_os = "android")] pub mod android_clipboard; pub mod animation_plugin; pub mod assets; +#[cfg(not(target_arch = "wasm32"))] pub mod audio_plugin; pub mod auto_complete_plugin; +#[cfg(not(target_arch = "wasm32"))] pub mod avatar_plugin; pub mod card_animation; pub mod card_plugin; @@ -26,6 +29,7 @@ pub mod home_plugin; pub mod hud_plugin; pub mod input_plugin; pub mod layout; +#[cfg(not(target_arch = "wasm32"))] pub mod leaderboard_plugin; pub mod onboarding_plugin; pub mod pause_plugin; @@ -43,7 +47,9 @@ pub mod selection_plugin; pub mod settings_plugin; pub mod splash_plugin; pub mod stats_plugin; +#[cfg(not(target_arch = "wasm32"))] pub mod sync_plugin; +#[cfg(not(target_arch = "wasm32"))] pub mod sync_setup_plugin; pub mod table_plugin; pub mod theme; @@ -57,14 +63,17 @@ pub mod weekly_goals_plugin; pub mod win_summary_plugin; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; +#[cfg(not(target_arch = "wasm32"))] pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource}; pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue}; pub use assets::{ AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url, populate_embedded_dark_theme, register_theme_asset_sources, }; +#[cfg(not(target_arch = "wasm32"))] pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use auto_complete_plugin::AutoCompletePlugin; +#[cfg(not(target_arch = "wasm32"))] pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource}; pub use card_animation::{ AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin, @@ -117,6 +126,7 @@ pub use hud_plugin::{ }; pub use input_plugin::InputPlugin; pub use layout::{Layout, LayoutResource, compute_layout}; +#[cfg(not(target_arch = "wasm32"))] pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource}; @@ -155,7 +165,9 @@ pub use stats_plugin::{ ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen, StatsUpdate, WatchReplayButton, format_replay_caption, }; +#[cfg(not(target_arch = "wasm32"))] pub use sync_plugin::{SyncPlugin, SyncProviderResource}; +#[cfg(not(target_arch = "wasm32"))] pub use sync_setup_plugin::SyncSetupPlugin; pub use table_plugin::{ BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin, diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 549dbbf..3c2b5d2 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -12,7 +12,11 @@ use solitaire_core::achievement::{ALL_ACHIEVEMENTS, achievement_by_id}; use solitaire_data::SyncBackend; use crate::achievement_plugin::AchievementsResource; +#[cfg(not(target_arch = "wasm32"))] use crate::avatar_plugin::AvatarResource; +#[cfg(target_arch = "wasm32")] +#[derive(bevy::prelude::Resource)] +struct AvatarResource(Option>); use crate::events::ToggleProfileRequestEvent; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs index d9344fe..42c6970 100644 --- a/solitaire_engine/src/resources.rs +++ b/solitaire_engine/src/resources.rs @@ -128,9 +128,16 @@ pub struct GameInputConsumedResource(pub bool); /// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply /// into every network task — safe for concurrent `block_on` calls from multiple /// worker threads. +/// +/// Gated to non-wasm because `tokio::runtime::Builder::new_multi_thread()` uses +/// `mio` for OS-level I/O polling which does not compile for wasm32. The +/// plugins that depend on this resource (AudioPlugin, SyncPlugin, +/// AnalyticsPlugin) are also gated out on wasm32 in `CoreGamePlugin`. +#[cfg(not(target_arch = "wasm32"))] #[derive(Resource, Clone)] pub struct TokioRuntimeResource(pub Arc); +#[cfg(not(target_arch = "wasm32"))] impl TokioRuntimeResource { /// Attempts to build the shared multi-threaded Tokio runtime. /// diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index d897218..e4a1b2a 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -24,6 +24,7 @@ use solitaire_data::{ use solitaire_data::settings::SyncBackend; +#[cfg(not(target_arch = "wasm32"))] use crate::assets::user_theme_dir; use crate::events::{ DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, @@ -32,9 +33,9 @@ use crate::events::{ use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; -use crate::theme::{ - ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry, -}; +use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry}; +#[cfg(not(target_arch = "wasm32"))] +use crate::theme::{ImportError, import_theme}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_modal::{ ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, @@ -404,6 +405,7 @@ impl Plugin for SettingsPlugin { sync_settings_panel_visibility, handle_settings_buttons, handle_sync_buttons, + #[cfg(not(target_arch = "wasm32"))] handle_scan_themes, update_sync_status_text, update_card_back_text, @@ -1857,6 +1859,7 @@ fn spawn_settings_panel( font_res, ); } + #[cfg(not(target_arch = "wasm32"))] import_themes_row(body, font_res); // --- Privacy (only shown when a Matomo URL is configured) --- @@ -2641,6 +2644,7 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont { /// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme /// already installed) are silently skipped; all other errors produce a warning /// toast. A final toast tells the player to reopen Settings to see new themes. +#[cfg(not(target_arch = "wasm32"))] fn handle_scan_themes( interaction_query: Query<(&Interaction, &SettingsButton), Changed>, mut toast: MessageWriter, @@ -2759,6 +2763,7 @@ fn pill_button( /// then presses the button. [`handle_scan_themes`] picks them up, validates, /// and installs them. Reopen Settings to see newly imported themes in the /// card-theme picker. +#[cfg(not(target_arch = "wasm32"))] fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { let caption_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs index dcbd416..877ed3b 100644 --- a/solitaire_engine/src/theme/mod.rs +++ b/solitaire_engine/src/theme/mod.rs @@ -12,6 +12,7 @@ //! handles directly on card entities, so a theme switch propagates on //! the next frame without re-spawning anything. +#[cfg(not(target_arch = "wasm32"))] pub mod importer; pub mod loader; pub mod manifest; @@ -28,6 +29,7 @@ use thiserror::Error; use solitaire_core::card::{Rank, Suit}; +#[cfg(not(target_arch = "wasm32"))] pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use manifest::ThemeManifest; diff --git a/solitaire_server/web/play.html b/solitaire_server/web/play.html new file mode 100644 index 0000000..9751687 --- /dev/null +++ b/solitaire_server/web/play.html @@ -0,0 +1,20 @@ + + + + + + Ferrous Solitaire + + + + + + + diff --git a/solitaire_web/Cargo.toml b/solitaire_web/Cargo.toml new file mode 100644 index 0000000..0e36702 --- /dev/null +++ b/solitaire_web/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "solitaire_web" +version.workspace = true +license.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +solitaire_engine = { path = "../solitaire_engine" } +solitaire_data = { path = "../solitaire_data" } +# Direct dep so `bevy::` resolves in lib.rs; zero extra features so this +# contributes nothing to unification with the desktop/Android feature set. +bevy = { workspace = true } +wasm-bindgen = "0.2" +getrandom = { version = "0.3", features = ["wasm_js"] } +console_error_panic_hook = "0.1" + +# webgl2 must only be enabled for the wasm target — it constrains the +# renderer to WebGL2 compatibility limits, which is wrong for native builds. +[target.'cfg(target_arch = "wasm32")'.dependencies] +bevy = { workspace = true, features = ["webgl2"] } diff --git a/solitaire_web/src/lib.rs b/solitaire_web/src/lib.rs new file mode 100644 index 0000000..0c08f3f --- /dev/null +++ b/solitaire_web/src/lib.rs @@ -0,0 +1,42 @@ +//! Browser entry point for the Ferrous Solitaire Bevy WASM build. +//! +//! This crate compiles the full `solitaire_engine` to `wasm32-unknown-unknown` +//! and renders to a `` element. It shares the same +//! ECS code path as the desktop and Android builds; the only differences are: +//! - Audio, sync, and analytics plugins are cfg-gated out in `CoreGamePlugin` +//! on the wasm32 target (see `solitaire_engine/src/core_game_plugin.rs`). +//! - `LocalOnlyProvider` is passed as the sync provider (sync is disabled). +//! - Storage is handled automatically by `WasmStorage` (localStorage-backed), +//! wired by `CoreGamePlugin` via `default_storage_backend()`. + +use bevy::prelude::*; +use bevy::window::{Window, WindowPlugin}; +use solitaire_data::LocalOnlyProvider; +use solitaire_engine::CoreGamePlugin; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(start)] +pub fn start() { + console_error_panic_hook::set_once(); + + App::new() + .add_plugins( + DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + // Bind to the existing in play.html. + // Without this, Bevy appends its own canvas to . + canvas: Some("#bevy-canvas".into()), + // Let CSS size the canvas; Bevy follows the element's size. + fit_canvas_to_parent: true, + // Prevent the browser stealing keyboard events and scroll. + prevent_default_event_handling: true, + ..default() + }), + ..default() + }), + ) + // LocalOnlyProvider disables cloud sync — correct for the web build + // since SyncPlugin is cfg-gated out on wasm32 anyway. + .add_plugins(CoreGamePlugin::new(Box::new(LocalOnlyProvider))) + .run(); +}