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