Files
NightSpire/web-next/components/ArmoryModel3D.tsx
T
Inna 3af7f5e804 Armería: muñeco de papel, talentos, actividad y caché del visor 3D
La ficha de personaje pasa a mostrar el equipo sobre el paperdoll (con
encantamientos y gemas en los tooltips de wowhead), el árbol de talentos y
la actividad reciente del personaje.

El proxy de /modelviewer/* deja de ser un rewrite de Next y pasa a un route
handler que cachea en disco los assets de zamimg, para no repetir la descarga
en cada visita.
2026-07-16 21:07:39 +00:00

200 lines
7.4 KiB
TypeScript

'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<string, unknown> }
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<void> {
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<number, Option> {
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<HTMLDivElement>(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 (
<div className="armory-model">
<div ref={ref} className="armory-model-canvas" />
{state === 'loading' && <p className="armory-hint">{labels.loading}</p>}
{state === 'fail' && <p className="armory-hint">{labels.unavailable}</p>}
</div>
)
}