locomotion.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. //! 桌面宠物「精细化动作」运动控制:轮询全局光标 + 移动窗口,实现走 / 跳着走 / 飞 / 站手跟随。
  2. //!
  3. //! 设计:宠物窗口仅 200×200,跨屏走/飞必须**移动窗口本身**;窗口位置只能由 Rust 修改,
  4. //! 故本模块是「移动者」,前端 `pet-pose.js` 只负责渲染局部姿态(走路摆动/跳跃/扑腾)。
  5. //!
  6. //! 免全局鼠标钩子:用 `WebviewWindow::cursor_position()` 轮询全局光标(跨平台、无 macOS 辅助功能权限)。
  7. use crate::app_state::AppState;
  8. use serde::Serialize;
  9. use std::sync::atomic::Ordering;
  10. use std::time::{Duration, Instant};
  11. use tauri::{AppHandle, Emitter, Manager, PhysicalPosition};
  12. /// 循环间隔(ms)
  13. const TICK_MS: u64 = 60;
  14. /// 宠物窗口物理尺寸(与 tauri.conf.json 保持一致)
  15. const WIN_W: i32 = 200;
  16. const WIN_H: i32 = 200;
  17. /// 宠物双脚在窗口内的锚点(窗口左上角为原点)。站手时双脚落在光标处。
  18. const FEET_ANCHOR_X: i32 = 100;
  19. const FEET_ANCHOR_Y: i32 = 200;
  20. // ── 宠物可视包围盒(窗口内),用于「鼠标悬停在宠物身上 → 暂停活动」判定 ──
  21. // 与 index.html 的 #pet(120×140,底部居中)布局保持一致:left=(200-120)/2=40,top=200-140=60
  22. const PET_BOX_OFFSET_X: i32 = 40;
  23. const PET_BOX_OFFSET_Y: i32 = 60;
  24. const PET_BOX_W: i32 = 120;
  25. const PET_BOX_H: i32 = 140;
  26. // ── 抖动检测参数 ──
  27. const SHAKE_WINDOW_MS: u128 = 600;
  28. const SHAKE_MIN_REVERSALS: usize = 4;
  29. const SHAKE_MIN_TRAVEL: f64 = 80.0;
  30. /// 甩飞后抖动冷却(tick 数,60ms/tick → 约 1.8s),避免落地即再触发
  31. const SHAKE_COOLDOWN_TICKS: u32 = 30;
  32. const MAX_SAMPLES: usize = 40;
  33. // ── 步态参数(每 tick 位移,60ms/tick → 约 27px/s 走 / 43px/s 跳)──
  34. const WALK_STEP: f64 = 1.6;
  35. const HOP_STEP: f64 = 2.6;
  36. const FLY_TICKS: u32 = 10; // ≈600ms
  37. const IDLE_MIN_TICKS: u32 = 45; // ≈2.7s 起才会有自主动作
  38. const IDLE_JITTER_TICKS: u64 = 70;
  39. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  40. enum Mode {
  41. Idle,
  42. Walk,
  43. Hop,
  44. Fly,
  45. Perch,
  46. }
  47. #[derive(Debug, Clone, Copy)]
  48. pub struct Sample {
  49. pub x: f64,
  50. pub y: f64,
  51. pub t: Instant,
  52. }
  53. /// 抖动检测:在时间窗口内统计水平方向反转次数与总位移。
  54. /// 反转 ≥4 次且总位移 ≥80px → 视为「抖动鼠标」。
  55. pub fn detect_shake(samples: &[Sample], now: Instant) -> bool {
  56. let recent: Vec<&Sample> = samples
  57. .iter()
  58. .filter(|s| now.duration_since(s.t).as_millis() <= SHAKE_WINDOW_MS)
  59. .collect();
  60. if recent.len() < 4 {
  61. return false;
  62. }
  63. let mut reversals = 0usize;
  64. let mut travel = 0.0f64;
  65. let mut prev_dir = 0i32;
  66. for w in recent.windows(2) {
  67. let dx = w[1].x - w[0].x;
  68. if dx.abs() < 1.0 {
  69. continue;
  70. }
  71. travel += dx.abs();
  72. let dir = if dx > 0.0 { 1 } else { -1 };
  73. if prev_dir != 0 && dir != prev_dir {
  74. reversals += 1;
  75. }
  76. prev_dir = dir;
  77. }
  78. reversals >= SHAKE_MIN_REVERSALS && travel >= SHAKE_MIN_TRAVEL
  79. }
  80. /// 把窗口左上角夹进工作区,保证整窗可见。
  81. pub fn clamp_window_pos(x: i32, y: i32, wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32) -> (i32, i32) {
  82. let max_x = wa_x + (wa_w - WIN_W).max(0);
  83. let max_y = wa_y + (wa_h - WIN_H).max(0);
  84. (x.clamp(wa_x, max_x), y.clamp(wa_y, max_y))
  85. }
  86. /// 光标是否落在窗口内的宠物包围盒上(用于「悬停在宠物身上 → 暂停活动」)。
  87. pub fn cursor_over_pet(cursor: (f64, f64), win_pos: (i32, i32)) -> bool {
  88. let left = (win_pos.0 + PET_BOX_OFFSET_X) as f64;
  89. let top = (win_pos.1 + PET_BOX_OFFSET_Y) as f64;
  90. let right = left + PET_BOX_W as f64;
  91. let bottom = top + PET_BOX_H as f64;
  92. cursor.0 >= left && cursor.0 <= right && cursor.1 >= top && cursor.1 <= bottom
  93. }
  94. /// 工作区内的确定性随机落点(LCG 单步,便于单测)。
  95. pub fn pick_random_target(wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32, seed: u64) -> (i32, i32) {
  96. let s = seed
  97. .wrapping_mul(6364136223846793005)
  98. .wrapping_add(1442695040888963407);
  99. let rx = ((s >> 33) as u32) as f64 / (u32::MAX as f64);
  100. let ry = ((s >> 11) as u32) as f64 / (u32::MAX as f64);
  101. let max_x = wa_x + (wa_w - WIN_W).max(0);
  102. let max_y = wa_y + (wa_h - WIN_H).max(0);
  103. let x = wa_x + (rx * (max_x - wa_x).max(0) as f64).round() as i32;
  104. let y = wa_y + (ry * (max_y - wa_y).max(0) as f64).round() as i32;
  105. clamp_window_pos(x, y, wa_x, wa_y, wa_w, wa_h)
  106. }
  107. fn next_rand(seed: &mut u64) -> u64 {
  108. *seed = seed
  109. .wrapping_mul(6364136223846793005)
  110. .wrapping_add(1442695040888963407);
  111. *seed
  112. }
  113. fn work_area(win: &tauri::WebviewWindow) -> (i32, i32, i32, i32) {
  114. if let Ok(Some(m)) = win.current_monitor() {
  115. let wa = m.work_area();
  116. (wa.position.x, wa.position.y, wa.size.width as i32, wa.size.height as i32)
  117. } else {
  118. (0, 0, 1920, 1080)
  119. }
  120. }
  121. fn set_pos(win: &tauri::WebviewWindow, x: i32, y: i32) {
  122. let _ = win.set_position(PhysicalPosition::new(x, y));
  123. }
  124. fn emit_gait(
  125. app: &AppHandle,
  126. last: &mut Option<(&'static str, &'static str)>,
  127. kind: &'static str,
  128. direction: &'static str,
  129. ) {
  130. if *last == Some((kind, direction)) {
  131. return;
  132. }
  133. *last = Some((kind, direction));
  134. let _ = app.emit(
  135. "pet://gait",
  136. GaitPayload { kind, direction },
  137. );
  138. }
  139. #[derive(Serialize, Clone)]
  140. struct GaitPayload {
  141. kind: &'static str,
  142. direction: &'static str,
  143. }
  144. /// 启动运动控制循环(在 `app.manage(AppState)` 之后调用)。
  145. pub fn spawn(app: AppHandle) {
  146. tauri::async_runtime::spawn(async move {
  147. let mut last_pos: (i32, i32) = app
  148. .get_webview_window("main")
  149. .and_then(|w| w.outer_position().ok())
  150. .map(|p| (p.x, p.y))
  151. .unwrap_or((0, 0));
  152. let mut mode = Mode::Idle;
  153. let mut target: (i32, i32) = last_pos;
  154. let mut fly_from: (i32, i32) = last_pos;
  155. let mut fly_left: u32 = 0;
  156. let mut idle_ticks: u32 = 0;
  157. let mut shake_cooldown: u32 = 0;
  158. let mut samples: Vec<Sample> = Vec::new();
  159. let mut last_gait: Option<(&'static str, &'static str)> = Some(("idle", "none"));
  160. let mut seed: u64 = std::time::SystemTime::now()
  161. .duration_since(std::time::UNIX_EPOCH)
  162. .map(|d| d.as_nanos() as u64)
  163. .unwrap_or(0x1234_5678_9abc_def0);
  164. loop {
  165. tokio::time::sleep(Duration::from_millis(TICK_MS)).await;
  166. let Some(win) = app.get_webview_window("main") else {
  167. continue;
  168. };
  169. let now = Instant::now();
  170. let (wa_x, wa_y, wa_w, wa_h) = work_area(&win);
  171. let follow = app
  172. .try_state::<AppState>()
  173. .map(|s| s.follow_cursor.load(Ordering::Relaxed))
  174. .unwrap_or(false);
  175. if shake_cooldown > 0 {
  176. shake_cooldown -= 1;
  177. }
  178. // 采样全局光标(用于抖动检测 / 站手跟随 / 悬停暂停)
  179. let mut cursor: Option<(f64, f64)> = None;
  180. if let Ok(cur) = win.cursor_position() {
  181. cursor = Some((cur.x, cur.y));
  182. samples.push(Sample { x: cur.x, y: cur.y, t: now });
  183. if samples.len() > MAX_SAMPLES {
  184. samples.remove(0);
  185. }
  186. }
  187. if follow {
  188. if mode != Mode::Perch {
  189. mode = Mode::Perch;
  190. samples.clear(); // 丢弃进入前的光标历史,避免接近动作被误判为抖动
  191. emit_gait(&app, &mut last_gait, "perch", "none");
  192. let _ = app.emit("pet://perch", serde_json::json!({ "active": true }));
  193. }
  194. if shake_cooldown == 0 && detect_shake(&samples, now) {
  195. // 抖动 → 甩飞到工作区随机点
  196. if let Some(s) = app.try_state::<AppState>() {
  197. s.follow_cursor.store(false, Ordering::Relaxed);
  198. }
  199. let (tx, ty) = pick_random_target(wa_x, wa_y, wa_w, wa_h, next_rand(&mut seed));
  200. fly_from = last_pos;
  201. target = (tx, ty);
  202. fly_left = FLY_TICKS;
  203. mode = Mode::Fly;
  204. shake_cooldown = SHAKE_COOLDOWN_TICKS;
  205. samples.clear();
  206. let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
  207. emit_gait(&app, &mut last_gait, "fly", "none");
  208. } else if let Some(s) = samples.last() {
  209. // 站手跟随:窗口 = 光标 - 双脚锚点
  210. let (cx, cy) = clamp_window_pos(
  211. s.x as i32 - FEET_ANCHOR_X,
  212. s.y as i32 - FEET_ANCHOR_Y,
  213. wa_x,
  214. wa_y,
  215. wa_w,
  216. wa_h,
  217. );
  218. set_pos(&win, cx, cy);
  219. last_pos = (cx, cy);
  220. }
  221. continue;
  222. } else if mode == Mode::Perch {
  223. mode = Mode::Idle;
  224. idle_ticks = 0;
  225. let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
  226. emit_gait(&app, &mut last_gait, "idle", "none");
  227. }
  228. // 鼠标悬停在宠物身上时暂停自主动作(走/跳/飞 → 立即待机)
  229. let hovered = cursor.map(|c| cursor_over_pet(c, last_pos)).unwrap_or(false);
  230. if hovered && matches!(mode, Mode::Walk | Mode::Hop | Mode::Fly) {
  231. mode = Mode::Idle;
  232. idle_ticks = 0;
  233. emit_gait(&app, &mut last_gait, "idle", "none");
  234. }
  235. match mode {
  236. Mode::Fly => {
  237. if fly_left > 0 {
  238. fly_left -= 1;
  239. let t = 1.0 - (fly_left as f64 / FLY_TICKS as f64);
  240. let x = fly_from.0 as f64 + (target.0 - fly_from.0) as f64 * t;
  241. let y = fly_from.1 as f64 + (target.1 - fly_from.1) as f64 * t;
  242. let (cx, cy) = clamp_window_pos(x as i32, y as i32, wa_x, wa_y, wa_w, wa_h);
  243. set_pos(&win, cx, cy);
  244. last_pos = (cx, cy);
  245. } else {
  246. mode = Mode::Idle;
  247. idle_ticks = 0;
  248. emit_gait(&app, &mut last_gait, "idle", "none");
  249. }
  250. }
  251. Mode::Walk | Mode::Hop => {
  252. let step = if mode == Mode::Walk { WALK_STEP } else { HOP_STEP };
  253. let dx = target.0 as f64 - last_pos.0 as f64;
  254. if dx.abs() <= step {
  255. let (cx, cy) = clamp_window_pos(target.0, last_pos.1, wa_x, wa_y, wa_w, wa_h);
  256. set_pos(&win, cx, cy);
  257. last_pos = (cx, cy);
  258. mode = Mode::Idle;
  259. idle_ticks = 0;
  260. emit_gait(&app, &mut last_gait, "idle", "none");
  261. } else {
  262. let nx = last_pos.0 as f64 + step * dx.signum();
  263. let (cx, cy) = clamp_window_pos(nx as i32, last_pos.1, wa_x, wa_y, wa_w, wa_h);
  264. set_pos(&win, cx, cy);
  265. last_pos = (cx, cy);
  266. }
  267. }
  268. _ => {
  269. if hovered {
  270. // 悬停期间保持静止:不累积空闲计时,不触发新的走/跳
  271. idle_ticks = 0;
  272. } else {
  273. idle_ticks += 1;
  274. let threshold =
  275. IDLE_MIN_TICKS + (next_rand(&mut seed) % IDLE_JITTER_TICKS) as u32;
  276. if idle_ticks >= threshold {
  277. idle_ticks = 0;
  278. let hop = next_rand(&mut seed) % 2 == 0;
  279. let dir: i32 = if next_rand(&mut seed) % 2 == 0 { 1 } else { -1 };
  280. let dist = 60 + (next_rand(&mut seed) % 120) as i32;
  281. let (tx, ty) = clamp_window_pos(
  282. last_pos.0 + dir * dist,
  283. last_pos.1,
  284. wa_x,
  285. wa_y,
  286. wa_w,
  287. wa_h,
  288. );
  289. target = (tx, ty);
  290. mode = if hop { Mode::Hop } else { Mode::Walk };
  291. emit_gait(
  292. &app,
  293. &mut last_gait,
  294. if hop { "hop" } else { "walk" },
  295. if dir > 0 { "right" } else { "left" },
  296. );
  297. }
  298. }
  299. }
  300. }
  301. }
  302. });
  303. }
  304. #[cfg(test)]
  305. mod tests {
  306. use super::*;
  307. fn sample(x: f64, t: Instant) -> Sample {
  308. Sample { x, y: 0.0, t }
  309. }
  310. #[test]
  311. fn shake_detected_on_rapid_reversals() {
  312. let now = Instant::now();
  313. let base = now - Duration::from_millis(500);
  314. let xs = [0.0, 40.0, 0.0, 40.0, 0.0, 40.0];
  315. let samples: Vec<Sample> = xs
  316. .iter()
  317. .enumerate()
  318. .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
  319. .collect();
  320. assert!(detect_shake(&samples, now));
  321. }
  322. #[test]
  323. fn no_shake_on_monotonic_movement() {
  324. let now = Instant::now();
  325. let base = now - Duration::from_millis(500);
  326. let xs = [0.0, 40.0, 80.0, 120.0, 160.0, 200.0];
  327. let samples: Vec<Sample> = xs
  328. .iter()
  329. .enumerate()
  330. .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
  331. .collect();
  332. assert!(!detect_shake(&samples, now));
  333. }
  334. #[test]
  335. fn no_shake_when_too_few_samples() {
  336. let now = Instant::now();
  337. let samples = vec![sample(0.0, now), sample(5.0, now)];
  338. assert!(!detect_shake(&samples, now));
  339. }
  340. #[test]
  341. fn no_shake_when_reversals_outside_window() {
  342. let now = Instant::now();
  343. let old = now - Duration::from_millis(2000);
  344. let old_samples: Vec<Sample> = (0..8)
  345. .map(|i| sample(if i % 2 == 0 { 0.0 } else { 40.0 }, old))
  346. .collect();
  347. assert!(!detect_shake(&old_samples, now));
  348. }
  349. #[test]
  350. fn clamp_keeps_window_inside_work_area() {
  351. assert_eq!(clamp_window_pos(-50, -50, 0, 0, 1920, 1080), (0, 0));
  352. assert_eq!(clamp_window_pos(5000, 5000, 0, 0, 1920, 1080), (1720, 880));
  353. assert_eq!(clamp_window_pos(100, 100, 0, 0, 1920, 1080), (100, 100));
  354. }
  355. #[test]
  356. fn clamp_handles_offset_work_area() {
  357. // 副屏起点 (1920, 0)
  358. assert_eq!(clamp_window_pos(1900, -10, 1920, 0, 1280, 1024), (1920, 0));
  359. assert_eq!(clamp_window_pos(9999, 9999, 1920, 0, 1280, 1024), (3000, 824));
  360. }
  361. #[test]
  362. fn random_target_stays_within_bounds() {
  363. for seed in [0u64, 1, 42, 123_456, u64::MAX] {
  364. let (x, y) = pick_random_target(0, 0, 1920, 1080, seed);
  365. assert!((0..=1720).contains(&x), "x={x}");
  366. assert!((0..=880).contains(&y), "y={y}");
  367. }
  368. }
  369. #[test]
  370. fn cursor_over_pet_detects_hit_and_miss() {
  371. // 窗口左上角 (100,100):宠物盒 = x∈[140,260], y∈[160,300]
  372. assert!(cursor_over_pet((200.0, 250.0), (100, 100)));
  373. assert!(!cursor_over_pet((10.0, 250.0), (100, 100)));
  374. assert!(!cursor_over_pet((200.0, 10.0), (100, 100)));
  375. // 边界包含
  376. assert!(cursor_over_pet((140.0, 160.0), (100, 100)));
  377. assert!(cursor_over_pet((260.0, 300.0), (100, 100)));
  378. // 窗口移动后包围盒随之平移
  379. assert!(cursor_over_pet((500.0, 600.0), (400, 450)));
  380. }
  381. }