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 (