From c0efb3915ba1e8288b4897f846c71488beb0a07f Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Sat, 14 Feb 2026 22:15:58 +0800 Subject: [PATCH 1/6] Add: states to use all 7 rows --- assets/sprite-packs/default/manifest.json | 67 ++++++++++++++++++-- assets/sprite-packs/demogorgon/manifest.json | 67 ++++++++++++++++++-- assets/sprite-packs/ferris/manifest.json | 67 ++++++++++++++++++-- crates/sprimo-app/src/main.rs | 2 +- crates/sprimo-runtime-core/src/lib.rs | 18 +++++- docs/SPRITE_PACK_SCHEMA.md | 17 +++++ docs/TAURI_FRONTEND_DESIGN.md | 8 +++ issues/issue6.md | 57 +++++++++++++++++ 8 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 issues/issue6.md diff --git a/assets/sprite-packs/default/manifest.json b/assets/sprite-packs/default/manifest.json index 299bf24..5612651 100644 --- a/assets/sprite-packs/default/manifest.json +++ b/assets/sprite-packs/default/manifest.json @@ -7,25 +7,80 @@ "animations": [ { "name": "idle", - "fps": 6, - "frames": [0, 1] + "fps": 8, + "frames": [0, 1, 2, 3, 4, 5, 6, 7] }, { "name": "active", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "happy", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "love", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "excited", + "fps": 8, + "frames": [16, 17, 18, 19, 20, 21, 22, 23] + }, + { + "name": "celebrate", "fps": 10, - "frames": [1, 0] + "frames": [16, 17, 18, 19, 20, 21, 22, 23], + "one_shot": true }, { "name": "success", "fps": 10, - "frames": [0, 1, 0], + "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, + { + "name": "sleepy", + "fps": 8, + "frames": [24, 25, 26, 27, 28, 29, 30, 31] + }, + { + "name": "snoring", + "fps": 8, + "frames": [24, 25, 26, 27, 28, 29, 30, 31] + }, + { + "name": "working", + "fps": 8, + "frames": [32, 33, 34, 35, 36, 37, 38, 39] + }, + { + "name": "angry", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "surprised", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "shy", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, { "name": "error", "fps": 8, - "frames": [1, 0, 1], - "one_shot": true + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "dragging", + "fps": 8, + "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], "anchor": { diff --git a/assets/sprite-packs/demogorgon/manifest.json b/assets/sprite-packs/demogorgon/manifest.json index a32e656..b99a648 100644 --- a/assets/sprite-packs/demogorgon/manifest.json +++ b/assets/sprite-packs/demogorgon/manifest.json @@ -7,25 +7,80 @@ "animations": [ { "name": "idle", - "fps": 6, - "frames": [0, 1] + "fps": 8, + "frames": [0, 1, 2, 3, 4, 5, 6, 7] }, { "name": "active", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "happy", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "love", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "excited", + "fps": 8, + "frames": [16, 17, 18, 19, 20, 21, 22, 23] + }, + { + "name": "celebrate", "fps": 10, - "frames": [1, 0] + "frames": [16, 17, 18, 19, 20, 21, 22, 23], + "one_shot": true }, { "name": "success", "fps": 10, - "frames": [0, 1, 0], + "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, + { + "name": "sleepy", + "fps": 8, + "frames": [24, 25, 26, 27, 28, 29, 30, 31] + }, + { + "name": "snoring", + "fps": 8, + "frames": [24, 25, 26, 27, 28, 29, 30, 31] + }, + { + "name": "working", + "fps": 8, + "frames": [32, 33, 34, 35, 36, 37, 38, 39] + }, + { + "name": "angry", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "surprised", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "shy", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, { "name": "error", "fps": 8, - "frames": [1, 0, 1], - "one_shot": true + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "dragging", + "fps": 8, + "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], "anchor": { diff --git a/assets/sprite-packs/ferris/manifest.json b/assets/sprite-packs/ferris/manifest.json index 8618138..ed3e514 100644 --- a/assets/sprite-packs/ferris/manifest.json +++ b/assets/sprite-packs/ferris/manifest.json @@ -7,25 +7,80 @@ "animations": [ { "name": "idle", - "fps": 6, - "frames": [0, 1] + "fps": 8, + "frames": [0, 1, 2, 3, 4, 5, 6, 7] }, { "name": "active", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "happy", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "love", + "fps": 8, + "frames": [8, 9, 10, 11, 12, 13, 14, 15] + }, + { + "name": "excited", + "fps": 8, + "frames": [16, 17, 18, 19, 20, 21, 22, 23] + }, + { + "name": "celebrate", "fps": 10, - "frames": [1, 0] + "frames": [16, 17, 18, 19, 20, 21, 22, 23], + "one_shot": true }, { "name": "success", "fps": 10, - "frames": [0, 1, 0], + "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, + { + "name": "sleepy", + "fps": 8, + "frames": [24, 25, 26, 27, 28, 29, 30, 31] + }, + { + "name": "snoring", + "fps": 8, + "frames": [24, 25, 26, 27, 28, 29, 30, 31] + }, + { + "name": "working", + "fps": 8, + "frames": [32, 33, 34, 35, 36, 37, 38, 39] + }, + { + "name": "angry", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "surprised", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "shy", + "fps": 8, + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, { "name": "error", "fps": 8, - "frames": [1, 0, 1], - "one_shot": true + "frames": [40, 41, 42, 43, 44, 45, 46, 47] + }, + { + "name": "dragging", + "fps": 8, + "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], "anchor": { diff --git a/crates/sprimo-app/src/main.rs b/crates/sprimo-app/src/main.rs index 7ad9db9..4635e93 100644 --- a/crates/sprimo-app/src/main.rs +++ b/crates/sprimo-app/src/main.rs @@ -1034,7 +1034,7 @@ fn default_animation_for_state(state: FrontendState) -> &'static str { FrontendState::Active => "active", FrontendState::Success => "success", FrontendState::Error => "error", - FrontendState::Dragging => "idle", + FrontendState::Dragging => "dragging", FrontendState::Hidden => "idle", } } diff --git a/crates/sprimo-runtime-core/src/lib.rs b/crates/sprimo-runtime-core/src/lib.rs index 7ceda69..c3ea898 100644 --- a/crates/sprimo-runtime-core/src/lib.rs +++ b/crates/sprimo-runtime-core/src/lib.rs @@ -252,7 +252,7 @@ fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'s sprimo_protocol::v1::FrontendState::Active => "active", sprimo_protocol::v1::FrontendState::Success => "success", sprimo_protocol::v1::FrontendState::Error => "error", - sprimo_protocol::v1::FrontendState::Dragging => "idle", + sprimo_protocol::v1::FrontendState::Dragging => "dragging", sprimo_protocol::v1::FrontendState::Hidden => "idle", } } @@ -284,6 +284,22 @@ mod tests { assert_eq!(snapshot.current_animation, "active"); } + #[test] + fn dragging_state_maps_to_dragging_animation() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("config.toml"); + let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) + .expect("core init"); + core.apply_command(&FrontendCommand::SetState { + state: FrontendState::Dragging, + ttl_ms: None, + }) + .expect("apply"); + let snapshot = core.snapshot().read().expect("snapshot lock").clone(); + assert_eq!(snapshot.state, FrontendState::Dragging); + assert_eq!(snapshot.current_animation, "dragging"); + } + #[test] fn click_through_flag_is_ignored_and_forced_false() { let temp = TempDir::new().expect("tempdir"); diff --git a/docs/SPRITE_PACK_SCHEMA.md b/docs/SPRITE_PACK_SCHEMA.md index 6d4b72a..4056765 100644 --- a/docs/SPRITE_PACK_SCHEMA.md +++ b/docs/SPRITE_PACK_SCHEMA.md @@ -75,3 +75,20 @@ For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`: - `frame_height = image_height / 7` - manifest `frame_width` and `frame_height` are ignored for this case. - animation frame indices are validated against the fixed grid frame count (`56`). + +## Recommended 7x8 Row Semantics + +For `sprite.png` packs using the fixed `8x7` topology, the project convention is: + +- row 1 (`0..7`): `idle` +- row 2 (`8..15`): `happy`, `love`, compatibility alias `active` +- row 3 (`16..23`): `excited`, `celebrate`, compatibility alias `success` +- row 4 (`24..31`): `sleepy`, `snoring` +- row 5 (`32..39`): `working` +- row 6 (`40..47`): `angry`, `surprised`, `shy`, compatibility alias `error` +- row 7 (`48..55`): `dragging` + +Default one-shot policy: + +- `celebrate` and `success` are one-shot. +- other row animations loop by default. diff --git a/docs/TAURI_FRONTEND_DESIGN.md b/docs/TAURI_FRONTEND_DESIGN.md index d1ed744..c420365 100644 --- a/docs/TAURI_FRONTEND_DESIGN.md +++ b/docs/TAURI_FRONTEND_DESIGN.md @@ -60,6 +60,14 @@ Frontend: from runtime snapshot events. - For `sprite.png` packs in tauri runtime, frame size is now derived from atlas dimensions with a fixed `8x7` grid topology to keep splitting stable across packaged asset resolutions. +- `sprite.png` animation naming now follows row semantics with backward-compatible aliases: + - row1: `idle` + - row2: `happy`/`love` + alias `active` + - row3: `excited`/`celebrate` + alias `success` + - row4: `sleepy`/`snoring` + - row5: `working` + - row6: `angry`/`surprised`/`shy` + alias `error` + - row7: `dragging` - React/Vite frontend now supports two window modes: - `main`: transparent overlay sprite renderer - `settings`: pop-out settings window for character and window controls diff --git a/issues/issue6.md b/issues/issue6.md new file mode 100644 index 0000000..d4f7598 --- /dev/null +++ b/issues/issue6.md @@ -0,0 +1,57 @@ +## Title + +Adopt row-based 7x8 sprite animation semantics with backward-compatible state aliases. + +## Severity + +P2 + +## Summary + +Standardize `sprite.png` packs so each of the 7 rows maps to semantic animation groups, while +keeping runtime compatibility with existing state names (`active`, `success`, `error`). + +## Scope + +- `assets/sprite-packs/{default,ferris,demogorgon}/manifest.json` +- `crates/sprimo-runtime-core/src/lib.rs` +- `crates/sprimo-app/src/main.rs` +- docs: + - `docs/SPRITE_PACK_SCHEMA.md` + - `docs/TAURI_FRONTEND_DESIGN.md` + +## Row Mapping Contract + +- row 1 (`0..7`): `idle` +- row 2 (`8..15`): `happy`, `love`, alias `active` +- row 3 (`16..23`): `excited`, `celebrate`, alias `success` +- row 4 (`24..31`): `sleepy`, `snoring` +- row 5 (`32..39`): `working` +- row 6 (`40..47`): `angry`, `surprised`, `shy`, alias `error` +- row 7 (`48..55`): `dragging` + +One-shot defaults: + +- `celebrate` and `success`: one-shot +- all others: loop + +## Implementation Notes + +1. Runtime state mapping updated: +- `Dragging` now maps to `"dragging"` instead of `"idle"` in runtime-core and Bevy frontend. +2. All bundled sprite-pack manifests now expose row-based names and compatibility aliases. +3. Added runtime-core unit test to confirm `SetState::Dragging` selects `"dragging"`. +4. Updated schema/design docs to formalize the row convention. + +## Verification + +### Commands Run + +- [x] `cargo test -p sprimo-runtime-core` +- [x] `cargo check -p sprimo-tauri` +- [x] `cargo check -p sprimo-app` + +### Result + +- Status: `Fix Implemented` +- Notes: packaged runtime visual verification pending. -- 2.49.1 From e5417b67999667346701e3c9b9f057d1bfd0f5e7 Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Sat, 14 Feb 2026 22:49:28 +0800 Subject: [PATCH 2/6] Add: backend testing script for new states --- docs/RANDOM_BACKEND_TESTING.md | 7 +++++++ scripts/random_backend_tester.py | 25 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/RANDOM_BACKEND_TESTING.md b/docs/RANDOM_BACKEND_TESTING.md index d44c095..65d7e5e 100644 --- a/docs/RANDOM_BACKEND_TESTING.md +++ b/docs/RANDOM_BACKEND_TESTING.md @@ -18,6 +18,13 @@ Supporting checks: - `GET /v1/health` - `GET /v1/state` (periodic sampling) +Animation traffic now targets row-based sprite names plus compatibility aliases: + +- semantic names: `idle`, `happy`, `love`, `excited`, `celebrate`, `sleepy`, `snoring`, + `working`, `angry`, `surprised`, `shy`, `dragging` +- compatibility aliases: `active`, `success`, `error` +- one intentional unknown name is still included to keep invalid animation-path coverage + ## Prerequisites - Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`). diff --git a/scripts/random_backend_tester.py b/scripts/random_backend_tester.py index 7c80baa..a72ba19 100644 --- a/scripts/random_backend_tester.py +++ b/scripts/random_backend_tester.py @@ -18,6 +18,27 @@ from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +ANIMATION_NAMES = ( + "idle", + "happy", + "love", + "excited", + "celebrate", + "sleepy", + "snoring", + "working", + "angry", + "surprised", + "shy", + "dragging", + # Backward-compatible aliases mapped in runtime/manifests. + "active", + "success", + "error", + # Intentionally invalid to keep unknown-animation traffic coverage. + "unknown_anim", +) + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -205,9 +226,7 @@ def random_valid_command(rng: random.Random) -> dict[str, Any]: if pick == "play_animation": payload = { - "name": rng.choice( - ["idle", "dance", "typing", "celebrate", "error", "unknown_anim"] - ), + "name": rng.choice(ANIMATION_NAMES), "priority": rng.randint(0, 10), "duration_ms": rng.choice([None, 250, 500, 1000, 3000]), "interrupt": rng.choice([None, True, False]), -- 2.49.1 From f20ed1fd9d3250bc82d09be8d19b06ed70104551 Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Sun, 15 Feb 2026 09:40:51 +0800 Subject: [PATCH 3/6] Add: global factor controlling fps base interval --- assets/sprite-packs/default/manifest.json | 30 +++---- assets/sprite-packs/demogorgon/manifest.json | 30 +++---- assets/sprite-packs/ferris/manifest.json | 30 +++---- crates/sprimo-config/src/lib.rs | 4 + crates/sprimo-runtime-core/src/lib.rs | 68 ++++++++++++++ crates/sprimo-tauri/src/main.rs | 32 +++++++ docs/CONFIG_REFERENCE.md | 4 + docs/SPRITE_PACK_SCHEMA.md | 5 ++ docs/TAURI_RUNTIME_TESTING.md | 15 +++- frontend/tauri-ui/src/main.tsx | 95 +++++++++++++++++++- frontend/tauri-ui/src/renderer/pixi_pet.ts | 17 +++- issues/issue6.md | 11 +++ 12 files changed, 289 insertions(+), 52 deletions(-) diff --git a/assets/sprite-packs/default/manifest.json b/assets/sprite-packs/default/manifest.json index 5612651..b133381 100644 --- a/assets/sprite-packs/default/manifest.json +++ b/assets/sprite-packs/default/manifest.json @@ -7,79 +7,79 @@ "animations": [ { "name": "idle", - "fps": 8, + "fps": 1, "frames": [0, 1, 2, 3, 4, 5, 6, 7] }, { "name": "active", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "happy", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "love", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "excited", - "fps": 8, + "fps": 1, "frames": [16, 17, 18, 19, 20, 21, 22, 23] }, { "name": "celebrate", - "fps": 10, + "fps": 2, "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, { "name": "success", - "fps": 10, + "fps": 2, "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, { "name": "sleepy", - "fps": 8, + "fps": 1, "frames": [24, 25, 26, 27, 28, 29, 30, 31] }, { "name": "snoring", - "fps": 8, + "fps": 1, "frames": [24, 25, 26, 27, 28, 29, 30, 31] }, { "name": "working", - "fps": 8, + "fps": 1, "frames": [32, 33, 34, 35, 36, 37, 38, 39] }, { "name": "angry", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "surprised", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "shy", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "error", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "dragging", - "fps": 8, + "fps": 1, "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], diff --git a/assets/sprite-packs/demogorgon/manifest.json b/assets/sprite-packs/demogorgon/manifest.json index b99a648..3908fa8 100644 --- a/assets/sprite-packs/demogorgon/manifest.json +++ b/assets/sprite-packs/demogorgon/manifest.json @@ -7,79 +7,79 @@ "animations": [ { "name": "idle", - "fps": 8, + "fps": 1, "frames": [0, 1, 2, 3, 4, 5, 6, 7] }, { "name": "active", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "happy", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "love", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "excited", - "fps": 8, + "fps": 1, "frames": [16, 17, 18, 19, 20, 21, 22, 23] }, { "name": "celebrate", - "fps": 10, + "fps": 2, "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, { "name": "success", - "fps": 10, + "fps": 2, "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, { "name": "sleepy", - "fps": 8, + "fps": 1, "frames": [24, 25, 26, 27, 28, 29, 30, 31] }, { "name": "snoring", - "fps": 8, + "fps": 1, "frames": [24, 25, 26, 27, 28, 29, 30, 31] }, { "name": "working", - "fps": 8, + "fps": 1, "frames": [32, 33, 34, 35, 36, 37, 38, 39] }, { "name": "angry", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "surprised", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "shy", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "error", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "dragging", - "fps": 8, + "fps": 1, "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], diff --git a/assets/sprite-packs/ferris/manifest.json b/assets/sprite-packs/ferris/manifest.json index ed3e514..b421ab1 100644 --- a/assets/sprite-packs/ferris/manifest.json +++ b/assets/sprite-packs/ferris/manifest.json @@ -7,79 +7,79 @@ "animations": [ { "name": "idle", - "fps": 8, + "fps": 1, "frames": [0, 1, 2, 3, 4, 5, 6, 7] }, { "name": "active", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "happy", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "love", - "fps": 8, + "fps": 1, "frames": [8, 9, 10, 11, 12, 13, 14, 15] }, { "name": "excited", - "fps": 8, + "fps": 1, "frames": [16, 17, 18, 19, 20, 21, 22, 23] }, { "name": "celebrate", - "fps": 10, + "fps": 2, "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, { "name": "success", - "fps": 10, + "fps": 2, "frames": [16, 17, 18, 19, 20, 21, 22, 23], "one_shot": true }, { "name": "sleepy", - "fps": 8, + "fps": 1, "frames": [24, 25, 26, 27, 28, 29, 30, 31] }, { "name": "snoring", - "fps": 8, + "fps": 1, "frames": [24, 25, 26, 27, 28, 29, 30, 31] }, { "name": "working", - "fps": 8, + "fps": 1, "frames": [32, 33, 34, 35, 36, 37, 38, 39] }, { "name": "angry", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "surprised", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "shy", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "error", - "fps": 8, + "fps": 1, "frames": [40, 41, 42, 43, 44, 45, 46, 47] }, { "name": "dragging", - "fps": 8, + "fps": 1, "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], diff --git a/crates/sprimo-config/src/lib.rs b/crates/sprimo-config/src/lib.rs index 14110ff..62a34ad 100644 --- a/crates/sprimo-config/src/lib.rs +++ b/crates/sprimo-config/src/lib.rs @@ -159,6 +159,7 @@ pub enum FrontendBackend { pub struct FrontendConfig { pub backend: FrontendBackend, pub debug_overlay_visible: bool, + pub tauri_animation_slowdown_factor: u8, } impl Default for FrontendConfig { @@ -166,6 +167,7 @@ impl Default for FrontendConfig { Self { backend: FrontendBackend::Bevy, debug_overlay_visible: false, + tauri_animation_slowdown_factor: 3, } } } @@ -225,11 +227,13 @@ mod tests { config.window.x = 42.0; config.frontend.backend = super::FrontendBackend::Tauri; config.frontend.debug_overlay_visible = true; + config.frontend.tauri_animation_slowdown_factor = 7; save(&path, &config).expect("save"); let (_, loaded) = load_or_create_at(&path).expect("reload"); assert!((loaded.window.x - 42.0).abs() < f32::EPSILON); assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri); assert!(loaded.frontend.debug_overlay_visible); + assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7); } } diff --git a/crates/sprimo-runtime-core/src/lib.rs b/crates/sprimo-runtime-core/src/lib.rs index c3ea898..d1d7a4d 100644 --- a/crates/sprimo-runtime-core/src/lib.rs +++ b/crates/sprimo-runtime-core/src/lib.rs @@ -8,6 +8,9 @@ use tokio::runtime::Runtime; use tokio::sync::{mpsc, Mutex}; use tracing::warn; +const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1; +const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 20; + #[derive(Debug, Error)] pub enum RuntimeCoreError { #[error("{0}")] @@ -104,6 +107,32 @@ impl RuntimeCore { self.persist_config() } + pub fn frontend_tauri_animation_slowdown_factor(&self) -> Result { + let guard = self + .config + .read() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + Ok(clamp_tauri_animation_slowdown_factor( + guard.frontend.tauri_animation_slowdown_factor, + )) + } + + pub fn set_frontend_tauri_animation_slowdown_factor( + &self, + value: u8, + ) -> Result { + let clamped = clamp_tauri_animation_slowdown_factor(value); + { + let mut guard = self + .config + .write() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + guard.frontend.tauri_animation_slowdown_factor = clamped; + } + self.persist_config()?; + Ok(clamped) + } + pub fn api_config(&self) -> ApiConfig { self.api_config.clone() } @@ -257,6 +286,13 @@ fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'s } } +fn clamp_tauri_animation_slowdown_factor(value: u8) -> u8 { + value.clamp( + TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN, + TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX, + ) +} + fn log_api_error(err: ApiServerError) { warn!(%err, "runtime core api server exited"); } @@ -331,4 +367,36 @@ mod tests { core.set_frontend_debug_overlay_visible(true).expect("set"); assert!(core.frontend_debug_overlay_visible().expect("get")); } + + #[test] + fn frontend_tauri_animation_slowdown_factor_roundtrips() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("config.toml"); + let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) + .expect("core init"); + let persisted = core + .set_frontend_tauri_animation_slowdown_factor(6) + .expect("set"); + assert_eq!(persisted, 6); + assert_eq!( + core.frontend_tauri_animation_slowdown_factor().expect("get"), + 6 + ); + } + + #[test] + fn frontend_tauri_animation_slowdown_factor_clamps() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("config.toml"); + let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) + .expect("core init"); + let persisted = core + .set_frontend_tauri_animation_slowdown_factor(0) + .expect("set"); + assert_eq!(persisted, 1); + assert_eq!( + core.frontend_tauri_animation_slowdown_factor().expect("get"), + 1 + ); + } } diff --git a/crates/sprimo-tauri/src/main.rs b/crates/sprimo-tauri/src/main.rs index f1405d0..b6235d2 100644 --- a/crates/sprimo-tauri/src/main.rs +++ b/crates/sprimo-tauri/src/main.rs @@ -31,6 +31,7 @@ const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay"; const MENU_ID_QUIT: &str = "quit"; const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot"; const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible"; +const EVENT_RUNTIME_ANIMATION_SLOWDOWN_FACTOR: &str = "runtime:animation-slowdown-factor"; #[derive(Debug, Clone, serde::Serialize)] struct UiAnimationClip { @@ -80,6 +81,7 @@ struct UiSettingsSnapshot { scale: f32, visible: bool, always_on_top: bool, + tauri_animation_slowdown_factor: u8, } #[derive(Debug, Clone, serde::Serialize)] @@ -183,9 +185,37 @@ fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result) -> Result { + state + .runtime_core + .frontend_tauri_animation_slowdown_factor() + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn set_tauri_animation_slowdown_factor( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + factor: u8, +) -> Result { + let persisted = state + .runtime_core + .set_frontend_tauri_animation_slowdown_factor(factor) + .map_err(|err| err.to_string())?; + app_handle + .emit(EVENT_RUNTIME_ANIMATION_SLOWDOWN_FACTOR, persisted) + .map_err(|err| err.to_string())?; + Ok(persisted) +} + #[tauri::command] fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result, String> { let root = sprite_pack_root(state.runtime_core.as_ref())?; @@ -349,6 +379,8 @@ fn main() -> Result<(), AppError> { debug_overlay_visible, set_debug_overlay_visible, settings_snapshot, + tauri_animation_slowdown_factor, + set_tauri_animation_slowdown_factor, list_sprite_packs, set_sprite_pack, set_scale, diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index e9ef7ca..2f41a11 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -40,6 +40,7 @@ recovery_hotkey = "Ctrl+Alt+P" [frontend] backend = "bevy" debug_overlay_visible = false +tauri_animation_slowdown_factor = 3 ``` ## Notes @@ -50,3 +51,6 @@ debug_overlay_visible = false - On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery. - `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`). - `frontend.debug_overlay_visible` controls whether tauri window debug diagnostics panel is shown. +- `frontend.tauri_animation_slowdown_factor` controls tauri animation pacing multiplier. + valid range: `1..20` + effective frame interval: `(1000 / clip_fps) * factor` diff --git a/docs/SPRITE_PACK_SCHEMA.md b/docs/SPRITE_PACK_SCHEMA.md index 4056765..6fcb939 100644 --- a/docs/SPRITE_PACK_SCHEMA.md +++ b/docs/SPRITE_PACK_SCHEMA.md @@ -92,3 +92,8 @@ Default one-shot policy: - `celebrate` and `success` are one-shot. - other row animations loop by default. + +Recommended FPS profile for 8-frame rows: + +- looping rows: `1` fps +- one-shot `celebrate`/`success`: `2` fps diff --git a/docs/TAURI_RUNTIME_TESTING.md b/docs/TAURI_RUNTIME_TESTING.md index 3e5e249..7405e8f 100644 --- a/docs/TAURI_RUNTIME_TESTING.md +++ b/docs/TAURI_RUNTIME_TESTING.md @@ -115,7 +115,12 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` 8. Chroma-key quality check: - verify no visible magenta background/fringe remains around sprite edges in normal runtime view, including non-exact gradient magenta atlas backgrounds (for example `ferris`/`demogorgon`) -9. Scale anchor and bounds check: +9. Animation tempo check for 8-frame rows: +- looping row animations should feel very slow/readable (1 fps profile) +- one-shot `celebrate`/`success` should run slightly faster than loops (2 fps profile) +- tauri renderer applies an additional global slowdown factor (`x2`) over clip fps; verify perceived + playback matches this expectation +10. Scale anchor and bounds check: - repeated scale changes should keep window centered without directional drift - window must remain within current monitor bounds during scale adjustments - no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor` @@ -138,10 +143,14 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` - main overlay auto-fits without clipping - persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds - value persists after restart -5. Toggle `Visible` and verify: +5. Change animation speed factor slider and verify: +- runtime animation pace updates immediately in main overlay +- value is clamped to `1..20` +- value persists after restart via `frontend.tauri_animation_slowdown_factor` +6. Toggle `Visible` and verify: - main overlay hide/show behavior - persisted value survives restart -6. Toggle `Always on top` and verify: +7. Toggle `Always on top` and verify: - main window z-order behavior updates - persisted value survives restart diff --git a/frontend/tauri-ui/src/main.tsx b/frontend/tauri-ui/src/main.tsx index 8c1e266..50fc990 100644 --- a/frontend/tauri-ui/src/main.tsx +++ b/frontend/tauri-ui/src/main.tsx @@ -17,6 +17,7 @@ type UiSettingsSnapshot = { scale: number; visible: boolean; always_on_top: boolean; + tauri_animation_slowdown_factor: number; }; type UiSpritePackOption = { @@ -33,6 +34,9 @@ const SCALE_MIN = 0.5; const SCALE_MAX = 3.0; const LOGICAL_BASE_FRAME_WIDTH = 512; const LOGICAL_BASE_FRAME_HEIGHT = 512; +const SLOWDOWN_FACTOR_MIN = 1; +const SLOWDOWN_FACTOR_MAX = 20; +const SLOWDOWN_FACTOR_DEFAULT = 3; async function invokeSetSpritePack(packIdOrPath: string): Promise { return invoke("set_sprite_pack", { packIdOrPath }); @@ -50,6 +54,14 @@ async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise { return invoke("set_always_on_top", { alwaysOnTop }); } +async function invokeAnimationSlowdownFactor(): Promise { + return invoke("tauri_animation_slowdown_factor"); +} + +async function invokeSetAnimationSlowdownFactor(factor: number): Promise { + return invoke("set_tauri_animation_slowdown_factor", { factor }); +} + function fittedWindowSize( scale: number ): { width: number; height: number } { @@ -142,6 +154,7 @@ function MainOverlayWindow(): JSX.Element { const activePackRef = React.useRef(null); const loadedPackKeyRef = React.useRef(null); const effectiveScaleSyncRef = React.useRef(null); + const slowdownFactorRef = React.useRef(SLOWDOWN_FACTOR_DEFAULT); const loadingPackRef = React.useRef(false); const mountedRef = React.useRef(false); @@ -158,6 +171,7 @@ function MainOverlayWindow(): JSX.Element { } const previousRenderer = rendererRef.current; const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot); + nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current); rendererRef.current = nextRenderer; activePackRef.current = pack; loadedPackKeyRef.current = nextSnapshot.active_sprite_pack; @@ -252,13 +266,18 @@ function MainOverlayWindow(): JSX.Element { Promise.all([ invoke("load_active_sprite_pack"), invoke("current_state"), - invoke("debug_overlay_visible") + invoke("debug_overlay_visible"), + invokeAnimationSlowdownFactor() ]) - .then(async ([pack, initialSnapshot, showDebug]) => { + .then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => { if (!mountedRef.current) { return; } setDebugOverlayVisible(showDebug); + slowdownFactorRef.current = Math.min( + Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); await recreateRenderer(pack, initialSnapshot); await processSnapshot(initialSnapshot); @@ -271,9 +290,23 @@ function MainOverlayWindow(): JSX.Element { } setDebugOverlayVisible(Boolean(event.payload)); }); + const unlistenSlowdown = await listen( + "runtime:animation-slowdown-factor", + (event) => { + if (!mountedRef.current) { + return; + } + slowdownFactorRef.current = Math.min( + Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); + rendererRef.current?.setAnimationSlowdownFactor(slowdownFactorRef.current); + } + ); unlisten = () => { unlistenSnapshot(); unlistenDebug(); + unlistenSlowdown(); }; }) .catch((err) => { @@ -406,10 +439,29 @@ function SettingsWindow(): JSX.Element { active_sprite_pack: payload.active_sprite_pack, scale: payload.scale, visible: payload.visible, - always_on_top: payload.always_on_top + always_on_top: payload.always_on_top, + tauri_animation_slowdown_factor: + prev.tauri_animation_slowdown_factor ?? SLOWDOWN_FACTOR_DEFAULT }; }); }); + const unlistenSlowdown = await listen("runtime:animation-slowdown-factor", (event) => { + if (!mounted) { + return; + } + const factor = Math.min( + Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); + setSettings((prev) => + prev === null ? prev : { ...prev, tauri_animation_slowdown_factor: factor } + ); + }); + const previousUnlisten = unlisten; + unlisten = () => { + previousUnlisten(); + unlistenSlowdown(); + }; }) .catch((err) => { if (mounted) { @@ -499,6 +551,29 @@ function SettingsWindow(): JSX.Element { [withPending] ); + const onAnimationSlowdownFactorChange = React.useCallback( + async (event: React.ChangeEvent) => { + const value = Number(event.target.value); + if (!Number.isFinite(value)) { + return; + } + const clamped = Math.min( + Math.max(Math.round(value), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); + const persisted = await withPending(() => invokeSetAnimationSlowdownFactor(clamped)); + if (persisted === null) { + return; + } + setSettings((prev) => + prev === null + ? prev + : { ...prev, tauri_animation_slowdown_factor: Number(persisted) } + ); + }, + [withPending] + ); + return (
@@ -535,6 +610,20 @@ function SettingsWindow(): JSX.Element { onChange={onScaleChange} /> +