Armería/Armory: buscador, ficha de personaje con equipo y visor 3D
Nueva sección Armería (menú Jugadores del header, pública). Migra la idea del módulo armory de FusionCMS a Next, ajustada al diseño del sitio y en ES/EN. DATOS (verificado antes de construir): item_template no existe en 3.4.3, pero los datos de ítem ya están en MySQL (django_wow.item_data, 44.873 filas con nombre es/en, calidad, item_level, inventory_type y display_id). El equipo del personaje sale de acore_characters (character_inventory + item_instance, con transmog) y se resuelve contra item_data en dos pasos (sin joins entre esquemas). lib/armory.ts + lib/wow-data.ts (razas/clases/slots ES/EN y colores de clase). RUTAS: - /armory: buscador con pestañas personajes/ítems/hermandades (SSR por searchParams), ítems con tooltip/color/icono de wowhead. - /armory/character/[guid]: identidad, equipo (tooltips wowhead, color por calidad) y visor 3D. - /armory/guild/[guid]: miembros con enlace a su ficha. VISOR 3D (components/ArmoryModel3D): usa la librería wow-model-viewer (generateModels) con jQuery + ZamModelViewer de wowhead cargados bajo demanda, alimentado con raza+género+display_ids del equipo (transmog si lo hay). Es integración cliente que depende del CDN de zamimg y NO se puede verificar sin navegador: va todo en try/catch con temporizador de reserva y aviso si no carga, para no romper la ficha. Verificado en producción (salvo el render 3D): buscador, ficha con equipo real («Camisa de acólito»/«Acolyte's Shirt»), tooltips de wowhead y página de hermandad, en ES y EN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
'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.
|
||||
*/
|
||||
|
||||
const ZAM_VIEWER = 'https://wow.zamimg.com/modelviewer/live/viewer/viewer.min.js'
|
||||
const JQUERY = 'https://code.jquery.com/jquery-3.7.1.min.js'
|
||||
const CONTENT_PATH = 'https://wow.zamimg.com/modelviewer/live/'
|
||||
|
||||
type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string }
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(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')
|
||||
if (cancelled || !ref.current) return
|
||||
|
||||
const items = await mv.findItemsInEquipments(
|
||||
equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })),
|
||||
'live',
|
||||
)
|
||||
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 {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
/** Caja de búsqueda de la armería: navega a /armory?tab=&q=. */
|
||||
export function ArmorySearchBox({ tab, initialQuery }: { tab: string; initialQuery: string }) {
|
||||
const t = useTranslations('Armory')
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState(initialQuery)
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const query = q.trim()
|
||||
if (query.length < 3) return
|
||||
router.push(`/armory?tab=${tab}&q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="armory-search">
|
||||
<input
|
||||
className="forum-input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
maxLength={100}
|
||||
/>
|
||||
<button type="submit" className="nice_button">
|
||||
{t('search')}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,9 @@ export function SiteHeader({
|
||||
<Link href={playersHref}>{t('header.players')}</Link>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<Link href="/armory">{t('header.armory')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user