fix(avatar): declarative canvas element in JSX; remove manual DOM append
React was potentially clearing the canvas on re-render because we
appended it manually to a div. Now using a <canvas ref={canvasRef}>
element directly in JSX that React manages. Pixi app uses .
Scale set to 82% of container.
This commit is contained in:
@@ -23,7 +23,7 @@ const OUTFIT_TEXTURES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function KiraAvatar(props: Props) {
|
export default function KiraAvatar(props: Props) {
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const modelRef = useRef<any>(null);
|
const modelRef = useRef<any>(null);
|
||||||
const textureRef = useRef<any>(null);
|
const textureRef = useRef<any>(null);
|
||||||
const lipSyncRef = useRef<number>(0);
|
const lipSyncRef = useRef<number>(0);
|
||||||
@@ -32,11 +32,10 @@ export default function KiraAvatar(props: Props) {
|
|||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
|
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
|
||||||
|
|
||||||
// Initialize Live2D
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
const container = wrapRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!container) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
let app: any = null;
|
let app: any = null;
|
||||||
let model: any = null;
|
let model: any = null;
|
||||||
@@ -51,8 +50,13 @@ export default function KiraAvatar(props: Props) {
|
|||||||
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
|
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
|
||||||
(Live2DModel as any).registerTicker(Ticker as any);
|
(Live2DModel as any).registerTicker(Ticker as any);
|
||||||
|
|
||||||
|
const w = canvas.clientWidth || 260;
|
||||||
|
const h = canvas.clientHeight || 320;
|
||||||
|
|
||||||
app = new Application({
|
app = new Application({
|
||||||
resizeTo: container,
|
view: canvas,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
||||||
backgroundAlpha: 0,
|
backgroundAlpha: 0,
|
||||||
@@ -60,8 +64,6 @@ export default function KiraAvatar(props: Props) {
|
|||||||
});
|
});
|
||||||
if (!mounted) { app.destroy(true); return; }
|
if (!mounted) { app.destroy(true); return; }
|
||||||
|
|
||||||
container.appendChild(app.view as HTMLCanvasElement);
|
|
||||||
|
|
||||||
model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
||||||
autoInteract: false,
|
autoInteract: false,
|
||||||
});
|
});
|
||||||
@@ -70,9 +72,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
const fit = () => {
|
const fit = () => {
|
||||||
const sw = app.screen.width;
|
const sw = app.screen.width;
|
||||||
const sh = app.screen.height;
|
const sh = app.screen.height;
|
||||||
// Scale to fit within the container, leaving margin so nothing clips
|
const s = Math.min((sw * 0.82) / model.width, (sh * 0.82) / model.height);
|
||||||
const margin = 0.9; // 90% of container
|
|
||||||
const s = Math.min((sw * margin) / model.width, (sh * margin) / 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);
|
model.position.set(sw / 2, sh / 2);
|
||||||
@@ -82,9 +82,6 @@ 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;
|
||||||
|
|
||||||
// Re-fit on resize
|
|
||||||
app.renderer.on('resize', fit);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
textureRef.current = {
|
textureRef.current = {
|
||||||
index: 2,
|
index: 2,
|
||||||
@@ -191,10 +188,14 @@ export default function KiraAvatar(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center w-full h-full overflow-hidden">
|
<div className="flex flex-col items-center w-full h-full overflow-hidden">
|
||||||
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
|
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
|
||||||
{/* Pixi canvas mounts here */}
|
{/* Live2D canvas */}
|
||||||
<div ref={wrapRef} className="w-full h-full" />
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full block"
|
||||||
|
style={{ opacity: live2dReady ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* SVG fallback when Live2D fails */}
|
{/* SVG fallback */}
|
||||||
{(!live2dReady || loadError) && (
|
{(!live2dReady || loadError) && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<AnimatedAvatar
|
<AnimatedAvatar
|
||||||
@@ -207,7 +208,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading spinner */}
|
{/* Loading */}
|
||||||
{!live2dReady && !loadError && (
|
{!live2dReady && !loadError && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||||
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
|
||||||
@@ -215,7 +216,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expression buttons overlay top */}
|
{/* Expression buttons */}
|
||||||
{live2dReady && (
|
{live2dReady && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none">
|
<div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none">
|
||||||
@@ -238,7 +239,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Talk button overlay bottom-center */}
|
{/* Talk button */}
|
||||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
|
||||||
<button
|
<button
|
||||||
onClick={props.onTalkToggle}
|
onClick={props.onTalkToggle}
|
||||||
@@ -253,7 +254,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status overlay bottom-right */}
|
{/* Status */}
|
||||||
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
|
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
|
||||||
<span className={`w-2 h-2 rounded-full ${
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
props.isSpeaking ? 'bg-kira-pink animate-pulse'
|
props.isSpeaking ? 'bg-kira-pink animate-pulse'
|
||||||
|
|||||||
Reference in New Issue
Block a user