'use client' import { useEffect, useRef, useState } from 'react' /** * Visor 3D del personaje. Usa la librería `wow-model-viewer` (generateModels), que a * su vez necesita jQuery y el ZamModelViewer de wowhead (wow.zamimg.com) cargados en * global. Se cargan bajo demanda solo en esta ficha. * * OJO: es integración cliente que depende de un CDN externo (zamimg) y no se puede * verificar sin navegador; por eso todo va envuelto en try/catch y con un temporizador * de reserva: si el modelo no aparece, se muestra un aviso en lugar de romper la ficha. * * El equipo se pasa como {slot, entry, displayid}; la librería mapea cada ítem a su * slot del visor. displayid = transmog si lo hay, si no el del ítem equipado. */ // Same-origin (proxy /modelviewer/* -> zamimg en next.config): evita el bloqueo CORS // del visor, que es lo que dejaba el modelo «no disponible». const ZAM_VIEWER = '/modelviewer/live/viewer/viewer.min.js' const JQUERY = 'https://code.jquery.com/jquery-3.7.1.min.js' const CONTENT_PATH = '/modelviewer/live/' type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string } /** * Garantiza que window.WH está completo. El viewer.min.js de zamimg crea un WH * propio SIN `debug`, y entonces el setup.js de la librería (que hace `if (!WH)`) * se lo salta y nunca define WH.debug → el visor casca con «WH.debug is not a * function». Aquí se rellena cada propiedad que falte, con fallback, tras cargar * los scripts y antes de generar el modelo. */ 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) }) } export function ArmoryModel3D({ race, gender, equip, labels, }: { race: number gender: number equip: { slot: number; entry: number; displayid: 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)), 12000) ;(async () => { try { const w = window as Win w.CONTENT_PATH = CONTENT_PATH await loadScript(JQUERY) await loadScript(ZAM_VIEWER) // La librería es ESM y usa jQuery/ZamModelViewer globales. const mv = await import('wow-model-viewer') ensureWH() // completar WH.debug/WebP/Wow tras cargar viewer.min.js + setup.js if (cancelled || !ref.current) return // La resolución de ítems hace consultas al CDN y puede fallar; si falla, se // renderiza al menos el personaje sin equipo en vez de dejar la ficha vacía. let items: unknown[] = [] try { items = await mv.findItemsInEquipments( equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })), 'live', ) } catch (err) { console.warn('[armory-3d] no se pudieron resolver los ítems del modelo:', err) } if (cancelled || !ref.current) return ref.current.id = ref.current.id || `armory-model-${race}-${gender}` await mv.generateModels(1, `#${ref.current.id}`, { race, gender, items }, 'live') 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}

}
) }