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:
@@ -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'} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
Reference in New Issue
Block a user