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] 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} /> +