diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3b890f40 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +# Source fingerprints must identify the same bytes on every build platform. +* text=auto eol=lf + +# Preserve the wrapper's upstream Windows bytes; it is not a Rust build input. +android/gradlew.bat -text + +*.png -text +*.jpg -text +*.jpeg -text +*.ico -text +*.ttf -text +*.otf -text +*.woff -text +*.woff2 -text +*.wasm -text +*.bin -text +*.glb -text +*.zip -text +*.gz -text +*.br -text +*.ogg -text +*.wav -text +*.mp3 -text +*.ktx2 -text +*.spv -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2538c34c..a6a7573b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,10 @@ jobs: VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + if: inputs.package + with: + node-version: '22' - name: Install the Rust toolchain shell: bash run: | @@ -82,7 +86,15 @@ jobs: cache-targets: false save-if: ${{ github.ref == 'refs/heads/master' }} - name: Build - run: cargo build --release --locked -p moly-app -p moly-launcher -p moly-server-host --target ${{ matrix.target }} + run: cargo build --release --locked -p moly-app -p moly-stage -p moly-launcher -p moly-server-host --target ${{ matrix.target }} + - name: Test shared rules, transport, settlement and runtime + if: runner.os == 'Linux' + run: >- + cargo test --release --locked --lib + -p moly-law -p moly-protocol -p moly-layout -p moly-masters + -p moly-assets -p moly-server -p moly-world -p moly-features + --target ${{ matrix.target }} + -- --test-threads=1 - name: Compiler cache statistics if: always() run: sccache --show-stats @@ -113,6 +125,7 @@ jobs: cp LICENSE THIRD_PARTY_NOTICES.md "dist/$name/" cp docs/release-package.md "dist/$name/README.md" done + node web/ui-font-package.mjs --copy-license "dist/$game" cd dist for name in "$game" "$server"; do if [[ "${{ matrix.archive }}" == zip ]]; then @@ -128,11 +141,18 @@ jobs: name: native-${{ matrix.target }} path: dist/out/* if-no-files-found: error + - uses: actions/upload-artifact@v4 + if: inputs.package && runner.os == 'Windows' + with: + name: stage-system-audit-windows + path: target/${{ matrix.target }}/release/moly-stage.exe + if-no-files-found: error browser: strategy: fail-fast: false matrix: renderer: [webgpu, webgl2] + product: [stage, game] runs-on: ubuntu-22.04 timeout-minutes: 240 steps: @@ -147,7 +167,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 +179,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/ci.yml b/.github/workflows/ci.yml index 04a68e3a..063fdc66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,10 @@ jobs: run: | if [[ -n "$PR_BASE_SHA" ]]; then git diff --check "$PR_BASE_SHA...$PR_HEAD_SHA" - elif [[ -n "$PUSH_BASE_SHA" && ! "$PUSH_BASE_SHA" =~ ^0+$ ]]; then + elif [[ -n "$PUSH_BASE_SHA" && ! "$PUSH_BASE_SHA" =~ ^0+$ ]] && git cat-file -e "$PUSH_BASE_SHA^{commit}" 2>/dev/null; then git diff --check "$PUSH_BASE_SHA" "$GITHUB_SHA" else + # A rebased push may no longer expose its previous tip to checkout. + # The PR check still checks the full base-to-head diff. git show --format= --check "$GITHUB_SHA" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cef4dc3a..4f1cc179 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,24 +77,31 @@ jobs: env: VERSION: ${{ needs.version.outputs.version }} steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' - uses: actions/download-artifact@v4 with: - pattern: browser-* - path: pkg + pattern: browser-stage-* + path: stage/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 + - uses: actions/download-artifact@v4 + with: + pattern: browser-game-* + path: game/pkg + merge-multiple: true + - name: Verify both renderers' font and its license + run: node web/ui-font-package.mjs --verify stage/pkg game/pkg - 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..1ad3aa08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1523,8 +1523,6 @@ dependencies = [ [[package]] name = "bevy_render" version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc86a32b2eef9859e1e7d323c6d1a9150fe77aa17c30fd05abebc12f645929dc" dependencies = [ "async-channel", "bevy_app", @@ -4358,6 +4356,8 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "zune-core", + "zune-jpeg", ] [[package]] @@ -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", @@ -5216,17 +5212,15 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "zip", ] [[package]] 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", @@ -5316,6 +5310,7 @@ dependencies = [ "moly-assets", "moly-law", "moly-protocol", + "serde", "serde_json", ] @@ -5351,11 +5346,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", @@ -5407,6 +5399,7 @@ dependencies = [ "moly-law", "moly-masters", "moly-protocol", + "serde", "serde_json", "smol-hyper", "wasm-bindgen", @@ -5422,6 +5415,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 +5451,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 +5503,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 +5522,7 @@ dependencies = [ "moly-protocol", "moly-render", "moly-store", + "moly-text", "moly-ui", "naga", "parry3d", @@ -10714,6 +10730,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/Cargo.toml b/Cargo.toml index 74a86fda..902023c7 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,16 +21,19 @@ 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", ] -exclude = ["crates/vendor/cpal"] +exclude = ["crates/vendor/cpal", "crates/vendor/bevy_render"] # CPAL 0.15.3's WebAudio backend retains expired buffer deadlines after a # main-thread stall. Keep the small transport fix in the fingerprinted sources. [patch.crates-io] cpal = { path = "crates/vendor/cpal" } +# Backport Bevy #24960: empty meshes must skip copies as well as allocation. +bevy_render = { path = "crates/vendor/bevy_render" } [workspace.package] edition = "2021" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4b8bacf1..c72a36e7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,18 +5,23 @@ ## Resource Han Rounded(字体子集) -`crates/game/moly-game/assets/font/ResourceHanRoundedSC-Medium.subset.ttf` 是 +`crates/game/moly-core/assets/font/ResourceHanRoundedSC-Medium.subset.ttf` 是 Resource Han Rounded SC Medium 0.990(作者 GitHub 发布的 TTF 版本)的一个 字形子集,运行时用于烘焙文字图集。子集保留此前全部码点,并覆盖日服、国服 产品可显示文本(界面文案、对话与推文语料、主表文本字段)以及产品自身写入的 文字;[tools/subset_catalog_font.py](tools/subset_catalog_font.py) 可由原字体与 同一组输入重建它。按 SIL Open Font License 1.1 授权,许可全文及版权声明见 -[OFL-1.1.txt](crates/game/moly-game/assets/font/OFL-1.1.txt) +[OFL-1.1.txt](crates/game/moly-core/assets/font/OFL-1.1.txt) (© 2018–2019 Cyano Hao;部分 © 2014、2015、2018 Adobe)。 子集化是 OFL 意义上的修改版本,文件名以 `.subset` 后缀标注。字体名不含 Adobe 的保留字体名(Reserved Font Name "Source")。 +原生游戏包将许可全文和版权声明附在 `fonts/OFL-1.1.txt`;浏览器包附在 +`pkg/fonts/OFL-1.1.txt`。浏览器每个后端的 `build.json` 中 `uiFont.license` +记录许可标识、相对路径、SHA-256 和字节数,发布的 `integrity.json` 也包含 +该文件。外部字体缺少许可证记录、文件或哈希不符时,发布检查会失败。 + ## Arm Optimized Routines(单精度 powf 移植) `crates/base/moly-law/src/powf.rs` 是 Arm Optimized Routines 中单精度 `powf` @@ -126,3 +131,11 @@ software is freely granted, provided that this notice is preserved. ==================================================== ``` + +## Bevy rendering backport + +The vendored `bevy_render` 0.19.1 crate retains its MIT / Apache-2.0 licenses +in [LICENSE-MIT](crates/vendor/bevy_render/LICENSE-MIT) and +[LICENSE-APACHE](crates/vendor/bevy_render/LICENSE-APACHE). +[MOLY-PATCH.md](crates/vendor/bevy_render/MOLY-PATCH.md) records the upstream +empty-mesh copy fix and original archive identity. 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..4217e3c2 --- /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(Box::new(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..6d0f268d --- /dev/null +++ b/crates/app/moly-stage/src/schedule.rs @@ -0,0 +1,703 @@ +//! 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::app::SceneSpawnerSystems; +use bevy::time::common_conditions; +use bevy::transform::TransformSystems; +use std::time::Duration; + +pub fn install(app: &mut App) { + app.init_resource::(); + // AvatarMaterialPlugin installs the companion domain, whose build swing + // uses this shared hand-item view. The game registers it with home_action; + // the embedded stage owns the same item lifecycle without that game UI. + app.init_resource::() + .add_message::() + .add_systems(Startup, player_avatar::avatar_item::load) + .add_systems( + Update, + player_avatar::avatar_item::parse + .before(moly_world::companion::CompanionSteps), + ) + .add_systems( + Update, + player_avatar::avatar_item::apply + .after(player_avatar::avatar_item::parse) + .after(player_avatar::body::wire) + .after(player_state::drive_from_input) + .before(player::advance) + .before(player_avatar::drive), + ); + 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::WorldInstanceSpawn), + ) + .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/bulk_harvest.rs b/crates/base/moly-law/src/bulk_harvest.rs new file mode 100644 index 00000000..93a4c87e --- /dev/null +++ b/crates/base/moly-law/src/bulk_harvest.rs @@ -0,0 +1,367 @@ +//! Engine-independent bulk harvest selection and resource budgets. +//! Callers supply the current site's available fixture and tool rows. + +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct FixtureKey { + pub fixture_id: i32, + pub position_x: i32, + pub position_z: i32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Candidate { + pub key: FixtureKey, + pub target_id: i32, + pub ordinal: usize, + pub distance_squared: f32, + pub hp: i32, + pub last_attack_stamina: i32, + pub required_tool_id: Option, + pub spawned: bool, + pub view_exists: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tool { + pub id: i32, + pub quantity: i32, + pub durability: i32, + pub max_durability: i32, + pub attack_power: i32, +} + +impl Tool { + pub fn remaining_hits(self) -> i64 { + if self.quantity < 1 || self.durability < 1 || self.max_durability < 1 { + 0 + } else { + i64::from(self.durability) + + i64::from(self.max_durability) * i64::from(self.quantity - 1) + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Budget { + pub normal: i32, + pub enhance: i32, + pub boost: i32, +} + +impl Budget { + pub fn total(self) -> i64 { + i64::from(self.normal.max(0)) + + i64::from(self.enhance.max(0)) + + i64::from(self.boost.max(0)) + } + + /// CreateStaminaUsedList: boost, enhance, normal; never overdrafts. + pub fn consume(self, amount: i64) -> Option { + if amount < 0 + || amount > self.total() + || self.normal < 0 + || self.enhance < 0 + || self.boost < 0 + { + return None; + } + let mut out = self; + let mut left = amount; + for pool in [&mut out.boost, &mut out.enhance, &mut out.normal] { + let debit = left.min(i64::from(*pool)); + *pool -= debit as i32; + left -= debit; + } + Some(out) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Plan { + pub targets: Vec, + pub target_ids: Vec, + pub consumed_stamina: i64, + pub tool_hits: BTreeMap, + pub stamina_after: Budget, + pub exists_tool_shortage_excluded_target: bool, + pub exists_stamina_shortage_excluded_target: bool, +} + +/// Site filtering and target ordinal assignment precede this function. +/// `checked=None` means there is no saved preference; `Some([])` selects none. +pub fn calculate( + candidates: &[Candidate], + checked: Option<&[i32]>, + tools: &[Tool], + budget: Budget, +) -> Plan { + let mut ordered: Vec<_> = candidates + .iter() + .filter(|row| { + row.spawned + && row.view_exists + && row.hp >= 0 + && row.last_attack_stamina >= 0 + && checked.is_none_or(|ids| ids.contains(&row.target_id)) + }) + .collect(); + ordered.sort_by(|a, b| { + a.ordinal + .cmp(&b.ordinal) + .then_with(|| a.distance_squared.total_cmp(&b.distance_squared)) + }); + let mut remaining: BTreeMap = tools + .iter() + .map(|tool| (tool.id, tool.remaining_hits())) + .collect(); + let mut seen = BTreeSet::new(); + let mut stamina = budget.total(); + let mut plan = Plan { + stamina_after: budget, + ..Plan::default() + }; + for row in ordered { + if !seen.insert(row.key) { + continue; + } + let hits = match row.required_tool_id { + Some(id) => { + let Some(tool) = tools + .iter() + .find(|tool| tool.id == id && tool.quantity > 0 && tool.attack_power > 0) + else { + plan.exists_tool_shortage_excluded_target = true; + continue; + }; + let attack = i64::from(tool.attack_power); + // The extra finishing hit is explicit in the original. + let hits = (i64::from(row.hp) + attack - 1) / attack + 1; + if remaining.get(&id).copied().unwrap_or(0) < hits { + plan.exists_tool_shortage_excluded_target = true; + continue; + } + hits + } + None => 0, + }; + let cost = i64::from(row.hp) + i64::from(row.last_attack_stamina); + if cost > stamina { + plan.exists_stamina_shortage_excluded_target = true; + continue; + } + stamina -= cost; + plan.consumed_stamina += cost; + if let Some(id) = row.required_tool_id { + *remaining.entry(id).or_default() -= hits; + *plan.tool_hits.entry(id).or_default() += hits; + } + plan.targets.push(row.key); + if !plan.target_ids.contains(&row.target_id) { + plan.target_ids.push(row.target_id); + } + } + plan.stamina_after = budget.consume(plan.consumed_stamina).unwrap_or(budget); + plan +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Gates { + pub pass_valid: bool, + pub possession_limit: bool, + pub treasure_box_not_synced: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(i32)] +pub enum UnavailableReason { + #[default] + None = 0, + PassExpired = 1, + PossessionLimit = 2, + TreasureBoxNotSynced = 3, + StaminaShortage = 4, + ToolShortage = 5, + NoTarget = 6, +} + +pub fn judge_available( + gates: Gates, + candidates: &[Candidate], + checked: Option<&[i32]>, + tools: &[Tool], + budget: Budget, +) -> UnavailableReason { + use UnavailableReason::{ + NoTarget, PassExpired, PossessionLimit, StaminaShortage, ToolShortage, TreasureBoxNotSynced, + }; + if !gates.pass_valid { + return PassExpired; + } + if gates.possession_limit { + return PossessionLimit; + } + if gates.treasure_box_not_synced { + return TreasureBoxNotSynced; + } + if budget.total() < 1 { + return StaminaShortage; + } + if !calculate(candidates, checked, tools, budget) + .targets + .is_empty() + { + return UnavailableReason::None; + } + if !calculate( + candidates, + checked, + tools, + Budget { + normal: i32::MAX, + enhance: i32::MAX, + boost: i32::MAX, + }, + ) + .targets + .is_empty() + { + return StaminaShortage; + } + if candidates.iter().any(|row| { + row.spawned && row.view_exists && checked.is_none_or(|ids| ids.contains(&row.target_id)) + }) { + ToolShortage + } else { + NoTarget + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn target(id: i32, ordinal: usize, distance: f32, hp: i32, tool: Option) -> Candidate { + Candidate { + key: FixtureKey { + fixture_id: id, + position_x: id, + position_z: 0, + }, + target_id: id, + ordinal, + distance_squared: distance, + hp, + last_attack_stamina: 10, + required_tool_id: tool, + spawned: true, + view_exists: true, + } + } + fn tool() -> Tool { + Tool { + id: 10, + quantity: 2, + durability: 1, + max_durability: 4, + attack_power: 40, + } + } + #[test] + fn target_priority_then_distance_and_explicit_empty_selection() { + let rows = [ + target(1, 1, 0., 0, None), + target(2, 0, 90000., 0, None), + target(3, 0, 1., 0, None), + ]; + let budget = Budget { + normal: 100, + ..Budget::default() + }; + assert_eq!(calculate(&rows, None, &[], budget).target_ids, [3, 2, 1]); + assert!(calculate(&rows, Some(&[]), &[], budget).targets.is_empty()); + assert_eq!(calculate(&rows, Some(&[2]), &[], budget).target_ids, [2]); + } + #[test] + fn finishing_hit_exact_tool_and_partial_budget_continue() { + let rows = [ + target(1, 0, 0., 80, Some(10)), + target(2, 1, 0., 80, Some(10)), + target(3, 2, 0., 0, None), + target(4, 3, 0., 80, Some(5)), + ]; + let plan = calculate( + &rows, + None, + &[tool()], + Budget { + normal: 100, + ..Budget::default() + }, + ); + assert_eq!(tool().remaining_hits(), 5); + assert_eq!(plan.target_ids, [1, 3]); + assert_eq!(plan.tool_hits[&10], 3); + assert!(plan.exists_tool_shortage_excluded_target); + assert_eq!(plan.consumed_stamina, 100); + } + #[test] + fn unaffordable_fixture_does_not_prevent_later_cheaper_one() { + let plan = calculate( + &[target(1, 0, 0., 100, None), target(2, 1, 0., 0, None)], + None, + &[], + Budget { + normal: 20, + ..Budget::default() + }, + ); + assert_eq!(plan.target_ids, [2]); + assert!(plan.exists_stamina_shortage_excluded_target); + } + #[test] + fn pool_debit_and_gate_priority() { + let b = Budget { + normal: 50, + enhance: 30, + boost: 20, + }; + assert_eq!( + b.consume(60), + Some(Budget { + normal: 40, + enhance: 0, + boost: 0 + }) + ); + assert_eq!(b.consume(101), None); + let mut gates = Gates { + pass_valid: false, + possession_limit: true, + treasure_box_not_synced: true, + }; + assert_eq!( + judge_available(gates, &[], None, &[], b), + UnavailableReason::PassExpired + ); + gates.pass_valid = true; + assert_eq!( + judge_available(gates, &[], None, &[], b), + UnavailableReason::PossessionLimit + ); + gates.possession_limit = false; + assert_eq!( + judge_available(gates, &[], None, &[], b), + UnavailableReason::TreasureBoxNotSynced + ); + gates.treasure_box_not_synced = false; + assert_eq!( + judge_available(gates, &[target(1, 0, 0., 100, None)], None, &[], b), + UnavailableReason::StaminaShortage + ); + assert_eq!( + judge_available(gates, &[target(1, 0, 0., 0, Some(10))], None, &[], b), + UnavailableReason::ToolShortage + ); + } +} diff --git a/crates/base/moly-law/src/cloth/solver.rs b/crates/base/moly-law/src/cloth/solver.rs index 440a4a56..6da0e9ff 100644 --- a/crates/base/moly-law/src/cloth/solver.rs +++ b/crates/base/moly-law/src/cloth/solver.rs @@ -32,6 +32,10 @@ //! 等价、瞬态有差。 //! * 约束读位:反编译同迭代内并行读位置(job 系统序不保证); //! 本律顺序投影(Gauss-Seidel)。 +//! * 显式 mode-0 重置沿用本律的 pos/prev 表示:当帧动画位播种, +//! 按帧恢复 velocityWeight,分别权重积分前惯性与约束/回拉后的 +//! 保存速度。没有补造原生独立速度/旋转/oldBase/oldSlow 缓冲, +//! 也没有改变现有的限速位置、摩擦缺口与约束速度全吸收语义。 use super::collider::WorldCollider; use super::math; @@ -219,6 +223,8 @@ impl EvaluatedParams { pub struct StepStats { /// 本帧实际子步数。 pub substeps: u32, + /// 本帧消费了显式 mode-0 重置,且至少执行一次物理子步。 + pub requested_reset: bool, /// 本帧发生了 teleport 复位。 pub teleport_reset: bool, /// 本帧发生了 NaN 复位。 @@ -246,6 +252,12 @@ pub struct ClothState { pub nan_resets: u32, /// teleport 复位累计(诊断)。 pub teleport_resets: u32, + /// 只在推进拥有当帧动画输入时消费;停帧保留,不从事件回调播种。 + pending_reset: Option, + /// TeamData.velocityWeight;新链恒为 1,不改变未请求重置的轨迹。 + velocity_weight: f32, + /// authored resetStabilizationTime,按帧 delta 恢复而非按子步恢复。 + velocity_recovery_time: f32, } impl ClothState { @@ -259,8 +271,25 @@ impl ClothState { accumulator: 0.0, nan_resets: 0, teleport_resets: 0, + pending_reset: None, + velocity_weight: 1.0, + velocity_recovery_time: 0.0, } } + + /// 排队 `BaseCloth.ResetCloth` 的 Reset(mode 0)分支。 + /// 调用本身不改粒子/锚点/时钟;下一次有正 dt 的有效输入消费它。 + /// 相同帧的多个请求折叠成一次,以最后一个 authored 时间为准。 + /// Keep(mode 1)保留形状及旋转速度的语义不由此 API 代替。 + pub fn request_reset_mode0(&mut self, stabilization_time: f32) -> Result<(), String> { + if !stabilization_time.is_finite() || stabilization_time < 0.0 { + return Err(format!( + "cloth solver: resetStabilizationTime 非法({stabilization_time})" + )); + } + self.pending_reset = Some(stabilization_time); + Ok(()) + } } /// 一帧的输入(全为当帧值,不跨帧)。 @@ -291,7 +320,11 @@ pub fn build_chain( rest.len() )); } - let fixed_bits: Vec = def.selection.iter().map(|&s| s == Selection::Fixed).collect(); + let fixed_bits: Vec = def + .selection + .iter() + .map(|&s| s == Selection::Fixed) + .collect(); // 拓扑序:反复放置「父已放」的顶点(demo 形)。 let mut order = Vec::with_capacity(n); @@ -429,79 +462,107 @@ pub fn advance_with_scratch( if input.anim.len() != topo.n || state.pos.len() != topo.n { return Err("cloth solver: 输入长度与链不一致".to_string()); } - if !state.inited { - seed(state, input.anim, input.anchor_rot); - return Ok(StepStats { - substeps: 0, - teleport_reset: false, - nan_reset: false, - max_disp: 0.0, - }); - } - - // ---- 世界运动惯性(demo step() 的 inertia 块;数据结构 = - // TeamData.WorldInfluence 的 now/old position/rotation)---- - let a = topo.anchor as usize; - let a1 = input.anim[a]; - let a0 = state.anchor_prev_pos; - let dq = math::quat_normalize(mul_quat( - input.anchor_rot, - math::quat_conjugate(state.anchor_prev_rot), - )); - let trans = sub(a1, a0); - let t_len = math::length(trans); - let rot_angle = 2.0 * dq[3].abs().clamp(0.0, 1.0).acos(); - - // teleport 复位:useResetTeleport 链用数据阈值;其余 1.0m 兜底。 - let dist_thresh = if params.use_reset_teleport { - params.teleport_distance - } else { - FALLBACK_TELEPORT_DISTANCE - }; - let mut stats = StepStats::default(); - if dt > 0.0 - && (t_len > dist_thresh - || (params.use_reset_teleport && rot_angle > params.teleport_rotation)) + let reset_time = state.pending_reset.filter(|_| dt > 0.0); + let resetting = reset_time.is_some(); + // Reject malformed reset inputs before changing any state or consuming the + // request. Initial/automatic seed behavior outside explicit reset is kept. + if resetting + && (input.anim.iter().flatten().any(|v| !v.is_finite()) + || input.anchor_rot.iter().any(|v| !v.is_finite())) { + return Err("cloth solver: 显式重置的动画输入非有限".to_string()); + } + if let Some(recovery_time) = reset_time { seed(state, input.anim, input.anchor_rot); - state.teleport_resets += 1; - stats.teleport_reset = true; - return Ok(stats); + // A topology's anchor need not occupy the first serialized vertex. + state.anchor_prev_pos = input.anim[topo.anchor as usize]; + state.velocity_recovery_time = recovery_time; + state.velocity_weight = if recovery_time > 1e-6 { 0.0 } else { 1.0 }; + // Native preprocess sets nowTime = updateDeltaTime before UpdateTime + // adds this frame's delta. This primes one update, clears old residual + // time, and keeps the existing maximum-substep cap. + state.accumulator = SIM_DT; + } else if !state.inited { + seed(state, input.anim, input.anchor_rot); + return Ok(StepStats::default()); + } else if state.velocity_weight < 1.0 { + // One selected frame delta, even when this frame contains many (or no) + // substeps. Do not recover on the frame that consumes a positive reset. + let increment = if state.velocity_recovery_time <= 1e-6 { + 1.0 + } else { + dt / state.velocity_recovery_time + }; + state.velocity_weight = (state.velocity_weight + increment).clamp(0.0, 1.0); } + let mut stats = StepStats::default(); - // 截流:maxMoveSpeed / maxRotationSpeed 是「每秒」量,乘帧 dt。 - let cap_t = params.max_move_speed * dt; - let felt_t = if t_len > cap_t && t_len > 0.0 { - mul(trans, cap_t / t_len) - } else { - trans - }; - let cap_r = params.max_rotation_speed * dt; - let felt_q = if rot_angle > cap_r && rot_angle > 1e-9 { - math::quat_slerp(math::quat_identity(), dq, cap_r / rot_angle) - } else { - dq - }; - for i in 0..topo.n { - if topo.fixed_bits[i] { - continue; + // Mode 0 clears world-movement history; its consumption frame must not + // reapply yesterday's anchor delta or enter automatic-teleport handling. + if !resetting { + // ---- 世界运动惯性(demo step() 的 inertia 块;数据结构 = + // TeamData.WorldInfluence 的 now/old position/rotation)---- + let a = topo.anchor as usize; + let a1 = input.anim[a]; + let a0 = state.anchor_prev_pos; + let dq = math::quat_normalize(mul_quat( + input.anchor_rot, + math::quat_conjugate(state.anchor_prev_rot), + )); + let trans = sub(a1, a0); + let t_len = math::length(trans); + let rot_angle = 2.0 * dq[3].abs().clamp(0.0, 1.0).acos(); + + // teleport 复位:useResetTeleport 链用数据阈值;其余 1.0m 兜底。 + let dist_thresh = if params.use_reset_teleport { + params.teleport_distance + } else { + FALLBACK_TELEPORT_DISTANCE + }; + if dt > 0.0 + && (t_len > dist_thresh + || (params.use_reset_teleport && rot_angle > params.teleport_rotation)) + { + seed(state, input.anim, input.anchor_rot); + state.teleport_resets += 1; + stats.teleport_reset = true; + return Ok(stats); } - let mi = params.move_influence[i].clamp(0.0, 1.0); - let ri = params.rotation_influence[i].clamp(0.0, 1.0); - let rel = sub(state.pos[i], a0); - // fullRigid = ΔR·(p-a0) - (p-a0) + trans:骨架刚性带动的那份 - let rigid_disp = { - let r = math::quat_rotate(dq, rel); - add(sub(r, rel), trans) + + // 截流:maxMoveSpeed / maxRotationSpeed 是「每秒」量,乘帧 dt。 + let cap_t = params.max_move_speed * dt; + let felt_t = if t_len > cap_t && t_len > 0.0 { + mul(trans, cap_t / t_len) + } else { + trans + }; + let cap_r = params.max_rotation_speed * dt; + let felt_q = if rot_angle > cap_r && rot_angle > 1e-9 { + math::quat_slerp(math::quat_identity(), dq, cap_r / rot_angle) + } else { + dq }; - // felt = 截流平移·mi + 截流旋转·ri:粒子「感到」的那份 - let felt_rot = sub(math::quat_rotate(felt_q, rel), rel); - let shift = sub(rigid_disp, add(mul(felt_t, mi), mul(felt_rot, ri))); - state.pos[i] = add(state.pos[i], shift); - state.prev[i] = add(state.prev[i], shift); + for i in 0..topo.n { + if topo.fixed_bits[i] { + continue; + } + let mi = params.move_influence[i].clamp(0.0, 1.0); + let ri = params.rotation_influence[i].clamp(0.0, 1.0); + let rel = sub(state.pos[i], a0); + // fullRigid = ΔR·(p-a0) - (p-a0) + trans:骨架刚性带动的那份 + let rigid_disp = { + let r = math::quat_rotate(dq, rel); + add(sub(r, rel), trans) + }; + // felt = 截流平移·mi + 截流旋转·ri:粒子「感到」的那份 + let felt_rot = sub(math::quat_rotate(felt_q, rel), rel); + let shift = sub(rigid_disp, add(mul(felt_t, mi), mul(felt_rot, ri))); + state.pos[i] = add(state.pos[i], shift); + state.prev[i] = add(state.prev[i], shift); + } + state.anchor_prev_pos = a1; + state.anchor_prev_rot = input.anchor_rot; } - state.anchor_prev_pos = a1; - state.anchor_prev_rot = input.anchor_rot; // ---- 定步长子步 ---- let dt = dt.clamp(0.0, MAX_FRAME_DT); @@ -517,6 +578,11 @@ pub fn advance_with_scratch( for _ in 0..n_sub { substep(state, topo, params, input, scratch); } + if resetting && n_sub > 0 { + // PostProcessTeamData clears reset-position only after an update. + state.pending_reset = None; + stats.requested_reset = true; + } // ---- NaN 守卫:任一非有限 → 整链复位并计数 ---- if state.pos.iter().any(|p| p.iter().any(|c| !c.is_finite())) { @@ -567,7 +633,15 @@ fn substep( continue; } let damp = (1.0 - params.drag[i]).max(0.0); - let mut v = mul(sub(state.pos[i], state.prev[i]), damp); + let saved_velocity = sub(state.pos[i], state.prev[i]); + // ForceAndVelocityJob weights stored velocity before drag; this step's + // gravity/external acceleration below is deliberately not weighted. + // Keep the old arithmetic exactly when no stabilization is active. + let mut v = if state.velocity_weight < 1.0 { + mul(mul(saved_velocity, state.velocity_weight), damp) + } else { + mul(saved_velocity, damp) + }; let limit = params.max_velocity[i] * dt; let sp = math::length(v); if sp > limit && sp > 0.0 { @@ -598,6 +672,26 @@ fn substep( // 回拉(restore):demo 形——每子步一次、迭代环外、向动画方向 // 按 power 旋转。见模块头「已知形差」。 restore_rotation(state, topo, input.anim); + + // FixPositionJob weights the saved post-constraint velocity a second time. + // In this solver prev still contains the pre-integration position x0, so + // prev = x1 - weight * (x1 - x0) stores that weighted displacement. Include + // the existing demo-form restore correction without moving its solver slot. + // At weight 1 leave prev untouched to preserve the old trajectory bitwise. + if state.velocity_weight < 1.0 { + for i in 0..topo.n { + if !topo.fixed_bits[i] { + state.prev[i] = if state.velocity_weight == 0.0 { + state.pos[i] + } else { + sub( + state.pos[i], + mul(sub(state.pos[i], state.prev[i]), state.velocity_weight), + ) + }; + } + } + } } fn project_collision( @@ -891,6 +985,136 @@ mod tests { advance(st, topo, ev, &input, dt).expect("advance") } + // ResetParticleJob copies the current snapBase into current/old positions + // and zeroes stored velocity. PreProcessTeamData resets old root pose to + // current root pose. With no force/correction, the numeric result is the + // supplied snapshot irrespective of the previous simulated history. + #[test] + fn source_mode0_reset_snapshot_and_anchor() { + let (mut def, mut p) = minimal_chain(); + def.parent = vec![1, -1]; + def.root = vec![1, -1]; + def.selection = vec![Selection::Move, Selection::Fixed]; + strip_constraints(&mut def); + disable_struct_spring(&mut p); + let old_anim = [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]; + let (topo, ev, mut st) = setup(&def, &p, &old_anim); + st.pos[0] = [30.0, 2.0, 0.0]; + st.prev[0] = [-20.0, -1.0, 0.0]; + st.anchor_prev_pos = [-10.0, 0.0, 0.0]; + st.anchor_prev_rot = [0.0, 1.0, 0.0, 0.0]; + st.accumulator = DT * 0.8; + st.request_reset_mode0(0.1).unwrap(); + let snapshot = [[2.0, 4.0, 0.0], [2.0, 3.0, 0.0]]; + let q = [0.0, 0.0, 0.5f32.sqrt(), 0.5f32.sqrt()]; + let s = frame(&mut st, &topo, &ev, &snapshot, q, DT * 0.25); + // ResetParticleJob: current=old=snapBase, saved velocity=0. + assert_eq!(st.pos, snapshot); + assert_eq!(st.prev, snapshot); + // PreProcessTeamData: oldPosition=nowPosition, oldRotation=nowRotation. + assert_eq!(st.anchor_prev_pos, snapshot[1]); + assert_eq!(st.anchor_prev_rot, q); + // PreProcessTeamData and UpdateTime: h + h/4 -> one update, h/4 left. + assert_eq!(s.substeps, 1); + assert!((st.accumulator - DT * 0.25).abs() < 1e-8); + } + + // PreProcessTeamData: reset overrides recovered weight to 0 when T>1e-6; + // later frames add frame_delta/T once and clamp01, independent of substeps. + #[test] + fn source_velocity_recovery_uses_frame_clock() { + let (mut def, mut p) = minimal_chain(); + strip_constraints(&mut def); + disable_struct_spring(&mut p); + let anim = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + let (topo, ev, mut st) = setup(&def, &p, &anim); + st.request_reset_mode0(0.1).unwrap(); + let s = frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT); + // Reset preprocess primes h before UpdateTime adds h: 2 updates. + assert_eq!(s.substeps, 2); + assert_eq!(st.velocity_weight, 0.0); + for expected in [0.4, 0.8, 1.0] { + frame(&mut st, &topo, &ev, &anim, math::quat_identity(), 0.04); + // clamp01(previous + 0.04/0.1). + assert!((st.velocity_weight - expected).abs() < 1e-6); + } + st.request_reset_mode0(0.1).unwrap(); + frame(&mut st, &topo, &ev, &anim, math::quat_identity(), 0.001); + // ResetStabilizationTime restarts the same native reset branch. + assert_eq!(st.velocity_weight, 0.0); + let s = frame(&mut st, &topo, &ev, &anim, math::quat_identity(), 0.001); + // 0.002 0.000001 comparison. + #[test] + fn source_reset_stabilization_threshold() { + let (def, p) = minimal_chain(); + let anim = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + let (topo, ev, mut st) = setup(&def, &p, &anim); + for (time, expected) in [(0.0, 1.0), (1e-6, 1.0), (1.0001e-6, 0.0)] { + st.request_reset_mode0(time).unwrap(); + frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT * 0.1); + assert_eq!(st.velocity_weight, expected); + } + } + + // ForceAndVelocityJob: dx = saved_v*w*h + a*h*h. FixPositionJob saves + // ((x1-x0)/h)*w. With w=0 and a*h*h=1, each step moves 1 and saves 0. + #[test] + fn source_zero_weight_keeps_acceleration_unweighted() { + let (mut def, mut p) = minimal_chain(); + strip_constraints(&mut def); + disable_struct_spring(&mut p); + p.use_gravity = true; + p.gravity = BezierParam::constant(1.0 / (DT * DT)); + p.gravity_direction = [0.0, 1.0, 0.0]; + let anim = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + let (topo, ev, mut st) = setup(&def, &p, &anim); + st.request_reset_mode0(0.1).unwrap(); + let s = frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT); + // PreProcessTeamData primes h and holds weight=0 for both updates. + assert_eq!(s.substeps, 2); + assert_eq!(st.velocity_weight, 0.0); + // Two ForceAndVelocityJob predictions add 1+1; each FixPositionJob + // multiplies the resulting stored velocity by zero. + assert!((st.pos[1][1] - 2.0).abs() < 1e-6); + assert_eq!(st.prev[1], st.pos[1]); + } + + // ForceAndVelocityJob then FixPositionJob, drag=0 and no correction: + // normalize d=saved_v*h, a*h*h=1; dx=d*w+1, x'=x+dx, d'=dx*w. + // Starting x=d=0, w=[0.5,0.5,0.25] gives the table below. + #[test] + fn source_velocity_weight_at_force_and_fix_sites() { + let (mut def, mut p) = minimal_chain(); + strip_constraints(&mut def); + disable_struct_spring(&mut p); + p.use_gravity = true; + p.gravity = BezierParam::constant(1.0 / (DT * DT)); + p.gravity_direction = [0.0, 1.0, 0.0]; + let anim = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + let (topo, ev, mut st) = setup(&def, &p, &anim); + let input = FrameInput { + anim: &anim, + anchor_rot: math::quat_identity(), + colliders: &[], + }; + let mut scratch = SolverScratch::default(); + for (weight, position, saved_displacement) in [ + (0.5, 1.0, 0.5), + (0.5, 2.25, 0.625), + (0.25, 3.40625, 0.2890625), + ] { + st.velocity_weight = weight; + substep(&mut st, &topo, &ev, &input, &mut scratch); + assert!((st.pos[1][1] - position).abs() < 1e-6); + assert!((st.pos[1][1] - st.prev[1][1] - saved_displacement).abs() < 1e-6); + } + } + /// 首帧播种:不积分,位置=动画;次帧静止全零。 #[test] fn first_frame_seeds() { @@ -921,7 +1145,11 @@ mod tests { let (topo, ev, mut st) = setup(&def, &p, &anim); frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT); let expected = -90.0 * DT * DT; - assert!((st.pos[1][1] - expected).abs() < 1e-7, "got {}", st.pos[1][1]); + assert!( + (st.pos[1][1] - expected).abs() < 1e-7, + "got {}", + st.pos[1][1] + ); } /// Verlet 阻尼:v' = v·(1-drag),drag=0.25 → 0.75 倍。 @@ -955,7 +1183,11 @@ mod tests { st.pos[1] = [1.0, 0.0, 0.0]; frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT); let expected = 1.0 + 3.0 * DT; - assert!((st.pos[1][0] - expected).abs() < 1e-6, "got {}", st.pos[1][0]); + assert!( + (st.pos[1][0] - expected).abs() < 1e-6, + "got {}", + st.pos[1][0] + ); } /// fixed 每子步与迭代后都钉在动画位(带重力也钉)。 @@ -990,13 +1222,21 @@ mod tests { let (topo, ev, mut st) = setup(&def, &p, &anim); frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT); let expected = 1.1 + 0.9 * 1e-4; - assert!((st.pos[1][0] - expected).abs() < 1e-4, "got {}", st.pos[1][0]); + assert!( + (st.pos[1][0] - expected).abs() < 1e-4, + "got {}", + st.pos[1][0] + ); let anim2 = [[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]]; let (topo, ev, mut st) = setup(&def, &p, &anim2); frame(&mut st, &topo, &ev, &anim2, math::quat_identity(), DT); let e = 0.2 * 1e-4; - assert!((st.pos[1][0] - (0.7 - e)).abs() < 1e-4, "got {}", st.pos[1][0]); + assert!( + (st.pos[1][0] - (0.7 - e)).abs() < 1e-4, + "got {}", + st.pos[1][0] + ); } /// 结构距离(RestoreDistanceJob 形):单条目 (1→0),质量分摊 @@ -1020,7 +1260,11 @@ mod tests { frame(&mut st, &topo, &ev, &anim, math::quat_identity(), DT); let w: f32 = (5.0 + 5.0 * 0.3) / (5.0 * 0.3 + 1.0 + 5.0 + 5.0 * 0.3); let expected: f32 = 1.0 + (2.0 - 1.0) * (1.0 - w).powi(4); - assert!((st.pos[1][0] - expected).abs() < 1e-4, "got {}", st.pos[1][0]); + assert!( + (st.pos[1][0] - expected).abs() < 1e-4, + "got {}", + st.pos[1][0] + ); } /// 限角(CompositeRotation clamp 半):30° 拉回限值 20°,长度保持。 @@ -1186,7 +1430,14 @@ mod tests { disable_struct_spring(&mut p); let anim = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; let (topo, ev, mut st) = setup(&def, &p, &anim); - let s = frame(&mut st, &topo, &ev, &anim, math::quat_identity(), 1.0 / 30.0); + let s = frame( + &mut st, + &topo, + &ev, + &anim, + math::quat_identity(), + 1.0 / 30.0, + ); assert_eq!(s.substeps, 3); assert!(st.accumulator.abs() < 1e-6, "残量 {}", st.accumulator); diff --git a/crates/base/moly-law/src/fixture/mod.rs b/crates/base/moly-law/src/fixture/mod.rs index 0d4fd4a2..e388c026 100644 --- a/crates/base/moly-law/src/fixture/mod.rs +++ b/crates/base/moly-law/src/fixture/mod.rs @@ -17,6 +17,7 @@ pub mod mip; pub mod outline; pub mod position; pub mod road; +pub mod road_edit; /// 朝向。源枚举把 Front 排在 0,旋转与换算都按这个取值分支; /// 越界值在源头是字节透传(switch default),这里收窄成闭集。 @@ -109,9 +110,5 @@ pub(crate) fn floor_half(n: i32) -> i32 { /// 负数夹 0。源写成 `(n & ~(n >> 31))` 一类的位式,语义就是 max(n, 0)。 pub(crate) fn max0(n: i32) -> i32 { - if n < 0 { - 0 - } else { - n - } + if n < 0 { 0 } else { n } } diff --git a/crates/base/moly-law/src/fixture/road_edit.rs b/crates/base/moly-law/src/fixture/road_edit.rs new file mode 100644 index 00000000..615f02a1 --- /dev/null +++ b/crates/base/moly-law/src/fixture/road_edit.rs @@ -0,0 +1,238 @@ +//! RoadEditPlanningState's grid planning arithmetic, in source Unity axes. +//! Selection and rendering stay with their owning hosts; this module does not +//! invent collider hits, user inventory or fixture costs. + +use super::GridPosition; + +fn checked_abs(value: i8) -> Result { + value + .checked_abs() + .map(i32::from) + .ok_or("Road planning signed-byte Abs overflow") +} + +/// SimulateHorizontal includes the endpoint, SimulateVertical excludes it. +/// Each road advances two grid units, independent of its master grid size. +fn axis( + first: GridPosition, + delta: GridPosition, + horizontal: bool, +) -> Result, &'static str> { + let value = if horizontal { delta.x } else { delta.z }; + let length = checked_abs(value)?; + let mut result = Vec::new(); + let mut step = 0; + while if horizontal { + step <= length + } else { + step < length + } { + let signed = if value > 0 { step } else { -step }; + result.push( + first + + if horizontal { + GridPosition::new(signed as i8, 0, 0) + } else { + GridPosition::new(0, 0, signed as i8) + }, + ); + step += 2; + } + Ok(result) +} + +/// SimulateTapPutAsync uses the dominant axis; ties choose horizontal. +pub fn tap_candidates( + first: GridPosition, + last: GridPosition, +) -> Result, &'static str> { + let delta = last - first; + axis(first, delta, checked_abs(delta.x)? >= checked_abs(delta.z)?) +} + +/// UpdateDragTileList: returning inside the first 2x2 bound collapses the +/// history; backtracking removes samples further from the first point. +pub fn update_drag_history(history: &mut Vec, next: GridPosition) { + let Some(&first) = history.first() else { + return; + }; + if (0..2).contains(&(i16::from(next.x) - i16::from(first.x))) + && (0..2).contains(&(i16::from(next.z) - i16::from(first.z))) + { + history.clear(); + history.push(first); + return; + } + let length = |point: GridPosition| { + let delta = point - first; + let x = f32::from(delta.x); + let y = f32::from(delta.y); + let z = f32::from(delta.z); + (x * x + y * y + z * z).sqrt() + }; + if history.len() >= 2 { + let distance = length(next); + history.retain(|&point| length(point) <= distance); + } + if history.last() != Some(&next) && !history.contains(&next) { + history.push(next); + } +} + +/// SimulateDragPutAsync sums the first five samples to choose the bend axis. +/// It then draws the two orthogonal legs, with the turning coordinate rounded +/// toward zero to an even grid displacement by the source's byte expression. +pub fn drag_candidates(history: &[GridPosition]) -> Result, &'static str> { + let Some(&first) = history.first() else { + return Ok(Vec::new()); + }; + let last = *history.last().expect("history has a first"); + let delta = last - first; + let accumulated = history + .iter() + .take(5) + .fold(GridPosition::ZERO, |sum, &point| sum + (point - first)); + let horizontal_first = checked_abs(accumulated.x)? >= checked_abs(accumulated.z)?; + let mut result = axis(first, delta, horizontal_first)?; + let even = |n: i8| ((i32::from(n) / 2) * 2) as i8; + let bend = first + + if horizontal_first { + GridPosition::new(even(delta.x), 0, 0) + } else { + GridPosition::new(0, 0, even(delta.z)) + }; + result.extend(axis(bend, delta, !horizontal_first)?); + Ok(result) +} + +/// Source GetSelectTileOnField after a verified RaycastTargetGroup hit has +/// been converted to site-local source coordinates. Y is clamped above zero +/// by FieldPosition.ToGridPosition; invalid byte-domain input is refused. +pub fn field_hit_grid(point: [f32; 3], tile: [f32; 3]) -> Option { + raw_field_hit_grid( + [point[0] - tile[0] * 0.5, point[1], point[2] - tile[2] * 0.5], + tile, + ) +} + +/// SiteLayoutUtility.GetTouchedTile, used by selection rectangles, omits +/// the road planner's half-tile offset. +pub fn raw_field_hit_grid(point: [f32; 3], tile: [f32; 3]) -> Option { + if point.iter().any(|v| !v.is_finite()) || tile.iter().any(|v| !v.is_finite() || *v <= 0.) { + return None; + } + let v = [ + (point[0] / tile[0]).floor(), + (point[1].max(0.) / tile[1]).floor(), + (point[2] / tile[2]).floor(), + ]; + if v.iter().any(|n| *n < i8::MIN as f32 || *n > i8::MAX as f32) { + return None; + } + Some(GridPosition::new(v[0] as i8, v[1] as i8, v[2] as i8)) +} + +/// Contiguous source placement-effect segments. The next axis change starts +/// a fresh segment; the preceding segment retains its own size and mean. +pub fn effect_segments( + rows: &[(GridPosition, super::Vector3Int)], +) -> Vec<(std::ops::Range, super::Vector3Int)> { + let mut output = Vec::new(); + let mut start = 0; + while start < rows.len() { + let mut direction = 0; + let mut size = rows[start].1; + let mut end = start + 1; + while end < rows.len() { + let (previous, current) = (rows[end - 1].0, rows[end].0); + let x = previous.x != current.x; + let z = previous.z != current.z; + let next = match (x, z) { + (true, true) => 3 - direction, + (true, false) => 1, + (false, true) => 2, + (false, false) => 3, + }; + if next == 3 || direction != 0 && direction != next { + break; + } + if next == 1 { + size.x += (i32::from(current.x) - i32::from(previous.x)).abs(); + } + if next == 2 { + size.z += (i32::from(current.z) - i32::from(previous.z)).abs(); + } + direction = next; + end += 1; + } + output.push((start..end, size)); + start = end; + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + fn p(x: i8, z: i8) -> GridPosition { + GridPosition::new(x, 0, z) + } + + #[test] + fn source_tap_axis_endpoint_asymmetry() { + assert_eq!( + tap_candidates(p(0, 0), p(4, 1)).unwrap(), + vec![p(0, 0), p(2, 0), p(4, 0)] + ); + assert_eq!( + tap_candidates(p(0, 0), p(1, 4)).unwrap(), + vec![p(0, 0), p(0, 2)] + ); + assert_eq!( + tap_candidates(p(0, 0), p(-3, 1)).unwrap(), + vec![p(0, 0), p(-2, 0)] + ); + } + + #[test] + fn drag_bend_follows_early_samples_and_signed_even_step() { + assert_eq!( + drag_candidates(&[p(0, 0), p(2, 0), p(4, 4)]).unwrap(), + vec![p(0, 0), p(2, 0), p(4, 0), p(4, 0), p(4, 2)] + ); + assert_eq!( + drag_candidates(&[p(0, 0), p(0, 2), p(-3, 4)]).unwrap(), + vec![p(0, 0), p(0, 2), p(0, 4), p(-2, 4)] + ); + // The repeated bend is rejected by CanPutRoad's planning-grid pass. + } + + #[test] + fn backtracking_and_field_hit_preserve_source_grid() { + let mut history = vec![p(0, 0), p(2, 0), p(6, 0)]; + update_drag_history(&mut history, p(3, 0)); + assert_eq!(history, vec![p(0, 0), p(2, 0), p(3, 0)]); + update_drag_history(&mut history, p(1, 1)); + assert_eq!(history, vec![p(0, 0)]); + assert_eq!(field_hit_grid([0., -0.01, 0.5], [0.25; 3]), Some(p(-1, 1))); + } + #[test] + fn effect_segments_split_turns_without_inflating_the_empty_corner() { + let size = super::super::Vector3Int::new(2, 1, 2); + let rows = [ + (p(0, 0), size), + (p(2, 0), size), + (p(2, 2), size), + (p(2, 6), size), + ]; + let segments = effect_segments(&rows); + assert_eq!( + segments, + vec![ + (0..2, super::super::Vector3Int::new(4, 1, 2)), + (2..4, super::super::Vector3Int::new(2, 1, 6)) + ] + ); + assert_eq!(raw_field_hit_grid([0., 0., 0.5], [0.25; 3]), Some(p(0, 2))); + } +} diff --git a/crates/base/moly-law/src/lib.rs b/crates/base/moly-law/src/lib.rs index da9e6c2f..8f2c2eb0 100644 --- a/crates/base/moly-law/src/lib.rs +++ b/crates/base/moly-law/src/lib.rs @@ -4,6 +4,7 @@ //! 移植时跑一次。 pub mod alone_action; +pub mod bulk_harvest; pub mod carve; pub mod cloth; pub mod fixture; 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/shading/avatar.rs b/crates/base/moly-law/src/shading/avatar.rs index 6fc4ebae..8cdba71b 100644 --- a/crates/base/moly-law/src/shading/avatar.rs +++ b/crates/base/moly-law/src/shading/avatar.rs @@ -146,12 +146,12 @@ pub fn alpha_gain(albedo: [f32; 3], alpha: f32) -> [f32; 3] { pub fn slot_albedo(input: &AvatarFragmentInputs, material: &AvatarMaterial) -> [f32; 3] { match input.part_index { SLOT_SKIN => { - // skin:tex.a 把 tex.rgb 往 _SkinColor.rgb 混,再过 alpha 增益。 + // Skin alpha blends the sample toward its multiplicative tint. let s = input.skin_rgba; let mut base = [0.0; 3]; for c in 0..3 { - // s.a * (skin_color - s.rgb) + s.rgb - base[c] = s[3] * (material.skin_color[c] - s[c]) + s[c]; + // s.a * (skin_color * s.rgb - s.rgb) + s.rgb + base[c] = s[3] * (material.skin_color[c] * s[c] - s[c]) + s[c]; } alpha_gain(base, material.alpha) } @@ -305,18 +305,23 @@ mod tests { } #[test] - fn skin_slot_blends_tex_toward_skin_color_by_alpha() { - // skin 支:base = tex.a*(skin_color-tex.rgb)+tex.rgb,再过 alpha 增益 + fn skin_slot_blends_tex_toward_multiplicative_tint_by_alpha() { + // skin 支:base = tex.a*(skin_color*tex.rgb-tex.rgb)+tex.rgb,再过 alpha 增益 // (_Alpha=1 ⇒ 增益 0 ⇒ 输出即 base;_Alpha=0 才整支白)。这里钉调色 // 混合的中间值:令 _Alpha=1(增益 0)看清 base。 let mut m = default_material(); m.alpha = 1.0; // 增益 0 ⇒ 输出 = base(看清混合) let input = skin_input(SLOT_SKIN, [0.4, 0.2, 0.0, 0.5]); let out = slot_albedo(&input, &m); - // base = 0.5*(1-tex)+tex。 - close(out[0], 0.7); // 0.5*0.6+0.4 = 0.7 - close(out[1], 0.6); // 0.5*0.8+0.2 = 0.6 - close(out[2], 0.5); // 0.5*1.0+0.0 = 0.5 + // White tint preserves the texture at every mask alpha. + close(out[0], 0.4); + close(out[1], 0.2); + close(out[2], 0.0); + m.skin_color = [0.5, 0.75, 1.0, 1.0]; + let out = slot_albedo(&input, &m); + close(out[0], 0.3); + close(out[1], 0.175); + close(out[2], 0.0); } #[test] @@ -377,15 +382,15 @@ mod tests { let mut g = globals(); // light w=0 ⇒ lit = 0*(base*l - base) + base = base。 g.light_color = [0.5, 0.5, 0.5, 0.0]; - let input = skin_input(SLOT_SKIN, [0.4, 0.4, 0.4, 1.0]); // a=1 ⇒ base=skin_color=白 + let input = skin_input(SLOT_SKIN, [0.4, 0.4, 0.4, 1.0]); // white tint preserves 0.4 let (target0, target1) = avatar_fragment(&input, &g, &m); - close(target0[0], 1.0); // base=白,w=0 不暗化 + close(target0[0], 0.4); // w=0 leaves the sample unchanged close(target0[3], 1.0); assert_eq!(target1, [0.0, 0.0, 0.0, 0.0]); - // w=1、光色 0.5:lit = 1*(1*0.5 - 1) + 1 = 0.5。 + // w=1 and light=0.5: lit = 1*(0.4*0.5 - 0.4) + 0.4 = 0.2. g.light_color = [0.5, 0.5, 0.5, 1.0]; let (target0, _) = avatar_fragment(&input, &g, &m); - close(target0[0], 0.5); + close(target0[0], 0.2); } #[test] diff --git a/crates/base/moly-law/src/talk/select.rs b/crates/base/moly-law/src/talk/select.rs index 7117952b..57444d4b 100644 --- a/crates/base/moly-law/src/talk/select.rs +++ b/crates/base/moly-law/src/talk/select.rs @@ -341,12 +341,15 @@ pub enum GeneralPick { }, /// No talk was kept: the character's own previous talk id, no draw. Previous { talk_id: i32 }, + /// Neither an eligible row nor a previous talk exists. No random draw. + Empty, } impl GeneralPick { pub fn talk_id(&self) -> i32 { match *self { GeneralPick::Drawn { talk_id, .. } | GeneralPick::Previous { talk_id } => talk_id, + GeneralPick::Empty => 0, } } } @@ -375,6 +378,15 @@ pub fn lottery_general_talk_id( }) } +/// JP 7's empty general-talk branch returns 0 without an integer draw. +/// The legacy entry point above retains its previous/index-error behavior. +pub fn lottery_general_talk_id_jp7( + pool: &[i32], previous: i32, rand: &mut impl UniformDraw, +) -> Result { + if pool.is_empty() && previous == 0 { return Ok(GeneralPick::Empty); } + lottery_general_talk_id(pool, previous, rand) +} + /// `NPCLottery*` 百分比族(服务端下发,mock 可改)。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct LotteryPercents { @@ -466,6 +478,19 @@ pub fn select_fixture_lane( mod tests { use super::*; + #[test] + fn general_empty_returns_zero_without_consuming_random() { + struct NoDraw; + impl UniformDraw for NoDraw { + fn draw(&mut self, _: usize) -> usize { panic!("empty pool must not draw") } + } + let empty = lottery_general_talk_id_jp7(&[], 0, &mut NoDraw).unwrap(); + assert_eq!(empty, GeneralPick::Empty); + assert_eq!(empty.talk_id(), 0); + assert_eq!(lottery_general_talk_id(&[], 41, &mut NoDraw).unwrap(), GeneralPick::Previous { talk_id: 41 }); + assert_eq!(lottery_general_talk_id(&[], 0, &mut NoDraw), Err(LotteryFault::GeneralPoolEmpty)); + } + /// 权重抽签替身:返回固定比例的落点并数调用——「恰一次求值」的量法。 struct FixedWeighted { fraction: f32, 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-law/src/ui/mod.rs b/crates/base/moly-law/src/ui/mod.rs index 1294319c..0a485ac9 100644 --- a/crates/base/moly-law/src/ui/mod.rs +++ b/crates/base/moly-law/src/ui/mod.rs @@ -16,4 +16,5 @@ pub mod image; pub mod mysekai_rank; pub mod raycast; pub mod screen_ray; +pub mod scrollbar; pub mod unity_math; diff --git a/crates/base/moly-law/src/ui/scrollbar.rs b/crates/base/moly-law/src/ui/scrollbar.rs new file mode 100644 index 00000000..e1a4a088 --- /dev/null +++ b/crates/base/moly-law/src/ui/scrollbar.rs @@ -0,0 +1,108 @@ +//! ScrollRect scrollbar geometry from UGUI: visibility, thumb size and anchors. +//! The values are content/view bounds in the same local units. Hosts retain +//! pointer ownership and provide the source scrollbar's direction. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + LeftToRight, + RightToLeft, + BottomToTop, + TopToBottom, +} + +impl Direction { + pub fn from_source(value: i64) -> Option { + match value { + 0 => Some(Self::LeftToRight), + 1 => Some(Self::RightToLeft), + 2 => Some(Self::BottomToTop), + 3 => Some(Self::TopToBottom), + _ => None, + } + } + + pub fn axis(self) -> usize { + match self { + Self::LeftToRight | Self::RightToLeft => 0, + Self::BottomToTop | Self::TopToBottom => 1, + } + } + + pub fn reversed(self) -> bool { + matches!(self, Self::RightToLeft | Self::TopToBottom) + } +} + +/// ScrollRect.get_vScrollingNeeded while playing (also used for horizontal). +pub fn scrolling_needed(content_extent: f32, view_extent: f32) -> bool { + content_extent > view_extent + 0.01 +} + +/// ScrollRect.UpdateScrollbars; overscroll shrinks the thumb at either edge. +pub fn thumb_size(content_extent: f32, view_extent: f32, offset: f32) -> f32 { + if content_extent <= 0.0 { + 1.0 + } else { + ((view_extent - offset.abs()) / content_extent).clamp(0.0, 1.0) + } +} + +/// Scrollbar.UpdateVisuals writes only the handle anchors. The source handle +/// sizeDelta, anchoredPosition and pivot must remain unchanged. +pub fn handle_anchors(direction: Direction, value: f32, size: f32) -> ([f32; 2], [f32; 2]) { + let mut min = [0.0; 2]; + let mut max = [1.0; 2]; + let movement = (1.0 - size) * value.clamp(0.0, 1.0); + let axis = direction.axis(); + if direction.reversed() { + max[axis] = 1.0 - movement; + min[axis] = max[axis] - size; + } else { + min[axis] = movement; + max[axis] = size + movement; + } + (min, max) +} + +/// Scrollbar.UpdateDrag / DoUpdateDrag, after the pointer is converted to the +/// container's local axis. `handle_corner` already subtracts the captured grab +/// offset and half of the handle's anchored span (excluding sizeDelta). +pub fn drag_value(direction: Direction, handle_corner: f32, remaining_size: f32) -> Option { + if remaining_size <= 0.0 { + return None; + } + let ratio = handle_corner / remaining_size; + Some(if direction.reversed() { 1.0 - ratio } else { ratio }.clamp(0.0, 1.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fitted_content_hides_scrollbar_and_does_not_drag() { + assert!(!scrolling_needed(724.0, 773.0)); + assert_eq!(thumb_size(724.0, 773.0, 0.0), 1.0); + assert_eq!(drag_value(Direction::BottomToTop, 0.0, 0.0), None); + assert!(!scrolling_needed(100.01, 100.0)); + assert!(scrolling_needed(100.02, 100.0)); + } + + #[test] + fn overflowing_content_thumb_reaches_both_ends_and_middle() { + let size = thumb_size(800.0, 200.0, 0.0); + assert_eq!(size, 0.25); + assert_eq!(handle_anchors(Direction::BottomToTop, 1.0, size), ([0.0, 0.75], [1.0, 1.0])); + assert_eq!(handle_anchors(Direction::BottomToTop, 0.0, size), ([0.0, 0.0], [1.0, 0.25])); + assert_eq!(handle_anchors(Direction::BottomToTop, 0.5, size), ([0.0, 0.375], [1.0, 0.625])); + assert_eq!(thumb_size(800.0, 200.0, -40.0), 0.2); + } + + #[test] + fn drag_uses_thumb_travel_and_preserves_direction() { + assert_eq!(drag_value(Direction::BottomToTop, 75.0, 150.0), Some(0.5)); + assert_eq!(drag_value(Direction::BottomToTop, 200.0, 150.0), Some(1.0)); + assert_eq!(drag_value(Direction::TopToBottom, 30.0, 150.0), Some(0.8)); + assert_eq!(handle_anchors(Direction::RightToLeft, 1.0, 0.25), ([0.0, 0.0], [0.25, 1.0])); + } +} 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/json/master.rs b/crates/engine/moly-assets/src/json/master.rs index ca003fed..c2db6baf 100644 --- a/crates/engine/moly-assets/src/json/master.rs +++ b/crates/engine/moly-assets/src/json/master.rs @@ -10,8 +10,11 @@ //! known (`source.region` of the root's `source.json`), each requested table //! is read as the merged table ([`merge_table`]): the JP table, then the same //! table of every enabled non-JP region ([`ENABLED`]). Each is read from the master -//! bases in order ([`MasterBases`]), base by base and JP's table first, and -//! the root's region's upstream data version is logged once. A base whose +//! bases in order ([`MasterBases`]), base by base and JP's table first. A +//! producer `master` pin in `source.json` selects its verified repository +//! commit for that region. Its version document must agree before tables +//! load; moving mirrors are not fallbacks for a pin. Roots without a pin +//! keep the latest sources. The upstream data version is logged once. A base whose //! read timed out (no response headers within the reader's bound) is demoted //! for the session: every later table tries it after the others. //! @@ -44,6 +47,10 @@ use bevy::asset::{ use bevy::prelude::*; use serde_json::{Map, Value}; +mod source; + +use source::{parse_source, MasterSource}; + use super::JsonAsset; use crate::remote::{self, Region, ENABLED}; @@ -51,8 +58,7 @@ pub use super::master_merge::{ merge_table, merged_regions, table_key, Collision, Merged, TableText, REGION_FIELD, TABLE_KEYS, }; -/// The root's identity document; its `source.region` names the root's -/// region, whose master storage the root's assets are read from. +/// The root's identity document: its region and optional producer master pin. const SOURCE_DOCUMENT: &str = "moly://source.json"; /// One master table a consumer reads. @@ -102,22 +108,10 @@ pub struct MasterBase { pub host: remote::Remote, } -impl MasterBase { - /// A region's table, by its upstream name. - fn table(self, region: Region, table: &str) -> AssetPath<'static> { - remote::master_table(self.host, region, table) - } - - /// A region's `versions/current_version.json`. - fn version(self, region: Region) -> AssetPath<'static> { - remote::master_version(self.host, region) - } -} - /// The bases every table is read from, tried in order: a table is absent -/// only when each of them has failed. The first is each region's repository; -/// the master hosts follow as its mirror, and carry the `jp` and `cn` -/// directories only. The app may replace the list. +/// only when each admitted base has failed. The first is each region's +/// repository; moving master hosts follow as mirrors for roots without a pin, +/// and carry the `jp` and `cn` directories only. The app may replace the list. #[derive(Resource, Clone)] pub struct MasterBases(pub Vec); @@ -140,6 +134,30 @@ impl Default for MasterBases { } } +impl MasterBases { + /// The first admitted untried base, preserving timeout demotion. A pin + /// admits only the repository; neither failover nor demotion drops it. + fn next( + &self, + source: &MasterSource, + region: Region, + tried: &[usize], + demoted: &[usize], + ) -> Option { + let available = |index: &usize| { + !tried.contains(index) + && self + .0 + .get(*index) + .is_some_and(|base| source.admits(base.host, region)) + }; + (0..self.0.len()) + .filter(available) + .find(|index| !demoted.contains(index)) + .or_else(|| demoted.iter().copied().find(available)) + } +} + /// Tables a region's master hosts do not publish. A request for one could /// only fail on every base, so it resolves as absent, by name, without one; /// its consumer names what it reads instead. @@ -243,10 +261,9 @@ impl TableOutcome { /// take them, and the tables named missing. #[derive(Resource, Default)] pub struct MasterData { - /// The root's region: its master storage, and the region its upstream - /// data version is read for. Each table is the merged table of - /// [`ENABLED`] whatever the root's region is. - region: Document, + /// The root's region and optional immutable producer master pin. Each + /// table still merges the regions in [`ENABLED`]. + source: Document, upstream_version: Document, pending: Vec, resolved: HashMap>, @@ -392,8 +409,8 @@ impl MasterData { /// master storage the root's own assets are read from, and the region /// whose upstream data version is logged. pub fn region(&self) -> Option { - match &self.region { - Document::Read(Ok(region)) => Some(*region), + match &self.source { + Document::Read(Ok(source)) => Some(source.region), _ => None, } } @@ -516,28 +533,34 @@ pub fn resolve( return; } let data = &mut *data; - let region = match data.region.poll( + let source = match data.source.poll( &server, &jsons, || AssetPath::from(SOURCE_DOCUMENT), - parse_region, + parse_source, ) { None => return, - Some(Ok(region)) => *region, + Some(Ok(source)) => source.clone(), Some(Err(reason)) => { - let reason = format!("the root's region is unknown: source.json {reason}"); - for entry in std::mem::take(&mut data.pending) { - let error = entry.error(reason.clone()); - data.name_missing(&error, true); - data.resolved.insert(entry.key, Err(error)); - } + let reason = format!("the root's master source is invalid: source.json {reason}"); + data.refuse_pending(reason); return; } }; - MasterData::read_version(data, &server, &jsons, &bases, region); + let version = data.read_version(&server, &jsons, &bases, &source); + if source.pin.is_some() { + match version { + None => return, + Some(Err(reason)) => { + data.refuse_pending(format!("the pinned master version was refused: {reason}")); + return; + } + Some(Ok(())) => {} + } + } let mut waiting = Vec::new(); for mut entry in std::mem::take(&mut data.pending) { - match entry.advance(data, &server, &jsons, &bases) { + match entry.advance(data, &server, &jsons, &bases, &source) { Some(result) => { data.resolved.insert(entry.key, result); } @@ -548,46 +571,59 @@ pub fn resolve( } impl MasterData { - /// Logs the root's region's upstream data version once, from the first - /// base. The version file is where a server keeps it, so a base that does - /// not serve it is left out of the run of the tables. + fn refuse_pending(&mut self, reason: String) { + for entry in std::mem::take(&mut self.pending) { + let error = entry.error(reason.clone()); + self.name_missing(&error, true); + self.resolved.insert(entry.key, Err(error)); + } + } + + /// Reads and logs the version once. Legacy roots only log it; a pinned + /// root waits for a matching version before requesting any master table. fn read_version( - data: &mut MasterData, + &mut self, server: &AssetServer, jsons: &Assets, bases: &MasterBases, - region: Region, - ) { - // The base the version was requested from: the first one, until the - // request names it. - let index = match &data.upstream_version { - Document::Read(_) => return, - Document::Loading(handle) => bases - .0 - .iter() - .position(|base| handle.path() == Some(&base.version(region))) - .unwrap_or(0), - Document::Unrequested => 0, - }; - let Some(base) = bases.0.get(index) else { - return; + source: &MasterSource, + ) -> Option> { + if let Document::Read(result) = &self.upstream_version { + return Some(result.as_ref().map(|_| ()).map_err(Clone::clone)); + } + let Some((base, path)) = bases + .0 + .iter() + .find_map(|base| source.version(base.host).map(|path| (base, path))) + else { + let reason = "no admitted master base serves the source version".to_owned(); + warn!("[masters] {reason}"); + self.upstream_version = Document::Read(Err(reason.clone())); + return Some(Err(reason)); }; - match data - .upstream_version - .poll(server, jsons, || base.version(region), parse_version) - { - Some(Ok(version)) => info!( - "[masters] {}: upstream dataVersion {version} ({})", - region.directory(), + let result = self.upstream_version.poll( + server, + jsons, + || path, + |text| source.parse_version(text), + )?; + let identity = source.pin.as_ref().map_or_else( + || "latest".to_owned(), + |pin| format!("commit {}", pin.commit), + ); + match result { + Ok(version) => info!( + "[masters] {}: upstream dataVersion {version} ({}; {identity})", + source.region.directory(), base.name ), - Some(Err(reason)) => warn!( - "[masters] {}: the upstream dataVersion is missing: {}'s versions/current_version.json {reason}", - region.directory(), + Err(reason) => warn!( + "[masters] {}: the upstream version was refused ({}; {identity}): {reason}", + source.region.directory(), base.name ), - None => {} } + Some(result.as_ref().map(|_| ()).map_err(Clone::clone)) } } @@ -608,6 +644,7 @@ impl Pending { server: &AssetServer, jsons: &Assets, bases: &MasterBases, + source: &MasterSource, ) -> Option> { // JP's table is the table; every later region adds to it. if self.region == 0 { @@ -618,7 +655,7 @@ impl Pending { let Some(region) = self.regions.get(self.region).copied() else { return Some(self.finish(data)); }; - if unpublished(region, self.table).is_some() { + if !source.pinned(region) && unpublished(region, self.table).is_some() { self.outcomes.push(TableOutcome::Unpublished(region)); self.next_region(); return None; @@ -626,15 +663,11 @@ impl Pending { // The next base: the first untried one that is not demoted, else the // first untried demoted one. if self.base.is_none() { - let untried = |index: &usize| !self.tried.contains(index); - self.base = (0..bases.0.len()) - .filter(untried) - .find(|index| !data.demoted.contains(index)) - .or_else(|| data.demoted.iter().copied().find(untried)); + self.base = bases.next(source, region, &self.tried, &data.demoted); } let Some(index) = self.base else { let reason = if self.failures.is_empty() { - "no master base is configured".to_owned() + "no admitted master base serves this source".to_owned() } else { format!("no master base served it ({})", self.failures.join("; ")) }; @@ -649,18 +682,22 @@ impl Pending { return None; }; let base = bases.0[index]; - let handle = self - .handle - .get_or_insert_with(|| server.load(base.table(region, self.table))) - .clone(); + let Some(path) = source.table(base.host, region, self.table) else { + self.failures + .push(format!("{} is not admitted for this source", base.name)); + self.tried.push(index); + self.base = None; + self.handle = None; + return None; + }; + let handle = self.handle.get_or_insert_with(|| server.load(path)).clone(); if let LoadState::Failed(error) = server.load_state(&handle) { self.failures.push(format!("{}: {error}", base.name)); self.tried.push(index); self.base = None; self.handle = None; if timed_out(&error) { - let next = (0..bases.0.len()) - .find(|other| !self.tried.contains(other) && !data.demoted.contains(other)); + let next = bases.next(source, region, &self.tried, &data.demoted); data.demote(index, &base, self.table, next.map(|other| &bases.0[other])); } return None; @@ -830,31 +867,6 @@ fn timed_out(error: &AssetLoadError) -> bool { ) } -/// `source.region` of the root's identity document: one of the region -/// directories. -fn parse_region(text: &str) -> Result { - let value: Value = - serde_json::from_str(text).map_err(|error| format!("is not JSON ({error})"))?; - if value["version"].as_u64() != Some(1) { - return Err("has an unsupported or missing identity version".into()); - } - match value["source"]["region"].as_str() { - Some(name) => Region::from_directory(name) - .ok_or_else(|| format!("names the source region {name:?}, not a region directory")), - None => Err("names no source region".into()), - } -} - -/// `dataVersion` of an upstream `current_version.json`. -fn parse_version(text: &str) -> Result { - let value: Value = - serde_json::from_str(text).map_err(|error| format!("is not JSON ({error})"))?; - value["dataVersion"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| "has no dataVersion".to_owned()) -} - // --------------------------------------------------------------------------- // Row readers for consumer parsers // --------------------------------------------------------------------------- @@ -1334,3 +1346,72 @@ mod merge_tests { assert_eq!(data.unmerged().len(), 1); } } + +#[cfg(test)] +mod master_pin_tests { + use super::*; + + fn source(pin: bool) -> MasterSource { + let mut value = serde_json::json!({ + "version": 1, + "source": {"region": "jp", "assetVersion": "7", "assetHash": "assets"} + }); + if pin { + value["master"] = serde_json::json!({ + "region": "jp", "repository": "Team-Haruki/haruki-sekai-master", + "commit": "0123456789abcdef0123456789abcdef01234567", + "dataVersion": "7", "assetVersion": "7", "assetHash": "assets" + }); + } + parse_source(&value.to_string()).unwrap() + } + + #[test] + fn pinned_reads_do_not_fail_over_to_a_latest_mirror() { + let bases = MasterBases::default(); + let source = source(true); + assert_eq!(bases.next(&source, Region::Jp, &[], &[]), Some(0)); + // A previous timeout does not make a moving mirror an alternative. + assert_eq!(bases.next(&source, Region::Jp, &[], &[0]), Some(0)); + // Once the repository failed, the pinned table has no further base. + assert_eq!(bases.next(&source, Region::Jp, &[0], &[0]), None); + assert_eq!(bases.next(&source, Region::Jp, &[0], &[]), None); + } + + #[test] + fn latest_reads_keep_the_existing_failover_and_demotion_order() { + let bases = MasterBases::default(); + let source = source(false); + assert_eq!(bases.next(&source, Region::Jp, &[], &[]), Some(0)); + assert_eq!(bases.next(&source, Region::Jp, &[0], &[]), Some(1)); + assert_eq!(bases.next(&source, Region::Jp, &[], &[0]), Some(1)); + assert_eq!(bases.next(&source, Region::Jp, &[1, 2], &[0]), Some(0)); + assert_eq!(bases.next(&source, Region::Jp, &[0, 1, 2], &[0]), None); + } + + #[test] + fn a_version_refusal_reaches_every_waiting_consumer() { + const ONE: MasterTable> = MasterTable { + table: "first", + name: "first", + parse: rows, + }; + const TWO: MasterTable> = MasterTable { + table: "second", + name: "second", + parse: rows, + }; + let mut data = MasterData::default(); + data.request(&ONE); + data.request(&TWO); + data.refuse_pending("the pinned master version was refused".into()); + assert!(data.ready()); + assert_eq!(data.missing().len(), 2); + for table in [&ONE, &TWO] { + assert_eq!( + data.take(table).unwrap().unwrap_err().reason, + "the pinned master version was refused" + ); + } + } +} diff --git a/crates/engine/moly-assets/src/json/master/source.rs b/crates/engine/moly-assets/src/json/master/source.rs new file mode 100644 index 00000000..568833a6 --- /dev/null +++ b/crates/engine/moly-assets/src/json/master/source.rs @@ -0,0 +1,298 @@ +//! The asset root's master identity and the paths it permits. A complete +//! producer pin selects an immutable commit on the already admitted regional +//! repository; it cannot supply a new host or fall back to moving mirrors. + +use bevy::asset::AssetPath; +use serde_json::Value; + +use crate::remote::{self, Region, Remote}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct MasterSource { + pub(super) region: Region, + pub(super) pin: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct MasterPin { + pub(super) commit: String, + data_version: String, + asset_version: String, + asset_hash: String, +} + +impl MasterSource { + pub(super) fn pinned(&self, region: Region) -> bool { + self.pin.is_some() && self.region == region + } + + pub(super) fn admits(&self, host: Remote, region: Region) -> bool { + host.is_master() && (!self.pinned(region) || host == Remote::MasterRepository) + } + + pub(super) fn table( + &self, + host: Remote, + region: Region, + table: &str, + ) -> Option> { + if !self.admits(host, region) { + return None; + } + if region == self.region { + if let Some(pin) = &self.pin { + return Some(remote::master_table_at(region, &pin.commit, table)); + } + } + Some(remote::master_table(host, region, table)) + } + + pub(super) fn version(&self, host: Remote) -> Option> { + if !self.admits(host, self.region) { + return None; + } + Some(match &self.pin { + Some(pin) => remote::master_version_at(self.region, &pin.commit), + None => remote::master_version(host, self.region), + }) + } + + /// A pinned document must agree with both the producer's master identity + /// and its bundle identity before any master table may be used. + pub(super) fn parse_version(&self, text: &str) -> Result { + let value: Value = + serde_json::from_str(text).map_err(|error| format!("is not JSON ({error})"))?; + let version = required(&value, "dataVersion", "upstream version")?; + if let Some(pin) = &self.pin { + for (field, expected) in [ + ("dataVersion", pin.data_version.as_str()), + ("assetVersion", pin.asset_version.as_str()), + ("assetHash", pin.asset_hash.as_str()), + ] { + if value[field].as_str() != Some(expected) { + return Err(format!( + "commit {} has {field} {:?}, expected {expected:?} from source.json", + pin.commit, value[field] + )); + } + } + } + Ok(version.to_owned()) + } +} + +fn required<'a>(value: &'a Value, field: &str, owner: &str) -> Result<&'a str, String> { + value[field] + .as_str() + .filter(|text| !text.is_empty()) + .ok_or_else(|| format!("{owner}.{field} is missing or not a nonempty string")) +} + +/// A legacy root without master metadata keeps the moving regional sources. +/// Once a pin is declared, a partial, conflicting or foreign pin is refused. +pub(super) fn parse_source(text: &str) -> Result { + let value: Value = + serde_json::from_str(text).map_err(|error| format!("is not JSON ({error})"))?; + if value["version"].as_u64() != Some(1) { + return Err("has an unsupported or missing identity version".into()); + } + let name = required(&value["source"], "region", "source")?; + let region = Region::from_directory(name) + .ok_or_else(|| format!("names the source region {name:?}, not a region directory"))?; + let mut source = MasterSource { region, pin: None }; + let Some(master) = value.get("master").filter(|master| !master.is_null()) else { + return Ok(source); + }; + if master + .get("provider") + .is_some_and(|provider| provider.as_str() != Some("haruki")) + { + return Err("master.provider is not the admitted haruki repository provider".into()); + } + if required(master, "region", "master")? != name { + return Err("master.region differs from source.region".into()); + } + let repository = required(master, "repository", "master")?; + if repository != format!("Team-Haruki/{}", region.repository()) { + return Err(format!( + "master.repository {repository:?} is not the admitted {name} repository" + )); + } + let commit = required(master, "commit", "master")?; + if commit.len() != 40 + || !commit + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err("master.commit must be a full lowercase 40-digit Git commit".into()); + } + let data_version = required(master, "dataVersion", "master")?.to_owned(); + let asset_version = required(master, "assetVersion", "master")?.to_owned(); + let asset_hash = required(master, "assetHash", "master")?.to_owned(); + for (field, expected) in [("assetVersion", &asset_version), ("assetHash", &asset_hash)] { + if required(&value["source"], field, "source")? != expected.as_str() { + return Err(format!("master.{field} differs from source.{field}")); + } + } + source.pin = Some(MasterPin { + commit: commit.to_owned(), + data_version, + asset_version, + asset_hash, + }); + Ok(source) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn identity() -> Value { + json!({ + "version": 1, + "source": {"region": "jp", "assetVersion": "7.0.0.30", "assetHash": "assets"}, + "master": { + "provider": "haruki", "repository": "Team-Haruki/haruki-sekai-master", + "region": "jp", "commit": "0123456789abcdef0123456789abcdef01234567", + "dataVersion": "7.0.0.32", "assetVersion": "7.0.0.30", "assetHash": "assets" + } + }) + } + + fn parsed(value: &Value) -> Result { + parse_source(&value.to_string()) + } + + #[test] + fn legacy_roots_keep_latest_repository_and_mirrors() { + for region in ["jp", "cn"] { + let source = parsed(&json!({"version": 1, "source": {"region": region}})).unwrap(); + assert_eq!(source.pin, None); + for host in [ + Remote::MasterRepository, + Remote::Master, + Remote::MasterMirror, + ] { + assert_eq!( + source.table(host, Region::Jp, "configs"), + Some(remote::master_table(host, Region::Jp, "configs")) + ); + assert_eq!( + source.version(host), + Some(remote::master_version(host, source.region)) + ); + } + assert!(source.parse_version(r#"{"dataVersion":"8.0.0"}"#).is_ok()); + } + } + + #[test] + fn a_pin_selects_commit_paths_and_excludes_moving_mirrors() { + let source = parsed(&identity()).unwrap(); + let commit = source.pin.as_ref().unwrap().commit.as_str(); + assert_eq!( + source.table(Remote::MasterRepository, Region::Jp, "configs"), + Some(remote::master_table_at(Region::Jp, commit, "configs")) + ); + assert_eq!( + source.version(Remote::MasterRepository), + Some(remote::master_version_at(Region::Jp, commit)) + ); + for host in [Remote::Master, Remote::MasterMirror, Remote::Storage] { + assert_eq!(source.table(host, Region::Jp, "configs"), None); + assert_eq!(source.version(host), None); + } + // The root's pin does not silently select another region's version. + assert_eq!( + source.table(Remote::Master, Region::Cn, "configs"), + Some(remote::master_table(Remote::Master, Region::Cn, "configs")) + ); + } + + #[test] + fn partial_pins_are_not_treated_as_legacy_roots() { + for field in [ + "repository", + "region", + "commit", + "dataVersion", + "assetVersion", + "assetHash", + ] { + let mut value = identity(); + value["master"].as_object_mut().unwrap().remove(field); + assert!(parsed(&value).is_err(), "missing {field}"); + } + for field in ["assetVersion", "assetHash"] { + let mut value = identity(); + value["source"].as_object_mut().unwrap().remove(field); + assert!(parsed(&value).is_err(), "missing source.{field}"); + } + let mut value = identity(); + value["master"] = json!({}); + assert!(parsed(&value).is_err()); + } + + #[test] + fn foreign_repositories_regions_and_provider_are_refused() { + for (field, wrong) in [ + ("repository", "someone/haruki-sekai-master"), + ("repository", "Team-Haruki/haruki-sekai-sc-master"), + ("repository", "https://example.org/evil"), + ("provider", "sekai-world"), + ("region", "cn"), + ] { + let mut value = identity(); + value["master"][field] = json!(wrong); + assert!(parsed(&value).is_err(), "{field}={wrong}"); + } + } + + #[test] + fn only_immutable_plain_commits_are_accepted() { + for commit in [ + "main", + "abcd", + "../main", + "0123456789ABCDEF0123456789ABCDEF01234567", + "0123456789abcdef0123456789abcdef0123456/", + ] { + let mut value = identity(); + value["master"]["commit"] = json!(commit); + assert!(parsed(&value).is_err(), "{commit}"); + } + } + + #[test] + fn producer_and_bundle_identity_must_agree() { + for field in ["assetVersion", "assetHash"] { + let mut value = identity(); + value["master"][field] = json!("other"); + assert!(parsed(&value).unwrap_err().contains(field)); + } + } + + #[test] + fn upstream_version_must_match_every_pinned_field() { + let source = parsed(&identity()).unwrap(); + let version = + json!({"dataVersion":"7.0.0.32", "assetVersion":"7.0.0.30", "assetHash":"assets"}); + assert_eq!( + source.parse_version(&version.to_string()).unwrap(), + "7.0.0.32" + ); + for field in ["dataVersion", "assetVersion", "assetHash"] { + let mut wrong = version.clone(); + wrong[field] = json!("other"); + assert!( + source + .parse_version(&wrong.to_string()) + .unwrap_err() + .contains(field) + ); + wrong.as_object_mut().unwrap().remove(field); + assert!(source.parse_version(&wrong.to_string()).is_err()); + } + } +} 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/engine/moly-assets/src/remote.rs b/crates/engine/moly-assets/src/remote.rs index decdc5d0..f1c89871 100644 --- a/crates/engine/moly-assets/src/remote.rs +++ b/crates/engine/moly-assets/src/remote.rs @@ -251,6 +251,26 @@ pub fn master_version(host: Remote, region: Region) -> AssetPath<'static> { AssetPath::from(format!("{}://{path}", host.source())) } +/// A table in an immutable commit of the admitted repository host. The +/// source identity reader validates the full commit and its repository before +/// calling this; it never names a different host or a moving mirror. +pub(crate) fn master_table_at(region: Region, commit: &str, table: &str) -> AssetPath<'static> { + AssetPath::from(format!( + "{}://{}/{commit}/master/{table}.json", + Remote::MasterRepository.source(), + region.repository() + )) +} + +/// The version document in the same immutable commit as [`master_table_at`]. +pub(crate) fn master_version_at(region: Region, commit: &str) -> AssetPath<'static> { + AssetPath::from(format!( + "{}://{}/{commit}/versions/current_version.json", + Remote::MasterRepository.source(), + region.repository() + )) +} + /// The bases the entry crate admitted; shared by the readers. #[derive(Resource, Clone, Default)] pub struct RemoteAdmission(Arc>>); @@ -562,3 +582,43 @@ mod native { .await } } + +#[cfg(test)] +mod master_pin_tests { + use super::*; + + #[test] + fn commit_paths_stay_below_the_admitted_repository_template() { + let bases = crate::asset_source::remote_bases(crate::asset_source::RemoteBaseOverrides { + master_repository: Some("https://mirror.example/owner/{repo}/"), + ..Default::default() + }) + .unwrap(); + let admission = RemoteAdmission::default(); + admission.set(bases); + let commit = "0123456789abcdef0123456789abcdef01234567"; + assert_eq!( + admission.url(&master_table_at(Region::Jp, commit, "mysekaiFixtures")), + Some(format!( + "https://mirror.example/owner/haruki-sekai-master/{commit}/master/mysekaiFixtures.json" + )) + ); + assert_eq!( + admission.url(&master_version_at(Region::Jp, commit)), + Some(format!( + "https://mirror.example/owner/haruki-sekai-master/{commit}/versions/current_version.json" + )) + ); + assert_eq!( + admission.url(&master_table( + Remote::MasterRepository, + Region::Jp, + "mysekaiFixtures" + )), + Some( + "https://mirror.example/owner/haruki-sekai-master/main/master/mysekaiFixtures.json" + .to_owned() + ) + ); + } +} 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..0305fc65 100644 --- a/crates/game/moly-audio/src/audio.rs +++ b/crates/game/moly-audio/src/audio.rs @@ -327,7 +327,12 @@ pub fn apply_sink_volumes( bus: Res, gate: Res, mut sinks: Query<(&BusVolume, &mut AudioSink)>, + mut spatial: Query<(&BusVolume, &mut bevy::audio::SpatialAudioSink)>, ) { + for (source, mut sink) in &mut spatial { + let volume = match source { BusVolume::Cue(v)=>v.linear(&bus),BusVolume::Se(v)=>v.volume(&bus) } * gate.factor(); + if sink.volume().to_linear()!=volume { sink.set_volume(Volume::Linear(volume)); } + } for (source, mut sink) in &mut sinks { let volume = match source { BusVolume::Cue(volume) => volume.linear(&bus), @@ -819,6 +824,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 +1945,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 @@ -2693,6 +2705,10 @@ pub fn start_timeline_cue( cue: &str, handles: Vec>, ) -> Result { + start_timeline_cue_at(world,package,cue,handles,None) +} + +pub fn start_timeline_cue_at(world: &mut World, package: &str, cue: &str, handles: Vec>, position: Option) -> Result { let plan = world .get_resource::() .ok_or_else(|| "audio routing is not ready".to_string())? @@ -2723,9 +2739,13 @@ pub fn start_timeline_cue( "timeline SE", ) }); - Ok(cue::spawn_playback_in_world(world, playback, now)) + Ok(cue::spawn_playback_in_world(world, playback.spatial(position), now)) } +/// Audio-only translation for an owner simulated in a different site's local frame. +#[derive(Component,Clone,Copy)] +pub struct SourcePositionOffset(pub Vec3); + // ---- A 套邻近环境音管理器 ----------------------------------------------------- /// 站点场景侧的声源(真源声源对象的等价物):cue + 包名 + 最大距离。 @@ -2777,7 +2797,7 @@ pub fn advance_proximity( routing: Option>, mut state: ResMut, sources: Query< - (&SoundObject, &GlobalTransform), + (&SoundObject, &GlobalTransform,Option<&SourcePositionOffset>), Without, >, avatars: Query<&GlobalTransform, With>, @@ -2806,8 +2826,8 @@ pub fn advance_proximity( // 真源初值是 int 最小值重释为 float,即 f32::MIN)。 let mut best: Option<(&SoundObject, f32)> = None; let mut best_volume = f32::MIN; - for (source, transform) in &sources { - let volume = proximity_volume(position, transform.translation(), source.max_distance); + for (source, transform, offset) in &sources { + let volume = proximity_volume(position, transform.translation()+offset.map_or(Vec3::ZERO,|o|o.0), source.max_distance); if volume > best_volume { best = Some((source, volume)); best_volume = volume; @@ -3039,8 +3059,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 +3134,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 +3152,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 +3182,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 +3219,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/cue.rs b/crates/game/moly-audio/src/audio/cue.rs index 35fae1e0..e402fc29 100644 --- a/crates/game/moly-audio/src/audio/cue.rs +++ b/crates/game/moly-audio/src/audio/cue.rs @@ -154,9 +154,11 @@ pub struct CuePlayback { channel: &'static str, /// The call's own volume factor (1.0 unless the call passes one). scale: f32, + spatial: Option, } impl CuePlayback { + pub fn spatial(mut self, position: Option) -> Self { self.spatial=position; self } /// The call's own volume: multiplies every track's volume. pub fn scaled(mut self, scale: f32) -> Self { self.scale = scale; @@ -210,6 +212,8 @@ pub type TrackBundle = ( PlaybackSettings, BusVolume, ChildOf, + Transform, + GlobalTransform, ); /// Spawn a started cue; the entity is the playback's handle (despawn stops @@ -226,6 +230,7 @@ pub fn spawn_playback( start_due_tracks(&mut playback, entity, bus, gate, now, &mut |track| { commands.spawn(track).id() }); + if let Some(position)=playback.spatial { commands.entity(entity).insert((Transform::from_translation(position),GlobalTransform::from_translation(position))); } commands.entity(entity).insert(playback); entity } @@ -242,6 +247,7 @@ pub fn spawn_playback_in_world( start_due_tracks(&mut playback, entity, &bus, &gate, now, &mut |track| { world.spawn(track).id() }); + if let Some(position)=playback.spatial { world.entity_mut(entity).insert((Transform::from_translation(position),GlobalTransform::from_translation(position))); } world.entity_mut(entity).insert(playback); entity } @@ -282,9 +288,11 @@ fn start_due_tracks( let linear = volume.linear(bus) * gate.factor(); let sound = spawn(( AudioPlayer::new(playback.handles[slot].clone()), - settings.with_volume(Volume::Linear(linear)), + settings.with_volume(Volume::Linear(linear)).with_spatial(playback.spatial.is_some()), BusVolume::Cue(volume), ChildOf(entity), + Transform::IDENTITY, + GlobalTransform::from_translation(playback.spatial.unwrap_or_default()), )); if playback.plan.work_key.is_some() { debug!( @@ -334,6 +342,7 @@ pub fn start_cue_with_handles( voices: Vec::new(), channel, scale: 1.0, + spatial: None, }; start_round(&mut playback, work, rngs, now); playback @@ -411,6 +420,7 @@ pub fn advance_cue_playbacks( mut playbacks: Query<(Entity, &mut CuePlayback)>, players: Query<&AudioPlayer>, sinks: Query<&AudioSink>, + spatial_sinks: Query<&bevy::audio::SpatialAudioSink>, ) { let now = time.elapsed_secs_f64(); for (entity, mut playback) in &mut playbacks { @@ -423,7 +433,7 @@ pub fn advance_cue_playbacks( for index in 0..playback.voices.len() { match playback.voices[index] { RoundVoice::Sounding { entity: sound, .. } => { - if one_shot_finished_or_failed(sound, &server, &players, &sinks) { + if spatial_sinks.get(sound).is_ok_and(|sink|sink.empty()) || one_shot_finished_or_failed(sound, &server, &players, &sinks) { if let Ok(mut sound_commands) = commands.get_entity(sound) { sound_commands.despawn(); } 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/markers.rs b/crates/game/moly-core/src/markers.rs index 91a82ed5..f6f334e0 100644 --- a/crates/game/moly-core/src/markers.rs +++ b/crates/game/moly-core/src/markers.rs @@ -255,3 +255,7 @@ impl crate::animation_time::UnitId for CharacterUnitId { /// driver is in place. #[derive(Component)] pub struct CharacterDriver; + +/// A detached draw whose live owner belongs to another displayed site. +#[derive(Component)] +pub struct SitePresentationHidden; 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-devtools/src/control/layout.rs b/crates/game/moly-devtools/src/control/layout.rs index 2e00ab9a..7d7ffdb1 100644 --- a/crates/game/moly-devtools/src/control/layout.rs +++ b/crates/game/moly-devtools/src/control/layout.rs @@ -13,6 +13,8 @@ use crate::fixture_edit::{ pub(super) const COMMANDS: &[&str] = &[ "enter", + "select_content", + "select_environment", "select_catalog", "select_placed", "select_inventory", @@ -41,6 +43,21 @@ pub(super) fn view(world: &World) -> Value { json!({ "revision": view.revision, "active": view.active, + "content": format!("{:?}", view.content), + "environment": view.environment, + "environment_choices": view.environment_choices.iter().map(|row| json!({ + "phenomena_id": row.phenomena_id, "name": row.name, + })).collect::>(), + "road": { + "planned": view.road.planned.iter().map(item).collect::>(), + "target": view.road.target.as_ref().map(item), + "can_decide": view.road.can_decide, + "remaining": view.road.remaining, + "select_mode": view.road.select_mode, + "show_fixtures": view.road.show_fixtures, + "moving": view.road.moving, + "dragging": view.road.dragging, + }, "phase": format!("{:?}", view.phase), "site_id": view.site_id, "site_type": view.site_type, @@ -122,6 +139,21 @@ pub fn command(params: &Map) -> Result { }; Ok(match name { "enter" => EditCommand::Enter, + "select_content" => { + let kind = params.get("kind").and_then(Value::as_i64) + .ok_or("select_content needs source kind 0, 1, 2, 3, 4 or 19")?; + EditCommand::SelectContent(crate::fixture_edit::EditContentType::from_tab_kind(kind) + .ok_or("select_content kind is not a source content kind")?) + } + "select_environment" => { + let phenomena_id = match params.get("phenomena_id") { + Some(Value::Null) => None, + Some(value) => Some(value.as_i64().and_then(|id| i32::try_from(id).ok()) + .filter(|id| *id > 0).ok_or("phenomena_id must be positive or null for default")?), + None => return Err("select_environment needs phenomena_id (null means default)".into()), + }; + EditCommand::SelectEnvironment { phenomena_id } + } "select_catalog" => EditCommand::SelectCatalog { index: params .get("index") @@ -387,4 +419,4 @@ pub fn apply_outcome(outcome: &moly_layout::ApplyOutcome) -> Value { "results_before": rejected.results_before.iter().map(result).collect::>(), }), } -} \ No newline at end of file +} diff --git a/crates/game/moly-devtools/src/control/ui.rs b/crates/game/moly-devtools/src/control/ui.rs index 4f85ff09..4b3bb550 100644 --- a/crates/game/moly-devtools/src/control/ui.rs +++ b/crates/game/moly-devtools/src/control/ui.rs @@ -39,8 +39,23 @@ pub(super) fn views(world: &mut World) -> Result { }) }) .collect(); - views.push(json!({ "view": view.key, "laidOut": true, "controls": controls })); + let scroll_rects: Vec = layouts.document(view.key).into_iter() + .flat_map(|doc| doc.nodes.iter()) + .flat_map(|node| node.components.iter().map(move |component| (node, component))) + .filter(|(_, component)| component.class.ends_with("ScrollRect")) + .map(|(node, component)| { + let rectangle = |field: &str| { + let pointer = &component.fields[field]; + if pointer[0].as_i64() != Some(0) { return Value::Null; } + let Some(id) = pointer[1].as_i64().filter(|id| *id != 0) else { return Value::Null; }; + view.rect(layouts, &format!("@{id}"), canvas).map_or(Value::Null, |rect| { + json!({"size": rect.size.to_array(), "center": rect.center().to_array(), "active": rect.active}) + }) + }; + json!({"path": node.path, "viewport": rectangle("m_Viewport"), "content": rectangle("m_Content")}) + }).collect(); + views.push(json!({ "view": view.key, "laidOut": true, "controls": controls, "scrollRects": scroll_rects })); } views.sort_by(|a, b| a["view"].as_str().cmp(&b["view"].as_str())); - Ok(json!({ "window": [window.width(), window.height()], "views": views })) + Ok(json!({ "window": [window.width(), window.height()], "canvas": canvas.to_array(), "scale": root.scale(&window), "views": views })) } diff --git a/crates/game/moly-devtools/src/lib.rs b/crates/game/moly-devtools/src/lib.rs index 1333eef4..23422e0d 100644 --- a/crates/game/moly-devtools/src/lib.rs +++ b/crates/game/moly-devtools/src/lib.rs @@ -34,9 +34,6 @@ pub use moly_core::render; pub use moly_core::system_access; pub use moly_core::ui_font; pub use moly_core::weather_transition; -// The TOML parser the plugin manifests are read with, under the name the -// tables the plugin runtime holds are written with. -pub(crate) use moly_plugin::toml; pub use moly_render::billboard; pub use moly_render::character_material; pub use moly_render::fixture_timeline_particles; diff --git a/crates/game/moly-features/Cargo.toml b/crates/game/moly-features/Cargo.toml index d6e06bdf..2d79865e 100644 --- a/crates/game/moly-features/Cargo.toml +++ b/crates/game/moly-features/Cargo.toml @@ -39,16 +39,10 @@ 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" +zip = { version = "9.1", default-features = false } +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } [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" @@ -66,7 +60,7 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" # 本地档(音量设置持久化)在浏览器侧落 localStorage:Window + Storage # 绑定。与 moly-app 同一版本源(锁内已有,不新增下载)。 -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"] } +web-sys = { version = "0.3", features = ["Window", "Storage", "Location", "UrlSearchParams", "Navigator", "Clipboard", "File", "Blob", "BlobPropertyBag", "Url", "FileList", "Document", "HtmlInputElement", "HtmlElement", "Element", "Node", "Event", "EventTarget", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Request", "RequestInit", "RequestRedirect", "Response", "Headers", "ResizeObserver", "DragEvent", "DataTransfer"] } [dev-dependencies] moly-core = { path = "../moly-core", features = ["test-support"] } 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/collect_notice/slide.rs b/crates/game/moly-features/src/collect_notice/slide.rs index 5d34979a..88b5dafb 100644 --- a/crates/game/moly-features/src/collect_notice/slide.rs +++ b/crates/game/moly-features/src/collect_notice/slide.rs @@ -137,10 +137,16 @@ pub trait NoticeData { impl SlideNotices { /// `Initialize`: `anchored_x` is each cell's anchored x after placement. pub fn new(anchored_x: f32) -> Self { + Self::with_shape(anchored_x, PARALLEL, POOL, SLIDE_IN_LEFT) + } + + /// Initialize another source notice controller with its own capacity and side. + pub fn with_shape(anchored_x: f32, parallel: usize, pool: usize, offset: f32) -> Self { + assert!(parallel > 0 && pool > 0); Self { - drivers: (0..PARALLEL) + drivers: (0..parallel) .map(|_| Driver { - start_x: anchored_x + SLIDE_IN_LEFT, + start_x: anchored_x + offset, queue: VecDeque::new(), phase: Phase::Idle, x: anchored_x, @@ -148,7 +154,7 @@ impl SlideNotices { }) .collect(), driver_index: 0, - pool: (0..POOL).map(|_| None).collect(), + pool: (0..pool).map(|_| None).collect(), pool_index: 0, } } @@ -178,7 +184,7 @@ impl SlideNotices { ) -> (usize, usize) { let slot = self.pool_index; self.pool[slot] = Some(data); - self.pool_index = (self.pool_index + 1) % POOL; + self.pool_index = (self.pool_index + 1) % self.pool.len(); if !self.is_playing() { self.driver_index = 0; } @@ -190,7 +196,7 @@ impl SlideNotices { driver.start_front(dt, false, &mut out); calls.extend(out.into_iter().map(|call| (index, call))); } - self.driver_index = (self.driver_index + 1) % PARALLEL; + self.driver_index = (self.driver_index + 1) % self.drivers.len(); (slot, index) } 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/convert/detail.rs b/crates/game/moly-features/src/convert/detail.rs index 5db4abb8..69e6228b 100644 --- a/crates/game/moly-features/src/convert/detail.rs +++ b/crates/game/moly-features/src/convert/detail.rs @@ -211,7 +211,8 @@ pub fn compose(doc: &mut UiPrefab, layouts: &UiLayouts, view: &str) -> Result { diff --git a/crates/game/moly-features/src/convert/dialogs.rs b/crates/game/moly-features/src/convert/dialogs.rs index 8f49d5ca..f5d6f7a2 100644 --- a/crates/game/moly-features/src/convert/dialogs.rs +++ b/crates/game/moly-features/src/convert/dialogs.rs @@ -259,6 +259,8 @@ pub fn read(doc: &UiPrefab, which: Which) -> Result { ]); } Which::Detail => { + // Unlike the confirm dialogs, the detail has no Content wrapper. + let content = sub(&window, "ContentRoot"); let thumbnail = sub(&content, "UIPartsItemThumbnail"); nodes.detail_icon = sub(&thumbnail, "mask/Icon"); nodes.detail_thumbnail = thumbnail; 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..7dda03d8 100644 --- a/crates/game/moly-features/src/fixture_edit.rs +++ b/crates/game/moly-features/src/fixture_edit.rs @@ -20,6 +20,7 @@ mod actors; mod assets; mod autoplay; mod edit_grid; +mod environment; pub mod field_build; mod game_state; mod input; @@ -27,6 +28,7 @@ mod layout_rules; mod owned; mod presentation; mod put_effect; +mod road; mod validation; #[cfg(test)] @@ -51,7 +53,8 @@ pub use crate::field_modes::{EditSessionActive, LayoutSaved}; /// UI and platform input may request operations, never mutate draft fields. pub use crate::ui_inputs::{ - EditCatalogView, EditCommand, EditItemView, EditPhase, EditSelectionView, EditView, PutStatus, + EditCatalogView, EditCommand, EditContentType, EditEnvironmentView, EditItemView, EditPhase, + EditSelectionView, EditView, PutStatus, }; /// The view row of a placed or held fixture. @@ -93,10 +96,15 @@ struct Selection { #[derive(Resource, Default)] pub struct EditSession { + road: road::RoadSession, + /// Retain road pointer ownership through the frame containing its release. + road_camera_drag_claimed: bool, next_fixture_uid: u64, revision: u64, phase: EditPhase, catalog_index: usize, + content: EditContentType, + environment: Option, baseline: Option, storage_baseline: Option, /// Keep editing input/actor ownership while the successful save's actual @@ -120,6 +128,14 @@ pub struct EditSession { feedback: String, } +impl EditSession { + /// Input ownership is read after commands in the same frame so a road + /// drag never also pans the field camera before the view is republished. + pub(crate) fn road_dragging(&self) -> bool { + self.road.dragging || self.road_camera_drag_claimed + } +} + /// The dialogs of `SiteLayoutEditor.SaveLayoutWithValidation`. #[derive(Clone, Debug)] struct SaveDialog { @@ -147,8 +163,12 @@ const INVALID_REMOVE: &str = "MSG_MYSEKAI_CONFIRM_REMOVE_FIXTURE_INVALID_LAYOUT" const INVALID_REMOVE_OK: &str = "WORD_MYSEKAI_INVALID_FIXTURE_LIST_OK_BUTTON"; /// The wordings of the save dialogs (the shell charset takes them). -pub const WORDINGS: &[&str] = - &[CUTSCENE_CONFIRM, INVALID_GO_HOME, INVALID_REMOVE, INVALID_REMOVE_OK]; +pub const WORDINGS: &[&str] = &[ + CUTSCENE_CONFIRM, + INVALID_GO_HOME, + INVALID_REMOVE, + INVALID_REMOVE_OK, +]; impl EditSession { /// The server call the editor waits for, for the director. @@ -175,11 +195,16 @@ impl EditSession { fn dirty(&self) -> bool { self.baseline.as_ref().is_some_and(|base| { - self.rows != base.editor_rows() || self.inventory != self.initial_inventory + self.rows != base.editor_rows() + || self.inventory != self.initial_inventory + || self.environment != base.phenomena_id() }) } fn cancel_selection(&mut self) { + if self.road.cancel() { + self.changed(); + } // No original row or inventory record was removed during selection. // Dropping this pose overlay therefore restores the exact start state. if self.selected.take().is_some() { @@ -198,6 +223,7 @@ impl EditSession { self.initial_inventory.clear(); self.inventory.clear(); self.selected = None; + self.road = road::RoadSession::default(); self.exit_dialog = false; self.clean_up_dialog = false; self.saving = None; @@ -222,7 +248,14 @@ pub enum FixtureEditSystems { } pub fn has_unsaved_layout(session: &EditSession) -> bool { - session.dirty() || session.selected.is_some() || session.pending_reload.is_some() + session.dirty() + || session.selected.is_some() + || road_pending(session) + || session.pending_reload.is_some() +} + +fn road_pending(session: &EditSession) -> bool { + !session.road.planned.is_empty() } /// Draft occupancy never enters the gameplay walk-field or claims playable @@ -243,6 +276,7 @@ pub fn clear_for_site_change(world: &mut World) { if let Some(mut active) = world.get_resource_mut::() { active.active = false; } + world.remove_resource::(); presentation::clear(world); actors::clear_overlay(world); game_state::exit_for_site_change(world); @@ -257,7 +291,8 @@ fn receive_commands( fn play_se(world: &mut World, cue: &'static str, source: &'static str) { if let Some(mut se) = world.get_resource_mut::() { - se.0.push(SeRequest { owner: None, + se.0.push(SeRequest { + owner: None, cue: cue.into(), class: SeClass::Ui, source, @@ -305,7 +340,8 @@ fn begin(world: &mut World, session: &mut EditSession) { return; } let homes = world.get_resource::(); - let entry = world.get_resource::() + let entry = world + .get_resource::() .ok_or_else(|| "布局存档所有者尚未就绪".to_owned()) .and_then(|layouts| layouts.begin_edit(&layout, homes)); let (storage_baseline, inventory) = match entry { @@ -316,6 +352,12 @@ fn begin(world: &mut World, session: &mut EditSession) { } }; session.next_fixture_uid = session.next_fixture_uid.max(layout.next_edit_uid()).max(1); + session.content = if layout.site_type() == "home_site" { + EditContentType::Outdoor + } else { + EditContentType::Floor + }; + session.environment = layout.phenomena_id(); session.rows = layout.editor_rows(); session.baseline = Some(layout); session.storage_baseline = Some(storage_baseline); @@ -335,6 +377,7 @@ fn begin(world: &mut World, session: &mut EditSession) { crate::fixture_scene_inputs::invalidate_for_site_change(world); play_se(world, "se_change_layout", "edit-enter"); game_state::enter(world); + environment::preview(world, session); if std::env::var("MOLY_EDIT_AUTOPLAY").is_ok() { census(world, session); } @@ -423,7 +466,10 @@ fn focus(world: &mut World, item: &EditableFixture, source: &'static str) { let zoom_support = match put_effect::is_block(world, item.fixture_id) { Some(block) => !block, None => { - warn!("[edit-camera] focus on {} ({source}): fixture {} has no handle type in the fixture table; focused without zoom support", item.uid, item.fixture_id); + warn!( + "[edit-camera] focus on {} ({source}): fixture {} has no handle type in the fixture table; focused without zoom support", + item.uid, item.fixture_id + ); false } }; @@ -486,12 +532,18 @@ fn put_search( let put_type = match put_effect::put_type(world, item.fixture_id) { Ok(put_type) => put_type, Err(error) => { - warn!("[edit-put] PutFixture {} ({source}): {error}; not put", item.uid); + warn!( + "[edit-put] PutFixture {} ({source}): {error}; not put", + item.uid + ); return Err("家具主表仍在加载,暂时不能摆放新家具。".into()); } }; let Some(source_direction) = placement::rotation_to_direction(yaw) else { - warn!("[edit-put] PutFixture {} ({source}): camera yaw {yaw} names no direction; not put", item.uid); + warn!( + "[edit-put] PutFixture {} ({source}): camera yaw {yaw} names no direction; not put", + item.uid + ); return Err("相机朝向无效,暂时不能摆放新家具。".into()); }; // The look-at relative to the site, in the source frame (X mirrored). @@ -502,8 +554,13 @@ fn put_search( { Ok(found) => found, Err(missing) => { - warn!("[edit-put] PutFixture {} ({source}): the put check's input {missing:?} is missing; not put", item.uid); - return Err(format!("摆放检查的输入尚未就绪({missing:?}),暂时不能摆放新家具。")); + warn!( + "[edit-put] PutFixture {} ({source}): the put check's input {missing:?} is missing; not put", + item.uid + ); + return Err(format!( + "摆放检查的输入尚未就绪({missing:?}),暂时不能摆放新家具。" + )); } }; let (board, rules) = board; @@ -536,7 +593,11 @@ fn put_search( found.start.z, found.source_direction, put_type, - if found.search_raised { "raised rings searched" } else { "rings at y 0" }, + if found.search_raised { + "raised rings searched" + } else { + "rings at y 0" + }, found.max_radius, found.rejected.len(), counts @@ -553,7 +614,10 @@ fn put_search( } } let Some((tile, center, direction)) = found.chosen else { - warn!("[edit-put] PutFixture {} ({source}): no placeable tile within the radius cap; CantPutFixture", item.uid); + warn!( + "[edit-put] PutFixture {} ({source}): no placeable tile within the radius cap; CantPutFixture", + item.uid + ); return Err("附近没有可以摆放的空间。".into()); }; item.center = center; @@ -561,10 +625,24 @@ fn put_search( match item.footprint() { Ok((min, max)) => info!( "[edit-put] PutFixture {} ({source}): tile ({}, {}, {}) (source frame) -> product center ({}, {}, {}) direction {direction:?}; footprint ({}, {}, {})..=({}, {}, {})", - item.uid, tile.x, tile.y, tile.z, center.x, center.y, center.z, - min.x, min.y, min.z, max.x, max.y, max.z + item.uid, + tile.x, + tile.y, + tile.z, + center.x, + center.y, + center.z, + min.x, + min.y, + min.z, + max.x, + max.y, + max.z + ), + Err(error) => warn!( + "[edit-put] PutFixture {} ({source}): chosen tile has no footprint ({error})", + item.uid ), - Err(error) => warn!("[edit-put] PutFixture {} ({source}): chosen tile has no footprint ({error})", item.uid), } Ok(()) } @@ -657,7 +735,9 @@ fn drag(world: &mut World, session: &mut EditSession, target: DragTarget) { let (rules, board, piece) = match prepared { Ok(prepared) => prepared, Err(missing) => { - session.say(format!("摆放检查的输入尚未就绪({missing:?}),未移动家具。")); + session.say(format!( + "摆放检查的输入尚未就绪({missing:?}),未移动家具。" + )); return; } }; @@ -767,7 +847,9 @@ fn rotate(world: &mut World, session: &mut EditSession) { let (rules, board, piece, stacked) = match prepared { Ok(prepared) => prepared, Err(missing) => { - session.say(format!("摆放检查的输入尚未就绪({missing:?}),未旋转家具。")); + session.say(format!( + "摆放检查的输入尚未就绪({missing:?}),未旋转家具。" + )); return; } }; @@ -782,14 +864,19 @@ fn rotate(world: &mut World, session: &mut EditSession) { // UpdateEditRotateTargetPosition at the grid position. let direction = Direction::from_u8((piece.direction as u8 + 1) % 4).expect("four source directions"); - let turned = - match tile_rules::Piece::at(&selected.item, &rules, piece.center, direction, piece.layout) { - Ok(turned) => turned, - Err(missing) => { - session.say(format!("旋转后超出网格({missing:?}),未旋转家具。")); - return; - } - }; + let turned = match tile_rules::Piece::at( + &selected.item, + &rules, + piece.center, + direction, + piece.layout, + ) { + Ok(turned) => turned, + Err(missing) => { + session.say(format!("旋转后超出网格({missing:?}),未旋转家具。")); + return; + } + }; let clamped = board.clamp(&turned, piece.center); let placed = match tile_rules::Piece::at(&selected.item, &rules, clamped, direction, piece.layout) { @@ -882,7 +969,9 @@ fn select( match stacked_rows(world, session, &item.uid) { Ok(stacked) => stacked, Err(missing) => { - session.say(format!("摆放检查的输入尚未就绪({missing:?}),暂时不能选中家具。")); + session.say(format!( + "摆放检查的输入尚未就绪({missing:?}),暂时不能选中家具。" + )); return; } } @@ -893,7 +982,10 @@ fn select( info!( "[edit-put] SelectFixture {}: stacked fixtures {:?} move with it", item.uid, - stacked.iter().map(|row| row.uid.as_str()).collect::>() + stacked + .iter() + .map(|row| row.uid.as_str()) + .collect::>() ); } // Selecting another item is an explicit Reset of an unfinished operation, @@ -1131,8 +1223,8 @@ fn save(session: &mut EditSession, world: &mut World, exit_after: bool, validate session.say("保存已完成,正在等待家具与可行走场恢复;角色继续暂停。"); return; } - if session.selected.is_some() { - session.say("请先决定或取消当前家具,再保存。"); + if session.selected.is_some() || road_pending(session) { + session.say("请先决定或取消当前家具或道路规划,再保存。"); return; } let Some(base) = session.baseline.as_ref() else { @@ -1144,6 +1236,7 @@ fn save(session: &mut EditSession, world: &mut World, exit_after: bool, validate if current.site_id() != base.site_id() || current.site_type() != base.site_type() || current.editor_rows() != base.editor_rows() + || current.phenomena_id() != base.phenomena_id() { session.say("当前地图布局已变化,未覆盖其它布局;本次草稿仍保留。"); return; @@ -1160,10 +1253,21 @@ fn save(session: &mut EditSession, world: &mut World, exit_after: bool, validate validate_cutscene, ) { let floor = base.floor_grid(); - refused_save(world, session, status, &error, floor, rules.as_ref().ok(), exit_after); + refused_save( + world, + session, + status, + &error, + floor, + rules.as_ref().ok(), + exit_after, + ); return; } - let next = match base.with_editor_rows(&session.rows, session.next_fixture_uid) { + let next = match base + .with_editor_rows(&session.rows, session.next_fixture_uid) + .and_then(|layout| layout.with_phenomena_id(session.environment)) + { Ok(next) => next, Err(error) => { session.say(format!("保存未完成:{error}。草稿仍保留。")); @@ -1179,7 +1283,9 @@ fn save(session: &mut EditSession, world: &mut World, exit_after: bool, validate let call = Call::HousingLayout(crate::fixture::layouts::housing_layout_request(&next)); let kind = call.kind(); let seq = world.resource_mut::().call(call); - info!("[edit-save] PostUserMysekaiHousingLayoutApi sent as seq {seq}; the draft is written on its reply"); + info!( + "[edit-save] PostUserMysekaiHousingLayoutApi sent as seq {seq}; the draft is written on its reply" + ); session.saving = Some(ServerPending { seq: Some(seq), call: kind, @@ -1256,13 +1362,14 @@ fn commit_saved_layout( let Some(storage_baseline) = session.storage_baseline.as_ref() else { return; }; - let receipt = match crate::fixture::layouts::persist_edit(&next, &session.inventory, storage_baseline) { - Ok(receipt) => receipt, - Err(error) => { - session.say(format!("保存失败:{error}。布局、库存及退出窗口均未提交。")); - return; - } - }; + let receipt = + match crate::fixture::layouts::persist_edit(&next, &session.inventory, storage_baseline) { + Ok(receipt) => receipt, + Err(error) => { + session.say(format!("保存失败:{error}。布局、库存及退出窗口均未提交。")); + return; + } + }; let next_storage_baseline = receipt.snapshot(); // Only the successful durable receipt may publish either half of this // transaction. Existing settings and other site buckets are untouched. @@ -1272,6 +1379,22 @@ 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(); @@ -1315,37 +1438,48 @@ fn refused_save( LayoutStatus::CutsceneAreaOverridden => ( "SiteLayoutEditor.ShowCutsceneAreaConfirmDialog", CUTSCENE_CONFIRM, - Buttons::Two { ok: "WORD_OK", cancel: "WORD_CANCEL" }, + Buttons::Two { + ok: "WORD_OK", + cancel: "WORD_CANCEL", + }, SaveDialogKind::CutsceneArea, ), LayoutStatus::ErrorLayout | LayoutStatus::ErrorAnimationSpace => { let homes = world.get_resource::(); let listed = match (floor, rules, homes) { - (Some(floor), Some(rules), Some(homes)) => validation::invalid_fixtures( - &session.rows, - floor, - rules, - |package| !homes.can_clean_up(package), - ), + (Some(floor), Some(rules), Some(homes)) => { + validation::invalid_fixtures(&session.rows, floor, rules, |package| { + !homes.can_clean_up(package) + }) + } _ => Err("家具主表或摆放规则仍在加载".into()), }; let listed = match listed { Ok(listed) if !listed.is_empty() => listed, Ok(_) => { - session.say(format!("保存未完成:{error};无效家具列表为空。原布局与草稿均保留。")); - warn!("[edit-save] CheckSaveLayout refused ({error}) but CreateInValidFixtureList found nothing"); + session.say(format!( + "保存未完成:{error};无效家具列表为空。原布局与草稿均保留。" + )); + warn!( + "[edit-save] CheckSaveLayout refused ({error}) but CreateInValidFixtureList found nothing" + ); return; } Err(reason) => { - session.say(format!("保存未完成:{error};{reason}。原布局与草稿均保留。")); + session.say(format!( + "保存未完成:{error};{reason}。原布局与草稿均保留。" + )); return; } }; let homes = world.get_resource::(); let house_or_gate = session.rows.iter().any(|row| { - listed.contains(&row.uid) && homes.is_some_and(|homes| !homes.can_clean_up(&row.package)) + listed.contains(&row.uid) + && homes.is_some_and(|homes| !homes.can_clean_up(&row.package)) }); - info!("[edit-save] CreateInValidFixtureList: {listed:?} (house or gate listed: {house_or_gate})"); + info!( + "[edit-save] CreateInValidFixtureList: {listed:?} (house or gate listed: {house_or_gate})" + ); if house_or_gate { ( "SiteLayoutEditor.ShowErrorGoToHomeDialog", @@ -1360,7 +1494,10 @@ fn refused_save( ( "SiteLayoutEditor.ShowConfirmRemoveInValidFixtureDialog", INVALID_REMOVE, - Buttons::Two { ok: INVALID_REMOVE_OK, cancel: "WORD_CANCEL" }, + Buttons::Two { + ok: INVALID_REMOVE_OK, + cancel: "WORD_CANCEL", + }, SaveDialogKind::RemoveInvalid(listed), ) } @@ -1368,11 +1505,17 @@ fn refused_save( }; let text = crate::common_dialog::wording(world, caller, body); let Some(id) = crate::common_dialog::open(world, caller, text, buttons, None) else { - session.say(format!("保存未完成:{error};确认窗口无法显示。原布局与草稿均保留。")); + session.say(format!( + "保存未完成:{error};确认窗口无法显示。原布局与草稿均保留。" + )); return; }; info!("[edit-save] CheckSaveLayout: {error}; {caller} ({body}) {id:?}"); - session.save_dialog = Some(SaveDialog { id, kind, exit_after }); + session.save_dialog = Some(SaveDialog { + id, + kind, + exit_after, + }); session.changed(); } @@ -1392,7 +1535,11 @@ fn answer_save_dialog(world: &mut World) { }; session.save_dialog = None; session.changed(); - info!("[edit-save] {:?} answered {}", dialog.kind, if ok { "OK" } else { "cancel" }); + info!( + "[edit-save] {:?} answered {}", + dialog.kind, + if ok { "OK" } else { "cancel" } + ); match (dialog.kind, ok) { (_, false) => session.say("已取消保存;草稿仍保留。"), (SaveDialogKind::CutsceneArea, true) => save(&mut session, world, dialog.exit_after, false), @@ -1419,6 +1566,11 @@ fn answer_save_dialog(world: &mut World) { if let Some(base) = session.baseline.as_ref() { session.rows = base.editor_rows(); } + session.environment = session + .baseline + .as_ref() + .and_then(FixturePlacements::phenomena_id); + environment::preview(world, &session); session.inventory = session.initial_inventory.clone(); session.selected = None; session.phase = EditPhase::Browsing; @@ -1461,14 +1613,39 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm { return; } + if road::handle(world, session, &command) { + return; + } match command { + EditCommand::RoadTap { .. } + | EditCommand::RoadLongStart { .. } + | EditCommand::RoadLongEnd + | EditCommand::RoadDragStart { .. } + | EditCommand::RoadDragMove { .. } + | EditCommand::RoadDragEnd + | EditCommand::RoadPointerCancel + | EditCommand::RoadSelectMode + | EditCommand::RoadPlanningMode + | EditCommand::RoadToggleFixtures => {} EditCommand::Enter => {} + EditCommand::SelectContent(content) => environment::select_content(world, session, content), + EditCommand::SelectEnvironment { phenomena_id } => { + environment::select(world, session, phenomena_id) + } EditCommand::SelectCatalog { index } => { + if session.content == EditContentType::Environment { + return; + } let Some(base) = session.baseline.as_ref() else { return; }; let site_id = base.site_id(); - let row = match owned::catalog(world, site_id, &session.rows) { + let row = match owned::catalog_for( + world, + site_id, + &session.rows, + session.content == EditContentType::Road, + ) { Ok((rows, _)) => rows.into_iter().nth(index), Err(error) => { session.say(format!("持有家具清单不可用:{error}。")); @@ -1523,6 +1700,9 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm select(session, item, SelectionOrigin::Owned, world); } EditCommand::SelectPlaced { uid } => { + if session.content == EditContentType::Environment { + return; + } let Some(item) = session.rows.iter().find(|row| row.uid == uid).cloned() else { session.say("该UID不在当前地图,没有选择相近位置的其它家具。"); return; @@ -1537,6 +1717,9 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm select(session, item, SelectionOrigin::Placed, world); } EditCommand::SelectInventory { uid } => { + if session.content == EditContentType::Environment { + return; + } let Some(mut item) = session.inventory.iter().find(|row| row.uid == uid).cloned() else { session.say("该UID不在离线库存,未生成替代物品。"); @@ -1583,7 +1766,10 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm session.changed(); } EditCommand::RequestCleanUp => { - if session.phase != EditPhase::Idle && !session.exit_dialog { + if session.phase != EditPhase::Idle + && !session.exit_dialog + && session.content != EditContentType::Environment + { session.clean_up_dialog = true; session.changed(); } @@ -1595,6 +1781,9 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm } } EditCommand::CleanUpAll => { + if session.content == EditContentType::Environment { + return; + } // Only the confirmation offers this action. if session.clean_up_dialog { session.clean_up_dialog = false; @@ -1609,7 +1798,14 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm // The published layout/inventory never changed. Drop only // the explicit discarded draft while retaining the actor/input // lease until Show has a safe current navigation attachment. - if let Some(base) = session.baseline.as_ref() { session.rows = base.editor_rows(); } + if let Some(base) = session.baseline.as_ref() { + session.rows = base.editor_rows(); + } + session.environment = session + .baseline + .as_ref() + .and_then(FixturePlacements::phenomena_id); + environment::preview(world, session); session.inventory = session.initial_inventory.clone(); session.selected = None; session.phase = EditPhase::Browsing; @@ -1623,10 +1819,23 @@ fn apply_command(world: &mut World, session: &mut EditSession, command: EditComm } fn apply_commands(world: &mut World) { + // The camera reads the whole gesture batch after these commands. A drag + // remains road-owned for that batch even when its End clears dragging. + if let Some(mut session) = world.get_resource_mut::() { + session.road_camera_drag_claimed = session.road.dragging; + } let pending = std::mem::take(&mut world.resource_mut::().0); if pending.is_empty() { return; } + // Pointer loss is lifecycle cleanup, not a new edit. Process it before + // modal/save gates, which intentionally reject new layout operations. + let pointer_cancelled = pending.iter().any(|command| matches!(command, EditCommand::RoadPointerCancel)); + if pointer_cancelled { + if let Some(mut session) = world.get_resource_mut::() { + if session.road.cancel_pointer() { session.changed(); } + } + } if world .get_resource::() .is_some_and(|panel| panel.blocks_world_input()) @@ -1637,12 +1846,24 @@ fn apply_commands(world: &mut World) { return; }; if let Some(dialog) = &session.save_dialog { - info!("[edit-save] {:?} is shown; {} editor commands dropped", dialog.kind, pending.len()); + info!( + "[edit-save] {:?} is shown; {} editor commands dropped", + dialog.kind, + pending.len() + ); world.insert_resource(session); return; } for command in pending { + // A cancelled drag has an End gesture as well. Do not interpret it + // as a release that confirms the selection rectangle in this batch. + if pointer_cancelled && matches!(command, + EditCommand::RoadPointerCancel | EditCommand::RoadLongStart { .. } + | EditCommand::RoadLongEnd | EditCommand::RoadDragStart { .. } + | EditCommand::RoadDragMove { .. } | EditCommand::RoadDragEnd + ) { continue; } apply_command(world, &mut session, command); + session.road_camera_drag_claimed |= session.road.dragging; } let was_active = world.resource::().active; world.resource_mut::().active = session.phase != EditPhase::Idle; @@ -1726,8 +1947,7 @@ fn item_from_served( ) -> Result { let min = GridPosition::new(row.minimum.x, row.minimum.y, row.minimum.z); let max = GridPosition::new(row.maximum.x, row.maximum.y, row.maximum.z); - let (mut center, derived) = - moly_law::fixture::position::footprint_to_center_size(min, max)?; + let (mut center, derived) = moly_law::fixture::position::footprint_to_center_size(min, max)?; let _ = derived; center.y = row.center_y; Ok(EditableFixture { @@ -1757,10 +1977,7 @@ fn apply_rejected(code: moly_layout::RefusalCode) -> moly_layout::ApplyOutcome { /// writes the resulting rows back exactly as the equivalent manual commands /// would (selection cleared, phase browsing, draft dirty, removed rows to the /// offline inventory). The whole-layout save remains the only uplink. -pub fn apply_request( - world: &mut World, - request: &moly_layout::Apply, -) -> moly_layout::ApplyOutcome { +pub fn apply_request(world: &mut World, request: &moly_layout::Apply) -> moly_layout::ApplyOutcome { let Some(mut session) = world.remove_resource::() else { return apply_rejected(moly_layout::RefusalCode::UnknownPiece); }; @@ -1800,7 +2017,10 @@ fn apply_in_session( .collect(); let by_piece: std::collections::HashMap<_, _> = old.iter().cloned().collect(); let mut kept = std::collections::HashSet::new(); - let site_id = session.baseline.as_ref().map_or(0, FixturePlacements::site_id); + let site_id = session + .baseline + .as_ref() + .map_or(0, FixturePlacements::site_id); let mut rows = Vec::with_capacity(layout.rows.len()); for placed in &layout.rows { let uid = match by_piece.get(&placed.piece) { @@ -1860,11 +2080,19 @@ fn apply_in_session( } fn advance_recovery(world: &mut World) { actors::maintain(world); - let Some(mut session) = world.remove_resource::() else { return; }; - let readiness = session.pending_reload.as_ref().map(|pending| actors::reload_ready(world, pending)); + let Some(mut session) = world.remove_resource::() else { + return; + }; + let readiness = session + .pending_reload + .as_ref() + .map(|pending| actors::reload_ready(world, pending)); match readiness { Some(Ok(true)) => { - let exit_after = session.pending_reload.as_ref().is_some_and(|pending| pending.exit_after); + let exit_after = session + .pending_reload + .as_ref() + .is_some_and(|pending| pending.exit_after); if exit_after { match actors::restore(world) { Ok(()) => { @@ -1881,7 +2109,9 @@ fn advance_recovery(world: &mut World) { // editor commands behind a completed reload. session.pending_reload = None; session.changed(); - session.say(format!("场景已加载,但{error};仍处于编辑,请调整家具后再退出。")); + session.say(format!( + "场景已加载,但{error};仍处于编辑,请调整家具后再退出。" + )); } } } else { @@ -1915,7 +2145,15 @@ fn publish_view(world: &mut World) { let owned_revision = world .get_resource::() .map_or(0, |owned| owned.revision); - if view.revision == session.revision && view.owned_revision == owned_revision { + let environment_choices = if session.phase != EditPhase::Idle { + environment::choices(world) + } else { + Vec::new() + }; + if view.revision == session.revision + && view.owned_revision == owned_revision + && view.environment_choices == environment_choices + { return; } let selected = session @@ -1945,6 +2183,10 @@ fn publish_view(world: &mut World) { view }; let next = EditView { + road: road::view(world, session), + content: session.content, + environment: session.environment, + environment_choices, revision: session.revision, active, phase: session.phase, @@ -1976,6 +2218,7 @@ fn publish_view(world: &mut World) { owned_revision, can_save: active && session.selected.is_none() + && !road_pending(session) && session.pending_reload.is_none() && session.save_dialog.is_none(), feedback: session.feedback.clone(), @@ -1986,13 +2229,23 @@ fn publish_view(world: &mut World) { /// The owned fixture list of the edited site; the list's account is logged /// whenever it changes. fn owned_catalog_view(world: &World, session: &EditSession) -> Vec { - let site_id = session.baseline.as_ref().map_or(0, FixturePlacements::site_id); - let (rows, note) = match owned::catalog(world, site_id, &session.rows) { + let site_id = session + .baseline + .as_ref() + .map_or(0, FixturePlacements::site_id); + let (rows, note) = match owned::catalog_for( + world, + site_id, + &session.rows, + session.content == EditContentType::Road, + ) { Ok((rows, omitted)) => { let note = format!( "{} rows listed (placeable {:?}); left out: {} without a master row, {} not floor or rug (their own edit states), {} without an exported fixture view, {} with no placeable count", rows.len(), - rows.iter().map(|row| (row.fixture_id, row.texture_id, row.placeable())).collect::>(), + rows.iter() + .map(|row| (row.fixture_id, row.texture_id, row.placeable())) + .collect::>(), omitted.no_master, omitted.other_layout, omitted.no_model, @@ -2026,6 +2279,8 @@ pub struct FixtureEditPlugin; impl Plugin for FixtureEditPlugin { fn build(&self, app: &mut App) { edit_grid::install(app); + crate::fixture_put_limit::install(app); + road::install(app); app.init_resource::() .init_resource::() .init_resource::() @@ -2063,7 +2318,12 @@ impl Plugin for FixtureEditPlugin { ) .add_systems( Update, - (receive_commands, answer_save_dialog, apply_commands, advance_recovery) + ( + receive_commands, + answer_save_dialog, + apply_commands, + advance_recovery, + ) .chain() .in_set(FixtureEditSystems::Commands) .after(FixtureEditSystems::Input) @@ -2084,7 +2344,12 @@ impl Plugin for FixtureEditPlugin { publish_unsaved.after(FixtureEditSystems::Commands), ) .add_systems(Update, read_save_answer.after(apply_commands)) - .add_systems(Update, (field_build::read_answers, field_build::advance).chain().after(apply_commands)) + .add_systems( + Update, + (field_build::read_answers, field_build::advance) + .chain() + .after(apply_commands), + ) .add_systems( Update, autoplay::autoplay @@ -2117,7 +2382,10 @@ impl Plugin for FixtureEditPlugin { // Emoticon's explicit showcase can also create draws in // PostUpdate. Flush those exact producers before the same // visibility owner captures roots, then let Bevy propagate it. - (bevy::prelude::ApplyDeferred, actors::maintain_detached_views) + ( + bevy::prelude::ApplyDeferred, + actors::maintain_detached_views, + ) .chain() .after(crate::balloon::place) .after(crate::emoticon::advance) diff --git a/crates/game/moly-features/src/fixture_edit/edit_grid.rs b/crates/game/moly-features/src/fixture_edit/edit_grid.rs index 0fd53221..005287df 100644 --- a/crates/game/moly-features/src/fixture_edit/edit_grid.rs +++ b/crates/game/moly-features/src/fixture_edit/edit_grid.rs @@ -94,16 +94,16 @@ use bevy::render::render_resource::{ use bevy::shader::ShaderRef; use moly_assets::json::JsonAsset; use moly_assets::material_passes::SourceRenderState; -use moly_law::fixture::areas::{motion_area_bounds, GridBound}; -use moly_law::fixture::position::layout_type; use moly_law::fixture::GridPosition; +use moly_law::fixture::areas::{GridBound, motion_area_bounds}; +use moly_law::fixture::position::layout_type; use serde_json::Value; use super::assets::FixtureAreas; use super::layout_rules::zones_of; -use moly_layout::validate::{self as tile_rules, TileBox}; use super::{EditSession, FixtureEditSystems, PutStatus}; use crate::fixture::{EditableFixture, FixturePlacements}; +use moly_layout::validate::{self as tile_rules, TileBox}; const GRID: &str = "moly://layout-grid/grid.json"; const PHENOMENA: &str = "moly://phenomena/index.json"; @@ -671,7 +671,10 @@ fn read_docs(world: &mut World, grid: &mut EditGrid) { doc.uber.float("_PatternRepeat"), doc.uber.float("_FillRate"), doc.profiles.len(), - doc.profiles.iter().map(|p| p.key.as_str()).collect::>() + doc.profiles + .iter() + .map(|p| p.key.as_str()) + .collect::>() ), Err(error) => error!("[edit-grid] {GRID}: {error}; the layout grid is not drawn"), } @@ -783,7 +786,11 @@ fn track_profile(world: &mut World, grid: &mut EditGrid) { environment.phenomenon, environment.site, doc.profiles[index].key, - if found.is_none() { " (no row has the key: the table's first row)" } else { "" } + if found.is_none() { + " (no row has the key: the table's first row)" + } else { + "" + } ); grid.profile_generation += 1; ProfileState::Read { @@ -1235,13 +1242,7 @@ fn fill( .find(|flag| *state & **flag != 0) .map_or(0u8, |flag| flag.trailing_zeros() as u8 + 1); counts[n as usize] += 1; - let bit = |b: u8| -> u8 { - if n & b != 0 { - 255 - } else { - 0 - } - }; + let bit = |b: u8| -> u8 { if n & b != 0 { 255 } else { 0 } }; texels.extend([bit(4), bit(2), bit(1), 255]); } Fill { @@ -1262,6 +1263,14 @@ fn fill( /// The fill of the session's current draft, with its inputs from the world. fn current_fill(world: &World, tiles: &TileBox) -> Option<(Fill, bool)> { let session = world.get_resource::()?; + if session.content == super::EditContentType::Road { + let empty = FixtureAreas::default(); + let inputs = FillInputs { + areas: &empty, + zones: Some(&[]), + }; + return Some((fill(tiles, &[], None, &inputs), true)); + } let floor = world .get_resource::() .and_then(FixturePlacements::floor_grid)?; @@ -1393,8 +1402,8 @@ fn spawn(world: &mut World, grid: &mut EditGrid) { Some(Err(_)) => return, Some(Ok(doc)) => doc, }; - if mode != 2 { - warn!("[edit-grid] ShowGrid({mode}): only the floor grid (2) is built; not drawn"); + if !matches!(mode, 2 | 8) { + warn!("[edit-grid] ShowGrid({mode}): only field floor/road grids are built; not drawn"); grid.shown = None; return; } @@ -1426,7 +1435,9 @@ fn spawn(world: &mut World, grid: &mut EditGrid) { line_uniform(&doc.line, colours.line), line_uniform(&doc.dotted, colours.dotted), ) else { - error!("[edit-grid] ShowGrid({mode}): a line material lacks _LineRepeat or _LineFill; not drawn"); + error!( + "[edit-grid] ShowGrid({mode}): a line material lacks _LineRepeat or _LineFill; not drawn" + ); return; }; let Some((filled, areas_loaded)) = current_fill(world, &tiles) else { @@ -1501,18 +1512,42 @@ fn spawn(world: &mut World, grid: &mut EditGrid) { } info!( "[edit-grid] ShowGrid({mode}) (SetShowGrid floor): floor grid {}x{}x{} (level {}, layout {}); tile box x {}..={} z {}..={} (source frame, product cells x {}..={}); {} tiles = {}x{} of {TILE_SIZE} m; plane x {:.3}..{:.3} z {:.3}..{:.3} (product x {:.3}..{:.3}) at y {:.3} above the site origin {:.3}", - floor.width, floor.height, floor.depth, floor.level, floor.layout_id, - tiles.min[0], tiles.max[0], tiles.min[2], tiles.max[2], - source_cell_x(tiles.max[0]), source_cell_x(tiles.min[0]), - width * height, width, height, - min_x, max_x, min_z, max_z, -max_x, -min_x, - offset.y, origin + floor.width, + floor.height, + floor.depth, + floor.level, + floor.layout_id, + tiles.min[0], + tiles.max[0], + tiles.min[2], + tiles.max[2], + source_cell_x(tiles.max[0]), + source_cell_x(tiles.min[0]), + width * height, + width, + height, + min_x, + max_x, + min_z, + max_z, + -max_x, + -min_x, + offset.y, + origin ); info!( "[edit-grid] lines: {} constant-x (width {}) + {} constant-z (width {}); {} solid (every {SOLID_INTERVAL}th, {}) and {} dotted ({}); IsShowLine {}; entities solid {:?} dotted {:?}", - lines.vertical, doc.vertical_line_size, lines.horizontal, doc.horizontal_line_size, - lines.solid_lines, doc.line.name, lines.dotted_lines, doc.dotted.name, - doc.show_line, solid, dotted + lines.vertical, + doc.vertical_line_size, + lines.horizontal, + doc.horizontal_line_size, + lines.solid_lines, + doc.line.name, + lines.dotted_lines, + doc.dotted.name, + doc.show_line, + solid, + dotted ); info!( "[edit-grid] tile face: quad uv (0,0)..({width},{height}), state texture {width}x{height} RGBA8, {} ({}), entity {face:?}", @@ -1520,13 +1555,23 @@ fn spawn(world: &mut World, grid: &mut EditGrid) { ); info!( "[edit-grid] colours from {}: GridLine _Color1 {:?}, DottedGridLine _Color1 {:?}; face _TileSize {} _PatternRepeat {} _FillRate {}, highlight {:?} ({}), fixture exist {:?}, hided {:?}/{:?}, motion area {:?}/{:?}, motion disable {:?}/{:?}, conflict {:?}/{:?}", - colours.source, colours.line, colours.dotted, - colours.face.params.x, colours.face.params.y, colours.face.params.z, - colours.face.highlight, if can_place { "safe" } else { "alert" }, - colours.face.fixture_exist, colours.face.hided, colours.face.hided2, - colours.face.motion_area, colours.face.motion_area2, - colours.face.motion_disable, colours.face.motion_disable2, - colours.face.conflict, colours.face.conflict2 + colours.source, + colours.line, + colours.dotted, + colours.face.params.x, + colours.face.params.y, + colours.face.params.z, + colours.face.highlight, + if can_place { "safe" } else { "alert" }, + colours.face.fixture_exist, + colours.face.hided, + colours.face.hided2, + colours.face.motion_area, + colours.face.motion_area2, + colours.face.motion_disable, + colours.face.motion_disable2, + colours.face.conflict, + colours.face.conflict2 ); log_fill(revision, &filled, areas_loaded); grid.shown = Some(Shown { @@ -1549,13 +1594,31 @@ fn log_fill(revision: u64, filled: &Fill, areas_loaded: bool) { }; info!( "[edit-grid] fill (session revision {revision}): packed n counts [empty {}, highlight {}, fixture exist {}, hided {}, motion area {}, motion disable {}, conflict {}]; flags fixture exist {} motion area {} motion disable {} conflict {}; {} floor fixtures whose footprints cover {} cells and add-using {} cells; {zones}; {} fixtures with motion areas ({} disabled, {} of them by the highlight); selection's own motion area {:?}{}", - filled.counts[0], filled.counts[1], filled.counts[2], filled.counts[3], - filled.counts[4], filled.counts[5], filled.counts[6], - flagged(FIXTURE_EXIST), flagged(MOTION_AREA), flagged(MOTION_DISABLE), flagged(MOTION_CONFLICT), - filled.floor_views, filled.footprint_cells, filled.add_using_cells, - filled.motion_fixtures, filled.disabled_fixtures, filled.highlight_disabled, - filled.selection_motion.map(|disabled| if disabled { "disabled" } else { "area" }), - if areas_loaded { "" } else { "; the fixture area table is not loaded (no motion areas)" } + filled.counts[0], + filled.counts[1], + filled.counts[2], + filled.counts[3], + filled.counts[4], + filled.counts[5], + filled.counts[6], + flagged(FIXTURE_EXIST), + flagged(MOTION_AREA), + flagged(MOTION_DISABLE), + flagged(MOTION_CONFLICT), + filled.floor_views, + filled.footprint_cells, + filled.add_using_cells, + filled.motion_fixtures, + filled.disabled_fixtures, + filled.highlight_disabled, + filled + .selection_motion + .map(|disabled| if disabled { "disabled" } else { "area" }), + if areas_loaded { + "" + } else { + "; the fixture area table is not loaded (no motion areas)" + } ); } diff --git a/crates/game/moly-features/src/fixture_edit/environment.rs b/crates/game/moly-features/src/fixture_edit/environment.rs new file mode 100644 index 00000000..b69b5ce2 --- /dev/null +++ b/crates/game/moly-features/src/fixture_edit/environment.rs @@ -0,0 +1,147 @@ +//! The source environment list changes the layout draft; today's schedule +//! stays authoritative for the default cell. The ordinary weather publisher +//! applies this preview and, after save/discard, the committed home layout. +use super::{EditContentType, EditEnvironmentView, EditSession}; +use bevy::prelude::*; + +pub(super) fn choices(world: &World) -> Vec { + let mut rows = vec![EditEnvironmentView { + phenomena_id: None, + name: world + .get_resource::() + .map(|layouts| layouts.wording("WORD_PHENOMENA_DEFAULT").to_owned()) + .unwrap_or_default(), + }]; + if world + .get_resource::() + .is_some_and(|panel| panel.is_tutorial()) + { + return rows; + } + let (Some(user), Some(masters)) = ( + world.get_resource::(), + world.get_resource::(), + ) else { + return rows; + }; + for row in user.rows() { + if let Some(master) = masters.phenomena(row.mysekai_phenomena_id) { + rows.push(EditEnvironmentView { + phenomena_id: Some(master.id), + name: master.name.clone(), + }); + } + } + // ThumbnailData.Seq is MasterData.id (0 for default), not a separate master sequence column. + rows.sort_by_key(|row| row.phenomena_id.unwrap_or(0)); + rows +} + +pub(super) fn preview(world: &mut World, session: &EditSession) { + if let Some(base) = session + .baseline + .as_ref() + .filter(|base| base.site_type() == "home_site") + { + world.insert_resource(crate::field_modes::LayoutEnvironmentPreview { + site_id: base.site_id(), + phenomena_id: session.environment, + }); + } +} + +pub(super) fn select(world: &mut World, session: &mut EditSession, phenomena_id: Option) { + if session.content != EditContentType::Environment || session.saving.is_some() { + return; + } + if !choices(world) + .iter() + .any(|row| row.phenomena_id == phenomena_id) + { + session.say("该天气尚未解锁,未改变当前布置。"); + return; + } + let ready = world + .get_resource::() + .is_some_and(|icons| icons.environment(phenomena_id).is_some()); + let target = phenomena_id.or_else(|| { + world + .get_resource::() + .and_then(|today| today.phenomena_id()) + }); + let available = target.is_some_and(|id| { + world + .get_resource::() + .is_some_and(|catalogue| catalogue.0.iter().any(|option| option.id == id)) + }); + if !ready || !available { + session.say("天气资源仍在加载,请稍后重试。"); + return; + } + if session.environment != phenomena_id { + session.environment = phenomena_id; + session.changed(); + preview(world, session); + info!( + "[edit-environment] preview fixed {:?}; default resolves to today's weather; not saved", + phenomena_id + ); + } +} + +pub(super) fn select_content( + world: &mut World, + session: &mut EditSession, + content: EditContentType, +) { + let Some(base) = session.baseline.as_ref() else { + return; + }; + let allowed = if base.site_type() == "home_site" { + matches!( + content, + EditContentType::Outdoor | EditContentType::Road | EditContentType::Environment + ) + } else { + content == EditContentType::Floor + }; + if !allowed || session.content == content || session.saving.is_some() { + return; + } + if content == EditContentType::Environment { + let ready = world + .get_resource::() + .is_some_and(|icons| icons.environment(None).is_some()) + && world + .get_resource::() + .zip(world.get_resource::()) + .is_some_and(|(layouts, server)| layouts.ready("EditorEnvironmentCell", server)); + if !ready { + session.say("天气列表仍在加载或当前资源版本未提供,请稍后重试。"); + return; + } + } + session.cancel_selection(); + session.road = super::road::RoadSession::default(); + session.content = content; + session.changed(); + if content == EditContentType::Environment { + super::edit_grid::hide(world); + if let Err(reason) = crate::floor_edit_camera::enter_environment(world) { + warn!("[edit-environment] {reason}"); + } + } else { + super::edit_grid::show( + world, + if content == EditContentType::Road { + 8 + } else { + 2 + }, + ); + if let Err(reason) = crate::floor_edit_camera::enter(world) { + warn!("[edit-environment] {reason}"); + } + } + info!("[edit-selector] selected {content:?}"); +} diff --git a/crates/game/moly-features/src/fixture_edit/game_state.rs b/crates/game/moly-features/src/fixture_edit/game_state.rs index b5db9d36..12b40458 100644 --- a/crates/game/moly-features/src/fixture_edit/game_state.rs +++ b/crates/game/moly-features/src/fixture_edit/game_state.rs @@ -66,6 +66,7 @@ pub(super) fn enter(world: &mut World) { /// `EditGameState.OnExit`, after the session ended (actors restored), and /// then Normal's entry for the camera. pub(super) fn exit(world: &mut World) { + world.remove_resource::(); if !world .get_resource::() .is_some_and(|state| state.grid.is_some() || state.tweets_hidden || state.fog_disabled) diff --git a/crates/game/moly-features/src/fixture_edit/input.rs b/crates/game/moly-features/src/fixture_edit/input.rs index f4473f8d..e2cf8345 100644 --- a/crates/game/moly-features/src/fixture_edit/input.rs +++ b/crates/game/moly-features/src/fixture_edit/input.rs @@ -28,7 +28,7 @@ pub fn read_keyboard( if keys.just_pressed(KeyCode::Escape) { actions.write(if session.exit_dialog { EditCommand::KeepEditing - } else if session.selected.is_some() { + } else if session.selected.is_some() || !session.road.planned.is_empty() { EditCommand::Cancel } else { EditCommand::RequestExit @@ -114,6 +114,10 @@ pub fn read_pointer( mut actions: MessageWriter, ) { if session.phase == EditPhase::Idle + || matches!( + session.content, + super::EditContentType::Environment | super::EditContentType::Road + ) || session.exit_dialog || session.clean_up_dialog || consumed.0 diff --git a/crates/game/moly-features/src/fixture_edit/owned.rs b/crates/game/moly-features/src/fixture_edit/owned.rs index 1aec595a..1c9cfad2 100644 --- a/crates/game/moly-features/src/fixture_edit/owned.rs +++ b/crates/game/moly-features/src/fixture_edit/owned.rs @@ -77,6 +77,7 @@ fn parse_master(value: &Value) -> Result, String> { let layout = match row["layoutType"].as_str() { Some("floor") => Some(layout_type::FLOOR), Some("rug") => Some(layout_type::RUG), + Some("road") => Some(layout_type::ROAD), Some(_) => None, None => return Err(format!("fixture master row {id} has no layoutType")), }; @@ -170,6 +171,15 @@ pub(super) fn catalog( world: &World, site_id: u32, draft: &[EditableFixture], +) -> Result<(Vec, Omitted), String> { + catalog_for(world, site_id, draft, false) +} + +pub(super) fn catalog_for( + world: &World, + site_id: u32, + draft: &[EditableFixture], + road: bool, ) -> Result<(Vec, Omitted), String> { let owned = world .get_resource::() @@ -198,6 +208,10 @@ pub(super) fn catalog( omitted.other_layout += 1; continue; }; + if (layout == layout_type::ROAD) != road { + omitted.other_layout += 1; + continue; + } if !paths.contains_key(&entry.package) { omitted.no_model += 1; continue; diff --git a/crates/game/moly-features/src/fixture_edit/presentation.rs b/crates/game/moly-features/src/fixture_edit/presentation.rs index bd08df6a..ebd74aae 100644 --- a/crates/game/moly-features/src/fixture_edit/presentation.rs +++ b/crates/game/moly-features/src/fixture_edit/presentation.rs @@ -41,6 +41,7 @@ struct ProjectionStamp { /// paths can remove only the editor's own overlays and restore captured poses. pub(super) fn clear(world: &mut World) { world.remove_resource::(); + world.remove_resource::(); // Preview ownership ends with the session. The catalog keeps paths, not // every GLB ever browsed; committed fixtures own their own source handles. if let Some(mut candidates) = world.get_resource_mut::() { @@ -96,7 +97,14 @@ pub(super) fn sync_visuals(world: &mut World) { return; } let session = world.resource::(); - let rows = session.rows.clone(); + let rows = super::road::projection(session); + let hide_fixtures = + session.content == super::EditContentType::Road && session.road.hide_fixtures; + let road_rows: Vec<_> = rows + .iter() + .filter(|row| row.layout == moly_law::fixture::position::layout_type::ROAD) + .cloned() + .collect(); let selected = session .selected .as_ref() @@ -146,7 +154,9 @@ pub(super) fn sync_visuals(world: &mut World) { .as_ref() .filter(|item| item.uid == uid) .or_else(|| by_uid.get(uid.as_str()).copied()); - let target_visibility = if item.is_some() { + let target_visibility = if item.is_some_and(|row| { + !hide_fixtures || row.layout == moly_law::fixture::position::layout_type::ROAD + }) { origin.visibility } else { Visibility::Hidden @@ -181,7 +191,31 @@ pub(super) fn sync_visuals(world: &mut World) { } } } + world.insert_resource(crate::fixture::road::RoadDraft { + revision, + rows: road_rows, + }); let waiting_assets = !sync_previews(world, &transient); + let previews: Vec<_> = world + .query::<( + Entity, + &DraftPreview, + Option<&crate::render_inputs::FixtureVisualReady>, + )>() + .iter(world) + .filter_map(|(entity, preview, ready)| ready.map(|_| (entity, preview.uid.clone()))) + .collect(); + for (entity, uid) in previews { + let visible = transient.iter().any(|row| { + row.uid == uid + && (!hide_fixtures || row.layout == moly_law::fixture::position::layout_type::ROAD) + }); + world.entity_mut(entity).insert(if visible { + Visibility::Inherited + } else { + Visibility::Hidden + }); + } sync_marker(world, selected.as_ref(), status); world.insert_resource(ProjectionStamp { revision, @@ -191,7 +225,9 @@ pub(super) fn sync_visuals(world: &mut World) { } fn sync_previews(world: &mut World, rows: &[EditableFixture]) -> bool { - world.resource_mut::().glbs + world + .resource_mut::() + .glbs .retain(|package, _| rows.iter().any(|row| &row.package == package)); let mut ready = true; let previews: Vec<_> = world @@ -209,6 +245,12 @@ fn sync_previews(world: &mut World, rows: &[EditableFixture]) -> bool { continue; }; if let Some((entity, _)) = previews.iter().find(|(_, uid)| *uid == row.uid) { + if world + .get::(*entity) + .is_none() + { + ready = false; + } if let Some(mut transform) = world.get_mut::(*entity) { if transform.translation != pose.translation || transform.rotation != pose.rotation { @@ -252,16 +294,23 @@ fn sync_previews(world: &mut World, rows: &[EditableFixture]) -> bool { // Share source material preparation, but not committed gameplay roles. // The material owner reveals this root after the scene is expanded and // all source replacements are ready, exactly as for saved furniture. - world.spawn(( + ready = false; + let mut entity = world.spawn(( WorldAssetRoot(scene), crate::fixture::FixtureVisualRoot { layout: row.layout }, - crate::fixture_colors::FixtureColorChoice { package: row.package.clone(), texture_id: row.texture_id }, + crate::fixture_colors::FixtureColorChoice { + package: row.package.clone(), + texture_id: row.texture_id, + }, DraftPreview { uid: row.uid.clone(), }, pose, Visibility::Hidden, )); + if row.layout == moly_law::fixture::position::layout_type::ROAD { + entity.insert(crate::fixture::road::RoadPreview(row.uid.clone())); + } } ready } diff --git a/crates/game/moly-features/src/fixture_edit/put_effect.rs b/crates/game/moly-features/src/fixture_edit/put_effect.rs index fe8fbe95..aaf80639 100644 --- a/crates/game/moly-features/src/fixture_edit/put_effect.rs +++ b/crates/game/moly-features/src/fixture_edit/put_effect.rs @@ -20,10 +20,12 @@ //! `FixtureController.PlayPutSound` (the same table lookup, without the //! effect) is what selecting a placed fixture plays after its pick sound. //! -//! Callers the product does not build: the wall put (`ShowWallPutEffect`, -//! rotation from the wall direction), fence and road placement -//! (`ShowFieldPlacementEffect` with cloned copies), the fence edit view and -//! the site view's own put effect. Ground editing is the only editor here. +//! Road placement shares this owner: `ShowFieldPlacementEffect` reuses the +//! instance for the first straight segment and clones it for later segments +//! when the direction changes. Each clone has independent source nodes and +//! particle systems, and is destroyed 3.0 s after SetEffect and PlayPutSound. +//! The caller supplies the segment size, mean global position and first +//! fixture's sound identity. Wall and fence editing are separate callers. //! //! Named differences: //! - The instance is held inactive once its scene exists and its particle @@ -70,6 +72,8 @@ const SAMPLE_SECONDS: f32 = 3.5; const SAMPLE_STEP: f32 = 0.25; /// How long the first put waits for the particle host's preparation. const PREPARE_WAIT: f32 = 10.0; +/// `FixturePutEffect.ShowFieldPutEffect`: Destroy(copy, 3.0). +const CLONE_SECONDS: f32 = 3.0; fn particle_doc_path() -> String { format!("moly://fixture-particles-v2/{PACKAGE}.json") @@ -153,7 +157,11 @@ impl SoundTables { id as i32, moly_layout::FixtureMaster { package: format!("mysekai__fixture__{bundle}"), - grid_size: Vector3Int::new(int("gridWidth")?, int("gridHeight")?, int("gridDepth")?), + grid_size: Vector3Int::new( + int("gridWidth")?, + int("gridHeight")?, + int("gridDepth")?, + ), layout: match settable_layout { "floor" => layout_type::FLOOR, "rug" => layout_type::RUG, @@ -207,6 +215,7 @@ enum Instance { }, Spawned { root: Entity, + scene: Handle, doc: Arc, /// When its systems were handed to the particle host. planned: Option, @@ -224,6 +233,17 @@ struct Put { size: Vector3Int, source: &'static str, at: f32, + clone: bool, +} + +/// A source clone of the shared prefab, prepared independently and retired +/// after its own activation. The shared instance remains reusable. +struct PutClone { + root: Entity, + doc: Arc, + put: Option, + planned: Option, + played_at: Option, } struct Sample { @@ -241,6 +261,7 @@ pub struct FixturePutEffect { queue: Vec, puts: u32, sample: Option, + clones: Vec, } pub(super) fn request_tables(mut commands: Commands, server: Res) { @@ -253,6 +274,7 @@ pub(super) fn request_tables(mut commands: Commands, server: Res) { queue: Vec::new(), puts: 0, sample: None, + clones: Vec::new(), }); } @@ -302,24 +324,34 @@ impl FixturePutEffect { let tables = match &self.tables { Tables::Read(Ok(tables)) => tables, Tables::Read(Err(error)) => { - warn!("[fixture-put] PlayPutSound for fixture {fixture_id} ({source}): {error}; no sound"); + warn!( + "[fixture-put] PlayPutSound for fixture {fixture_id} ({source}): {error}; no sound" + ); return; } Tables::Requested { .. } => { - warn!("[fixture-put] PlayPutSound for fixture {fixture_id} ({source}): the fixture and put sound tables are still loading; no sound"); + warn!( + "[fixture-put] PlayPutSound for fixture {fixture_id} ({source}): the fixture and put sound tables are still loading; no sound" + ); return; } }; let Some(&id) = tables.put_sound_of.get(&fixture_id) else { - warn!("[fixture-put] PlayPutSound: fixture {fixture_id} ({source}) has no master row; no sound"); + warn!( + "[fixture-put] PlayPutSound: fixture {fixture_id} ({source}) has no master row; no sound" + ); return; }; let Some(cue) = tables.cue_of.get(&id) else { // GetMysekaiFixturePutSound returns nothing: the source plays nothing. - info!("[fixture-put] PlayPutSound: fixture {fixture_id} put sound id {id} has no put sound row; the source plays nothing"); + info!( + "[fixture-put] PlayPutSound: fixture {fixture_id} put sound id {id} has no put sound row; the source plays nothing" + ); return; }; - info!("[fixture-put] PlayPutSound({id}) for fixture {fixture_id} ({source}): SE one-shot {cue}"); + info!( + "[fixture-put] PlayPutSound({id}) for fixture {fixture_id} ({source}): SE one-shot {cue}" + ); // The same one-shot call as the editor's other sounds; the product's // class mapping puts layout-edit cues in the UI class. if let Some(mut se) = world.get_resource_mut::() { @@ -380,6 +412,37 @@ pub(crate) fn show(world: &mut World, item: &EditableFixture, source: &'static s size, source, at: now, + clone: false, + }); +} + +/// One `ShowFieldPutCenterEffect` segment. Position is the arithmetic mean +/// of every controller's GlobalPosition; fixture_id belongs to the first +/// controller. The source starts with clone=false, then uses true for the +/// remaining segments whenever the placement direction changes. +pub(super) fn show_field_placement( + world: &mut World, + fixture_id: i32, + position: Vec3, + size: Vector3Int, + clone: bool, + source: &'static str, +) { + let at = world.get_resource::