Просмотр исходного кода

feat(desktop): provider-agnostic 动作帧序列 schema + 客户端播放(item 3 基础)

liaoxg 21 часов назад
Родитель
Сommit
6ce0a6e150
2 измененных файлов с 115 добавлено и 9 удалено
  1. 64 0
      cfc-desktop-pet/src-tauri/src/config.rs
  2. 51 9
      cfc-desktop-pet/src/pet-engine.js

+ 64 - 0
cfc-desktop-pet/src-tauri/src/config.rs

@@ -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);
+    }
 }

+ 51 - 9
cfc-desktop-pet/src/pet-engine.js

@@ -11,6 +11,8 @@
   let lastConfig = null;
   let customImage = null;
   let bubbleTimer = null;
+  let frameTimer = null;
+  let frameIdx = 0;
 
   const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
     encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
@@ -45,6 +47,7 @@
     },
     setCustomImage: function (url) {
       customImage = url || null;
+      stopClip();
       if (customImage) {
         petImg.src = customImage;
         petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
@@ -68,6 +71,7 @@
 
   function applyImage(cfg) {
     if (!cfg) return;
+    stopClip();
     lastConfig = cfg;
     const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
     if (!src) return;
@@ -79,18 +83,21 @@
     currentState = stateName;
     if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
 
-    let animClass = null;
-    if (config && config.actions && config.actions[stateName]) {
-      animClass = 'anim-' + config.actions[stateName];
+    const actionName = (config && config.actions && config.actions[stateName]) || stateName;
+    const clipCfg = (config && config.animations) ? config.animations[actionName] : null;
+
+    if (clipCfg && clipCfg.frames && clipCfg.frames.length) {
+      playClip(clipCfg);
     } else {
-      animClass = 'anim-' + stateName;
+      stopClip();
+      applyImage(lastConfig);
+      const animClass = 'anim-' + actionName;
+      const styleEl = document.getElementById('pet-animations');
+      const sheet = styleEl ? styleEl.sheet : null;
+      const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
+      if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
     }
 
-    const styleEl = document.getElementById('pet-animations');
-    const sheet = styleEl ? styleEl.sheet : null;
-    const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
-    if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
-
     if (reason === 'drink_water') {
       bubble.textContent = '该喝水啦~';
       bubble.style.display = 'block';
@@ -109,6 +116,41 @@
     }
   }
 
+  function baseSrc() {
+    if (customImage) return customImage;
+    if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
+    return DEFAULT_IMAGE;
+  }
+
+  function stopClip() {
+    if (frameTimer) { clearInterval(frameTimer); frameTimer = null; }
+  }
+
+  // 播放帧序列剪辑(provider 无关)。loop:false 播完回到基础形象。
+  function playClip(clip) {
+    stopClip();
+    const base = (lastConfig && lastConfig.image_base_url) || '';
+    const frames = clip.frames.map(function (f) {
+      return /^(https?:|data:|\/)/.test(f) ? f : base + f;
+    });
+    const fps = clip.fps > 0 ? clip.fps : 12;
+    frameIdx = 0;
+    petImg.onerror = null;
+    petImg.src = frames[0];
+    frameTimer = setInterval(function () {
+      frameIdx++;
+      if (frameIdx >= frames.length) {
+        if (clip.loop === false) {
+          stopClip();
+          petImg.src = baseSrc();
+          return;
+        }
+        frameIdx = 0;
+      }
+      petImg.src = frames[frameIdx];
+    }, 1000 / fps);
+  }
+
   petImg.src = DEFAULT_IMAGE;
 
   setInterval(function () {