| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- 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<String, String>,
- /// 动作名 → 帧序列剪辑(真运动序列帧)。provider 无关:
- /// frames[] 为逐帧图片(相对 image_base_url 或绝对 URL);sprite 为图集(可选)。
- #[serde(default)]
- pub animations: HashMap<String, ActionClip>,
- #[serde(default)]
- pub sounds: HashMap<String, String>,
- #[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<String>,
- /// 图集图片(可选,优先级低于 frames)。
- #[serde(default)]
- pub sprite: Option<String>,
- #[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<Self, serde_json::Error> {
- 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);
- }
- }
|