feat(pets): replace static cats with Live2D LittleCat model (black texture)

- Copied LittleCat model files to frontend/public/live2d/models/little-cat/
- Using the black alternate texture as default
- Created Live2DCat component that renders the model in a small canvas
- PetZone now shows a single Live2D cat instead of two SVG cats
This commit is contained in:
2026-06-05 12:55:24 -04:00
parent 15199dfdee
commit 017c81cffa
22 changed files with 2040 additions and 69 deletions
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef } from 'react';
interface Props {
className?: string;
}
export default function Live2DCat({ className }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let mounted = true;
const canvas = canvasRef.current;
if (!canvas) return;
let app: any = null;
const init = async () => {
try {
await loadScript('/live2d/cubism/live2dcubismcore.min.js');
const { Application, Ticker } = await import('pixi.js');
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
(Live2DModel as any).registerTicker(Ticker as any);
const w = canvas.clientWidth || 120;
const h = canvas.clientHeight || 120;
app = new Application({
view: canvas,
width: w,
height: h,
antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2),
backgroundAlpha: 0,
autoDensity: true,
});
if (!mounted) { app.destroy(true); return; }
const model = await Live2DModel.from('/live2d/models/little-cat/LittleCat.model3.json', {
autoInteract: false,
});
if (!mounted) { app.destroy(true); return; }
// Scale to fit the small canvas
const sw = app.screen.width;
const sh = app.screen.height;
const s = Math.min((sw * 0.85) / model.width, (sh * 0.85) / model.height);
model.scale.set(s);
model.anchor.set(0.5, 0.5);
model.position.set(sw / 2, sh / 2);
app.stage.addChild(model as any);
(model as any).isInteractive = () => false;
try { model.motion('Idle'); } catch { /* */ }
} catch (e) {
console.warn('[Live2DCat]', e);
}
};
init();
return () => {
mounted = false;
if (app) { app.destroy(true, { children: true }); }
};
}, []);
return (
<canvas
ref={canvasRef}
className={className}
style={{ display: 'block' }}
/>
);
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed ' + src));
document.head.appendChild(s);
});
}