use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PetType { #[default] Animal, Human, Custom, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct PetConfig { #[serde(default = "default_version")] pub version: String, #[serde(rename = "type", default)] pub pet_type: PetType, #[serde(default = "default_species")] pub species: String, #[serde(default = "default_gender")] pub gender: String, #[serde(default)] pub image_base_url: String, #[serde(default = "default_image")] pub default_image: String, #[serde(default)] pub actions: HashMap, /// 动作名 → 帧序列剪辑(真运动序列帧)。provider 无关: /// frames[] 为逐帧图片(相对 image_base_url 或绝对 URL);sprite 为图集(可选)。 #[serde(default)] pub animations: HashMap, #[serde(default)] pub sounds: HashMap, #[serde(default)] pub parameters: PetParameters, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct PetParameters { #[serde(default = "default_move_distance")] pub move_distance: i32, #[serde(default = "default_animation_speed")] pub animation_speed: f64, #[serde(default = "default_interaction_timeout")] pub interaction_timeout: i32, } impl Default for PetParameters { fn default() -> Self { Self { move_distance: default_move_distance(), animation_speed: default_animation_speed(), interaction_timeout: default_interaction_timeout(), } } } /// 单个动作的帧序列剪辑(provider 无关的动作动画基础)。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct ActionClip { /// 逐帧图片列表(8-16 帧)。相对路径以 image_base_url 为前缀。 #[serde(default)] pub frames: Vec, /// 图集图片(可选,优先级低于 frames)。 #[serde(default)] pub sprite: Option, #[serde(default = "default_frame_width")] pub frame_width: u32, #[serde(default = "default_frame_height")] pub frame_height: u32, #[serde(default)] pub count: u32, #[serde(default = "default_fps")] pub fps: f64, #[serde(default = "default_true")] pub r#loop: bool, } fn default_version() -> String { "1.0".into() } fn default_species() -> String { "other".into() } fn default_gender() -> String { "neutral".into() } fn default_image() -> String { "standing.png".into() } fn default_move_distance() -> i32 { 15 } fn default_animation_speed() -> f64 { 1.0 } fn default_interaction_timeout() -> i32 { 300 } fn default_frame_width() -> u32 { 128 } fn default_frame_height() -> u32 { 128 } fn default_fps() -> f64 { 12.0 } fn default_true() -> bool { true } impl PetConfig { /// 解析 JSON;actions 为空时补默认映射(idle/happy/busy/sleeping) pub fn parse(json: &str) -> Result { let mut cfg: PetConfig = serde_json::from_str(json)?; if cfg.actions.is_empty() { cfg.actions = [ ("idle".to_string(), "idle".to_string()), ("happy".to_string(), "happy".to_string()), ("busy".to_string(), "busy".to_string()), ("sleeping".to_string(), "sleeping".to_string()), ] .into_iter() .collect(); } Ok(cfg) } pub fn get_action_for_state(&self, state_name: &str) -> String { self.actions.get(state_name).cloned().unwrap_or_else(|| state_name.to_string()) } /// 解析某状态对应的帧序列剪辑(provider 无关)。无则 None(前端回退 CSS 动画)。 pub fn get_clip_for_state(&self, state_name: &str) -> Option<&ActionClip> { let action = self.get_action_for_state(state_name); self.animations.get(&action) } } #[cfg(test)] mod tests { use super::*; const XIBAO_JSON: &str = include_str!("../../src/DefaultPets/xibao/pet-config.json"); #[test] fn parses_real_xibao_config() { let cfg = PetConfig::parse(XIBAO_JSON).unwrap(); assert_eq!(cfg.version, "1.0"); assert!(matches!(cfg.pet_type, PetType::Animal)); assert_eq!(cfg.image_base_url, "DefaultPets/xibao/"); assert_eq!(cfg.default_image, "standing.png"); assert!(cfg.actions.contains_key("idle")); assert_eq!(cfg.get_action_for_state("happy"), "wings_spread"); } #[test] fn fills_default_actions_when_empty() { let json = r#"{"version":"1.0","type":"custom","actions":{}}"#; let cfg = PetConfig::parse(json).unwrap(); assert_eq!(cfg.actions.len(), 4); assert_eq!(cfg.actions["idle"], "idle"); } #[test] fn action_falls_back_to_state_name() { let json = r#"{"type":"custom","actions":{"idle":"idle"}}"#; let cfg = PetConfig::parse(json).unwrap(); assert_eq!(cfg.get_action_for_state("no_such_state"), "no_such_state"); } #[test] fn parse_error_on_bad_json() { assert!(PetConfig::parse("not json").is_err()); } #[test] fn parses_action_frames_and_resolves_by_state() { let json = r#"{"type":"custom","actions":{"happy":"cheer"},"animations":{"cheer":{"frames":["a_01.png","a_02.png","a_03.png"],"fps":15,"loop":false}}}"#; let cfg = PetConfig::parse(json).unwrap(); let clip = cfg.get_clip_for_state("happy").expect("clip"); assert_eq!(clip.frames.len(), 3); assert_eq!(clip.fps, 15.0); assert!(!clip.r#loop); assert_eq!(clip.frame_width, 128); assert_eq!(clip.frame_height, 128); assert_eq!(clip.count, 0); } #[test] fn no_clip_when_animations_absent() { let json = r#"{"type":"custom","actions":{"idle":"idle"}}"#; let cfg = PetConfig::parse(json).unwrap(); assert!(cfg.get_clip_for_state("idle").is_none()); assert!(cfg.animations.is_empty()); } #[test] fn action_clip_loop_defaults_true() { let json = r#"{"type":"custom","actions":{"idle":"idle"},"animations":{"idle":{"frames":["x.png"]}}}"#; let cfg = PetConfig::parse(json).unwrap(); assert!(cfg.get_clip_for_state("idle").unwrap().r#loop); } }