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.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type ActivityEntry = { id: number; name: string; icon: string; date: number }
|
||||
|
||||
/**
|
||||
* Navegador de logros del personaje: búsqueda en tiempo real (por nombre o ID) y
|
||||
* paginación (anterior/siguiente + números). Los datos se piden a /api/armory/activity;
|
||||
* la primera página llega ya renderizada del servidor.
|
||||
*/
|
||||
export function ArmoryActivity({
|
||||
guid,
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
guid: number
|
||||
locale: string
|
||||
initial: ActivityEntry[]
|
||||
total: number
|
||||
pageSize: number
|
||||
labels: { earned: string; search: string; empty: string }
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [q, setQ] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, query: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/armory/activity?guid=${guid}&page=${p}&q=${encodeURIComponent(query)}&locale=${locale}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Búsqueda en tiempo real (con retardo para no consultar en cada tecla).
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, q)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, q)
|
||||
}
|
||||
|
||||
const fmt = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, page - win)
|
||||
const to = Math.min(pages, page + win)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
return (
|
||||
<>
|
||||
<WowheadRefresh dep={`${page}-${q}-${entries.length}`} />
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={labels.search}
|
||||
aria-label={labels.search}
|
||||
/>
|
||||
<div className={`armory-activity${loading ? ' loading' : ''}`}>
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
entries.map((a, i) => (
|
||||
<div className="armory-activity-row" key={`${a.id}-${a.date}-${i}`}>
|
||||
<span
|
||||
className="armory-activity-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(a.icon, 'small')})` }}
|
||||
/>
|
||||
<span className="armory-activity-text">
|
||||
{labels.earned}{' '}
|
||||
<a
|
||||
className="armory-activity-link"
|
||||
href={wowheadUrl('achievement', a.id, locale)}
|
||||
data-wowhead={wowheadData('achievement', a.id, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
[{a.name}]
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<span className="armory-activity-date">{fmt(a.date)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => goto(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((p) => (
|
||||
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => goto(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,25 +3,61 @@
|
||||
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.
|
||||
* Visor 3D del personaje (ZamModelViewer de wowhead, servido bajo /modelviewer/*
|
||||
* como proxy same-origin para evitar CORS).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// 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 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 CONTENT_PATH = '/modelviewer/live/'
|
||||
const MODEL_TYPE_CHARACTER = 16
|
||||
|
||||
type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string }
|
||||
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) => {
|
||||
@@ -35,15 +71,57 @@ function loadScript(src: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
equip: { slot: number; entry: number; displayid: 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)
|
||||
@@ -51,33 +129,45 @@ export function ArmoryModel3D({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const failTimer = setTimeout(() => !cancelled && setState((s) => (s === 'loading' ? 'fail' : s)), 12000)
|
||||
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)
|
||||
// La librería es ESM y usa jQuery/ZamModelViewer globales.
|
||||
const mv = await import('wow-model-viewer')
|
||||
const options = await remapPromise
|
||||
ensureWH()
|
||||
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
|
||||
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}`
|
||||
await mv.generateModels(1, `#${ref.current.id}`, { race, gender, items }, 'live')
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArmoryModel3D } from './ArmoryModel3D'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { slotLabel, PAPERDOLL_LEFT, PAPERDOLL_RIGHT, PAPERDOLL_WEAPONS } from '@/lib/wow-data'
|
||||
import { wowheadUrl, wowheadData, wowheadIcon } from '@/lib/wowhead'
|
||||
|
||||
export type SheetItem = {
|
||||
slot: number
|
||||
entry: number
|
||||
name: string
|
||||
quality: number
|
||||
itemLevel: number
|
||||
inventoryType: number
|
||||
displayId: number | null
|
||||
transmogDisplayId: number | null
|
||||
ench: number
|
||||
gems: number[]
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Paperdoll interactivo de la ficha: iconos de equipo alrededor del modelo 3D, con
|
||||
* conmutador modelo/lista y toggles MH/OH para mostrar u ocultar las armas en el
|
||||
* modelo. Cliente porque maneja estado; los datos llegan ya resueltos del servidor.
|
||||
*/
|
||||
export function ArmoryPaperdoll({
|
||||
race,
|
||||
gender,
|
||||
customizations,
|
||||
items,
|
||||
health,
|
||||
power,
|
||||
powerLabel,
|
||||
powerColor,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
race: number
|
||||
gender: number
|
||||
customizations: { optionId: number; choiceId: number }[]
|
||||
items: SheetItem[]
|
||||
health: number
|
||||
power: number
|
||||
powerLabel: string
|
||||
powerColor: string
|
||||
locale: string
|
||||
labels: { loading: string; unavailable: string; health: string; itemLevel: string; model: string; list: string }
|
||||
}) {
|
||||
const [view, setView] = useState<'model' | 'list'>('model')
|
||||
const [mh, setMh] = useState(true)
|
||||
const [oh, setOh] = useState(true)
|
||||
|
||||
const bySlot = new Map(items.map((i) => [i.slot, i]))
|
||||
|
||||
const HIDDEN = new Set([1, 10, 11, 12, 13])
|
||||
const viewerSlot = (s: number, inv: number): number => {
|
||||
if (s === 15) return 21
|
||||
if (s === 16) return inv === 23 ? 23 : 22
|
||||
if (s === 17) return inv === 15 || inv === 26 ? inv : 0
|
||||
return inv
|
||||
}
|
||||
const modelEquip = items
|
||||
.filter((e) => !HIDDEN.has(e.slot))
|
||||
.filter((e) => (e.slot === 15 ? mh : e.slot === 16 ? oh : true))
|
||||
.map((e) => ({ slot: viewerSlot(e.slot, e.inventoryType), display: e.transmogDisplayId ?? e.displayId ?? 0 }))
|
||||
.filter((e) => e.slot > 0 && e.display > 0)
|
||||
|
||||
const Tile = ({ slot }: { slot: number }) => {
|
||||
const it = bySlot.get(slot)
|
||||
const label = slotLabel(slot, locale)
|
||||
if (!it) return <div className="armory-slot empty" title={label} />
|
||||
return (
|
||||
<a
|
||||
className={`armory-slot q${it.quality}`}
|
||||
href={wowheadUrl('item', it.entry, locale)}
|
||||
data-wowhead={wowheadData('item', it.entry, locale, { ench: it.ench, gems: it.gems })}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={it.name}
|
||||
>
|
||||
{it.icon ? (
|
||||
<span className="armory-slot-icon" style={{ backgroundImage: `url(${wowheadIcon(it.icon, 'large')})` }} />
|
||||
) : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const ListRow = ({ item }: { item: SheetItem }) => (
|
||||
<a
|
||||
className="armory-listrow"
|
||||
href={wowheadUrl('item', item.entry, locale)}
|
||||
data-wowhead={wowheadData('item', item.entry, locale, { ench: item.ench, gems: item.gems })}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className={`armory-slot small q${item.quality}`}>
|
||||
{item.icon ? (
|
||||
<span className="armory-slot-icon" style={{ backgroundImage: `url(${wowheadIcon(item.icon, 'small')})` }} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="armory-listrow-info">
|
||||
<span className={`armory-cell-name q${item.quality}`}>{item.name}</span>
|
||||
<span className="armory-cell-slot">{slotLabel(item.slot, locale)}</span>
|
||||
</span>
|
||||
{item.itemLevel ? <span className="armory-cell-ilvl">{item.itemLevel}</span> : null}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="armory-doll-wrap">
|
||||
<WowheadRefresh dep={`${view}-${mh}-${oh}`} />
|
||||
|
||||
<div className="armory-toolbar">
|
||||
<button className={`armory-toggle ${mh ? 'on' : 'off'}`} onClick={() => setMh((v) => !v)} type="button">
|
||||
MH:{mh ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<button className={`armory-toggle ${oh ? 'on' : 'off'}`} onClick={() => setOh((v) => !v)} type="button">
|
||||
OH:{oh ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<span className="armory-view-switch">
|
||||
<button className={view === 'model' ? 'active' : ''} onClick={() => setView('model')} type="button" aria-label={labels.model}>
|
||||
<i className="fas fa-user" />
|
||||
</button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} type="button" aria-label={labels.list}>
|
||||
<i className="fas fa-th-list" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view === 'model' ? (
|
||||
<div className="armory-paperdoll">
|
||||
<div className="armory-slots-col">
|
||||
{PAPERDOLL_LEFT.map((s) => (
|
||||
<Tile key={s} slot={s} />
|
||||
))}
|
||||
</div>
|
||||
<div className="armory-center">
|
||||
<div className="armory-model-wrap">
|
||||
<ArmoryModel3D
|
||||
key={`${mh}-${oh}`}
|
||||
race={race}
|
||||
gender={gender}
|
||||
equip={modelEquip}
|
||||
customizations={customizations}
|
||||
labels={{ loading: labels.loading, unavailable: labels.unavailable }}
|
||||
/>
|
||||
</div>
|
||||
<div className="armory-weapons">
|
||||
{PAPERDOLL_WEAPONS.map((s) => (
|
||||
<Tile key={s} slot={s} />
|
||||
))}
|
||||
</div>
|
||||
<div className="armory-bars">
|
||||
<span className="armory-bar health">
|
||||
{labels.health}: <b>{health.toLocaleString(locale)}</b>
|
||||
</span>
|
||||
<span className="armory-bar power">
|
||||
{powerLabel}: <b style={{ color: powerColor }}>{power.toLocaleString(locale)}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="armory-slots-col">
|
||||
{PAPERDOLL_RIGHT.map((s) => (
|
||||
<Tile key={s} slot={s} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="armory-listview">
|
||||
{[...PAPERDOLL_LEFT, ...PAPERDOLL_RIGHT, ...PAPERDOLL_WEAPONS]
|
||||
.map((s) => bySlot.get(s))
|
||||
.filter((it): it is SheetItem => !!it)
|
||||
.map((it) => (
|
||||
<ListRow key={it.slot} item={it} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { wowheadUrl, wowheadData, wowheadIcon } from '@/lib/wowhead'
|
||||
|
||||
export type TalentCell = { tier: number; col: number; rank: number; max: number; spell: number }
|
||||
export type TalentTreeData = { index: number; name: string; points: number; talents: TalentCell[] }
|
||||
|
||||
/**
|
||||
* Árbol de talentos estilo calculador: 3 árboles, cada uno una rejilla de casillas
|
||||
* posicionadas por fila (tier) y columna. Cada casilla muestra el icono del talento
|
||||
* (del hechizo de rango 1) y su rango actual/máximo; las no aprendidas van atenuadas.
|
||||
* El icono va en un <span> hijo porque wowhead sobrescribe el background del <a>.
|
||||
*/
|
||||
export function ArmoryTalents({
|
||||
trees,
|
||||
spec,
|
||||
total,
|
||||
maxPoints,
|
||||
locale,
|
||||
spellIcons,
|
||||
}: {
|
||||
trees: TalentTreeData[]
|
||||
spec: string | null
|
||||
total: number
|
||||
maxPoints: number
|
||||
locale: string
|
||||
spellIcons: Record<number, string>
|
||||
}) {
|
||||
return (
|
||||
<div className="armory-talents">
|
||||
<div className="armory-spec">
|
||||
<span className="armory-spec-name">{spec}</span>
|
||||
<span className="armory-spec-dist">{trees.map((tr) => tr.points).join(' / ')}</span>
|
||||
<span className="armory-spec-total">
|
||||
{total} / {maxPoints}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-talent-trees">
|
||||
{trees.map((tree) => {
|
||||
const maxTier = tree.talents.reduce((m, t) => Math.max(m, t.tier), 0)
|
||||
return (
|
||||
<div className="armory-talent-tree" key={tree.index}>
|
||||
<div className="armory-talent-tree-head">
|
||||
<span>{tree.name}</span>
|
||||
<span className="armory-talent-tree-pts">{tree.points}</span>
|
||||
</div>
|
||||
<div className="armory-talent-grid" style={{ gridTemplateRows: `repeat(${maxTier + 1}, 44px)` }}>
|
||||
{tree.talents.map((tal) => {
|
||||
const icon = spellIcons[tal.spell]
|
||||
const state = tal.rank === 0 ? 'off' : tal.rank >= tal.max ? 'max' : 'on'
|
||||
return (
|
||||
<a
|
||||
key={`${tal.tier}-${tal.col}`}
|
||||
className={`armory-talent ${state}`}
|
||||
style={{ gridRow: tal.tier + 1, gridColumn: tal.col + 1 }}
|
||||
href={wowheadUrl('spell', tal.spell, locale)}
|
||||
data-wowhead={wowheadData('spell', tal.spell, locale)}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
className="armory-talent-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(icon, 'medium')})` }}
|
||||
/>
|
||||
) : null}
|
||||
{tal.rank > 0 ? (
|
||||
<span className="armory-talent-rank">
|
||||
{tal.rank}/{tal.max}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user