소스 검색

feat(desktop-pet): 玄凤鹦鹉精细化动作(走/跳着走/飞/站手跟随)

liaoxg 1 일 전
부모
커밋
e20c3a4126

+ 3 - 0
cfc-desktop-pet/src-tauri/src/app_state.rs

@@ -1,4 +1,5 @@
 use crate::api::ApiClient;
+use std::sync::atomic::AtomicBool;
 use std::sync::{Arc, Mutex};
 
 pub struct AppState {
@@ -6,5 +7,7 @@ pub struct AppState {
     pub poll_handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
     /// 当前提醒中、可一键完成的待办任务 id(点击宠物即完成)
     pub pending_task: Mutex<Option<i64>>,
+    /// 是否处于「站手跟随光标」模式(前端胸部悬停满 5s 后置真;抖动甩飞/退出后置假)
+    pub follow_cursor: AtomicBool,
     pub api: Arc<ApiClient>,
 }

+ 15 - 0
cfc-desktop-pet/src-tauri/src/commands.rs

@@ -3,6 +3,7 @@ use crate::poller;
 use crate::settings::SettingsStore;
 use crate::state::{PetState, PetStatePayload};
 use crate::token::TokenStore;
+use std::sync::atomic::Ordering;
 use tauri::{AppHandle, Emitter, Manager, State};
 
 #[tauri::command]
@@ -39,6 +40,20 @@ pub async fn drag_end() -> Result<(), String> {
     Ok(())
 }
 
+/// 站手跟随:前端「胸部悬停满 5s」后调用。locomotion 循环会接管窗口移动并回发 `pet://perch`。
+#[tauri::command]
+pub fn start_perch(state: State<AppState>) -> Result<(), String> {
+    state.follow_cursor.store(true, Ordering::Relaxed);
+    Ok(())
+}
+
+/// 退出站手跟随(前端主动中止;抖动甩飞是循环内自动退出)。
+#[tauri::command]
+pub fn stop_perch(state: State<AppState>) -> Result<(), String> {
+    state.follow_cursor.store(false, Ordering::Relaxed);
+    Ok(())
+}
+
 #[tauri::command]
 pub fn open_settings(app: AppHandle) -> Result<(), String> {
     if let Some(win) = app.get_webview_window("settings") {

+ 363 - 0
cfc-desktop-pet/src-tauri/src/locomotion.rs

@@ -0,0 +1,363 @@
+//! 桌面宠物「精细化动作」运动控制:轮询全局光标 + 移动窗口,实现走 / 跳着走 / 飞 / 站手跟随。
+//!
+//! 设计:宠物窗口仅 200×200,跨屏走/飞必须**移动窗口本身**;窗口位置只能由 Rust 修改,
+//! 故本模块是「移动者」,前端 `pet-pose.js` 只负责渲染局部姿态(走路摆动/跳跃/扑腾)。
+//!
+//! 免全局鼠标钩子:用 `WebviewWindow::cursor_position()` 轮询全局光标(跨平台、无 macOS 辅助功能权限)。
+
+use crate::app_state::AppState;
+use serde::Serialize;
+use std::sync::atomic::Ordering;
+use std::time::{Duration, Instant};
+use tauri::{AppHandle, Emitter, Manager, PhysicalPosition};
+
+/// 循环间隔(ms)
+const TICK_MS: u64 = 60;
+/// 宠物窗口物理尺寸(与 tauri.conf.json 保持一致)
+const WIN_W: i32 = 200;
+const WIN_H: i32 = 200;
+/// 宠物双脚在窗口内的锚点(窗口左上角为原点)。站手时双脚落在光标处。
+const FEET_ANCHOR_X: i32 = 100;
+const FEET_ANCHOR_Y: i32 = 200;
+
+// ── 抖动检测参数 ──
+const SHAKE_WINDOW_MS: u128 = 600;
+const SHAKE_MIN_REVERSALS: usize = 4;
+const SHAKE_MIN_TRAVEL: f64 = 80.0;
+/// 甩飞后抖动冷却(tick 数,60ms/tick → 约 1.8s),避免落地即再触发
+const SHAKE_COOLDOWN_TICKS: u32 = 30;
+const MAX_SAMPLES: usize = 40;
+
+// ── 步态参数(每 tick 位移,60ms/tick → 约 27px/s 走 / 43px/s 跳)──
+const WALK_STEP: f64 = 1.6;
+const HOP_STEP: f64 = 2.6;
+const FLY_TICKS: u32 = 10; // ≈600ms
+const IDLE_MIN_TICKS: u32 = 45; // ≈2.7s 起才会有自主动作
+const IDLE_JITTER_TICKS: u64 = 70;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Mode {
+    Idle,
+    Walk,
+    Hop,
+    Fly,
+    Perch,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct Sample {
+    pub x: f64,
+    pub y: f64,
+    pub t: Instant,
+}
+
+/// 抖动检测:在时间窗口内统计水平方向反转次数与总位移。
+/// 反转 ≥4 次且总位移 ≥80px → 视为「抖动鼠标」。
+pub fn detect_shake(samples: &[Sample], now: Instant) -> bool {
+    let recent: Vec<&Sample> = samples
+        .iter()
+        .filter(|s| now.duration_since(s.t).as_millis() <= SHAKE_WINDOW_MS)
+        .collect();
+    if recent.len() < 4 {
+        return false;
+    }
+    let mut reversals = 0usize;
+    let mut travel = 0.0f64;
+    let mut prev_dir = 0i32;
+    for w in recent.windows(2) {
+        let dx = w[1].x - w[0].x;
+        if dx.abs() < 1.0 {
+            continue;
+        }
+        travel += dx.abs();
+        let dir = if dx > 0.0 { 1 } else { -1 };
+        if prev_dir != 0 && dir != prev_dir {
+            reversals += 1;
+        }
+        prev_dir = dir;
+    }
+    reversals >= SHAKE_MIN_REVERSALS && travel >= SHAKE_MIN_TRAVEL
+}
+
+/// 把窗口左上角夹进工作区,保证整窗可见。
+pub fn clamp_window_pos(x: i32, y: i32, wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32) -> (i32, i32) {
+    let max_x = wa_x + (wa_w - WIN_W).max(0);
+    let max_y = wa_y + (wa_h - WIN_H).max(0);
+    (x.clamp(wa_x, max_x), y.clamp(wa_y, max_y))
+}
+
+/// 工作区内的确定性随机落点(LCG 单步,便于单测)。
+pub fn pick_random_target(wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32, seed: u64) -> (i32, i32) {
+    let s = seed
+        .wrapping_mul(6364136223846793005)
+        .wrapping_add(1442695040888963407);
+    let rx = ((s >> 33) as u32) as f64 / (u32::MAX as f64);
+    let ry = ((s >> 11) as u32) as f64 / (u32::MAX as f64);
+    let max_x = wa_x + (wa_w - WIN_W).max(0);
+    let max_y = wa_y + (wa_h - WIN_H).max(0);
+    let x = wa_x + (rx * (max_x - wa_x).max(0) as f64).round() as i32;
+    let y = wa_y + (ry * (max_y - wa_y).max(0) as f64).round() as i32;
+    clamp_window_pos(x, y, wa_x, wa_y, wa_w, wa_h)
+}
+
+fn next_rand(seed: &mut u64) -> u64 {
+    *seed = seed
+        .wrapping_mul(6364136223846793005)
+        .wrapping_add(1442695040888963407);
+    *seed
+}
+
+fn work_area(win: &tauri::WebviewWindow) -> (i32, i32, i32, i32) {
+    if let Ok(Some(m)) = win.current_monitor() {
+        let wa = m.work_area();
+        (wa.position.x, wa.position.y, wa.size.width as i32, wa.size.height as i32)
+    } else {
+        (0, 0, 1920, 1080)
+    }
+}
+
+fn set_pos(win: &tauri::WebviewWindow, x: i32, y: i32) {
+    let _ = win.set_position(PhysicalPosition::new(x, y));
+}
+
+fn emit_gait(
+    app: &AppHandle,
+    last: &mut Option<(&'static str, &'static str)>,
+    kind: &'static str,
+    direction: &'static str,
+) {
+    if *last == Some((kind, direction)) {
+        return;
+    }
+    *last = Some((kind, direction));
+    let _ = app.emit(
+        "pet://gait",
+        GaitPayload { kind, direction },
+    );
+}
+
+#[derive(Serialize, Clone)]
+struct GaitPayload {
+    kind: &'static str,
+    direction: &'static str,
+}
+
+/// 启动运动控制循环(在 `app.manage(AppState)` 之后调用)。
+pub fn spawn(app: AppHandle) {
+    tauri::async_runtime::spawn(async move {
+        let mut last_pos: (i32, i32) = app
+            .get_webview_window("main")
+            .and_then(|w| w.outer_position().ok())
+            .map(|p| (p.x, p.y))
+            .unwrap_or((0, 0));
+        let mut mode = Mode::Idle;
+        let mut target: (i32, i32) = last_pos;
+        let mut fly_from: (i32, i32) = last_pos;
+        let mut fly_left: u32 = 0;
+        let mut idle_ticks: u32 = 0;
+        let mut shake_cooldown: u32 = 0;
+        let mut samples: Vec<Sample> = Vec::new();
+        let mut last_gait: Option<(&'static str, &'static str)> = Some(("idle", "none"));
+        let mut seed: u64 = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_nanos() as u64)
+            .unwrap_or(0x1234_5678_9abc_def0);
+
+        loop {
+            tokio::time::sleep(Duration::from_millis(TICK_MS)).await;
+
+            let Some(win) = app.get_webview_window("main") else {
+                continue;
+            };
+            let now = Instant::now();
+            let (wa_x, wa_y, wa_w, wa_h) = work_area(&win);
+            let follow = app
+                .try_state::<AppState>()
+                .map(|s| s.follow_cursor.load(Ordering::Relaxed))
+                .unwrap_or(false);
+            if shake_cooldown > 0 {
+                shake_cooldown -= 1;
+            }
+
+            // 采样全局光标(用于抖动检测 / 站手跟随)
+            if let Ok(cur) = win.cursor_position() {
+                samples.push(Sample { x: cur.x, y: cur.y, t: now });
+                if samples.len() > MAX_SAMPLES {
+                    samples.remove(0);
+                }
+            }
+
+            if follow {
+                if mode != Mode::Perch {
+                    mode = Mode::Perch;
+                    samples.clear(); // 丢弃进入前的光标历史,避免接近动作被误判为抖动
+                    emit_gait(&app, &mut last_gait, "perch", "none");
+                    let _ = app.emit("pet://perch", serde_json::json!({ "active": true }));
+                }
+                if shake_cooldown == 0 && detect_shake(&samples, now) {
+                    // 抖动 → 甩飞到工作区随机点
+                    if let Some(s) = app.try_state::<AppState>() {
+                        s.follow_cursor.store(false, Ordering::Relaxed);
+                    }
+                    let (tx, ty) = pick_random_target(wa_x, wa_y, wa_w, wa_h, next_rand(&mut seed));
+                    fly_from = last_pos;
+                    target = (tx, ty);
+                    fly_left = FLY_TICKS;
+                    mode = Mode::Fly;
+                    shake_cooldown = SHAKE_COOLDOWN_TICKS;
+                    samples.clear();
+                    let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
+                    emit_gait(&app, &mut last_gait, "fly", "none");
+                } else if let Some(s) = samples.last() {
+                    // 站手跟随:窗口 = 光标 - 双脚锚点
+                    let (cx, cy) = clamp_window_pos(
+                        s.x as i32 - FEET_ANCHOR_X,
+                        s.y as i32 - FEET_ANCHOR_Y,
+                        wa_x,
+                        wa_y,
+                        wa_w,
+                        wa_h,
+                    );
+                    set_pos(&win, cx, cy);
+                    last_pos = (cx, cy);
+                }
+                continue;
+            } else if mode == Mode::Perch {
+                mode = Mode::Idle;
+                idle_ticks = 0;
+                let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
+                emit_gait(&app, &mut last_gait, "idle", "none");
+            }
+
+            match mode {
+                Mode::Fly => {
+                    if fly_left > 0 {
+                        fly_left -= 1;
+                        let t = 1.0 - (fly_left as f64 / FLY_TICKS as f64);
+                        let x = fly_from.0 as f64 + (target.0 - fly_from.0) as f64 * t;
+                        let y = fly_from.1 as f64 + (target.1 - fly_from.1) as f64 * t;
+                        let (cx, cy) = clamp_window_pos(x as i32, y as i32, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                    } else {
+                        mode = Mode::Idle;
+                        idle_ticks = 0;
+                        emit_gait(&app, &mut last_gait, "idle", "none");
+                    }
+                }
+                Mode::Walk | Mode::Hop => {
+                    let step = if mode == Mode::Walk { WALK_STEP } else { HOP_STEP };
+                    let dx = target.0 as f64 - last_pos.0 as f64;
+                    if dx.abs() <= step {
+                        let (cx, cy) = clamp_window_pos(target.0, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                        mode = Mode::Idle;
+                        idle_ticks = 0;
+                        emit_gait(&app, &mut last_gait, "idle", "none");
+                    } else {
+                        let nx = last_pos.0 as f64 + step * dx.signum();
+                        let (cx, cy) = clamp_window_pos(nx as i32, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                    }
+                }
+                _ => {
+                    idle_ticks += 1;
+                    let threshold = IDLE_MIN_TICKS + (next_rand(&mut seed) % IDLE_JITTER_TICKS) as u32;
+                    if idle_ticks >= threshold {
+                        idle_ticks = 0;
+                        let hop = next_rand(&mut seed) % 2 == 0;
+                        let dir: i32 = if next_rand(&mut seed) % 2 == 0 { 1 } else { -1 };
+                        let dist = 60 + (next_rand(&mut seed) % 120) as i32;
+                        let (tx, ty) =
+                            clamp_window_pos(last_pos.0 + dir * dist, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        target = (tx, ty);
+                        mode = if hop { Mode::Hop } else { Mode::Walk };
+                        emit_gait(
+                            &app,
+                            &mut last_gait,
+                            if hop { "hop" } else { "walk" },
+                            if dir > 0 { "right" } else { "left" },
+                        );
+                    }
+                }
+            }
+        }
+    });
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample(x: f64, t: Instant) -> Sample {
+        Sample { x, y: 0.0, t }
+    }
+
+    #[test]
+    fn shake_detected_on_rapid_reversals() {
+        let now = Instant::now();
+        let base = now - Duration::from_millis(500);
+        let xs = [0.0, 40.0, 0.0, 40.0, 0.0, 40.0];
+        let samples: Vec<Sample> = xs
+            .iter()
+            .enumerate()
+            .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
+            .collect();
+        assert!(detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_on_monotonic_movement() {
+        let now = Instant::now();
+        let base = now - Duration::from_millis(500);
+        let xs = [0.0, 40.0, 80.0, 120.0, 160.0, 200.0];
+        let samples: Vec<Sample> = xs
+            .iter()
+            .enumerate()
+            .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
+            .collect();
+        assert!(!detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_when_too_few_samples() {
+        let now = Instant::now();
+        let samples = vec![sample(0.0, now), sample(5.0, now)];
+        assert!(!detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_when_reversals_outside_window() {
+        let now = Instant::now();
+        let old = now - Duration::from_millis(2000);
+        let old_samples: Vec<Sample> = (0..8)
+            .map(|i| sample(if i % 2 == 0 { 0.0 } else { 40.0 }, old))
+            .collect();
+        assert!(!detect_shake(&old_samples, now));
+    }
+
+    #[test]
+    fn clamp_keeps_window_inside_work_area() {
+        assert_eq!(clamp_window_pos(-50, -50, 0, 0, 1920, 1080), (0, 0));
+        assert_eq!(clamp_window_pos(5000, 5000, 0, 0, 1920, 1080), (1720, 880));
+        assert_eq!(clamp_window_pos(100, 100, 0, 0, 1920, 1080), (100, 100));
+    }
+
+    #[test]
+    fn clamp_handles_offset_work_area() {
+        // 副屏起点 (1920, 0)
+        assert_eq!(clamp_window_pos(1900, -10, 1920, 0, 1280, 1024), (1920, 0));
+        assert_eq!(clamp_window_pos(9999, 9999, 1920, 0, 1280, 1024), (3000, 824));
+    }
+
+    #[test]
+    fn random_target_stays_within_bounds() {
+        for seed in [0u64, 1, 42, 123_456, u64::MAX] {
+            let (x, y) = pick_random_target(0, 0, 1920, 1080, seed);
+            assert!((0..=1720).contains(&x), "x={x}");
+            assert!((0..=880).contains(&y), "y={y}");
+        }
+    }
+}

+ 6 - 0
cfc-desktop-pet/src-tauri/src/main.rs

@@ -4,6 +4,7 @@ mod api;
 mod app_state;
 mod commands;
 mod config;
+mod locomotion;
 mod poller;
 mod settings;
 mod state;
@@ -36,8 +37,11 @@ fn main() {
                 passthrough: std::sync::Mutex::new(false),
                 poll_handle: std::sync::Mutex::new(Some(handle)),
                 pending_task: std::sync::Mutex::new(None),
+                follow_cursor: std::sync::atomic::AtomicBool::new(false),
                 api,
             });
+            // 精细化动作运动控制:轮询全局光标 + 移动窗口(走/跳/飞/站手跟随)
+            locomotion::spawn(app.handle().clone());
             tray::setup_tray(app)?;
             Ok(())
         })
@@ -45,6 +49,8 @@ fn main() {
             commands::on_interact,
             commands::drag_start,
             commands::drag_end,
+            commands::start_perch,
+            commands::stop_perch,
             commands::get_settings,
             commands::save_settings,
             commands::open_settings,

+ 12 - 0
cfc-desktop-pet/src/animations.css

@@ -22,6 +22,18 @@
 @keyframes puzzle_active { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(180deg) } }
 @keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } }
 
+/* ===== 精细化动作:姿态层(#pet-body)=====
+   由 locomotion.rs 的 pet://gait 事件驱动;与 #pet 上的情绪动画(.anim-*)分层叠加 */
+@keyframes walk_bob { 0%, 100% { transform: translateY(0) rotate(0deg) } 25% { transform: translateY(-2px) rotate(-3deg) } 75% { transform: translateY(0) rotate(3deg) } }
+@keyframes hop_squash { 0% { transform: translateY(0) scale(1, 1) } 30% { transform: translateY(-3px) scale(1.1, 0.9) } 55% { transform: translateY(-12px) scale(0.92, 1.1) } 80% { transform: translateY(0) scale(1, 1) } }
+@keyframes fly_flutter { 0%, 100% { transform: translateY(0) rotate(-6deg) scale(1, 1) } 25% { transform: translateY(-6px) rotate(4deg) scale(1.08, 0.92) } 50% { transform: translateY(-2px) rotate(-4deg) scale(0.95, 1.05) } 75% { transform: translateY(-8px) rotate(6deg) scale(1.06, 0.94) } }
+@keyframes perch_lean { 0%, 100% { transform: rotate(-2deg) } 50% { transform: rotate(2deg) } }
+
+.pose-walk { animation: walk_bob 0.9s ease-in-out infinite; }
+.pose-hop { animation: hop_squash 0.7s ease-in-out infinite; }
+.pose-fly { animation: fly_flutter 0.45s ease-in-out infinite; }
+.pose-perch { animation: perch_lean 1.6s ease-in-out infinite; }
+
 .anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; }
 .anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; }
 .anim-head_tilt { animation: head_tilt 1.6s ease-in-out infinite; }

+ 6 - 0
cfc-desktop-pet/src/bridge.js

@@ -60,6 +60,12 @@
     },
     onDragEnd: function () {
       try { invoke('drag_end'); } catch (e) { /* noop */ }
+    },
+    startPerch: function () {
+      try { invoke('start_perch'); } catch (e) { /* noop */ }
+    },
+    stopPerch: function () {
+      try { invoke('stop_perch'); } catch (e) { /* noop */ }
     }
   };
 })();

+ 17 - 3
cfc-desktop-pet/src/index.html

@@ -16,11 +16,21 @@
     background: transparent !important;
   }
   #pet {
-    width: 120px; height: 140px; pointer-events: auto;
-    cursor: grab; user-select: none;
+    position: relative; width: 120px; height: 140px; pointer-events: auto;
+    cursor: pointer; user-select: none;
     -webkit-app-region: no-drag;
   }
+  #pet-body {
+    position: relative; width: 100%; height: 100%;
+    transform-origin: 50% 100%;
+  }
   #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
+  /* 胸部热区:悬停 5s → 站手跟随(纯 hover,无需按键) */
+  #chest {
+    position: absolute; left: 50%; top: 16%; width: 55%; height: 42%;
+    transform: translateX(-50%);
+    pointer-events: auto; cursor: pointer; background: transparent;
+  }
   #bubble {
     position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
     background: rgba(255,255,255,0.95); border: 2px solid #4A9BD7;
@@ -51,7 +61,10 @@
 <body>
 <div id="pet-stage">
   <div id="pet">
-    <img id="pet-img" src="" alt="pet" draggable="false" />
+    <div id="pet-body">
+      <img id="pet-img" src="" alt="pet" draggable="false" />
+    </div>
+    <div id="chest"></div>
     <div id="bubble"></div>
   </div>
 </div>
@@ -64,6 +77,7 @@
 </div>
 <script src="animation-gen.js"></script>
 <script src="pet-engine.js"></script>
+<script src="pet-pose.js"></script>
 <script src="bridge.js"></script>
 <script src="interactions.js"></script>
 </body>

+ 25 - 0
cfc-desktop-pet/src/interactions.js

@@ -2,12 +2,15 @@
   'use strict';
 
   const petEl = document.getElementById('pet');
+  const chest = document.getElementById('chest');
   const ctxMenu = document.getElementById('context-menu');
   const invoke = window.__TAURI__?.core?.invoke;
 
   let dragStartX = 0, dragStartY = 0;
   let pressTimer = null;
   let isDragging = false;
+  const CHEST_HOLD_MS = 5000;
+  let chestTimer = null;
 
   // ── 右键菜单 ──────────────────────────────
   petEl.addEventListener('contextmenu', function (e) {
@@ -37,6 +40,7 @@ ctxMenu.addEventListener('click', function (e) {
 
   // ── 点击 / 长按 / 拖拽 ───────────────────
   petEl.addEventListener('mousedown', function (e) {
+    if (window.__petPerched) return; // 站手跟随中:窗口由 Rust 驱动,禁用原生拖拽
     dragStartX = e.clientX;
     dragStartY = e.clientY;
     isDragging = false;
@@ -67,6 +71,27 @@ ctxMenu.addEventListener('click', function (e) {
     }
   });
 
+  // ── 胸部悬停 5s → 站手跟随 ────────────────
+  if (chest) {
+    chest.addEventListener('mouseenter', function () {
+      if (window.__petPerched) return;
+      clearChestTimer();
+      chestTimer = setTimeout(function () {
+        chestTimer = null;
+        if (window.__petPerched) return;
+        window.__petPerched = true; // 乐观置位;Rust 会回发 pet://perch 确认
+        try { window.cfcHost.startPerch(); } catch (_) { /* noop */ }
+        try { window.cfcPetAPI.showBubble('飞到你手上~', 2000); } catch (_) { /* noop */ }
+      }, CHEST_HOLD_MS);
+    });
+    // 悬停未满 5s 就移开 → 取消;站手后窗口跟随光标会使指针移出胸部,此处仅为清理(无害)
+    chest.addEventListener('mouseleave', clearChestTimer);
+  }
+
+  function clearChestTimer() {
+    if (chestTimer) { clearTimeout(chestTimer); chestTimer = null; }
+  }
+
   // 本地即时反馈:不依赖后端也能看到宠物对点击的响应。
   function localReact(kind) {
     try { if (window.cfcPetAPI && window.cfcPetAPI.react) window.cfcPetAPI.react(kind); } catch (_) { /* noop */ }

+ 36 - 0
cfc-desktop-pet/src/pet-pose.js

@@ -0,0 +1,36 @@
+// 姿态渲染层:监听 locomotion.rs 的 pet://gait / pet://perch,把动作映射到 #pet-body 上的 .pose-* 类。
+// 与 #pet 上的情绪动画(pet-engine.js 管理 .anim-*)分层叠加,互不覆盖。
+(function () {
+  'use strict';
+
+  var petBody = document.getElementById('pet-body');
+  if (!petBody) return;
+
+  var POSES = { walk: 'pose-walk', hop: 'pose-hop', fly: 'pose-fly', perch: 'pose-perch' };
+  var currentPose = null;
+
+  function apply(kind) {
+    var cls = (kind && kind !== 'idle' && POSES[kind]) ? POSES[kind] : null;
+    if (currentPose === cls) return;
+    if (currentPose) petBody.classList.remove(currentPose);
+    currentPose = cls;
+    if (cls) {
+      void petBody.offsetWidth; // 强制回流,确保可重播
+      petBody.classList.add(cls);
+    }
+  }
+
+  try {
+    var listen = window.__TAURI__.event.listen;
+    listen('pet://gait', function (e) {
+      apply(e.payload && e.payload.kind);
+    });
+    listen('pet://perch', function (e) {
+      window.__petPerched = !!(e.payload && e.payload.active);
+      // 站手瞬间 gait 事件已带 perch;此处兜底确保姿态出现
+      if (window.__petPerched) apply('perch');
+    });
+  } catch (err) {
+    console.log('[pet-pose] tauri events unavailable', err);
+  }
+})();