From 7eafeff10e85ea0c330dbe039e69a66b2cde75af Mon Sep 17 00:00:00 2001 From: karashiiro <49822414+karashiiro@users.noreply.github.com> Date: Mon, 5 Jan 2026 01:55:14 -0800 Subject: [PATCH] Add basic animation states for agent --- tiny-workshop/game/src/animation_state.rs | 332 ++++++++++++++++++++++ tiny-workshop/game/src/lib.rs | 75 ++++- 2 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 tiny-workshop/game/src/animation_state.rs diff --git a/tiny-workshop/game/src/animation_state.rs b/tiny-workshop/game/src/animation_state.rs new file mode 100644 index 0000000..adeca33 --- /dev/null +++ b/tiny-workshop/game/src/animation_state.rs @@ -0,0 +1,332 @@ +//! Animation State System for Tiny Workshop +//! +//! This module manages the visual representation of the AI agent's animation state +//! using a color-changing cube as a placeholder. The cube color reflects the current +//! body state (idle, working, speaking). + +use fyrox::core::color::Color; +use fyrox::rand::Rng; + +/// Main body animation state +#[derive(Debug, Clone, PartialEq)] +pub enum BodyState { + Idle(IdleVariant), + Working(WorkAnimation), + TurningToCamera, + TurningFromCamera, + Talking, +} + +/// Idle state variations for natural movement +#[derive(Debug, Clone, PartialEq)] +pub enum IdleVariant { + Sitting, + Thinking, + Stretching, + LookingAround, +} + +/// Work animations categorized by tool type +#[derive(Debug, Clone, PartialEq)] +pub enum WorkAnimation { + Terminal, // Bash, Execute tools + Reading, // Read, Glob, Grep tools + Writing, // Write, Edit tools + WebBrowsing, // WebSearch, WebFetch tools + DeepThinking, // Task, Explore tools (subagent spawn) +} + +/// Animation controller that manages state transitions and timing +#[derive(Debug)] +pub struct AnimationController { + /// Current body animation state + body_state: BodyState, + + /// Whether the assistant is currently speaking + speaking: bool, + + /// Currently active tool (if any) + active_tool: Option, + + /// Timer for idle variation changes + idle_variation_timer: f32, + + /// Random interval until next idle variation (10-30 seconds) + idle_variation_interval: f32, + + /// Timer for turn animation (simulated 1 second turn) + turn_timer: f32, +} + +impl Default for AnimationController { + fn default() -> Self { + Self::new() + } +} + +impl AnimationController { + /// Create a new animation controller in default idle state + pub fn new() -> Self { + Self { + body_state: BodyState::Idle(IdleVariant::Sitting), + speaking: false, + active_tool: None, + idle_variation_timer: 0.0, + idle_variation_interval: fyrox::rand::thread_rng().gen_range(10.0..30.0), + turn_timer: 0.0, + } + } + + /// Update the animation controller each frame + /// + /// Handles: + /// - Idle variation timer (random state changes when idle) + /// - Turn animation timing (TurningToCamera -> Talking, TurningFromCamera -> Idle) + pub fn update(&mut self, dt: f32) { + // Handle turn animation timing + match &self.body_state { + BodyState::TurningToCamera => { + self.turn_timer += dt; + if self.turn_timer >= 1.0 { + // Turn complete, start talking + self.body_state = BodyState::Talking; + self.turn_timer = 0.0; + } + } + BodyState::TurningFromCamera => { + self.turn_timer += dt; + if self.turn_timer >= 1.0 { + // Turn complete, return to appropriate state + self.turn_timer = 0.0; + if self.active_tool.is_some() { + // Return to working state if tool is still active + let tool = self.active_tool.clone().unwrap(); + self.body_state = BodyState::Working(Self::categorize_tool(&tool)); + } else { + // Return to idle + self.body_state = BodyState::Idle(IdleVariant::Sitting); + } + } + } + BodyState::Idle(_) => { + // Handle idle variation timer + self.idle_variation_timer += dt; + if self.idle_variation_timer >= self.idle_variation_interval { + self.trigger_idle_variation(); + self.idle_variation_timer = 0.0; + self.idle_variation_interval = fyrox::rand::thread_rng().gen_range(10.0..30.0); + } + } + _ => {} + } + } + + /// Called when a tool starts executing + pub fn on_tool_start(&mut self, tool_name: &str) { + self.active_tool = Some(tool_name.to_string()); + + // Only change body state if not speaking (speaking has higher priority) + if !self.speaking { + let work_anim = Self::categorize_tool(tool_name); + self.body_state = BodyState::Working(work_anim); + } + + fyrox::core::log::Log::info(format!( + "Animation: Tool started '{}' -> {:?}", + tool_name, self.body_state + )); + } + + /// Called when a tool finishes executing + pub fn on_tool_end(&mut self) { + self.active_tool = None; + + // Return to idle if not speaking (speaking has higher priority) + if !self.speaking + && !matches!( + self.body_state, + BodyState::TurningToCamera | BodyState::TurningFromCamera + ) + { + self.body_state = BodyState::Idle(IdleVariant::Sitting); + } + + fyrox::core::log::Log::info(format!("Animation: Tool ended -> {:?}", self.body_state)); + } + + /// Called when the assistant starts speaking (first text delta received) + pub fn on_speaking_start(&mut self) { + self.speaking = true; + self.body_state = BodyState::TurningToCamera; + self.turn_timer = 0.0; + + fyrox::core::log::Log::info("Animation: Speaking started -> TurningToCamera"); + } + + /// Called when the assistant finishes speaking (message complete) + pub fn on_speaking_end(&mut self) { + self.speaking = false; + self.body_state = BodyState::TurningFromCamera; + self.turn_timer = 0.0; + + fyrox::core::log::Log::info("Animation: Speaking ended -> TurningFromCamera"); + } + + /// Called on each text delta while speaking + #[allow(dead_code)] + pub fn on_text_delta(&mut self, _content: &str) { + // If we've finished turning, ensure we're in talking state + if matches!(self.body_state, BodyState::TurningToCamera) { + // Still turning, wait for update() to complete the turn + } else if self.speaking { + self.body_state = BodyState::Talking; + } + } + + /// Get the current body state + #[allow(dead_code)] + pub fn body_state(&self) -> &BodyState { + &self.body_state + } + + /// Get the color representing the current animation state + pub fn get_color(&self) -> Color { + match &self.body_state { + // Idle states - muted colors + BodyState::Idle(IdleVariant::Sitting) => Color::from_rgba(128, 128, 128, 255), // Gray #808080 + BodyState::Idle(IdleVariant::Thinking) => Color::from_rgba(135, 206, 235, 255), // Light Blue #87CEEB + BodyState::Idle(IdleVariant::Stretching) => Color::from_rgba(144, 238, 144, 255), // Light Green #90EE90 + BodyState::Idle(IdleVariant::LookingAround) => Color::from_rgba(255, 255, 224, 255), // Light Yellow #FFFFE0 + + // Working states - vibrant colors + BodyState::Working(WorkAnimation::Terminal) => Color::from_rgba(255, 165, 0, 255), // Orange #FFA500 + BodyState::Working(WorkAnimation::Reading) => Color::from_rgba(0, 255, 255, 255), // Cyan #00FFFF + BodyState::Working(WorkAnimation::Writing) => Color::from_rgba(147, 112, 219, 255), // Purple #9370DB + BodyState::Working(WorkAnimation::WebBrowsing) => Color::from_rgba(65, 105, 225, 255), // Royal Blue #4169E1 + BodyState::Working(WorkAnimation::DeepThinking) => Color::from_rgba(75, 0, 130, 255), // Indigo #4B0082 + + // Speaking states - attention colors + BodyState::TurningToCamera => Color::from_rgba(255, 255, 0, 255), // Yellow #FFFF00 + BodyState::Talking => Color::from_rgba(0, 255, 0, 255), // Bright Green #00FF00 + BodyState::TurningFromCamera => Color::from_rgba(255, 215, 0, 255), // Gold #FFD700 + } + } + + /// Trigger a random idle variation + fn trigger_idle_variation(&mut self) { + let variant = match fyrox::rand::thread_rng().gen_range(0..4) { + 0 => IdleVariant::Sitting, + 1 => IdleVariant::Thinking, + 2 => IdleVariant::Stretching, + _ => IdleVariant::LookingAround, + }; + + self.body_state = BodyState::Idle(variant.clone()); + + fyrox::core::log::Log::info(format!("Animation: Idle variation -> {variant:?}")); + } + + /// Categorize a tool name into a work animation type + fn categorize_tool(tool_name: &str) -> WorkAnimation { + match tool_name { + // Terminal/execution tools + "Bash" | "BashOutput" | "Execute" | "KillShell" => WorkAnimation::Terminal, + + // Reading/searching tools + "Read" + | "Glob" + | "Grep" + | "View" + | "LSP" + | "ListMcpResourcesTool" + | "ReadMcpResourceTool" => WorkAnimation::Reading, + + // Writing/editing tools + "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => WorkAnimation::Writing, + + // Web browsing tools + "WebSearch" | "WebFetch" | "web_search" | "web_fetch" => WorkAnimation::WebBrowsing, + + // Deep thinking / subagent tools + "Task" | "Explore" | "TaskOutput" => WorkAnimation::DeepThinking, + + // MCP room tools - categorize by action + name if name.starts_with("mcp__room-tools__") => { + if name.contains("create") || name.contains("write") { + WorkAnimation::Writing + } else if name.contains("look") || name.contains("examine") || name.contains("read") + { + WorkAnimation::Reading + } else { + WorkAnimation::DeepThinking + } + } + + // MCP proxy tools + name if name.starts_with("mcp__") => WorkAnimation::DeepThinking, + + // Fallback for unknown tools + _ => WorkAnimation::DeepThinking, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_categorization() { + assert_eq!( + AnimationController::categorize_tool("Bash"), + WorkAnimation::Terminal + ); + assert_eq!( + AnimationController::categorize_tool("Read"), + WorkAnimation::Reading + ); + assert_eq!( + AnimationController::categorize_tool("Write"), + WorkAnimation::Writing + ); + assert_eq!( + AnimationController::categorize_tool("WebSearch"), + WorkAnimation::WebBrowsing + ); + assert_eq!( + AnimationController::categorize_tool("Task"), + WorkAnimation::DeepThinking + ); + } + + #[test] + fn test_state_transitions() { + let mut controller = AnimationController::new(); + + // Start in idle + assert!(matches!(controller.body_state, BodyState::Idle(_))); + + // Tool starts + controller.on_tool_start("Bash"); + assert!(matches!( + controller.body_state, + BodyState::Working(WorkAnimation::Terminal) + )); + + // Tool ends + controller.on_tool_end(); + assert!(matches!(controller.body_state, BodyState::Idle(_))); + + // Speaking starts (higher priority than tools) + controller.on_tool_start("Read"); + controller.on_speaking_start(); + assert!(matches!(controller.body_state, BodyState::TurningToCamera)); + + // Speaking ends + controller.on_speaking_end(); + assert!(matches!( + controller.body_state, + BodyState::TurningFromCamera + )); + } +} diff --git a/tiny-workshop/game/src/lib.rs b/tiny-workshop/game/src/lib.rs index 0885ca5..3102a0d 100644 --- a/tiny-workshop/game/src/lib.rs +++ b/tiny-workshop/game/src/lib.rs @@ -5,6 +5,7 @@ //! - Chat UI //! - 3D scene management +mod animation_state; mod backend; mod chat_ui; mod credential_manager; @@ -12,10 +13,12 @@ mod credential_manager; mod debug_server; mod ipc; +use animation_state::AnimationController; use credential_manager::CredentialManager; #[allow(unused_imports)] use fyrox::graph::prelude::*; +#[allow(unused_imports)] use fyrox::{ core::pool::Handle, core::reflect::prelude::*, @@ -25,7 +28,7 @@ use fyrox::{ gui::{message::UiMessage, UserInterface}, keyboard::{KeyCode, PhysicalKey}, plugin::{Plugin, PluginContext, PluginRegistrationContext}, - scene::Scene, + scene::{mesh::Mesh, node::Node, Scene}, }; use std::path::{Path, PathBuf}; use tokio::sync::mpsc; @@ -98,6 +101,19 @@ pub struct Game { #[reflect(hidden)] shift_held: bool, + // ======================================================================== + // Animation System Fields + // ======================================================================== + /// Animation state controller for the agent + #[visit(skip)] + #[reflect(hidden)] + animation_controller: AnimationController, + + /// Handle to the agent cube in the scene (placeholder for character) + #[visit(skip)] + #[reflect(hidden)] + agent_cube: Handle, + // ======================================================================== // Debug Server Fields (only present when feature enabled) // ======================================================================== @@ -269,6 +285,12 @@ impl Plugin for Game { self.pending_send = false; self.do_send_message(context); } + + // Update animation controller (handles idle variations and turn timing) + self.animation_controller.update(context.dt); + + // Update agent cube color based on animation state + self.update_agent_cube_color(context); } fn on_os_event(&mut self, event: &Event<()>, _context: PluginContext) { @@ -371,13 +393,47 @@ impl Plugin for Game { _path: &Path, scene: Handle, _data: &[u8], - _context: &mut PluginContext, + context: &mut PluginContext, ) { self.scene = scene; + + // Find the agent cube in the scene (placeholder for character) + if let Some(scene_ref) = context.scenes.try_get(scene) { + if let Some((cube_handle, _)) = scene_ref.graph.find_by_name_from_root("Cube") { + self.agent_cube = cube_handle; + fyrox::core::log::Log::info("Animation: Found agent cube in scene"); + } else { + fyrox::core::log::Log::warn("Animation: Could not find 'Cube' node in scene"); + } + } } } impl Game { + /// Update the agent cube's material color based on the current animation state + fn update_agent_cube_color(&mut self, context: &mut PluginContext) { + // Skip if we don't have a valid cube handle + if self.agent_cube.is_none() { + return; + } + + // Get the color for the current animation state + let color = self.animation_controller.get_color(); + + // Apply color to the cube's material + if let Some(scene) = context.scenes.try_get_mut(self.scene) { + if let Some(mesh) = scene.graph[self.agent_cube].cast_mut::() { + for surface in mesh.surfaces_mut() { + let material = surface.material(); + let _ = material.data_ref().set_property( + "diffuseColor", + fyrox::core::color::Color::from_rgba(color.r, color.g, color.b, color.a), + ); + } + } + } + } + /// Handle a message received from the backend fn handle_backend_message(&mut self, msg: BackendMessage, context: &mut PluginContext) { let ui = context.user_interfaces.first_mut(); @@ -389,6 +445,11 @@ impl Game { } BackendMessage::AssistantTextDelta { content } => { + // Trigger speaking animation on first delta + if self.current_response.is_empty() { + self.animation_controller.on_speaking_start(); + } + self.current_response.push_str(&content); // Update chat UI with streaming text if let Some(chat_ui) = &mut self.chat_ui { @@ -406,6 +467,9 @@ impl Game { chat_ui.finish_streaming(&content, ui); } + // Trigger end of speaking animation + self.animation_controller.on_speaking_end(); + // Track message for debug server #[cfg(feature = "debug_server")] self.track_assistant_message(&content); @@ -415,6 +479,10 @@ impl Game { BackendMessage::ToolStart { id, tool, .. } => { fyrox::core::log::Log::info(format!("Tool started: {} ({})", tool, id)); + + // Trigger tool animation + self.animation_controller.on_tool_start(&tool); + // Show tool indicator in chat if let Some(chat_ui) = &self.chat_ui { chat_ui.add_user_message(&format!("🔧 Using tool: {}", tool), ui); @@ -427,6 +495,9 @@ impl Game { BackendMessage::ToolEnd { id, success, .. } => { fyrox::core::log::Log::info(format!("Tool ended: {} (success: {})", id, success)); + + // Trigger end of tool animation + self.animation_controller.on_tool_end(); } BackendMessage::SessionEnded { reason } => { -- 2.51.2