From 2e751297da3e56283c660b0568eda34fc951bee3 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 26 Oct 2026 02:41:08 +0800 Subject: [PATCH 01/22] feat(runtime): add JP hierarchical NPCs and independent translated stage --- .github/workflows/build.yml | 9 +- .github/workflows/release.yml | 22 +- Cargo.lock | 51 +- Cargo.toml | 2 + crates/app/moly-app/Cargo.toml | 4 +- crates/app/moly-app/src/asset_source.rs | 161 +- crates/app/moly-app/src/lib.rs | 46 + crates/app/moly-app/src/player_data_input.rs | 49 +- crates/app/moly-app/src/render_backend.rs | 23 +- crates/app/moly-app/src/site_request.rs | 137 +- crates/app/moly-app/src/wasm_heap.rs | 109 +- crates/app/moly-server-host/Cargo.toml | 2 +- crates/app/moly-stage/Cargo.toml | 28 + crates/app/moly-stage/README.md | 27 + crates/app/moly-stage/src/browser.rs | 132 ++ crates/app/moly-stage/src/lib.rs | 227 +++ crates/app/moly-stage/src/main.rs | 33 + crates/app/moly-stage/src/schedule.rs | 682 +++++++ crates/base/moly-law/src/npc_hfsm/common.rs | 91 + crates/base/moly-law/src/npc_hfsm/decider.rs | 45 +- crates/base/moly-law/src/npc_hfsm/mod.rs | 14 +- crates/base/moly-law/src/objective/mod.rs | 10 +- crates/base/moly-law/src/objective/overlap.rs | 2 +- crates/base/moly-law/src/text/metrics.rs | 15 +- crates/base/moly-text/Cargo.toml | 9 + crates/base/moly-text/README.md | 28 + crates/base/moly-text/examples/inspect.rs | 15 + crates/base/moly-text/src/lib.rs | 340 ++++ crates/engine/moly-assets/Cargo.toml | 2 +- .../engine/moly-assets/src/browser_source.rs | 163 ++ crates/engine/moly-assets/src/lib.rs | 1 + crates/game/moly-audio/Cargo.toml | 7 - crates/game/moly-audio/src/audio.rs | 20 +- crates/game/moly-audio/src/audio_inputs.rs | 5 + crates/game/moly-core/Cargo.toml | 3 + crates/game/moly-core/src/browser_log.rs | 11 +- crates/game/moly-core/src/lib.rs | 5 + crates/game/moly-core/src/render_backend.rs | 21 + crates/game/moly-core/src/ui_font.rs | 147 +- crates/game/moly-core/src/wasm_heap.rs | 110 ++ crates/game/moly-features/Cargo.toml | 7 - .../game/moly-features/src/action_button.rs | 16 +- .../game/moly-features/src/content_library.rs | 4 + .../src/content_library/bridge.rs | 9 +- .../src/content_library/catalog.rs | 41 +- .../src/content_library/context.rs | 1 + .../src/content_library/playback.rs | 2 +- .../moly-features/src/content_library/qa.rs | 5 + crates/game/moly-features/src/cutscene.rs | 35 +- .../game/moly-features/src/cutscene/cast.rs | 4 + .../game/moly-features/src/entry/indicator.rs | 22 +- crates/game/moly-features/src/fixture_edit.rs | 6 + .../game/moly-features/src/game_settings.rs | 23 +- crates/game/moly-features/src/gate_flow.rs | 63 +- crates/game/moly-features/src/joystick.rs | 6 +- crates/game/moly-features/src/lib.rs | 6 + crates/game/moly-features/src/menu_dialog.rs | 23 +- crates/game/moly-features/src/photo_shot.rs | 581 ++++++ crates/game/moly-features/src/player_data.rs | 15 +- .../moly-features/src/player_data_input.rs | 47 + crates/game/moly-features/src/player_input.rs | 3 +- crates/game/moly-features/src/site_move.rs | 7 +- crates/game/moly-features/src/site_request.rs | 135 ++ crates/game/moly-features/src/tutorial.rs | 996 ++++++++++ crates/game/moly-game/Cargo.toml | 7 - crates/game/moly-game/src/lib.rs | 3 + crates/game/moly-game/src/schedule.rs | 1 + crates/game/moly-render/Cargo.toml | 7 - crates/game/moly-render/src/ui_particle.rs | 39 +- .../moly-render/src/ui_particle/admission.rs | 5 +- crates/game/moly-ui/Cargo.toml | 7 - crates/game/moly-ui/examples/text_font.rs | 91 + crates/game/moly-ui/src/ui_layout.rs | 20 +- .../game/moly-ui/src/ui_layout/tmp_layout.rs | 46 +- crates/game/moly-world/Cargo.toml | 8 +- .../moly-world/src/alone_action_runtime.rs | 10 +- crates/game/moly-world/src/balloon.rs | 93 +- crates/game/moly-world/src/camera.rs | 76 +- .../moly-world/src/communication_runtime.rs | 178 ++ crates/game/moly-world/src/emoticon.rs | 53 +- .../src/fixture_gimmick/house_door.rs | 141 +- crates/game/moly-world/src/lib.rs | 3 + crates/game/moly-world/src/npc.rs | 276 ++- .../moly-world/src/npc/change_site_state.rs | 362 +++- .../game/moly-world/src/npc/gate_entries.rs | 4 + crates/game/moly-world/src/npc/residency.rs | 30 +- .../moly-world/src/npc_fixture_activity.rs | 552 +++++- .../src/npc_fixture_activity/preview.rs | 10 +- .../game/moly-world/src/npc_fixture_talk.rs | 437 ++++- crates/game/moly-world/src/npc_gate.rs | 9 +- crates/game/moly-world/src/npc_ik.rs | 253 +++ .../moly-world/src/npc_objective/common.rs | 613 ++++++ .../src/npc_objective/controller.rs | 940 ++++++++- .../moly-world/src/npc_objective/events.rs | 826 ++++++++ .../src/npc_objective/events/tutorial.rs | 434 ++++ .../moly-world/src/npc_objective/lower.rs | 374 ++++ .../game/moly-world/src/npc_objective/mod.rs | 1740 +++++++++++++---- crates/game/moly-world/src/npc_presenter.rs | 14 +- crates/game/moly-world/src/npc_state.rs | 2 + .../game/moly-world/src/npc_talk_lottery.rs | 476 ++++- crates/game/moly-world/src/player.rs | 1 + crates/game/moly-world/src/player_talk.rs | 266 ++- crates/game/moly-world/src/talk.rs | 12 +- crates/game/moly-world/src/talk_ingest.rs | 31 +- crates/game/moly-world/src/talk_window.rs | 51 +- .../game/moly-world/src/text_localization.rs | 107 + crates/server/moly-server/Cargo.toml | 7 +- crates/server/moly-server/src/lib.rs | 2 + docs/building.md | 14 + docs/npc-runtime-migration.md | 47 + web/.gitignore | 1 + web/EMBEDDING.md | 10 + web/RUNTIME.md | 7 + web/TESTING.md | 8 + web/build-wasm-arguments.mjs | 6 +- web/build-wasm-arguments.test.mjs | 4 +- web/build-wasm.mjs | 14 +- web/embed-contract.mjs | 2 +- web/embed-stage.mjs | 55 +- web/embed.d.ts | 53 +- web/embed.mjs | 6 + web/engine-loader.mjs | 8 +- web/engine-loader.test.mjs | 3 +- web/qa/acceptance.mjs | 30 + web/release-artifact.mjs | 55 +- web/resource-sources.mjs | 6 +- web/serve.mjs | 14 +- web/stage.mjs | 188 +- web/text-fonts.mjs | 252 +++ web/text-fonts.test.mjs | 153 ++ web/text-localization.mjs | 680 +++++++ web/text-localization.test.mjs | 182 ++ web/text-publication.mjs | 74 + 133 files changed, 13839 insertions(+), 1515 deletions(-) create mode 100644 crates/app/moly-stage/Cargo.toml create mode 100644 crates/app/moly-stage/README.md create mode 100644 crates/app/moly-stage/src/browser.rs create mode 100644 crates/app/moly-stage/src/lib.rs create mode 100644 crates/app/moly-stage/src/main.rs create mode 100644 crates/app/moly-stage/src/schedule.rs create mode 100644 crates/base/moly-law/src/npc_hfsm/common.rs create mode 100644 crates/base/moly-text/Cargo.toml create mode 100644 crates/base/moly-text/README.md create mode 100644 crates/base/moly-text/examples/inspect.rs create mode 100644 crates/base/moly-text/src/lib.rs create mode 100644 crates/engine/moly-assets/src/browser_source.rs create mode 100644 crates/game/moly-core/src/render_backend.rs create mode 100644 crates/game/moly-core/src/wasm_heap.rs create mode 100644 crates/game/moly-features/src/photo_shot.rs create mode 100644 crates/game/moly-features/src/player_data_input.rs create mode 100644 crates/game/moly-features/src/site_request.rs create mode 100644 crates/game/moly-features/src/tutorial.rs create mode 100644 crates/game/moly-ui/examples/text_font.rs create mode 100644 crates/game/moly-world/src/communication_runtime.rs create mode 100644 crates/game/moly-world/src/npc_ik.rs create mode 100644 crates/game/moly-world/src/npc_objective/common.rs create mode 100644 crates/game/moly-world/src/npc_objective/events.rs create mode 100644 crates/game/moly-world/src/npc_objective/events/tutorial.rs create mode 100644 crates/game/moly-world/src/npc_objective/lower.rs create mode 100644 crates/game/moly-world/src/text_localization.rs create mode 100644 docs/npc-runtime-migration.md create mode 100644 web/text-fonts.mjs create mode 100644 web/text-fonts.test.mjs create mode 100644 web/text-localization.mjs create mode 100644 web/text-localization.test.mjs create mode 100644 web/text-publication.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2538c34c..68805d8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,6 +133,7 @@ jobs: fail-fast: false matrix: renderer: [webgpu, webgl2] + product: [stage, game] runs-on: ubuntu-22.04 timeout-minutes: 240 steps: @@ -147,7 +148,7 @@ jobs: - uses: mozilla-actions/sccache-action@v0.0.11 - uses: Swatinem/rust-cache@v2 with: - key: wasm32-${{ matrix.renderer }} + key: wasm32-${{ matrix.product }}-${{ matrix.renderer }} cache-targets: false cache-bin: true save-if: ${{ github.ref == 'refs/heads/master' }} @@ -159,16 +160,16 @@ jobs: cargo install wasm-bindgen-cli --version "$version" --locked --force fi wasm-bindgen --version - - name: Build the ${{ matrix.renderer }} package + - name: Build the ${{ matrix.product }} ${{ matrix.renderer }} package env: CARGO_BUILD_JOBS: '4' - run: node web/build-wasm.mjs --renderer ${{ matrix.renderer }} + run: node web/build-wasm.mjs --product ${{ matrix.product }} --renderer ${{ matrix.renderer }} --out-dir web/pkg - name: Compiler cache statistics if: always() run: sccache --show-stats - uses: actions/upload-artifact@v4 if: inputs.package with: - name: browser-${{ matrix.renderer }} + name: browser-${{ matrix.product }}-${{ matrix.renderer }} path: web/pkg if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cef4dc3a..a57d7c79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,22 +79,30 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - pattern: browser-* - path: pkg + pattern: browser-stage-* + path: stage/pkg + merge-multiple: true + - uses: actions/download-artifact@v4 + with: + pattern: browser-game-* + path: game/pkg merge-multiple: true - name: Check both renderers and the font are present run: | - test -f pkg/webgpu/build.json - test -f pkg/webgl2/build.json - test "$(find pkg/fonts -name '*.ttf' | wc -l)" -eq 1 + for product in stage game; do + test -f "$product/pkg/webgpu/build.json" + test -f "$product/pkg/webgl2/build.json" + test "$(find "$product/pkg/fonts" -name '*.ttf' | wc -l)" -eq 1 + done - name: Package run: | mkdir -p dist - tar -czf "dist/moly-$VERSION-web.tar.gz" pkg + tar -czf "dist/moly-$VERSION-web-stage.tar.gz" -C stage pkg + tar -czf "dist/moly-$VERSION-web-game.tar.gz" -C game pkg - uses: actions/upload-artifact@v4 with: name: browser - path: dist/moly-${{ env.VERSION }}-web.tar.gz + path: dist/moly-${{ env.VERSION }}-web-*.tar.gz if-no-files-found: error release: diff --git a/Cargo.lock b/Cargo.lock index 5bede96c..5a5e5b8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5020,9 +5020,10 @@ version = "0.0.4" dependencies = [ "bevy", "console_error_panic_hook", - "dlmalloc", "js-sys", "moly-assets", + "moly-core", + "moly-features", "moly-game", "moly-link", "moly-perf", @@ -5061,11 +5062,8 @@ dependencies = [ name = "moly-audio" version = "0.0.4" dependencies = [ - "async-channel", "async-lock", - "base64 0.22.1", "bevy", - "bevy_remote", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5104,6 +5102,7 @@ version = "0.0.4" dependencies = [ "async-lock", "bevy", + "dlmalloc", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5177,11 +5176,8 @@ name = "moly-features" version = "0.0.4" dependencies = [ "arboard", - "async-channel", "async-lock", - "base64 0.22.1", "bevy", - "bevy_remote", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5222,11 +5218,8 @@ dependencies = [ name = "moly-game" version = "0.0.4" dependencies = [ - "async-channel", "async-lock", - "base64 0.22.1", "bevy", - "bevy_remote", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5351,11 +5344,8 @@ dependencies = [ name = "moly-render" version = "0.0.4" dependencies = [ - "async-channel", "async-lock", - "base64 0.22.1", "bevy", - "bevy_remote", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5422,6 +5412,26 @@ dependencies = [ "moly-server", ] +[[package]] +name = "moly-stage" +version = "0.0.4" +dependencies = [ + "bevy", + "console_error_panic_hook", + "moly-assets", + "moly-audio", + "moly-core", + "moly-features", + "moly-link", + "moly-perf", + "moly-protocol", + "moly-render", + "moly-ui", + "moly-world", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "moly-store" version = "0.0.4" @@ -5438,15 +5448,20 @@ dependencies = [ "zip", ] +[[package]] +name = "moly-text" +version = "0.0.4" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "moly-ui" version = "0.0.4" dependencies = [ - "async-channel", "async-lock", - "base64 0.22.1", "bevy", - "bevy_remote", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5485,11 +5500,8 @@ dependencies = [ name = "moly-world" version = "0.0.4" dependencies = [ - "async-channel", "async-lock", - "base64 0.22.1", "bevy", - "bevy_remote", "futures-channel", "futures-util", "getrandom 0.2.17", @@ -5507,6 +5519,7 @@ dependencies = [ "moly-protocol", "moly-render", "moly-store", + "moly-text", "moly-ui", "naga", "parry3d", diff --git a/Cargo.toml b/Cargo.toml index 74a86fda..767b1071 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/base/moly-law", "crates/base/moly-store", "crates/base/moly-plugin", + "crates/base/moly-text", "crates/engine/moly-assets", "crates/engine/moly-perf", "crates/net/moly-protocol", @@ -20,6 +21,7 @@ members = [ "crates/game/moly-masters", "crates/game/moly-game", "crates/app/moly-app", + "crates/app/moly-stage", "crates/app/moly-android", "crates/app/moly-launcher", "crates/app/moly-server-host", diff --git a/crates/app/moly-app/Cargo.toml b/crates/app/moly-app/Cargo.toml index 858603aa..b34a17fc 100644 --- a/crates/app/moly-app/Cargo.toml +++ b/crates/app/moly-app/Cargo.toml @@ -13,6 +13,8 @@ webgl2 = ["bevy/webgl2"] bevy = { workspace = true } moly-assets = { path = "../../engine/moly-assets" } moly-game = { path = "../../game/moly-game" } +moly-core = { path = "../../game/moly-core", features = ["browser-heap"] } +moly-features = { path = "../../game/moly-features" } moly-perf = { path = "../../engine/moly-perf" } moly-store = { path = "../../base/moly-store" } # The game connects to its server over one HTTP endpoint, natively and in the @@ -37,5 +39,3 @@ web-sys = { version = "0.3", features = ["Window", "Location", "UrlSearchParams" js-sys = "0.3" # The control export answers a Promise. wasm-bindgen-futures = "0.4" -# The global allocator's heap; its system layer is the crate's own (src/wasm_heap.rs). -dlmalloc = "0.2" diff --git a/crates/app/moly-app/src/asset_source.rs b/crates/app/moly-app/src/asset_source.rs index e50ed54a..ec4fc005 100644 --- a/crates/app/moly-app/src/asset_source.rs +++ b/crates/app/moly-app/src/asset_source.rs @@ -1,81 +1,8 @@ -//! 资产根的解析入口。 -//! -//! native 读 `MOLY_ASSET_ROOT`(缺省退回启动器装好的资源),由 -//! `moly_assets::asset_source` 实现——native 游戏与 server 共用那一个函数; -//! web 没有 env,读页面的 `?assets=` URL 前缀,浏览器游戏读启动种子里的同名字段, -//! 两条路走同一套准入(准入也在 `moly_assets::asset_source`)。 -//! -//! web 选「URL 参数」不选「同目录约定」:约定无法同步判「缺」——要探测就得 -//! 发请求,失败会散成逐资产 404,唯一拒绝点就没了;参数缺了当场拒绝。 -//! 前缀是同源绝对路径或明确许可的公共资源 HTTPS 根,且以 `/` 结尾。 -//! 玩家数据与页面通信仍在同源 iframe 内,只有公开资源走 CDN。 - +//! Entry adapters around the shared source admission. +pub use moly_assets::browser_source::*; #[cfg(target_arch = "wasm32")] use moly_assets::AssetSource; -pub use moly_assets::asset_source::{ - is_canonical_directory, public_asset_parts, validate_asset_selection, RemoteBaseOverrides, - MASTER_BASE, MASTER_HARUKI_BASE, MASTER_MIRROR_BASE, STORAGE_BASE, STORAGE_MIRROR_BASE, -}; - -/// Native: the resource root, read by the same function the server reads. -#[cfg(not(target_arch = "wasm32"))] -pub use moly_assets::asset_source::{resolve, resolve_remote}; - -/// Pages and the browser game: the same bases the native entry reads, with -/// each one required to be its canonical URL. The repository template itself -/// is checked with a repository substituted for `{repo}`, which the URL of a -/// `{repo}` segment would otherwise escape. -#[cfg(target_arch = "wasm32")] -fn canonical_bases( - bases: moly_assets::remote::RemoteBases, -) -> Result { - let checked = [ - bases.storage.clone(), - bases.storage_mirror.clone(), - bases.master.clone(), - bases.master_mirror.clone(), - MASTER_HARUKI_BASE.replace("{repo}", moly_assets::remote::Region::Jp.repository()), - ]; - for base in &checked { - let url = web_sys::Url::new(base).map_err(|_| format!("Invalid remote base {base}"))?; - if url.href() != *base || !url.search().is_empty() || !url.hash().is_empty() { - return Err(format!("remote base {base} must be its canonical HTTPS directory")); - } - } - Ok(bases) -} - -/// Pages: `?storage_base=`, `?storage_mirror_base=`, `?master_base=`, -/// `?master_mirror_base=` and `?master_haruki_base=` (a template naming -/// `{repo}`) replace the hosts. -#[cfg(target_arch = "wasm32")] -pub fn resolve_remote() -> Result { - let window = - web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; - let search = window - .location() - .search() - .map_err(|e| format!("could not read the page query string: {e:?}"))?; - let params = web_sys::UrlSearchParams::new_with_str(&search) - .map_err(|e| format!("could not parse the query string {search:?}: {e:?}"))?; - let (storage, storage_mirror, master, master_mirror, master_repository) = ( - params.get("storage_base"), - params.get("storage_mirror_base"), - params.get("master_base"), - params.get("master_mirror_base"), - params.get("master_haruki_base"), - ); - let bases = moly_assets::asset_source::remote_bases(RemoteBaseOverrides { - storage: storage.as_deref(), - storage_mirror: storage_mirror.as_deref(), - master: master.as_deref(), - master_mirror: master_mirror.as_deref(), - master_repository: master_repository.as_deref(), - })?; - canonical_bases(bases) -} - /// The browser game: the seed's `storageBase`, `storageMirrorBase`, /// `masterBase` and `masterMirrorBase` mean what the page parameters mean. #[cfg(target_arch = "wasm32")] @@ -93,90 +20,6 @@ pub fn resolve_remote_seed( canonical_bases(bases) } -/// One admission for the page query and the game seed. -#[cfg(target_arch = "wasm32")] -fn resolve_selection( - base: String, - packs: bool, - catalog: Option, - configured_origin: Option<&str>, - configured_base: Option<&str>, -) -> Result { - validate_asset_selection(&base, configured_origin, configured_base)?; - let window = - web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; - if let Some(root) = configured_base { - let url = web_sys::Url::new(root).map_err(|_| "Invalid resource_base")?; - if url.href() != root || !url.search().is_empty() || !url.hash().is_empty() { - return Err("resource_base must be its canonical HTTPS directory".into()); - } - } - let location = window.location(); - let page = location.href().map_err(|_| "Could not read page URL")?; - let url = web_sys::Url::new_with_base(&base, &page).map_err(|_| "Invalid asset URL prefix")?; - let public_parts = public_asset_parts(&base); - let allowed_origin = public_parts.map(|(origin, _)| origin.to_owned()).unwrap_or( - location - .origin() - .map_err(|_| "Could not read page origin")?, - ); - if url.origin() != allowed_origin - || url.pathname() != public_parts.map(|(_, path)| path).unwrap_or(&base) - || (public_parts.is_some() && url.href() != base) - || !url.search().is_empty() - || !url.hash().is_empty() - { - return Err("?assets= must resolve to its canonical trusted resource path".into()); - } - validate_catalog_id(&catalog)?; - match (packs, catalog) { - (true, catalog) => Ok(AssetSource::HttpPacks { url: base, catalog }), - (false, None) => Ok(AssetSource::HttpBase { url: base }), - (false, Some(_)) => Err("asset_catalog requires packs=1".into()), - } -} - -/// A configured release id is a full lowercase SHA-256. -#[cfg(target_arch = "wasm32")] -fn validate_catalog_id(catalog: &Option) -> Result<(), String> { - if catalog.as_deref().is_some_and(|id| { - id.len() != 64 - || !id - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - }) { - return Err("asset catalog must be a full lowercase SHA-256".into()); - } - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -pub fn resolve() -> Result { - let window = - web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; - let search = window - .location() - .search() - .map_err(|e| format!("could not read the page query string: {e:?}"))?; - let params = web_sys::UrlSearchParams::new_with_str(&search) - .map_err(|e| format!("could not parse the query string {search:?}: {e:?}"))?; - let base = params.get("assets").ok_or_else(|| { - "missing ?assets=/ — the HTTP base assets are fetched from".to_owned() - })?; - let packs = match params.get("packs").as_deref() { - Some("1") => true, - None | Some("0") => false, - _ => return Err("?packs= must be 0 or 1".into()), - }; - resolve_selection( - base, - packs, - params.get("asset_catalog"), - params.get("resource_origin").as_deref(), - params.get("resource_base").as_deref(), - ) -} - /// The browser game's asset source: the seed's fields mean what the /// same-named page parameters mean, and pass the same admission. #[cfg(target_arch = "wasm32")] diff --git a/crates/app/moly-app/src/lib.rs b/crates/app/moly-app/src/lib.rs index 2e2d1993..d09588b0 100644 --- a/crates/app/moly-app/src/lib.rs +++ b/crates/app/moly-app/src/lib.rs @@ -400,6 +400,52 @@ pub fn library_snapshot() -> String { moly_game::library_snapshot() } +/// Installs a hash-verified optional font for translated display text. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn install_text_font(bytes: Vec, sha256: &str) -> Result<(), wasm_bindgen::JsValue> { + moly_game::text_localization::install_font(bytes, sha256) + .map_err(|error| wasm_bindgen::JsValue::from_str(&error)) +} + +/// Install display text independently of the selected game-resource region. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn configure_text(source: &str, official: &str, community: &str, locale: &str) -> Result<(), wasm_bindgen::JsValue> { + moly_game::text_localization::configure(source, official, community, locale) + .map_err(|error| wasm_bindgen::JsValue::from_str(&error)) +} + +/// Called at the stage's idle boundary after all publication fonts download. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn configure_text_session( + source: &str, + official: &str, + community: &str, + locale: &str, + font_bytes: Vec, + font_references: &str, +) -> Result<(), wasm_bindgen::JsValue> { + moly_core::ui_font::with_text_font_session(font_bytes, font_references, || { + if source.is_empty() && official.is_empty() && community.is_empty() { + moly_game::text_localization::clear() + } else { + moly_game::text_localization::configure(source, official, community, locale) + } + }).map_err(|error| wasm_bindgen::JsValue::from_str(&error)) +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn clear_text() -> Result<(), wasm_bindgen::JsValue> { + moly_game::text_localization::clear().map_err(|error| wasm_bindgen::JsValue::from_str(&error)) +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn text_ready() -> bool { moly_game::text_localization::ready() } + /// Deployment tooling requests a source-scoped catalogue, never a live entity. #[cfg(target_arch = "wasm32")] #[wasm_bindgen] diff --git a/crates/app/moly-app/src/player_data_input.rs b/crates/app/moly-app/src/player_data_input.rs index aacfd4e1..b7d136c9 100644 --- a/crates/app/moly-app/src/player_data_input.rs +++ b/crates/app/moly-app/src/player_data_input.rs @@ -1,47 +1,2 @@ -//! Optional startup inputs for the shared player-data importer. - -use bevy::prelude::App; -use moly_game::player_data::PlayerDataImport; - -#[cfg(not(target_arch = "wasm32"))] -pub fn configure(app: &mut App) -> Result<(), String> { - let env = |name: &str| -> Result, String> { - match std::env::var(name) { - Ok(value) => Ok(Some(value.trim().to_owned())), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(_) => Err(format!("{name} is not Unicode")), - } - }; - let uid = env("MOLY_PLAYER_UID")?; - let file = std::env::var_os("MOLY_PLAYER_DATA_FILE"); - if uid.is_some() && file.is_some() { - return Err("Choose either MOLY_PLAYER_UID or MOLY_PLAYER_DATA_FILE".into()); - } - let mut state = app.world_mut().resource_mut::(); - state.configure(env("MOLY_PLAYER_API")?, env("MOLY_PLAYER_REGION")?)?; - if let Some(uid) = uid { - state.request_uid(uid); - } - if let Some(file) = file { - let text = moly_game::player_data::read_json_file(std::path::Path::new(&file))?; - state.request_json(text, true); - } - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -pub fn configure(app: &mut App) -> Result<(), String> { - let window = web_sys::window().ok_or("Browser window is unavailable")?; - let search = window - .location() - .search() - .map_err(|_| "Could not read player import parameters")?; - let params = web_sys::UrlSearchParams::new_with_str(&search) - .map_err(|_| "Invalid player import parameters")?; - let mut state = app.world_mut().resource_mut::(); - state.configure(params.get("player_api"), params.get("player_region"))?; - if let Some(uid) = params.get("player_uid") { - state.request_uid(uid); - } - Ok(()) -} +//! Shared page inputs used by both application products. +pub use moly_features::player_data_input::*; diff --git a/crates/app/moly-app/src/render_backend.rs b/crates/app/moly-app/src/render_backend.rs index ff5483c8..72ce1d28 100644 --- a/crates/app/moly-app/src/render_backend.rs +++ b/crates/app/moly-app/src/render_backend.rs @@ -1,21 +1,4 @@ -//! Consume the backend selected by the browser's asynchronous preflight. - -use bevy::render::settings::{Backends, WgpuLimits, WgpuSettings}; - -pub(crate) fn settings(backend: &str) -> Result { - if (backend == "webgpu") != cfg!(feature = "webgpu") { - return Err("The selected renderer requires its matching browser module".into()); - } - match backend { - "webgpu" => Ok(WgpuSettings { - backends: Some(Backends::BROWSER_WEBGPU), - ..Default::default() - }), - "webgl2" => Ok(WgpuSettings { - backends: Some(Backends::GL), - limits: WgpuLimits::downlevel_webgl2_defaults(), - ..Default::default() - }), - _ => Err("A preflighted webgpu or webgl2 backend is required".into()), - } +//! Application feature selection over the shared browser renderer setup. +pub(crate) fn settings(backend: &str) -> Result { + moly_core::render_backend::settings(backend, cfg!(feature = "webgpu")) } diff --git a/crates/app/moly-app/src/site_request.rs b/crates/app/moly-app/src/site_request.rs index a73a2f96..4eaa0fdc 100644 --- a/crates/app/moly-app/src/site_request.rs +++ b/crates/app/moly-app/src/site_request.rs @@ -1,135 +1,2 @@ -//! Explicit offline site inputs. Missing levels defer to that site's saved -//! selection, then its named offline default. Home and room levels are separate. -//! These are expansion-stage overrides, never a fake Mysekai rank progression. - -use moly_game::site::{OfflineSceneContent, SiteRequest}; - -/// The source always enters MySekai on the home site: the join executor's -/// `ChangeSiteCore` changes to `home_site` before the join action, and no -/// remembered site is read. The saved preferred site that `resolve` consults -/// first is a product feature the source does not have. -const DEFAULT_SITE: &str = "home_site"; -#[cfg(any(target_arch = "wasm32", test))] -const INDEPENDENT_BROWSER_SITE: &str = "home_site"; - -#[cfg(any(target_arch = "wasm32", test))] -fn browser_default_site(experience: Option<&str>, preferred: Option<&str>) -> String { - if experience.map(str::trim) == Some("current") { - preferred.unwrap_or(DEFAULT_SITE).to_owned() - } else { - INDEPENDENT_BROWSER_SITE.to_owned() - } -} - -fn parse_content(raw: Option<&str>) -> Result { - match raw.map(str::trim) { - None | Some("compact") => Ok(OfflineSceneContent::Compact), - Some("full") => Ok(OfflineSceneContent::Full), - _ => Err("scene_content must be compact or full".to_owned()), - } -} - -fn parse_level(raw: Option<&str>, name: &str) -> Result, String> { - raw.map(|raw| { - raw.trim() - .parse::() - .ok() - .filter(|v| *v != 0) - .ok_or_else(|| format!("{name} must be a positive level number")) - }) - .transpose() -} - -/// The game, native or in a page, enters like the source: always on the home site, with no -/// remembered site and no level or content overrides. -pub fn game() -> SiteRequest { - SiteRequest { - site: DEFAULT_SITE.to_owned(), - room_level: None, - offline_home_level: None, - content: OfflineSceneContent::Compact, - } -} - -#[cfg(not(target_arch = "wasm32"))] -pub fn resolve() -> Result { - let env = |name: &str| -> Result, String> { - match std::env::var(name) { - Ok(value) => Ok(Some(value)), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(_) => Err(format!("{name} is not Unicode")), - } - }; - let site = match env("MOLY_SITE")? { - Some(raw) if !raw.trim().is_empty() => raw.trim().to_owned(), - Some(_) => return Err("MOLY_SITE is set but empty; it names a site type".into()), - None => moly_game::player_data::preferred_site().unwrap_or_else(|| DEFAULT_SITE.to_owned()), - }; - let room_level = parse_level(env("MOLY_ROOM_LEVEL")?.as_deref(), "MOLY_ROOM_LEVEL")?; - let offline_home_level = parse_level( - env("MOLY_OFFLINE_HOME_LEVEL")?.as_deref(), - "MOLY_OFFLINE_HOME_LEVEL", - )?; - let content = parse_content(env("MOLY_SCENE_CONTENT")?.as_deref())?; - Ok(SiteRequest { - site, - room_level, - offline_home_level, - content, - }) -} - -#[cfg(target_arch = "wasm32")] -pub fn resolve() -> Result { - let window = - web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; - let search = window - .location() - .search() - .map_err(|e| format!("could not read query string: {e:?}"))?; - let params = web_sys::UrlSearchParams::new_with_str(&search) - .map_err(|e| format!("could not parse query string: {e:?}"))?; - let site = match params.get("site") { - Some(raw) if !raw.trim().is_empty() => raw.trim().to_owned(), - Some(_) => return Err("?site= is empty; it names a site type".into()), - None => { - let preferred = moly_game::player_data::preferred_site(); - browser_default_site(params.get("experience").as_deref(), preferred.as_deref()) - } - }; - let room_level = parse_level(params.get("level").as_deref(), "level")?; - let offline_home_level = parse_level( - params.get("offline_home_level").as_deref(), - "offline_home_level", - )?; - let content = parse_content(params.get("scene_content").as_deref())?; - Ok(SiteRequest { - site, - room_level, - offline_home_level, - content, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn browser_independent_mode_starts_on_clean_home_site() { - assert_eq!(browser_default_site(None, Some("grassland")), "home_site"); - assert_eq!( - browser_default_site(Some("independent"), Some("flower_garden")), - "home_site" - ); - } - - #[test] - fn browser_current_mode_preserves_the_normal_site_default() { - assert_eq!( - browser_default_site(Some("current"), Some("flower_garden")), - "flower_garden" - ); - assert_eq!(browser_default_site(Some("current"), None), DEFAULT_SITE); - } -} +//! Shared page inputs used by both application products. +pub use moly_features::site_request::*; diff --git a/crates/app/moly-app/src/wasm_heap.rs b/crates/app/moly-app/src/wasm_heap.rs index 2ab84b0c..fc0a9618 100644 --- a/crates/app/moly-app/src/wasm_heap.rs +++ b/crates/app/moly-app/src/wasm_heap.rs @@ -1,108 +1 @@ -//! The browser build's global allocator: dlmalloc over a system layer that -//! grows the WebAssembly memory geometrically. -//! -//! dlmalloc asks its system layer for what one allocation needs, rounded up to -//! 64 KiB, and the stock WebAssembly layer grows the memory by exactly that. -//! A browser's `memory.grow` takes time that rises with the memory's current -//! size, so a heap that climbs to gigabytes in 64 KiB steps spends most of its -//! loading time growing. This layer grows by an eighth of the current memory -//! (at least 16 MiB) and hands dlmalloc the whole grown region, so the number -//! of grows stays logarithmic in the heap size. The region is contiguous with -//! the previous one, which dlmalloc merges into its top chunk; the only memory -//! held beyond the heap is the unused tail of the last grow. - -use core::alloc::{GlobalAlloc, Layout}; -use core::arch::wasm32; -use core::cell::UnsafeCell; -use core::ptr; - -#[cfg(target_feature = "atomics")] -compile_error!("the browser heap has no lock; a threaded build needs one"); - -const PAGE: usize = 64 * 1024; -/// The smallest grow, in pages (16 MiB). -const MIN_GROW_PAGES: usize = 256; - -struct GeometricGrowth; - -unsafe impl dlmalloc::Allocator for GeometricGrowth { - fn alloc(&self, size: usize) -> (*mut u8, usize, u32) { - let needed = size.div_ceil(PAGE); - let wanted = needed.max(wasm32::memory_size(0) / 8).max(MIN_GROW_PAGES); - // Near the memory's limit the larger grow can fail where the needed - // one still fits. - for pages in [wanted, needed] { - let previous = wasm32::memory_grow(0, pages); - if previous != usize::MAX { - return region(previous, pages); - } - if pages == needed { - break; - } - } - (ptr::null_mut(), 0, 0) - } - - fn remap(&self, _ptr: *mut u8, _old: usize, _new: usize, _can_move: bool) -> *mut u8 { - ptr::null_mut() - } - - fn free_part(&self, _ptr: *mut u8, _old: usize, _new: usize) -> bool { - false - } - - fn free(&self, _ptr: *mut u8, _size: usize) -> bool { - false - } - - fn can_release_part(&self, _flags: u32) -> bool { - false - } - - fn allocates_zeros(&self) -> bool { - true - } - - fn page_size(&self) -> usize { - PAGE - } -} - -/// The grown pages as a dlmalloc region. A region that ends at the top of the -/// 32-bit address space keeps its last bytes out, so no pointer past its end -/// wraps to zero. -fn region(previous: usize, pages: usize) -> (*mut u8, usize, u32) { - let base = previous * PAGE; - let size = pages * PAGE; - if base.wrapping_add(size) == 0 { - return (base as *mut u8, size - 16, 0); - } - (base as *mut u8, size, 0) -} - -struct Heap(UnsafeCell>); - -// The browser build has one thread (no `atomics` target feature), so the heap -// is never entered from two threads. -unsafe impl Sync for Heap {} - -#[global_allocator] -static HEAP: Heap = Heap(UnsafeCell::new(dlmalloc::Dlmalloc::new_with_allocator(GeometricGrowth))); - -unsafe impl GlobalAlloc for Heap { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - unsafe { (*self.0.get()).malloc(layout.size(), layout.align()) } - } - - unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { - unsafe { (*self.0.get()).calloc(layout.size(), layout.align()) } - } - - unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - unsafe { (*self.0.get()).free(ptr, layout.size(), layout.align()) } - } - - unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { - unsafe { (*self.0.get()).realloc(ptr, layout.size(), layout.align(), new_size) } - } -} +//! The browser allocator is supplied by moly-core/browser-heap. diff --git a/crates/app/moly-server-host/Cargo.toml b/crates/app/moly-server-host/Cargo.toml index de966c9c..4b31ecad 100644 --- a/crates/app/moly-server-host/Cargo.toml +++ b/crates/app/moly-server-host/Cargo.toml @@ -13,7 +13,7 @@ name = "moly-server" path = "src/main.rs" [dependencies] -moly-server = { path = "../../server/moly-server" } +moly-server = { path = "../../server/moly-server", features = ["control"] } moly-assets = { path = "../../engine/moly-assets", default-features = false } # The control port: bevy_remote's JSON-RPC server over HTTP. bevy_remote = { version = "0.19.1", default-features = false, features = ["http"] } diff --git a/crates/app/moly-stage/Cargo.toml b/crates/app/moly-stage/Cargo.toml new file mode 100644 index 00000000..562e93a0 --- /dev/null +++ b/crates/app/moly-stage/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "moly-stage" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = ["webgpu"] +webgpu = ["bevy/webgpu"] +webgl2 = ["bevy/webgl2"] + +[dependencies] +bevy = { workspace = true } +moly-core = { path = "../../game/moly-core", features = ["browser-heap"] } +moly-render = { path = "../../game/moly-render" } +moly-audio = { path = "../../game/moly-audio" } +moly-ui = { path = "../../game/moly-ui" } +moly-world = { path = "../../game/moly-world" } +moly-features = { path = "../../game/moly-features" } +moly-assets = { path = "../../engine/moly-assets" } +moly-perf = { path = "../../engine/moly-perf" } +moly-link = { path = "../../net/moly-link", features = ["local"] } +moly-protocol = { path = "../../net/moly-protocol" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "=0.2.127" +console_error_panic_hook = "0.1" +web-sys = { version = "0.3", features = ["Window", "Location", "UrlSearchParams", "Navigator", "Event", "EventTarget", "CustomEvent", "CustomEventInit", "Url"] } diff --git a/crates/app/moly-stage/README.md b/crates/app/moly-stage/README.md new file mode 100644 index 00000000..9674d835 --- /dev/null +++ b/crates/app/moly-stage/README.md @@ -0,0 +1,27 @@ +# moly-stage + +The embedded browser application used by the content SDK. It owns its Bevy +application and schedule and calls the existing field, conversation, fixture, +camera, weather and audio systems directly. It does not depend on `moly-game` +or `moly-devtools`. + +The page keeps control of the catalogue, locale and playback intents. Temporary +cast and furniture staging, restoration, and player-layout preview/exploration +use the same runtime owners as the game. The local server remains because those +owners consume its weather schedule, talk qualification and client data copies. +The stage has no durable save lease and no remote game server endpoint. + +The application does not install harvesting, inventory, crafting, conversion, +missions, the layout editor or developer controls. Shared read-only UI state is +installed where the field's input qualification requires it. Proximity buttons +only offer stage actions; absent game screens cannot trap the field input. + +Build both renderers with `node web/build-wasm.mjs --product stage`, or use +`--renderer webgpu` / `--renderer webgl2`. The binding output keeps the public +`moly-app` filename for existing SDK URL and cache compatibility; `build.json` +identifies the `moly-stage` crate and stage product. Release tooling checks the +stage ABI and rejects game persistence, director and control exports. + +Use the same source revision, profile, toolchain and backend when comparing +size against `--product game`. A crate boundary alone does not demonstrate +smaller downloaded or decoded bytes. diff --git a/crates/app/moly-stage/src/browser.rs b/crates/app/moly-stage/src/browser.rs new file mode 100644 index 00000000..ce82f52e --- /dev/null +++ b/crates/app/moly-stage/src/browser.rs @@ -0,0 +1,132 @@ +//! Browser ABI: source admission and a single canvas/event loop. +use bevy::prelude::*; +use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + +fn error(message: String) -> JsValue { + JsValue::from_str(&message) +} + +#[wasm_bindgen] +pub fn install_ui_font(bytes: Vec) -> Result<(), JsValue> { + moly_core::ui_font::install(bytes).map_err(error) +} + +#[wasm_bindgen] +pub fn install_text_font(bytes: Vec, sha256: &str) -> Result<(), JsValue> { + moly_world::text_localization::install_font(bytes, sha256).map_err(error) +} + +#[wasm_bindgen] +pub fn start_stage(backend: &str) -> Result<(), JsValue> { + moly_core::ui_font::require().map_err(error)?; + let settings = + moly_core::render_backend::settings(backend, cfg!(feature = "webgpu")).map_err(error)?; + let source = moly_assets::browser_source::resolve().map_err(error)?; + let remote = moly_assets::browser_source::resolve_remote().map_err(error)?; + let site = moly_features::site_request::resolve().map_err(error)?; + moly_core::settings_store::set_browser_storage_writable(false); + std::panic::set_hook(Box::new(|info| { + if let Some(window) = web_sys::window() { + let message = info + .payload() + .downcast_ref::() + .map(String::as_str) + .or_else(|| info.payload().downcast_ref::<&str>().copied()) + .unwrap_or("Unexpected stage error"); + let init = web_sys::CustomEventInit::new(); + init.set_detail(&message.into()); + if let Ok(event) = web_sys::CustomEvent::new_with_event_init_dict("moly-error", &init) { + let _ = window.dispatch_event(&event); + } + } + console_error_panic_hook::hook(info); + })); + let mut app = crate::app(source, site, settings); + moly_assets::remote::admit(&mut app, remote); + moly_features::player_data_input::configure(&mut app).map_err(error)?; + // Weather schedules, talk qualification and imported-layout exploration + // consume the local server's client copies. It has no remote write target. + app.add_plugins(moly_link::LocalLinkPlugin { + user: moly_protocol::UserId::local(), + client: format!("moly-stage {}", crate::VERSION), + }); + let world = app.world_mut(); + let mut windows = world.query_filtered::<&mut Window, With>(); + let mut window = windows.single_mut(world).expect("exactly one stage canvas"); + window.canvas = Some("#app-canvas".into()); + window.fit_canvas_to_parent = true; + app.add_systems(Startup, || { + if let Some(window) = web_sys::window() { + if let Ok(event) = web_sys::Event::new("moly-ready") { + let _ = window.dispatch_event(&event); + } + } + }); + app.run(); + Ok(()) +} + +#[wasm_bindgen] +pub fn library_command(command: &str) -> Result<(), JsValue> { + moly_features::content_library::bridge::library_command(command).map_err(error) +} +#[wasm_bindgen] +pub fn library_snapshot() -> String { + moly_features::content_library::bridge::library_snapshot() +} +#[wasm_bindgen] +pub fn library_catalog() -> String { + moly_features::content_library::bridge::library_catalog() +} +#[wasm_bindgen] +pub fn library_diagnostics() -> String { + moly_features::content_library::library_diagnostics() +} +#[wasm_bindgen] +pub fn player_data_command(operation: &str, region: &str, json: &str) -> Result<(), JsValue> { + moly_features::player_data::browser_command(operation, region, json).map_err(error) +} +#[wasm_bindgen] +pub fn player_data_snapshot() -> String { + moly_features::player_data::browser_snapshot() +} +#[wasm_bindgen] +pub fn perf_snapshot(frames: Option) -> String { + moly_perf::snapshot_json(frames.map_or(moly_perf::DEFAULT_RECENT, |frames| frames as usize)) +} +#[wasm_bindgen] +pub fn configure_text( + source: &str, + official: &str, + community: &str, + locale: &str, +) -> Result<(), JsValue> { + moly_world::text_localization::configure(source, official, community, locale).map_err(error) +} + +/// Called at the stage's idle boundary after all publication fonts download. +#[wasm_bindgen] +pub fn configure_text_session( + source: &str, + official: &str, + community: &str, + locale: &str, + font_bytes: Vec, + font_references: &str, +) -> Result<(), JsValue> { + moly_core::ui_font::with_text_font_session(font_bytes, font_references, || { + if source.is_empty() && official.is_empty() && community.is_empty() { + moly_world::text_localization::clear() + } else { + moly_world::text_localization::configure(source, official, community, locale) + } + }).map_err(error) +} +#[wasm_bindgen] +pub fn clear_text() -> Result<(), JsValue> { + moly_world::text_localization::clear().map_err(error) +} +#[wasm_bindgen] +pub fn text_ready() -> bool { + moly_world::text_localization::ready() +} diff --git a/crates/app/moly-stage/src/lib.rs b/crates/app/moly-stage/src/lib.rs new file mode 100644 index 00000000..26b369d3 --- /dev/null +++ b/crates/app/moly-stage/src/lib.rs @@ -0,0 +1,227 @@ +//! Embedded field runtime. The host owns the catalogue and page controls. +//! This assembly does not depend on moly-game or install its progression, +//! inventory, layout-editor, developer-console or full-game lifecycle plugins. + +pub use moly_audio::audio; +pub use moly_audio::audio_startup; +pub use moly_audio::site_sound; +pub use moly_audio::voice_pcm; +pub(crate) use moly_core::animation_time; +#[cfg(target_arch = "wasm32")] +pub(crate) use moly_core::browser_log; +pub use moly_core::client_config; +pub use moly_core::env; +pub use moly_core::inactive_nodes; +pub use moly_core::input_claims; +pub use moly_core::light; +pub use moly_core::markers; +pub use moly_core::material_order; +pub use moly_core::mesh_buffer_release; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use moly_core::native_graphics_diagnostics; +pub use moly_core::overlay_camera; +pub use moly_core::render; +pub(crate) use moly_core::run_mode; +pub use moly_core::ui_font; +pub use moly_core::weather_transition; +pub use moly_features::action_button; +pub use moly_features::birthday; +pub use moly_features::content_library; +pub use moly_features::cutscene; +pub use moly_features::entry; +pub use moly_features::fixture_edit; +pub use moly_features::footstep; +pub use moly_features::game_settings; +pub use moly_features::game_state; +pub use moly_features::info; +pub use moly_features::joystick; +pub use moly_features::mysekai_rank; +pub use moly_features::pick; +pub use moly_features::player_data; +pub use moly_features::player_input; +pub use moly_features::site_move; +pub use moly_features::site_object_dither; +pub use moly_features::sitemap; +pub use moly_features::userdata; +pub use moly_render::character_material; +pub use moly_render::fixture_colors; +pub use moly_render::fixture_emission; +pub use moly_render::fixture_material; +pub use moly_render::gpu_image_release; +pub use moly_render::particle_runtime; +pub use moly_render::shadowmap; +pub use moly_render::site_material; +pub use moly_render::sky; +pub use moly_render::source_color; +pub use moly_render::source_particle_render; +pub use moly_render::source_shader; +pub use moly_render::uber_particle; +pub use moly_render::ui_particle; +pub use moly_render::weather_fx; +pub use moly_ui::browser_stage; +pub use moly_ui::common_dialog; +pub use moly_ui::gesture; +pub use moly_ui::screen_fade; +pub use moly_ui::ui_charsets; +pub use moly_ui::ui_inputs; +pub use moly_ui::ui_layers; +pub use moly_ui::ui_layout; +pub use moly_world::alone_action_runtime; +pub use moly_world::avatar_material; +pub use moly_world::balloon; +pub use moly_world::camera; +pub use moly_world::character; +pub use moly_world::character_silhouette; +pub use moly_world::cloth_runtime; +pub use moly_world::cutscene_camera; +pub use moly_world::emoticon; +pub use moly_world::field_modes; +pub use moly_world::fixture; +pub use moly_world::fixture_activity_data; +pub use moly_world::fixture_activity_provider; +pub use moly_world::fixture_activity_state; +pub use moly_world::fixture_activity_timeline; +pub use moly_world::fixture_attach; +pub use moly_world::fixture_clock; +pub use moly_world::fixture_collision; +pub use moly_world::fixture_gimmick; +pub use moly_world::fixture_player_navigation; +pub use moly_world::fixture_scene_inputs; +pub use moly_world::fixture_talk; +pub use moly_world::house; +pub use moly_world::npc; +pub use moly_world::npc_fixture_activity; +pub use moly_world::npc_gate; +pub use moly_world::npc_look_at; +pub use moly_world::npc_objective; +pub use moly_world::npc_presenter; +pub use moly_world::npc_state; +pub use moly_world::npc_tweet; +pub use moly_world::plain_background; +pub use moly_world::player; +pub use moly_world::player_avatar; +pub use moly_world::player_fixture_action; +pub use moly_world::player_state; +pub use moly_world::player_talk; +pub use moly_world::room_appearance; +pub use moly_world::server_panel; +pub use moly_world::site; +pub use moly_world::system_fixture; +pub use moly_world::talk; +pub use moly_world::talk_camera; +pub use moly_world::talk_window; +pub use moly_world::voice_mouth; +pub use moly_world::walk_face; +pub use moly_world::weather; + +use bevy::prelude::*; +pub use moly_core::VERSION; +#[cfg(target_arch = "wasm32")] +mod browser; +pub mod schedule; +#[cfg(target_arch = "wasm32")] +pub use browser::*; + +pub fn app( + source: moly_assets::AssetSource, + site: site::SiteRequest, + #[cfg(target_arch = "wasm32")] web_render_settings: bevy::render::settings::WgpuSettings, +) -> App { + let mut app = App::new(); + #[cfg(target_arch = "wasm32")] + browser_log::install(); + // Asset sources must be registered before AssetPlugin is built. + moly_assets::install(&mut app, source); + player_avatar::oc::source::register_source(&mut app); + let plugins = DefaultPlugins.set(bevy::window::WindowPlugin { + primary_window: Some(Window { + title: format!("moly v{VERSION}"), + // A native run may start with the window hidden + // (`MOLY_WINDOW_HIDDEN`) for headless batches. + #[cfg(not(target_arch = "wasm32"))] + visible: std::env::var_os("MOLY_WINDOW_HIDDEN").is_none(), + ..default() + }), + ..default() + }); + // On wasm the web asset plugin would add an `https` source that reaches + // any host; remote resources go only through the admitted bases of + // `moly_assets::remote`, which use the same reader. Native builds have no + // such plugin. + #[cfg(target_arch = "wasm32")] + let plugins = plugins.disable::(); + #[cfg(target_arch = "wasm32")] + // The browser host reports failures to its retry page. Keep that hook. + let plugins = plugins + .disable::() + .disable::() + .set(bevy::render::RenderPlugin { + render_creation: bevy::render::settings::RenderCreation::Automatic(web_render_settings), + ..default() + }); + app.add_plugins(plugins); + // A host that seats no documents leaves the instance a non-game one; the + // game's host replaces this before the first frame. + app.init_resource::(); + // Every Time reader sees the engine's Time.maximumDeltaTime as the longest + // frame, not the virtual clock's own 250 ms default. + app.world_mut() + .resource_mut::>() + .set_max_delta(particle_runtime::PLAYER_MAXIMUM_DELTA); + app.add_plugins(game_settings::perf_plugin()); + app.add_plugins(mesh_buffer_release::MeshBufferReleasePlugin); + gpu_image_release::install(&mut app); + #[cfg(not(target_arch = "wasm32"))] + app.add_plugins(native_graphics_diagnostics::NativeGraphicsDiagnosticsPlugin); + + moly_assets::json::register(&mut app); + moly_assets::source_shader::register(&mut app); + app.add_plugins(source_shader::SourceShaderPlugin); + moly_assets::material_textures::register(&mut app); + sky::install(&mut app); + emoticon::install(&mut app); + system_fixture::install(&mut app); + app.add_plugins(weather::WeatherPlugin); + uber_particle::install(&mut app); + audio::install(&mut app); + input_claims::install(&mut app); + ui_layout::install(&mut app); + app.add_plugins(site::SitePlugin(site)); + talk_window::install(&mut app); + schedule::install(&mut app); + browser_stage::configure_browser_stage(&mut app); + content_library::bridge::configure_browser_library(&mut app); + app.add_plugins(userdata::UserDataPlugin); + app.add_plugins(server_panel::ServerPanelPlugin); + app.add_plugins(npc_state::NpcStatePlugin); + site_move::install(&mut app); + screen_fade::install(&mut app); + overlay_camera::install(&mut app); + game_state::install(&mut app); + cutscene_camera::install(&mut app); + footstep::install(&mut app); + app.add_plugins(site_material::SiteMaterialPlugin); + site_object_dither::install(&mut app); + room_appearance::install(&mut app); + plain_background::install(&mut app); + app.add_plugins(material_order::MaterialOrderPlugin); + app.add_plugins(character_material::CharacterMaterialPlugin); + app.add_plugins(avatar_material::AvatarMaterialPlugin); + player_avatar::oc::install(&mut app); + app.add_plugins(shadowmap::ShadowmapPlugin); + app.add_plugins(( + fixture::FixturePlugin, + fixture_material::FixtureMaterialPlugin, + fixture_emission::FixtureEmissionPlugin, + )); + app.add_plugins(character_silhouette::CharacterSilhouettePlugin); + app.add_plugins(( + source_color::SourceColorPlugin, + source_particle_render::SourceParticlePlugin, + )); + app.add_plugins(ui_particle::UiParticlePlugin); + common_dialog::install(&mut app); + // Door transitions can publish this lifecycle notification; stage has no home menu. + app.add_message::(); + app +} diff --git a/crates/app/moly-stage/src/main.rs b/crates/app/moly-stage/src/main.rs new file mode 100644 index 00000000..b4220736 --- /dev/null +++ b/crates/app/moly-stage/src/main.rs @@ -0,0 +1,33 @@ +//! WASM exports start the stage only after browser preflight and a gesture. +#[cfg(target_arch = "wasm32")] +fn main() { + let _entry_referenced_not_called = moly_stage::start_stage; +} +#[cfg(not(target_arch = "wasm32"))] +fn main() { + if !std::env::args().any(|arg| arg == "--check-systems") { + eprintln!("moly-stage is an embedded browser runtime; --check-systems audits its assembly"); + return; + } + // Read-only assembly audit using the same source admission and field + // plugins. It never starts a visible interactive window or writes saves. + std::env::set_var("MOLY_WINDOW_HIDDEN", "1"); + let source = moly_assets::asset_source::resolve().expect("stage asset source"); + let remote = moly_assets::asset_source::resolve_remote().expect("stage remote sources"); + let site = moly_features::site_request::resolve().expect("stage site request"); + let mut app = moly_stage::app(source, site); + moly_assets::remote::admit(&mut app, remote); + app.add_plugins(moly_link::LocalLinkPlugin { + user: moly_protocol::UserId::local(), + client: format!("moly-stage audit {}", moly_stage::VERSION), + }); + let (count, errors) = moly_core::system_access::conflicts(&mut app); + for error in &errors { + eprintln!("[stage-system-access] {error}"); + } + eprintln!( + "[stage-system-access] {count} systems, {} rejected", + errors.len() + ); + std::process::exit(if errors.is_empty() { 0 } else { 1 }); +} diff --git a/crates/app/moly-stage/src/schedule.rs b/crates/app/moly-stage/src/schedule.rs new file mode 100644 index 00000000..4378e147 --- /dev/null +++ b/crates/app/moly-stage/src/schedule.rs @@ -0,0 +1,682 @@ +//! Field, conversation and rendering schedule for the embedded stage. +//! Systems are shared with the game; this product chooses which ones exist. +//! Keep NPC, animation, camera and audio producer/consumer ordering aligned +//! with their domain contracts when either application gains a new system. + +use crate::{ + action_button, alone_action_runtime, audio, balloon, birthday, camera, character, + character_material, client_config, cloth_runtime, content_library, emoticon, fixture_attach, + fixture_edit, fixture_talk, gesture, inactive_nodes, info, joystick, light, npc, npc_objective, + pick, player, player_avatar, player_state, player_talk, site, site_sound, sitemap, sky, talk, + talk_camera, talk_window, uber_particle, ui_layers, walk_face, weather, weather_fx, +}; +use bevy::app::{AnimationSystems, App, PostUpdate, PreUpdate, SpawnScene, Startup, Update}; +use bevy::prelude::{ApplyDeferred, IntoScheduleConfigs}; +use bevy::scene::SceneSpawnerSystems; +use bevy::time::common_conditions; +use bevy::transform::TransformSystems; +use std::time::Duration; + +pub fn install(app: &mut App) { + content_library::install(app); + app.add_systems( + Update, + content_library::qa_open + .run_if(content_library::diagnostics_requested) + .after(content_library::retire_scene) + .after(content_library::build_talk_catalog) + .before(content_library::refresh), + ); + app.add_systems(Startup, content_library::load_capabilities); + app.add_systems( + PreUpdate, + content_library::bridge::gate_input + .after(bevy::input::InputSystems) + .before(content_library::input), + ); + app.add_systems( + Update, + content_library::bridge::publish.after(content_library::refresh), + ); + app.add_systems( + PreUpdate, + content_library::publish_mode + .after(content_library::bridge::gate_input) + .after(content_library::input), + ); + app.add_systems( + Update, + (ApplyDeferred, content_library::publish_mode) + .chain() + .after(content_library::dispatch) + .before(balloon::tick), + ); + app.add_systems( + Update, + ( + content_library::parse_assets, + content_library::parse_capabilities, + content_library::build_talk_catalog, + content_library::build_activity_catalog, + content_library::refresh_context, + content_library::prepare_pending, + content_library::dispatch.run_if(crate::audio_startup::can_play), + ) + .chain() + .after(crate::player_fixture_action::refresh_availability) + .before(player_talk::consume_trigger) + .before(crate::player_fixture_action::receive_requests), + ) + .add_systems( + Update, + content_library::refresh + .after(player_talk::advance_session) + .after(talk::advance_talk) + .after(crate::player_fixture_action::advance), + ) + .add_systems( + Update, + content_library::observe_start + .after(crate::npc_fixture_activity::advance) + .after(crate::fixture_gimmick::advance) + .after(player_talk::advance_session) + .after(talk::advance_talk) + .after(crate::player_fixture_action::advance) + .before(content_library::refresh), + ); + app.add_systems( + Update, + ( + content_library::reap_preview_owners, + content_library::retire_scene, + ) + .chain() + .after(content_library::observe_start) + .before(content_library::refresh), + ); + crate::voice_pcm::install(app); + app.init_resource::() + .add_systems( + Update, + ( + ApplyDeferred, + crate::voice_pcm::prepare, + ApplyDeferred, + crate::voice_pcm::report_start, + crate::voice_mouth::attach, + ApplyDeferred, + crate::voice_mouth::advance, + ) + .chain() + .after(audio::serve_voice) + .after(alone_action_runtime::attach) + .after(alone_action_runtime::advance) + .after(talk::advance_talk) + .after(player_talk::advance_session) + .after(crate::fixture_activity_timeline::advance), + ); + app.configure_sets( + Update, + crate::npc_state::NpcPresenterSet.after(npc_objective::decide), + ) + .add_systems( + Update, + ( + crate::npc_state::on_update, + crate::npc_presenter::try_greeting, + ) + .chain() + .in_set(crate::npc_state::NpcPresenterSet), + ); + app.init_resource::() + .init_resource::() + .add_systems( + Update, + crate::npc_fixture_activity::advance + .after(content_library::dispatch) + .after(npc::advance) + .after(crate::fixture_scene_inputs::advance) + .before(crate::fixture_activity_timeline::advance), + ); + app.init_resource::() + .init_resource::() + .add_systems( + Update, + crate::fixture_scene_inputs::advance + .after(player::reseed) + .after(crate::fixture::refresh_activity_view) + .after(crate::fixture_edit::FixtureEditSystems::Commands) + .before(crate::player_fixture_action::refresh_availability), + ); + app.add_systems( + Update, + crate::fixture_player_navigation::publish + .after(crate::fixture_scene_inputs::advance) + .after(npc_objective::build_face) + .after(walk_face::rebake_on_save) + .before(content_library::refresh_context) + .before(crate::player_fixture_action::refresh_availability) + .before(crate::player_fixture_action::receive_requests), + ); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Startup, crate::fixture_activity_data::load) + .add_systems(Startup, crate::fixture_gimmick::load) + .add_systems( + Update, + crate::fixture_gimmick::parse.before(crate::player_fixture_action::advance), + ) + .add_systems(Update, crate::fixture_activity_data::parse) + .add_systems(Startup, crate::fixture_clock::load) + .add_systems( + Update, + ( + crate::fixture_clock::supply, + crate::fixture_clock::bind, + crate::fixture_clock::advance, + ) + .chain(), + ) + .add_systems( + Update, + crate::fixture_gimmick::catalog_stream::advance + .after(crate::fixture_activity_data::parse) + .after(crate::fixture::refresh_activity_view) + .before(crate::player_fixture_action::refresh_availability), + ); + app.add_systems( + Startup, + ( + crate::entry::init, + crate::entry::cover::spawn, + crate::entry::indicator::request, + crate::house::request, + ), + ) + .add_systems( + Update, + crate::house::build.before(crate::fixture::FixtureLayoutSet), + ) + .add_systems( + Update, + joystick::force_reset + .after(action_button::click) + .before(player::advance), + ) + .add_systems( + Update, + crate::entry::advance + .after(crate::fixture::FixtureLayoutSet) + .after(player_state::drive_from_input) + .after(action_button::click) + .before(crate::player_fixture_action::request_end_from_input) + .before(player::advance) + .before(player_avatar::drive) + .before(audio::advance_se), + ); + crate::game_settings::install(app); + crate::player_data::install_stage(app); + crate::fixture_colors::install(app); + app.add_systems(bevy::app::PreStartup, crate::ui_font::add_asset); + app.add_systems(Startup, audio::init_settings); + app.add_systems( + PreUpdate, + crate::game_settings::release_input_guard + .after(bevy::input::InputSystems) + .before(content_library::input), + ); + app.add_systems( + Startup, + (info::init, crate::game_settings::setup_stage).chain(), + ) + .add_systems( + Startup, + content_library::setup.after(balloon::overlay_camera), + ) + .add_systems( + PreUpdate, + content_library::input.after(bevy::ui::UiSystems::Focus), + ) + .add_systems(Update, crate::game_settings::apply_graphics) + .add_systems(PostUpdate, audio::apply_sink_volumes); + app.add_systems(Startup, crate::ui_layout::load) + .add_systems(Update, crate::ui_layout::parse) + .add_systems(Update, crate::ui_layout::render) + .add_systems( + Update, + ( + uber_particle::request_fixture_particles, + uber_particle::plan_fixture_particles, + uber_particle::spawn_fixture_particles, + crate::weather_fx::fixture::spawn_when_ready, + ) + .chain(), + ) + .add_systems( + PostUpdate, + uber_particle::advance_fixture_particles + .after(TransformSystems::Propagate) + .after(camera::follow_avatar), + ); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_message::() + .insert_resource(crate::ui_charsets::ShellTextCharset { chars: Vec::new() }) + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .init_resource::() + .init_resource::() + .add_observer(character::on_model_scene_ready) + .add_observer(player_avatar::body::on_scene_ready) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems( + bevy::app::First, + crate::particle_runtime::advance_unscaled_clock.after(bevy::time::TimeSystems), + ) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(PreUpdate, camera::track_prev_site) + .add_systems( + Startup, + ( + ( + site::load, + character::load, + camera::spawn, + light::spawn, + npc::load, + player::load, + sky::load, + character_material::insert_neutral, + character_material::apply_sunny, + character_material::load_fog, + ) + .chain(), + ( + client_config::load, + birthday::load, + crate::mysekai_rank::load, + uber_particle::load, + fixture_attach::load, + balloon::load, + alone_action_runtime::load, + emoticon::load, + talk::load, + player_talk::load, + content_library::load, + talk_window::load, + joystick::load, + action_button::load, + audio::load, + balloon::overlay_camera, + sitemap::overlay_camera, + ) + .chain(), + ) + .chain(), + ) + .add_systems(Update, character_material::apply_fog) + .add_systems( + Update, + ( + ( + client_config::parse, + birthday::parse, + crate::mysekai_rank::parse, + ), + ( + site::parse_masters, + site::read_switch.after(site::parse_masters), + site::plan.after(site::read_switch), + site::spawn_when_ready.after(site::plan), + inactive_nodes::parse, + site_sound::parse, + site_sound::apply + .after(site_sound::parse) + .after(inactive_nodes::parse), + site::report_anchor.after(site::spawn_when_ready), + walk_face::build.after(site::spawn_when_ready), + npc_objective::build_face.after(walk_face::build), + walk_face::rebake_on_save.after(walk_face::build), + ), + ( + joystick::advance, + gesture::advance, + joystick::spawn_when_ready, + joystick::place_ui, + action_button::parse_tables, + action_button::spawn_when_ready, + action_button::advance.before(crate::markers::ActionButtonOrder), + action_button::place_ui, + action_button::place_change_ui, + action_button::click, + pick::pick, + ) + .chain(), + ( + ( + fixture_attach::parse, + npc::parse, + npc::spawn_when_ready + .after(npc::parse) + .after(fixture_attach::parse), + npc::reseed.after(npc::spawn_when_ready), + npc::advance.after(npc::reseed), + npc_objective::decide + .after(npc::advance) + .before(npc::sync_rest_lifecycle), + crate::npc_gate::appear + .after(npc_objective::decide) + .before(npc::sync_rest_lifecycle), + crate::npc_look_at::step.after(npc::advance), + npc::report + .after(npc::advance) + .run_if(common_conditions::on_timer(Duration::from_secs(2))), + ), + ( + camera::parse, + camera::apply_input + .after(camera::parse) + .after(gesture::advance) + .run_if(crate::game_settings::camera_input_enabled), + ), + ( + player::spawn_when_ready, + player_avatar::body::request.after(player::spawn_when_ready), + player_avatar::body::attach.after(player_avatar::body::request), + player_avatar::body::wire.after(player_avatar::body::attach), + character::plan_when_ready + .after(player::spawn_when_ready) + .after(npc::spawn_when_ready), + character::attach_when_ready.after(character::plan_when_ready), + character::wire_when_ready.after(character::attach_when_ready), + character_material::plan_when_wired.after(character::wire_when_ready), + character_material::swap_when_planned + .after(character_material::plan_when_wired), + cloth_runtime::plan_when_wired.after(character::wire_when_ready), + player::reseed.after(player::spawn_when_ready), + ( + crate::player_input::read_input.after(player::reseed), + player::advance, + ) + .chain(), + ), + ( + alone_action_runtime::parse_alone_actions, + alone_action_runtime::parse_facial_tables, + alone_action_runtime::attach + .after(alone_action_runtime::parse_alone_actions) + .after(alone_action_runtime::parse_facial_tables) + .after(character_material::swap_when_planned), + alone_action_runtime::report.after(alone_action_runtime::attach), + ), + ( + player_avatar::drive + .after(player::advance) + .after(player_avatar::body::wire), + player::tune_animation_speed.after(player_avatar::drive), + player::report + .after(player::advance) + .run_if(common_conditions::on_timer(Duration::from_secs(2))), + balloon::bake_atlas + .after(crate::npc_tweet::parse) + .after(player_talk::parse), + ( + balloon::hud_event + .after(balloon::bake_atlas) + .after(crate::npc_state::NpcPresenterSet), + ApplyDeferred, + balloon::after_edit_reaction, + ) + .chain(), + balloon::tick.after(balloon::after_edit_reaction), + ), + ( + emoticon::parse, + emoticon::spawn_when_ready.after(emoticon::parse), + ), + ( + ( + talk::parse, + fixture_talk::plan_timelines, + fixture_talk::resolve_timelines, + talk::prepare, + player_talk::parse, + player_talk::prepare, + player_talk::consume_trigger, + ApplyDeferred, + npc::sync_rest_lifecycle.after(npc::advance), + alone_action_runtime::advance, + (talk::fixture_action::drive, character::drive).chain(), + emoticon::serve_rest, + (talk::publish_voice_prefetch, audio::prefetch_voice).chain(), + talk_window::read_click_input + .run_if(crate::game_settings::talk_input_enabled), + fixture_talk::discover_faces, + ( + fixture_talk::discover_animation, + fixture_talk::discover_late_animation, + ) + .chain(), + talk::advance_talk, + player_talk::advance_session, + ) + .chain(), + ( + ApplyDeferred, + audio::serve_voice, + fixture_talk::progress_timelines, + fixture_talk::progress_turns, + talk::progress_turns, + player_talk::progress_turns, + emoticon::serve_talk, + talk_window::tick_window, + talk::report + .run_if(common_conditions::on_timer(Duration::from_secs(5))), + player_talk::report + .run_if(common_conditions::on_timer(Duration::from_secs(5))), + ) + .chain(), + ) + .chain(), + ), + ) + .chain(), + ) + .add_systems( + Update, + ( + player_state::drive_from_talk, + player_state::drive_from_input, + ) + .chain() + .after(crate::player_input::read_input), + ) + .add_systems( + Update, + ( + crate::player_fixture_action::request_end_from_input, + crate::player_fixture_action::refresh_availability, + crate::player_fixture_action::receive_requests, + crate::player_fixture_action::advance, + crate::fixture_gimmick::advance, + crate::player_avatar::item_timeline::advance, + crate::fixture_activity_timeline::advance.after(crate::npc_state::NpcPresenterSet), + ) + .chain() + .after(action_button::click) + .after(player_state::drive_from_input) + .before(player::advance) + .before(player_avatar::drive) + .before(audio::advance_se), + ) + .add_systems( + SpawnScene, + inactive_nodes::apply.after(SceneSpawnerSystems::Spawn), + ) + .add_systems( + Update, + ( + sky::parse_packages, + sky::parse_sidecar, + sky::spawn_when_ready, + ) + .chain(), + ) + .add_systems(Update, sky::parse_phenomena) + .add_systems( + Update, + ( + uber_particle::plan, + uber_particle::spawn_when_ready, + uber_particle::report.run_if(common_conditions::on_timer(Duration::from_secs(2))), + ) + .chain(), + ) + .add_systems( + Update, + ( + weather_fx::notify_invisible, + weather_fx::expire_retirements, + crate::fixture_collision::publish + .after(crate::fixture::FixtureLayoutSet) + .before(weather_fx::sync_fixture_colliders), + weather_fx::sync_fixture_colliders, + weather_fx::watch, + weather_fx::parse, + weather_fx::plan, + weather_fx::spawn_when_ready, + weather_fx::refresh_effect_visible, + weather_fx::report.run_if(common_conditions::on_timer(Duration::from_secs(2))), + ) + .chain() + .after(crate::weather_transition::WeatherEnvironmentUpdate), + ) + .add_systems( + Update, + ( + audio::parse, + audio::advance_bgm.run_if(crate::audio_startup::can_prepare), + audio::advance_ambient + .after(weather::commit_environment) + .run_if(crate::audio_startup::can_prepare), + audio::advance_ambient_sequence.run_if(crate::audio_startup::can_prepare), + audio::advance_proximity.run_if(crate::audio_startup::can_prepare), + audio::advance_se + .in_set(audio::SeDrainSet::Drain) + .after(talk::advance_talk) + .after(talk_window::tick_window), + audio::report.run_if(common_conditions::on_timer(Duration::from_secs(2))), + ) + .chain(), + ) + .add_systems( + PostUpdate, + crate::animation_time::refuse_non_finite:: + .after(bevy::animation::advance_animations) + .before(bevy::animation::animate_targets), + ) + .add_systems( + PostUpdate, + ( + cloth_runtime::advance + .after(AnimationSystems) + .before(TransformSystems::Propagate), + cloth_runtime::report + .after(cloth_runtime::advance) + .run_if(common_conditions::on_timer(cloth_runtime::REPORT_PERIOD)), + ), + ) + .add_systems( + PostUpdate, + crate::particle_runtime::collision_scene::observe_product + .after(TransformSystems::Propagate), + ) + .add_systems( + PostUpdate, + ( + camera::frame_site + .after(TransformSystems::Propagate) + .in_set(crate::markers::SiteFramed), + camera::follow_avatar + .after(TransformSystems::Propagate) + .after(camera::frame_site), + camera::report_follow + .after(camera::follow_avatar) + .run_if(common_conditions::on_timer(Duration::from_secs(2))), + sky::follow_player_view.after(camera::follow_avatar), + uber_particle::advance + .after(TransformSystems::Propagate) + .after(camera::follow_avatar), + weather_fx::advance + .after(TransformSystems::Propagate) + .after(camera::follow_avatar) + .after(crate::particle_runtime::collision_scene::observe_product), + character_material::write_frame_state + .after(TransformSystems::Propagate) + .after(camera::frame_site), + character_material::update_head.after(TransformSystems::Propagate), + character_material::report_head + .after(character_material::update_head) + .run_if(common_conditions::on_timer(Duration::from_secs(3))), + balloon::place + .after(TransformSystems::Propagate) + .after(camera::follow_avatar), + emoticon::advance + .after(TransformSystems::Propagate) + .after(camera::follow_avatar), + emoticon::report + .after(emoticon::advance) + .run_if(common_conditions::on_timer(Duration::from_secs(2))), + ), + ) + .add_systems( + PostUpdate, + ( + talk_camera::capture_normal + .after(camera::frame_site) + .before(camera::follow_avatar), + talk_camera::advance + .after(camera::follow_avatar) + .before(sky::follow_player_view) + .before(character_material::write_frame_state) + .before(balloon::place) + .before(emoticon::advance), + ), + ); + ui_layers::install(app); + app.add_message::() + .init_resource::() + .add_systems(Update, ui_layers::advance.after(action_button::click)); + app.add_systems( + Update, + ( + crate::common_dialog::click.run_if(crate::game_settings::scene_input_enabled), + crate::common_dialog::place, + ) + .chain() + .after(action_button::click) + .after(ui_layers::back_key) + .before(ui_layers::advance), + ); +} diff --git a/crates/base/moly-law/src/npc_hfsm/common.rs b/crates/base/moly-law/src/npc_hfsm/common.rs new file mode 100644 index 00000000..844c6931 --- /dev/null +++ b/crates/base/moly-law/src/npc_hfsm/common.rs @@ -0,0 +1,91 @@ +//! Ordinary multi-character gathering. Geometry preserves member order while +//! assigning circle slots by the members' angles around the selected centre. + +pub const ARRIVAL_DISTANCE: f32 = 0.25; +pub const FINAL_APPROACH_SPEED: f32 = 0.3; +pub const FINAL_APPROACH_EPSILON: f32 = 0.01; +pub const RESERVATION_DEADLINE: f32 = 30.0; +pub const GATHER_STALL_SECONDS: f32 = 15.0; +pub const RECRUIT_RETRY_SECONDS: f32 = 10.0; +pub const SHARED_PLAN_GRACE_SECONDS: f32 = 1.0; + +pub fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { + let [x, y, z] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + (z * z + (x * x + y * y)).sqrt() +} + +pub fn already_together(positions: &[[f32; 3]]) -> bool { + positions.iter().enumerate().all(|(i, a)| positions.iter().skip(i + 1) + .all(|b| distance(*a, *b) <= ARRIVAL_DISTANCE)) +} + +pub fn circle_positions(positions: &[[f32; 3]], center: [f32; 3]) -> Option> { + if !(2..=5).contains(&positions.len()) { return None; } + let angles: Vec = positions.iter().map(|p| (p[2] - center[2]).atan2(p[0] - center[0])).collect(); + let mut order: Vec = (0..positions.len()).collect(); + order.sort_by(|a, b| angles[*a].total_cmp(&angles[*b])); + // These literals intentionally retain the authored precision. + let count = positions.len() as f32; + let radius = 1.0 / (2.0 * (3.1416 / count).sin()); + let base = angles[order[0]]; + let mut output = positions.to_vec(); + for (slot, member) in order.into_iter().enumerate() { + let angle = base + slot as f32 * 6.2832 / count; + output[member] = [center[0] + radius * angle.cos(), center[1], center[2] + radius * angle.sin()]; + } + Some(output) +} + +pub fn approach_step(from: [f32; 3], target: [f32; 3], dt: f32) -> [f32; 3] { + let length = distance(from, target); + let step = dt * FINAL_APPROACH_SPEED; + if length == 0.0 || (step >= 0.0 && length <= step) { return target; } + std::array::from_fn(|i| from[i] + step * ((target[i] - from[i]) / length)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommonPhase { Gathering, Tweeting, WaitingTalkEnd, Complete } + +#[derive(Debug, Clone, Copy)] +pub struct GatheringClock { + pub elapsed: f32, + pub last_progress: f32, + pub deadline: f32, +} + +impl GatheringClock { + pub fn new(now: f32) -> Self { + Self { elapsed: 0.0, last_progress: 0.0, deadline: now + RESERVATION_DEADLINE } + } + pub fn advance(&mut self, dt: f32, now: f32, pending: bool, moving: bool) -> bool { + self.elapsed += dt; + if pending && now >= self.deadline { return false; } + if pending || moving { self.progress(); } + self.elapsed - self.last_progress < GATHER_STALL_SECONDS + } + pub fn progress(&mut self) { self.last_progress = self.elapsed; } + pub fn can_retry(&self) -> bool { self.elapsed < RECRUIT_RETRY_SECONDS } +} + +/// Reservations are unique per member. Validity is tied to the live main +/// plan, rather than to the talk id (the same conversation can run again). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReservationKind { Common, MultiFixture, WhileDoingWait } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReservationIdentity { + pub kind: ReservationKind, + pub plan: u64, + pub main: u32, +} + +pub fn valid_reservation( + reservation: ReservationIdentity, + active: Option<(u64, u32, bool)>, + pending_upper: Option<(u64, u32)>, +) -> bool { + match active { + Some((plan, main, accepting)) if plan == reservation.plan && main == reservation.main => accepting, + _ => pending_upper == Some((reservation.plan, reservation.main)), + } +} diff --git a/crates/base/moly-law/src/npc_hfsm/decider.rs b/crates/base/moly-law/src/npc_hfsm/decider.rs index 08727696..756e310b 100644 --- a/crates/base/moly-law/src/npc_hfsm/decider.rs +++ b/crates/base/moly-law/src/npc_hfsm/decider.rs @@ -12,7 +12,7 @@ //! roulette. //! //! The already-read entry's `CreateFixtureTalkSubTreePlan` draws `r1` in -//! `[0, 100)` and takes the none-talk branch when `P143 <= r1` (a high roll); +//! `[0, 100)` and takes the none-talk branch when `r1 < P143`; //! the other branch draws `r2` and takes the common-fixture plan when //! `P148 <= r2`, the already-read fixture plan otherwise. Every branch that //! yields no plan answers no plan from `Decide` as well. @@ -63,7 +63,7 @@ pub enum EntryPlan { /// The already-read entry's sub-tree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SubTree { - /// `P143 <= r1`: the none-talk branch. + /// `r1 < P143`: the none-talk branch. NoneTalk, /// `P148 <= r2`: the common-fixture plan. CommonFixtureTalk, @@ -117,7 +117,7 @@ impl DeciderOutcome { /// `CreateFixtureTalkSubTreePlan`: `r1` always, `r2` only on the low `r1`. pub fn fixture_talk_subtree(percents: &LotteryPercents, rand: &mut (impl PercentDraw + ?Sized)) -> (SubTree, Option, Option) { let r1 = rand.draw_percent(); - if percents.none_talk_fixture_action <= r1 as f32 { + if (r1 as f32) < percents.none_talk_fixture_action { return (SubTree::NoneTalk, Some(r1), None); } let r2 = rand.draw_percent(); @@ -170,6 +170,25 @@ pub fn decide( outcome } +/// The plan factory reached by the controller's objective decider. +/// A failed fixture entry falls back to the general factory. The first +/// entry uses the same subtree when the client has no unread talk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanFactory { UnreadFixture, NoneTalk, CommonFixture, ReadFixture, General } + +pub fn select_plan(percents: &LotteryPercents, unread: bool, rand: &mut impl PercentDraw) -> PlanFactory { + let draw = rand.draw_percent() as f32; + if percents.fixture_talk > draw && unread { return PlanFactory::UnreadFixture; } + if percents.fixture_talk + percents.already_read_fixture_talk <= draw { + return PlanFactory::General; + } + match fixture_talk_subtree(percents, rand).0 { + SubTree::NoneTalk => PlanFactory::NoneTalk, + SubTree::CommonFixtureTalk => PlanFactory::CommonFixture, + SubTree::AlreadyReadTalk => PlanFactory::ReadFixture, + } +} + #[cfg(test)] mod tests { use super::*; @@ -247,13 +266,13 @@ mod tests { ] { let (outcome, queue) = match want_entry { Some(DeciderEntry::FixtureTalk) => run(&percents, &[r], EntryPlan::FixtureTalk, [EntryPlan::NoneTalk, EntryPlan::CommonFixtureTalk]), - _ => run(&percents, &[r, 90, 10], EntryPlan::FixtureTalk, [EntryPlan::NoneTalk, EntryPlan::CommonFixtureTalk]), + _ => run(&percents, &[r, 10, 90], EntryPlan::FixtureTalk, [EntryPlan::NoneTalk, EntryPlan::CommonFixtureTalk]), }; assert_eq!(outcome.entry.map(|(entry, _)| entry), want_entry, "r={r}"); if let Some(plan) = want_plan { assert_eq!(outcome.plan(), Some(plan), "r={r}"); } - // The turnstile always; the already-read entry adds its `r1` (90, + // The turnstile always; the already-read entry adds its `r1` (10, // the none-talk branch, so no `r2`); the fixture entry and a walk // that reaches no entry spend the turnstile alone. let expected = match want_entry { @@ -268,17 +287,17 @@ mod tests { #[test] fn the_subtree_branches_on_its_two_draws() { let percents = percents(); - // Already-read entry (turnstile 60 enters it): `P143 <= r1` (25) is the - // none-talk branch — one draw, no `r2`; below it the code draws `r2` + // Already-read entry (turnstile 60 enters it): `r1 < P143` (25) is the + // none-talk branch — one draw, no `r2`; at or above it the code draws `r2` // and takes the common plan when `P148 <= r2` (50), the already-read // plan otherwise. let plans = [EntryPlan::NoneTalk, EntryPlan::CommonFixtureTalk]; let cases = [ - (25, 90, SubTree::NoneTalk, Some(2)), - (0, 50, SubTree::CommonFixtureTalk, Some(3)), - (24, 50, SubTree::CommonFixtureTalk, Some(3)), - (24, 49, SubTree::AlreadyReadTalk, Some(3)), - (10, 0, SubTree::AlreadyReadTalk, Some(3)), + (24, 90, SubTree::NoneTalk, Some(2)), + (0, 50, SubTree::NoneTalk, Some(2)), + (25, 50, SubTree::CommonFixtureTalk, Some(3)), + (25, 49, SubTree::AlreadyReadTalk, Some(3)), + (99, 0, SubTree::AlreadyReadTalk, Some(3)), ]; for (r1, r2, want_sub, want_taken) in cases { let mut queue = Queue::at(&[60, r1, r2]); @@ -310,7 +329,7 @@ mod tests { let mut sub = subtree([EntryPlan::NoPlan, EntryPlan::NoPlan]); let outcome = decide(&percents(), &mut queue, &mut || EntryPlan::FixtureTalk, &mut sub); assert_eq!(outcome.plan(), None); - assert_eq!(queue.taken, if r1 >= 25 { 2 } else { 3 }); + assert_eq!(queue.taken, if r1 < 25 { 2 } else { 3 }); } } diff --git a/crates/base/moly-law/src/npc_hfsm/mod.rs b/crates/base/moly-law/src/npc_hfsm/mod.rs index d2554e2b..bd37eb35 100644 --- a/crates/base/moly-law/src/npc_hfsm/mod.rs +++ b/crates/base/moly-law/src/npc_hfsm/mod.rs @@ -16,6 +16,7 @@ //! the presenter's fixture-first test. pub mod decider; +pub mod common; pub mod lower_dispatch; pub mod lower_idle; pub mod plan; @@ -466,7 +467,7 @@ impl LowerState { // state body decides. if matches!( self, - L::CutScene | L::EntrySite | L::SubFixtureAction | L::ImmediatelyTimeline + L::CutScene | L::EntrySite | L::SubFixtureAction | L::MultiFixtureAction | L::ImmediatelyTimeline ) { flags.can_interrupt = false; } @@ -478,6 +479,11 @@ impl LowerState { | L::MultiFixtureAction | L::SubFixtureAction | L::ImmediatelyTimeline + | L::CommunicationWhileDoingWait + | L::SubCommunicationWhileDoingWait + | L::CommonSomeCharacterCommunication + | L::SubCommonSomeCharacterWait + | L::NoneTalkFixtureAction ) { flags.can_be_recruited = false; } @@ -487,6 +493,8 @@ impl LowerState { | L::CommonFixtureTweet | L::SingleFixtureAction | L::MultiFixtureAction + | L::SubFixtureAction + | L::NoneTalkFixtureAction ) { flags.blocks_photo_shot_capture = true; } @@ -498,6 +506,8 @@ impl LowerState { | L::CommunicationWhileDoingWait | L::MainCommunication | L::SubCommunication + | L::SubCommunicationWhileDoingWait + | L::SubCommonSomeCharacterWait ) { flags.is_multi_character_action = true; } @@ -724,4 +734,4 @@ mod tests { assert!(!should_return_to_tutorial_waiting(true, Some(TalkType::Common))); assert!(!should_return_to_tutorial_waiting(true, None)); } -} \ No newline at end of file +} diff --git a/crates/base/moly-law/src/objective/mod.rs b/crates/base/moly-law/src/objective/mod.rs index 7b751863..73fa12de 100644 --- a/crates/base/moly-law/src/objective/mod.rs +++ b/crates/base/moly-law/src/objective/mod.rs @@ -140,7 +140,7 @@ impl ObjectiveType { } } -/// 对话槽位类型(源 `NPCAvatarAITalkType`,19 值闭集)——梯子的槽位输入。 +/// 对话槽位类型(源 `NPCAvatarAITalkType`)——梯子的槽位输入。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] pub enum TalkType { @@ -163,11 +163,12 @@ pub enum TalkType { EntrySite = 16, CutScene = 17, BirthdayParty = 18, + CommonSomeCharacter = 19, } impl TalkType { /// 全值域(源闭集,下标即判别值)。 - pub const ALL: [TalkType; 19] = [ + pub const ALL: [TalkType; 20] = [ TalkType::None, TalkType::Common, TalkType::SingleCharacterFixture, @@ -187,6 +188,7 @@ impl TalkType { TalkType::EntrySite, TalkType::CutScene, TalkType::BirthdayParty, + TalkType::CommonSomeCharacter, ]; /// 判别值到成员的闭集还原;域外一律 `None`。 @@ -226,11 +228,11 @@ mod tests { #[test] fn talk_type_covers_source_closed_set() { - // 19 值闭集:判别值 0..=18 全部可还原,19 起域外 + // 判别值 0..=19 全部可还原,20 起域外。 for (index, member) in TalkType::ALL.iter().enumerate() { assert_eq!(TalkType::from_discriminant(index as u8), Some(*member)); } - assert_eq!(TalkType::from_discriminant(19), None); + assert_eq!(TalkType::from_discriminant(20), None); } #[test] diff --git a/crates/base/moly-law/src/objective/overlap.rs b/crates/base/moly-law/src/objective/overlap.rs index 45d05e0d..dfcd722c 100644 --- a/crates/base/moly-law/src/objective/overlap.rs +++ b/crates/base/moly-law/src/objective/overlap.rs @@ -7,7 +7,7 @@ use super::{ObjectiveType, TalkType}; pub fn has_group_talk(current: Option, slot: Option) -> bool { slot.is_some() && (matches!(current, Some(ObjectiveType::MainSomeCharacterCommunication | ObjectiveType::SubSomeCharacterCommunication)) - || matches!(slot, Some(TalkType::MultipleCharacterFixture | TalkType::CommunicationWhileDoingWait))) + || matches!(slot, Some(TalkType::MultipleCharacterFixture | TalkType::CommunicationWhileDoingWait | TalkType::CommonSomeCharacter))) } /// The action states in which the overlap cancel may run: the bits of diff --git a/crates/base/moly-law/src/text/metrics.rs b/crates/base/moly-law/src/text/metrics.rs index 2c977593..f94bdac9 100644 --- a/crates/base/moly-law/src/text/metrics.rs +++ b/crates/base/moly-law/src/text/metrics.rs @@ -17,7 +17,7 @@ //! 且锚基旁路竖直界(voffset 上下界)。 use super::advance::{force_fallback_glyph, resolve_glyph_advance}; -use super::tags::{parse_rich_segments, transformed_glyphs, Indent, SizeSpec}; +use super::tags::{parse_rich_segments, transformed_glyphs, Indent, SizeSpec, TextSegment}; /// 行量法输出。全部以**排版像素**计(`TEXT_SCALE` 已除回)。 #[derive(Clone, Debug, PartialEq)] @@ -59,6 +59,19 @@ pub fn layout_metrics( glyph_advance: &dyn Fn(char) -> Option, ) -> LayoutMetrics { let segments = parse_rich_segments(text); + layout_segments(&segments, font_size, font_family, line_spacing, base_size, glyph_advance) +} + +/// The same line measurement for preselected segments. Display translations +/// use one literal segment, without interpreting any formatting tags. +pub fn layout_segments( + segments: &[TextSegment], + font_size: f32, + font_family: &str, + line_spacing: f32, + base_size: f32, + glyph_advance: &dyn Fn(char) -> Option, +) -> LayoutMetrics { let mut clean: String = segments.iter().map(|seg| seg.text.as_str()).collect(); if clean.ends_with('\n') { clean.pop(); diff --git a/crates/base/moly-text/Cargo.toml b/crates/base/moly-text/Cargo.toml new file mode 100644 index 00000000..4c4e8f3d --- /dev/null +++ b/crates/base/moly-text/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "moly-text" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/base/moly-text/README.md b/crates/base/moly-text/README.md new file mode 100644 index 00000000..59643124 --- /dev/null +++ b/crates/base/moly-text/README.md @@ -0,0 +1,28 @@ +# moly-text + +Display text resolution shared by the game and the embedded stage. It has no +engine dependency and does not execute scripts or fetch network resources. + +`SourceCatalog` reads `moly-text-source/1`; `Overlay` reads +`moly-text-overlay/1`. A `TextSession` pins a source catalogue, locale and +official/community overlay revisions for one playback. Matching official +text takes priority, followed by human translation, then the original. + +Bindings identify `(script, current step, text kind)` in the source catalogue. +Stable text IDs identify translations. Every replacement also requires a +matching source hash and the original display text. A source update can reuse +unchanged translations without permitting changed lines to receive stale text. +The source region remains independent of the display locale. + +The host must admit the source catalogue against the chosen resource revision +and fetch overlays from its configured publication service. Parsing an overlay +does not authenticate its publisher or establish that its `source` label is +true. Published documents are immutable; draft preview uses a separate path. + +The caller receives display text only: leave control-flow labels, actor IDs, +animation commands, voice cues and playback timing in the original script. +The caller also owns glyph coverage and text layout. This library alone does +not install runtime UI or authorize publication. + +Inspect caller-provided documents with `cargo run -p moly-text --example inspect +-- SOURCE OVERLAY LOCALE`. diff --git a/crates/base/moly-text/examples/inspect.rs b/crates/base/moly-text/examples/inspect.rs new file mode 100644 index 00000000..5dca2fac --- /dev/null +++ b/crates/base/moly-text/examples/inspect.rs @@ -0,0 +1,15 @@ +//! Validate caller-provided text documents without starting the game. +use moly_text::{Overlay, SourceCatalog, TextSession}; +use std::sync::Arc; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let source_path = args.next().ok_or("usage: inspect SOURCE OVERLAY LOCALE")?; + let overlay_path = args.next().ok_or("missing overlay path")?; + let locale = args.next().ok_or("missing locale")?; + let source = Arc::new(SourceCatalog::parse(&std::fs::read_to_string(source_path)?)?); + let overlay = Arc::new(Overlay::parse(&std::fs::read_to_string(overlay_path)?)?); + let session = TextSession::new(source.clone(), &locale, Some(overlay.clone()), None)?; + println!("source={} text={} overlay={}", source.region, session.locale, overlay.revision); + Ok(()) +} diff --git a/crates/base/moly-text/src/lib.rs b/crates/base/moly-text/src/lib.rs new file mode 100644 index 00000000..388a7052 --- /dev/null +++ b/crates/base/moly-text/src/lib.rs @@ -0,0 +1,340 @@ +//! Display-only localization over an immutable source catalogue. +//! +//! The source region selects the simulation and assets. A separate text +//! locale selects official or community text. Neither operation rewrites +//! script instructions, control labels, voice cues or actor identities. + +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +const MAX_DOCUMENT_BYTES: usize = 32 * 1024 * 1024; +const MAX_TEXT_BYTES: usize = 16 * 1024; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum TextKind { + Text, + Speaker, + Tweet, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceLine { + pub id: String, + pub kind: TextKind, + pub step: Option, + pub text: String, + pub source_hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceScript { + key: String, + lines: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourceDocument { + schema: String, + region: String, + revision: String, + scripts: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Translation { + pub id: String, + pub source_hash: String, + pub text: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OverlayDocument { + schema: String, + source_region: String, + source_revision: String, + locale: String, + revision: String, + entries: Vec, +} + +#[derive(Clone, Debug)] +pub struct SourceCatalog { + pub region: String, + pub revision: String, + lines: HashMap, + bindings: HashMap<(String, Option, TextKind), String>, +} + +#[derive(Clone, Debug)] +pub struct Overlay { + pub region: String, + pub source_revision: String, + pub locale: String, + pub revision: String, + entries: HashMap, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextOrigin { + Original, + Official, + Community, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DisplayText<'a> { + pub text: &'a str, + pub origin: TextOrigin, +} + +fn valid_hash(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn region(value: &str) -> bool { + matches!(value, "jp" | "cn" | "tw" | "en" | "kr") +} + +fn locale(value: &str) -> bool { + matches!(value, "ja-JP" | "zh-CN" | "zh-TW" | "en-US" | "ko-KR") +} + +fn document Deserialize<'de>>(text: &str) -> Result { + if text.len() > MAX_DOCUMENT_BYTES { + return Err("text document exceeds size limit".into()); + } + serde_json::from_str(text).map_err(|error| format!("invalid text document: {error}")) +} + +fn valid_text(text: &str) -> bool { + text.len() <= MAX_TEXT_BYTES + && !text.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t')) +} + +impl SourceCatalog { + pub fn parse(json: &str) -> Result { + let doc: SourceDocument = document(json)?; + if doc.schema != "moly-text-source/1" || !region(&doc.region) || !valid_hash(&doc.revision) { + return Err("unsupported source text identity".into()); + } + let mut result = Self { + region: doc.region, + revision: doc.revision, + lines: HashMap::new(), + bindings: HashMap::new(), + }; + let mut scripts = HashSet::new(); + for script in doc.scripts { + if script.key.is_empty() || script.key.len() > 512 || !scripts.insert(script.key.clone()) { + return Err("empty, duplicate or oversized text script key".into()); + } + for line in script.lines { + if line.id.is_empty() || line.id.len() > 160 || !valid_hash(&line.source_hash) + || !valid_text(&line.text) + || (line.kind == TextKind::Tweet) != line.step.is_none() + { + return Err(format!("invalid source text unit {}", line.id)); + } + let binding = (script.key.clone(), line.step, line.kind); + if result.bindings.insert(binding, line.id.clone()).is_some() + || result.lines.insert(line.id.clone(), line).is_some() + { + return Err("duplicate source text identity or binding".into()); + } + } + } + Ok(result) + } + + pub fn line(&self, id: &str) -> Option<&SourceLine> { + self.lines.get(id) + } +} + +impl Overlay { + pub fn parse(json: &str) -> Result { + let doc: OverlayDocument = document(json)?; + if doc.schema != "moly-text-overlay/1" || !region(&doc.source_region) + || !locale(&doc.locale) || !valid_hash(&doc.revision) || !valid_hash(&doc.source_revision) + { + return Err("unsupported translation identity".into()); + } + let mut entries = HashMap::new(); + for entry in doc.entries { + if entry.id.is_empty() || entry.id.len() > 160 || !valid_hash(&entry.source_hash) + || !valid_text(&entry.text) + || entry.text.chars().any(|ch| { + matches!(ch, '\t' | '\u{ad}' | '\u{fdd0}'..='\u{fdef}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + || (ch as u32 & 0xffff) >= 0xfffe + }) + || !matches!(entry.source.as_str(), "human" | "official_cn" | "official_tw" | "official_en" | "official_kr") + { + return Err(format!("invalid translation unit {}", entry.id)); + } + if entries.insert(entry.id.clone(), entry).is_some() { + return Err("duplicate translation identity".into()); + } + } + Ok(Self { + region: doc.source_region, + source_revision: doc.source_revision, + locale: doc.locale, + revision: doc.revision, + entries, + }) + } +} + +/// A session pins this value until it ends. Installing another locale or +/// revision for future sessions cannot change an in-progress typewriter. +#[derive(Clone, Debug)] +pub struct TextSession { + source: Arc, + pub locale: String, + official: Option>, + community: Option>, +} + +impl TextSession { + pub fn new( + source: Arc, + locale_value: &str, + official: Option>, + community: Option>, + ) -> Result { + if !locale(locale_value) { + return Err("unsupported text locale".into()); + } + for overlay in official.iter().chain(community.iter()) { + if overlay.region != source.region || overlay.locale != locale_value { + return Err("translation locale or source region does not match".into()); + } + // Revisions may differ: unchanged stable IDs with equal source + // hashes remain usable after a source update. Changed lines do not. + } + Ok(Self { source, locale: locale_value.into(), official, community }) + } + + pub fn resolve<'a>( + &'a self, + script: &str, + step: Option, + kind: TextKind, + original: &'a str, + ) -> DisplayText<'a> { + let fallback = DisplayText { text: original, origin: TextOrigin::Original }; + let Some(id) = self.source.bindings.get(&(script.to_owned(), step, kind)) else { + return fallback; + }; + let Some(line) = self.source.lines.get(id).filter(|line| line.text == original) else { + return fallback; + }; + let expected_official = match self.locale.as_str() { + "zh-CN" => "official_cn", + "zh-TW" => "official_tw", + "en-US" => "official_en", + "ko-KR" => "official_kr", + _ => "", + }; + for (overlay, origin, source_kind) in [ + (&self.official, TextOrigin::Official, expected_official), + (&self.community, TextOrigin::Community, "human"), + ] { + if let Some(entry) = overlay.as_ref().and_then(|o| o.entries.get(id)) { + if entry.source_hash == line.source_hash && entry.source == source_kind && !entry.text.trim().is_empty() { + return DisplayText { text: &entry.text, origin }; + } + } + } + fallback + } + + /// Characters needed to render this session's admitted display strings. + /// The host can prepare glyphs before applying the session to playback. + pub fn characters(&self) -> Vec { + // Use precisely the same precedence, source kind, nonempty check and + // source hash as rendering. A superseded human translation must not + // require a font when the official line is what the user will see. + let mut chars: Vec = self.source.bindings.iter().flat_map(|((script, step, kind), id)| { + let original = &self.source.lines[id].text; + self.resolve(script, *step, *kind, original).text.chars() + }).collect(); + chars.sort_unstable(); + chars.dedup(); + chars + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn source(hash: &str) -> Arc { + Arc::new(SourceCatalog::parse(&json!({ + "schema":"moly-text-source/1", "region":"jp", "revision":"a".repeat(64), + "scripts":[{"key":"sample", "lines":[{ + "id":"line-a", "kind":"text", "step":2, "text":"Original", "sourceHash":hash + }]}] + }).to_string()).unwrap()) + } + + fn overlay(text: &str, provenance: &str, hash: &str) -> Arc { + Arc::new(Overlay::parse(&json!({ + "schema":"moly-text-overlay/1", "sourceRegion":"jp", + "sourceRevision":"b".repeat(64), "locale":"zh-CN", "revision":"c".repeat(64), + "entries":[{"id":"line-a", "sourceHash":hash, "text":text, "source":provenance}] + }).to_string()).unwrap()) + } + + #[test] + fn chinese_uses_official_then_community_then_original() { + let hash = "d".repeat(64); + let official = overlay("官方", "official_cn", &hash); + let community = overlay("团队", "human", &hash); + let session = TextSession::new(source(&hash), "zh-CN", Some(official), Some(community.clone())).unwrap(); + assert_eq!(session.resolve("sample", Some(2), TextKind::Text, "Original"), + DisplayText { text: "官方", origin: TextOrigin::Official }); + let session = TextSession::new(source(&hash), "zh-CN", None, Some(community)).unwrap(); + assert_eq!(session.resolve("sample", Some(2), TextKind::Text, "Original").text, "团队"); + let session = TextSession::new(source(&hash), "zh-CN", None, None).unwrap(); + assert_eq!(session.resolve("sample", Some(2), TextKind::Text, "Original").origin, TextOrigin::Original); + } + + #[test] + fn source_change_or_wrong_binding_never_applies_stale_text() { + let old_hash = "d".repeat(64); + let new_hash = "e".repeat(64); + let session = TextSession::new(source(&new_hash), "zh-CN", Some(overlay("Old", "official_cn", &old_hash)), None).unwrap(); + assert_eq!(session.resolve("sample", Some(2), TextKind::Text, "Original").origin, TextOrigin::Original); + assert_eq!(session.resolve("sample", Some(3), TextKind::Text, "Original").origin, TextOrigin::Original); + assert_eq!(session.resolve("sample", Some(2), TextKind::Text, "Changed").text, "Changed"); + } + + #[test] + fn locale_switch_does_not_mutate_an_existing_session() { + let hash = "d".repeat(64); + let old = TextSession::new(source(&hash), "zh-CN", Some(overlay("中文", "official_cn", &hash)), None).unwrap(); + let new = TextSession::new(source(&hash), "ja-JP", None, None).unwrap(); + assert_eq!(old.resolve("sample", Some(2), TextKind::Text, "Original").text, "中文"); + assert_eq!(new.resolve("sample", Some(2), TextKind::Text, "Original").text, "Original"); + } + + #[test] + fn overlay_cannot_cross_source_region_or_locale() { + let hash = "d".repeat(64); + let zh = overlay("中文", "official_cn", &hash); + assert!(TextSession::new(source(&hash), "en-US", Some(zh), None).is_err()); + let mut cn = (*source(&hash)).clone(); + cn.region = "cn".into(); + assert!(TextSession::new(Arc::new(cn), "zh-CN", Some(overlay("中文", "official_cn", &hash)), None).is_err()); + } +} diff --git a/crates/engine/moly-assets/Cargo.toml b/crates/engine/moly-assets/Cargo.toml index f1de6fca..7b79ef59 100644 --- a/crates/engine/moly-assets/Cargo.toml +++ b/crates/engine/moly-assets/Cargo.toml @@ -40,4 +40,4 @@ bevy = { version = "0.19.1", default-features = false, features = ["https"] } js-sys = "0.3" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "WorkerGlobalScope", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Request", "RequestInit", "RequestMode", "RequestRedirect", "RequestCredentials", "Response", "ResponseType", "Headers"] } +web-sys = { version = "0.3", features = ["Window", "Location", "Url", "UrlSearchParams", "WorkerGlobalScope", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Request", "RequestInit", "RequestMode", "RequestRedirect", "RequestCredentials", "Response", "ResponseType", "Headers"] } diff --git a/crates/engine/moly-assets/src/browser_source.rs b/crates/engine/moly-assets/src/browser_source.rs new file mode 100644 index 00000000..d7861355 --- /dev/null +++ b/crates/engine/moly-assets/src/browser_source.rs @@ -0,0 +1,163 @@ +//! 资产根的解析入口。 +//! +//! native 读 `MOLY_ASSET_ROOT`(缺省退回启动器装好的资源),由 +//! `crate::asset_source` 实现——native 游戏与 server 共用那一个函数; +//! web 没有 env,读页面的 `?assets=` URL 前缀,浏览器游戏读启动种子里的同名字段, +//! 两条路走同一套准入(准入也在 `crate::asset_source`)。 +//! +//! web 选「URL 参数」不选「同目录约定」:约定无法同步判「缺」——要探测就得 +//! 发请求,失败会散成逐资产 404,唯一拒绝点就没了;参数缺了当场拒绝。 +//! 前缀是同源绝对路径或明确许可的公共资源 HTTPS 根,且以 `/` 结尾。 +//! 玩家数据与页面通信仍在同源 iframe 内,只有公开资源走 CDN。 + +#[cfg(target_arch = "wasm32")] +use crate::AssetSource; + +pub use crate::asset_source::{ + is_canonical_directory, public_asset_parts, validate_asset_selection, RemoteBaseOverrides, + MASTER_BASE, MASTER_HARUKI_BASE, MASTER_MIRROR_BASE, STORAGE_BASE, STORAGE_MIRROR_BASE, +}; + +/// Native: the resource root, read by the same function the server reads. +#[cfg(not(target_arch = "wasm32"))] +pub use crate::asset_source::{resolve, resolve_remote}; + +/// Pages and the browser game: the same bases the native entry reads, with +/// each one required to be its canonical URL. The repository template itself +/// is checked with a repository substituted for `{repo}`, which the URL of a +/// `{repo}` segment would otherwise escape. +#[cfg(target_arch = "wasm32")] +pub fn canonical_bases( + bases: crate::remote::RemoteBases, +) -> Result { + let checked = [ + bases.storage.clone(), + bases.storage_mirror.clone(), + bases.master.clone(), + bases.master_mirror.clone(), + MASTER_HARUKI_BASE.replace("{repo}", crate::remote::Region::Jp.repository()), + ]; + for base in &checked { + let url = web_sys::Url::new(base).map_err(|_| format!("Invalid remote base {base}"))?; + if url.href() != *base || !url.search().is_empty() || !url.hash().is_empty() { + return Err(format!( + "remote base {base} must be its canonical HTTPS directory" + )); + } + } + Ok(bases) +} + +/// Pages: `?storage_base=`, `?storage_mirror_base=`, `?master_base=`, +/// `?master_mirror_base=` and `?master_haruki_base=` (a template naming +/// `{repo}`) replace the hosts. +#[cfg(target_arch = "wasm32")] +pub fn resolve_remote() -> Result { + let window = + web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; + let search = window + .location() + .search() + .map_err(|e| format!("could not read the page query string: {e:?}"))?; + let params = web_sys::UrlSearchParams::new_with_str(&search) + .map_err(|e| format!("could not parse the query string {search:?}: {e:?}"))?; + let (storage, storage_mirror, master, master_mirror, master_repository) = ( + params.get("storage_base"), + params.get("storage_mirror_base"), + params.get("master_base"), + params.get("master_mirror_base"), + params.get("master_haruki_base"), + ); + let bases = crate::asset_source::remote_bases(RemoteBaseOverrides { + storage: storage.as_deref(), + storage_mirror: storage_mirror.as_deref(), + master: master.as_deref(), + master_mirror: master_mirror.as_deref(), + master_repository: master_repository.as_deref(), + })?; + canonical_bases(bases) +} + +/// One admission for the page query and the game seed. +#[cfg(target_arch = "wasm32")] +pub fn resolve_selection( + base: String, + packs: bool, + catalog: Option, + configured_origin: Option<&str>, + configured_base: Option<&str>, +) -> Result { + validate_asset_selection(&base, configured_origin, configured_base)?; + let window = + web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; + if let Some(root) = configured_base { + let url = web_sys::Url::new(root).map_err(|_| "Invalid resource_base")?; + if url.href() != root || !url.search().is_empty() || !url.hash().is_empty() { + return Err("resource_base must be its canonical HTTPS directory".into()); + } + } + let location = window.location(); + let page = location.href().map_err(|_| "Could not read page URL")?; + let url = web_sys::Url::new_with_base(&base, &page).map_err(|_| "Invalid asset URL prefix")?; + let public_parts = public_asset_parts(&base); + let allowed_origin = public_parts.map(|(origin, _)| origin.to_owned()).unwrap_or( + location + .origin() + .map_err(|_| "Could not read page origin")?, + ); + if url.origin() != allowed_origin + || url.pathname() != public_parts.map(|(_, path)| path).unwrap_or(&base) + || (public_parts.is_some() && url.href() != base) + || !url.search().is_empty() + || !url.hash().is_empty() + { + return Err("?assets= must resolve to its canonical trusted resource path".into()); + } + validate_catalog_id(&catalog)?; + match (packs, catalog) { + (true, catalog) => Ok(AssetSource::HttpPacks { url: base, catalog }), + (false, None) => Ok(AssetSource::HttpBase { url: base }), + (false, Some(_)) => Err("asset_catalog requires packs=1".into()), + } +} + +/// A configured release id is a full lowercase SHA-256. +#[cfg(target_arch = "wasm32")] +fn validate_catalog_id(catalog: &Option) -> Result<(), String> { + if catalog.as_deref().is_some_and(|id| { + id.len() != 64 + || !id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) { + return Err("asset catalog must be a full lowercase SHA-256".into()); + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub fn resolve() -> Result { + let window = + web_sys::window().ok_or_else(|| "the wasm build only runs inside a page".to_owned())?; + let search = window + .location() + .search() + .map_err(|e| format!("could not read the page query string: {e:?}"))?; + let params = web_sys::UrlSearchParams::new_with_str(&search) + .map_err(|e| format!("could not parse the query string {search:?}: {e:?}"))?; + let base = params.get("assets").ok_or_else(|| { + "missing ?assets=/ — the HTTP base assets are fetched from".to_owned() + })?; + let packs = match params.get("packs").as_deref() { + Some("1") => true, + None | Some("0") => false, + _ => return Err("?packs= must be 0 or 1".into()), + }; + resolve_selection( + base, + packs, + params.get("asset_catalog"), + params.get("resource_origin").as_deref(), + params.get("resource_base").as_deref(), + ) +} diff --git a/crates/engine/moly-assets/src/lib.rs b/crates/engine/moly-assets/src/lib.rs index 7ada6006..f326a5ee 100644 --- a/crates/engine/moly-assets/src/lib.rs +++ b/crates/engine/moly-assets/src/lib.rs @@ -13,6 +13,7 @@ pub mod material_passes; #[cfg(feature = "render")] pub mod source_shader; pub mod asset_source; +pub mod browser_source; pub mod weather_effect; pub mod weather_index; pub mod weather_icons; diff --git a/crates/game/moly-audio/Cargo.toml b/crates/game/moly-audio/Cargo.toml index ccfe589b..124e3726 100644 --- a/crates/game/moly-audio/Cargo.toml +++ b/crates/game/moly-audio/Cargo.toml @@ -35,16 +35,9 @@ regex = "1" # a file it cannot read) before a player gets it. Both already locked. sha2 = "0.10" rodio = { version = "0.22", default-features = false, features = ["mp3"] } -# The control surface: Bevy Remote Protocol without a transport on every -# target (the browser page sends through the mailbox), plus its localhost -# HTTP endpoint on native. Captured frames are answered as base64 PNG. -bevy_remote = { version = "0.19.1", default-features = false } -async-channel = "2" image = { version = "0.25", default-features = false, features = ["png"] } -base64 = "0.22" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bevy_remote = { version = "0.19.1", default-features = false, features = ["http"] } # Fixture clocks read the device's local clock (localtime_r / localtime_s); # already locked, no new download. libc = "0.2" diff --git a/crates/game/moly-audio/src/audio.rs b/crates/game/moly-audio/src/audio.rs index 279291c3..2a76d05e 100644 --- a/crates/game/moly-audio/src/audio.rs +++ b/crates/game/moly-audio/src/audio.rs @@ -819,6 +819,7 @@ const TALK_VOICE_PACKAGE_PREFIX: &str = "mysekai__talk__voice__"; /// The talk voice bank under the Birthday context /// (`mysekai/talk/birthday/voice/{0}`), flattened like the plain one. const BIRTHDAY_TALK_VOICE_PACKAGE_PREFIX: &str = "mysekai__talk__birthday__voice__"; +const TUTORIAL_TALK_VOICE_PACKAGE_PREFIX: &str = "mysekai__talk__tutorial__voice__"; impl Routing { fn phenomenon(&self, name: &str) -> &PhenomenonAudio { @@ -1939,12 +1940,18 @@ fn parse_corpus(corpus: &serde_json::Value) -> VoiceCorpus { /// loads `mysekai/talk/birthday/voice/`, otherwise /// `mysekai/talk/voice/`. pub fn voice_package(cue: &str, birthday: bool) -> Option { + contextual_voice_package(cue, false, birthday) +} + +fn contextual_voice_package(cue: &str, tutorial: bool, birthday: bool) -> Option { let body = cue.strip_prefix("voice_")?; let stem = body.rsplit_once('_')?.0.rsplit_once('_')?.0; if stem.is_empty() { return None; } - let prefix = if birthday { + let prefix = if tutorial { + TUTORIAL_TALK_VOICE_PACKAGE_PREFIX + } else if birthday { BIRTHDAY_TALK_VOICE_PACKAGE_PREFIX } else { TALK_VOICE_PACKAGE_PREFIX @@ -3039,8 +3046,9 @@ fn route_voice( partvoice: Option<&PartVoiceMap>, streams: &Streams, birthday: bool, + tutorial: bool, ) -> VoiceRoute { - if let Some(package) = voice_package(&line.cue, birthday) { + if let Some(package) = contextual_voice_package(&line.cue, tutorial, birthday) { return if streams.0.contains_key(&(line.cue.clone(), package.clone())) { VoiceRoute::Play { package, @@ -3113,7 +3121,7 @@ pub fn prefetch_voice( routing: Option>, partvoice: Option>, talk: Option>, - birthday: Option>, + birthday: (Option>, Option>), mut cache: ResMut, ) { let Some(routing) = routing else { @@ -3131,7 +3139,7 @@ pub fn prefetch_voice( speaker: None, }; let VoiceRoute::Play { package, .. } = - route_voice(&line, partvoice.as_deref(), &routing.streams, birthday.is_some()) + route_voice(&line, partvoice.as_deref(), &routing.streams, birthday.0.is_some(), birthday.1.is_some()) else { continue; }; @@ -3161,7 +3169,7 @@ pub fn serve_voice( corpus: Option>, routing: Option>, partvoice: Option>, - birthday: Option>, + birthday: (Option>, Option>), mut prefetch: ResMut, mut channel: ResMut, lines: Query<(Entity, &VoiceLine)>, @@ -3198,7 +3206,7 @@ pub fn serve_voice( entity_commands.despawn(); } } - match route_voice(&line, partvoice.as_deref(), &routing.streams, birthday.is_some()) { + match route_voice(&line, partvoice.as_deref(), &routing.streams, birthday.0.is_some(), birthday.1.is_some()) { VoiceRoute::Play { package, partvoice } => { let Some(stream) = routing.streams.0.get(&(line.cue.clone(), package.clone())) else { diff --git a/crates/game/moly-audio/src/audio_inputs.rs b/crates/game/moly-audio/src/audio_inputs.rs index a7cfcd01..bd728ebf 100644 --- a/crates/game/moly-audio/src/audio_inputs.rs +++ b/crates/game/moly-audio/src/audio_inputs.rs @@ -50,6 +50,11 @@ pub struct BirthdayVoice { pub party: i64, } +/// The tutorial manager's active flag. The world publishes it; voice-bank +/// selection gives this priority over the birthday context. +#[derive(Resource, Clone, Copy, Debug)] +pub struct TutorialVoice; + /// `MysekaiBGMManager.PlayBGMAsync` for the destination has not run yet: the /// current music keeps playing across a site move. #[derive(Resource)] diff --git a/crates/game/moly-core/Cargo.toml b/crates/game/moly-core/Cargo.toml index 9a82bbe4..6322f026 100644 --- a/crates/game/moly-core/Cargo.toml +++ b/crates/game/moly-core/Cargo.toml @@ -27,6 +27,8 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "time", "net", "fs", "io-util"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +# Shared optional browser allocator for application crates. +dlmalloc = { version = "0.2", optional = true } # Browser console diagnostics without an unbounded User Timing recording. tracing-wasm = "0.2.1" # Frame captures are stamped with the page clock (std's SystemTime is a stub @@ -39,6 +41,7 @@ wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Storage", "Location", "UrlSearchParams", "Navigator", "Clipboard", "File", "Blob", "FileList", "Document", "HtmlInputElement", "HtmlElement", "Element", "Node", "Event", "EventTarget", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Request", "RequestInit", "RequestRedirect", "Response", "Headers", "ResizeObserver", "DragEvent", "DataTransfer"] } [features] +browser-heap = ["dep:dlmalloc"] # Test helpers that the higher layers' tests call. test-support = [] diff --git a/crates/game/moly-core/src/browser_log.rs b/crates/game/moly-core/src/browser_log.rs index 7640d5c4..056f171d 100644 --- a/crates/game/moly-core/src/browser_log.rs +++ b/crates/game/moly-core/src/browser_log.rs @@ -12,11 +12,18 @@ pub fn install() { // tracing-wasm 0.2 records EVERY entered/exited span even with event // timings disabled. Bevy render spans otherwise leave millions of marks // and measures in Performance's entry buffer during a long-lived stage. - // Console events retain Bevy's normal filter, including warnings/errors. + // Production pages keep warnings/errors. Detailed diagnostics are opt-in + // per document so a long-lived scene does not flood the user's console. let console = tracing_wasm::WASMLayer::new(config.build()) .with_filter(filter_fn(|metadata| metadata.is_event())); + let filter = web_sys::window() + .and_then(|window| window.location().search().ok()) + .and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok()) + .and_then(|params| params.get("log")) + .filter(|value| matches!(value.as_str(), "error" | "warn" | "info" | "debug" | "trace")) + .unwrap_or_else(|| "warn".to_owned()); Registry::default() - .with(EnvFilter::new(format!("info,{}", bevy::log::DEFAULT_FILTER))) + .with(EnvFilter::new(format!("{filter},{}", bevy::log::DEFAULT_FILTER))) .with(console) .try_init() .expect("one browser tracing subscriber per runtime"); diff --git a/crates/game/moly-core/src/lib.rs b/crates/game/moly-core/src/lib.rs index 9f7ef6e2..dd1638f6 100644 --- a/crates/game/moly-core/src/lib.rs +++ b/crates/game/moly-core/src/lib.rs @@ -55,3 +55,8 @@ pub mod weather_transition; /// Product version shared by the settings panel and application entry points. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(target_arch = "wasm32")] +pub mod render_backend; +#[cfg(all(target_arch = "wasm32", feature = "browser-heap"))] +mod wasm_heap; diff --git a/crates/game/moly-core/src/render_backend.rs b/crates/game/moly-core/src/render_backend.rs new file mode 100644 index 00000000..6b72d7da --- /dev/null +++ b/crates/game/moly-core/src/render_backend.rs @@ -0,0 +1,21 @@ +//! Consume the backend selected by the browser's asynchronous preflight. + +use bevy::render::settings::{Backends, WgpuLimits, WgpuSettings}; + +pub fn settings(backend: &str, supports_webgpu: bool) -> Result { + if (backend == "webgpu") != supports_webgpu { + return Err("The selected renderer requires its matching browser module".into()); + } + match backend { + "webgpu" => Ok(WgpuSettings { + backends: Some(Backends::BROWSER_WEBGPU), + ..Default::default() + }), + "webgl2" => Ok(WgpuSettings { + backends: Some(Backends::GL), + limits: WgpuLimits::downlevel_webgl2_defaults(), + ..Default::default() + }), + _ => Err("A preflighted webgpu or webgl2 backend is required".into()), + } +} diff --git a/crates/game/moly-core/src/ui_font.rs b/crates/game/moly-core/src/ui_font.rs index 754813f6..c32a960f 100644 --- a/crates/game/moly-core/src/ui_font.rs +++ b/crates/game/moly-core/src/ui_font.rs @@ -118,7 +118,152 @@ mod tests { fn shared_font_has_latin_japanese_and_chinese_glyphs() { let font = swash::FontRef::from_index(&UI_FONT, 0).expect("valid shared UI font"); for character in ['A', '0', 'あ', 'が', '家', '界', '设', '置'] { - assert_ne!(font.charmap().map(character as u32), 0, "missing {character}"); + assert_ne!( + font.charmap().map(character as u32), + 0, + "missing {character}" + ); } } } + +/// Fonts selected by the current text session, shared by rasterization and +/// measurement. Readers retain Arc snapshots; replacing the selected set does +/// not invalidate an in-flight reader or the texture handles of an older atlas. +static TEXT_FONTS: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); +static TEXT_FONT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static TEXT_FONT_UPDATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); +const MAX_TEXT_FONTS: usize = 8; +const MAX_TEXT_FONT_BYTES: usize = 32 * 1024 * 1024; + +fn verify_text_font(bytes: &[u8], expected: &str) -> Result<(), String> { + use sha2::{Digest, Sha256}; + if bytes.is_empty() || bytes.len() > MAX_TEXT_FONT_BYTES { + return Err("translation font exceeds size limit".into()); + } + let digest: String = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + if digest != expected || swash::FontRef::from_index(bytes, 0).is_none() { + return Err("translation font integrity or format mismatch".into()); + } + Ok(()) +} + +/// Compatibility entry point for callers that install one font before a text +/// session. Repeated playback uses `with_text_font_session` to replace the set. +pub fn install_text_font(bytes: Vec, expected: &str) -> Result<(), String> { + verify_text_font(&bytes, expected)?; + let _update = TEXT_FONT_UPDATE.lock().map_err(|_| "translation font update unavailable")?; + let mut fonts = TEXT_FONTS + .get_or_init(|| std::sync::Mutex::new(Vec::new())) + .lock() + .map_err(|_| "translation font state unavailable")?; + if fonts.iter().any(|font| font.as_ref() == bytes.as_slice()) { + return Ok(()); + } + if fonts.len() >= MAX_TEXT_FONTS { + return Err("translation font resource limit reached".into()); + } + fonts.push(std::sync::Arc::from(bytes)); + TEXT_FONT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Release); + Ok(()) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TextFontReference { + sha256: String, + bytes: usize, +} + +/// Atomically selects a bounded font set while installing its display session. +/// The browser caller must invoke this synchronously at an idle playback boundary. +/// Bytes contain the referenced fonts concatenated in manifest order. All font +/// hashes and formats are checked before the selected set changes. If text +/// configuration fails, the previous Arc set is restored before this returns. +/// No render tick or async operation may run inside `configure`. +pub fn with_text_font_session( + bytes: Vec, + references: &str, + configure: impl FnOnce() -> Result, +) -> Result { + if references.len() > 4096 || bytes.len() > MAX_TEXT_FONTS * MAX_TEXT_FONT_BYTES { + return Err("translation font manifest exceeds size limit".into()); + } + let references: Vec = serde_json::from_str(references) + .map_err(|_| "invalid translation font manifest")?; + if references.len() > MAX_TEXT_FONTS { + return Err("translation font resource limit reached".into()); + } + let _update = TEXT_FONT_UPDATE.lock().map_err(|_| "translation font update unavailable")?; + let installed = text_fonts(); + let mut selected = Vec::with_capacity(references.len()); + let mut offset = 0usize; + let mut identities = std::collections::HashSet::new(); + for reference in references { + let end = offset.checked_add(reference.bytes) + .filter(|end| *end <= bytes.len()) + .ok_or("translation font byte boundary mismatch")?; + let font = &bytes[offset..end]; + verify_text_font(font, &reference.sha256)?; + if !identities.insert(reference.sha256) { + return Err("duplicate translation font identity".into()); + } + selected.push(installed.iter().find(|existing| existing.as_ref() == font) + .cloned().unwrap_or_else(|| std::sync::Arc::from(font))); + offset = end; + } + if offset != bytes.len() { + return Err("unreferenced translation font bytes".into()); + } + drop(bytes); + let changed = installed.len() != selected.len() + || installed.iter().zip(&selected).any(|(old, new)| !std::sync::Arc::ptr_eq(old, new)); + if !changed { + return configure(); + } + let replace = |fonts| -> Result<(), String> { + *TEXT_FONTS.get_or_init(|| std::sync::Mutex::new(Vec::new())) + .lock().map_err(|_| "translation font state unavailable")? = fonts; + TEXT_FONT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Release); + Ok(()) + }; + replace(selected)?; + let result = configure(); + if result.is_err() { + replace(installed)?; + } + result +} + +pub fn text_fonts() -> Vec> { + TEXT_FONTS + .get() + .and_then(|fonts| fonts.lock().ok().map(|fonts| fonts.clone())) + .unwrap_or_default() +} + +pub fn text_font_revision() -> u64 { + TEXT_FONT_REVISION.load(std::sync::atomic::Ordering::Acquire) +} + +/// Prefer the release font, then the installed publication fonts. The borrow +/// cannot outlive the retained bytes and no raw FontRef is cached globally. +pub fn with_text_font(ch: char, use_font: impl FnOnce(swash::FontRef<'_>) -> T) -> Option { + let primary = swash::FontRef::from_index(&UI_FONT, 0)?; + if primary.charmap().map(ch as u32) != 0 { + return Some(use_font(primary)); + } + for bytes in text_fonts() { + let Some(font) = swash::FontRef::from_index(&bytes, 0) else { + continue; + }; + if font.charmap().map(ch as u32) != 0 { + return Some(use_font(font)); + } + } + None +} diff --git a/crates/game/moly-core/src/wasm_heap.rs b/crates/game/moly-core/src/wasm_heap.rs new file mode 100644 index 00000000..d0640230 --- /dev/null +++ b/crates/game/moly-core/src/wasm_heap.rs @@ -0,0 +1,110 @@ +//! The browser build's global allocator: dlmalloc over a system layer that +//! grows the WebAssembly memory geometrically. +//! +//! dlmalloc asks its system layer for what one allocation needs, rounded up to +//! 64 KiB, and the stock WebAssembly layer grows the memory by exactly that. +//! A browser's `memory.grow` takes time that rises with the memory's current +//! size, so a heap that climbs to gigabytes in 64 KiB steps spends most of its +//! loading time growing. This layer grows by an eighth of the current memory +//! (at least 16 MiB) and hands dlmalloc the whole grown region, so the number +//! of grows stays logarithmic in the heap size. The region is contiguous with +//! the previous one, which dlmalloc merges into its top chunk; the only memory +//! held beyond the heap is the unused tail of the last grow. + +use core::alloc::{GlobalAlloc, Layout}; +use core::arch::wasm32; +use core::cell::UnsafeCell; +use core::ptr; + +#[cfg(target_feature = "atomics")] +compile_error!("the browser heap has no lock; a threaded build needs one"); + +const PAGE: usize = 64 * 1024; +/// The smallest grow, in pages (16 MiB). +const MIN_GROW_PAGES: usize = 256; + +struct GeometricGrowth; + +unsafe impl dlmalloc::Allocator for GeometricGrowth { + fn alloc(&self, size: usize) -> (*mut u8, usize, u32) { + let needed = size.div_ceil(PAGE); + let wanted = needed.max(wasm32::memory_size(0) / 8).max(MIN_GROW_PAGES); + // Near the memory's limit the larger grow can fail where the needed + // one still fits. + for pages in [wanted, needed] { + let previous = wasm32::memory_grow(0, pages); + if previous != usize::MAX { + return region(previous, pages); + } + if pages == needed { + break; + } + } + (ptr::null_mut(), 0, 0) + } + + fn remap(&self, _ptr: *mut u8, _old: usize, _new: usize, _can_move: bool) -> *mut u8 { + ptr::null_mut() + } + + fn free_part(&self, _ptr: *mut u8, _old: usize, _new: usize) -> bool { + false + } + + fn free(&self, _ptr: *mut u8, _size: usize) -> bool { + false + } + + fn can_release_part(&self, _flags: u32) -> bool { + false + } + + fn allocates_zeros(&self) -> bool { + true + } + + fn page_size(&self) -> usize { + PAGE + } +} + +/// The grown pages as a dlmalloc region. A region that ends at the top of the +/// 32-bit address space keeps its last bytes out, so no pointer past its end +/// wraps to zero. +fn region(previous: usize, pages: usize) -> (*mut u8, usize, u32) { + let base = previous * PAGE; + let size = pages * PAGE; + if base.wrapping_add(size) == 0 { + return (base as *mut u8, size - 16, 0); + } + (base as *mut u8, size, 0) +} + +struct Heap(UnsafeCell>); + +// The browser build has one thread (no `atomics` target feature), so the heap +// is never entered from two threads. +unsafe impl Sync for Heap {} + +#[global_allocator] +static HEAP: Heap = Heap(UnsafeCell::new(dlmalloc::Dlmalloc::new_with_allocator( + GeometricGrowth, +))); + +unsafe impl GlobalAlloc for Heap { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + unsafe { (*self.0.get()).malloc(layout.size(), layout.align()) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + unsafe { (*self.0.get()).calloc(layout.size(), layout.align()) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { (*self.0.get()).free(ptr, layout.size(), layout.align()) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + unsafe { (*self.0.get()).realloc(ptr, layout.size(), layout.align(), new_size) } + } +} diff --git a/crates/game/moly-features/Cargo.toml b/crates/game/moly-features/Cargo.toml index d6e06bdf..07242535 100644 --- a/crates/game/moly-features/Cargo.toml +++ b/crates/game/moly-features/Cargo.toml @@ -39,16 +39,9 @@ regex = "1" # a file it cannot read) before a player gets it. Both already locked. sha2 = "0.10" rodio = { version = "0.22", default-features = false, features = ["mp3"] } -# The control surface: Bevy Remote Protocol without a transport on every -# target (the browser page sends through the mailbox), plus its localhost -# HTTP endpoint on native. Captured frames are answered as base64 PNG. -bevy_remote = { version = "0.19.1", default-features = false } -async-channel = "2" image = { version = "0.25", default-features = false, features = ["png"] } -base64 = "0.22" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bevy_remote = { version = "0.19.1", default-features = false, features = ["http"] } # Fixture clocks read the device's local clock (localtime_r / localtime_s); # already locked, no new download. libc = "0.2" diff --git a/crates/game/moly-features/src/action_button.rs b/crates/game/moly-features/src/action_button.rs index db34821a..33bece24 100644 --- a/crates/game/moly-features/src/action_button.rs +++ b/crates/game/moly-features/src/action_button.rs @@ -663,9 +663,10 @@ pub use crate::ui_inputs::ActionTapConsumed; /// 图标按真源那张表里出现过的全部文件名一次装齐(每张两三 KB), /// 而不是只装当前接得出的两种——按钮类型是闭集,装齐以后新接一种 /// 不需要再改装载面。 -pub fn load(mut commands: Commands, server: Res) { +pub fn load(mut commands: Commands, server: Res, stage: Option>) { let mut art = ActionButtonArt::default(); for button in ALL_BUTTON_TYPES { + if stage.is_some() && !stage_button(button) { continue; } if let Some(name) = button.icon_file_name() { art.icons .entry(name) @@ -684,6 +685,11 @@ pub fn load(mut commands: Commands, server: Res) { info!("[action_button] 图标装载请求 {count} 件(真源二十五格表里的去重文件名)"); } +fn stage_button(button: ButtonType) -> bool { + matches!(button, ButtonType::Talk | ButtonType::GimmickFixture | + ButtonType::TimelineFixture | ButtonType::HouseEntry | ButtonType::GoHomeSite) +} + /// 按钮类型闭集。源枚举取值不连续,这里逐个列出而不是数值区间遍历。 const ALL_BUTTON_TYPES: [ButtonType; 23] = [ ButtonType::Talk, @@ -986,9 +992,10 @@ pub fn advance( site_move: Option>, homes: Option>, room_door: Option>, - (system_fixtures, birthday): ( + (system_fixtures, birthday, stage): ( Res, Option>, + Option>, ), ) { let state = &mut *state; @@ -1146,6 +1153,11 @@ pub fn advance( let Some(button) = facts_of.button.or_else(party).or_else(home) else { continue; }; + // The stage has no progression or inventory screens. It exposes + // only actions whose owners are installed by that application. + if stage.is_some() && !stage_button(button) { + continue; + } let package = facts.package_for_glb(&facts_of.glb) .expect("action button fixture has no package identity"); let Some(has_collider) = inputs.fixture_has_collider(package) else { diff --git a/crates/game/moly-features/src/content_library.rs b/crates/game/moly-features/src/content_library.rs index 92286652..c898f63e 100644 --- a/crates/game/moly-features/src/content_library.rs +++ b/crates/game/moly-features/src/content_library.rs @@ -44,6 +44,7 @@ mod input; mod playback; mod qa; pub use qa::library_diagnostics; +pub use qa::diagnostics_requested; pub use qa::resource_residency_summary; mod stage_framing; mod staging; @@ -103,10 +104,13 @@ pub enum EntryKey { struct DialogueLine { speaker: String, text: String, + text_step: Option, + speaker_step: Option, } #[derive(Clone)] struct LibraryTalk { content: TalkContent, + script_key: String, preview_tweet: Option, units: Vec, fixture_ids: Vec, diff --git a/crates/game/moly-features/src/content_library/bridge.rs b/crates/game/moly-features/src/content_library/bridge.rs index bce5d874..f87d4a57 100644 --- a/crates/game/moly-features/src/content_library/bridge.rs +++ b/crates/game/moly-features/src/content_library/bridge.rs @@ -270,6 +270,7 @@ fn parse_key(value: &str) -> Result { Ok(match parts.as_slice() { ["talk", "general", id] => EntryKey::Talk(TalkBackend::General, positive_id(id)?), ["talk", "fixture", id] => EntryKey::Talk(TalkBackend::Fixture, positive_id(id)?), + ["talk", "common", id] => EntryKey::Talk(TalkBackend::CommonSomeCharacter, positive_id(id)?), ["fixture", id] => EntryKey::Fixture(positive_id(id)?), ["activity", origin, id, timeline] => EntryKey::Activity(ActivityKey { origin: match *origin { @@ -286,6 +287,7 @@ fn key(value: EntryKey) -> String { match value { EntryKey::Talk(TalkBackend::General, id) => format!("talk:general:{id}"), EntryKey::Talk(TalkBackend::Fixture, id) => format!("talk:fixture:{id}"), + EntryKey::Talk(TalkBackend::CommonSomeCharacter, id) => format!("talk:common:{id}"), EntryKey::Fixture(id) => format!("fixture:{id}"), EntryKey::Activity(activity) => format!( "activity:{}:{}:{}", @@ -610,7 +612,7 @@ fn project_row( if detail { row.lines .iter() - .map(|line| json!({"speaker":line.speaker,"text":line.text})) + .map(|line| json!({"speaker":line.speaker,"text":line.text,"textStep":line.text_step,"speakerStep":line.speaker_step})) .collect() } else { vec![] @@ -629,6 +631,9 @@ fn project_row( }; let mut row = json!({"key":key(key_value),"title":title,"subtitle":subtitle,"image":fixture_ids.first().and_then(|id|image(catalog,*id)), "characters":units.iter().map(|id|character(catalog,*id)).collect::>(),"kind":kind,"available":reason.is_none(),"reason":reason}); + if let Some(talk) = catalog.talk(key_value) { + row["scriptKey"] = json!(talk.script_key); + } if detail { let mut related = Vec::new(); for id in &fixture_ids { @@ -944,6 +949,7 @@ mod tests { } fn talk(id: i32, backend: TalkBackend) -> LibraryTalk { LibraryTalk { + script_key: "sample".into(), preview_tweet: None, content: TalkContent { master_id: id, @@ -957,6 +963,7 @@ mod tests { lines: vec![DialogueLine { speaker: "一歌".into(), text: "第一句\n长台词".into(), + text_step: Some(1), speaker_step: Some(0), }], search: format!("对话 {id}"), furniture_related: false, diff --git a/crates/game/moly-features/src/content_library/catalog.rs b/crates/game/moly-features/src/content_library/catalog.rs index 5ab94620..b95b98a9 100644 --- a/crates/game/moly-features/src/content_library/catalog.rs +++ b/crates/game/moly-features/src/content_library/catalog.rs @@ -542,6 +542,7 @@ pub fn build_talk_catalog( }); let lines = general_lines(&row.steps, &catalog.character(*unit), &row.tweet.text); talks.push(make_talk( + &row.lua, TalkContent { master_id: row.talk_id, backend: TalkBackend::General, @@ -557,6 +558,20 @@ pub fn build_talk_catalog( )); } } + for row in &store.common { + if !identities.insert((2, row.talk_id)) { + catalog.data_issues.push(format!("对话 {} 的多人脚本重复,已保留第一份", row.talk_id)); + continue; + } + let units: Vec = row.unit_ids.iter().map(|unit| *unit as u32).collect(); + let fallback = units.first().map(|unit| catalog.character(*unit)).unwrap_or_default(); + talks.push(make_talk( + &row.lua, + TalkContent { master_id: row.talk_id, backend: TalkBackend::CommonSomeCharacter, is_general: Some(true) }, + units, Vec::new(), fixture_lines(&row.steps, &fallback, &row.tweet.text), + false, false, &catalog, &row.tweet, + )); + } for row in &fixtures.rows { if !identities.insert((1, row.talk_id)) { catalog @@ -586,6 +601,7 @@ pub fn build_talk_catalog( // operations, not the fixture-script representation alone, imply it. let related = !fixture_ids.is_empty() || drives; talks.push(make_talk( + &row.lua, TalkContent { master_id: row.talk_id, backend: TalkBackend::Fixture, @@ -610,6 +626,7 @@ pub fn build_talk_catalog( ); } fn make_talk( + lua: &str, content: TalkContent, units: Vec, fixture_ids: Vec, @@ -651,6 +668,7 @@ fn make_talk( ) .to_lowercase(); LibraryTalk { + script_key: lua.strip_suffix(".lua").unwrap_or(lua).to_owned(), preview_tweet: (tweet.id > 0 && !tweet.text.trim().is_empty()).then(|| tweet.clone()), content, units, @@ -665,13 +683,16 @@ fn make_talk( } fn general_lines(steps: &[TalkStep], fallback: &str, tweet: &str) -> Vec { let mut speaker = fallback.to_owned(); + let mut speaker_step = None; let mut lines = Vec::new(); - for step in steps { + for (index, step) in steps.iter().enumerate() { match step { - TalkStep::Label { name } if !name.is_empty() => speaker = plain_text(name), + TalkStep::Label { name } if !name.is_empty() => { speaker = plain_text(name); speaker_step = Some(index); }, TalkStep::Text { text } if !text.is_empty() => lines.push(DialogueLine { speaker: speaker.clone(), text: plain_text(text), + text_step: Some(index), + speaker_step, }), _ => {} } @@ -680,19 +701,24 @@ fn general_lines(steps: &[TalkStep], fallback: &str, tweet: &str) -> Vec Vec { let mut speaker = fallback.to_owned(); + let mut speaker_step = None; let mut lines = Vec::new(); - for step in steps { + for (index, step) in steps.iter().enumerate() { match step { - FixtureStep::Label { name } if !name.is_empty() => speaker = plain_text(name), + FixtureStep::Label { name } if !name.is_empty() => { speaker = plain_text(name); speaker_step = Some(index); }, FixtureStep::Text { text } if !text.is_empty() => lines.push(DialogueLine { speaker: speaker.clone(), text: plain_text(text), + text_step: Some(index), + speaker_step, }), _ => {} } @@ -701,6 +727,8 @@ fn fixture_lines(steps: &[FixtureStep], fallback: &str, tweet: &str) -> Vec LibraryTalk { LibraryTalk { + script_key: "sample".into(), preview_tweet: None, content: TalkContent { master_id: 6998, diff --git a/crates/game/moly-features/src/content_library/playback.rs b/crates/game/moly-features/src/content_library/playback.rs index c1885933..311e8af8 100644 --- a/crates/game/moly-features/src/content_library/playback.rs +++ b/crates/game/moly-features/src/content_library/playback.rs @@ -297,7 +297,7 @@ pub fn observe_start( EntryKey::Talk(TalkBackend::General, id) => player_session .as_ref() .is_some_and(|session| session.talk_id() == id), - EntryKey::Talk(TalkBackend::Fixture, id) => pair_session + EntryKey::Talk(TalkBackend::Fixture | TalkBackend::CommonSomeCharacter, id) => pair_session .as_ref() // Body admission starts playback; the same owner stays active // while its window is closed and the authored ending still runs. diff --git a/crates/game/moly-features/src/content_library/qa.rs b/crates/game/moly-features/src/content_library/qa.rs index fedee6e7..ce628a75 100644 --- a/crates/game/moly-features/src/content_library/qa.rs +++ b/crates/game/moly-features/src/content_library/qa.rs @@ -11,6 +11,11 @@ use std::sync::{ static BROWSER_DIAGNOSTICS: AtomicBool = AtomicBool::new(false); static DIAGNOSTICS: OnceLock> = OnceLock::new(); +/// The read-only browser export can enable telemetry without developer tools. +pub fn diagnostics_requested() -> bool { + BROWSER_DIAGNOSTICS.load(Ordering::Relaxed) +} + /// Opt-in read-only telemetry from the same native QA projection. No command /// is accepted and no simulation data, actor or fixture is created or changed. /// Normal embeds never call this export; they retain the compact UI snapshot. diff --git a/crates/game/moly-features/src/cutscene.rs b/crates/game/moly-features/src/cutscene.rs index edcd8be8..021ce596 100644 --- a/crates/game/moly-features/src/cutscene.rs +++ b/crates/game/moly-features/src/cutscene.rs @@ -348,7 +348,7 @@ pub fn play_async(world: &mut World, play: CastPlay) -> Result<(), String> { return Err("another cut-scene is being played".into()); } info!( - "[cutscene] {}: CutSceneExecutor.PlayAsync(characterUnitIds {:?}, cutSceneId {}, startTransform {:?}, useAlreadyExistCharacter {}, hidePlayer {}, showUI {}): CutSceneRoot found (its own pose taken as identity); MagicaCloth RequestReset (logged only); FadeAndSetUpAsync: bundle mysekai/cut_scene/{}", + "[cutscene] {}: CutSceneExecutor.PlayAsync(characterUnitIds {:?}, cutSceneId {}, startTransform {:?}, useAlreadyExistCharacter {}, hidePlayer {}, showUI {}): CutSceneRoot found (created at zero with default rotation and scale); MagicaCloth RequestReset (logged only); FadeAndSetUpAsync: bundle mysekai/cut_scene/{}", play.caller.name(), play.units, play.cut_scene_id, @@ -601,11 +601,11 @@ fn screen_cast(caller: &Caller, timeline: &str) -> screen::ScreenCast { match caller { Caller::Cast(cast) => screen::ScreenCast { caller: cast.play.caller.name(), - voice_bank: (cast.play.caller == CastCaller::Birthday).then(|| { - crate::audio::cutscene::bundle_package(&format!( - "mysekai/talk/birthday/voice/{timeline}" - )) - }), + voice_bank: match cast.play.caller { + CastCaller::Tutorial => Some(crate::audio::cutscene::bundle_package(&format!("mysekai/talk/tutorial/voice/{timeline}"))), + CastCaller::Birthday => Some(crate::audio::cutscene::bundle_package(&format!("mysekai/talk/birthday/voice/{timeline}"))), + _ => None, + }, members: cast.members(), }, _ => screen::ScreenCast { @@ -620,6 +620,7 @@ fn screen_cast(caller: &Caller, timeline: &str) -> screen::ScreenCast { fn cast_returned(world: &mut World, cast: cast::Cast) { match cast.play.caller { CastCaller::Birthday => birthday::cut_scene_returned(world, &cast), + CastCaller::Tutorial => crate::tutorial::cut_scene_returned(world, &cast), CastCaller::Invite | CastCaller::GoHome => { crate::gate_flow::cut_scene_returned(world, cast) } @@ -667,7 +668,7 @@ fn unlock_cutscene(world: &mut World, unlock: SiteLevelRow, rank: i32) -> Option ); save_unlock(world, &unlock); hide_fixture_all(world); - info!("[cutscene] CutSceneExecutor.PlayAsync: CutSceneRoot found (its own pose taken as identity); MagicaCloth RequestReset (logged only); FadeAndSetUpAsync"); + info!("[cutscene] CutSceneExecutor.PlayAsync: CutSceneRoot found (created at zero with default rotation and scale); MagicaCloth RequestReset (logged only); FadeAndSetUpAsync"); let package = format!("{PACKAGE_PREFIX}{}", row.bundle); let server = world.resource::().clone(); let tracks = @@ -1842,7 +1843,8 @@ fn presenter_fade_in(world: &mut World, plan: &Plan) { } _ => [0.0; 4], }; - crate::screen_fade::cutscene_fade_in(world, color, LAYER_FADE, "CutScenePresenter.FadeInAsync"); + let duration = match &plan.caller { Caller::Cast(cast) if cast.play.caller == CastCaller::Tutorial => 0.0, _ => LAYER_FADE }; + crate::screen_fade::cutscene_fade_in(world, color, duration, "CutScenePresenter.FadeInAsync"); } /// `SetupInternal`, then `CutSceneView.PlayAsync` starts the director. @@ -1851,6 +1853,7 @@ fn setup_internal(world: &mut World, plan: &mut Plan) -> Play { if let Caller::Cast(cast) = &plan.caller { match cast.play.caller { CastCaller::Birthday => birthday::start_fade_in_callback(world, cast), + CastCaller::Tutorial => crate::tutorial::start_fade_in_callback(world, cast), CastCaller::Invite | CastCaller::GoHome => { crate::gate_flow::cut_scene_started(world, cast) } @@ -2556,7 +2559,7 @@ fn end_async(world: &mut World, plan: &mut Plan, play: &Play) { crate::screen_fade::cutscene_fade_out_color( world, end, - LAYER_FADE, + if cast.play.caller == CastCaller::Tutorial { 0.0 } else { LAYER_FADE }, "CutScenePresenter.FadeOutAsync", ); return; @@ -2580,3 +2583,17 @@ pub fn install(app: &mut App) { ), ); } + +/// SceneMysekai.CreateCutSceneRoot creates an unparented GameObject, names it +/// CutSceneRoot and sets position zero. Its default rotation/scale are kept. +#[derive(Resource)] +struct SceneRoot(Entity); + +pub fn scene_root(world: &mut World) -> Entity { + if let Some(root) = world.get_resource::().map(|root|root.0) { + if world.get_entity(root).is_ok() { return root; } + } + let entity = world.spawn((Name::new("CutSceneRoot"), Transform::IDENTITY, GlobalTransform::IDENTITY)).id(); + world.insert_resource(SceneRoot(entity)); + entity +} diff --git a/crates/game/moly-features/src/cutscene/cast.rs b/crates/game/moly-features/src/cutscene/cast.rs index 65faa9d4..19ab4be6 100644 --- a/crates/game/moly-features/src/cutscene/cast.rs +++ b/crates/game/moly-features/src/cutscene/cast.rs @@ -80,6 +80,8 @@ pub enum CastCaller { GoHome, /// `BirthdayPartyPresenter.PlayCutSceneAsync` (see `super::birthday`). Birthday, + /// The feature tutorial processor owns the chapter callbacks. + Tutorial, } impl CastCaller { @@ -88,6 +90,7 @@ impl CastCaller { Self::Invite => "PlayInviteCutSceneAsync", Self::GoHome => "TryShowGoHomeCutSceneAsync", Self::Birthday => "BirthdayPartyPresenter.PlayCutSceneAsync", + Self::Tutorial => "MysekaiTutorialProcessor.PlayTutorialCutSceneAsync", } } } @@ -1274,6 +1277,7 @@ pub(super) fn after_dispose(world: &mut World, cast: &mut Cast, definition: &Tim // The caller's `onEndFadeOutCallback`. match cast.play.caller { CastCaller::Birthday => super::birthday::end_fade_out_callback(world, cast), + CastCaller::Tutorial => crate::tutorial::end_fade_out_callback(world, cast), CastCaller::Invite | CastCaller::GoHome => { crate::gate_flow::cut_scene_end_callback(world, cast) } diff --git a/crates/game/moly-features/src/entry/indicator.rs b/crates/game/moly-features/src/entry/indicator.rs index 20f2e57d..bfca80a0 100644 --- a/crates/game/moly-features/src/entry/indicator.rs +++ b/crates/game/moly-features/src/entry/indicator.rs @@ -955,16 +955,18 @@ fn step(world: &mut World, running: &mut Running, dt: f32) { running.updates += 1; apply_palette(world, running); layout(world, running); - let scales: Vec = running - .circles - .iter() - .map(|c| format!("{}={:.4}", c.name, c.tween.value().x)) - .collect(); - info!( - "[entry-indicator] update {} dt={dt:.4} applied={applied} {}", - running.updates, - scales.join(" ") - ); + if bevy::log::tracing::enabled!(bevy::log::tracing::Level::TRACE) { + let scales: Vec = running + .circles + .iter() + .map(|c| format!("{}={:.4}", c.name, c.tween.value().x)) + .collect(); + trace!( + "[entry-indicator] update {} dt={dt:.4} applied={applied} {}", + running.updates, + scales.join(" ") + ); + } } #[cfg(test)] diff --git a/crates/game/moly-features/src/fixture_edit.rs b/crates/game/moly-features/src/fixture_edit.rs index b57928b1..e7d51a55 100644 --- a/crates/game/moly-features/src/fixture_edit.rs +++ b/crates/game/moly-features/src/fixture_edit.rs @@ -1272,6 +1272,12 @@ fn commit_saved_layout( { layouts.did_save(&next, receipt); } + let previous = session.baseline.as_ref().map(|layout|layout.editor_rows()).unwrap_or_default(); + let updated: Vec = next.editor_rows().into_iter() + .filter(|row| previous.iter().find(|before| before.uid == row.uid) != Some(row)) + .map(|row|row.uid).collect(); + world.init_resource::(); + world.resource_mut::().pending.push(updated); session.baseline = Some(next); session.storage_baseline = Some(next_storage_baseline); session.initial_inventory = session.inventory.clone(); diff --git a/crates/game/moly-features/src/game_settings.rs b/crates/game/moly-features/src/game_settings.rs index bcda1afd..8f4dcf60 100644 --- a/crates/game/moly-features/src/game_settings.rs +++ b/crates/game/moly-features/src/game_settings.rs @@ -252,8 +252,9 @@ pub fn perf_plugin() -> moly_perf::PerfPlugin { pub fn scene_input_enabled( overlays: OverlayPanels, library: Res, + tutorial: Option>, ) -> bool { - !overlays.block_pointer() && !library.blocks_world_input() + !tutorial.is_some_and(|input|input.taps_blocked) && !overlays.block_pointer() && !library.blocks_world_input() } pub fn camera_input_enabled( @@ -270,8 +271,9 @@ pub fn camera_input_enabled( pub fn talk_input_enabled( overlays: OverlayPanels, library: Res, + tutorial: Option>, ) -> bool { - !overlays.block_pointer() && !library.blocks_talk_input() + !tutorial.is_some_and(|input|input.taps_blocked) && !overlays.block_pointer() && !library.blocks_talk_input() } /// Version of the stored `GameSettings` section. Version 2 changed the meaning @@ -311,6 +313,16 @@ fn graphics_from_document(document: &Value) -> GraphicsSettings { value } +/// Graphics state for a host-owned stage; no settings controls are created. +pub fn setup_stage(mut store: ResMut, mut settings: ResMut) { + if let Some(document) = store.load() { + let image_quality = settings.graphics.image_quality; + settings.graphics = GraphicsSettings { + image_quality, fxaa: image_quality.fxaa, ..graphics_from_document(&document) + }; + } +} + pub fn setup( mut commands: Commands, ui_font: Res, @@ -686,6 +698,7 @@ fn take_player_data_request( mut panel: ResMut, mut request: ResMut, mut import: Option>, + stage: Option>, ) { let Some(status) = request.status.take() else { return; @@ -693,8 +706,10 @@ fn take_player_data_request( if let Some(import) = import.as_mut() { import.status = status; } - panel.open = true; - panel.player_data = true; + if stage.is_none() { + panel.open = true; + panel.player_data = true; + } } /// Publishes the settings modal's claim on the field's input, read from the diff --git a/crates/game/moly-features/src/gate_flow.rs b/crates/game/moly-features/src/gate_flow.rs index 2f5d67f2..cfc79ea5 100644 --- a/crates/game/moly-features/src/gate_flow.rs +++ b/crates/game/moly-features/src/gate_flow.rs @@ -1622,6 +1622,11 @@ fn appearance_step(world: &mut World, appearance: &mut Appearance) -> bool { } appearance.next_at = t; for (index, unit) in appearance.units.iter().enumerate() { + if let Some((_, actor)) = npcs.iter().find(|(id, _)| id == unit) { + if crate::npc_objective::events::enrolled(world, *actor) { + crate::npc_objective::events::enter_standby(world, *actor); + } + } appearance.visitors.push(Visitor { unit: *unit, timeline: if index == 0 { FIRST_VISIT } else { NEXT_VISIT }, @@ -1814,6 +1819,12 @@ fn force_update_objective_immediately_timeline( visitor: &Visitor, t: f64, ) { + if let Some(character) = crate::npc::gate_entries::find_npc(world, visitor.unit) { + if crate::npc_objective::events::enrolled(world, character) { + info!("[gate] t={t:.3} unit {}: preparing the complete immediate NPC timeline", visitor.unit); + return; + } + } let uid = world .get::(gate) .map(|identity| identity.uid.clone()); @@ -1847,6 +1858,10 @@ fn visitor_step( if t + 1e-9 < timeline_start { return; } + let hfsm_actor = if visitor.unit == 0 { None } else { + crate::npc::gate_entries::find_npc(world, visitor.unit) + .filter(|actor| crate::npc_objective::events::enrolled(world, *actor)) + }; if let Some(token) = visitor.token { let status = world .resource::() @@ -1879,7 +1894,7 @@ fn visitor_step( visitor.timeline, visitor.unit ); timeline::cancel_and_release(world, token); - if visitor.unit != 0 { + if hfsm_actor.is_none() && visitor.unit != 0 { info!("[gate] PlayGateCharacterAppearTimelineInternal(unit {}): the gate part ends; the NPC side after the forced timeline (its waits, ReleaseUsingFixture and the second cancel) is not routed with it", visitor.unit); } visitor.done = true; @@ -1909,8 +1924,10 @@ fn visitor_step( Some(request) => request, None => { *generation += 1; - let (kept, left_out) = gate_part(&definition, visitor.unit != 0); - if visitor.unit != 0 { + let (kept, left_out) = if hfsm_actor.is_some() { + (definition.clone(), Vec::new()) + } else { gate_part(&definition, visitor.unit != 0) }; + if visitor.unit != 0 && hfsm_actor.is_none() { force_update_objective_immediately_timeline(world, gate, visitor, t); info!("[gate] t={t:.3} {} (unit {}): the timeline's own gate, SE and Control tracks play on the gate; the NPC's tracks left out: {:?}", visitor.timeline, visitor.unit, left_out); } @@ -1918,22 +1935,26 @@ fn visitor_step( StartTimeline { owner: TimelineOwner { activity: FixtureActivityOwner { - actor: gate, + actor: hfsm_actor.unwrap_or(gate), generation: *generation, }, - kind: TimelineOwnerKind::FixtureOnly, + kind: if hfsm_actor.is_some() { TimelineOwnerKind::Npc } else { TimelineOwnerKind::FixtureOnly }, }, fixture: gate, definition: kept, bindings: TimelineBindings::default(), companions: Vec::new(), timeout_secs: NO_TIMEOUT, - timeout_budget: TimelineTimeoutBudget::PlayerWall, + timeout_budget: if hfsm_actor.is_some() { TimelineTimeoutBudget::OwnerGated { advance: Some(true) } } else { TimelineTimeoutBudget::PlayerWall }, } } }; + let cast = hfsm_actor.and_then(|actor| world.get::(actor) + .map(|driver| (visitor.unit, actor, driver.player, driver.graph.clone()))); + if hfsm_actor.is_some() && cast.is_none() { visitor.request = Some(request); return; } let prepared = world.resource_scope(|world, mut provider: Mut| { - provider.prepare_fixture_only_bindings(world, &mut request) + if let Some(cast) = cast { provider.prepare_talk_cast_bindings(world, &mut request, &[cast]) } + else { provider.prepare_fixture_only_bindings(world, &mut request) } }); match prepared { Ok(()) => {} @@ -1950,6 +1971,11 @@ fn visitor_step( } Err(pending) => { let reason = format!("{}: {}", pending.stage, pending.reason); + if hfsm_actor.is_some() { + error!("[gate] complete NPC timeline {} failed preparation: {reason}", visitor.timeline); + visitor.done = true; + return; + } if !step_down(visitor, &mut request, &reason) { error!( "[gate] {} (unit {}): {reason}; its gate part does not play", @@ -1976,7 +2002,7 @@ fn visitor_step( visitor.request = Some(request); return; } - if step_down(visitor, &mut request, &error.message) { + if hfsm_actor.is_none() && step_down(visitor, &mut request, &error.message) { return; } error!( @@ -1986,9 +2012,12 @@ fn visitor_step( visitor.done = true; return; } - let token = world - .resource_mut::() - .request_start(request.clone()); + let token = if let Some(actor) = hfsm_actor { + match crate::npc_objective::events::begin_immediate_timeline(world, actor, request.clone()) { + Ok(token) => token, + Err(reason) => { visitor.request = Some(request); visitor.reported = Some(reason); return; } + } + } else { world.resource_mut::().request_start(request.clone()) }; info!( "[gate] t={t:.3} {} (unit {}) starts on the gate: director {} duration {:.4} s, {} tracks ({} Control clips driven, {} refused), SE {:?}", visitor.timeline, @@ -2098,9 +2127,9 @@ pub fn cut_scene_started(world: &mut World, cast: &Cast) { CastCaller::Invite => { info!("[gate] PlayInviteCutSceneAsync: no start callback"); } - // Empty: cutscene.rs dispatches Birthday casts to its own callbacks - // and never calls gate_flow for them. - CastCaller::Birthday => {} + // These callers own separate callbacks in cutscene.rs. Return here + // as well so this gate entry cannot publish duplicate side effects. + CastCaller::Birthday | CastCaller::Tutorial => return, } info!( "[gate] joystick enabled before the cut-scene state: {:?}", @@ -2129,9 +2158,9 @@ pub fn cut_scene_end_callback(world: &mut World, cast: &Cast) { } dispose_npc_all(world, "OnEndGoHomeCutScene"); } - // Empty: cutscene.rs dispatches Birthday casts to its own callbacks - // and never calls gate_flow for them. - CastCaller::Birthday => {} + // These callers own separate callbacks in cutscene.rs. Return here + // as well so this gate entry cannot publish duplicate side effects. + CastCaller::Birthday | CastCaller::Tutorial => return, } } diff --git a/crates/game/moly-features/src/joystick.rs b/crates/game/moly-features/src/joystick.rs index 18a70c7c..abb82285 100644 --- a/crates/game/moly-features/src/joystick.rs +++ b/crates/game/moly-features/src/joystick.rs @@ -147,8 +147,9 @@ pub fn advance( root_canvas: Option>, learn: Option>, held: Option>, - screens: Option>, + gates: (Option>, Option>), ) { + let (screens, tutorial) = gates; let Some(window) = windows.single().ok() else { return; }; @@ -158,7 +159,8 @@ pub fn advance( // 分派表里),不算禁用源。 // MysekaiUtility.EnableJoyStick(true) runs in the entry's Finish. let was_enabled = state.enabled; - state.enabled = !edits.is_active() + state.enabled = !tutorial.is_some_and(|input|input.movement_blocked) + && !edits.is_active() && player_talk.is_none() && !overlays.block_world_input() && !library.blocks_exploration_input() diff --git a/crates/game/moly-features/src/lib.rs b/crates/game/moly-features/src/lib.rs index 259e21ee..60a7e210 100644 --- a/crates/game/moly-features/src/lib.rs +++ b/crates/game/moly-features/src/lib.rs @@ -190,6 +190,7 @@ pub mod companion_cues; pub mod effect_show; pub mod mysekai_rank; pub mod option_dialog; +pub mod photo_shot; pub mod pick; pub mod player_input; pub mod player_data; @@ -202,3 +203,8 @@ pub mod sitemap; pub mod sitemap_phenomena; pub mod stamina_recover_dialog; pub mod weather_dialog; + +pub mod site_request; +pub mod player_data_input; + +pub mod tutorial; diff --git a/crates/game/moly-features/src/menu_dialog.rs b/crates/game/moly-features/src/menu_dialog.rs index b691c7b4..6ea2caf2 100644 --- a/crates/game/moly-features/src/menu_dialog.rs +++ b/crates/game/moly-features/src/menu_dialog.rs @@ -48,7 +48,7 @@ //! | avatar change (`_mysekaiAvtarChangeButton`, the source's spelling) | unless active: `PushUIScreen(MysekaiAvatarCostumeSetting)` (629); `Close` | `true` | greyed until a MysekaiAvatarCostumeSetting view exists (registered, no view) | //! | back to title (`_transitionToTitleButton`, `TitleCell`) | `MysekaiUtility.TransitionToTitle`: `Disconnect`, then `SceneManager.RequestScene(Title)` | no write (the serialized `true`) | hidden, as in the source: `TitleCell` is inactive in the prefab and nothing activates it | //! | leave MySekai (`_exitMysekaiButton`, `Home`) | `TransitionToOutGame`: once (`_isTransitionOutGame`), tap off, BGM stop and fade out, then `MysekaiUtility.TransitionToOutGame`: `Disconnect`, then `RequestScene(OutGame)` | `true` | greyed until an out-game scene exists (this product has only the MySekai scene) | -//! | photo (`_photoShotButton`) | unless in it: `GameStateManager.ChangeState(PhotoShot)`, whose `OnEnter` pushes MysekaiPhotoShot (632); `Close` | `MysekaiPhotoShotUtility.IsAllowedToPhotoShotInCurrentSite()` = `CurrentSiteType < grassland`, read from the active site | greyed until a MysekaiPhotoShot view exists (registered, no view) | +//! | photo (`_photoShotButton`) | unless in it: `GameStateManager.ChangeState(PhotoShot)`, whose `OnEnter` pushes MysekaiPhotoShot (632); `Close` | `MysekaiPhotoShotUtility.IsAllowedToPhotoShotInCurrentSite()` = `CurrentSiteType < grassland`, read from the active site | source-bound full-game view, camera, character call, local capture and Back; enabled when the source layout and textures are available | //! | album (`_photoAlbumButton`) | `MysekaiPhotoAlbumUtility.TryShowPhotoAlbumScreen`: unless active, `PushUIScreen(MysekaiPhotoAlbum)` (642); `Close` | no write (the serialized `true`) | greyed until a MysekaiPhotoAlbum view exists (registered, no view) | //! | crystal shop (`_crystalShopButton`) | active: `Close`; else `BootArgObject`, `PushUIScreen(CrystalShop)` (89), `Close` | `MysekaiUtility.IsAllowedToOpenCrystalShop()` = not visiting a housing competition entry, and the room owner or an offline boot | greyed until a CrystalShop screen exists (outside this product's screen table) | //! | sketch (`_whiteBlueprintSketchButton`) | unless in it: `Player.ChangeState(SelectSketchItem)`, `ChangeState(Sketch)`; `Close` (the owner-editing warning needs another player) | `SketchUtility.IsSketchAvailable()` | enters the sketch state (unchanged) | @@ -149,7 +149,7 @@ //! window pixels (the tap map for driving the menu with real taps). //! //! Named gaps (not implemented here): -//! - The six greyed targets of the table above, and the title scene behind +//! - The remaining greyed targets of the table above, and the title scene behind //! the hidden title button. //! - `SE_SUBWINDOW_CLOSE`. `SetupTutorial` (the tutorial is out of scope) and //! `SetupForBirthday` (no birthday context runs in this product). @@ -310,8 +310,7 @@ impl MenuButton { MenuButton::PhotoShot => ButtonSetup { rule: "SetupMysekaiPhotoShotButton: enabled = IsAllowedToPhotoShotInCurrentSite() = CurrentSiteType < grassland", source: inputs.photo_shot_allowed, - // Adaptation: greyed until the MysekaiPhotoShot view exists. - greyed_until: Some(Missing::Screen(MenuScreenType::MysekaiPhotoShot)), + greyed_until: None, }, MenuButton::PhotoAlbum => ButtonSetup { rule: "SetupMysekaiPhotoAlbumButton: no enabled write (the serialized true)", @@ -372,6 +371,8 @@ const RECOVER_BOOST_STAMINA_DIALOG: DialogType = DialogType::MysekaiRecoverBoost enum Missing { /// A MySekai screen id that no view of this product draws. Screen(MenuScreenType), + /// A supported screen whose required source document or images are absent. + ScreenSource(MenuScreenType), /// A `MenuScreenType` outside the MySekai block (its source name and /// value); the product's screen table holds the MySekai block only. OutGameScreen(&'static str, u16), @@ -393,6 +394,7 @@ impl Missing { }; match self { Missing::Screen(screen) => format!("{screen:?} is {}", table(screen)), + Missing::ScreenSource(screen) => format!("{screen:?}: the current source lacks its required layout bindings or textures"), Missing::OutGameScreen(name, id) => format!("{name}({id}) is {}", table(MenuScreenType(id))), Missing::Dialog(dialog) => match dialog.prefab() { Some(prefab) => format!( @@ -1121,7 +1123,13 @@ pub fn place( root.dialog = Some(id); // Setup's enabled writes, once per open. let inputs = SetupInputs::read(&mock, site.as_deref()); - root.setup = Some(ALL_BUTTONS.map(|which| which.setup(&inputs))); + root.setup = Some(ALL_BUTTONS.map(|which| { + let mut setup=which.setup(&inputs); + if which == MenuButton::PhotoShot && !crate::photo_shot::source_available(&layouts) { + setup.greyed_until=Some(Missing::ScreenSource(MenuScreenType::MysekaiPhotoShot)); + } + setup + })); } Err(error) => { error!("[menu_dialog] {error}: the menu does not open"); @@ -1354,6 +1362,7 @@ pub fn click( views: Query<(&crate::ui_layout::UiPrefabView, &MenuDialogRoot)>, mut sounds: ResMut, mut recover_requests: MessageWriter, + mut photo_requests: MessageWriter, root_canvas: Option>, ) { let taps: Vec = gestures @@ -1418,6 +1427,10 @@ pub fn click( } else { layouts.queue_button_se(&mut sounds, view.key, which.source_path()); match which { + MenuButton::PhotoShot => { + photo_requests.write(crate::photo_shot::OpenPhoto); + close_requested = true; + } MenuButton::Chest => { // OnClickChestButton: when IsActiveScreen(607) it // only closes; else it reports a state to the diff --git a/crates/game/moly-features/src/photo_shot.rs b/crates/game/moly-features/src/photo_shot.rs new file mode 100644 index 00000000..3152aae4 --- /dev/null +++ b/crates/game/moly-features/src/photo_shot.rs @@ -0,0 +1,581 @@ +//! Photo mode's source screen: menu entry, visible-character call and capture. +//! The screen follows ScreenManager and uses its serialized component bindings. +//! Capture renders the field camera alone at its current pixel dimensions, then +//! crops the source frame rectangle. Saving exports a local image; no album +//! server, film purchase or online success is simulated by this module. +use bevy::{ + asset::RenderAssetUsages, + camera::{visibility::RenderLayers, RenderTarget}, + gltf::{Gltf, GltfNode}, + math::Vec3A, + prelude::*, + render::{render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages}, + render_asset::RenderAssets, texture::GpuImage, Render, RenderApp, RenderSystems, + view::screenshot::{save_to_disk, Screenshot, ScreenshotCaptured}}, + window::PrimaryWindow, +}; +use moly_assets::ui_layout::{UiComponent, UiPrefab}; +use crate::{gesture::{GestureEvent, GestureState}, ui_layers::{MenuScreenType, ScreenManager}, + ui_layout::{UiLayouts, UiPrefabView}}; + +const SCREEN: MenuScreenType = MenuScreenType::MysekaiPhotoShot; +const KEY: &str = "PhotoShot"; +const PHOTO_ROOT: &str = "ComponentRoot/Root/PhotoShot"; +const SELECT_ROOT: &str = "ComponentRoot/Root/SelectFrame"; +const SAVE_ROOT: &str = "ComponentRoot/PhotoSave"; +const CALL_EFFECTS: [(&str,&str);2] = [ + ("photo-shot/call-button/call-button.json","fx_mysekai_photo_shot_call_character_pt"), + ("photo-shot/call-wave/call-wave.json","fx_mysekai_photo_shot_call_character_wave"), +]; + +#[derive(Message)] +pub struct OpenPhoto; +#[derive(Message, Clone, Copy)] +enum PhotoAction { Open, Back, Call, Capture(UVec2) } +#[derive(Component)] +struct PhotoView; +#[derive(Component)] +struct PhotoHeader; +#[derive(Resource, Default)] +struct PhotoState { entered: bool, capture: Option, call_effect: Option, last_error: Option } +struct CallEffect { hosts:Vec, elapsed:f32, fade:Option } +#[derive(Resource)] +struct PhotoEffects { documents:[Handle;2], textures:Vec>, prepared:bool } +struct CaptureJob { camera: Entity, previous: RenderTarget, target: Handle, elapsed: f32, + requested: UVec2, output: std::path::PathBuf, dispatched: bool, restored: bool } +#[derive(Resource, Clone, Default)] +struct CaptureReadiness(std::sync::Arc, bool)>>>); +#[derive(Resource)] +struct PhotoBindings { + frame: String, shutter: String, call: String, + cover: String, call_cover: String, + textures: Vec<(String, &'static str)>, + unavailable: Vec, + disabled_covers: Vec, + disabled_alpha: Vec<(String,f32)>, + call_graphics:Vec, + call_wave_root:String, + back: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all="camelCase")] +struct RendererBounds { node:usize, root_bone:Option, name:String, + center:[f32;3], extents:[f32;3], update_when_offscreen:bool } +#[derive(serde::Deserialize)] +#[serde(rename_all="camelCase")] +struct BoundsDocument { renderer_bounds:Vec } + +fn component<'a>(doc: &'a UiPrefab, class: &str) -> Result<&'a UiComponent, String> { + let mut found = doc.nodes.iter().flat_map(|node| &node.components).filter(|c| c.class == class); + let value = found.next().ok_or_else(|| format!("photo layout lacks {class}"))?; + if found.next().is_some() { return Err(format!("photo layout duplicates {class}")); } + Ok(value) +} +fn reference(doc: &UiPrefab, fields: &serde_json::Value, field: &str) -> Result { + let ptr = fields[field].as_array().filter(|ptr| ptr.len() == 2) + .ok_or_else(|| format!("photo layout lacks {field} binding"))?; + let id = ptr[1].as_i64().filter(|id| *id != 0).ok_or("invalid photo component reference")?; + if ptr[0].as_i64() != Some(0) || !doc.nodes.iter().any(|node| node.game_object_id == id + || node.transform_id == id || node.components.iter().any(|c| c.path_id == id)) { + return Err(format!("unresolved photo component {field}")); + } + Ok(format!("@{id}")) +} +impl PhotoBindings { + fn read(doc: &UiPrefab) -> Result { + let view = component(doc, "Sekai.Mysekai.MysekaiPhotoShotView")?; + let fields = &view.fields; + let r = |name| reference(doc, fields, name); + let mut textures = Vec::new(); + for (field, texture) in [ + ("_overlayImage", "overlay_photoshot_screen"), ("_frameImage", "frame_PhotoShot"), + ("_photoShotBaseImage", "btn_photoshot_gr_h180"), ("_photoShotCoverBaseImage", "btn_photoshot_gr_h180"), + ("_photoShotIconImage", "icon_camera_wh_h80"), ("_photoShotIconCoverImage", "icon_camera_wh_h80"), + ("_callCharacterButtonIcon", "icon_callout_wh"), ("_callCharacterButtonCoverIcon", "icon_callout_wh"), + ("_photoEffectIconImage", "icon_cameraEffect_h56"), ("_photoAlbumIcon", "icon_photoalbum_wh_h42"), + ("_dofTextImage", "txt_photoshot_blur"), ("_remainCountTextImage", "txt_photoshot_filmCount"), + ] { textures.push((r(field)?, texture)); } + for (field, texture) in [("_lineCornerImageList", "line_cornerguide_photoshot"), ("_iconBaseImageList", "btn_circle120_lgr"), ("_tripodIconList", "icon_tripod_wh_h44")] { + for ptr in fields[field].as_array().ok_or_else(|| format!("photo layout lacks {field}"))? { + let fake = serde_json::json!({"item":ptr}); + textures.push((reference(doc, &fake, "item")?, texture)); + } + } + // These controls depend on the album service or separate editing views. + // Keep their authored layout with disabled appearance until those + // separate views are implemented. Camera, call, capture and Back run. + let unavailable = ["_openPhotoAlbumButton", "_openAvatarMotionSelectorButton", + "_showsSelectPhotoEffectDialogButton", "_switchEditModeToggle", "_dofAdjustSlider"] + .into_iter().map(r).collect::, _>>()?; + let mut disabled_covers=Vec::new();let mut disabled_alpha=Vec::new(); + for path in &unavailable { + let node=&doc.nodes[doc.find(path).map_err(|e|e.to_string())?]; + let control=node.components.iter().find(|c|c.fields.get("m_DisabledColor").is_some()) + .ok_or("photo disabled control lacks its selectable appearance")?; + if control.fields["coverImage"].as_array().is_some_and(|ptr|ptr.get(1).and_then(|id|id.as_i64()).is_some_and(|id|id!=0)) { + disabled_covers.push(reference(doc,&control.fields,"coverImage")?); + } else { + let alpha=control.fields["m_DisabledColor"][3].as_f64().ok_or("photo disabled color is missing")? as f32; + disabled_alpha.push((path.clone(),alpha)); + } + } + let call_graphics=fields["_callCharacterButtonGraphics"].as_array().ok_or("photo call graphics are missing")? + .iter().map(|ptr| reference(doc,&serde_json::json!({"graphic":ptr}),"graphic")? + .trim_start_matches('@').parse::().map_err(|e|e.to_string())).collect::,_>>()?; + Ok(Self { frame:r("_frameImage")?, shutter:r("_photoShotButton")?, call:r("_callCharacterButton")?, + cover:r("_photoShotCoverButton")?, call_cover:r("_callCharacterButtonCover")?, textures, unavailable, + disabled_covers,disabled_alpha,call_graphics,call_wave_root:r("_callCharacterWaveParticleRoot")?,back:String::new() }) + } +} + +/// This source may predate the photo screen. The menu advertises only a +/// source whose serialized bindings and dynamic texture inventory are present. +pub fn source_available(layouts: &UiLayouts) -> bool { + layouts.document(KEY).and_then(|doc|PhotoBindings::read(doc).ok()) + .is_some_and(|bindings|bindings.textures.iter().all(|(_,name)|layouts.has_runtime_texture(name))) +} + +pub fn install(app: &mut App) { + let readiness=CaptureReadiness::default(); + app.insert_resource(readiness.clone()); + if let Some(render)=app.get_sub_app_mut(RenderApp) { + render.insert_resource(readiness).add_systems(Render, mark_capture_ready.after(RenderSystems::PrepareAssets)); + } + app.init_resource::().add_message::().add_message::() + .add_systems(Update, spawn_when_ready) + .add_systems(Update,prepare_effects.after(spawn_when_ready).before(click)) + .add_systems(Update, click.after(crate::menu_dialog::click).before(crate::pick::pick) + .before(crate::ui_layers::advance).run_if(crate::game_settings::scene_input_enabled)) + .add_systems(Update, dispatch.after(click).before(crate::ui_layers::advance)) + .add_systems(Update, lifecycle.after(crate::ui_layers::advance).before(place)) + .add_systems(Update, place.after(crate::menu_shell::place).before(crate::ui_layout::render)) + .add_systems(Update,fit_call_effect.after(place).before(crate::ui_layout::render)); +} + +fn mark_capture_ready(readiness: Res, images: Res>) { + if let Ok(mut slot)=readiness.0.lock() { + if let Some((id,ready))=slot.as_mut() { *ready=images.get(*id).is_some(); } + } +} + +fn spawn_when_ready(mut commands: Commands, layouts: Res, server: Res, + bindings: Option>, mut state: ResMut) { + if bindings.is_some() || !layouts.ready(KEY, &server) || !layouts.ready("EditorHeader", &server) || !source_available(&layouts) { return; } + let result = PhotoBindings::read(layouts.document(KEY).expect("ready photo document")); + match result { + Ok(mut bindings) => { + let doc = layouts.document("EditorHeader").expect("ready shared header"); + let mut buttons = doc.nodes.iter().flat_map(|node| &node.components).filter(|c| c.class.ends_with(".CustomButton") + && c.fields["m_OnClick"]["m_Calls"].as_array().is_some_and(|calls| calls.iter().any(|call| call["m_MethodName"].as_str()==Some("OnClickBackUIScreen")))); + let Some(button) = buttons.next() else { error!("[photo] shared header BackUI callback missing"); return; }; + assert!(buttons.next().is_none(), "shared header BackUI callback is ambiguous"); + bindings.back = format!("@{}",button.path_id); + let path = &doc.nodes[doc.find(&bindings.back).expect("bound header back")].path; + let mut header = UiPrefabView::new("EditorHeader",crate::sitemap::SITEMAP_LAYER); + for node in &doc.nodes { + if node.path != *path && !path.starts_with(&format!("{}/",node.path)) && !node.path.starts_with(&format!("{path}/")) { + header.set_visible(&format!("@{}",node.transform_id),false); + } + } + header.set_behaviour_enabled(button.path_id,true); + let root=layouts.root().expect("ready UI root").trim_end_matches('/'); + commands.insert_resource(PhotoEffects {documents:CALL_EFFECTS.map(|(file,_)|server.load(format!("{root}/{file}"))),textures:Vec::new(),prepared:false}); + commands.insert_resource(bindings); + commands.spawn((PhotoView, Visibility::Hidden, Transform::default(), + RenderLayers::layer(crate::sitemap::SITEMAP_LAYER), UiPrefabView::new(KEY, crate::sitemap::SITEMAP_LAYER))); + commands.spawn((PhotoHeader, Visibility::Hidden, Transform::from_xyz(0.,0.,20.), + RenderLayers::layer(crate::sitemap::SITEMAP_LAYER), header)); + } + Err(error) => { if state.last_error.as_deref() != Some(&error) { error!("[photo] {error}"); state.last_error = Some(error); } } + } +} + +fn prepare_effects(effects:Option>,docs:Res>,server:Res,layouts:Res) { + let Some(mut effects)=effects else {return}; + if effects.prepared {return;} + let Some(root)=layouts.root() else {return}; + let mut textures=Vec::new(); + for (index,(file,_)) in CALL_EFFECTS.iter().enumerate() { + let Some(raw)=docs.get(&effects.documents[index]) else {return}; + let Ok(doc)=serde_json::from_str::(&raw.0) else {return}; + let Some(rows)=doc["particles"].as_array() else {return}; + for row in rows { + let Some(map)=row["renderer"]["material"]["textures"].as_object() else {return}; + for value in map.values() { + let Some(path)=value.as_str() else {return}; + textures.push(moly_assets::residency::load_image(&server,format!("{}{}/{path}",root,file.rsplit_once('/').expect("effect directory").0))); + } + } + } + effects.textures=textures;effects.prepared=true; +} + +fn click(mut open: MessageReader, mut gestures: MessageReader, + mut actions: MessageWriter, windows: Query<&Window, With>, + canvas: Option>, layouts: Res, screens: Res, + views: Query<&UiPrefabView, With>, headers: Query<&UiPrefabView, With>, bindings: Option>, + mut consumed: ResMut, state: Res, mut sounds: ResMut) { + for _ in open.read() { actions.write(PhotoAction::Open); } + let taps: Vec<_> = gestures.read().filter(|event| event.kind.is_tap_family() && event.state == GestureState::End) + .map(|event| event.position).collect(); + if screens.current_screen() != Some(SCREEN) || screens.is_ui_layer_working() || state.capture.is_some() { return; } + let (Ok(window), Some(canvas), Ok(view), Some(bindings)) = (windows.single(), canvas.as_deref(), views.single(), bindings.as_deref()) else { return; }; + let size = canvas.size(window); + for tap in taps { + if consumed.0 { break; } + let position = Vec2::new(tap.x - window.width() / 2., window.height() / 2. - tap.y) / canvas.scale(window); + let hit = |path:&str| view.rect(&layouts, path, size).is_some_and(|rect| rect.active && rect.contains(position)); + if headers.single().ok().and_then(|header| header.rect(&layouts,&bindings.back,size)).is_some_and(|rect| rect.active && rect.contains(position)) { + consumed.0=true;layouts.queue_button_se(&mut sounds,"EditorHeader",&bindings.back);actions.write(PhotoAction::Back); + } + else if hit(&bindings.call) { + consumed.0 = true; + if state.call_effect.as_ref().is_none_or(|effect|effect.fade.is_some()) { + layouts.queue_button_se(&mut sounds,KEY,&bindings.call); actions.write(PhotoAction::Call); + } + } + else if hit(&bindings.shutter) { + consumed.0 = true; + layouts.queue_button_se(&mut sounds,KEY,&bindings.shutter); + if let Some(rect) = view.rect(&layouts, &bindings.frame, size) { + let pixels = rect.size * canvas.scale(window) * window.scale_factor(); + actions.write(PhotoAction::Capture(pixels.as_uvec2())); + } + } + } +} + +fn dispatch(world: &mut World, mut reader: Local>) { + let actions: Vec<_> = reader.read(world.resource::>()).copied().collect(); + for action in actions { + let result = match action { + PhotoAction::Open => { + if world.resource::().entered { continue; } + if !world.contains_resource::() { Err("photo screen is still loading".to_owned()) } + else { crate::npc_objective::events::set_photo_mode(world, true).map(|()| { + world.resource_mut::().change_state( + crate::game_state::GameStateType::PhotoShot,"PhotoShotGameState.OnEnter"); + crate::camera::change_state(world, crate::camera::CameraStateType::PhotoShot, "photo"); + world.resource_mut::().entered = true; + world.resource_mut::().push_ui_screen(SCREEN, None, "PhotoShotGameState.OnEnter"); + }) } + } + PhotoAction::Call => { + visible_units(world).and_then(|units| { + for unit in units { crate::npc_objective::events::call_photo_character(world,unit)?; } + start_call_effect(world) + }) + } + PhotoAction::Back => { world.resource_mut::().back_ui_screen(false,"PhotoShot header BackUI");Ok(()) } + PhotoAction::Capture(size) => capture(world, size), + }; + if let Err(error) = result { error!("[photo] {error}"); world.resource_mut::().last_error = Some(error); } + } +} + +fn lifecycle(world: &mut World) { + let delta = world.resource::