fix(pets): cat renders on shared KiraAvatar canvas via onAppReady callback

Single WebGL context, no bindBuffer spam. Cat model loads onto
KiraAvatar's stage and positions itself at bottom-right corner.
PetZone passes app prop through to Live2DCat.
This commit is contained in:
2026-06-05 13:19:32 -04:00
parent 37f8bf59a0
commit 1f8bcf6b4f
4 changed files with 38 additions and 44 deletions
+3 -1
View File
@@ -33,6 +33,7 @@ export default function App() {
const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie'); const [currentOutfit, setCurrentOutfit] = useState('cozy-hoodie');
const [currentAcc, setCurrentAcc] = useState<string | null>(null); const [currentAcc, setCurrentAcc] = useState<string | null>(null);
const [textInput, setTextInput] = useState(''); const [textInput, setTextInput] = useState('');
const [pixiApp, setPixiApp] = useState<any>(null);
useEffect(() => { useEffect(() => {
if (preferences.scene) setCurrentSceneId(preferences.scene); if (preferences.scene) setCurrentSceneId(preferences.scene);
@@ -147,6 +148,7 @@ export default function App() {
outfit={currentOutfit} outfit={currentOutfit}
accessory={currentAcc} accessory={currentAcc}
onTalkToggle={handleTalkToggle} onTalkToggle={handleTalkToggle}
onAppReady={setPixiApp}
/> />
</div> </div>
@@ -155,7 +157,7 @@ export default function App() {
<MusicPlayer /> <MusicPlayer />
<WhiteNoise /> <WhiteNoise />
<Wardrobe onOutfitChange={handleOutfitChange} onAccessoryChange={handleAccessoryChange} /> <Wardrobe onOutfitChange={handleOutfitChange} onAccessoryChange={handleAccessoryChange} />
<PetZone /> <PetZone app={pixiApp} />
</div> </div>
</div> </div>
+2
View File
@@ -7,6 +7,7 @@ interface Props {
outfit: string; outfit: string;
accessory: string | null; accessory: string | null;
onTalkToggle: () => void; onTalkToggle: () => void;
onAppReady?: (app: any) => void;
} }
type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing'; type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing';
@@ -82,6 +83,7 @@ export default function KiraAvatar(props: Props) {
app.stage.addChild(model as any); app.stage.addChild(model as any);
(model as any).isInteractive = () => false; (model as any).isInteractive = () => false;
if (!mounted) return; if (!mounted) return;
if (props.onAppReady) props.onAppReady(app);
try { try {
textureRef.current = { textureRef.current = {
+23 -39
View File
@@ -1,58 +1,44 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
interface Props { interface Props {
className?: string; app: any; // shared Pixi Application from KiraAvatar
} }
export default function Live2DCat({ className }: Props) { export default function Live2DCat({ app }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const modelRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
if (!app) return;
let mounted = true; let mounted = true;
const canvas = canvasRef.current;
if (!canvas) return;
let app: any = null;
const init = async () => { const init = async () => {
try { try {
const { Application, Ticker } = await import('pixi.js');
const { Live2DModel } = await import('pixi-live2d-display/cubism4'); const { Live2DModel } = await import('pixi-live2d-display/cubism4');
const { Ticker } = await import('pixi.js');
(Live2DModel as any).registerTicker(Ticker as any); (Live2DModel as any).registerTicker(Ticker as any);
const w = canvas.clientWidth || 120;
const h = canvas.clientHeight || 120;
// forceCanvas avoids WebGL context conflicts with KiraAvatar's context
app = new Application({
view: canvas,
width: w,
height: h,
antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2),
backgroundAlpha: 0,
autoDensity: true,
forceCanvas: true,
});
if (!mounted) { app.destroy(true); return; }
const model = await Live2DModel.from('/live2d/models/little-cat/LittleCat.model3.json', { const model = await Live2DModel.from('/live2d/models/little-cat/LittleCat.model3.json', {
autoInteract: false, autoInteract: false,
}); });
if (!mounted) { app.destroy(true); return; } if (!mounted) { model.destroy(); return; }
const sw = app.screen.width; // Scale cat to ~15% of avatar canvas height
const sh = app.screen.height; const targetH = app.screen.height * 0.18;
const s = Math.min((sw * 0.85) / model.width, (sh * 0.85) / model.height); const s = targetH / model.height;
model.scale.set(s); model.scale.set(s);
model.anchor.set(0.5, 0.5); model.anchor.set(0.5, 0.5);
model.position.set(sw / 2, sh / 2);
// Bottom-right corner of Kira's canvas
model.position.set(
app.screen.width - (model.width * s) / 2 - 10,
app.screen.height - (model.height * s) / 2 - 10
);
app.stage.addChild(model as any); app.stage.addChild(model as any);
(model as any).isInteractive = () => false; (model as any).isInteractive = () => false;
modelRef.current = model;
try { model.motion('Idle'); } catch { /* */ } try { model.motion('Idle'); } catch { /* */ }
} catch (e) { } catch (e) {
console.warn('[Live2DCat]', e); console.warn('[Live2DCat]', e);
} }
@@ -62,15 +48,13 @@ export default function Live2DCat({ className }: Props) {
return () => { return () => {
mounted = false; mounted = false;
if (app) { app.destroy(true, { children: true }); } if (modelRef.current && app) {
app.stage.removeChild(modelRef.current);
modelRef.current.destroy();
modelRef.current = null;
}
}; };
}, []); }, [app]);
return ( return null; // renders on KiraAvatar's shared canvas
<canvas
ref={canvasRef}
className={className}
style={{ display: 'block' }}
/>
);
} }
+10 -4
View File
@@ -1,17 +1,23 @@
import Live2DCat from './Live2DCat'; import Live2DCat from './Live2DCat';
export default function PetZone() { interface Props {
app?: any;
}
export default function PetZone({ app }: Props) {
return ( return (
<div className="p-4 relative overflow-hidden" style={{ minHeight: 140 }}> <div className="p-4 relative overflow-hidden" style={{ minHeight: 140 }}>
<h3 className="text-sm font-bold text-kira-plum mb-2 flex items-center gap-2"> <h3 className="text-sm font-bold text-kira-plum mb-2 flex items-center gap-2">
<span>🐱</span> Pet Zone <span>🐱</span> Pet Zone
</h3> </h3>
<div className="flex items-center justify-center"> {app ? (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<Live2DCat className="w-28 h-28" /> <Live2DCat app={app} />
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Mochi</span> <span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Mochi</span>
</div> </div>
</div> ) : (
<p className="text-xs text-kira-plum/30">loading...</p>
)}
</div> </div>
); );
} }