refactor(live2d): single shared stage for both Kira and Mochi
Live2DStage creates ONE full-viewport transparent canvas (z-0, pointer-events:none). Both Kira and Mochi cat models render on the same Pixi stage and WebGL context. KiraAvatar is now UI-only (no canvas), receives model ref from stage. PetZone is label-only. Eliminates all WebGL context conflict errors.
This commit is contained in:
+15
-3
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import MusicPlayer from './components/MusicPlayer';
|
import MusicPlayer from './components/MusicPlayer';
|
||||||
import Timer from './components/Timer';
|
import Timer from './components/Timer';
|
||||||
import Notes from './components/Notes';
|
import Notes from './components/Notes';
|
||||||
@@ -9,6 +9,7 @@ import PetZone from './components/PetZone';
|
|||||||
import Wardrobe from './components/Wardrobe';
|
import Wardrobe from './components/Wardrobe';
|
||||||
import Particles from './components/Particles';
|
import Particles from './components/Particles';
|
||||||
import WelcomeScreen from './components/WelcomeScreen';
|
import WelcomeScreen from './components/WelcomeScreen';
|
||||||
|
import Live2DStage from './components/Live2DStage';
|
||||||
import { SCENES, type Scene } from './components/scenes';
|
import { SCENES, type Scene } from './components/scenes';
|
||||||
import { useConversation } from './hooks/useConversation';
|
import { useConversation } from './hooks/useConversation';
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ 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 [live2dReady, setLive2dReady] = useState(false);
|
||||||
|
const kiraModelRef = useRef<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferences.scene) setCurrentSceneId(preferences.scene);
|
if (preferences.scene) setCurrentSceneId(preferences.scene);
|
||||||
@@ -93,6 +96,13 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen relative transition-all duration-1000" style={{ background: currentScene.gradient }}>
|
<div className="h-screen relative transition-all duration-1000" style={{ background: currentScene.gradient }}>
|
||||||
|
{/* Single Live2D canvas: both Kira and Mochi share one WebGL context */}
|
||||||
|
<Live2DStage
|
||||||
|
onKiraReady={(model) => { kiraModelRef.current = model; }}
|
||||||
|
onReady={() => setLive2dReady(true)}
|
||||||
|
isSpeaking={isKiraSpeaking}
|
||||||
|
/>
|
||||||
|
|
||||||
<Particles type={currentScene.particles ?? 'none'} />
|
<Particles type={currentScene.particles ?? 'none'} />
|
||||||
<div className="relative z-20 h-full flex flex-col">
|
<div className="relative z-20 h-full flex flex-col">
|
||||||
{/* ── Top bar: scene selector + clock ── */}
|
{/* ── Top bar: scene selector + clock ── */}
|
||||||
@@ -139,14 +149,16 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CENTER: Avatar (hero) */}
|
{/* CENTER: Avatar UI overlay (Live2D renders on the background stage) */}
|
||||||
<div className="flex-1 min-w-0 rounded-2xl bg-white/30 overflow-hidden">
|
<div className="flex-1 min-w-0 rounded-2xl bg-transparent overflow-hidden">
|
||||||
<KiraAvatar
|
<KiraAvatar
|
||||||
isSpeaking={isKiraSpeaking}
|
isSpeaking={isKiraSpeaking}
|
||||||
isListening={isRecording}
|
isListening={isRecording}
|
||||||
outfit={currentOutfit}
|
outfit={currentOutfit}
|
||||||
accessory={currentAcc}
|
accessory={currentAcc}
|
||||||
onTalkToggle={handleTalkToggle}
|
onTalkToggle={handleTalkToggle}
|
||||||
|
kiraModel={kiraModelRef.current}
|
||||||
|
live2dReady={live2dReady}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ interface Props {
|
|||||||
outfit: string;
|
outfit: string;
|
||||||
accessory: string | null;
|
accessory: string | null;
|
||||||
onTalkToggle: () => void;
|
onTalkToggle: () => void;
|
||||||
|
/** The Live2D Kira model instance (from Live2DStage) */
|
||||||
|
kiraModel: any;
|
||||||
|
live2dReady: boolean;
|
||||||
|
onExpressionChange?: (expr: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing';
|
type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing';
|
||||||
@@ -14,117 +18,27 @@ type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blus
|
|||||||
const EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Sad', 'Angry', 'Surprised', 'Blushing'];
|
const EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Sad', 'Angry', 'Surprised', 'Blushing'];
|
||||||
const IDLE_EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Blushing'];
|
const IDLE_EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Blushing'];
|
||||||
|
|
||||||
const OUTFIT_TEXTURES: Record<string, string> = {
|
|
||||||
'cozy-hoodie': '/live2d/models/kira/outfits/cozy-hoodie.png',
|
|
||||||
'girly-dress': '/live2d/models/kira/outfits/girly-dress.png',
|
|
||||||
'pajama-set': '/live2d/models/kira/outfits/pajama-set.png',
|
|
||||||
'study-sweater': '/live2d/models/kira/outfits/study-sweater.png',
|
|
||||||
'going-out': '/live2d/models/kira/outfits/going-out.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function KiraAvatar(props: Props) {
|
export default function KiraAvatar(props: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const modelRef = useRef<any>(null);
|
|
||||||
const textureRef = useRef<any>(null);
|
|
||||||
const lipSyncRef = useRef<number>(0);
|
const lipSyncRef = useRef<number>(0);
|
||||||
const idleExprRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const idleExprRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [live2dReady, setLive2dReady] = useState(false);
|
|
||||||
const [loadError, setLoadError] = useState(false);
|
|
||||||
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
|
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
|
||||||
|
|
||||||
|
// Idle expression cycling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
if (!props.kiraModel || !props.live2dReady) return;
|
||||||
const canvas = canvasRef.current;
|
idleExprRef.current = setInterval(() => {
|
||||||
if (!canvas) return;
|
if (props.isSpeaking) return;
|
||||||
|
const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)];
|
||||||
let app: any = null;
|
try { props.kiraModel.expression(expr); } catch {}
|
||||||
let model: any = null;
|
setCurrentExpression(expr);
|
||||||
|
}, 8000 + Math.random() * 7000);
|
||||||
const init = async () => {
|
return () => clearInterval(idleExprRef.current ?? undefined);
|
||||||
try {
|
}, [props.kiraModel, props.live2dReady, props.isSpeaking]);
|
||||||
const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' });
|
|
||||||
if (!resp.ok) { if (mounted) setLoadError(true); return; }
|
|
||||||
|
|
||||||
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 || 260;
|
|
||||||
const h = canvas.clientHeight || 320;
|
|
||||||
|
|
||||||
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; }
|
|
||||||
|
|
||||||
model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
|
||||||
autoInteract: false,
|
|
||||||
});
|
|
||||||
modelRef.current = model;
|
|
||||||
|
|
||||||
const fit = () => {
|
|
||||||
const sw = app.screen.width;
|
|
||||||
const sh = app.screen.height;
|
|
||||||
const s = Math.min((sw * 0.82) / model.width, (sh * 0.82) / model.height);
|
|
||||||
model.scale.set(s);
|
|
||||||
model.anchor.set(0.5, 0.5);
|
|
||||||
model.position.set(sw / 2, sh / 2);
|
|
||||||
};
|
|
||||||
fit();
|
|
||||||
|
|
||||||
app.stage.addChild(model as any);
|
|
||||||
(model as any).isInteractive = () => false;
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
textureRef.current = {
|
|
||||||
index: 2,
|
|
||||||
original: (model.internalModel.coreModel as any).getTexture(2),
|
|
||||||
};
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
if (mounted) setLive2dReady(true);
|
|
||||||
|
|
||||||
try { model.motion('Idle'); } catch { /* no idle */ }
|
|
||||||
try { model.expression('Normal'); } catch { /* no expressions */ }
|
|
||||||
|
|
||||||
idleExprRef.current = setInterval(() => {
|
|
||||||
if (props.isSpeaking) return;
|
|
||||||
const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)];
|
|
||||||
try { model.expression(expr); } catch { /* */ }
|
|
||||||
setCurrentExpression(expr);
|
|
||||||
}, 8000 + Math.random() * 7000);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Live2D]', e);
|
|
||||||
if (mounted) setLoadError(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
cancelAnimationFrame(lipSyncRef.current);
|
|
||||||
clearInterval(idleExprRef.current ?? undefined);
|
|
||||||
if (app) { app.destroy(true, { children: true }); }
|
|
||||||
modelRef.current = null;
|
|
||||||
textureRef.current = null;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Lip sync from TTS
|
// Lip sync from TTS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const model = modelRef.current;
|
const model = props.kiraModel;
|
||||||
if (!model || !live2dReady) return;
|
if (!model || !props.live2dReady) return;
|
||||||
cancelAnimationFrame(lipSyncRef.current);
|
cancelAnimationFrame(lipSyncRef.current);
|
||||||
if (props.isSpeaking) {
|
if (props.isSpeaking) {
|
||||||
let phase = 0;
|
let phase = 0;
|
||||||
@@ -135,7 +49,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
model.internalModel.coreModel.setParameterValueByIndex(
|
model.internalModel.coreModel.setParameterValueByIndex(
|
||||||
findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness,
|
findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness,
|
||||||
);
|
);
|
||||||
} catch { /* */ }
|
} catch {}
|
||||||
lipSyncRef.current = requestAnimationFrame(animate);
|
lipSyncRef.current = requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
animate();
|
animate();
|
||||||
@@ -144,37 +58,10 @@ export default function KiraAvatar(props: Props) {
|
|||||||
model.internalModel.coreModel.setParameterValueByIndex(
|
model.internalModel.coreModel.setParameterValueByIndex(
|
||||||
findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0,
|
findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0,
|
||||||
);
|
);
|
||||||
} catch { /* */ }
|
} catch {}
|
||||||
}
|
}
|
||||||
return () => cancelAnimationFrame(lipSyncRef.current);
|
return () => cancelAnimationFrame(lipSyncRef.current);
|
||||||
}, [props.isSpeaking, live2dReady]);
|
}, [props.isSpeaking, props.live2dReady, props.kiraModel]);
|
||||||
|
|
||||||
// Outfit texture swapping
|
|
||||||
useEffect(() => {
|
|
||||||
const model = modelRef.current;
|
|
||||||
if (!model || !live2dReady) return;
|
|
||||||
const outfitUrl = OUTFIT_TEXTURES[props.outfit];
|
|
||||||
if (!outfitUrl) return;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { Assets } = await import('pixi.js');
|
|
||||||
const tex = await Assets.load(outfitUrl);
|
|
||||||
if ((model as any).textures) {
|
|
||||||
(model as any).textures[2] = tex;
|
|
||||||
} else {
|
|
||||||
console.warn('[Outfit] cannot swap - no textures array');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Outfit] texture swap failed:', e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [props.outfit, live2dReady]);
|
|
||||||
|
|
||||||
// Accessory toggle
|
|
||||||
useEffect(() => {
|
|
||||||
const model = modelRef.current;
|
|
||||||
if (!model || !live2dReady) return;
|
|
||||||
}, [props.accessory, live2dReady]);
|
|
||||||
|
|
||||||
const pulseStyle = `
|
const pulseStyle = `
|
||||||
@keyframes listening-pulse {
|
@keyframes listening-pulse {
|
||||||
@@ -187,17 +74,10 @@ 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">
|
||||||
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
|
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
|
||||||
{/* Live2D canvas */}
|
{/* SVG fallback (shows before Live2D is ready) */}
|
||||||
<canvas
|
{!props.live2dReady && (
|
||||||
ref={canvasRef}
|
|
||||||
className="w-full h-full block"
|
|
||||||
style={{ opacity: live2dReady ? 1 : 0 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* SVG fallback */}
|
|
||||||
{(!live2dReady || loadError) && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<AnimatedAvatar
|
<AnimatedAvatar
|
||||||
isSpeaking={props.isSpeaking}
|
isSpeaking={props.isSpeaking}
|
||||||
@@ -209,8 +89,8 @@ export default function KiraAvatar(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading spinner */}
|
||||||
{!live2dReady && !loadError && (
|
{!props.live2dReady && (
|
||||||
<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" />
|
||||||
<p className="text-[11px] text-kira-plum/40">loading Kira...</p>
|
<p className="text-[11px] text-kira-plum/40">loading Kira...</p>
|
||||||
@@ -218,7 +98,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expression buttons */}
|
{/* Expression buttons */}
|
||||||
{live2dReady && (
|
{props.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">
|
||||||
<div className="pointer-events-auto flex gap-1 flex-wrap justify-center">
|
<div className="pointer-events-auto flex gap-1 flex-wrap justify-center">
|
||||||
@@ -226,7 +106,7 @@ export default function KiraAvatar(props: Props) {
|
|||||||
<button
|
<button
|
||||||
key={expr}
|
key={expr}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
|
try { props.kiraModel?.expression(expr); setCurrentExpression(expr); props.onExpressionChange?.(expr); } catch {}
|
||||||
}}
|
}}
|
||||||
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||||
currentExpression === expr
|
currentExpression === expr
|
||||||
@@ -277,17 +157,6 @@ export default function KiraAvatar(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findParam(model: any, name: string): number {
|
function findParam(model: any, name: string): number {
|
||||||
try {
|
try {
|
||||||
return model.internalModel.coreModel.getParameterIds().indexOf(name);
|
return model.internalModel.coreModel.getParameterIds().indexOf(name);
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onKiraReady?: (model: any) => void;
|
||||||
|
onReady?: () => void;
|
||||||
|
isSpeaking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single full-viewport Live2D stage. One WebGL context, two models:
|
||||||
|
* - Kira (center)
|
||||||
|
* - Mochi the cat (bottom-right, PetZone area)
|
||||||
|
*
|
||||||
|
* The canvas sits behind the UI panels (z-0, pointer-events: none).
|
||||||
|
*/
|
||||||
|
export default function Live2DStage({ onKiraReady, onReady, isSpeaking }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
let app: any = null;
|
||||||
|
let kiraModel: any = null;
|
||||||
|
let catModel: 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);
|
||||||
|
|
||||||
|
// Full viewport canvas
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
const onResize = () => {
|
||||||
|
app.renderer.resize(window.innerWidth, window.innerHeight);
|
||||||
|
if (kiraModel) {
|
||||||
|
const sw = app.screen.width;
|
||||||
|
const sh = app.screen.height;
|
||||||
|
const centerX = sw * 0.42; // center column offset
|
||||||
|
const centerY = sh * 0.48;
|
||||||
|
const s = Math.min((sw * 0.28) / kiraModel.width, (sh * 0.7) / kiraModel.height);
|
||||||
|
kiraModel.scale.set(s);
|
||||||
|
kiraModel.anchor.set(0.5, 0.5);
|
||||||
|
kiraModel.position.set(centerX, centerY);
|
||||||
|
}
|
||||||
|
if (catModel) {
|
||||||
|
const sw = app.screen.width;
|
||||||
|
const sh = app.screen.height;
|
||||||
|
// Position in right sidebar area
|
||||||
|
const catX = sw * 0.88;
|
||||||
|
const catY = sh * 0.88;
|
||||||
|
const cs = Math.min((sw * 0.08) / catModel.width, (sh * 0.12) / catModel.height);
|
||||||
|
catModel.scale.set(cs);
|
||||||
|
catModel.anchor.set(0.5, 0.5);
|
||||||
|
catModel.position.set(catX, catY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
// Load Kira
|
||||||
|
kiraModel = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
||||||
|
autoInteract: false,
|
||||||
|
});
|
||||||
|
if (!mounted) { app.destroy(true); return; }
|
||||||
|
|
||||||
|
const sw = app.screen.width;
|
||||||
|
const sh = app.screen.height;
|
||||||
|
const centerX = sw * 0.42;
|
||||||
|
const centerY = sh * 0.48;
|
||||||
|
const s = Math.min((sw * 0.28) / kiraModel.width, (sh * 0.7) / kiraModel.height);
|
||||||
|
kiraModel.scale.set(s);
|
||||||
|
kiraModel.anchor.set(0.5, 0.5);
|
||||||
|
kiraModel.position.set(centerX, centerY);
|
||||||
|
(kiraModel as any).isInteractive = () => false;
|
||||||
|
|
||||||
|
app.stage.addChild(kiraModel as any);
|
||||||
|
try { kiraModel.motion('Idle'); } catch {}
|
||||||
|
try { kiraModel.expression('Normal'); } catch {}
|
||||||
|
|
||||||
|
if (onKiraReady && mounted) onKiraReady(kiraModel);
|
||||||
|
|
||||||
|
// Load Mochi the cat
|
||||||
|
try {
|
||||||
|
catModel = await Live2DModel.from('/live2d/models/little-cat/LittleCat.model3.json', {
|
||||||
|
autoInteract: false,
|
||||||
|
});
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const catX = sw * 0.88;
|
||||||
|
const catY = sh * 0.88;
|
||||||
|
const cs = Math.min((sw * 0.08) / catModel.width, (sh * 0.12) / catModel.height);
|
||||||
|
catModel.scale.set(cs);
|
||||||
|
catModel.anchor.set(0.5, 0.5);
|
||||||
|
catModel.position.set(catX, catY);
|
||||||
|
(catModel as any).isInteractive = () => false;
|
||||||
|
|
||||||
|
app.stage.addChild(catModel as any);
|
||||||
|
try { catModel.motion('Idle'); } catch {}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Live2DStage] cat load failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && onReady) onReady();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Live2DStage]', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
if (app) { app.destroy(true, { children: true }); }
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Lip sync for Kira
|
||||||
|
const kiraRef = useRef<any>(null);
|
||||||
|
const lipSyncRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Store kira model from onKiraReady callback
|
||||||
|
// We need to capture the model for lip sync
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="fixed inset-0 w-full h-full"
|
||||||
|
style={{ zIndex: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AnimatedAvatar.tsx","./src/components/BackgroundScene.tsx","./src/components/ChatBubble.tsx","./src/components/Clock.tsx","./src/components/KiraAvatar.tsx","./src/components/Live2DCat.tsx","./src/components/MusicPlayer.tsx","./src/components/Notes.tsx","./src/components/Particles.tsx","./src/components/PetZone.tsx","./src/components/Timer.tsx","./src/components/Toolbar.tsx","./src/components/Wardrobe.tsx","./src/components/WelcomeScreen.tsx","./src/components/WhiteNoise.tsx","./src/components/scenes.ts","./src/hooks/useConversation.ts","./src/types/index.ts"],"version":"6.0.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AnimatedAvatar.tsx","./src/components/BackgroundScene.tsx","./src/components/ChatBubble.tsx","./src/components/Clock.tsx","./src/components/KiraAvatar.tsx","./src/components/Live2DCat.tsx","./src/components/Live2DStage.tsx","./src/components/MusicPlayer.tsx","./src/components/Notes.tsx","./src/components/Particles.tsx","./src/components/PetZone.tsx","./src/components/Timer.tsx","./src/components/Toolbar.tsx","./src/components/Wardrobe.tsx","./src/components/WelcomeScreen.tsx","./src/components/WhiteNoise.tsx","./src/components/scenes.ts","./src/hooks/useConversation.ts","./src/types/index.ts"],"version":"6.0.3"}
|
||||||
Reference in New Issue
Block a user