feat(wardrobe): swap Live2D outfit textures via wardrobe buttons

Epsilon model has 3 texture sheets: body (00), hair (01), clothes (02).
Outfit PNGs in /outfits/ replace texture_02 at runtime via PIXI BaseTexture swap.
Live2DStage now accepts outfit prop and swaps on change.
App passes currentOutfit to Live2DStage.
This commit is contained in:
2026-06-05 15:29:20 -04:00
parent 8a50fef24b
commit a3b5477524
2 changed files with 57 additions and 1 deletions
+1
View File
@@ -110,6 +110,7 @@ export default function App() {
onKiraReady={(model) => { kiraModelRef.current = model; }} onKiraReady={(model) => { kiraModelRef.current = model; }}
onReady={() => setLive2dReady(true)} onReady={() => setLive2dReady(true)}
isSpeaking={isKiraSpeaking} isSpeaking={isKiraSpeaking}
outfit={currentOutfit}
/> />
<Particles type={currentScene.particles ?? 'none'} /> <Particles type={currentScene.particles ?? 'none'} />
+56 -1
View File
@@ -4,6 +4,7 @@ interface Props {
onKiraReady?: (model: any) => void; onKiraReady?: (model: any) => void;
onReady?: () => void; onReady?: () => void;
isSpeaking?: boolean; isSpeaking?: boolean;
outfit?: string;
} }
/** /**
@@ -46,8 +47,10 @@ function positionCat(cat: any) {
cat.position.set(r.left + r.width / 2, r.top + r.height / 2 + 10); cat.position.set(r.left + r.width / 2, r.top + r.height / 2 + 10);
} }
export default function Live2DStage({ onKiraReady, onReady }: Props) { export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const kiraModelRef = useRef<any>(null);
const clothTexIdxRef = useRef<number>(-1);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -96,6 +99,22 @@ export default function Live2DStage({ onKiraReady, onReady }: Props) {
(kiraModel as any).isInteractive = () => false; (kiraModel as any).isInteractive = () => false;
app.stage.addChild(kiraModel as any); app.stage.addChild(kiraModel as any);
kiraModelRef.current = kiraModel;
// Find the clothing texture index (texture_02 = the outfit sheet)
try {
const core = (kiraModel as any).internalModel?.coreModel;
if (core) {
const drawCount = core.getDrawableCount();
// texture_02 is the last texture, find which drawable uses it
// We'll swap the PIXI texture directly on the model's textures array
const textures = (kiraModel as any).internalModel?.textures;
if (textures && textures.length >= 3) {
clothTexIdxRef.current = 2; // texture_02 is the outfit
}
}
} catch {}
try { kiraModel.motion('Idle'); } catch {} try { kiraModel.motion('Idle'); } catch {}
try { kiraModel.expression('Normal'); } catch {} try { kiraModel.expression('Normal'); } catch {}
@@ -135,6 +154,42 @@ export default function Live2DStage({ onKiraReady, onReady }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Outfit swap effect
useEffect(() => {
const model = kiraModelRef.current;
const texIdx = clothTexIdxRef.current;
if (!model || texIdx < 0 || !outfit) return;
const outfitSrc = `/live2d/models/kira/outfits/${outfit}.png`;
const swapTexture = async () => {
try {
const textures = (model as any).internalModel?.textures;
if (!textures || !textures[texIdx]) return;
const PIXI = await import('pixi.js');
const newBase = PIXI.BaseTexture.from(outfitSrc);
const doSwap = () => {
if (newBase.valid) {
textures[texIdx].baseTexture = newBase;
textures[texIdx].update();
}
};
if (newBase.valid) {
doSwap();
} else {
newBase.once('loaded', doSwap);
}
} catch (e) {
console.warn('[Live2DStage] outfit swap failed:', e);
}
};
swapTexture();
}, [outfit]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}