Commit 43bcc18d by luoqi

feat(web): 详情页桌宠「小牙」— 动作/物理/互动 + LLM 环境发言

Q 版磨牙桌宠,挂在 plans/layout(替代原助手 Bot FAB),三层架构:
- 感知 pet-events(语义事件 + DirectorScript 受限词表,LLM 接入口)
- 大脑 pet-brain(FSM 基线姿态 + idle 动作轮盘:张望/牙线/闪亮/咬合/牙医组合)
- 身体 pet-body(程序化 SVG,无外部资产)+ 运动层 pet-locomotion(物理)

动作/物理:重力抛掷·撞墙反弹·任意位栖息(自动发现带边框元素)·走边缘踩空·
爬楼(背朝外悬挂垫脚,可上可下)·动锚点钟摆套绳荡飞(绳端钉实时鼠标)。
互动:摸摸/惊吓/好奇凑近/眼神追随;圈选文字 → 走过去+爬楼+放大镜照+爬下楼。
组合派生:牙医诊疗(检查→洗牙/补牙/终止→抛光/涂氟护盾/终止→收尾,概率分叉)。
NPC:蛀牙菌(地板/边框两种 lane,追/逃/敲爆;隐藏 10% 超人模式:变身→直飞秒杀)。
仪式:坐稳卡片刷牙→闪亮→漱口→涂氟护盾。5 分钟无操作打盹。

LLM:新增 POST /pac/v1/assistant/pet-say(SSE,无工具,≤30 字流式),
前端 use-pet-voice 仅页面激活 + 低概率 + 4min 冷却时观察环境说一句(唯一带气泡的动作);
其余动作全为纯动作/道具,不出说话框。

/pet-lab 为动作陈列 + 触发调试页(dev 用)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 8910c3d3
......@@ -84,6 +84,39 @@ export class AssistantController {
return { text: r.text };
}
/** 桌宠发言:输入一段环境观察,流式吐一句 ≤30 字的台词(无工具,极轻)。 */
@Post('pet-say')
@ApiOperation({ summary: '桌宠环境观察发言(SSE,简短一句)' })
async petSay(
@Req() req: Request,
@Res() res: Response,
@Body() body: { observation?: string },
): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
const send = (event: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
};
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
const r = this.assistant.petSay({
observation: String(body.observation ?? '').slice(0, 400), // 观察必须简短,后端兜底截断
abortSignal: ac.signal,
});
for await (const text of r.textStream) {
if (text) send({ type: 'text', text });
}
send({ type: 'done' });
} catch (err) {
send({ type: 'error', error: err instanceof Error ? err.message : String(err) });
} finally {
res.end();
}
}
@Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat(@Req() req: Request, @Res() res: Response, @Body() body: ChatBody): Promise<void> {
......
......@@ -24,6 +24,13 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所
用中文,简洁专业、友好。`;
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
只评论观察里出现的事,不编造患者信息,不给医疗建议,不自称 AI 或模型;
不假设用户的性别/称呼(不要叫哥哥姐姐,直接用"你"或不称呼)。
直接输出台词本身,不要引号、不要解释。`;
export interface AssistantChatInput {
userToken: string;
modelId?: string;
......@@ -96,4 +103,18 @@ export class AssistantService {
abortSignal: input.abortSignal,
});
}
/** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
const { model } = this.provider.resolve('deepseek');
return streamText({
model,
system: PET_SYSTEM_PROMPT,
prompt: `环境观察:${input.observation}`,
// 注:思考型模型 reasoning 也计入 output token,上限给宽;台词长度靠系统提示词约束(≤30 字)
maxOutputTokens: 500,
temperature: 1.0,
abortSignal: input.abortSignal,
});
}
}
'use client';
import { useState } from 'react';
import { emitPetEvent } from '@/lib/pet-events';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetBody, type PetBodyPose } from '@/components/pet/pet-body';
import { PetFab } from '@/components/pet/pet-widget';
const POSES: PetBodyPose[] = ['idle', 'think', 'celebrate', 'sleep', 'walk', 'fall', 'sit', 'happy', 'brush', 'rinse', 'floss', 'shine', 'chomp', 'fly', 'vet_exam', 'vet_scale', 'vet_fill', 'vet_polish', 'vet_fluoride', 'climb', 'magnify'];
/**
* 宠物实验室(dev 调试页,无需登录)— 上:各姿态静态陈列(直接喂 PetBody);
* 真·PetFab(带大脑 + 运动层)在页面里自由活动:拖起有重力、空闲散步、
* 可跳上下方的 data-pet-perch 栖息台。按钮模拟感知事件,验证 FSM 流转。
* 不进生产导航,仅供调动画/调行为时手动访问 /pet-lab。
*/
export default function PetLabPage() {
const [glance, setGlance] = useState(0);
return (
<div className="min-h-screen bg-slate-50 p-8">
<h1 className="mb-6 text-lg font-semibold text-slate-700">宠物实验室 /pet-lab</h1>
{/* 姿态卡片本身也是栖息点(data-pet-perch):宠物会跳到卡片上边框蹲着 */}
<section className="mb-8 flex flex-wrap gap-6">
{POSES.map((p) => (
<figure
key={p}
data-pet-perch
className="flex flex-col items-center gap-2 rounded-xl border border-slate-100 bg-white p-4 shadow-sm"
>
<PetBody pose={p} glanceSeq={p === 'idle' ? glance : 0} size={96} />
<figcaption className="text-xs text-slate-500">{p}</figcaption>
</figure>
))}
{/* 涂氟护盾(与姿态正交的 buff,叠在 idle 上展示) */}
<figure
data-pet-perch
className="flex flex-col items-center gap-2 rounded-xl border border-slate-100 bg-white p-4 shadow-sm"
>
<PetBody pose="idle" glanceSeq={0} shield size={96} />
<figcaption className="text-xs text-slate-500">shield(涂氟护盾)</figcaption>
</figure>
</section>
<section className="flex flex-wrap gap-2">
<LabBtn label="张望一下" onClick={() => setGlance((g) => g + 1)} />
<LabBtn label="AI 开始思考" onClick={() => emitPetEvent({ type: 'ai_thinking_start' })} />
<LabBtn label="AI 结束" onClick={() => emitPetEvent({ type: 'ai_thinking_end' })} />
<LabBtn
label="模拟成约(庆祝)"
onClick={() => {
const s = usePlanSyncStore.getState();
s.setCurrent({ planId: `lab-${Date.now()}`, patientName: '测试患者' });
s.notify('lab-plan', 'completed');
}}
/>
<LabBtn
label="模拟切患者(打招呼)"
onClick={() =>
usePlanSyncStore.getState().setCurrent({ planId: `lab-${Date.now()}`, patientName: '王小明' })
}
/>
<LabBtn
label="去栖息(跳上卡片/台子)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'perch' } }))}
/>
<LabBtn
label="随便散步"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'stroll' } }))}
/>
<LabBtn
label="刷牙刷字(需先坐上卡片)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'brush' } }))}
/>
<LabBtn
label="套绳荡飞(鼠标停高处=套鼠标)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'swing' } }))}
/>
<LabBtn
label="放一只蛀牙菌 🦠"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'germ' } }))}
/>
<LabBtn
label="超人模式 🦸(强制)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'germ-super' } }))}
/>
<LabBtn
label="牙医组合 🦷(检查→治疗→处置)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'vet' } }))}
/>
<LabBtn
label="爬下楼 🧗(需先坐上卡片)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'climb' } }))}
/>
<LabBtn
label="说句话 💬(LLM,需登录态)"
onClick={() => window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'say' } }))}
/>
</section>
<p className="mt-4 text-xs text-slate-400">
真 PetFab 在页面里自由活动:拖起松手会摔到底边(落地压扁);静置 25-40s 它会自己散步,
有概率跳上下面的栖息台(蹲够了走到台边踩空摔下);90s 无操作打盹;AI 思考中若助手窗开着会走过去蹲守。
<br />
鼠标互动:悬停它身上一会 = 摸摸(眯眼笑冒爱心);鼠标快速从它身边掠过 = 吓一跳蹦开;
鼠标在它附近慢慢停留几秒 = 好奇凑过来看你;拖着甩出去 = 带惯性抛飞、撞墙反弹。
坐上卡片 8-18 秒后会自己掏牙刷把脚下"刷出泡沫",刷完闪✨再鼓腮漱口吐泡泡(也可用按钮立即触发)。
下落/抛飞/荡飞途中穿过任何带边框元素的顶边都会被接住落在上面;划选一段文字,它会走到选区正下方围观。
把鼠标停在它头顶高处不动 25s+,它有概率甩绳子套住你的光标荡秋千 —— 荡的时候绳子一端永远钉在鼠标上
(移动鼠标整条摆跟着走),此时不撞墙;荡够松手才变抛飞,这时才撞墙/被边框接住。
爱牙时间:每隔约 1-2 分钟可能刷出一只蛀牙菌(地板底边,或骑在某个边框上)——它会追着打,菌被追急了会逃,
在地板就走过去敲、在边框就纵身一跃去敲(不会卡在中间楼层),爆成星星然后庆祝;追不上就让它溜了。
隐藏 10%:进入超人模式 🦸 —— 先原地变身(披风展开+金色光环蓄力)再直线飞过去秒掉它(lab 可强制)。它还会不定期冒护牙小贴士。
牙医组合 🦷:闲时小概率化身牙医跑一套诊疗 —— 检查 → 概率(洗牙/补牙/收工)→ 概率(抛光/涂氟拿护盾/收工)→ 收尾;每次流程随机长短,
中途拖它/出蛀虫就丢下工具去忙别的(lab 可一键触发)。
闲时小动作轮盘:张望 / 牙线自理 / 闪亮微笑 / 咬合操,隔 7-16 秒随机出一个;
完成"刷牙→闪亮→漱口"全套仪式后获得 12 秒涂氟护盾(青色光罩)。
</p>
{/* 栖息台:离地 ~120px,在可跳高度内(data-pet-perch 机制演示) */}
<div
data-pet-perch
className="fixed bottom-[120px] left-1/2 h-3 w-56 -translate-x-1/2 rounded-full bg-teal-100 shadow-inner"
title="栖息台(data-pet-perch)"
/>
{/* 真 FAB(带大脑 + 运动层) */}
<PetFab open={false} onToggle={() => console.log('[pet-lab] fab clicked')} watchRect={null} />
</div>
);
}
function LabBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:bg-slate-50"
>
{label}
</button>
);
}
'use client';
import { useEffect, useRef, useState } from 'react';
import { Bot } from 'lucide-react';
import { useState } from 'react';
import { Permission } from '@pac/types';
import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetFab } from '@/components/pet/pet-widget';
import { AssistantChat } from './assistant-chat';
/**
......@@ -19,8 +19,8 @@ import { AssistantChat } from './assistant-chat';
export function AssistantWidget() {
const allowed = useHasPermission(Permission.AGENT_INVOKE);
const [open, setOpen] = useState(false);
// 窗口落点:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winPos, setWinPos] = useState<{ x: number; y: number } | null>(null);
// 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const openAt = (fab: DOMRect) => {
const W = Math.min(400, window.innerWidth - 16);
......@@ -32,7 +32,7 @@ export function AssistantWidget() {
if (y < 8) y = fab.bottom + 8;
x = Math.min(Math.max(x, 8), window.innerWidth - W - 8);
y = Math.min(Math.max(y, 8), window.innerHeight - H - 8);
setWinPos({ x, y });
setWinRect({ x, y, w: W, h: H });
setOpen(true);
};
// 场景化开场建议(规则,非 AI):有当前患者 → 围绕该患者;否则兜底通用
......@@ -51,10 +51,10 @@ export function AssistantWidget() {
<>
{/* 吸附窗:常驻挂载,关着时 invisible(状态不丢) */}
<div
style={winPos ? { left: winPos.x, top: winPos.y } : undefined}
style={winRect ? { left: winRect.x, top: winRect.y } : undefined}
className={cn(
'fixed z-[60] flex flex-col overflow-hidden rounded-xl border border-slate-100 shadow-2xl',
!winPos && 'bottom-4 right-4',
!winRect && 'bottom-4 right-4',
'h-[620px] max-h-[78vh] w-[400px] max-w-[calc(100vw-2rem)]',
'transition-[opacity,transform] duration-150',
open ? 'translate-y-0 opacity-100' : 'pointer-events-none invisible translate-y-2 opacity-0',
......@@ -62,82 +62,12 @@ export function AssistantWidget() {
>
<AssistantChat variant="widget" onClose={() => setOpen(false)} examples={examples} />
</div>
{/* 悬浮开关钮(可拖移:位移 >4px 判定为拖,松手即记忆位置;否则当点击打开) */}
{!open && <DraggableFab onOpen={openAt} />}
{/* 助手宠物(替代原 Bot FAB:可拖有重力、会散步;点击开/关助手窗;AI 生成中会走到窗边蹲守) */}
<PetFab
open={open}
onToggle={(r) => (open ? setOpen(false) : openAt(r))}
watchRect={open ? winRect : null}
/>
</>
);
}
const FAB_POS_KEY = 'pac-assistant-fab-pos';
/** 可拖移悬浮钮:pointer 事件拖动(阈值 4px 区分点击),位置 localStorage 记忆并随窗口缩放夹紧。 */
function DraggableFab({ onOpen }: { onOpen: (fabRect: DOMRect) => void }) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null); // null = 默认右下角
const drag = useRef<{ startX: number; startY: number; baseX: number; baseY: number; moved: boolean } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
try {
const raw = localStorage.getItem(FAB_POS_KEY);
if (raw) setPos(clampPos(JSON.parse(raw) as { x: number; y: number }));
} catch {
/* 忽略坏数据 */
}
}, []);
const onPointerDown = (e: React.PointerEvent) => {
const r = btnRef.current!.getBoundingClientRect();
drag.current = { startX: e.clientX, startY: e.clientY, baseX: r.left, baseY: r.top, moved: false };
btnRef.current!.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (!d.moved && Math.hypot(dx, dy) < 4) return;
d.moved = true;
setPos(clampPos({ x: d.baseX + dx, y: d.baseY + dy }));
};
const onPointerUp = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (d.moved) {
setPos((p) => {
if (p) localStorage.setItem(FAB_POS_KEY, JSON.stringify(p));
return p;
});
} else {
const r = btnRef.current?.getBoundingClientRect();
if (r) onOpen(r);
}
};
return (
<button
ref={btnRef}
type="button"
title="打开助手(可拖动)"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={pos ? { left: pos.x, top: pos.y } : undefined}
className={cn(
'fixed z-[60] inline-flex h-12 w-12 touch-none items-center justify-center rounded-full bg-teal-600 text-white shadow-lg hover:bg-teal-700',
!pos && 'bottom-4 right-4',
)}
>
<Bot className="h-5 w-5" />
</button>
);
}
function clampPos(p: { x: number; y: number }): { x: number; y: number } {
const size = 48; // h-12 w-12
return {
x: Math.min(Math.max(p.x, 4), window.innerWidth - size - 4),
y: Math.min(Math.max(p.y, 4), window.innerHeight - size - 4),
};
}
......@@ -2,6 +2,7 @@
import { useCallback, useRef, useState } from 'react';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
......@@ -144,6 +145,7 @@ export function useAssistantChat() {
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setStatus('streaming');
emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物)
// 更新 assistant 消息 blocks 的小工具
const patch = (fn: (blocks: Block[]) => Block[]) =>
......@@ -279,6 +281,7 @@ export function useAssistantChat() {
}
} finally {
abortRef.current = null;
emitPetEvent({ type: 'ai_thinking_end' });
}
},
[messages, model, status],
......
'use client';
import { useEffect, useRef } from 'react';
import type { PetPose } from './pet-brain';
/** 身体姿态 = 大脑表情(idle/think/celebrate/sleep) + 运动层姿态(walk/fall/sit=坐边沿/fly=超人飞/morph=超人变身) + 互动(happy=被摸 / brush=刷牙刷字 / rinse=漱口吐泡泡)。 */
export type PetBodyPose = PetPose | 'walk' | 'fall' | 'happy' | 'sit' | 'brush' | 'rinse' | 'fly' | 'morph' | 'climb' | 'magnify';
/**
* 宠物身体 — Q 版磨牙(程序化 SVG,零资产)。
*
* 形象:白胖牙冠 + 双牙根成腿(走路天然摆腿),大眼 + 腮红。
* 生命感:呼吸 / 随机眨眼 / 瞳孔 rAF 追鼠标;走路摆腿 + 颠步;摔落瞪圆眼;
* 全部动画包在 prefers-reduced-motion: no-preference 内,无障碍自动降级为静态。
* 整层可替换:将来换 Rive/Live2D 只动这个文件。
*/
export function PetBody({
pose,
glanceSeq,
facing = 1,
squashSeq = 0,
shield = false,
seated = false,
size = 64,
}: {
pose: PetBodyPose;
glanceSeq: number;
/** 朝向:1 右 / -1 左(走路时由运动层给)。注意:左右翻转由宿主(widget)在外层做,
* 这里只用它换算瞳孔的"世界方向 → 脸坐标系"(根 svg 的 transform 留给跳/压扁动画)。 */
facing?: 1 | -1;
/** 落地信号(自增):每次 +1 播一遍压扁回弹 */
squashSeq?: number;
/** 涂氟护盾 buff(刷牙仪式完成后获得,与姿态正交) */
shield?: boolean;
/** 坐在边沿上(与姿态正交):无论说话/思考/被摸,腿都耷拉着晃 */
seated?: boolean;
size?: number;
}) {
const rootRef = useRef<SVGSVGElement>(null);
const pupilsRef = useRef<SVGGElement>(null);
const target = useRef({ x: 0, y: 0 });
const current = useRef({ x: 0, y: 0 });
const glanceUntil = useRef(0);
const poseRef = useRef<PetBodyPose>(pose);
poseRef.current = pose;
const facingRef = useRef(facing);
facingRef.current = facing;
// 张望信号:1s 内瞳孔看向随机方向(idle 防机械感)
useEffect(() => {
if (!glanceSeq) return;
const a = Math.random() * Math.PI * 2;
target.current = { x: Math.cos(a) * 2.4, y: Math.sin(a) * 1.8 };
glanceUntil.current = Date.now() + 900;
}, [glanceSeq]);
// 落地信号:重触发压扁回弹动画(移除类→强制 reflow→加回,CSS 动画才会重播)
useEffect(() => {
if (!squashSeq) return;
const el = rootRef.current;
if (!el) return;
el.classList.remove('pac-pet-squash');
void el.getBoundingClientRect();
el.classList.add('pac-pet-squash');
}, [squashSeq]);
useEffect(() => {
const onMove = (e: PointerEvent) => {
if (Date.now() < glanceUntil.current) return;
const el = rootRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const dist = Math.hypot(dx, dy) || 1;
const k = Math.min(dist / 90, 1);
target.current = { x: (dx / dist) * 2.6 * k, y: (dy / dist) * 2.0 * k };
};
window.addEventListener('pointermove', onMove, { passive: true });
let raf = 0;
const loop = () => {
raf = requestAnimationFrame(loop);
const p = poseRef.current;
// 思考望右上;走路看前方;摔落看下;睡觉回中
const t =
p === 'think'
? { x: 1.6, y: -2.2 }
: p === 'walk'
? { x: 2.2 * facingRef.current, y: 0.4 }
: p === 'fall'
? { x: 0, y: 2.2 }
: p === 'brush'
? { x: 0, y: 2.4 } // 专注看着脚下刷的地方
: p === 'floss'
? { x: 0, y: -2.4 } // 仰头看头顶的牙线
: p === 'fly'
? { x: 2.8 * facingRef.current, y: 0 } // 超人:目光锁定前方
: p === 'morph'
? { x: 0, y: -1.8 } // 变身:目光上扬蓄力
: p.startsWith('vet_')
? { x: 1.2 * facingRef.current, y: 2.3 } // 牙医:专注盯着手里的活
: p === 'magnify'
? { x: 3 * facingRef.current, y: 0.6 } // 放大镜:盯着面前的文字
: p === 'sleep'
? { x: 0, y: 0 }
: target.current;
// 身体可能被 scaleX(-1) 翻转:瞳孔目标在"脸坐标系"里要再翻一次,保持看的还是世界方向
const fx = t.x * facingRef.current;
current.current.x += (fx - current.current.x) * 0.12;
current.current.y += (t.y - current.current.y) * 0.12;
pupilsRef.current?.setAttribute(
'transform',
`translate(${current.current.x.toFixed(2)} ${current.current.y.toFixed(2)})`,
);
};
raf = requestAnimationFrame(loop);
return () => {
window.removeEventListener('pointermove', onMove);
cancelAnimationFrame(raf);
};
}, []);
const sleeping = pose === 'sleep';
const celebrating = pose === 'celebrate';
const thinking = pose === 'think';
const walking = pose === 'walk';
const falling = pose === 'fall';
const happy = pose === 'happy'; // 被摸:眯眼笑 + 腮红加深 + 冒爱心,不动
const sitting = pose === 'sit'; // 坐在边沿:双腿前荡(脸部同 idle,瞳孔照常追鼠标)
const brushing = pose === 'brush'; // 刷牙刷字:坐姿 + 牙刷快速刷洗
const rinsing = pose === 'rinse'; // 漱口:鼓腮 + 吐泡泡
const flossing = pose === 'floss'; // 牙线自理:头顶拉线锯冠缝
const shining = pose === 'shine'; // 闪亮微笑:咧嘴 + 牙面 ding
const chomping = pose === 'chomp'; // 咬合操:嘴快速张合
const flying = pose === 'fly'; // 超人:红披风直线飞
const morphing = pose === 'morph'; // 超人变身:原地蓄力 + 披风展开 + 光环
// 牙医组合:统一戴额镜,按步骤换手里的器械
const vetExam = pose === 'vet_exam';
const vetScale = pose === 'vet_scale';
const vetFill = pose === 'vet_fill';
const vetPolish = pose === 'vet_polish';
const vetFluoride = pose === 'vet_fluoride';
const vetMode = vetExam || vetScale || vetFill || vetPolish || vetFluoride;
const climbing = pose === 'climb'; // 爬下楼:背朝外、悬挂垫脚(看不到脸)
const magnifying = pose === 'magnify'; // 到达选区:举放大镜照
const arcEyes = celebrating || happy || shining; // ^^ 眼共用
return (
<svg
ref={rootRef}
viewBox="0 0 64 64"
width={size}
height={size}
className={[
'pac-pet',
celebrating ? 'pac-pet-jump' : '',
falling ? 'pac-pet-falling' : '',
happy ? 'pac-pet-wiggle' : '',
flying ? 'pac-pet-flylean' : '',
].join(' ')}
aria-hidden
>
<style>{PET_CSS}</style>
{/* 超人红披风:画在身体之后(置于身后),朝后(facing 由外层翻转)飘动;变身时从领口展开 */}
{(flying || morphing) && (
<g className={morphing ? 'pac-pet-cape-unfurl' : 'pac-pet-cape'}>
<path d="M22 14 C6 16 2 34 8 50 C14 44 18 46 22 50 L24 16 Z" fill="#dc2626" stroke="#b91c1c" strokeWidth="1" />
<path d="M22 14 C12 18 10 34 13 48" stroke="#f87171" strokeWidth="1" fill="none" opacity="0.7" />
</g>
)}
{/* 超人变身:身后展开的金色蓄力光环 + 火花(置于身体之后) */}
{morphing && (
<g>
<circle className="pac-pet-ring" cx="32" cy="34" r="16" fill="none" stroke="#fbbf24" strokeWidth="2.5" />
<circle className="pac-pet-ring" cx="32" cy="34" r="16" fill="none" stroke="#fde68a" strokeWidth="2" style={{ animationDelay: '0.35s' }} />
<path className="pac-pet-spark" fill="#fbbf24" stroke="none" d="M8 20 l1 2.4 2.4 1 -2.4 1 -1 2.4 -1 -2.4 -2.4 -1 2.4 -1 Z" />
<path className="pac-pet-spark" fill="#fbbf24" stroke="none" d="M55 24 l1 2.4 2.4 1 -2.4 1 -1 2.4 -1 -2.4 -2.4 -1 2.4 -1 Z" style={{ animationDelay: '0.3s' }} />
<path className="pac-pet-spark" fill="#fde68a" stroke="none" d="M50 6 l0.8 2 2 0.8 -2 0.8 -0.8 2 -0.8 -2 -2 -0.8 2 -0.8 Z" style={{ animationDelay: '0.55s' }} />
</g>
)}
{/* 爬下楼:背朝外、悬挂垫脚(看不到脸)—— 单独的背面身体 */}
{climbing ? (
<g className="pac-pet-climb">
{/* 手臂上举抓住上方边沿(垫脚悬挂) */}
<g stroke="#ffffff" strokeWidth="3" strokeLinecap="round" fill="none">
<path className="pac-pet-climb-armL" d="M21 18 Q18 10 21 5" />
<path className="pac-pet-climb-armR" d="M43 18 Q46 10 43 5" />
</g>
<circle cx="21" cy="4.5" r="2.4" fill="#ffffff" stroke="#e2e8f0" strokeWidth="0.8" />
<circle cx="43" cy="4.5" r="2.4" fill="#ffffff" stroke="#e2e8f0" strokeWidth="0.8" />
{/* 背面牙身(无脸)+ 中缝 */}
<path
d="M32 8 C22 8 15 13 15 24 C15 31 16.5 36 18.5 41 L45.5 41 C47.5 36 49 31 49 24 C49 13 42 8 32 8 Z"
fill="#f1f5f9"
stroke="#e2e8f0"
strokeWidth="1.2"
/>
<path d="M32 11 L32 40" stroke="#e2e8f0" strokeWidth="1.4" />
<path d="M24 14 Q22 27 24 39 M40 14 Q42 27 40 39" stroke="#e2e8f0" strokeWidth="0.9" fill="none" opacity="0.7" />
{/* 牙根小腿:向下垫脚(交替探脚) */}
<g className="pac-pet-climb-legL">
<path d="M20 40 C17 47 17 53 21 54 C24 54.5 25 49 24 44 L23 40 Z" fill="#f1f5f9" stroke="#e2e8f0" strokeWidth="1" />
</g>
<g className="pac-pet-climb-legR">
<path d="M44 40 C47 47 47 53 43 54 C40 54.5 39 49 40 44 L41 40 Z" fill="#f1f5f9" stroke="#e2e8f0" strokeWidth="1" />
</g>
</g>
) : (
<>
{/* 身体组:呼吸(静止)/ 颠步(走路)/ 变身蓄力(原地上下颠+微胀) */}
<g className={walking ? 'pac-pet-walkbob' : morphing ? 'pac-pet-morph' : 'pac-pet-breath'}>
{/* 牙根小腿(走路交替摆 / 坐&刷&任意坐姿前荡;seated 让说话/思考时也耷拉着) */}
<g className={walking ? 'pac-pet-leg-l' : sitting || brushing || seated ? 'pac-pet-dangle-l' : ''}>
<path d="M19 38 C18 45 19 51 23 52.5 C26 53 27.5 48 26.5 43 L25.5 38 Z" fill="#ffffff" stroke="#e2e8f0" strokeWidth="1" />
</g>
<g className={walking ? 'pac-pet-leg-r' : sitting || brushing || seated ? 'pac-pet-dangle-r' : ''}>
<path d="M45 38 C46 45 45 51 41 52.5 C38 53 36.5 48 37.5 43 L38.5 38 Z" fill="#ffffff" stroke="#e2e8f0" strokeWidth="1" />
</g>
{/* 牙冠 */}
<path
d="M32 6 C21 6 13 12 13 23 C13 30 14.5 35.5 16.5 40.5 L47.5 40.5 C49.5 35.5 51 30 51 23 C51 12 43 6 32 6 Z"
fill="#ffffff"
stroke="#e2e8f0"
strokeWidth="1.2"
/>
{/* 冠顶咬合面小凹(磨牙特征)+ 左上高光 */}
<path d="M25 8.5 Q32 12 39 8.5" stroke="#e2e8f0" strokeWidth="1.4" strokeLinecap="round" fill="none" />
<path d="M18 16 Q19 11 24 9.5" stroke="#f8fafc" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.9" />
{/* 下缘淡影(立体感) */}
<path d="M18 38 Q32 43 46 38 L47.5 40.5 L16.5 40.5 Z" fill="#f1f5f9" />
{/* 眼白 */}
<circle cx="24" cy="24" r="5.6" fill="#f8fafc" />
<circle cx="40" cy="24" r="5.6" fill="#f8fafc" />
{/* 瞳孔(rAF 追鼠标;摔落瞪小圆眼) */}
{!arcEyes && (
<g ref={pupilsRef}>
<circle cx="24" cy="24.5" r={falling ? 1.7 : 2.6} fill="#0f172a" />
<circle cx="40" cy="24.5" r={falling ? 1.7 : 2.6} fill="#0f172a" />
<circle cx="25" cy="23.6" r="0.9" fill="#ffffff" />
<circle cx="41" cy="23.6" r="0.9" fill="#ffffff" />
</g>
)}
{/* 开心眼 ^^(庆祝/被摸) */}
{arcEyes && (
<g stroke="#0f172a" strokeWidth="2.2" strokeLinecap="round" fill="none">
<path d="M19.5 25 Q24 20.5 28.5 25" />
<path d="M35.5 25 Q40 20.5 44.5 25" />
</g>
)}
{/* 眼皮(眨眼;睡觉常闭) */}
<g className={sleeping ? '' : 'pac-pet-blink'} style={sleeping ? undefined : { transform: 'scaleY(0)' }}>
<rect x="17.5" y="18" width="13" height="12" rx="6" fill="#ffffff" />
<rect x="33.5" y="18" width="13" height="12" rx="6" fill="#ffffff" />
</g>
{sleeping && (
<g stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round" fill="none">
<path d="M20 25 Q24 27.5 28 25" />
<path d="M36 25 Q40 27.5 44 25" />
</g>
)}
{/* 漱口鼓腮:两侧脸颊鼓起 */}
{rinsing && (
<g fill="#ffffff" stroke="#e2e8f0" strokeWidth="1">
<ellipse cx="19" cy="31.5" rx="4.2" ry="3.6" />
<ellipse cx="45" cy="31.5" rx="4.2" ry="3.6" />
</g>
)}
{/* 嘴:摔落 o / 庆祝&闪亮大笑 / 被摸甜笑 / 漱口抿紧 / 咬合操(双嘴交替) / 平时微笑 */}
{falling ? (
<circle cx="32" cy="33" r="2.4" fill="#334155" />
) : celebrating || shining ? (
<path d="M27 31.5 Q32 38 37 31.5 Z" fill="#334155" />
) : happy ? (
<path d="M28 31 Q32 35.5 36 31" stroke="#334155" strokeWidth="1.8" strokeLinecap="round" fill="none" />
) : rinsing ? (
<path d="M30.5 32.5 L33.5 32.5" stroke="#334155" strokeWidth="1.6" strokeLinecap="round" fill="none" />
) : chomping ? (
<g>
<ellipse className="pac-pet-chomp-open" cx="32" cy="32.5" rx="4" ry="3.2" fill="#334155" />
<path className="pac-pet-chomp-closed" d="M28.5 32.5 L35.5 32.5" stroke="#334155" strokeWidth="1.8" strokeLinecap="round" />
</g>
) : flying || morphing ? (
<path d="M28.5 32 Q32 35 35.5 32" stroke="#334155" strokeWidth="2" strokeLinecap="round" fill="none" />
) : (
<path d="M29.5 32 Q32 34.2 34.5 32" stroke="#334155" strokeWidth="1.6" strokeLinecap="round" fill="none" />
)}
{/* 腮红(常驻,Q 版灵魂) */}
<g fill="#fda4af" opacity={celebrating || happy ? 0.9 : 0.55}>
<ellipse cx="17.5" cy="29.5" rx="3" ry="1.8" />
<ellipse cx="46.5" cy="29.5" rx="3" ry="1.8" />
</g>
</g>
</>
)}
{/* 放大镜:握在身前朝向文字(facing 由外层翻转),镜片带高光 + 轻晃 */}
{magnifying && (
<g className="pac-pet-mag">
<line x1="51" y1="40" x2="46" y2="33" stroke="#92400e" strokeWidth="2.4" strokeLinecap="round" />
<circle cx="44" cy="29" r="6" fill="#bae6fd" fillOpacity="0.35" stroke="#0ea5e9" strokeWidth="1.8" />
<path d="M41 27 Q43 25.5 45.5 26" stroke="#ffffff" strokeWidth="1.4" strokeLinecap="round" fill="none" opacity="0.9" />
</g>
)}
{/* 牙医组合:额镜(全程戴)+ 当前步骤的器械 + 特效 */}
{vetMode && (
<>
{/* 额镜:额头反光圆盘(中心孔)+ 头带 */}
<g>
<rect x="19" y="9" width="26" height="2" rx="1" fill="#94a3b8" />
<circle cx="32" cy="6.5" r="4.2" fill="#e2e8f0" stroke="#94a3b8" strokeWidth="1" />
<circle cx="32" cy="6.5" r="1.7" fill="#475569" />
<circle cx="30.6" cy="5.2" r="0.9" fill="#ffffff" />
</g>
{/* 器械:都握在身前嘴边(~35,38);facing 由外层翻转 */}
{vetExam && (
<g stroke="#64748b" strokeWidth="1.6" strokeLinecap="round">
<line x1="44" y1="50" x2="36" y2="40" />
<circle cx="35" cy="38.5" r="2.6" fill="#e2e8f0" />
</g>
)}
{vetScale && (
<>
<g className="pac-vet-buzz">
<line x1="45" y1="51" x2="37" y2="41" stroke="#64748b" strokeWidth="2" strokeLinecap="round" />
<path d="M37 41 L33.5 37.5" stroke="#94a3b8" strokeWidth="1.4" strokeLinecap="round" />
</g>
<g fill="#a5f3fc" stroke="#67e8f9" strokeWidth="0.5">
<circle className="pac-pet-rinse-b" cx="33" cy="36" r="1.3" style={{ animationDelay: '0s' }} />
<circle className="pac-pet-rinse-b" cx="36" cy="34" r="1.7" style={{ animationDelay: '0.4s' }} />
<circle className="pac-pet-rinse-b" cx="30" cy="35" r="1.1" style={{ animationDelay: '0.8s' }} />
</g>
</>
)}
{vetFill && (
<>
<line x1="46" y1="51" x2="38" y2="42" stroke="#475569" strokeWidth="3" strokeLinecap="round" />
<circle className="pac-vet-spin" cx="35.5" cy="39" r="2.4" fill="#cbd5e1" stroke="#64748b" strokeWidth="0.8" />
<path d="M35.5 36.6 L35.5 41.4 M33.1 39 L37.9 39" stroke="#94a3b8" strokeWidth="0.7" />
<g fill="#fbbf24">
<circle className="pac-pet-spark" cx="31" cy="36" r="1" />
<circle className="pac-pet-spark" cx="39" cy="37" r="0.9" style={{ animationDelay: '0.3s' }} />
</g>
</>
)}
{vetPolish && (
<>
<line x1="46" y1="51" x2="38" y2="42" stroke="#475569" strokeWidth="3" strokeLinecap="round" />
<path className="pac-vet-spin" d="M33.5 37 L37.5 37 L36.5 40.5 L34.5 40.5 Z" fill="#fca5a5" stroke="#ef4444" strokeWidth="0.6" />
<g fill="#fde68a" stroke="#fbbf24" strokeWidth="0.5">
<path className="pac-pet-spark" d="M30 34 l0.8 1.8 1.8 0.8 -1.8 0.8 -0.8 1.8 -0.8 -1.8 -1.8 -0.8 1.8 -0.8 Z" />
<path className="pac-pet-spark" d="M40 36 l0.7 1.6 1.6 0.7 -1.6 0.7 -0.7 1.6 -0.7 -1.6 -1.6 -0.7 1.6 -0.7 Z" style={{ animationDelay: '0.35s' }} />
</g>
</>
)}
{vetFluoride && (
<>
<line x1="45" y1="51" x2="37" y2="41" stroke="#0d9488" strokeWidth="2" strokeLinecap="round" />
<ellipse className="pac-vet-buzz" cx="35" cy="39" rx="2.4" ry="1.6" fill="#5eead4" stroke="#0d9488" strokeWidth="0.6" />
<g fill="#99f6e4" stroke="#5eead4" strokeWidth="0.4">
<circle className="pac-pet-rinse-b" cx="33" cy="36" r="1.1" />
<circle className="pac-pet-rinse-b" cx="37" cy="35" r="1.4" style={{ animationDelay: '0.5s' }} />
</g>
</>
)}
</>
)}
{/* 思考:头顶冒点点 */}
{thinking && (
<g fill="#0d9488">
<circle className="pac-pet-dot" cx="46" cy="12" r="2" style={{ animationDelay: '0s' }} />
<circle className="pac-pet-dot" cx="52" cy="8" r="2.6" style={{ animationDelay: '0.2s' }} />
<circle className="pac-pet-dot" cx="58" cy="3.5" r="3.2" style={{ animationDelay: '0.4s' }} />
</g>
)}
{/* 刷牙刷字:身前牙刷快速刷洗 + 冒小泡泡 */}
{brushing && (
<>
<g className="pac-pet-scrub">
{/* 刷柄(品牌青) + 刷毛 */}
<rect x="22" y="50" width="20" height="3.6" rx="1.8" fill="#0d9488" transform="rotate(-8 32 52)" />
<rect x="38" y="45.5" width="7" height="6" rx="1.2" fill="#ffffff" stroke="#e2e8f0" strokeWidth="0.8" transform="rotate(-8 41 48)" />
</g>
<g fill="#a5f3fc" stroke="#67e8f9" strokeWidth="0.5">
<circle className="pac-pet-bubble" cx="26" cy="46" r="1.6" style={{ animationDelay: '0s' }} />
<circle className="pac-pet-bubble" cx="34" cy="44" r="2.2" style={{ animationDelay: '0.35s' }} />
<circle className="pac-pet-bubble" cx="42" cy="46" r="1.3" style={{ animationDelay: '0.7s' }} />
</g>
</>
)}
{/* 牙线自理:头顶拉一根牙线锯冠缝 */}
{flossing && (
<g className="pac-pet-floss">
<path d="M8 13 Q32 22 56 13" stroke="#5eead4" strokeWidth="1.6" fill="none" strokeLinecap="round" />
<circle cx="8" cy="13" r="2.2" fill="#0d9488" />
<circle cx="56" cy="13" r="2.2" fill="#0d9488" />
</g>
)}
{/* 闪亮微笑:牙面 ding 两颗星 */}
{shining && (
<g fill="#fef9c3" stroke="#fbbf24" strokeWidth="0.6">
<path className="pac-pet-spark" d="M20 13 l1.2 2.6 2.6 1.2 -2.6 1.2 -1.2 2.6 -1.2 -2.6 -2.6 -1.2 2.6 -1.2 Z" />
<path className="pac-pet-spark" d="M45 10 l1 2.2 2.2 1 -2.2 1 -1 2.2 -1 -2.2 -2.2 -1 2.2 -1 Z" style={{ animationDelay: '0.3s' }} />
</g>
)}
{/* 涂氟护盾 buff:青色光罩 + 微光(与姿态正交,可叠加在任何动作上) */}
{shield && (
<g className="pac-pet-shieldfx">
<circle cx="32" cy="34" r="29" fill="rgba(103,232,249,0.08)" stroke="#67e8f9" strokeWidth="1.4" />
<path className="pac-pet-spark" fill="#a5f3fc" stroke="none" d="M10 22 l0.9 2 2 0.9 -2 0.9 -0.9 2 -0.9 -2 -2 -0.9 2 -0.9 Z" />
<path className="pac-pet-spark" fill="#a5f3fc" stroke="none" d="M53 44 l0.9 2 2 0.9 -2 0.9 -0.9 2 -0.9 -2 -2 -0.9 2 -0.9 Z" style={{ animationDelay: '0.55s' }} />
</g>
)}
{/* 漱口:嘴边吐出一串泡泡往上飘 */}
{rinsing && (
<g fill="#a5f3fc" stroke="#67e8f9" strokeWidth="0.5">
<circle className="pac-pet-rinse-b" cx="36" cy="29" r="1.4" style={{ animationDelay: '0s' }} />
<circle className="pac-pet-rinse-b" cx="40" cy="26" r="2.2" style={{ animationDelay: '0.4s' }} />
<circle className="pac-pet-rinse-b" cx="44" cy="22" r="1.7" style={{ animationDelay: '0.8s' }} />
<circle className="pac-pet-rinse-b" cx="47" cy="17" r="2.8" style={{ animationDelay: '1.1s' }} />
</g>
)}
{/* 被摸:头顶冒小爱心 */}
{happy && (
<g fill="#fb7185">
<path className="pac-pet-heart" d="M46 10 c-1.5 -2.6 -5 -1.4 -5 1 c0 2 2.6 3.6 5 5.4 c2.4 -1.8 5 -3.4 5 -5.4 c0 -2.4 -3.5 -3.6 -5 -1 Z" />
<path className="pac-pet-heart" d="M55 5 c-1 -1.8 -3.4 -1 -3.4 0.7 c0 1.4 1.8 2.4 3.4 3.6 c1.6 -1.2 3.4 -2.2 3.4 -3.6 c0 -1.7 -2.4 -2.5 -3.4 -0.7 Z" style={{ animationDelay: '0.5s' }} />
</g>
)}
{/* 睡觉:Zzz */}
{sleeping && (
<g fill="#0d9488" fontWeight={700} fontFamily="ui-sans-serif">
<text className="pac-pet-z" x="46" y="14" fontSize="9" style={{ animationDelay: '0s' }}>z</text>
<text className="pac-pet-z" x="52" y="8" fontSize="12" style={{ animationDelay: '0.6s' }}>Z</text>
</g>
)}
{/* 摔落:头顶速度线 */}
{falling && (
<g stroke="#94a3b8" strokeWidth="1.6" strokeLinecap="round" opacity="0.8">
<path d="M22 4 L22 0" />
<path d="M32 3 L32 -2" />
<path d="M42 4 L42 0" />
</g>
)}
{/* 庆祝:小星星 */}
{celebrating && (
<g fill="#fbbf24">
<path className="pac-pet-spark" d="M10 12 l1.4 3 3 1.4 -3 1.4 -1.4 3 -1.4 -3 -3 -1.4 3 -1.4 Z" />
<path className="pac-pet-spark" d="M53 16 l1 2.2 2.2 1 -2.2 1 -1 2.2 -1 -2.2 -2.2 -1 2.2 -1 Z" style={{ animationDelay: '0.25s' }} />
</g>
)}
</svg>
);
}
/** 动画全包在 reduced-motion 守卫内 — 用户要求减弱动效时自动退化为静态形象。 */
const PET_CSS = `
@media (prefers-reduced-motion: no-preference) {
.pac-pet-breath { transform-origin: 32px 53px; animation: pacPetBreath 3.4s ease-in-out infinite; }
.pac-pet-walkbob { transform-origin: 32px 53px; animation: pacPetWalkBob 0.4s ease-in-out infinite; }
.pac-pet-leg-l { transform-box: fill-box; transform-origin: top center; animation: pacPetLeg 0.4s ease-in-out infinite; }
.pac-pet-leg-r { transform-box: fill-box; transform-origin: top center; animation: pacPetLeg 0.4s ease-in-out infinite reverse; }
.pac-pet-dangle-l { transform-box: fill-box; transform-origin: top center; animation: pacPetDangle 1.7s ease-in-out infinite; }
.pac-pet-dangle-r { transform-box: fill-box; transform-origin: top center; animation: pacPetDangle 1.7s ease-in-out infinite reverse; }
.pac-pet-scrub { animation: pacPetScrub 0.24s ease-in-out infinite; }
.pac-pet-bubble { animation: pacPetBubble 1.1s ease-in-out infinite; }
.pac-pet-rinse-b { animation: pacPetRinse 1.6s ease-out infinite; }
.pac-pet-floss { animation: pacPetFloss 0.5s ease-in-out infinite; }
.pac-pet-chomp-open { animation: pacPetChomp 0.5s steps(1) infinite; }
.pac-pet-chomp-closed { animation: pacPetChomp 0.5s steps(1) infinite; animation-delay: -0.25s; }
.pac-pet-shieldfx { animation: pacPetShield 2.2s ease-in-out infinite; transform-origin: 32px 34px; }
.pac-pet-blink { transform-origin: 32px 18px; animation: pacPetBlink 4.7s infinite; }
.pac-pet-jump { animation: pacPetJump 0.9s ease-in-out 2; }
.pac-pet-wiggle { animation: pacPetWiggle 0.7s ease-in-out infinite; }
.pac-pet-heart { animation: pacPetHeart 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.pac-pet-squash { animation: pacPetSquash 0.26s ease-out 1; }
.pac-pet-falling { animation: pacPetFlail 0.5s ease-in-out infinite; }
.pac-pet-flylean { animation: pacPetFlyLean 0.5s ease-in-out infinite; }
.pac-pet-cape { transform-box: fill-box; transform-origin: 22px 16px; animation: pacPetCape 0.4s ease-in-out infinite; }
.pac-pet-cape-unfurl { transform-box: fill-box; transform-origin: 23px 15px; animation: pacPetCapeUnfurl 0.5s ease-out 1, pacPetCape 0.4s ease-in-out 0.5s infinite; }
.pac-pet-morph { transform-origin: 32px 53px; animation: pacPetMorph 0.45s ease-in-out infinite; }
.pac-pet-ring { transform-box: fill-box; transform-origin: center; animation: pacPetRing 0.7s ease-out infinite; }
.pac-vet-buzz { transform-box: fill-box; transform-origin: center; animation: pacVetBuzz 0.09s linear infinite; }
.pac-vet-spin { transform-box: fill-box; transform-origin: center; animation: pacVetSpin 0.18s linear infinite; }
.pac-pet-climb { transform-origin: 32px 30px; animation: pacPetClimb 0.62s ease-in-out infinite; }
.pac-pet-climb-armL { transform-box: fill-box; transform-origin: bottom center; animation: pacPetClimbLimb 0.62s ease-in-out infinite; }
.pac-pet-climb-armR { transform-box: fill-box; transform-origin: bottom center; animation: pacPetClimbLimb 0.62s ease-in-out infinite reverse; }
.pac-pet-climb-legL { transform-box: fill-box; transform-origin: top center; animation: pacPetClimbLimb 0.62s ease-in-out infinite reverse; }
.pac-pet-climb-legR { transform-box: fill-box; transform-origin: top center; animation: pacPetClimbLimb 0.62s ease-in-out infinite; }
.pac-pet-mag { transform-box: fill-box; transform-origin: 51px 40px; animation: pacPetMag 1.6s ease-in-out infinite; }
.pac-pet-dot { animation: pacPetDot 1.2s ease-in-out infinite; }
.pac-pet-z { animation: pacPetZ 1.8s ease-in-out infinite; }
.pac-pet-spark { animation: pacPetSpark 1.1s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
}
@keyframes pacPetBreath { 0%,100% { transform: scaleY(1); } 50% { transform: scaleY(0.962); } }
@keyframes pacPetWalkBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@keyframes pacPetLeg { 0%,100% { transform: rotate(-13deg); } 50% { transform: rotate(13deg); } }
@keyframes pacPetDangle { 0%,100% { transform: rotate(16deg); } 50% { transform: rotate(-7deg); } }
@keyframes pacPetScrub { 0%,100% { transform: translateX(-3.5px); } 50% { transform: translateX(3.5px); } }
@keyframes pacPetBubble { 0% { opacity: 0; transform: translateY(2px) scale(0.6); } 40% { opacity: 1; } 100% { opacity: 0; transform: translateY(-6px) scale(1.05); } }
@keyframes pacPetRinse { 0% { opacity: 0; transform: translateY(3px) scale(0.5); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateY(-12px) scale(1.15); } }
@keyframes pacPetFloss { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@keyframes pacPetChomp { 0%,49.9% { opacity: 1; } 50%,100% { opacity: 0; } }
@keyframes pacPetShield { 0%,100% { opacity: 0.55; transform: scale(0.985); } 50% { opacity: 0.95; transform: scale(1.015); } }
@keyframes pacPetBlink { 0%,91%,97%,100% { transform: scaleY(0); } 93.5% { transform: scaleY(1); } }
@keyframes pacPetJump { 0%,100% { transform: translateY(0); } 35% { transform: translateY(-7px); } 55% { transform: translateY(0); } 70% { transform: translateY(-3px); } }
@keyframes pacPetWiggle { 0%,100% { transform: rotate(-2.5deg); } 50% { transform: rotate(2.5deg); } }
@keyframes pacPetHeart { 0% { opacity: 0; transform: translateY(2px) scale(0.7); } 35% { opacity: 1; transform: translateY(-1px) scale(1); } 100% { opacity: 0; transform: translateY(-5px) scale(0.9); } }
@keyframes pacPetSquash { 0% { transform: scaleY(0.78) scaleX(1.12); } 100% { transform: scaleY(1) scaleX(1); } }
@keyframes pacPetFlail { 0%,100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }
@keyframes pacPetFlyLean { 0%,100% { transform: rotate(-16deg); } 50% { transform: rotate(-12deg); } }
@keyframes pacPetCape { 0%,100% { transform: skewY(-6deg) scaleX(1); } 50% { transform: skewY(6deg) scaleX(1.08); } }
@keyframes pacPetCapeUnfurl { 0% { transform: scale(0.1) rotate(-30deg); opacity: 0; } 60% { opacity: 1; } 100% { transform: scale(1) rotate(0deg); opacity: 1; } }
@keyframes pacPetMorph { 0%,100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-3px) scale(1.07); } }
@keyframes pacPetRing { 0% { opacity: 0.85; transform: scale(0.3); } 100% { opacity: 0; transform: scale(1.7); } }
@keyframes pacVetBuzz { 0% { transform: translate(0.6px, 0); } 50% { transform: translate(-0.6px, 0.4px); } 100% { transform: translate(0.6px, 0); } }
@keyframes pacVetSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes pacPetClimb { 0%,100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-1.6px) rotate(2deg); } }
@keyframes pacPetClimbLimb { 0%,100% { transform: rotate(-9deg); } 50% { transform: rotate(9deg); } }
@keyframes pacPetMag { 0%,100% { transform: translate(0,0) rotate(0deg); } 50% { transform: translate(1.5px,-1px) rotate(-5deg); } }
@keyframes pacPetDot { 0%,100% { opacity: 0.25; } 50% { opacity: 1; } }
@keyframes pacPetZ { 0% { opacity: 0; transform: translateY(3px); } 40% { opacity: 1; } 100% { opacity: 0; transform: translateY(-4px); } }
@keyframes pacPetSpark { 0%,100% { opacity: 0.2; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1.15); } }
`;
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { emitPetEvent, onPetEvent, type DirectorScript } from '@/lib/pet-events';
/** 身体能演的姿态(基线姿态 + 一次性演出共用同一词表)。 */
export type PetPose =
| 'idle'
| 'think'
| 'celebrate'
| 'sleep'
| 'floss'
| 'shine'
| 'chomp'
| 'vet_exam'
| 'vet_scale'
| 'vet_fill'
| 'vet_polish'
| 'vet_fluoride';
/** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */
const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = {
celebrate: 'celebrate',
sleep: 'sleep',
floss: 'floss',
shine: 'shine',
chomp: 'chomp',
vet_exam: 'vet_exam',
vet_scale: 'vet_scale',
vet_fill: 'vet_fill',
vet_polish: 'vet_polish',
vet_fluoride: 'vet_fluoride',
};
const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹
const GLANCE_MIN_MS = 7_000; // idle 小动作(随机张望)间隔
const GLANCE_MAX_MS = 16_000;
/**
* 宠物大脑 — 反射层(纯规则,毫秒级,永远在线)。
*
* 基线姿态按优先级推导:AI 生成中 think > 用户久未操作 sleep > idle;
* 一次性演出(celebrate/气泡)由 play(DirectorScript) 叠加,ttl 到点复位回基线。
* play 是唯一指令入口 —— 将来 LLM 导演的输出也走这里,断了就只是"不说话",宠物照活。
*/
export function usePetBrain() {
const [pose, setPose] = useState<PetPose>('idle');
const [bubble, setBubble] = useState<string | null>(null);
/** 自增信号:身体收到后做一次随机张望(idle 防机械感)。 */
const [glanceSeq, setGlanceSeq] = useState(0);
const thinking = useRef(false);
const voiceThinking = useRef(false); // LLM 已发起、第一个词还没到 → 思考脸
const voiceTalking = useRef(false); // 流式吐字中 → 嘴动(复用 chomp)
const lastActiveAt = useRef(Date.now());
const gestureTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** 没有一次性演出时应处的基线姿态。 */
const basePose = useCallback((): PetPose => {
if (voiceThinking.current) return 'think'; // 说话前先思考(等第一个词)
if (voiceTalking.current) return 'chomp'; // 说话中嘴一张一合
if (thinking.current) return 'think';
if (Date.now() - lastActiveAt.current > SLEEP_AFTER_MS) return 'sleep';
return 'idle';
}, []);
/** 演出脚本唯一入口(规则导演现在用,LLM 导演将来同入口)。 */
const play = useCallback(
(script: DirectorScript) => {
const ttl = script.ttlMs ?? 3_000;
if (script.gesture === 'none') {
// 显式打断:立即清掉一次性演出,复位基线(组合动作中途被打断用)
if (gestureTimer.current) {
clearTimeout(gestureTimer.current);
gestureTimer.current = null;
}
setPose(basePose());
} else if (script.gesture) {
const oneShot = ONE_SHOT_POSE[script.gesture] ?? null;
if (oneShot) {
if (gestureTimer.current) clearTimeout(gestureTimer.current);
setPose(oneShot);
gestureTimer.current = setTimeout(() => {
gestureTimer.current = null;
setPose(basePose());
}, ttl);
}
}
if (script.bubble) {
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
setBubble(script.bubble);
bubbleTimer.current = setTimeout(() => {
bubbleTimer.current = null;
setBubble(null);
}, ttl);
}
},
[basePose],
);
/** 回到基线(若没有一次性演出占着)。 */
const settle = useCallback(() => {
if (!gestureTimer.current) setPose(basePose());
}, [basePose]);
// ── 感知:AI 生成中(use-assistant-chat 发事件)+ director 脚本直达 ──
useEffect(
() =>
onPetEvent((e) => {
if (e.type === 'director') {
play(e.script);
return;
}
if (e.type === 'pet_voice') {
// think → talk → end:驱动基线姿态(思考脸 / 嘴动 / 复位),气泡走 director
voiceThinking.current = e.phase === 'think';
voiceTalking.current = e.phase === 'talk';
// 清掉残留的一次性 gesture(如刚才的咬合操),让说话姿态立即生效
if (e.phase !== 'end' && gestureTimer.current) {
clearTimeout(gestureTimer.current);
gestureTimer.current = null;
}
setPose(basePose());
return;
}
if (e.type === 'ai_thinking_start') thinking.current = true;
if (e.type === 'ai_thinking_end') thinking.current = false;
settle();
}),
[settle, play, basePose],
);
// ── 感知:业务事件(直接订阅工作台同步 store,业务代码零改动)──
// 注:除 LLM 语音外,所有动作都不带说话框 —— 成约只演庆祝动作,切患者只抬头看一眼。
useEffect(() => {
let prevSeq = usePlanSyncStore.getState().seq;
let prevPlanId = usePlanSyncStore.getState().current?.planId ?? null;
return usePlanSyncStore.subscribe((s) => {
// 成约 → 庆祝动作(无气泡)
if (s.seq !== prevSeq) {
prevSeq = s.seq;
if (s.status === 'completed') play({ gesture: 'celebrate', ttlMs: 3_600 });
}
// 切患者 → 抬头看一眼(无语言)
const planId = s.current?.planId ?? null;
if (planId && planId !== prevPlanId) {
prevPlanId = planId;
if (s.current?.patientName) setGlanceSeq((g) => g + 1);
}
});
}, [play]);
// ── 感知:用户活跃度(打盹/唤醒)+ 周期对账基线姿态 ──
useEffect(() => {
const onActivity = () => {
lastActiveAt.current = Date.now();
// 在睡 → 立即醒(不等 tick)
if (!gestureTimer.current) setPose((p) => (p === 'sleep' ? basePose() : p));
};
window.addEventListener('pointermove', onActivity, { passive: true });
window.addEventListener('keydown', onActivity, { passive: true });
window.addEventListener('wheel', onActivity, { passive: true });
const tick = setInterval(settle, 5_000);
return () => {
window.removeEventListener('pointermove', onActivity);
window.removeEventListener('keydown', onActivity);
window.removeEventListener('wheel', onActivity);
clearInterval(tick);
};
}, [basePose, settle]);
// ── idle 小动作轮盘:张望 / 牙线自理 / 闪亮微笑 / 咬合操(防机械循环)──
useEffect(() => {
if (pose !== 'idle') return;
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(() => {
const r = Math.random();
if (r < 0.42) setGlanceSeq((s) => s + 1);
else if (r < 0.6) play({ gesture: 'floss', ttlMs: 3_000 });
else if (r < 0.76) play({ gesture: 'shine', ttlMs: 1_800 });
else if (r < 0.9) play({ gesture: 'chomp', ttlMs: 2_400 });
else emitPetEvent({ type: 'vet_combo' }); // ~10%:交给 widget 跑牙医诊疗组合
schedule();
}, GLANCE_MIN_MS + Math.random() * (GLANCE_MAX_MS - GLANCE_MIN_MS));
};
schedule();
return () => clearTimeout(timer);
}, [pose, play]);
// 卸载清理
useEffect(
() => () => {
if (gestureTimer.current) clearTimeout(gestureTimer.current);
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
},
[],
);
return { pose, bubble, glanceSeq };
}
'use client';
import { useEffect, useRef, useState } from 'react';
/**
* 宠物运动层 — 桌宠式物理 + 自主行为(与大脑层正交:大脑管表情,这里管身体在哪)。
*
* 机制:
* - 重力:拖起松手 / 视窗 resize / 栖息点消失 → 下落,落地小压扁;
* - 地板 = 视窗底边(fixed 定位,滚动无关);
* - 散步:用户空闲一段时间后沿地板溜达(限"家"附近 ±WANDER,不跑去挡操作);
* - 栖息:页面元素标 data-pet-perch(任意高度)→ 跳上去蹲着,每帧跟随元素
* rect(滚动/布局变化),元素不可见即摔落;可在平台上散步,**走过边缘就踩空摔下**;
* - 蹲守:外部给 watchX(如 AI 生成中的助手窗边)→ 走过去守着。
*
* 实现要点:位置每帧直接写 el.style(60fps 不走 React);React 只收 motion/facing
* 两个低频状态用于切换姿态/朝向。
*/
export type PetMotion = 'rest' | 'walk' | 'fall' | 'jump' | 'drag' | 'swing' | 'fly' | 'climb' | 'magnify';
const GRAVITY = 2600; // px/s²
const WALK_SPEED = 64; // px/s
const FLY_SPEED = 1100; // 超人直线飞行速度 px/s
const CLIMB_SPEED = 115; // 爬下楼下降速度 px/s
const WANDER = 240; // 散步范围:家两侧各 240px
const STROLL_IDLE_MIN_MS = 25_000; // 空闲多久开始想散步
const STROLL_IDLE_MAX_MS = 40_000;
// ── 鼠标互动 ──
const STARTLE_DIST = 90; // 快速掠过判定半径
const STARTLE_SPEED = 1500; // px/s,鼠标速度阈值
const STARTLE_COOLDOWN_MS = 8_000;
const CURIOUS_DIST = 260; // 好奇判定:鼠标在附近
const CURIOUS_DWELL_MS = 2_500; // 鼠标慢下来停留多久触发
const CURIOUS_COOLDOWN_MS = 30_000;
const SEL_WATCH_MS = 12_000; // 选中文字围观限时
const INSPECT_MS = 3_000; // 到达后用放大镜照多久
const SEEK_COOLDOWN_MS = 12_000; // 两次"凑近选区"最小间隔(防每次划选都跑)
/** 栖息面选择器:显式标注 + 一切带边框的元素(Tailwind border* 类 —— 卡片/行/按钮/输入框都算) */
const PERCH_SELECTOR = '[data-pet-perch], [class*="border"]';
export interface LocomotionOpts {
size: number;
/** true = 不要自主行动(思考/庆祝/助手窗开着) */
getBusy: () => boolean;
/** 想蹲守的地板 x(AI 生成中给助手窗边),null = 无 */
getWatchX: () => number | null;
/** 落点持久化(下次进页面从这恢复) */
persist: (x: number) => void;
}
export function usePetLocomotion(
elRef: React.RefObject<HTMLElement | null>,
{ size, getBusy, getWatchX, persist }: LocomotionOpts,
) {
const [motion, setMotion] = useState<PetMotion>('rest');
const [facing, setFacing] = useState<1 | -1>(1);
/** 落地信号(自增):身体收到后播一次压扁回弹 */
const [landSeq, setLandSeq] = useState(0);
/** 是否栖在元素上(true 时静止姿态用"坐在边沿晃腿") */
const [anchored, setAnchored] = useState(false);
/** 套绳锚点(荡飞中绳子另一端的视窗坐标;null = 没在荡) */
const [rope, setRope] = useState<{ x: number; y: number } | null>(null);
// 全部物理状态进 ref(rAF 闭环,不触发渲染)
const s = useRef({
x: 0,
y: 0,
vx: 0,
vy: 0,
motion: 'rest' as PetMotion,
facing: 1 as 1 | -1,
homeX: 0, // "家":最近一次用户放置/落地点,散步以此为锚
walkTargetX: 0,
/** 走到目标后要跳的栖息点(没有 = 纯散步) */
jumpTo: null as { el: Element; offsetX: number } | null,
/** 当前栖息的元素(rest 时非 null = 蹲在元素上) */
anchor: null as { el: Element; offsetX: number } | null,
lastActiveAt: Date.now(),
nextStrollAt: Date.now() + STROLL_IDLE_MIN_MS,
// 鼠标互动:光标位置/速度 + 惊吓/好奇冷却
cur: { x: -1e4, y: -1e4, t: 0, lastFastAt: 0 },
startleUntil: 0,
curiousUntil: 0,
// 拖拽测速(松手抛掷用)
dragLast: null as { x: number; t: number } | null,
// 选中文字围观:用"走 → 爬楼 → 放大镜照 → 爬下楼回地板"接近选区
seek: null as { x: number; y: number; until: number; face: 1 | -1 } | null,
seekStage: '' as '' | 'walk' | 'up' | 'inspect' | 'down',
seekCooldownUntil: 0,
climbTargetY: 0, // 爬行(上/下)的目标高度
magnifyUntil: 0,
// 蛀虫追捕:huntJump=true 时空中段不被中间层边框接住,一路落到地板(防卡中间层);
// fly=超人直线飞向目标(无视重力/墙/边框)
huntJump: false,
flyX: 0,
flyY: 0,
flyCmdAt: 0,
// 套绳荡飞(钟摆:θ 从锚点垂直向下量起)
rope: null as { ax: number; ay: number; L: number; theta: number; omega: number; cross: number; t0: number } | null,
});
// 对外命令(拖拽接入)
const api = useRef({
startDrag() {
const c = s.current;
c.motion = 'drag';
c.anchor = null;
c.vx = 0;
c.vy = 0;
c.dragLast = null;
c.rope = null; // 荡到一半被抓住 → 绳收掉
setRope(null);
},
dragTo(x: number, y: number) {
const c = s.current;
// 估算拖拽横速(指数平滑),松手时变成抛掷初速
const now = performance.now();
const nx = clampX(x, size);
if (c.dragLast) {
const dtm = (now - c.dragLast.t) / 1000;
if (dtm > 0.001) c.vx = 0.7 * c.vx + 0.3 * ((nx - c.x) / dtm);
}
c.dragLast = { x: nx, t: now };
c.x = nx;
c.y = Math.min(Math.max(y, 4), floorY(size));
},
/** 松手 → 带惯性抛出(重力下落 + 横速保留;已在地板且没甩 → 原地安家) */
drop() {
const c = s.current;
c.homeX = c.x;
c.vx = Math.max(-900, Math.min(900, c.vx));
// 最近 120ms 内没在动 → 不算甩,清横速
if (!c.dragLast || performance.now() - c.dragLast.t > 120) c.vx = 0;
if (c.y >= floorY(size) - 1 && Math.abs(c.vx) < 60) {
c.motion = 'rest';
c.vx = 0;
persist(c.x);
} else {
c.motion = 'fall';
c.vy = 0;
}
},
/** 初始放置(restore):放到指定 x,从其 y 落下(进页面的小开场) */
place(x: number, y: number) {
const c = s.current;
c.x = clampX(x, size);
c.y = Math.min(Math.max(y, 4), floorY(size));
c.homeX = c.x;
c.motion = c.y >= floorY(size) - 1 ? 'rest' : 'fall';
},
});
useEffect(() => {
const el = elRef.current;
if (!el) return;
const c = s.current;
const apply = () => {
el.style.left = `${c.x.toFixed(1)}px`;
el.style.top = `${c.y.toFixed(1)}px`;
};
const sync = () => {
setMotion(c.motion);
setFacing(c.facing);
setAnchored(!!c.anchor);
};
const land = () => {
c.y = floorY(size);
c.vy = 0;
c.vx = 0;
c.motion = 'rest';
c.homeX = c.x;
c.huntJump = false; // 落地即解除"穿透中间层"
setLandSeq((n) => n + 1);
persist(c.x);
sync();
};
/** 栖息面快照(150ms TTL):广选择器 + 下落每帧检测必须缓存,否则全 DOM 测矩形会卡。 */
let surfCache: { t: number; list: { el: Element; left: number; right: number; top: number }[] } | null = null;
const surfaces = () => {
const now = performance.now();
if (surfCache && now - surfCache.t < 150) return surfCache.list;
const list: { el: Element; left: number; right: number; top: number }[] = [];
document.querySelectorAll(PERCH_SELECTOR).forEach((p) => {
if (el.contains(p)) return; // 宠物自己身上的(气泡/泡沫层)不算
// visibility:hidden / display:none 的不算(如收起的助手窗,rect 仍有尺寸)
if ((p as HTMLElement).checkVisibility && !(p as HTMLElement).checkVisibility()) return;
const r = p.getBoundingClientRect();
if (r.width < size + 8 || r.height < 4) return;
// 水平在视窗内才算(抽屉平移出屏的元素 rect 仍在,不滤会跳到屏幕外)
if (r.left < 0 || r.right > window.innerWidth) return;
if (r.top - size < 4) return; // 站上去头要在视窗内
list.push({ el: p, left: r.left, right: r.right, top: r.top });
});
surfCache = { t: now, list };
return list;
};
/** 找栖息点(散步目的地):从可见面里随机挑一个。 */
const findPerch = (): { el: Element; offsetX: number } | null => {
const list = surfaces();
const pick = list[Math.floor(Math.random() * list.length)];
if (!pick) return null;
return { el: pick.el, offsetX: 8 + Math.random() * Math.max(pick.right - pick.left - size - 16, 1) };
};
/** 下落穿面检测:本帧脚底从 prevBottom 落到 newBottom,途中跨过某栖息面顶边
* 且横向重心在面内 → 接住(返回最高的那个面)。 */
const surfaceUnder = (prevBottom: number, newBottom: number): { el: Element; offsetX: number } | null => {
let best: { el: Element; left: number; top: number } | null = null;
const cx = c.x + size / 2;
for (const sf of surfaces()) {
if (cx < sf.left || cx > sf.right) continue;
if (sf.top < prevBottom - 2 || sf.top > newBottom) continue; // 本帧没跨过其顶边
if (!best || sf.top < best.top) best = sf;
}
if (!best) return null;
const b = best as { el: Element; left: number; top: number };
return { el: b.el, offsetX: Math.max(4, c.x - b.left) };
};
const scheduleStroll = () => {
c.nextStrollAt =
Date.now() + STROLL_IDLE_MIN_MS + Math.random() * (STROLL_IDLE_MAX_MS - STROLL_IDLE_MIN_MS);
};
const startWalk = (targetX: number, jumpTo: typeof c.jumpTo = null, keepAnchor = false) => {
c.walkTargetX = clampX(targetX, size);
c.jumpTo = jumpTo;
if (!keepAnchor) c.anchor = null; // keepAnchor = 在栖息平台上走(可能走到边缘踩空)
c.motion = 'walk';
c.facing = c.walkTargetX >= c.x ? 1 : -1;
sync();
};
/** 套绳荡飞:绳子套在锚点(默认 = 当前鼠标位置),钟摆荡 1.5 个来回后松手甩飞。 */
/** 套绳就绪:鼠标在视窗内、且离宠物足够远(锚点可在任意方位 —— 上/下/侧都行)。 */
const swingReady = () =>
c.cur.t > 0 &&
c.cur.x >= 0 &&
c.cur.x <= window.innerWidth &&
c.cur.y >= 0 &&
c.cur.y <= window.innerHeight &&
Math.hypot(c.cur.x - (c.x + size / 2), c.cur.y - (c.y + size / 2)) > 110;
const startSwing = (anchor?: { x: number; y: number }) => {
const pcx = c.x + size / 2;
const pcy = c.y + size / 2;
const ax = anchor?.x ?? c.cur.x;
const ay = anchor?.y ?? c.cur.y;
// 锚点(鼠标)只需在视窗内 —— 可在宠物上方/下方/侧方任意位置(宠物会绕锚点甩荡)
if (ax < 0 || ax > window.innerWidth || ay < 0 || ay > window.innerHeight) return;
const L = Math.hypot(pcx - ax, pcy - ay);
if (L < 50) return;
const dir = ax < window.innerWidth / 2 ? 1 : -1; // 往屏幕开阔侧荡
c.rope = {
ax,
ay,
L,
theta: Math.atan2(pcx - ax, pcy - ay),
omega: dir * 1.5 * Math.sqrt(GRAVITY / L),
cross: 0,
t0: performance.now(),
};
c.anchor = null;
c.jumpTo = null;
c.motion = 'swing';
setRope({ x: ax, y: ay });
sync();
};
/** 爬楼:背朝外、悬挂垫脚,匀速爬到目标高度(默认地板;接近选区时爬到选区高度)。 */
const startClimb = (targetY: number = floorY(size)) => {
c.anchor = null;
c.jumpTo = null;
c.vx = 0;
c.vy = 0;
c.climbTargetY = targetY;
c.motion = 'climb';
sync();
};
const onActivity = () => {
c.lastActiveAt = Date.now();
};
window.addEventListener('pointerdown', onActivity, { passive: true });
window.addEventListener('keydown', onActivity, { passive: true });
window.addEventListener('wheel', onActivity, { passive: true });
// 鼠标互动:测速 + 惊吓(快速掠过身边 → 吓得往旁边蹦一下)
const onCursor = (e: PointerEvent) => {
const now = Date.now();
const dtm = now - c.cur.t;
if (dtm > 0) {
const speed = (Math.hypot(e.clientX - c.cur.x, e.clientY - c.cur.y) / dtm) * 1000;
if (speed > STARTLE_SPEED) {
c.cur.lastFastAt = now;
const dist = Math.hypot(e.clientX - (c.x + size / 2), e.clientY - (c.y + size / 2));
if (
dist < STARTLE_DIST &&
now > c.startleUntil &&
(c.motion === 'rest' || c.motion === 'walk') &&
!getBusy()
) {
c.startleUntil = now + STARTLE_COOLDOWN_MS;
c.anchor = null; // 在栖息台上被吓到 → 直接蹦下来
c.jumpTo = null;
c.facing = e.clientX < c.x + size / 2 ? 1 : -1; // 背对鼠标方向逃
c.vx = 150 * c.facing;
c.vy = -460;
c.motion = 'jump';
sync();
}
}
}
c.cur.x = e.clientX;
c.cur.y = e.clientY;
c.cur.t = now;
};
window.addEventListener('pointermove', onCursor, { passive: true });
// 选中文字围观:划选 → 走到选区正下方仰头看(限时;选区取消不再追)
let selTimer: ReturnType<typeof setTimeout> | undefined;
const onSelChange = () => {
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = document.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
if (c.seekStage === '') c.seek = null;
return;
}
const r = sel.getRangeAt(0).getBoundingClientRect();
if (!r || r.width < 8 || r.bottom < 4 || r.top > window.innerHeight - 4) {
if (c.seekStage === '') c.seek = null;
return;
}
// 停在文字左侧(放不下就去右侧),面朝文字;高度对齐文字中线
let ix = r.left - size - 2;
let face: 1 | -1 = 1;
if (ix < 4) {
ix = r.right + 2;
face = -1;
}
const iy = Math.min(Math.max(r.top + r.height / 2 - size / 2, 4), floorY(size));
if (c.seekStage === '') c.seek = { x: clampX(ix, size), y: iy, until: Date.now() + SEL_WATCH_MS, face };
}, 500);
};
document.addEventListener('selectionchange', onSelChange);
// 内部命令通道:pet-lab 一键触发 + 蛀虫追捕等编排模块驱动(生产业务代码不派发)
const onDebug = (e: Event) => {
const detail = (e as CustomEvent<{ cmd?: string; x?: number; y?: number; perch?: Element | null }>).detail;
const cmd = detail?.cmd;
// 超人直飞(fly):每帧刷新目标,无视一切;持续到 widget 停发(蛀虫已灭)→ fly case 超时落地
if (cmd === 'fly') {
c.flyX = typeof detail?.x === 'number' ? detail.x : c.x;
c.flyY = typeof detail?.y === 'number' ? detail.y : c.y;
c.flyCmdAt = performance.now();
if (c.motion !== 'fly') {
c.anchor = null;
c.jumpTo = null;
c.rope = null;
c.motion = 'fly';
sync();
}
return;
}
// 地板追捕(hunt):走到蛀虫正下方;若正蹲在边框上 → 跳下来(穿透中间层直落地板)
if (cmd === 'hunt') {
if (getBusy() || c.motion === 'fly') return;
const x = typeof detail?.x === 'number' ? detail.x : c.x;
if (c.anchor && c.motion === 'rest') {
c.anchor = null;
c.motion = 'fall';
c.vy = 0;
c.huntJump = true; // 直落地板,不卡中间层
sync();
} else if (c.motion === 'rest' && !c.anchor && Math.abs(x - c.x) > 8) {
startWalk(x);
}
return;
}
// 纵身一跃(pounce):站在蛀虫正下方的地板 → 跳起去敲边框上的它(不落在边框、不卡层)
if (cmd === 'pounce') {
if (getBusy() || c.motion !== 'rest' || c.anchor) return;
const px = typeof detail?.x === 'number' ? detail.x : c.x;
const ty = typeof detail?.y === 'number' ? detail.y : c.y; // 蛀虫顶边
const apex = ty - 8; // 跳到略高于蛀虫,让宠物在它高度有停留
c.motion = 'jump';
c.jumpTo = null;
c.huntJump = true;
c.vy = -Math.sqrt(2 * GRAVITY * Math.max(c.y - apex, 30));
c.vx = (px - c.x) * 1.4;
c.facing = c.vx >= 0 ? 1 : -1;
sync();
return;
}
// 爬下楼(lab):需正蹲在边框上
if (cmd === 'climb') {
if (c.anchor && c.motion === 'rest' && !getBusy()) startClimb();
return;
}
// 套绳荡飞:站着/坐高处都能起(从边框上也能甩荡;鼠标可在低处)
if (cmd === 'swing') {
if (getBusy() || c.motion === 'swing' || c.motion === 'fly') return;
const fallback = {
x: clampX(c.x + (Math.random() < 0.5 ? -140 : 140), size) + size / 2,
y: Math.max(c.y - 240, 30),
};
startSwing(swingReady() ? undefined : fallback);
return;
}
if (c.motion !== 'rest' || c.anchor || getBusy()) return; // lab 指令:只在地板待命时响应
if (cmd === 'perch') {
const p = findPerch();
if (p) {
const r = p.el.getBoundingClientRect();
startWalk(r.left + p.offsetX, p);
}
} else if (cmd === 'stroll') {
startWalk(c.homeX + (Math.random() * 2 - 1) * WANDER);
}
};
window.addEventListener('pac-pet-debug', onDebug);
const onResize = () => {
c.x = clampX(c.x, size);
if (c.motion === 'rest' && !c.anchor && c.y < floorY(size) - 1) {
c.motion = 'fall'; // 地板下移了 → 摔下去
sync();
}
c.y = Math.min(c.y, floorY(size));
};
window.addEventListener('resize', onResize);
let raf = 0;
let prev = performance.now();
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min((now - prev) / 1000, 0.05); // tab 切回的大 dt 截断
prev = now;
if (document.hidden) return;
// 选区接近被打断(拖拽/惊吓/荡绳/被追…任何非 seek 运动)→ 放弃这次接近
if (c.seekStage && c.motion !== 'walk' && c.motion !== 'climb' && c.motion !== 'magnify') {
c.seek = null;
c.seekStage = '';
}
switch (c.motion) {
case 'drag':
break;
case 'fall': {
const prevBottom = c.y + size;
c.vy += GRAVITY * dt;
c.y += c.vy * dt;
// 抛掷惯性:横向位移 + 空气阻尼 + 撞墙反弹
if (c.vx !== 0) {
c.x += c.vx * dt;
c.vx *= Math.max(0, 1 - 1.8 * dt);
const minX = 4;
const maxX = window.innerWidth - size - 4;
if (c.x <= minX || c.x >= maxX) {
c.x = c.x <= minX ? minX : maxX;
c.vx = -c.vx * 0.45; // 弹墙损耗
c.facing = c.vx >= 0 ? 1 : -1;
sync();
}
}
// 下落途中穿过卡片顶边 → 接住,落在边框上(不直接掉到底);追捕空中段穿透中间层直落地板
if (c.vy > 0 && !c.huntJump) {
const surf = surfaceUnder(prevBottom, c.y + size);
if (surf) {
const r = surf.el.getBoundingClientRect();
c.y = r.top - size;
c.x = clampX(r.left + surf.offsetX, size);
c.vx = 0;
c.vy = 0;
c.anchor = surf;
c.motion = 'rest';
setLandSeq((n) => n + 1);
sync();
break;
}
}
if (c.y >= floorY(size)) land();
break;
}
case 'walk': {
c.x += WALK_SPEED * c.facing * dt;
// 撞到屏幕边缘 → 撞一下(压扁)、回头往回走
const minX = 4;
const maxX = window.innerWidth - size - 4;
if (!c.anchor && (c.x <= minX || c.x >= maxX)) {
c.x = c.x <= minX ? minX : maxX;
setLandSeq((n) => n + 1); // 复用压扁动画当"撞墙"反馈
c.facing = c.x <= minX ? 1 : -1;
c.walkTargetX = clampX(c.x + c.facing * (80 + Math.random() * 160), size);
sync();
break;
}
// 在栖息平台上走:y 贴平台顶,重心越过边缘 → 踩空摔落
if (c.anchor) {
const r = c.anchor.el.getBoundingClientRect();
const gone = !c.anchor.el.isConnected || r.width === 0;
const overEdge = c.x + size / 2 < r.left || c.x + size / 2 > r.right;
if (gone || overEdge) {
c.anchor = null;
c.motion = 'fall';
c.vy = 0;
sync();
break;
}
c.y = r.top - size;
}
const arrived = c.facing === 1 ? c.x >= c.walkTargetX : c.x <= c.walkTargetX;
if (getBusy()) {
// 思考/庆祝来了 → 原地停下演
c.motion = 'rest';
sync();
} else if (arrived) {
c.x = c.walkTargetX;
if (c.jumpTo) {
const r = c.jumpTo.el.getBoundingClientRect();
const standY = r.top - size;
c.motion = 'jump';
c.vy = -Math.sqrt(2 * GRAVITY * Math.max(c.y - standY + 12, 20));
// 横速按飞行时间(到顶点)算,高跳低跳都同步到位
const flight = Math.abs(c.vy) / GRAVITY;
c.vx = (r.left + c.jumpTo.offsetX - c.x) / Math.max(flight, 0.2);
sync();
} else if (c.seekStage === 'walk' && c.seek) {
// 走到选区正下方 → 爬楼上去(到选区高度)
c.seekStage = 'up';
startClimb(c.seek.y);
} else {
c.motion = 'rest';
c.homeX = c.x;
sync();
scheduleStroll();
}
}
break;
}
case 'jump': {
const prevBottomJ = c.y + size;
c.vy += GRAVITY * dt;
c.x += c.vx * dt;
c.y += c.vy * dt;
// 无目标跳(惊吓蹦开)下落段同样可被卡片顶边接住
if (!c.jumpTo && c.vy > 0 && !c.huntJump) {
const surf = surfaceUnder(prevBottomJ, c.y + size);
if (surf) {
const r = surf.el.getBoundingClientRect();
c.y = r.top - size;
c.x = clampX(r.left + surf.offsetX, size);
c.vx = 0;
c.vy = 0;
c.anchor = surf;
c.motion = 'rest';
setLandSeq((n) => n + 1);
sync();
break;
}
}
const t = c.jumpTo;
if (t) {
const r = t.el.getBoundingClientRect();
const standY = r.top - size;
if (c.vy > 0 && c.y >= standY) {
// 落上栖息点
c.x = clampX(r.left + t.offsetX, size);
c.y = standY;
c.vx = 0;
c.vy = 0;
c.anchor = t;
c.jumpTo = null;
c.motion = 'rest';
setLandSeq((n) => n + 1);
sync();
}
}
if (c.y >= floorY(size)) land(); // 没接住 → 落地
break;
}
case 'swing': {
const ro = c.rope;
if (!ro) {
c.motion = 'fall';
sync();
break;
}
// ① 绳子一端永远钉在鼠标:锚点每帧跟随光标(在视窗内才更新,鼠标出界则保持上一位置)
if (c.cur.t > 0 && c.cur.x >= 0 && c.cur.x <= window.innerWidth && c.cur.y >= 0) {
ro.ax = c.cur.x;
ro.ay = c.cur.y;
}
// 钟摆积分 + 轻阻尼(摆角 θ 从锚点正下方量起;锚点平移则整条摆跟着平移)
ro.omega += -(GRAVITY / ro.L) * Math.sin(ro.theta) * dt;
ro.omega *= Math.max(0, 1 - 0.12 * dt);
const prevTheta = ro.theta;
ro.theta += ro.omega * dt;
if (Math.sign(prevTheta) !== Math.sign(ro.theta)) ro.cross++; // 过最低点计数
// ② 有绳期间不碰壁:位置纯由"锚点 + 摆"决定,不夹边界、不撞墙反弹
c.x = ro.ax + ro.L * Math.sin(ro.theta) - size / 2;
c.y = ro.ay + ro.L * Math.cos(ro.theta) - size / 2;
c.facing = ro.omega >= 0 ? 1 : -1;
if (c.y >= floorY(size)) {
c.rope = null;
setRope(null);
land(); // 绳太长蹭到地 → 直接落地
break;
}
// 松绳条件:荡够(过底 3 次)或超时;松手后才进 fall → ③ 此时边界/边框才生效
if (ro.cross >= 3 || performance.now() - ro.t0 > 4_500) {
c.vx = Math.max(-820, Math.min(820, ro.L * ro.omega * Math.cos(ro.theta)));
c.vy = Math.max(-720, Math.min(500, -ro.L * ro.omega * Math.sin(ro.theta)));
c.rope = null;
setRope(null);
c.motion = 'fall';
c.facing = c.vx >= 0 ? 1 : -1;
c.x = clampX(c.x, size); // 落地态接管前,把位置夹回视窗(可能荡出界了)
sync();
}
break;
}
case 'climb': {
// 背朝外、悬挂垫脚,匀速爬向 climbTargetY(可上可下;x 不动,穿过中间楼层)
const cdy = c.climbTargetY - c.y;
c.y += Math.sign(cdy) * Math.min(CLIMB_SPEED * dt, Math.abs(cdy));
if (Math.abs(c.y - c.climbTargetY) < 1) {
if (c.seekStage === 'up') {
// 爬到选区高度 → 掏放大镜照
c.seekStage = 'inspect';
c.motion = 'magnify';
c.magnifyUntil = now + INSPECT_MS;
if (c.seek) c.facing = c.seek.face;
sync();
} else if (c.seekStage === 'down') {
c.seek = null;
c.seekStage = '';
land();
} else {
land(); // 普通爬下楼到地板
}
}
break;
}
case 'magnify': {
// 悬在选区旁,用放大镜照(不动);看够了 / 选区过期 → 爬下楼回地板
if (c.seek) c.facing = c.seek.face;
if (now > c.magnifyUntil || !c.seek || Date.now() > c.seek.until) {
c.seekStage = 'down';
startClimb(floorY(size));
}
break;
}
case 'fly': {
// 超人:直线匀速飞向目标(无视重力/墙/边框);命中由 widget 的 2D 判定触发,
// 一段时间收不到新目标(蛀虫已灭)→ 转 fall 一路落回地板
if (performance.now() - c.flyCmdAt > 450) {
c.motion = 'fall';
c.huntJump = true; // 落回地板途中不被中间层接住
c.vy = 0;
sync();
break;
}
const dx = c.flyX - c.x;
const dy = c.flyY - c.y;
const d = Math.hypot(dx, dy) || 1;
const step = FLY_SPEED * dt;
c.x += (dx / d) * Math.min(step, d);
c.y += (dy / d) * Math.min(step, d);
c.facing = dx >= 0 ? 1 : -1;
break;
}
case 'rest': {
if (c.anchor) {
// 蹲在元素上:跟随 rect;元素没了/出视野 → 摔落
const r = c.anchor.el.getBoundingClientRect();
const gone =
!c.anchor.el.isConnected ||
r.width === 0 ||
r.top - size < -size ||
r.top > floorY(size) + size ||
r.right < size || // 横向滑出视窗(如抽屉收起的平移)也算没了
r.left > window.innerWidth - size;
if (gone) {
c.anchor = null;
c.motion = 'fall';
c.vy = 0;
sync();
break;
}
// 蹲够了 / 要去蹲守 AI → 走向平台边缘,自然踩空下来
const wantDown = Date.now() > c.nextStrollAt || getWatchX() !== null;
if (wantDown && !getBusy()) {
// 在边框高处也能套绳荡飞(鼠标在低处也行)→ 直接离框甩荡
if (getWatchX() === null && Math.random() < 0.18 && swingReady()) {
startSwing();
scheduleStroll();
break;
}
// 一定概率"爬下楼":背朝外、悬挂垫脚,慢慢爬回地板(非地板态专属)
if (getWatchX() === null && Math.random() < 0.45) {
startClimb();
scheduleStroll();
break;
}
const watchX = getWatchX();
const center = c.x + size / 2;
// 候选目标(越过边缘 24px);clamp 后重心仍在平台外才"走得出去"
// (平台贴视窗边时该侧出不去,如左栏贴左缘的"加载更多")
const tl = r.left - size / 2 - 24;
const tr = r.right - size / 2 + 24;
const canL = clampX(tl, size) + size / 2 < r.left;
const canR = clampX(tr, size) + size / 2 > r.right;
const preferL = watchX !== null ? watchX < center : center - r.left < r.right - center;
if (canL || canR) {
const goLeft = canL && (preferL || !canR);
startWalk(goLeft ? tl : tr, null, true);
} else {
// 平台横跨整个视窗,没边可走 → 原地跳下兜底
c.anchor = null;
c.motion = 'fall';
c.vy = 0;
sync();
}
scheduleStroll();
break;
}
c.x = clampX(r.left + c.anchor.offsetX, size);
c.y = r.top - size;
break;
}
// 蹲守点(AI 生成中守助手窗)优先于散步
const watchX = getWatchX();
if (watchX !== null && Math.abs(watchX - c.x) > 12 && !getBusy()) {
startWalk(watchX);
break;
}
// 选中文字 → 凑近选区:走到下方 → 爬楼上去 → 放大镜照 → 爬下楼回(地板待命时才起)
if (
c.seek &&
c.seekStage === '' &&
!c.anchor &&
Date.now() < c.seek.until &&
Date.now() > c.seekCooldownUntil &&
getWatchX() === null &&
!getBusy()
) {
c.seekCooldownUntil = Date.now() + SEEK_COOLDOWN_MS;
c.seekStage = 'walk';
startWalk(c.seek.x);
break;
}
// 好奇:鼠标在附近慢下来停了一会 → 凑过去看看(地板上才凑,带冷却防黏人)
{
const now = Date.now();
const dx = c.cur.x - (c.x + size / 2);
const near =
Math.abs(dx) < CURIOUS_DIST && Math.abs(c.cur.y - (c.y + size / 2)) < CURIOUS_DIST;
const dwelling = c.cur.t > 0 && now - c.cur.lastFastAt > CURIOUS_DWELL_MS && now - c.cur.t < CURIOUS_DWELL_MS;
if (near && dwelling && Math.abs(dx) > 90 && now > c.curiousUntil && !getBusy()) {
c.curiousUntil = now + CURIOUS_COOLDOWN_MS;
// 凑到鼠标侧边 ~52px 处(不顶到光标正下方,留出看的距离)
startWalk(c.cur.x - size / 2 - Math.sign(dx) * 52);
break;
}
}
// 用户空闲 → 散步/跳栖息点
const idleMs = Date.now() - c.lastActiveAt;
if (idleMs > STROLL_IDLE_MIN_MS && Date.now() > c.nextStrollAt && !getBusy() && watchX === null) {
const roll = Math.random();
// 偶尔表演套绳荡飞:鼠标在视窗任意位置(上/下/侧)都行
if (roll < 0.15 && swingReady()) {
startSwing();
scheduleStroll();
break;
}
const perch = roll < 0.7 ? findPerch() : null;
if (perch) {
const r = perch.el.getBoundingClientRect();
startWalk(r.left + perch.offsetX, perch);
} else {
startWalk(c.homeX + (Math.random() * 2 - 1) * WANDER);
}
scheduleStroll();
}
break;
}
}
apply();
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('pointerdown', onActivity);
window.removeEventListener('keydown', onActivity);
window.removeEventListener('wheel', onActivity);
window.removeEventListener('pointermove', onCursor);
window.removeEventListener('pac-pet-debug', onDebug);
window.removeEventListener('resize', onResize);
document.removeEventListener('selectionchange', onSelChange);
clearTimeout(selTimer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elRef, size]);
return { motion, facing, landSeq, anchored, rope, api: api.current };
}
function floorY(size: number): number {
return window.innerHeight - size - 2;
}
function clampX(x: number, size: number): number {
return Math.min(Math.max(x, 4), window.innerWidth - size - 4);
}
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { emitPetEvent, onPetEvent } from '@/lib/pet-events';
import { usePetBrain } from './pet-brain';
import { usePetLocomotion } from './pet-locomotion';
import { usePetVoice } from './use-pet-voice';
import { PetBody, type PetBodyPose } from './pet-body';
const PET_X_KEY = 'pac-pet-x'; // 只存 x:y 永远由物理决定(地板/栖息点)
const SIZE = 64;
/**
* PetFab — 助手宠物(Q 版磨牙),桌宠化悬浮钮。
*
* 大脑(usePetBrain)管表情,运动层(usePetLocomotion)管位置:
* 拖起松手有重力,空闲沿底边散步,可跳上 data-pet-perch 元素蹲着,
* AI 生成中会走到助手窗边守着。点击(非拖)= 开/关助手窗。
*/
export function PetFab({
open,
onToggle,
watchRect,
}: {
open: boolean;
onToggle: (fabRect: DOMRect) => void;
/** 助手窗矩形(开着时),AI 思考中宠物走过去蹲守 */
watchRect: { x: number; y: number; w: number; h: number } | null;
}) {
const { pose, bubble, glanceSeq } = usePetBrain();
const btnRef = useRef<HTMLButtonElement>(null);
const poseRef = useRef(pose);
poseRef.current = pose;
const watchRef = useRef(watchRect);
watchRef.current = watchRect;
// ── 刷牙刷字:坐上卡片一会儿 → 掏牙刷把脚下标题区"刷出泡沫",刷完闪✨ ──
const [brush, setBrush] = useState<{
rect: { x: number; y: number; w: number; h: number };
phase: 'scrub' | 'shine';
} | null>(null);
const [rinsing, setRinsing] = useState(false); // 刷完 → 漱口吐泡泡
const [shield, setShield] = useState(false); // 漱完 → 涂氟护盾 buff(12s 青色光罩)
// ── 蛀牙菌狩猎:刷怪 → 追捕(菌会逃)→ 牙刷敲打 → 爆星 → 庆祝 ──
const [germAlive, setGermAlive] = useState(false);
const [whacking, setWhacking] = useState(false); // 敲打中(复用牙刷姿态)
const [burst, setBurst] = useState<{ x: number; top: number } | null>(null); // 消灭爆星(x + 离地高度)
const germEl = useRef<HTMLSpanElement>(null);
// perch = 蛀虫骑在哪个边框上(null = 在地板底边爬)
const germ = useRef<{
x: number;
dir: 1 | -1;
t0: number;
state: 'crawl' | 'flee' | 'dying';
perch: Element | null;
superman: boolean; // 隐藏小概率:宠物披红披风直线飞过去秒掉它
} | null>(null);
const [superPhase, setSuperPhase] = useState<'off' | 'transform' | 'fly'>('off'); // 超人:变身 → 飞
const superPhaseRef = useRef(superPhase);
superPhaseRef.current = superPhase;
const brushingRef = useRef(false);
brushingRef.current = brush?.phase === 'scrub' || rinsing || whacking;
const brushCooldownUntil = useRef(0);
const { motion, facing, landSeq, anchored, rope, api } = usePetLocomotion(btnRef, {
size: SIZE,
// 庆祝原地演完;睡着了不梦游;刷字刷到一半不许走
getBusy: () =>
poseRef.current === 'celebrate' || poseRef.current === 'sleep' || brushingRef.current,
// AI 思考中且助手窗开着 → 走到窗左下角守着(放不下就去右边)
getWatchX: () => {
const r = watchRef.current;
if (!r || poseRef.current !== 'think') return null;
const left = r.x - SIZE - 6;
return left >= 8 ? left : r.x + r.w + 6;
},
persist: (x) => {
try {
localStorage.setItem(PET_X_KEY, String(Math.round(x)));
} catch {
/* 私密模式等 */
}
},
});
// 初始登场:恢复上次 x(默认右下),从半空轻轻落下
useEffect(() => {
let x = window.innerWidth - SIZE - 16;
try {
const raw = localStorage.getItem(PET_X_KEY);
if (raw) x = Number(raw) || x;
} catch {
/* ignore */
}
api.place(x, window.innerHeight - SIZE - 120);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 刷牙调度:坐稳(栖在卡片上)8-18s 后开刷;离开栖息点立即收场
const startBrush = () => {
if (Date.now() < brushCooldownUntil.current) return;
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
brushCooldownUntil.current = Date.now() + 30_000;
setBrush({
rect: { x: r.left - 30, y: r.bottom - 4, w: r.width + 60, h: 22 },
phase: 'scrub',
});
};
const startBrushRef = useRef(startBrush);
startBrushRef.current = startBrush;
const sittingNow = anchored && motion === 'rest';
useEffect(() => {
if (!sittingNow) {
setBrush(null);
return;
}
const t = setTimeout(() => startBrushRef.current(), 8_000 + Math.random() * 10_000);
return () => clearTimeout(t);
}, [sittingNow]);
// 刷 3s → 闪亮 0.9s → 漱口 2.2s → 收
useEffect(() => {
if (!brush) return;
const t =
brush.phase === 'scrub'
? setTimeout(() => setBrush((b) => (b ? { ...b, phase: 'shine' } : null)), 3_000)
: setTimeout(() => {
setBrush(null);
setRinsing(true);
}, 900);
return () => clearTimeout(t);
}, [brush]);
useEffect(() => {
if (!rinsing) return;
const t = setTimeout(() => {
setRinsing(false);
setShield(true); // 刷牙仪式完成 → 获得涂氟护盾
}, 2_200);
return () => clearTimeout(t);
}, [rinsing]);
useEffect(() => {
if (!shield) return;
const t = setTimeout(() => setShield(false), 12_000);
return () => clearTimeout(t);
}, [shield]);
// dev 后门(pet-lab 一键触发):坐着时立即开刷
const sittingRef = useRef(sittingNow);
sittingRef.current = sittingNow;
useEffect(() => {
const h = (e: Event) => {
if ((e as CustomEvent<{ cmd?: string }>).detail?.cmd !== 'brush') return;
if (sittingRef.current) {
brushCooldownUntil.current = 0;
startBrushRef.current();
}
};
window.addEventListener('pac-pet-debug', h);
return () => window.removeEventListener('pac-pet-debug', h);
}, []);
// ── 蛀牙菌:自然刷怪(低频)+ lab 一键 ──
const motionRef = useRef(motion);
motionRef.current = motion;
const anchoredRef = useRef(anchored); // 追捕需知道宠物是否在地板(决定走/跳/飞)
anchoredRef.current = anchored;
// ── 牙医组合派生链:检查 → (洗牙/补牙/终止) → (抛光/涂氟/终止) → 收尾;可被交互/蛀虫打断 ──
const vetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vetRunning = useRef(false);
const vetAbort = () => {
if (vetTimer.current) clearTimeout(vetTimer.current);
vetTimer.current = null;
if (vetRunning.current) {
vetRunning.current = false;
emitPetEvent({ type: 'director', script: { gesture: 'none' } }); // 丢工具、收额镜、回基线
}
};
const vetAbortRef = useRef(vetAbort);
vetAbortRef.current = vetAbort;
// ── LLM 开口说话:页面激活 + 低概率 + 长冷却,观察周围环境说一句(流式进气泡)──
usePetVoice(btnRef, () => poseRef.current === 'idle' && motionRef.current === 'rest');
// 牙医组合运行器:大脑 idle 轮盘抽中 vet_combo(或 lab 按钮)→ 跑诊疗派生链
useEffect(() => {
// 可继续的前提:在地板/边框静止待命、没在追蛀虫、没在变超人
const ok = () => motionRef.current === 'rest' && !germ.current && superPhaseRef.current === 'off';
// 无气泡(诊疗组合只演动作 + 道具,不说话)
const step = (gesture: string, dur: number, next: () => void) => {
emitPetEvent({ type: 'director', script: { gesture: gesture as never, ttlMs: dur + 500 } });
vetTimer.current = setTimeout(() => {
if (!ok()) {
vetAbortRef.current();
return;
}
next();
}, dur);
};
const wrap = () => step('shine', 1_300, () => (vetRunning.current = false)); // 摘镜 + 闪亮收尾
const stage3 = () => {
const r = Math.random();
if (r < 0.4) step('vet_polish', 1_800, wrap);
else if (r < 0.7) {
setShield(true); // 涂氟 → 获得护盾 buff
step('vet_fluoride', 1_800, wrap);
} else wrap(); // 第三动作终止
};
const stage2 = () => {
const r = Math.random();
if (r < 0.35) step('vet_scale', 2_000, stage3);
else if (r < 0.65) step('vet_fill', 2_000, stage3);
else wrap(); // 第二动作终止
};
const startVet = () => {
if (vetRunning.current || !ok() || poseRef.current !== 'idle') return;
vetRunning.current = true;
step('vet_exam', 1_600, stage2);
};
const unsub = onPetEvent((e) => {
if (e.type === 'vet_combo') startVet();
});
const onDebug = (e: Event) => {
if ((e as CustomEvent<{ cmd?: string }>).detail?.cmd === 'vet') startVet();
};
window.addEventListener('pac-pet-debug', onDebug);
return () => {
unsub();
window.removeEventListener('pac-pet-debug', onDebug);
vetAbortRef.current();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const spawnGerm = (forceSuper = false) => {
if (germ.current) return; // 允许宠物坐着/走着时也刷(它会下来/跳上去追)
vetAbortRef.current(); // 蛀虫出现 → 中断诊疗组合,优先去追
const superman = forceSuper || Math.random() < 0.1; // 隐藏 10%:超人模式(lab 可强制)
// 40% 概率骑在某个可见边框上,否则在地板底边
let perch: Element | null = null;
if (Math.random() < 0.4) {
const cands: Element[] = [];
document.querySelectorAll('[data-pet-perch], [class*="border"]').forEach((p) => {
const r = p.getBoundingClientRect();
if (r.width < 60 || r.height < 6) return;
if (r.left < 0 || r.right > window.innerWidth) return;
if (r.top < 50 || r.top > window.innerHeight - 90) return; // 太高(跳不上)/太低(≈地板)不选
cands.push(p);
});
perch = cands[Math.floor(Math.random() * cands.length)] ?? null;
}
if (perch) {
const r = perch.getBoundingClientRect();
germ.current = {
x: r.left + 6 + Math.random() * Math.max(r.width - 28, 1),
dir: Math.random() < 0.5 ? -1 : 1,
t0: performance.now(),
state: 'crawl',
perch,
superman,
};
} else {
const r = btnRef.current?.getBoundingClientRect();
const baseX = r ? r.left : window.innerWidth / 2;
const side = Math.random() < 0.5 ? -1 : 1;
germ.current = {
x: Math.min(Math.max(baseX + side * (200 + Math.random() * 160), 8), window.innerWidth - 30),
dir: side > 0 ? -1 : 1,
t0: performance.now(),
state: 'crawl',
perch: null,
superman,
};
}
setGermAlive(true);
};
const spawnRef = useRef(spawnGerm);
spawnRef.current = spawnGerm;
useEffect(() => {
const iv = setInterval(() => {
if (!document.hidden && Math.random() < 0.5) spawnRef.current();
}, 90_000);
const h = (e: Event) => {
const cmd = (e as CustomEvent<{ cmd?: string }>).detail?.cmd;
if (cmd === 'germ') spawnRef.current(false);
else if (cmd === 'germ-super') spawnRef.current(true);
};
window.addEventListener('pac-pet-debug', h);
return () => {
clearInterval(iv);
window.removeEventListener('pac-pet-debug', h);
};
}, []);
// 狩猎主循环:菌按"地板/边框"两种 lane 爬+逃 + 给运动层发追捕指令(floor 走 / perch 跳上去) + 2D 判定追上/溜走
useEffect(() => {
if (!germAlive) return;
let raf = 0;
let prev = performance.now();
let lastCmd = 0;
const GW = 22; // 菌的渲染宽度
const tick = (now: number) => {
raf = requestAnimationFrame(tick);
const g = germ.current;
if (!g || document.hidden) return;
const dt = Math.min((now - prev) / 1000, 0.05);
prev = now;
// lane:菌骑的边框还在吗?算出本帧爬行区间 [loX, hiX] 与离地高度 yTop
let loX = 4;
let hiX = window.innerWidth - GW - 4;
let yTop = window.innerHeight - GW - 2; // 地板
if (g.perch) {
const rp = (g.perch as HTMLElement).checkVisibility?.() === false ? null : g.perch.getBoundingClientRect();
if (!rp || rp.width < 40 || !g.perch.isConnected) {
g.perch = null; // 边框没了 → 掉回地板
} else {
loX = rp.left + 2;
hiX = rp.right - GW - 2;
yTop = rp.top - GW;
}
}
if (g.state !== 'dying') {
const pr = btnRef.current?.getBoundingClientRect();
const petCx = pr ? pr.left + SIZE / 2 : -1e4;
const petCy = pr ? pr.top + SIZE / 2 : -1e4;
const gCx = g.x + GW / 2;
const gCy = yTop + GW / 2;
const dx = Math.abs(petCx - gCx);
const dy = Math.abs(petCy - gCy);
if (g.superman) {
const TRANSFORM_MS = 950;
const elapsed = now - g.t0;
if (elapsed < TRANSFORM_MS) {
// ① 变身:原地蓄力(用 fly 指令定在自身位置 → 悬停不动、不受重力),不发起攻击
if (superPhase !== 'transform') setSuperPhase('transform');
const pr2 = btnRef.current?.getBoundingClientRect();
if (pr2) {
window.dispatchEvent(
new CustomEvent('pac-pet-debug', { detail: { cmd: 'fly', x: pr2.left, y: pr2.top } }),
);
}
if (germEl.current) {
germEl.current.style.left = `${g.x.toFixed(1)}px`;
germEl.current.style.top = `${yTop.toFixed(1)}px`;
}
return; // 蛀虫此刻冻在原地(g.x 不更新),等变身完
}
// ② 飞行:不逃也没用 —— 直线飞过去,每帧锁定目标
if (superPhase !== 'fly') setSuperPhase('fly');
window.dispatchEvent(
new CustomEvent('pac-pet-debug', { detail: { cmd: 'fly', x: gCx - SIZE / 2, y: gCy - SIZE / 2 } }),
);
if (dx < 46 && dy < 48) {
// 撞上即秒杀(无牙刷,披风冲击)
g.state = 'dying';
setBurst({ x: g.x, top: yTop });
germ.current = null;
setGermAlive(false);
setTimeout(() => {
setSuperPhase('off');
setBurst(null);
}, 700);
emitPetEvent({ type: 'director', script: { gesture: 'celebrate', ttlMs: 2_800 } });
}
if (germEl.current) {
germEl.current.style.left = `${g.x.toFixed(1)}px`;
germEl.current.style.top = `${yTop.toFixed(1)}px`;
}
return;
}
// 普通追捕:菌被水平逼近就逃窜,平时瞎溜达
g.state = dx < 150 ? 'flee' : 'crawl';
if (g.state === 'flee') g.dir = petCx < gCx ? 1 : -1;
else if (Math.random() < 0.004) g.dir = g.dir === 1 ? -1 : 1;
g.x = Math.min(Math.max(g.x + (g.state === 'flee' ? 34 : 13) * g.dir * dt, loX), hiX);
// 追捕指令(节流):地板上走过去;边框上的菌 → 走到正下方后纵身一跃
if (now - lastCmd > 450) {
lastCmd = now;
const onFloorReady = !anchoredRef.current && motionRef.current === 'rest';
if (g.perch && onFloorReady && dx < 26) {
window.dispatchEvent(
new CustomEvent('pac-pet-debug', { detail: { cmd: 'pounce', x: gCx - SIZE / 2, y: yTop } }),
);
} else {
window.dispatchEvent(new CustomEvent('pac-pet-debug', { detail: { cmd: 'hunt', x: gCx - SIZE / 2 } }));
}
}
if (dx < 38 && dy < 44 && motionRef.current !== 'walk') {
// 同高度且贴近(地板走到 / 纵身跳到)→ 敲打 1.1s → 爆星 → 庆祝
g.state = 'dying';
setWhacking(true);
setTimeout(() => {
setWhacking(false);
setBurst({ x: germ.current?.x ?? 0, top: yTop });
germ.current = null;
setGermAlive(false);
setTimeout(() => setBurst(null), 700);
emitPetEvent({ type: 'director', script: { gesture: 'celebrate', ttlMs: 2_800 } });
}, 1_100);
} else if (now - g.t0 > 16_000) {
germ.current = null;
setGermAlive(false); // 追不上,菌溜走(无气泡)
}
}
if (germEl.current && germ.current) {
germEl.current.style.left = `${germ.current.x.toFixed(1)}px`;
germEl.current.style.top = `${yTop.toFixed(1)}px`;
}
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [germAlive]);
// ── 摸摸:悬停 ≥350ms 算"被摸"(眯眼笑 + 爱心),路过不算 ──
const [cuddled, setCuddled] = useState(false);
const cuddleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onHoverEnter = () => {
if (cuddleTimer.current) clearTimeout(cuddleTimer.current);
cuddleTimer.current = setTimeout(() => setCuddled(true), 350);
};
const onHoverLeave = () => {
if (cuddleTimer.current) clearTimeout(cuddleTimer.current);
cuddleTimer.current = null;
setCuddled(false);
};
useEffect(
() => () => {
if (cuddleTimer.current) clearTimeout(cuddleTimer.current);
},
[],
);
// ── 拖拽(4px 阈值区分点击;拖中跟手,松手交还物理) ──
const drag = useRef<{ startX: number; startY: number; baseX: number; baseY: number; moved: boolean } | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
vetAbortRef.current(); // 拖拽/点击 → 中断诊疗组合
const r = btnRef.current!.getBoundingClientRect();
drag.current = { startX: e.clientX, startY: e.clientY, baseX: r.left, baseY: r.top, moved: false };
btnRef.current!.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (!d.moved) {
if (Math.hypot(dx, dy) < 4) return;
d.moved = true;
api.startDrag();
}
api.dragTo(d.baseX + dx, d.baseY + dy);
};
const onPointerUp = () => {
const d = drag.current;
drag.current = null;
if (!d) return;
if (d.moved) {
api.drop();
} else {
const r = btnRef.current?.getBoundingClientRect();
if (r) onToggle(r);
}
};
// 套绳可视化:rAF 把绳子两端都钉住 —— 锚点端跟随实时鼠标,另一端在宠物头顶(60fps,不走 React)
const ropeLineRef = useRef<SVGLineElement>(null);
const ropeAnchorRef = useRef<SVGCircleElement>(null);
useEffect(() => {
if (!rope) return;
const mouse = { x: rope.x, y: rope.y };
const onMove = (e: PointerEvent) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
};
window.addEventListener('pointermove', onMove, { passive: true });
let raf = 0;
const tick = () => {
raf = requestAnimationFrame(tick);
const r = btnRef.current?.getBoundingClientRect();
const line = ropeLineRef.current;
if (r && line) {
line.setAttribute('x1', String(mouse.x));
line.setAttribute('y1', String(mouse.y));
line.setAttribute('x2', String(r.left + SIZE / 2));
line.setAttribute('y2', String(r.top + 8));
ropeAnchorRef.current?.setAttribute('cx', String(mouse.x));
ropeAnchorRef.current?.setAttribute('cy', String(mouse.y));
}
};
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener('pointermove', onMove);
cancelAnimationFrame(raf);
};
}, [rope]);
// 姿态合成:超人披风 > 敲打 > 运动(摔/跳/拖/荡/飞 → 慌张/飞)> 走 > 刷字 > 被摸 > 坐 > 表情
const bodyPose: PetBodyPose =
superPhase === 'transform'
? 'morph' // 超人变身(原地蓄力 + 披风展开 + 光环)
: superPhase === 'fly'
? 'fly' // 超人飞行(红披风)
: whacking
? 'brush' // 敲蛀牙菌:无论站着还是纵身跃起,都挥牙刷
: motion === 'fly'
? 'fly'
: motion === 'climb'
? 'climb' // 爬楼(背朝外悬挂垫脚)
: motion === 'magnify'
? 'magnify' // 到达选区举放大镜照
: motion === 'fall' || motion === 'jump' || motion === 'drag' || motion === 'swing'
? 'fall'
: motion === 'walk'
? 'walk'
: brush?.phase === 'scrub'
? 'brush'
: rinsing
? 'rinse'
: cuddled && (pose === 'idle' || pose === 'sleep')
? 'happy'
: anchored && pose === 'idle'
? 'sit'
: pose;
// 气泡弹向:按当前实际位置(运动层直接写 style,这里读回来)
const onLeftHalf = (() => {
const r = btnRef.current?.getBoundingClientRect();
return r ? r.left + SIZE / 2 < window.innerWidth / 2 : false;
})();
return (
<button
ref={btnRef}
type="button"
title={open ? '收起助手' : '打开助手(可拖动,会自己溜达)'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerEnter={onHoverEnter}
onPointerLeave={onHoverLeave}
className="fixed z-[61] touch-none select-none"
style={{ left: -9999, top: -9999 }} // 首帧屏外,place() 后由运动层接管
>
{bubble && (
<span
className={cn(
// w-max 让短句贴合内容、max-w 封顶,长句自动换行不溢出气泡框
'absolute bottom-full mb-1.5 w-max max-w-[230px] whitespace-normal break-words rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-xs leading-snug text-slate-700 shadow-lg',
onLeftHalf ? 'left-0' : 'right-0',
)}
>
{bubble}
</span>
)}
{/* 套绳:锚点(鼠标处)到宠物头顶的一条绳(x2/y2 由 rAF 实时钉) */}
{rope && (
<svg
className="pointer-events-none fixed left-0 top-0 z-[60]"
style={{ width: '100vw', height: '100vh' }}
>
<line ref={ropeLineRef} x1={rope.x} y1={rope.y} x2={rope.x} y2={rope.y} stroke="#d97706" strokeWidth="2" strokeLinecap="round" />
<circle ref={ropeAnchorRef} cx={rope.x} cy={rope.y} r="3.5" fill="#d97706" />
</svg>
)}
{/* 蛀牙菌:地板或边框上乱爬的紫色小坏蛋(left/top 由 rAF 实时钉;被发现会逃,被追上就被牙刷敲爆) */}
{germAlive && (
<span ref={germEl} className="pointer-events-none fixed z-[60]" style={{ top: -9999, left: -9999 }}>
<svg viewBox="0 0 24 24" width={22} height={22}>
<g className="pac-germ-bob">
<g stroke="#7c3aed" strokeWidth="1.6" strokeLinecap="round">
<path d="M12 3 L12 0.5" />
<path d="M5 6 L3.2 4.2" />
<path d="M19 6 L20.8 4.2" />
<path d="M3.5 13 L1 13" />
<path d="M20.5 13 L23 13" />
</g>
<circle cx="12" cy="13" r="8.5" fill="#a78bfa" stroke="#7c3aed" strokeWidth="1" />
<circle cx="9" cy="16" r="1.6" fill="#7c3aed" opacity="0.5" />
<circle cx="15.5" cy="15" r="1.1" fill="#7c3aed" opacity="0.5" />
<circle cx="9.5" cy="11.5" r="1.4" fill="#1e1b4b" />
<circle cx="14.5" cy="11.5" r="1.4" fill="#1e1b4b" />
<path d="M8 9.4 L11 10.4 M16 9.4 L13 10.4" stroke="#1e1b4b" strokeWidth="1" strokeLinecap="round" />
<path d="M9.5 17.5 Q12 19.5 14.5 17.5" stroke="#1e1b4b" strokeWidth="1.1" fill="none" strokeLinecap="round" />
</g>
</svg>
</span>
)}
{/* 消灭爆星(在蛀虫被敲爆的位置) */}
{burst && (
<span className="pointer-events-none fixed z-[60]" style={{ left: burst.x - 6, top: burst.top - 4 }}>
<span className="pac-germ-pop"></span>
<span className="pac-germ-pop" style={{ animationDelay: '0.08s', marginLeft: 6 }}></span>
<span className="pac-germ-pop" style={{ animationDelay: '0.16s', marginLeft: 6 }}></span>
</span>
)}
{/* 刷字泡沫层:盖在脚下卡片标题区,刷完换闪光(纯视觉覆盖,不动 DOM 文字) */}
{brush && (
<span
className="pointer-events-none fixed z-[60] block"
style={{ left: brush.rect.x, top: brush.rect.y, width: brush.rect.w, height: brush.rect.h }}
>
<style>{FOAM_CSS}</style>
{brush.phase === 'scrub'
? FOAM_DOTS.map((d, i) => (
<span
key={i}
className="pac-foam-b"
style={{ left: `${d.l}%`, width: d.s, height: d.s, animationDelay: `${d.d}s` }}
/>
))
: SHINE_DOTS.map((d, i) => (
<span key={i} className="pac-foam-s" style={{ left: `${d.l}%`, animationDelay: `${d.d}s` }}>
</span>
))}
</span>
)}
{/* 朝向翻转放外层 span(svg 根的 transform 留给跳/压扁动画) */}
<span
className="block drop-shadow-md transition-transform hover:scale-105"
style={{ transform: facing === -1 ? 'scaleX(-1)' : undefined }}
>
<PetBody
pose={bodyPose}
glanceSeq={glanceSeq}
facing={facing}
squashSeq={landSeq}
shield={shield}
seated={anchored && motion === 'rest'}
size={SIZE}
/>
</span>
</button>
);
}
// 刷字泡沫/闪光的固定布点(模块级一次性布局,渲染稳定)
const FOAM_DOTS = [
{ l: 6, d: 0, s: 7 },
{ l: 22, d: 0.3, s: 10 },
{ l: 40, d: 0.15, s: 8 },
{ l: 58, d: 0.45, s: 11 },
{ l: 76, d: 0.1, s: 7 },
{ l: 90, d: 0.55, s: 9 },
];
const SHINE_DOTS = [
{ l: 14, d: 0 },
{ l: 48, d: 0.15 },
{ l: 80, d: 0.3 },
];
const FOAM_CSS = `
.pac-foam-b, .pac-foam-s { position: absolute; bottom: 2px; opacity: 0; }
.pac-foam-b { border-radius: 9999px; background: rgba(255,255,255,0.92); border: 1px solid #bae6fd; }
.pac-foam-s { color: #fbbf24; font-size: 13px; line-height: 1; }
.pac-germ-pop { display: inline-block; color: #a78bfa; font-size: 15px; opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
.pac-foam-b { animation: pacFoamB 1s ease-in-out infinite; }
.pac-foam-s { animation: pacFoamS 0.85s ease-out forwards; }
.pac-germ-bob { transform-box: fill-box; transform-origin: center bottom; animation: pacGermBob 0.5s ease-in-out infinite; }
.pac-germ-pop { animation: pacGermPop 0.6s ease-out forwards; }
}
@keyframes pacFoamB { 0% { opacity: 0; transform: translateY(3px) scale(0.6); } 40% { opacity: 1; } 100% { opacity: 0; transform: translateY(-9px) scale(1.1); } }
@keyframes pacFoamS { 0% { opacity: 0; transform: scale(0.5) rotate(0deg); } 35% { opacity: 1; } 100% { opacity: 0; transform: scale(1.25) rotate(40deg); } }
@keyframes pacGermBob { 0%,100% { transform: scaleY(1); } 50% { transform: scaleY(0.9); } }
@keyframes pacGermPop { 0% { opacity: 1; transform: scale(0.4); } 100% { opacity: 0; transform: scale(1.7) translateY(-10px); } }
`;
'use client';
import { useEffect, useRef } from 'react';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
/**
* 宠物开口说话(LLM 导演的第一个落地)— 观察周围环境,低频说一句简短的话。
*
* 纪律(对齐 PAC 原则 5):
* - 只在页面激活(visible + focus)时才可能触发;低概率 + 长冷却,绝不刷屏;
* - 输入极简(一段 ≤400 字的环境观察文本),输出极短(≤30 字,流式进气泡);
* - LLM 挂了 = 静默不说话,反射层(规则宠物)完全不受影响。
*/
const CHECK_MS = 75_000; // 检查节拍
const SPEAK_PROB = 0.12; // 每拍触发概率
const COOLDOWN_MS = 4 * 60_000; // 两次发言最小间隔
/** 环境感知:拼一段简短的观察文本(宠物"看到"什么)。 */
function sense(btn: HTMLElement | null): string {
const parts: string[] = [];
const h = new Date().getHours();
parts.push(
`现在是${h < 6 ? '深夜' : h < 9 ? '清晨' : h < 12 ? '上午' : h < 14 ? '中午' : h < 18 ? '下午' : '晚上'}`,
);
const cur = usePlanSyncStore.getState().current;
if (cur?.patientName) parts.push(`客服正在跟进患者「${cur.patientName}」的召回工作台`);
const r = btn?.getBoundingClientRect();
if (r) {
const onFloor = r.bottom > window.innerHeight - 70;
if (onFloor) {
parts.push('我正站在屏幕底边');
} else {
// 身下是什么卡片:取其最近容器里的标题文字
const below = document.elementFromPoint(
Math.min(Math.max(r.left + r.width / 2, 1), window.innerWidth - 1),
Math.min(r.bottom + 6, window.innerHeight - 1),
);
const card = below?.closest('section, aside, figure, header, [data-pet-perch]');
const title = card
?.querySelector('h1,h2,h3,figcaption,[class*="font-semibold"]')
?.textContent?.trim()
.slice(0, 24);
parts.push(title ? `我正蹲在「${title}」卡片的边框上` : '我蹲在一个卡片边框上');
}
}
return parts.join(';');
}
export function usePetVoice(btnRef: React.RefObject<HTMLElement | null>, canSpeak: () => boolean) {
const lastAt = useRef(0);
const speaking = useRef(false);
useEffect(() => {
const speak = async () => {
if (speaking.current) return;
speaking.current = true;
lastAt.current = Date.now();
// 一发起就进"思考"(还没收到第一个词)→ 思考脸 + 头顶冒点
emitPetEvent({ type: 'pet_voice', phase: 'think' });
let talking = false;
try {
const token = useAuthStore.getState().accessToken;
const res = await fetch(new URL('/pac/v1/assistant/pet-say', env.apiBaseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ observation: sense(btnRef.current) }),
});
if (!res.ok || !res.body) return; // 静默降级(finally 会收尾)
let acc = '';
let buffer = '';
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
try {
const evt = JSON.parse(line.slice('data:'.length).trim()) as { type?: string; text?: string };
if (evt.type === 'text' && evt.text) {
if (!talking) {
talking = true;
emitPetEvent({ type: 'pet_voice', phase: 'talk' }); // 第一个词到 → 切"说话"(嘴动)
}
acc += evt.text;
// 流式进气泡:每个增量重发(ttl 给长,防中途消失;说完再收短)
emitPetEvent({ type: 'director', script: { bubble: acc, ttlMs: 15_000 } });
}
} catch {
/* 半截帧忽略 */
}
}
}
if (acc) emitPetEvent({ type: 'director', script: { bubble: acc, ttlMs: 4_500 } });
} catch {
/* LLM 不可用 → 保持沉默,反射层照常活 */
} finally {
emitPetEvent({ type: 'pet_voice', phase: 'end' }); // 停止说话动作,复位基线
speaking.current = false;
}
};
const iv = setInterval(() => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) return; // 只在页面激活时
if (Date.now() - lastAt.current < COOLDOWN_MS) return;
if (Math.random() > SPEAK_PROB) return;
if (!canSpeak()) return;
void speak();
}, CHECK_MS);
// lab 一键触发(跳过概率/冷却,保留激活态要求)
const h = (e: Event) => {
if ((e as CustomEvent<{ cmd?: string }>).detail?.cmd === 'say') void speak();
};
window.addEventListener('pac-pet-debug', h);
return () => {
clearInterval(iv);
window.removeEventListener('pac-pet-debug', h);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
......@@ -227,9 +227,11 @@ export function PatientPickerRail({
{!loading && items.length === 0 && !error && (
<div className="p-6 text-center text-[11.5px] text-slate-400">没有匹配的患者</div>
)}
{/* data-pet-perch:助手宠物的栖息点(滚到列表底时它会跳上来蹲;按钮被压住也无妨,滚动加载兜底) */}
{!loading && hasMore && (
<button
type="button"
data-pet-perch
onClick={loadMore}
className="block w-full py-2 text-center text-[11px] text-teal-700 hover:bg-teal-50/50"
>
......
/**
* 宠物神经系统 — 语义事件总线 + 演出脚本契约。
*
* 三层架构(感知 → 大脑 → 身体,单向):
* 感知层:业务代码/传感器只 emitPetEvent(语义事件),不关心宠物怎么演;
* 大脑层:pet-brain 订阅事件,经"导演"产出 DirectorScript;
* 身体层:pet-body 纯展示,按姿态渲染。
*
* DirectorScript 是大脑的唯一指令入口:现在由规则导演(模板)产出;
* 将来 LLM 导演产同构 JSON(受限词表 + zod 校验)走同一入口 —— 主流程确定性,
* LLM 只补台词,断了即退回纯规则(对齐 PAC 原则 5:Agent 路径必须可降级)。
*/
/** 动作词表(受限枚举,LLM 导演也只能从这里选,不许自由发挥)。 */
export type PetGesture =
| 'none'
| 'think'
| 'celebrate'
| 'greet'
| 'sleep'
| 'floss' // 牙线自理
| 'shine' // 闪亮微笑(牙面 ding 一下)
| 'chomp' // 咬合操(张合嘴)
// 牙医组合派生动作(检查 → 治疗 → 处置)
| 'vet_exam' // 看牙检查(戴额镜 + 口镜)
| 'vet_scale' // 洗牙(超声洁治 + 水珠)
| 'vet_fill' // 补牙(电钻 + 火星)
| 'vet_polish' // 抛光(抛光杯 + ✨)
| 'vet_fluoride'; // 涂氟(applicator,随后获护盾)
/** 语义事件 — 感知层发,大脑订阅。 */
export type PetEvent =
| { type: 'ai_thinking_start' }
| { type: 'ai_thinking_end' }
/** 直接递一段演出脚本给大脑(规则模块现在用,LLM 导演将来发同构事件) */
| { type: 'director'; script: DirectorScript }
/** 宠物开口说话的三个阶段:think=已发起 LLM 还没吐第一个词 / talk=流式中(嘴动) / end=说完 */
| { type: 'pet_voice'; phase: 'think' | 'talk' | 'end' }
/** 大脑 idle 轮盘抽中"牙医组合"→ 通知 widget 跑诊疗派生链 */
| { type: 'vet_combo' };
/** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */
export interface DirectorScript {
gesture?: PetGesture;
/** 唯一自由文本:气泡台词(LLM 的本职就在这一个字段)。 */
bubble?: string;
/** 演出时长,到点自动复位回基线姿态。 */
ttlMs?: number;
}
type Listener = (e: PetEvent) => void;
const listeners = new Set<Listener>();
export function emitPetEvent(e: PetEvent): void {
listeners.forEach((l) => l(e));
}
export function onPetEvent(l: Listener): () => void {
listeners.add(l);
return () => listeners.delete(l);
}
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