feat(pet): Mochi celebrates when tasks are completed

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.
This commit is contained in:
2026-06-06 00:26:22 -04:00
parent d807eb0424
commit 27ef371bc8
3 changed files with 118 additions and 2 deletions
+2
View File
@@ -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}
/>
<Particles type={currentScene.particles ?? 'none'} />
+103 -1
View File
@@ -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<HTMLCanvasElement>(null);
const kiraModelRef = useRef<any>(null);
const catModelRef = useRef<any>(null);
const pixiAppRef = useRef<any>(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<string, number> = {};
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 (
<canvas
ref={canvasRef}
+13 -1
View File
@@ -70,6 +70,7 @@ export function useConversation() {
const [loadingPrefs, setLoadingPrefs] = useState(true);
const [micError, setMicError] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [celebrateTrigger, setCelebrateTrigger] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const streamRef = useRef<MediaStream | null>(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() }));