Files
NightSpire/web-next/components/ArmoryPaperdoll.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

187 lines
6.3 KiB
TypeScript

'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>
)
}