|
|
@@ -27,6 +27,10 @@ pub struct PetConfig {
|
|
|
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)]
|
|
|
@@ -54,6 +58,28 @@ impl Default for PetParameters {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/// 单个动作的帧序列剪辑(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() }
|
|
|
@@ -61,6 +87,10 @@ 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)
|
|
|
@@ -82,6 +112,12 @@ impl PetConfig {
|
|
|
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)]
|
|
|
@@ -120,4 +156,32 @@ mod tests {
|
|
|
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);
|
|
|
+ }
|
|
|
}
|