| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- //! 桌面宠物「精细化动作」运动控制:轮询全局光标 + 移动窗口,实现走 / 跳着走 / 飞 / 站手跟随。
- //!
- //! 设计:宠物窗口仅 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;
- // ── 宠物可视包围盒(窗口内),用于「鼠标悬停在宠物身上 → 暂停活动」判定 ──
- // 与 index.html 的 #pet(120×140,底部居中)布局保持一致:left=(200-120)/2=40,top=200-140=60
- const PET_BOX_OFFSET_X: i32 = 40;
- const PET_BOX_OFFSET_Y: i32 = 60;
- const PET_BOX_W: i32 = 120;
- const PET_BOX_H: i32 = 140;
- // ── 抖动检测参数 ──
- 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))
- }
- /// 光标是否落在窗口内的宠物包围盒上(用于「悬停在宠物身上 → 暂停活动」)。
- pub fn cursor_over_pet(cursor: (f64, f64), win_pos: (i32, i32)) -> bool {
- let left = (win_pos.0 + PET_BOX_OFFSET_X) as f64;
- let top = (win_pos.1 + PET_BOX_OFFSET_Y) as f64;
- let right = left + PET_BOX_W as f64;
- let bottom = top + PET_BOX_H as f64;
- cursor.0 >= left && cursor.0 <= right && cursor.1 >= top && cursor.1 <= bottom
- }
- /// 工作区内的确定性随机落点(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;
- }
- // 采样全局光标(用于抖动检测 / 站手跟随 / 悬停暂停)
- let mut cursor: Option<(f64, f64)> = None;
- if let Ok(cur) = win.cursor_position() {
- cursor = Some((cur.x, cur.y));
- 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");
- }
- // 鼠标悬停在宠物身上时暂停自主动作(走/跳/飞 → 立即待机)
- let hovered = cursor.map(|c| cursor_over_pet(c, last_pos)).unwrap_or(false);
- if hovered && matches!(mode, Mode::Walk | Mode::Hop | Mode::Fly) {
- mode = Mode::Idle;
- idle_ticks = 0;
- 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);
- }
- }
- _ => {
- if hovered {
- // 悬停期间保持静止:不累积空闲计时,不触发新的走/跳
- idle_ticks = 0;
- } else {
- 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}");
- }
- }
- #[test]
- fn cursor_over_pet_detects_hit_and_miss() {
- // 窗口左上角 (100,100):宠物盒 = x∈[140,260], y∈[160,300]
- assert!(cursor_over_pet((200.0, 250.0), (100, 100)));
- assert!(!cursor_over_pet((10.0, 250.0), (100, 100)));
- assert!(!cursor_over_pet((200.0, 10.0), (100, 100)));
- // 边界包含
- assert!(cursor_over_pet((140.0, 160.0), (100, 100)));
- assert!(cursor_over_pet((260.0, 300.0), (100, 100)));
- // 窗口移动后包围盒随之平移
- assert!(cursor_over_pet((500.0, 600.0), (400, 450)));
- }
- }
|