feat(web): add solitaire_web Bevy WASM build targeting play.html canvas
Build and Deploy / build-and-push (push) Failing after 58s

Adds a new `solitaire_web` crate that compiles the full `solitaire_engine`
to `wasm32-unknown-unknown` and renders to a `<canvas id="bevy-canvas">`
element in `play.html` — the same ECS code path as desktop and Android.

Changes to enable the WASM target:
- .cargo/config.toml: add wasm32-unknown-unknown rustflags for getrandom
- Workspace Cargo.toml: add solitaire_web member
- solitaire_data/Cargo.toml: gate tokio/reqwest/dirs/keyring to non-wasm
- solitaire_data/src: add wasm32 branch to data_dir() (returns None);
  cfg-gate sync_client network types, auth_tokens, matomo_client
- solitaire_engine/Cargo.toml: gate tokio/reqwest/kira/arboard/dirs/zip
  to non-wasm (mio/cpal/arboard don't compile for wasm32-unknown-unknown)
- solitaire_engine/src/lib.rs: cfg-gate module declarations and re-exports
  for analytics, audio, sync, sync_setup, avatar, leaderboard plugins
- solitaire_engine/src/core_game_plugin.rs: cfg-gate plugin registrations
  that require TokioRuntime (audio, sync, analytics, leaderboard, avatar)
- solitaire_engine/src/resources.rs: cfg-gate TokioRuntimeResource
- solitaire_engine/src/game_plugin.rs: cfg-gate std::fs::remove_file (x10)
- solitaire_engine/src/theme/mod.rs: cfg-gate importer module (uses dirs+zip)
- solitaire_engine/src/settings_plugin.rs: cfg-gate theme ZIP import UI
- solitaire_engine/src/assets/sources.rs: cfg-gate FileAssetReader/user_theme_dir
- solitaire_engine/src/auto_complete_plugin.rs: cfg-gate audio system
- solitaire_engine/src/daily_challenge_plugin.rs: cfg-gate server fetch
- solitaire_engine/src/hud_plugin.rs: cfg-gate AvatarResource import
- solitaire_engine/src/profile_plugin.rs: cfg-gate AvatarResource import
- solitaire_server/web/play.html: minimal HTML canvas shell
- solitaire_web/: new crate (Cargo.toml + src/lib.rs)
- build_wasm.sh: add Bevy WASM build step (cargo + wasm-bindgen + wasm-opt)

All tests pass; clippy --workspace -- -D warnings clean; native build
(solitaire_engine, solitaire_app) unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 13:46:45 -07:00
parent 9260ca7994
commit 835a48fe9d
23 changed files with 573 additions and 51 deletions
+3
View File
@@ -1,2 +1,5 @@
[registries.Quaternions] [registries.Quaternions]
index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/" index = "sparse+https://git.aleshym.co/api/packages/Quaternions/cargo/"
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
Generated
+283
View File
@@ -723,6 +723,28 @@ dependencies = [
"android-activity", "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]] [[package]]
name = "bevy_app" name = "bevy_app"
version = "0.18.1" version = "0.18.1"
@@ -884,6 +906,35 @@ dependencies = [
"syn", "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]] [[package]]
name = "bevy_diagnostic" name = "bevy_diagnostic"
version = "0.18.1" version = "0.18.1"
@@ -951,6 +1002,36 @@ dependencies = [
"encase_derive_impl", "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]] [[package]]
name = "bevy_gizmos" name = "bevy_gizmos"
version = "0.18.1" version = "0.18.1"
@@ -1073,14 +1154,17 @@ checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754"
dependencies = [ dependencies = [
"bevy_a11y", "bevy_a11y",
"bevy_android", "bevy_android",
"bevy_anti_alias",
"bevy_app", "bevy_app",
"bevy_asset", "bevy_asset",
"bevy_camera", "bevy_camera",
"bevy_color", "bevy_color",
"bevy_core_pipeline", "bevy_core_pipeline",
"bevy_derive", "bevy_derive",
"bevy_dev_tools",
"bevy_diagnostic", "bevy_diagnostic",
"bevy_ecs", "bevy_ecs",
"bevy_feathers",
"bevy_gizmos_render", "bevy_gizmos_render",
"bevy_image", "bevy_image",
"bevy_input", "bevy_input",
@@ -1088,6 +1172,7 @@ dependencies = [
"bevy_log", "bevy_log",
"bevy_math", "bevy_math",
"bevy_mesh", "bevy_mesh",
"bevy_pbr",
"bevy_platform", "bevy_platform",
"bevy_ptr", "bevy_ptr",
"bevy_reflect", "bevy_reflect",
@@ -1107,6 +1192,27 @@ dependencies = [
"bevy_winit", "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]] [[package]]
name = "bevy_log" name = "bevy_log"
version = "0.18.1" version = "0.18.1"
@@ -1167,7 +1273,9 @@ dependencies = [
"bevy_asset", "bevy_asset",
"bevy_derive", "bevy_derive",
"bevy_ecs", "bevy_ecs",
"bevy_image",
"bevy_math", "bevy_math",
"bevy_mikktspace",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"bevy_transform", "bevy_transform",
@@ -1180,6 +1288,71 @@ dependencies = [
"wgpu-types", "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]] [[package]]
name = "bevy_platform" name = "bevy_platform"
version = "0.18.1" version = "0.18.1"
@@ -1506,6 +1679,7 @@ dependencies = [
"bevy_input", "bevy_input",
"bevy_input_focus", "bevy_input_focus",
"bevy_math", "bevy_math",
"bevy_picking",
"bevy_platform", "bevy_platform",
"bevy_reflect", "bevy_reflect",
"bevy_sprite", "bevy_sprite",
@@ -1518,6 +1692,7 @@ dependencies = [
"taffy", "taffy",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
@@ -1551,6 +1726,26 @@ dependencies = [
"tracing", "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]] [[package]]
name = "bevy_utils" name = "bevy_utils"
version = "0.18.1" version = "0.18.1"
@@ -1678,6 +1873,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [ dependencies = [
"bytemuck",
"serde_core", "serde_core",
] ]
@@ -3483,6 +3679,17 @@ dependencies = [
"weezl", "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]] [[package]]
name = "glam" name = "glam"
version = "0.30.10" version = "0.30.10"
@@ -3511,6 +3718,27 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 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]] [[package]]
name = "governor" name = "governor"
version = "0.10.4" version = "0.10.4"
@@ -4335,6 +4563,23 @@ dependencies = [
"uuid", "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]] [[package]]
name = "kira" name = "kira"
version = "0.12.0" version = "0.12.0"
@@ -7146,6 +7391,18 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
@@ -9106,6 +9363,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"js-sys",
"log", "log",
"naga", "naga",
"portable-atomic", "portable-atomic",
@@ -9113,6 +9371,8 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"smallvec", "smallvec",
"static_assertions", "static_assertions",
"wasm-bindgen",
"web-sys",
"wgpu-core", "wgpu-core",
"wgpu-hal", "wgpu-hal",
"wgpu-types", "wgpu-types",
@@ -9144,6 +9404,7 @@ dependencies = [
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu-core-deps-apple", "wgpu-core-deps-apple",
"wgpu-core-deps-wasm",
"wgpu-core-deps-windows-linux-android", "wgpu-core-deps-windows-linux-android",
"wgpu-hal", "wgpu-hal",
"wgpu-types", "wgpu-types",
@@ -9158,6 +9419,15 @@ dependencies = [
"wgpu-hal", "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]] [[package]]
name = "wgpu-core-deps-windows-linux-android" name = "wgpu-core-deps-windows-linux-android"
version = "27.0.0" version = "27.0.0"
@@ -9183,15 +9453,20 @@ dependencies = [
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"core-graphics-types 0.2.0", "core-graphics-types 0.2.0",
"glow",
"glutin_wgl_sys",
"gpu-alloc", "gpu-alloc",
"gpu-allocator", "gpu-allocator",
"gpu-descriptor", "gpu-descriptor",
"hashbrown 0.16.1", "hashbrown 0.16.1",
"js-sys",
"khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log", "log",
"metal", "metal",
"naga", "naga",
"ndk-sys",
"objc", "objc",
"once_cell", "once_cell",
"ordered-float", "ordered-float",
@@ -9204,6 +9479,8 @@ dependencies = [
"renderdoc-sys", "renderdoc-sys",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
"wgpu-types", "wgpu-types",
"windows 0.58.0", "windows 0.58.0",
"windows-core 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "xmlwriter" name = "xmlwriter"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -8,6 +8,7 @@ members = [
"solitaire_app", "solitaire_app",
"solitaire_assetgen", "solitaire_assetgen",
"solitaire_wasm", "solitaire_wasm",
"solitaire_web",
] ]
resolver = "2" resolver = "2"
+43 -7
View File
@@ -1,18 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Rebuild the solitaire_wasm crate and install the output into # Rebuild WASM artifacts and install them into solitaire_server/web/pkg/.
# solitaire_server/web/pkg/ so the server can serve the replay viewer. #
# 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: # Prerequisites:
# cargo install wasm-pack # cargo install wasm-pack wasm-bindgen-cli
# rustup target add wasm32-unknown-unknown # rustup target add wasm32-unknown-unknown
# (optional) cargo install wasm-opt # for smaller canvas_bg.wasm
# #
# Run from the repo root: # Run from the repo root:
# ./build_wasm.sh # ./build_wasm.sh
# #
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are # The generated pkg/ files are committed to git so self-hosters who don't
# committed to git so self-hosters who don't touch the WASM crate can # touch the WASM crates can skip this step. Regenerate after any change to
# skip this step. Regenerate after any change to solitaire_wasm/ or # solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
# solitaire_core/.
set -euo pipefail set -euo pipefail
@@ -36,5 +39,38 @@ wasm-pack build \
# Remove them — we manage the output directory ourselves. # Remove them — we manage the output directory ourselves.
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore" 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:" echo "Done. Output:"
ls -lh "$OUT_DIR" ls -lh "$OUT_DIR"
+9 -3
View File
@@ -12,11 +12,17 @@ serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { 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 } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true }
klondike = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `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 # on bionic). On Android `auth_tokens` falls back to a stub
# implementation that always returns `KeychainUnavailable`; the # implementation that always returns `KeychainUnavailable`; the
# real backend lands when we wire Android Keystore via JNI. # 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 } keyring-core = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
+7 -1
View File
@@ -146,13 +146,17 @@ pub use settings::{
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android_keystore; mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens; pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{ pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
}; };
pub mod sync_client; 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 mod replay;
pub use replay::{ pub use replay::{
@@ -163,7 +167,9 @@ pub use replay::{
#[allow(deprecated)] #[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to}; pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
#[cfg(not(target_arch = "wasm32"))]
pub mod matomo_client; pub mod matomo_client;
#[cfg(not(target_arch = "wasm32"))]
pub use matomo_client::MatomoClient; pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
+9 -1
View File
@@ -55,7 +55,15 @@ pub fn data_dir() -> Option<PathBuf> {
{ {
Some(PathBuf::from(ANDROID_APP_FILES_DIR)) 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() dirs::data_dir()
} }
+16 -2
View File
@@ -14,8 +14,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}; use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
use crate::{SyncError, SyncProvider};
#[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
SyncError, SyncProvider,
auth_tokens::{load_access_token, load_refresh_token, store_tokens}, auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay, replay::Replay,
settings::SyncBackend, settings::SyncBackend,
@@ -54,12 +56,17 @@ impl SyncProvider for LocalOnlyProvider {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SolitaireServerClient // 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. /// HTTP sync client for the self-hosted Ferrous Solitaire server.
/// ///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the /// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once /// client automatically attempts a token refresh and retries the request once
/// before returning an error. /// before returning an error.
#[cfg(not(target_arch = "wasm32"))]
pub struct SolitaireServerClient { pub struct SolitaireServerClient {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Base URL of the server, e.g. `"https://solitaire.example.com"`.
/// Trailing slashes are stripped on construction. /// Trailing slashes are stripped on construction.
@@ -70,6 +77,7 @@ pub struct SolitaireServerClient {
client: reqwest::Client, client: reqwest::Client,
} }
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient { impl SolitaireServerClient {
/// Construct a new client for the given server URL and username. /// Construct a new client for the given server URL and username.
/// ///
@@ -201,6 +209,7 @@ impl SolitaireServerClient {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
#[async_trait] #[async_trait]
impl SyncProvider for SolitaireServerClient { impl SyncProvider for SolitaireServerClient {
/// Fetch the latest sync payload from the server. /// Fetch the latest sync payload from the server.
@@ -486,6 +495,7 @@ impl SyncProvider for SolitaireServerClient {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl SolitaireServerClient { impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the /// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path. /// 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 /// Deserialize a pull response body as [`SyncResponse`] and return its
/// `merged` field, or map non-200 statuses to the appropriate [`SyncError`]. /// `merged` field, or map non-200 statuses to the appropriate [`SyncError`].
/// ///
@@ -607,6 +618,7 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
} }
} }
#[cfg(not(target_arch = "wasm32"))]
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`. /// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body( async fn extract_leaderboard_body(
resp: reqwest::Response, 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 /// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`]. /// statuses to the appropriate [`SyncError`].
/// ///
@@ -652,6 +665,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
/// This is the **one** place in the codebase that matches on [`SyncBackend`] /// This is the **one** place in the codebase that matches on [`SyncBackend`]
/// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>` /// variants. All other code receives a `Box<dyn SyncProvider + Send + Sync>`
/// and remains backend-agnostic. /// and remains backend-agnostic.
#[cfg(not(target_arch = "wasm32"))]
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> { pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend { match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider), SyncBackend::Local => Box::new(LocalOnlyProvider),
+15 -11
View File
@@ -7,15 +7,12 @@ edition.workspace = true
[dependencies] [dependencies]
bevy = { workspace = true } bevy = { workspace = true }
image = { workspace = true } image = { workspace = true }
reqwest = { workspace = true }
kira = { workspace = true }
solitaire_core = { workspace = true } solitaire_core = { workspace = true }
solitaire_data = { workspace = true } solitaire_data = { workspace = true }
solitaire_sync = { workspace = true } solitaire_sync = { workspace = true }
klondike = { workspace = true } klondike = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -23,17 +20,24 @@ usvg = { workspace = true }
resvg = { workspace = true } resvg = { workspace = true }
tiny-skia = { workspace = true } tiny-skia = { workspace = true }
ron = { 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 } dirs = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
# `arboard` provides clipboard access for the Stats overlay's # `arboard` has no Android backend and no wasm32 backend. Gate it out for
# "Copy share link" button. The crate has no Android backend # both; the copy-share-link button surfaces an informational toast instead.
# (its `platform::Clipboard` module is unimplemented for the [target.'cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))'.dependencies]
# 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 = { workspace = true } arboard = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
+12 -5
View File
@@ -50,9 +50,11 @@
use bevy::asset::AssetApp; use bevy::asset::AssetApp;
use bevy::asset::io::AssetSourceBuilder; use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::io::embedded::EmbeddedAssetRegistry; use bevy::asset::io::embedded::EmbeddedAssetRegistry;
#[cfg(not(target_arch = "wasm32"))]
use bevy::asset::io::file::FileAssetReader; use bevy::asset::io::file::FileAssetReader;
use bevy::prelude::*; use bevy::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_dir::user_theme_dir; use crate::assets::user_dir::user_theme_dir;
/// `AssetSourceId` of the user-themes asset source. Use it as /// `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 /// Returns the `&mut App` so the call can be chained from the binary
/// entry point. /// entry point.
pub fn register_theme_asset_sources(app: &mut App) -> &mut App { pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
let root = user_theme_dir(); // User themes are stored on the filesystem; wasm32 has no filesystem and
app.register_asset_source( // `FileAssetReader` is not available on that target.
USER_THEMES, #[cfg(not(target_arch = "wasm32"))]
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))), {
); let root = user_theme_dir();
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
}
app app
} }
@@ -11,6 +11,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::RequestRedraw; use bevy::window::RequestRedraw;
#[cfg(not(target_arch = "wasm32"))]
use crate::audio_plugin::{AudioState, SoundLibrary}; use crate::audio_plugin::{AudioState, SoundLibrary};
use crate::events::{MoveRequestEvent, StateChangedEvent}; use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; 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 /// 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 /// volume (`AUTO_COMPLETE_CHIME_VOLUME`) so it is clearly recognisable but does
/// not overwhelm the card-place sounds that follow immediately. /// not overwhelm the card-place sounds that follow immediately.
#[cfg(not(target_arch = "wasm32"))]
fn on_auto_complete_start( fn on_auto_complete_start(
state: Res<AutoCompleteState>, state: Res<AutoCompleteState>,
mut was_active: Local<bool>, mut was_active: Local<bool>,
@@ -117,6 +119,12 @@ fn on_auto_complete_start(
audio.play_sfx_at_volume(&lib.fanfare, AUTO_COMPLETE_CHIME_VOLUME); 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<AutoCompleteState>, mut was_active: Local<bool>) {
*was_active = state.active;
}
/// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active. /// Fires one `MoveRequestEvent` per `STEP_INTERVAL` while auto-complete is active.
fn drive_auto_complete( fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>, mut state: ResMut<AutoCompleteState>,
+24 -16
View File
@@ -13,16 +13,18 @@ use crate::platform::{
default_storage_backend, default_storage_backend,
}; };
use crate::{ use crate::{
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
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. /// Groups all Ferrous Solitaire gameplay plugins.
@@ -45,6 +47,7 @@ impl Plugin for CoreGamePlugin {
Ok(guard) => guard, Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
let sync_provider = sync_provider let sync_provider = sync_provider
.take() .take()
.expect("CoreGamePlugin::build called twice"); .expect("CoreGamePlugin::build called twice");
@@ -104,21 +107,26 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HudPlugin) .add_plugins(HudPlugin)
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default()) .add_plugins(HomePlugin::default())
.add_plugins(AvatarPlugin)
.add_plugins(ProfilePlugin) .add_plugins(ProfilePlugin)
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin) .add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin) .add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin); .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);
} }
} }
@@ -25,6 +25,7 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge. /// Bonus XP awarded for completing today's daily challenge.
@@ -116,17 +117,21 @@ impl Plugin for DailyChallengePlugin {
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight // record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame. // ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate)) .add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation)) .add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning) .add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover); .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. /// Startup system: spawns an async task to fetch the server's daily challenge.
/// ///
/// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is /// Only runs when `SyncProviderResource` is present (i.e. `SyncPlugin` is
@@ -142,6 +147,7 @@ fn fetch_server_challenge(
task_res.0 = Some(task); task_res.0 = Some(task);
} }
#[cfg(not(target_arch = "wasm32"))]
/// Update system: polls the server-challenge fetch task. /// Update system: polls the server-challenge fetch task.
/// ///
/// On success, replaces the locally-computed seed in `DailyChallengeResource` /// On success, replaces the locally-computed seed in `DailyChallengeResource`
+10
View File
@@ -1512,6 +1512,7 @@ mod tests {
use solitaire_data::load_game_state_from; use solitaire_data::load_game_state_from;
let path = tmp_gs_path("exit_save"); let path = tmp_gs_path("exit_save");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let mut app = test_app(7); 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"); let loaded = load_game_state_from(&path).expect("file should exist after exit");
assert_eq!(loaded.seed, 7654); assert_eq!(loaded.seed, 7654);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
@@ -1571,6 +1573,7 @@ mod tests {
use solitaire_data::load_game_state_from; use solitaire_data::load_game_state_from;
let path = tmp_gs_path("auto_save_30s"); let path = tmp_gs_path("auto_save_30s");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let mut app = test_app(42); let mut app = test_app(42);
@@ -1601,6 +1604,7 @@ mod tests {
let loaded = load_game_state_from(&path).expect("file must be loadable"); let loaded = load_game_state_from(&path).expect("file must be loadable");
assert_eq!(loaded.seed, 42); assert_eq!(loaded.seed, 42);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
@@ -1608,6 +1612,7 @@ mod tests {
#[test] #[test]
fn auto_save_skips_when_no_moves() { fn auto_save_skips_when_no_moves() {
let path = tmp_gs_path("auto_save_skip"); let path = tmp_gs_path("auto_save_skip");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let mut app = test_app(99); let mut app = test_app(99);
@@ -2165,6 +2170,7 @@ mod tests {
use solitaire_data::load_replay_history_from; use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json"); let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let mut app = test_app(7654); let mut app = test_app(7654);
@@ -2223,6 +2229,7 @@ mod tests {
other => panic!("second entry must be a Move, got {other:?}"), other => panic!("second entry must be a Move, got {other:?}"),
} }
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
@@ -2234,6 +2241,7 @@ mod tests {
use solitaire_data::load_replay_history_from; use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_history_append.json"); 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 _ = std::fs::remove_file(&path);
let mut app = test_app(11); let mut app = test_app(11);
@@ -2270,6 +2278,7 @@ mod tests {
assert_eq!(history.replays[0].final_score, 200); assert_eq!(history.replays[0].final_score, 200);
assert_eq!(history.replays[1].final_score, 100); assert_eq!(history.replays[1].final_score, 100);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
@@ -2281,6 +2290,7 @@ mod tests {
#[test] #[test]
fn replay_with_empty_recording_skips_save() { fn replay_with_empty_recording_skips_save() {
let path = std::env::temp_dir().join("engine_test_replay_empty_skip.json"); 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 _ = std::fs::remove_file(&path);
let mut app = test_app(1); let mut app = test_app(1);
+7
View File
@@ -13,7 +13,14 @@ use solitaire_core::card::Suit;
use solitaire_core::game_state::{DrawMode, GameMode}; use solitaire_core::game_state::{DrawMode, GameMode};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> 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<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
+12
View File
@@ -1,13 +1,16 @@
//! Bevy integration layer for Ferrous Solitaire. //! Bevy integration layer for Ferrous Solitaire.
pub mod achievement_plugin; pub mod achievement_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod analytics_plugin; pub mod analytics_plugin;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub mod android_clipboard; pub mod android_clipboard;
pub mod animation_plugin; pub mod animation_plugin;
pub mod assets; pub mod assets;
#[cfg(not(target_arch = "wasm32"))]
pub mod audio_plugin; pub mod audio_plugin;
pub mod auto_complete_plugin; pub mod auto_complete_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod avatar_plugin; pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
@@ -26,6 +29,7 @@ pub mod home_plugin;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
#[cfg(not(target_arch = "wasm32"))]
pub mod leaderboard_plugin; pub mod leaderboard_plugin;
pub mod onboarding_plugin; pub mod onboarding_plugin;
pub mod pause_plugin; pub mod pause_plugin;
@@ -43,7 +47,9 @@ pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod sync_plugin; pub mod sync_plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod sync_setup_plugin; pub mod sync_setup_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod theme; pub mod theme;
@@ -57,14 +63,17 @@ pub mod weekly_goals_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))]
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource}; pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue}; pub use animation_plugin::{ActiveToast, AnimationPlugin, CardAnim, ToastEntity, ToastQueue};
pub use assets::{ pub use assets::{
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, bundled_theme_url,
populate_embedded_dark_theme, register_theme_asset_sources, populate_embedded_dark_theme, register_theme_asset_sources,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary}; pub use audio_plugin::{AudioPlugin, AudioState, SoundLibrary};
pub use auto_complete_plugin::AutoCompletePlugin; pub use auto_complete_plugin::AutoCompletePlugin;
#[cfg(not(target_arch = "wasm32"))]
pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource}; pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
pub use card_animation::{ pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin, AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
@@ -117,6 +126,7 @@ pub use hud_plugin::{
}; };
pub use input_plugin::InputPlugin; pub use input_plugin::InputPlugin;
pub use layout::{Layout, LayoutResource, compute_layout}; pub use layout::{Layout, LayoutResource, compute_layout};
#[cfg(not(target_arch = "wasm32"))]
pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen};
pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource}; pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
@@ -155,7 +165,9 @@ pub use stats_plugin::{
ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource, StatsScreen,
StatsUpdate, WatchReplayButton, format_replay_caption, StatsUpdate, WatchReplayButton, format_replay_caption,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
#[cfg(not(target_arch = "wasm32"))]
pub use sync_setup_plugin::SyncSetupPlugin; pub use sync_setup_plugin::SyncSetupPlugin;
pub use table_plugin::{ pub use table_plugin::{
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin, BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
+4
View File
@@ -12,7 +12,11 @@ use solitaire_core::achievement::{ALL_ACHIEVEMENTS, achievement_by_id};
use solitaire_data::SyncBackend; use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent; use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
+7
View File
@@ -128,9 +128,16 @@ pub struct GameInputConsumedResource(pub bool);
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply /// 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 /// into every network task — safe for concurrent `block_on` calls from multiple
/// worker threads. /// 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)] #[derive(Resource, Clone)]
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>); pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
#[cfg(not(target_arch = "wasm32"))]
impl TokioRuntimeResource { impl TokioRuntimeResource {
/// Attempts to build the shared multi-threaded Tokio runtime. /// Attempts to build the shared multi-threaded Tokio runtime.
/// ///
+8 -3
View File
@@ -24,6 +24,7 @@ use solitaire_data::{
use solitaire_data::settings::SyncBackend; use solitaire_data::settings::SyncBackend;
#[cfg(not(target_arch = "wasm32"))]
use crate::assets::user_theme_dir; use crate::assets::user_theme_dir;
use crate::events::{ use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
@@ -32,9 +33,9 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry};
ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry, #[cfg(not(target_arch = "wasm32"))]
}; use crate::theme::{ImportError, import_theme};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
@@ -404,6 +405,7 @@ impl Plugin for SettingsPlugin {
sync_settings_panel_visibility, sync_settings_panel_visibility,
handle_settings_buttons, handle_settings_buttons,
handle_sync_buttons, handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
handle_scan_themes, handle_scan_themes,
update_sync_status_text, update_sync_status_text,
update_card_back_text, update_card_back_text,
@@ -1857,6 +1859,7 @@ fn spawn_settings_panel(
font_res, font_res,
); );
} }
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res); import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) --- // --- 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 /// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme
/// already installed) are silently skipped; all other errors produce a warning /// 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. /// toast. A final toast tells the player to reopen Settings to see new themes.
#[cfg(not(target_arch = "wasm32"))]
fn handle_scan_themes( fn handle_scan_themes(
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
@@ -2759,6 +2763,7 @@ fn pill_button(
/// then presses the button. [`handle_scan_themes`] picks them up, validates, /// then presses the button. [`handle_scan_themes`] picks them up, validates,
/// and installs them. Reopen Settings to see newly imported themes in the /// and installs them. Reopen Settings to see newly imported themes in the
/// card-theme picker. /// card-theme picker.
#[cfg(not(target_arch = "wasm32"))]
fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { fn import_themes_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
let caption_font = TextFont { let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
+2
View File
@@ -12,6 +12,7 @@
//! handles directly on card entities, so a theme switch propagates on //! handles directly on card entities, so a theme switch propagates on
//! the next frame without re-spawning anything. //! the next frame without re-spawning anything.
#[cfg(not(target_arch = "wasm32"))]
pub mod importer; pub mod importer;
pub mod loader; pub mod loader;
pub mod manifest; pub mod manifest;
@@ -28,6 +29,7 @@ use thiserror::Error;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::card::{Rank, Suit};
#[cfg(not(target_arch = "wasm32"))]
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into}; pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest; pub use manifest::ThemeManifest;
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; overflow: hidden; }
#bevy-canvas { display: block; width: 100vw; height: 100vh; }
</style>
</head>
<body>
<canvas id="bevy-canvas"></canvas>
<script type="module">
import init from "/web/pkg/canvas.js";
await init();
</script>
</body>
</html>
+23
View File
@@ -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"] }
+42
View File
@@ -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 `<canvas id="bevy-canvas">` 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 <canvas id="bevy-canvas"> in play.html.
// Without this, Bevy appends its own canvas to <body>.
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();
}