From 27ef371bc873a07c5c674b95f647b43216d0b67c Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Sat, 6 Jun 2026 00:26:22 -0400 Subject: [PATCH] feat(pet): Mochi celebrates when tasks are completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.8s animation: tail wag (6 segments), ear wiggle (L/R), eye smile, cheek blush, body bounce. Uses Cubism coreModel.setParameterValueById. Triggered by detecting task.completed transition from false→true. --- frontend/src/App.tsx | 2 + frontend/src/components/Live2DStage.tsx | 104 +++++++++++++++++++++++- frontend/src/hooks/useConversation.ts | 14 +++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ddceaa8..2bf1583 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ export default function App() { startRecording, stopRecording, tasks, + celebrateTrigger, addTask, } = useConversation(); @@ -111,6 +112,7 @@ export default function App() { onReady={() => setLive2dReady(true)} isSpeaking={isKiraSpeaking} outfit={currentOutfit} + celebrateTrigger={celebrateTrigger} /> diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index f5a3b3f..d5c1ba6 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -5,6 +5,7 @@ interface Props { onReady?: () => void; isSpeaking?: boolean; outfit?: string; + celebrateTrigger?: number; } /** @@ -48,9 +49,10 @@ function positionCat(cat: any) { cat.position.set(r.left + r.width / 2, r.top + r.height / 2 + 10); } -export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { +export default function Live2DStage({ onKiraReady, onReady, outfit, celebrateTrigger }: Props) { const canvasRef = useRef(null); const kiraModelRef = useRef(null); + const catModelRef = useRef(null); const pixiAppRef = useRef(null); useEffect(() => { @@ -119,6 +121,7 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { positionCat(catModel); (catModel as any).isInteractive = () => false; app.stage.addChild(catModel as any); + catModelRef.current = catModel; try { catModel.motion('Idle'); } catch {} } catch (e) { @@ -213,6 +216,105 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { return () => { cancelled = true; }; }, [outfit]); + // Celebration animation effect + useEffect(() => { + const cat = catModelRef.current; + if (!cat || celebrateTrigger === undefined || celebrateTrigger === 0) return; + + const coreModel = cat.internalModel?.coreModel; + if (!coreModel) return; + + const PARAMS = { + tail0: 'Param_Angle_Rotation2', + tail1: 'Param_Angle_Rotation3', + tail2: 'Param_Angle_Rotation4', + tail3: 'Param_Angle_Rotation5', + tail4: 'Param_Angle_Rotation6', + tail5: 'Param_Angle_Rotation7', + earL0: 'Param_Angle_Rotation10', + earL1: 'Param_Angle_Rotation11', + earR0: 'Param_Angle_Rotation12', + earR1: 'Param_Angle_Rotation13', + cheek: 'ParamCheek', + eyeSmileL: 'ParamEyeLSmile', + eyeSmileR: 'ParamEyeRSmile', + bodyX: 'ParamBodyAngleX', + bodyY: 'ParamBodyAngleY', + mouth: 'ParamMouthOpenY', + }; + + const getParamIndex = (id: string) => { + try { return coreModel.getParameterIndex(id); } catch { return -1; } + }; + + const setParam = (id: string, value: number) => { + const idx = getParamIndex(id); + if (idx >= 0) coreModel.setParameterValueById(id, value); + }; + + const saveBase = (id: string): number => { + const idx = getParamIndex(id); + return idx >= 0 ? coreModel.getParameterValueById(id) : 0; + }; + + // Save base values + const base: Record = {}; + for (const param of Object.values(PARAMS)) { + base[param] = saveBase(param); + } + + let start = 0; + const DURATION = 1800; // ms + let animId: number; + + const animate = (ts: number) => { + if (!start) start = ts; + const elapsed = ts - start; + const t = Math.min(elapsed / DURATION, 1); + + // Bounce curve (3 bounces that decay) + const bounce = Math.sin(t * Math.PI * 6) * (1 - t) * 15; + const squish = 1 + Math.sin(t * Math.PI * 6) * (1 - t) * 0.03; + + // Tail wag (all segments) + const tailWag = Math.sin(t * Math.PI * 8) * (1 - t) * 0.7; + setParam(PARAMS.tail0, base[PARAMS.tail0] + tailWag); + setParam(PARAMS.tail1, base[PARAMS.tail1] + tailWag * 0.8); + setParam(PARAMS.tail2, base[PARAMS.tail2] + tailWag * 0.6); + setParam(PARAMS.tail3, base[PARAMS.tail3] + tailWag * 0.4); + setParam(PARAMS.tail4, base[PARAMS.tail4] + tailWag * 0.3); + setParam(PARAMS.tail5, base[PARAMS.tail5] + tailWag * 0.2); + + // Ear wiggle + const earWig = Math.sin(t * Math.PI * 10) * (1 - t) * 0.5; + setParam(PARAMS.earL0, base[PARAMS.earL0] + earWig); + setParam(PARAMS.earL1, base[PARAMS.earL1] + earWig * 0.6); + setParam(PARAMS.earR0, base[PARAMS.earR0] - earWig); + setParam(PARAMS.earR1, base[PARAMS.earR1] - earWig * 0.6); + + // Happy face: eye smile + cheek blush + open mouth + const faceEase = Math.sin(t * Math.PI) ; // peaks at t=0.5 + setParam(PARAMS.eyeSmileL, Math.max(base[PARAMS.eyeSmileL], faceEase)); + setParam(PARAMS.eyeSmileR, Math.max(base[PARAMS.eyeSmileR], faceEase)); + setParam(PARAMS.cheek, Math.max(base[PARAMS.cheek], faceEase * 0.8)); + setParam(PARAMS.mouth, Math.max(base[PARAMS.mouth], faceEase * 0.4)); + + // Body bounce + setParam(PARAMS.bodyY, base[PARAMS.bodyY] + bounce); + cat.scale.set(cat.scale.x / (cat.scale.x || 1) * squish * (cat.scale.x || 1)); + + if (t < 1) { + animId = requestAnimationFrame(animate); + } else { + // Reset scale + positionCat(cat); + } + }; + + animId = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animId); + }, [celebrateTrigger]); + return ( (null); const [tasks, setTasks] = useState([]); + const [celebrateTrigger, setCelebrateTrigger] = useState(0); const wsRef = useRef(null); const streamRef = useRef(null); @@ -168,7 +169,17 @@ export function useConversation() { break; case 'tasks': - if (msg.tasks) setTasks(msg.tasks); + if (msg.tasks) { + setTasks((prev) => { + // Detect newly completed tasks + const prevMap = new Map(prev.map(t => [t.id, t.completed])); + const newCompleted = msg.tasks.filter((t: Task) => t.completed && !prevMap.get(t.id)); + if (newCompleted.length > 0) { + setCelebrateTrigger(c => c + 1); + } + return msg.tasks; + }); + } break; } }, []); @@ -331,6 +342,7 @@ export function useConversation() { startRecording, stopRecording, tasks, + celebrateTrigger, addTask: (text: string) => { if (!text.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; wsRef.current.send(JSON.stringify({ type: 'add_task', text: text.trim() }));