Commit 800d7775 by luoqi

feat(web): 桌宠微调 — 打招呼气泡/画圈套绳/超人按距离/各概率

- 切患者:恢复"开始跟进「XX」"说话框(此动作保留气泡)
- 鼠标画圈手势(累计转角>330°+成环+冷却)→ 以画圈处为锚点套绳荡飞
- 边框上想去别层:套绳概率 18%→40%
- 蛀虫骑边框概率 40%→60%
- 超人模式改按"宠物-蛀虫距离"给概率 min(32%, 4%+dist/2200),越远越易触发
- 牙医组合 idle 轮盘 10%→16%

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 43bcc18d
...@@ -143,11 +143,14 @@ export function usePetBrain() { ...@@ -143,11 +143,14 @@ export function usePetBrain() {
prevSeq = s.seq; prevSeq = s.seq;
if (s.status === 'completed') play({ gesture: 'celebrate', ttlMs: 3_600 }); if (s.status === 'completed') play({ gesture: 'celebrate', ttlMs: 3_600 });
} }
// 切患者 → 抬头看一眼(无语言) // 切患者 → 抬头看一眼 + 打招呼气泡(此动作保留说话框)
const planId = s.current?.planId ?? null; const planId = s.current?.planId ?? null;
if (planId && planId !== prevPlanId) { if (planId && planId !== prevPlanId) {
prevPlanId = planId; prevPlanId = planId;
if (s.current?.patientName) setGlanceSeq((g) => g + 1); if (s.current?.patientName) {
setGlanceSeq((g) => g + 1);
play({ gesture: 'greet', bubble: `开始跟进「${s.current.patientName}」`, ttlMs: 2_600 });
}
} }
}); });
}, [play]); }, [play]);
...@@ -178,11 +181,11 @@ export function usePetBrain() { ...@@ -178,11 +181,11 @@ export function usePetBrain() {
const schedule = () => { const schedule = () => {
timer = setTimeout(() => { timer = setTimeout(() => {
const r = Math.random(); const r = Math.random();
if (r < 0.42) setGlanceSeq((s) => s + 1); if (r < 0.4) setGlanceSeq((s) => s + 1);
else if (r < 0.6) play({ gesture: 'floss', ttlMs: 3_000 }); else if (r < 0.56) play({ gesture: 'floss', ttlMs: 3_000 });
else if (r < 0.76) play({ gesture: 'shine', ttlMs: 1_800 }); else if (r < 0.71) play({ gesture: 'shine', ttlMs: 1_800 });
else if (r < 0.9) play({ gesture: 'chomp', ttlMs: 2_400 }); else if (r < 0.84) play({ gesture: 'chomp', ttlMs: 2_400 });
else emitPetEvent({ type: 'vet_combo' }); // ~10%:交给 widget 跑牙医诊疗组合 else emitPetEvent({ type: 'vet_combo' }); // ~16%:交给 widget 跑牙医诊疗组合
schedule(); schedule();
}, GLANCE_MIN_MS + Math.random() * (GLANCE_MAX_MS - GLANCE_MIN_MS)); }, GLANCE_MIN_MS + Math.random() * (GLANCE_MAX_MS - GLANCE_MIN_MS));
}; };
......
...@@ -80,6 +80,8 @@ export function usePetLocomotion( ...@@ -80,6 +80,8 @@ export function usePetLocomotion(
nextStrollAt: Date.now() + STROLL_IDLE_MIN_MS, nextStrollAt: Date.now() + STROLL_IDLE_MIN_MS,
// 鼠标互动:光标位置/速度 + 惊吓/好奇冷却 // 鼠标互动:光标位置/速度 + 惊吓/好奇冷却
cur: { x: -1e4, y: -1e4, t: 0, lastFastAt: 0 }, cur: { x: -1e4, y: -1e4, t: 0, lastFastAt: 0 },
// 转圈手势识别(画圈套绳):累计转角 + 路径范围 + 冷却
circle: { prevH: null as number | null, accum: 0, t0: 0, len: 0, minX: 0, maxX: 0, minY: 0, maxY: 0, lastAt: 0 },
startleUntil: 0, startleUntil: 0,
curiousUntil: 0, curiousUntil: 0,
// 拖拽测速(松手抛掷用) // 拖拽测速(松手抛掷用)
...@@ -317,6 +319,51 @@ export function usePetLocomotion( ...@@ -317,6 +319,51 @@ export function usePetLocomotion(
} }
} }
} }
// 画圈手势 → 套绳荡飞:累计转角超过约一圈(且路径成环、范围合理)即触发
const cr = c.circle;
const mdx = e.clientX - c.cur.x;
const mdy = e.clientY - c.cur.y;
const seg = Math.hypot(mdx, mdy);
if (seg > 2.5) {
if (cr.prevH === null || now - cr.t0 > 1_400) {
cr.accum = 0; // 窗口重置(停顿/超时)
cr.len = 0;
cr.t0 = now;
cr.minX = cr.maxX = e.clientX;
cr.minY = cr.maxY = e.clientY;
cr.prevH = null;
}
const h = Math.atan2(mdy, mdx);
if (cr.prevH !== null) {
let d = h - cr.prevH;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
cr.accum += d;
cr.len += seg;
}
cr.prevH = h;
cr.minX = Math.min(cr.minX, e.clientX);
cr.maxX = Math.max(cr.maxX, e.clientX);
cr.minY = Math.min(cr.minY, e.clientY);
cr.maxY = Math.max(cr.maxY, e.clientY);
const diag = Math.hypot(cr.maxX - cr.minX, cr.maxY - cr.minY);
if (
Math.abs(cr.accum) > 5.8 && // ≈ 330°
cr.len > 140 &&
diag > 30 &&
diag < 650 &&
now - cr.lastAt > 6_000 &&
c.motion === 'rest' &&
!getBusy()
) {
cr.lastAt = now;
cr.accum = 0;
cr.prevH = null;
startSwing({ x: e.clientX, y: e.clientY }); // 锚点 = 画圈处
}
}
c.cur.x = e.clientX; c.cur.x = e.clientX;
c.cur.y = e.clientY; c.cur.y = e.clientY;
c.cur.t = now; c.cur.t = now;
...@@ -699,8 +746,8 @@ export function usePetLocomotion( ...@@ -699,8 +746,8 @@ export function usePetLocomotion(
// 蹲够了 / 要去蹲守 AI → 走向平台边缘,自然踩空下来 // 蹲够了 / 要去蹲守 AI → 走向平台边缘,自然踩空下来
const wantDown = Date.now() > c.nextStrollAt || getWatchX() !== null; const wantDown = Date.now() > c.nextStrollAt || getWatchX() !== null;
if (wantDown && !getBusy()) { if (wantDown && !getBusy()) {
// 在边框高处也能套绳荡飞(鼠标在低处也行)→ 直接离框甩荡 // 在边框上想去别的楼层时,优先甩绳荡过去(概率提高)
if (getWatchX() === null && Math.random() < 0.18 && swingReady()) { if (getWatchX() === null && Math.random() < 0.4 && swingReady()) {
startSwing(); startSwing();
scheduleStroll(); scheduleStroll();
break; break;
......
...@@ -235,10 +235,9 @@ export function PetFab({ ...@@ -235,10 +235,9 @@ export function PetFab({
const spawnGerm = (forceSuper = false) => { const spawnGerm = (forceSuper = false) => {
if (germ.current) return; // 允许宠物坐着/走着时也刷(它会下来/跳上去追) if (germ.current) return; // 允许宠物坐着/走着时也刷(它会下来/跳上去追)
vetAbortRef.current(); // 蛀虫出现 → 中断诊疗组合,优先去追 vetAbortRef.current(); // 蛀虫出现 → 中断诊疗组合,优先去追
const superman = forceSuper || Math.random() < 0.1; // 隐藏 10%:超人模式(lab 可强制) // 60% 概率骑在某个可见边框上,否则在地板底边
// 40% 概率骑在某个可见边框上,否则在地板底边
let perch: Element | null = null; let perch: Element | null = null;
if (Math.random() < 0.4) { if (Math.random() < 0.6) {
const cands: Element[] = []; const cands: Element[] = [];
document.querySelectorAll('[data-pet-perch], [class*="border"]').forEach((p) => { document.querySelectorAll('[data-pet-perch], [class*="border"]').forEach((p) => {
const r = p.getBoundingClientRect(); const r = p.getBoundingClientRect();
...@@ -249,29 +248,31 @@ export function PetFab({ ...@@ -249,29 +248,31 @@ export function PetFab({
}); });
perch = cands[Math.floor(Math.random() * cands.length)] ?? null; perch = cands[Math.floor(Math.random() * cands.length)] ?? null;
} }
// 算出蛀虫落点(x + 离地高度 yTop),再按"与宠物距离"决定超人概率(越远越可能)
let gx: number;
let gyTop: number;
if (perch) { if (perch) {
const r = perch.getBoundingClientRect(); const r = perch.getBoundingClientRect();
germ.current = { gx = r.left + 6 + Math.random() * Math.max(r.width - 28, 1);
x: r.left + 6 + Math.random() * Math.max(r.width - 28, 1), gyTop = r.top - 22;
dir: Math.random() < 0.5 ? -1 : 1,
t0: performance.now(),
state: 'crawl',
perch,
superman,
};
} else { } else {
const r = btnRef.current?.getBoundingClientRect(); const r = btnRef.current?.getBoundingClientRect();
const baseX = r ? r.left : window.innerWidth / 2; const baseX = r ? r.left : window.innerWidth / 2;
const side = Math.random() < 0.5 ? -1 : 1; const side = Math.random() < 0.5 ? -1 : 1;
germ.current = { gx = Math.min(Math.max(baseX + side * (200 + Math.random() * 160), 8), window.innerWidth - 30);
x: Math.min(Math.max(baseX + side * (200 + Math.random() * 160), 8), window.innerWidth - 30), gyTop = window.innerHeight - 22 - 2;
dir: side > 0 ? -1 : 1,
t0: performance.now(),
state: 'crawl',
perch: null,
superman,
};
} }
const pr = btnRef.current?.getBoundingClientRect();
const dist = pr ? Math.hypot(gx + 11 - (pr.left + SIZE / 2), gyTop + 11 - (pr.top + SIZE / 2)) : 0;
const superman = forceSuper || Math.random() < Math.min(0.32, 0.04 + dist / 2200); // 距离越远越可能变超人
germ.current = {
x: gx,
dir: Math.random() < 0.5 ? -1 : 1,
t0: performance.now(),
state: 'crawl',
perch,
superman,
};
setGermAlive(true); setGermAlive(true);
}; };
const spawnRef = useRef(spawnGerm); const spawnRef = useRef(spawnGerm);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment