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,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
tasks,
|
tasks,
|
||||||
|
celebrateTrigger,
|
||||||
addTask,
|
addTask,
|
||||||
} = useConversation();
|
} = useConversation();
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ export default function App() {
|
|||||||
onReady={() => setLive2dReady(true)}
|
onReady={() => setLive2dReady(true)}
|
||||||
isSpeaking={isKiraSpeaking}
|
isSpeaking={isKiraSpeaking}
|
||||||
outfit={currentOutfit}
|
outfit={currentOutfit}
|
||||||
|
celebrateTrigger={celebrateTrigger}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Particles type={currentScene.particles ?? 'none'} />
|
<Particles type={currentScene.particles ?? 'none'} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface Props {
|
|||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
isSpeaking?: boolean;
|
isSpeaking?: boolean;
|
||||||
outfit?: string;
|
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);
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const kiraModelRef = useRef<any>(null);
|
const kiraModelRef = useRef<any>(null);
|
||||||
|
const catModelRef = useRef<any>(null);
|
||||||
const pixiAppRef = useRef<any>(null);
|
const pixiAppRef = useRef<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,6 +121,7 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
|||||||
positionCat(catModel);
|
positionCat(catModel);
|
||||||
(catModel as any).isInteractive = () => false;
|
(catModel as any).isInteractive = () => false;
|
||||||
app.stage.addChild(catModel as any);
|
app.stage.addChild(catModel as any);
|
||||||
|
catModelRef.current = catModel;
|
||||||
|
|
||||||
try { catModel.motion('Idle'); } catch {}
|
try { catModel.motion('Idle'); } catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -213,6 +216,105 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [outfit]);
|
}, [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 (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function useConversation() {
|
|||||||
const [loadingPrefs, setLoadingPrefs] = useState(true);
|
const [loadingPrefs, setLoadingPrefs] = useState(true);
|
||||||
const [micError, setMicError] = useState<string | null>(null);
|
const [micError, setMicError] = useState<string | null>(null);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [celebrateTrigger, setCelebrateTrigger] = useState(0);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
@@ -168,7 +169,17 @@ export function useConversation() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tasks':
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -331,6 +342,7 @@ export function useConversation() {
|
|||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
tasks,
|
tasks,
|
||||||
|
celebrateTrigger,
|
||||||
addTask: (text: string) => {
|
addTask: (text: string) => {
|
||||||
if (!text.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
if (!text.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||||
wsRef.current.send(JSON.stringify({ type: 'add_task', text: text.trim() }));
|
wsRef.current.send(JSON.stringify({ type: 'add_task', text: text.trim() }));
|
||||||
|
|||||||
Reference in New Issue
Block a user