From a3b5477524debb89f7cb0110220efdf45b4b7e12 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 5 Jun 2026 15:29:20 -0400 Subject: [PATCH] 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. --- frontend/src/App.tsx | 1 + frontend/src/components/Live2DStage.tsx | 57 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a073cdb..4518cec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -110,6 +110,7 @@ export default function App() { onKiraReady={(model) => { kiraModelRef.current = model; }} onReady={() => setLive2dReady(true)} isSpeaking={isKiraSpeaking} + outfit={currentOutfit} /> diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index a985969..1546738 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -4,6 +4,7 @@ interface Props { onKiraReady?: (model: any) => void; onReady?: () => void; 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); } -export default function Live2DStage({ onKiraReady, onReady }: Props) { +export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { const canvasRef = useRef(null); + const kiraModelRef = useRef(null); + const clothTexIdxRef = useRef(-1); useEffect(() => { let mounted = true; @@ -96,6 +99,22 @@ export default function Live2DStage({ onKiraReady, onReady }: Props) { (kiraModel as any).isInteractive = () => false; 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.expression('Normal'); } catch {} @@ -135,6 +154,42 @@ export default function Live2DStage({ onKiraReady, onReady }: Props) { // 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 (