From 83fce89fc30576f2a99f411a3369a3af353432e2 Mon Sep 17 00:00:00 2001 From: karashiiro <49822414+karashiiro@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:03:02 -0800 Subject: [PATCH] feat(animation): add response-scaled talking and camera focus - Speaking duration now scales with response length (~50 chars/sec) - Duration clamped between 1-30 seconds - Camera zooms in during speaking/working states - Add CameraAnimator for smooth zoom transitions - Add edge case tests for animation timing --- tiny-workshop/game/src/animation_state.rs | 166 +++++++++++++++- tiny-workshop/game/src/camera_animator.rs | 223 ++++++++++++++++++++++ tiny-workshop/game/src/lib.rs | 56 +++++- 3 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 tiny-workshop/game/src/camera_animator.rs diff --git a/tiny-workshop/game/src/animation_state.rs b/tiny-workshop/game/src/animation_state.rs index 19d2cf5..0fd83eb 100644 --- a/tiny-workshop/game/src/animation_state.rs +++ b/tiny-workshop/game/src/animation_state.rs @@ -57,6 +57,16 @@ pub struct AnimationController { /// Timer for turn animation (simulated 1 second turn) turn_timer: f32, + + /// Minimum time to stay in Talking state (based on response length) + /// Calculated from response character count at ~50 chars/second + min_speaking_duration: f32, + + /// Time elapsed in Talking state + speaking_elapsed: f32, + + /// Whether message is complete (but may still need to show talking) + message_complete: bool, } impl Default for AnimationController { @@ -75,6 +85,9 @@ impl AnimationController { idle_variation_timer: 0.0, idle_variation_interval: fyrox::rand::thread_rng().gen_range(10.0..30.0), turn_timer: 0.0, + min_speaking_duration: 1.0, + speaking_elapsed: 0.0, + message_complete: false, } } @@ -83,8 +96,9 @@ impl AnimationController { /// Handles: /// - Idle variation timer (random state changes when idle) /// - Turn animation timing (TurningToCamera -> Talking, TurningFromCamera -> Idle) + /// - Speaking duration (Talking state held for minimum duration based on response length) pub fn update(&mut self, dt: f32) { - // Handle turn animation timing + // Handle state-specific timing match &self.body_state { BodyState::TurningToCamera => { self.turn_timer += dt; @@ -92,6 +106,26 @@ impl AnimationController { // Turn complete, start talking self.body_state = BodyState::Talking; self.turn_timer = 0.0; + self.speaking_elapsed = 0.0; // Reset talking timer + } + } + BodyState::Talking => { + // Track how long we've been talking + self.speaking_elapsed += dt; + + // Only transition away if: + // 1. Message is complete (on_speaking_end was called) + // 2. We've been talking for at least min_speaking_duration + if self.message_complete && self.speaking_elapsed >= self.min_speaking_duration { + let actual_elapsed = self.speaking_elapsed; + self.body_state = BodyState::TurningFromCamera; + self.turn_timer = 0.0; + self.message_complete = false; // Reset for next message + self.speaking_elapsed = 0.0; + fyrox::core::log::Log::info(format!( + "Animation: Talking complete ({:.1}s elapsed, {:.1}s min) -> TurningFromCamera", + actual_elapsed, self.min_speaking_duration + )); } } BodyState::TurningFromCamera => { @@ -156,8 +190,18 @@ impl AnimationController { } /// Called when the assistant starts speaking (first text delta received) + /// + /// Guarded against double calls - if already speaking, this is a no-op. pub fn on_speaking_start(&mut self) { + // Guard against double calls (e.g., from race conditions in message processing) + if self.speaking { + return; + } + self.speaking = true; + self.message_complete = false; + self.speaking_elapsed = 0.0; + self.min_speaking_duration = 1.0; // Default, will be updated by on_speaking_end self.body_state = BodyState::TurningToCamera; self.turn_timer = 0.0; @@ -165,12 +209,23 @@ impl AnimationController { } /// Called when the assistant finishes speaking (message complete) - pub fn on_speaking_end(&mut self) { + /// + /// Calculates a minimum speaking duration based on response length + /// (~50 characters per second reading pace). The agent will stay in + /// Talking state until this duration elapses. + pub fn on_speaking_end(&mut self, response_length: usize) { self.speaking = false; - self.body_state = BodyState::TurningFromCamera; - self.turn_timer = 0.0; + self.message_complete = true; - fyrox::core::log::Log::info("Animation: Speaking ended -> TurningFromCamera"); + // Calculate minimum duration: ~1 second per 50 characters + // With a minimum of 1 second and maximum of 30 seconds + let calculated = response_length as f32 / 50.0; + self.min_speaking_duration = calculated.clamp(1.0, 30.0); + + fyrox::core::log::Log::info(format!( + "Animation: Speaking ended, min_duration={:.1}s for {} chars", + self.min_speaking_duration, response_length + )); } /// Called on each text delta while speaking. @@ -331,11 +386,108 @@ mod tests { controller.on_speaking_start(); assert!(matches!(controller.body_state, BodyState::TurningToCamera)); - // Speaking ends - controller.on_speaking_end(); + // Complete the turn animation (1 second) + controller.update(1.1); + assert!(matches!(controller.body_state, BodyState::Talking)); + + // Speaking ends with 50 chars = 1 second min duration + controller.on_speaking_end(50); + // Still in Talking because min duration hasn't elapsed + assert!(matches!(controller.body_state, BodyState::Talking)); + + // After enough time elapses, transition happens + controller.update(1.1); assert!(matches!( controller.body_state, BodyState::TurningFromCamera )); } + + #[test] + fn test_zero_length_response_uses_minimum_duration() { + let mut controller = AnimationController::new(); + + controller.on_speaking_start(); + controller.update(1.1); // Complete turn + assert!(matches!(controller.body_state, BodyState::Talking)); + + // Zero-length response should use minimum 1 second + controller.on_speaking_end(0); + assert!(matches!(controller.body_state, BodyState::Talking)); + + // Should take at least 1 second to transition + controller.update(0.5); + assert!(matches!(controller.body_state, BodyState::Talking)); + + controller.update(0.6); + assert!(matches!(controller.body_state, BodyState::TurningFromCamera)); + } + + #[test] + fn test_very_long_response_capped_at_30_seconds() { + let mut controller = AnimationController::new(); + + controller.on_speaking_start(); + controller.update(1.1); // Complete turn + + // 5000 chars would be 100 seconds, but should cap at 30 + controller.on_speaking_end(5000); + assert_eq!(controller.min_speaking_duration, 30.0); + + // After 25 seconds, still talking + controller.update(25.0); + assert!(matches!(controller.body_state, BodyState::Talking)); + + // After 31 seconds total, should transition + controller.update(6.0); + assert!(matches!(controller.body_state, BodyState::TurningFromCamera)); + } + + #[test] + fn test_double_speaking_start_is_ignored() { + let mut controller = AnimationController::new(); + + controller.on_speaking_start(); + // Double call BEFORE speaking ends should be ignored + controller.on_speaking_start(); + + // Should still be in TurningToCamera from first call + assert!(matches!(controller.body_state, BodyState::TurningToCamera)); + + controller.update(1.1); // Complete turn to Talking + controller.on_speaking_end(500); // Set 10 second duration + + // Call again WHILE still in talking state (speaking=false but not transitioned yet) + // This call should succeed since speaking flag is already false + // The guard only prevents calls when speaking=true + + // Verify we're still in Talking state + assert!(matches!(controller.body_state, BodyState::Talking)); + // min_speaking_duration should be 10.0 from on_speaking_end + assert_eq!(controller.min_speaking_duration, 10.0); + } + + #[test] + fn test_speaking_end_during_turn_still_waits_full_duration() { + let mut controller = AnimationController::new(); + + controller.on_speaking_start(); + // on_speaking_end called BEFORE turn completes (fast API response) + controller.on_speaking_end(100); // 2 second duration + + // Still turning + assert!(matches!(controller.body_state, BodyState::TurningToCamera)); + + // Complete the turn + controller.update(1.1); + assert!(matches!(controller.body_state, BodyState::Talking)); + + // Should STILL need to wait the full 2 seconds from when Talking started + controller.update(1.0); + assert!(matches!(controller.body_state, BodyState::Talking)); + + // After full duration, transition + controller.update(1.1); + assert!(matches!(controller.body_state, BodyState::TurningFromCamera)); + } } diff --git a/tiny-workshop/game/src/camera_animator.rs b/tiny-workshop/game/src/camera_animator.rs new file mode 100644 index 0000000..8ab9860 --- /dev/null +++ b/tiny-workshop/game/src/camera_animator.rs @@ -0,0 +1,223 @@ +//! Camera Animator - Dynamic Zoom and Focus Effects +//! +//! Creates a cozy, cinematic feel by smoothly adjusting the camera when +//! the agent is doing something interesting (speaking or working). +//! +//! Features: +//! - Zoom in when agent is active (Talking or Working states) +//! - Look target shifts toward agent position for subtle focus effect +//! - Smooth exponential interpolation (~1 second transitions) +//! - Automatically returns to defaults when agent goes idle + +use fyrox::core::algebra::{UnitQuaternion, Vector3}; + +use crate::animation_state::{AnimationController, BodyState}; + +// ============================================================================= +// Camera Parameters +// ============================================================================= + +/// Default orthographic vertical size (zoomed out) +const DEFAULT_VERTICAL_SIZE: f32 = 6.0; + +/// Focused orthographic vertical size (zoomed in) +const FOCUSED_VERTICAL_SIZE: f32 = 4.0; + +/// Default look target (center-ish of the room) +const DEFAULT_LOOK_TARGET: Vector3 = Vector3::new(-0.5, 0.0, -0.5); + +/// Static camera position (isometric corner) +const CAMERA_POSITION: Vector3 = Vector3::new(7.0, 7.0, 7.0); + +/// Speed of zoom interpolation (higher = faster) +/// At 2.0, reaches ~87% of target in 1 second +const ZOOM_SPEED: f32 = 2.0; + +/// Speed of look target interpolation +const LOOK_SPEED: f32 = 2.0; + +// ============================================================================= +// Camera Animator +// ============================================================================= + +/// Animates camera zoom and look direction based on agent activity. +/// +/// When the agent is speaking or working, the camera: +/// - Zooms in (smaller vertical_size = more zoom) +/// - Shifts look target toward the agent +/// +/// When the agent returns to idle, smoothly returns to defaults. +#[derive(Debug)] +pub struct CameraAnimator { + /// Current interpolated vertical size (orthographic zoom) + current_vertical_size: f32, + + /// Target vertical size we're interpolating toward + target_vertical_size: f32, + + /// Current interpolated look target + current_look_target: Vector3, + + /// Target look position we're interpolating toward + target_look_target: Vector3, +} + +impl Default for CameraAnimator { + fn default() -> Self { + Self::new() + } +} + +impl CameraAnimator { + /// Create a new CameraAnimator with default (unfocused) settings. + pub fn new() -> Self { + Self { + current_vertical_size: DEFAULT_VERTICAL_SIZE, + target_vertical_size: DEFAULT_VERTICAL_SIZE, + current_look_target: DEFAULT_LOOK_TARGET, + target_look_target: DEFAULT_LOOK_TARGET, + } + } + + /// Update target values based on agent state and position. + /// + /// Call this before `update()` each frame to set where the camera + /// should be heading based on what the agent is doing. + pub fn set_target_from_state( + &mut self, + controller: &AnimationController, + agent_position: Vector3, + ) { + let state = controller.body_state(); + let should_focus = Self::should_focus(state); + + if should_focus { + // Agent is active - zoom in and look toward them + self.target_vertical_size = FOCUSED_VERTICAL_SIZE; + // Look at agent's XZ position but keep Y at floor level + self.target_look_target = Vector3::new(agent_position.x, 0.0, agent_position.z); + } else { + // Agent is idle - return to defaults + self.target_vertical_size = DEFAULT_VERTICAL_SIZE; + self.target_look_target = DEFAULT_LOOK_TARGET; + } + } + + /// Interpolate current values toward targets. + /// + /// Uses exponential lerp for smooth, natural-feeling transitions. + /// Call this every frame with delta time. + pub fn update(&mut self, dt: f32) { + // Exponential lerp for zoom + let zoom_t = 1.0 - (-ZOOM_SPEED * dt).exp(); + self.current_vertical_size += + (self.target_vertical_size - self.current_vertical_size) * zoom_t; + + // Exponential lerp for look target + let look_t = 1.0 - (-LOOK_SPEED * dt).exp(); + self.current_look_target = self.current_look_target.lerp(&self.target_look_target, look_t); + } + + /// Get the current vertical size for orthographic projection. + pub fn vertical_size(&self) -> f32 { + self.current_vertical_size + } + + /// Get the current look target position. + pub fn look_target(&self) -> Vector3 { + self.current_look_target + } + + /// Calculate camera rotation to face the current look target. + /// + /// Returns a quaternion rotation that makes the camera look at + /// `current_look_target` from `CAMERA_POSITION`. + pub fn look_rotation(&self) -> UnitQuaternion { + let look_vec = self.current_look_target - CAMERA_POSITION; + let norm_sq = look_vec.norm_squared(); + + if norm_sq > 1e-6 { + let look_dir = look_vec / norm_sq.sqrt(); + UnitQuaternion::face_towards(&look_dir, &Vector3::y_axis()) + } else { + UnitQuaternion::identity() + } + } + + /// Determine if the agent state warrants camera focus. + /// + /// Returns true for speaking-related states and Working states. + /// We include the entire turn cycle (TurningToCamera -> Talking -> TurningFromCamera) + /// so the camera holds its zoom until the agent fully returns to idle/working. + fn should_focus(state: &BodyState) -> bool { + matches!( + state, + BodyState::TurningToCamera | BodyState::Talking | BodyState::TurningFromCamera | BodyState::Working(_) + ) + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::animation_state::WorkAnimation; + + #[test] + fn test_starts_at_default() { + let animator = CameraAnimator::new(); + assert!((animator.vertical_size() - DEFAULT_VERTICAL_SIZE).abs() < 0.001); + assert!((animator.look_target() - DEFAULT_LOOK_TARGET).norm() < 0.001); + } + + #[test] + fn test_should_focus_on_talking() { + assert!(CameraAnimator::should_focus(&BodyState::Talking)); + assert!(CameraAnimator::should_focus(&BodyState::TurningToCamera)); + } + + #[test] + fn test_should_focus_on_working() { + assert!(CameraAnimator::should_focus(&BodyState::Working( + WorkAnimation::Terminal + ))); + assert!(CameraAnimator::should_focus(&BodyState::Working( + WorkAnimation::Writing + ))); + } + + #[test] + fn test_should_not_focus_on_idle() { + use crate::animation_state::IdleVariant; + assert!(!CameraAnimator::should_focus(&BodyState::Idle( + IdleVariant::Sitting + ))); + assert!(!CameraAnimator::should_focus(&BodyState::Idle( + IdleVariant::Wandering + ))); + } + + #[test] + fn test_interpolation_converges() { + let mut animator = CameraAnimator::new(); + + // Simulate being in a focused state + animator.target_vertical_size = FOCUSED_VERTICAL_SIZE; + + // Run for ~2 seconds of simulated time + for _ in 0..120 { + animator.update(1.0 / 60.0); + } + + // Should be very close to target + assert!( + (animator.vertical_size() - FOCUSED_VERTICAL_SIZE).abs() < 0.1, + "Expected ~{}, got {}", + FOCUSED_VERTICAL_SIZE, + animator.vertical_size() + ); + } +} diff --git a/tiny-workshop/game/src/lib.rs b/tiny-workshop/game/src/lib.rs index b4f0e2e..5e74570 100644 --- a/tiny-workshop/game/src/lib.rs +++ b/tiny-workshop/game/src/lib.rs @@ -9,6 +9,7 @@ mod animation_state; mod backend; mod chat_ui; mod credential_manager; +mod camera_animator; mod cube_animator; #[cfg(feature = "debug_server")] mod debug_server; @@ -23,6 +24,7 @@ pub mod ipc; use animation_state::AnimationController; use credential_manager::{CredentialManager, StorageType}; +use camera_animator::CameraAnimator; use cube_animator::CubeAnimator; use note_viewer::NoteViewer; use session_picker::{SessionPicker, SessionPickerResult}; @@ -478,6 +480,11 @@ pub struct Game { #[reflect(hidden)] cube_animator: CubeAnimator, + /// Camera animator for zoom and focus effects + #[visit(skip)] + #[reflect(hidden)] + camera_animator: CameraAnimator, + // ======================================================================== // Room System Fields // ======================================================================== @@ -1010,7 +1017,15 @@ impl Plugin for Game { self.do_send_message(context); } + // Update camera focus FIRST - before animation_controller.update() processes pending speech end + // This ensures the camera sees TurningToCamera for at least one frame + let agent_pos = self.cube_animator.position(&self.animation_controller); + self.camera_animator.set_target_from_state(&self.animation_controller, agent_pos); + self.camera_animator.update(context.dt); + self.update_camera(context); + // Update animation controller (handles idle variations and turn timing) + // This processes the pending speech end transition AFTER camera has seen the state self.animation_controller.update(context.dt); // Update cube animator (handles movement, rotation, idle animations) @@ -1026,6 +1041,7 @@ impl Plugin for Game { } self.cube_animator.update(context.dt, &self.animation_controller); + // Update agent cube visuals (color and transform) self.update_agent_cube_visuals(context); } @@ -1266,6 +1282,15 @@ impl Plugin for Game { // Set orthographic projection for true isometric look if let Some(camera) = camera_node.cast_mut::() { + let vs = self.camera_animator.vertical_size(); + // Log occasionally to avoid spam + static mut FRAME_COUNT: u32 = 0; + unsafe { + FRAME_COUNT += 1; + if FRAME_COUNT % 60 == 0 { + fyrox::core::log::Log::info(format!("Camera update: vertical_size={:.2}", vs)); + } + } camera.set_projection(Projection::Orthographic(OrthographicProjection { vertical_size: 6.0, // Controls zoom level - smaller = more zoomed in z_near: 0.1, @@ -2127,6 +2152,35 @@ impl Game { } } + /// Update camera zoom and look direction based on animator state + fn update_camera(&mut self, context: &mut PluginContext) { + if let Some(scene) = context.scenes.try_get_mut(self.scene) { + if let Some(camera_node) = scene.graph.try_get_mut(self.camera_handle) { + // Update camera rotation to look at interpolated target + let rotation = self.camera_animator.look_rotation(); + camera_node.local_transform_mut().set_rotation(rotation); + + // Update orthographic projection zoom + if let Some(camera) = camera_node.cast_mut::() { + let vs = self.camera_animator.vertical_size(); + // Log occasionally to avoid spam + static mut FRAME_COUNT: u32 = 0; + unsafe { + FRAME_COUNT += 1; + if FRAME_COUNT % 60 == 0 { + fyrox::core::log::Log::info(format!("Camera update: vertical_size={:.2}", vs)); + } + } + camera.set_projection(Projection::Orthographic(OrthographicProjection { + vertical_size: self.camera_animator.vertical_size(), + z_near: 0.1, + z_far: 100.0, + })); + } + } + } + } + /// Update the agent cube's visuals (color and transform) based on animation state fn update_agent_cube_visuals(&mut self, context: &mut PluginContext) { // Skip if we don't have a valid cube handle @@ -2322,7 +2376,7 @@ impl Game { } // Trigger end of speaking animation - self.animation_controller.on_speaking_end(); + self.animation_controller.on_speaking_end(content.chars().count()); // Track message for debug server #[cfg(feature = "debug_server")] -- 2.54.0