Bug fix and UX improve #3

Merged
manbo merged 7 commits from dev-tauri into master 2026-02-15 18:18:14 +08:00
12 changed files with 289 additions and 52 deletions
Showing only changes of commit f20ed1fd9d - Show all commits

View File

@@ -7,79 +7,79 @@
"animations": [ "animations": [
{ {
"name": "idle", "name": "idle",
"fps": 8, "fps": 1,
"frames": [0, 1, 2, 3, 4, 5, 6, 7] "frames": [0, 1, 2, 3, 4, 5, 6, 7]
}, },
{ {
"name": "active", "name": "active",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "happy", "name": "happy",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "love", "name": "love",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "excited", "name": "excited",
"fps": 8, "fps": 1,
"frames": [16, 17, 18, 19, 20, 21, 22, 23] "frames": [16, 17, 18, 19, 20, 21, 22, 23]
}, },
{ {
"name": "celebrate", "name": "celebrate",
"fps": 10, "fps": 2,
"frames": [16, 17, 18, 19, 20, 21, 22, 23], "frames": [16, 17, 18, 19, 20, 21, 22, 23],
"one_shot": true "one_shot": true
}, },
{ {
"name": "success", "name": "success",
"fps": 10, "fps": 2,
"frames": [16, 17, 18, 19, 20, 21, 22, 23], "frames": [16, 17, 18, 19, 20, 21, 22, 23],
"one_shot": true "one_shot": true
}, },
{ {
"name": "sleepy", "name": "sleepy",
"fps": 8, "fps": 1,
"frames": [24, 25, 26, 27, 28, 29, 30, 31] "frames": [24, 25, 26, 27, 28, 29, 30, 31]
}, },
{ {
"name": "snoring", "name": "snoring",
"fps": 8, "fps": 1,
"frames": [24, 25, 26, 27, 28, 29, 30, 31] "frames": [24, 25, 26, 27, 28, 29, 30, 31]
}, },
{ {
"name": "working", "name": "working",
"fps": 8, "fps": 1,
"frames": [32, 33, 34, 35, 36, 37, 38, 39] "frames": [32, 33, 34, 35, 36, 37, 38, 39]
}, },
{ {
"name": "angry", "name": "angry",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "surprised", "name": "surprised",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "shy", "name": "shy",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "error", "name": "error",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "dragging", "name": "dragging",
"fps": 8, "fps": 1,
"frames": [48, 49, 50, 51, 52, 53, 54, 55] "frames": [48, 49, 50, 51, 52, 53, 54, 55]
} }
], ],

View File

@@ -7,79 +7,79 @@
"animations": [ "animations": [
{ {
"name": "idle", "name": "idle",
"fps": 8, "fps": 1,
"frames": [0, 1, 2, 3, 4, 5, 6, 7] "frames": [0, 1, 2, 3, 4, 5, 6, 7]
}, },
{ {
"name": "active", "name": "active",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "happy", "name": "happy",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "love", "name": "love",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "excited", "name": "excited",
"fps": 8, "fps": 1,
"frames": [16, 17, 18, 19, 20, 21, 22, 23] "frames": [16, 17, 18, 19, 20, 21, 22, 23]
}, },
{ {
"name": "celebrate", "name": "celebrate",
"fps": 10, "fps": 2,
"frames": [16, 17, 18, 19, 20, 21, 22, 23], "frames": [16, 17, 18, 19, 20, 21, 22, 23],
"one_shot": true "one_shot": true
}, },
{ {
"name": "success", "name": "success",
"fps": 10, "fps": 2,
"frames": [16, 17, 18, 19, 20, 21, 22, 23], "frames": [16, 17, 18, 19, 20, 21, 22, 23],
"one_shot": true "one_shot": true
}, },
{ {
"name": "sleepy", "name": "sleepy",
"fps": 8, "fps": 1,
"frames": [24, 25, 26, 27, 28, 29, 30, 31] "frames": [24, 25, 26, 27, 28, 29, 30, 31]
}, },
{ {
"name": "snoring", "name": "snoring",
"fps": 8, "fps": 1,
"frames": [24, 25, 26, 27, 28, 29, 30, 31] "frames": [24, 25, 26, 27, 28, 29, 30, 31]
}, },
{ {
"name": "working", "name": "working",
"fps": 8, "fps": 1,
"frames": [32, 33, 34, 35, 36, 37, 38, 39] "frames": [32, 33, 34, 35, 36, 37, 38, 39]
}, },
{ {
"name": "angry", "name": "angry",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "surprised", "name": "surprised",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "shy", "name": "shy",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "error", "name": "error",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "dragging", "name": "dragging",
"fps": 8, "fps": 1,
"frames": [48, 49, 50, 51, 52, 53, 54, 55] "frames": [48, 49, 50, 51, 52, 53, 54, 55]
} }
], ],

View File

@@ -7,79 +7,79 @@
"animations": [ "animations": [
{ {
"name": "idle", "name": "idle",
"fps": 8, "fps": 1,
"frames": [0, 1, 2, 3, 4, 5, 6, 7] "frames": [0, 1, 2, 3, 4, 5, 6, 7]
}, },
{ {
"name": "active", "name": "active",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "happy", "name": "happy",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "love", "name": "love",
"fps": 8, "fps": 1,
"frames": [8, 9, 10, 11, 12, 13, 14, 15] "frames": [8, 9, 10, 11, 12, 13, 14, 15]
}, },
{ {
"name": "excited", "name": "excited",
"fps": 8, "fps": 1,
"frames": [16, 17, 18, 19, 20, 21, 22, 23] "frames": [16, 17, 18, 19, 20, 21, 22, 23]
}, },
{ {
"name": "celebrate", "name": "celebrate",
"fps": 10, "fps": 2,
"frames": [16, 17, 18, 19, 20, 21, 22, 23], "frames": [16, 17, 18, 19, 20, 21, 22, 23],
"one_shot": true "one_shot": true
}, },
{ {
"name": "success", "name": "success",
"fps": 10, "fps": 2,
"frames": [16, 17, 18, 19, 20, 21, 22, 23], "frames": [16, 17, 18, 19, 20, 21, 22, 23],
"one_shot": true "one_shot": true
}, },
{ {
"name": "sleepy", "name": "sleepy",
"fps": 8, "fps": 1,
"frames": [24, 25, 26, 27, 28, 29, 30, 31] "frames": [24, 25, 26, 27, 28, 29, 30, 31]
}, },
{ {
"name": "snoring", "name": "snoring",
"fps": 8, "fps": 1,
"frames": [24, 25, 26, 27, 28, 29, 30, 31] "frames": [24, 25, 26, 27, 28, 29, 30, 31]
}, },
{ {
"name": "working", "name": "working",
"fps": 8, "fps": 1,
"frames": [32, 33, 34, 35, 36, 37, 38, 39] "frames": [32, 33, 34, 35, 36, 37, 38, 39]
}, },
{ {
"name": "angry", "name": "angry",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "surprised", "name": "surprised",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "shy", "name": "shy",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "error", "name": "error",
"fps": 8, "fps": 1,
"frames": [40, 41, 42, 43, 44, 45, 46, 47] "frames": [40, 41, 42, 43, 44, 45, 46, 47]
}, },
{ {
"name": "dragging", "name": "dragging",
"fps": 8, "fps": 1,
"frames": [48, 49, 50, 51, 52, 53, 54, 55] "frames": [48, 49, 50, 51, 52, 53, 54, 55]
} }
], ],

View File

@@ -159,6 +159,7 @@ pub enum FrontendBackend {
pub struct FrontendConfig { pub struct FrontendConfig {
pub backend: FrontendBackend, pub backend: FrontendBackend,
pub debug_overlay_visible: bool, pub debug_overlay_visible: bool,
pub tauri_animation_slowdown_factor: u8,
} }
impl Default for FrontendConfig { impl Default for FrontendConfig {
@@ -166,6 +167,7 @@ impl Default for FrontendConfig {
Self { Self {
backend: FrontendBackend::Bevy, backend: FrontendBackend::Bevy,
debug_overlay_visible: false, debug_overlay_visible: false,
tauri_animation_slowdown_factor: 3,
} }
} }
} }
@@ -225,11 +227,13 @@ mod tests {
config.window.x = 42.0; config.window.x = 42.0;
config.frontend.backend = super::FrontendBackend::Tauri; config.frontend.backend = super::FrontendBackend::Tauri;
config.frontend.debug_overlay_visible = true; config.frontend.debug_overlay_visible = true;
config.frontend.tauri_animation_slowdown_factor = 7;
save(&path, &config).expect("save"); save(&path, &config).expect("save");
let (_, loaded) = load_or_create_at(&path).expect("reload"); let (_, loaded) = load_or_create_at(&path).expect("reload");
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON); assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri); assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
assert!(loaded.frontend.debug_overlay_visible); assert!(loaded.frontend.debug_overlay_visible);
assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7);
} }
} }

View File

@@ -8,6 +8,9 @@ use tokio::runtime::Runtime;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tracing::warn; use tracing::warn;
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1;
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 20;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum RuntimeCoreError { pub enum RuntimeCoreError {
#[error("{0}")] #[error("{0}")]
@@ -104,6 +107,32 @@ impl RuntimeCore {
self.persist_config() self.persist_config()
} }
pub fn frontend_tauri_animation_slowdown_factor(&self) -> Result<u8, RuntimeCoreError> {
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<u8, RuntimeCoreError> {
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 { pub fn api_config(&self) -> ApiConfig {
self.api_config.clone() 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) { fn log_api_error(err: ApiServerError) {
warn!(%err, "runtime core api server exited"); warn!(%err, "runtime core api server exited");
} }
@@ -331,4 +367,36 @@ mod tests {
core.set_frontend_debug_overlay_visible(true).expect("set"); core.set_frontend_debug_overlay_visible(true).expect("set");
assert!(core.frontend_debug_overlay_visible().expect("get")); 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
);
}
} }

View File

@@ -31,6 +31,7 @@ const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay";
const MENU_ID_QUIT: &str = "quit"; const MENU_ID_QUIT: &str = "quit";
const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot"; const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot";
const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible"; 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)] #[derive(Debug, Clone, serde::Serialize)]
struct UiAnimationClip { struct UiAnimationClip {
@@ -80,6 +81,7 @@ struct UiSettingsSnapshot {
scale: f32, scale: f32,
visible: bool, visible: bool,
always_on_top: bool, always_on_top: bool,
tauri_animation_slowdown_factor: u8,
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -183,9 +185,37 @@ fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnap
scale: snapshot.scale, scale: snapshot.scale,
visible: snapshot.flags.visible, visible: snapshot.flags.visible,
always_on_top: snapshot.flags.always_on_top, always_on_top: snapshot.flags.always_on_top,
tauri_animation_slowdown_factor: state
.runtime_core
.frontend_tauri_animation_slowdown_factor()
.map_err(|err| err.to_string())?,
}) })
} }
#[tauri::command]
fn tauri_animation_slowdown_factor(state: tauri::State<'_, AppState>) -> Result<u8, String> {
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<u8, String> {
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] #[tauri::command]
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> { fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?; let root = sprite_pack_root(state.runtime_core.as_ref())?;
@@ -349,6 +379,8 @@ fn main() -> Result<(), AppError> {
debug_overlay_visible, debug_overlay_visible,
set_debug_overlay_visible, set_debug_overlay_visible,
settings_snapshot, settings_snapshot,
tauri_animation_slowdown_factor,
set_tauri_animation_slowdown_factor,
list_sprite_packs, list_sprite_packs,
set_sprite_pack, set_sprite_pack,
set_scale, set_scale,

View File

@@ -40,6 +40,7 @@ recovery_hotkey = "Ctrl+Alt+P"
[frontend] [frontend]
backend = "bevy" backend = "bevy"
debug_overlay_visible = false debug_overlay_visible = false
tauri_animation_slowdown_factor = 3
``` ```
## Notes ## Notes
@@ -50,3 +51,6 @@ debug_overlay_visible = false
- On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery. - 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.backend` selects runtime frontend implementation (`bevy` or `tauri`).
- `frontend.debug_overlay_visible` controls whether tauri window debug diagnostics panel is shown. - `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`

View File

@@ -92,3 +92,8 @@ Default one-shot policy:
- `celebrate` and `success` are one-shot. - `celebrate` and `success` are one-shot.
- other row animations loop by default. - other row animations loop by default.
Recommended FPS profile for 8-frame rows:
- looping rows: `1` fps
- one-shot `celebrate`/`success`: `2` fps

View File

@@ -115,7 +115,12 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
8. Chroma-key quality check: 8. Chroma-key quality check:
- verify no visible magenta background/fringe remains around sprite edges in normal runtime view, - 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`) 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 - repeated scale changes should keep window centered without directional drift
- window must remain within current monitor bounds during scale adjustments - window must remain within current monitor bounds during scale adjustments
- no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor` - 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 - main overlay auto-fits without clipping
- persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds - persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds
- value persists after restart - 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 - main overlay hide/show behavior
- persisted value survives restart - persisted value survives restart
6. Toggle `Always on top` and verify: 7. Toggle `Always on top` and verify:
- main window z-order behavior updates - main window z-order behavior updates
- persisted value survives restart - persisted value survives restart

View File

@@ -17,6 +17,7 @@ type UiSettingsSnapshot = {
scale: number; scale: number;
visible: boolean; visible: boolean;
always_on_top: boolean; always_on_top: boolean;
tauri_animation_slowdown_factor: number;
}; };
type UiSpritePackOption = { type UiSpritePackOption = {
@@ -33,6 +34,9 @@ const SCALE_MIN = 0.5;
const SCALE_MAX = 3.0; const SCALE_MAX = 3.0;
const LOGICAL_BASE_FRAME_WIDTH = 512; const LOGICAL_BASE_FRAME_WIDTH = 512;
const LOGICAL_BASE_FRAME_HEIGHT = 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<UiSnapshot> { async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath }); return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
@@ -50,6 +54,14 @@ async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop }); return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
} }
async function invokeAnimationSlowdownFactor(): Promise<number> {
return invoke<number>("tauri_animation_slowdown_factor");
}
async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number> {
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
}
function fittedWindowSize( function fittedWindowSize(
scale: number scale: number
): { width: number; height: number } { ): { width: number; height: number } {
@@ -142,6 +154,7 @@ function MainOverlayWindow(): JSX.Element {
const activePackRef = React.useRef<UiSpritePack | null>(null); const activePackRef = React.useRef<UiSpritePack | null>(null);
const loadedPackKeyRef = React.useRef<string | null>(null); const loadedPackKeyRef = React.useRef<string | null>(null);
const effectiveScaleSyncRef = React.useRef<number | null>(null); const effectiveScaleSyncRef = React.useRef<number | null>(null);
const slowdownFactorRef = React.useRef<number>(SLOWDOWN_FACTOR_DEFAULT);
const loadingPackRef = React.useRef(false); const loadingPackRef = React.useRef(false);
const mountedRef = React.useRef(false); const mountedRef = React.useRef(false);
@@ -158,6 +171,7 @@ function MainOverlayWindow(): JSX.Element {
} }
const previousRenderer = rendererRef.current; const previousRenderer = rendererRef.current;
const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot); const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot);
nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current);
rendererRef.current = nextRenderer; rendererRef.current = nextRenderer;
activePackRef.current = pack; activePackRef.current = pack;
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack; loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
@@ -252,13 +266,18 @@ function MainOverlayWindow(): JSX.Element {
Promise.all([ Promise.all([
invoke<UiSpritePack>("load_active_sprite_pack"), invoke<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state"), invoke<UiSnapshot>("current_state"),
invoke<boolean>("debug_overlay_visible") invoke<boolean>("debug_overlay_visible"),
invokeAnimationSlowdownFactor()
]) ])
.then(async ([pack, initialSnapshot, showDebug]) => { .then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => {
if (!mountedRef.current) { if (!mountedRef.current) {
return; return;
} }
setDebugOverlayVisible(showDebug); setDebugOverlayVisible(showDebug);
slowdownFactorRef.current = Math.min(
Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN),
SLOWDOWN_FACTOR_MAX
);
await recreateRenderer(pack, initialSnapshot); await recreateRenderer(pack, initialSnapshot);
await processSnapshot(initialSnapshot); await processSnapshot(initialSnapshot);
@@ -271,9 +290,23 @@ function MainOverlayWindow(): JSX.Element {
} }
setDebugOverlayVisible(Boolean(event.payload)); setDebugOverlayVisible(Boolean(event.payload));
}); });
const unlistenSlowdown = await listen<number>(
"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 = () => { unlisten = () => {
unlistenSnapshot(); unlistenSnapshot();
unlistenDebug(); unlistenDebug();
unlistenSlowdown();
}; };
}) })
.catch((err) => { .catch((err) => {
@@ -406,10 +439,29 @@ function SettingsWindow(): JSX.Element {
active_sprite_pack: payload.active_sprite_pack, active_sprite_pack: payload.active_sprite_pack,
scale: payload.scale, scale: payload.scale,
visible: payload.visible, 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<number>("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) => { .catch((err) => {
if (mounted) { if (mounted) {
@@ -499,6 +551,29 @@ function SettingsWindow(): JSX.Element {
[withPending] [withPending]
); );
const onAnimationSlowdownFactorChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
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 ( return (
<main className="settings-root"> <main className="settings-root">
<section className="settings-card"> <section className="settings-card">
@@ -535,6 +610,20 @@ function SettingsWindow(): JSX.Element {
onChange={onScaleChange} onChange={onScaleChange}
/> />
</label> </label>
<label className="field">
<span>
Animation Speed Factor: x{settings.tauri_animation_slowdown_factor}
</span>
<input
type="range"
min={SLOWDOWN_FACTOR_MIN}
max={SLOWDOWN_FACTOR_MAX}
step={1}
value={settings.tauri_animation_slowdown_factor}
disabled={pending}
onChange={onAnimationSlowdownFactorChange}
/>
</label>
<label className="toggle"> <label className="toggle">
<input <input
type="checkbox" type="checkbox"

View File

@@ -53,6 +53,9 @@ const HALO_SAT_MIN = 0.15;
const HALO_VAL_MIN = 0.04; const HALO_VAL_MIN = 0.04;
const RENDER_FIT_PADDING = 16; const RENDER_FIT_PADDING = 16;
const MIN_RENDER_SCALE = 0.01; const MIN_RENDER_SCALE = 0.01;
const ANIMATION_SLOWDOWN_FACTOR_MIN = 1;
const ANIMATION_SLOWDOWN_FACTOR_MAX = 20;
const ANIMATION_SLOWDOWN_FACTOR_DEFAULT = 3;
export class PixiPetRenderer { export class PixiPetRenderer {
private app: Application; private app: Application;
@@ -63,6 +66,7 @@ export class PixiPetRenderer {
private frameCursor = 0; private frameCursor = 0;
private frameElapsedMs = 0; private frameElapsedMs = 0;
private baseTexture: BaseTexture; private baseTexture: BaseTexture;
private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT;
private disposed = false; private disposed = false;
private constructor( private constructor(
@@ -431,13 +435,24 @@ export class PixiPetRenderer {
this.layoutSprite(); this.layoutSprite();
} }
setAnimationSlowdownFactor(factor: number): void {
if (!Number.isFinite(factor)) {
return;
}
const rounded = Math.round(factor);
this.animationSlowdownFactor = Math.min(
Math.max(rounded, ANIMATION_SLOWDOWN_FACTOR_MIN),
ANIMATION_SLOWDOWN_FACTOR_MAX
);
}
private startTicker(): void { private startTicker(): void {
this.app.ticker.add((ticker) => { this.app.ticker.add((ticker) => {
if (this.disposed) { if (this.disposed) {
return; return;
} }
this.layoutSprite(); this.layoutSprite();
const frameMs = 1000 / Math.max(this.currentClip.fps, 1); const frameMs = (1000 / Math.max(this.currentClip.fps, 1)) * this.animationSlowdownFactor;
this.frameElapsedMs += ticker.deltaMS; this.frameElapsedMs += ticker.deltaMS;
if (this.frameElapsedMs < frameMs) { if (this.frameElapsedMs < frameMs) {
return; return;

View File

@@ -10,6 +10,7 @@ P2
Standardize `sprite.png` packs so each of the 7 rows maps to semantic animation groups, while 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`). keeping runtime compatibility with existing state names (`active`, `success`, `error`).
Also retune 8-frame animation tempo to a slower profile for readability.
## Scope ## Scope
@@ -42,6 +43,16 @@ One-shot defaults:
2. All bundled sprite-pack manifests now expose row-based names and compatibility aliases. 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"`. 3. Added runtime-core unit test to confirm `SetState::Dragging` selects `"dragging"`.
4. Updated schema/design docs to formalize the row convention. 4. Updated schema/design docs to formalize the row convention.
5. Retuned bundled pack fps profile:
- loops: `1` fps
- one-shot `celebrate` and `success`: `2` fps
6. Tauri renderer-level pacing adjusted:
- added global frame interval slowdown factor (`x2`) in Pixi ticker to further reduce perceived speed
without changing manifest schema type (`fps` remains integer).
7. Added persisted tauri slowdown factor control:
- config key: `frontend.tauri_animation_slowdown_factor` (integer, range `1..20`, default `3`)
- new tauri commands/events to read/update factor at runtime
- settings window slider added for live tuning and persistence.
## Verification ## Verification