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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,79 +1,17 @@
|
||||
import Live2DCat from './Live2DCat';
|
||||
|
||||
export default function PetZone() {
|
||||
return (
|
||||
<div className="p-4 relative overflow-hidden" style={{ minHeight: 120 }}>
|
||||
<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">
|
||||
<span>🐱</span> Pet Zone
|
||||
</h3>
|
||||
<div className="flex items-end justify-around gap-4">
|
||||
{/* Orange fluffy cat */}
|
||||
<div className="flex flex-col items-center animate-bounce-slow">
|
||||
<div className="relative">
|
||||
{/* Body */}
|
||||
<div className="w-14 h-10 bg-orange-300 rounded-3xl rounded-br-xl relative">
|
||||
{/* Head */}
|
||||
<div className="w-10 h-9 bg-orange-300 rounded-full absolute -top-5 left-2">
|
||||
{/* Ears */}
|
||||
<div className="absolute -top-2 left-0 w-3 h-3 bg-orange-300 rounded-tl-full transform -rotate-12" />
|
||||
<div className="absolute -top-2 right-0 w-3 h-3 bg-orange-300 rounded-tr-full transform rotate-12" />
|
||||
{/* Eyes */}
|
||||
<div className="absolute top-2 left-1.5 w-2 h-2.5 bg-amber-800 rounded-full" />
|
||||
<div className="absolute top-2 right-1.5 w-2 h-2.5 bg-amber-800 rounded-full" />
|
||||
{/* Nose */}
|
||||
<div className="absolute top-3.5 left-3.5 w-1.5 h-1 bg-pink-300 rounded-full" />
|
||||
</div>
|
||||
{/* Tail */}
|
||||
<div className="absolute -right-3 top-1 w-8 h-2.5 bg-orange-300 rounded-full origin-left rotate-12" />
|
||||
</div>
|
||||
{/* Paws */}
|
||||
<div className="flex gap-3 mt-0.5 ml-2">
|
||||
<div className="w-2.5 h-1.5 bg-orange-200 rounded-full" />
|
||||
<div className="w-2.5 h-1.5 bg-orange-200 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Live2DCat className="w-28 h-28" />
|
||||
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Mochi</span>
|
||||
</div>
|
||||
|
||||
{/* Black shorthair cat - sleeping */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative animate-float-slow">
|
||||
{/* Sleeping body (curled up) */}
|
||||
<div className="w-16 h-8 bg-gray-900 rounded-full relative overflow-hidden">
|
||||
{/* Head tucked in */}
|
||||
<div className="w-8 h-6 bg-gray-800 rounded-full absolute -left-2 -top-1">
|
||||
{/* Ears */}
|
||||
<div className="absolute -top-1.5 left-1 w-2.5 h-2.5 bg-gray-900 rounded-tl-full transform -rotate-12" />
|
||||
<div className="absolute -top-1.5 right-1 w-2.5 h-2.5 bg-gray-900 rounded-tr-full transform rotate-12" />
|
||||
{/* Closed eyes (sleeping) */}
|
||||
<div className="absolute top-2 left-1 w-2 h-0.5 bg-gray-600 rounded-full" />
|
||||
<div className="absolute top-2 right-1 w-2 h-0.5 bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
{/* Tail curled around */}
|
||||
<div className="absolute -right-2 top-2 w-6 h-2 bg-gray-900 rounded-full" />
|
||||
</div>
|
||||
{/* ZZZ */}
|
||||
<div className="absolute -top-3 -right-2 text-[10px] text-kira-lav font-bold opacity-60 animate-zzz">Z z z</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-kira-plum/60 mt-1 font-medium">Luna</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes bounce-slow {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-2px) scale(1.01); }
|
||||
}
|
||||
@keyframes zzz {
|
||||
0%, 100% { opacity: 0.3; transform: translateX(0); }
|
||||
50% { opacity: 0.8; transform: translateX(3px); }
|
||||
}
|
||||
.animate-bounce-slow { animation: bounce-slow 3s ease-in-out infinite; }
|
||||
.animate-float-slow { animation: float-slow 4s ease-in-out infinite; }
|
||||
.animate-zzz { animation: zzz 2s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user