'use client' import { useEffect, useRef, useState } from 'react' /** * Visor 3D del personaje (ZamModelViewer de wowhead, servido bajo /modelviewer/* * como proxy same-origin para evitar CORS). * * Problema de fondo (3.4.3 sobre CDN de wowhead): * - La rama `wrath` SÍ tiene los IDs de personalización de WotLK que guarda * AzerothCore, pero NO publica la geometría de los modelos (los .mo3 dan 404). * - La rama `live` (retail) SÍ tiene geometría y texturas, pero sus choiceID son * los de retail actual, distintos a los de WotLK → la piel salía negra. * * Solución: renderizar con geometría de `live` y REMAPEAR cada choiceID de la BD * (WotLK) a su equivalente retail por POSICIÓN dentro de la opción, usando el orden * de choices de `wrath` como referencia. Así la piel/cara/pelo se ven correctos. */ const GEO_BRANCH = 'live' // geometría + texturas (wrath no publica modelos) const CONTENT_PATH = `/modelviewer/${GEO_BRANCH}/` const ZAM_VIEWER = `/modelviewer/${GEO_BRANCH}/viewer/viewer.min.js` const JQUERY = 'https://code.jquery.com/jquery-3.7.1.min.js' const MODEL_TYPE_CHARACTER = 16 type Choice = { Id: number } type Option = { Id: number; Name?: string; Choices: Choice[] } type CustomizationDoc = { Options?: Option[]; data?: { Options?: Option[] } } type Win = typeof window & { jQuery?: (sel: string) => unknown ZamModelViewer?: new (opts: unknown) => unknown CONTENT_PATH?: string WOTLK_TO_RETAIL_DISPLAY_ID_API?: unknown } function ensureWH() { const w = window as unknown as { WH?: Record } const WH = (w.WH = w.WH || {}) if (typeof WH.debug !== 'function') WH.debug = () => {} if (!WH.defaultAnimation) WH.defaultAnimation = 'Stand' if (!WH.WebP) WH.WebP = { getImageExtension: () => '.webp' } if (!WH.Wow) { WH.Wow = { Item: { INVENTORY_TYPE_HEAD: 1, INVENTORY_TYPE_NECK: 2, INVENTORY_TYPE_SHOULDERS: 3, INVENTORY_TYPE_SHIRT: 4, INVENTORY_TYPE_CHEST: 5, INVENTORY_TYPE_WAIST: 6, INVENTORY_TYPE_LEGS: 7, INVENTORY_TYPE_FEET: 8, INVENTORY_TYPE_WRISTS: 9, INVENTORY_TYPE_HANDS: 10, INVENTORY_TYPE_FINGER: 11, INVENTORY_TYPE_TRINKET: 12, INVENTORY_TYPE_ONE_HAND: 13, INVENTORY_TYPE_SHIELD: 14, INVENTORY_TYPE_RANGED: 15, INVENTORY_TYPE_BACK: 16, INVENTORY_TYPE_TWO_HAND: 17, INVENTORY_TYPE_BAG: 18, INVENTORY_TYPE_TABARD: 19, INVENTORY_TYPE_ROBE: 20, INVENTORY_TYPE_MAIN_HAND: 21, INVENTORY_TYPE_OFF_HAND: 22, INVENTORY_TYPE_HELD_IN_OFF_HAND: 23, INVENTORY_TYPE_PROJECTILE: 24, INVENTORY_TYPE_THROWN: 25, INVENTORY_TYPE_RANGED_RIGHT: 26, INVENTORY_TYPE_QUIVER: 27, INVENTORY_TYPE_RELIC: 28, INVENTORY_TYPE_PROFESSION_TOOL: 29, INVENTORY_TYPE_PROFESSION_ACCESSORY: 30, }, } } } function loadScript(src: string): Promise { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) return resolve() const s = document.createElement('script') s.src = src s.async = true s.onload = () => resolve() s.onerror = () => reject(new Error(`no se pudo cargar ${src}`)) document.head.appendChild(s) }) } function optionsById(doc: CustomizationDoc): Map { const list = doc.Options ?? doc.data?.Options ?? [] return new Map(list.map((o) => [o.Id, o])) } /** * Remapea los choiceID de WotLK (de la BD) a choiceID de retail por índice. * Usa el orden de choices de `wrath` (WotLK) para hallar la posición, y el de `live` * (retail) para obtener el ID equivalente. Si algo falla, devuelve lo que pueda. */ async function remapCustomizations( raceGender: number, db: { optionId: number; choiceId: number }[], ): Promise<{ optionId: number; choiceId: number }[]> { try { const [wrathDoc, liveDoc] = await Promise.all([ fetch(`/modelviewer/wrath/meta/charactercustomization/${raceGender}.json`).then((r) => r.json()), fetch(`/modelviewer/live/meta/charactercustomization/${raceGender}.json`).then((r) => r.json()), ]) const W = optionsById(wrathDoc as CustomizationDoc) const L = optionsById(liveDoc as CustomizationDoc) const out: { optionId: number; choiceId: number }[] = [] for (const { optionId, choiceId } of db) { const wo = W.get(optionId) const lo = L.get(optionId) if (!wo || !lo) continue const idx = wo.Choices.findIndex((c) => c.Id === choiceId) if (idx < 0) continue const lc = lo.Choices[idx] ?? lo.Choices[lo.Choices.length - 1] if (lc) out.push({ optionId, choiceId: lc.Id }) } return out } catch (err) { console.warn('[armory-3d] no se pudo remapear la apariencia:', err) return [] } } export function ArmoryModel3D({ race, gender, equip, customizations, labels, }: { race: number gender: number /** Equipo ya resuelto a slots del visor: [slotVisor, displayId]. */ equip: { slot: number; display: number }[] /** Apariencia desde character_customizations (IDs de WotLK): {optionId, choiceId}. */ customizations: { optionId: number; choiceId: number }[] labels: { loading: string; unavailable: string } }) { const ref = useRef(null) const [state, setState] = useState<'loading' | 'ok' | 'fail'>('loading') useEffect(() => { let cancelled = false const failTimer = setTimeout(() => !cancelled && setState((s) => (s === 'loading' ? 'fail' : s)), 15000) ;(async () => { try { const w = window as Win w.CONTENT_PATH = CONTENT_PATH w.WOTLK_TO_RETAIL_DISPLAY_ID_API = false const raceGender = race * 2 - 1 + gender // El remapeo (peticiones fetch) puede ir en paralelo, pero jQuery debe cargar // ANTES que viewer.min.js (que lo usa como global), así que ese orden es estricto. const remapPromise = remapCustomizations(raceGender, customizations) await loadScript(JQUERY) await loadScript(ZAM_VIEWER) const options = await remapPromise ensureWH() if (cancelled || !ref.current) return const jQuery = w.jQuery const ZamModelViewer = w.ZamModelViewer if (!jQuery || !ZamModelViewer) throw new Error('ZamModelViewer/jQuery no disponibles') ref.current.id = ref.current.id || `armory-model-${race}-${gender}` const items = equip.filter((e) => e.display > 0).map((e) => [e.slot, e.display]) const models = { type: 2, contentPath: CONTENT_PATH, container: jQuery(`#${ref.current.id}`), aspect: 1, hd: true, items, models: { id: raceGender, type: MODEL_TYPE_CHARACTER }, charCustomization: { options }, } // eslint-disable-next-line new-cap new ZamModelViewer(models) if (!cancelled) { clearTimeout(failTimer) setState('ok') } } catch (err) { console.error('[armory-3d] no se pudo cargar el visor 3D:', err) if (!cancelled) { clearTimeout(failTimer) setState('fail') } } })() return () => { cancelled = true clearTimeout(failTimer) } // solo al montar / cambiar de personaje // eslint-disable-next-line react-hooks/exhaustive-deps }, [race, gender]) return (
{state === 'loading' &&

{labels.loading}

} {state === 'fail' &&

{labels.unavailable}

}
) }