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,223 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
// Armería: búsqueda de personajes/ítems/hermandades y ficha de personaje con equipo.
|
||||
// Datos de personaje/equipo en acore_characters; datos de ítem (nombre/calidad/
|
||||
// display_id) en django_wow.item_data (importado de los db2; item_template no existe
|
||||
// en 3.4.3). Se resuelve en dos pasos para no depender de joins entre esquemas.
|
||||
|
||||
export interface CharacterResult {
|
||||
guid: number
|
||||
name: string
|
||||
race: number
|
||||
gender: number
|
||||
class: number
|
||||
level: number
|
||||
}
|
||||
|
||||
export interface ItemResult {
|
||||
entry: number
|
||||
name: string
|
||||
quality: number
|
||||
itemLevel: number
|
||||
inventoryType: number
|
||||
}
|
||||
|
||||
export interface GuildResult {
|
||||
guildid: number
|
||||
name: string
|
||||
members: number
|
||||
}
|
||||
|
||||
export interface EquipItem {
|
||||
slot: number
|
||||
entry: number
|
||||
name: string
|
||||
quality: number
|
||||
itemLevel: number
|
||||
inventoryType: number
|
||||
displayId: number | null
|
||||
transmogEntry: number | null
|
||||
transmogDisplayId: number | null
|
||||
}
|
||||
|
||||
const LIMIT = 25
|
||||
|
||||
export async function searchCharacters(q: string, limit = LIMIT): Promise<CharacterResult[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT guid, name, race, gender, class, level FROM characters WHERE name LIKE ? ORDER BY level DESC, name LIMIT ?',
|
||||
[`%${q}%`, limit],
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
guid: r.guid,
|
||||
name: r.name,
|
||||
race: r.race,
|
||||
gender: r.gender,
|
||||
class: r.class,
|
||||
level: r.level,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchGuilds(q: string, limit = LIMIT): Promise<GuildResult[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT g.guildid, g.name, (SELECT COUNT(*) FROM guild_member gm WHERE gm.guildid = g.guildid) AS members
|
||||
FROM guild g WHERE g.name LIKE ? ORDER BY members DESC LIMIT ?`,
|
||||
[`%${q}%`, limit],
|
||||
)
|
||||
return rows.map((r) => ({ guildid: r.guildid, name: r.name, members: Number(r.members ?? 0) }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchItems(q: string, locale: string, limit = LIMIT): Promise<ItemResult[]> {
|
||||
const col = locale === 'en' ? 'name_en' : 'name_es'
|
||||
try {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
`SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type
|
||||
FROM item_data WHERE ${col} LIKE ? OR name_en LIKE ? ORDER BY item_level DESC LIMIT ?`,
|
||||
[`%${q}%`, `%${q}%`, limit],
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
entry: r.entry,
|
||||
name: r.name,
|
||||
quality: r.quality,
|
||||
itemLevel: r.item_level,
|
||||
inventoryType: r.inventory_type,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCharacter(guid: number): Promise<
|
||||
(CharacterResult & { guildName: string | null; guildId: number | null; online: boolean }) | null
|
||||
> {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT guid, name, race, gender, class, level, online FROM characters WHERE guid = ?',
|
||||
[guid],
|
||||
)
|
||||
const c = rows[0]
|
||||
if (!c) return null
|
||||
let guildName: string | null = null
|
||||
let guildId: number | null = null
|
||||
try {
|
||||
const [g] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT gu.guildid, gu.name FROM guild_member gm JOIN guild gu ON gu.guildid = gm.guildid WHERE gm.guid = ?`,
|
||||
[guid],
|
||||
)
|
||||
if (g[0]) {
|
||||
guildId = g[0].guildid
|
||||
guildName = g[0].name
|
||||
}
|
||||
} catch {
|
||||
/* sin hermandad */
|
||||
}
|
||||
return {
|
||||
guid: c.guid,
|
||||
name: c.name,
|
||||
race: c.race,
|
||||
gender: c.gender,
|
||||
class: c.class,
|
||||
level: c.level,
|
||||
online: c.online === 1,
|
||||
guildName,
|
||||
guildId,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Ítems equipados (slots 0-18) del personaje, resueltos con item_data. */
|
||||
export async function getCharacterEquipment(guid: number, locale: string): Promise<EquipItem[]> {
|
||||
const col = locale === 'en' ? 'name_en' : 'name_es'
|
||||
try {
|
||||
const [inv] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT ci.slot, ii.itemEntry, ii.transmogrification
|
||||
FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item
|
||||
WHERE ci.guid = ? AND ci.bag = 0 AND ci.slot <= 18 ORDER BY ci.slot`,
|
||||
[guid],
|
||||
)
|
||||
if (inv.length === 0) return []
|
||||
|
||||
// Resolver todos los entries (equipados + transmog) en una consulta a item_data.
|
||||
const entries = [
|
||||
...new Set(inv.flatMap((r) => [Number(r.itemEntry), Number(r.transmogrification)]).filter((n) => n > 0)),
|
||||
]
|
||||
const meta = new Map<number, RowDataPacket>()
|
||||
if (entries.length) {
|
||||
const [items] = await db(DB.default).query<RowDataPacket[]>(
|
||||
`SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type, display_id
|
||||
FROM item_data WHERE entry IN (${entries.map(() => '?').join(',')})`,
|
||||
entries,
|
||||
)
|
||||
for (const it of items) meta.set(Number(it.entry), it)
|
||||
}
|
||||
|
||||
return inv.map((r) => {
|
||||
const entry = Number(r.itemEntry)
|
||||
const tmog = Number(r.transmogrification) || null
|
||||
const m = meta.get(entry)
|
||||
const tm = tmog ? meta.get(tmog) : undefined
|
||||
return {
|
||||
slot: r.slot,
|
||||
entry,
|
||||
name: m?.name ?? `#${entry}`,
|
||||
quality: m ? Number(m.quality) : 0,
|
||||
itemLevel: m ? Number(m.item_level) : 0,
|
||||
inventoryType: m ? Number(m.inventory_type) : 0,
|
||||
displayId: m && m.display_id != null ? Number(m.display_id) : null,
|
||||
transmogEntry: tmog,
|
||||
transmogDisplayId: tm && tm.display_id != null ? Number(tm.display_id) : null,
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuildMember {
|
||||
guid: number
|
||||
name: string
|
||||
race: number
|
||||
class: number
|
||||
level: number
|
||||
rank: number
|
||||
}
|
||||
|
||||
export async function getGuild(
|
||||
guildid: number,
|
||||
): Promise<{ guildid: number; name: string; members: GuildMember[]; leaderName: string | null } | null> {
|
||||
try {
|
||||
const [g] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT guildid, name, leaderguid FROM guild WHERE guildid = ?',
|
||||
[guildid],
|
||||
)
|
||||
if (!g[0]) return null
|
||||
const [members] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT c.guid, c.name, c.race, c.class, c.level, gm.rank
|
||||
FROM guild_member gm JOIN characters c ON c.guid = gm.guid
|
||||
WHERE gm.guildid = ? ORDER BY gm.rank, c.level DESC`,
|
||||
[guildid],
|
||||
)
|
||||
const list = members.map((m) => ({
|
||||
guid: m.guid,
|
||||
name: m.name,
|
||||
race: m.race,
|
||||
class: m.class,
|
||||
level: m.level,
|
||||
rank: m.rank,
|
||||
}))
|
||||
const leader = list.find((m) => m.guid === g[0].leaderguid)
|
||||
return { guildid: g[0].guildid, name: g[0].name, members: list, leaderName: leader?.name ?? null }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Mapas de datos estáticos de WoW (WotLK 3.4.3): razas, clases, slots de equipo.
|
||||
// Nombres en ES/EN; el color de clase para la interfaz de la armería.
|
||||
|
||||
export const RACES: Record<number, { es: string; en: string; faction: 'alliance' | 'horde' }> = {
|
||||
1: { es: 'Humano', en: 'Human', faction: 'alliance' },
|
||||
2: { es: 'Orco', en: 'Orc', faction: 'horde' },
|
||||
3: { es: 'Enano', en: 'Dwarf', faction: 'alliance' },
|
||||
4: { es: 'Elfo de la noche', en: 'Night Elf', faction: 'alliance' },
|
||||
5: { es: 'No-muerto', en: 'Undead', faction: 'horde' },
|
||||
6: { es: 'Tauren', en: 'Tauren', faction: 'horde' },
|
||||
7: { es: 'Gnomo', en: 'Gnome', faction: 'alliance' },
|
||||
8: { es: 'Trol', en: 'Troll', faction: 'horde' },
|
||||
10: { es: 'Elfo de sangre', en: 'Blood Elf', faction: 'horde' },
|
||||
11: { es: 'Draenei', en: 'Draenei', faction: 'alliance' },
|
||||
}
|
||||
|
||||
export const CLASSES: Record<number, { es: string; en: string; color: string }> = {
|
||||
1: { es: 'Guerrero', en: 'Warrior', color: '#C79C6E' },
|
||||
2: { es: 'Paladín', en: 'Paladin', color: '#F58CBA' },
|
||||
3: { es: 'Cazador', en: 'Hunter', color: '#ABD473' },
|
||||
4: { es: 'Pícaro', en: 'Rogue', color: '#FFF569' },
|
||||
5: { es: 'Sacerdote', en: 'Priest', color: '#FFFFFF' },
|
||||
6: { es: 'Caballero de la Muerte', en: 'Death Knight', color: '#C41F3B' },
|
||||
7: { es: 'Chamán', en: 'Shaman', color: '#0070DE' },
|
||||
8: { es: 'Mago', en: 'Mage', color: '#69CCF0' },
|
||||
9: { es: 'Brujo', en: 'Warlock', color: '#9482C9' },
|
||||
11: { es: 'Druida', en: 'Druid', color: '#FF7D0A' },
|
||||
}
|
||||
|
||||
// Slots de equipo 0-18 (EQUIPMENT_SLOT_*). Los que no se muestran (camisa 3, tabardo 18)
|
||||
// se dejan igual por completitud del modelo 3D.
|
||||
export const EQUIP_SLOTS: { slot: number; es: string; en: string }[] = [
|
||||
{ slot: 0, es: 'Cabeza', en: 'Head' },
|
||||
{ slot: 1, es: 'Cuello', en: 'Neck' },
|
||||
{ slot: 2, es: 'Hombros', en: 'Shoulders' },
|
||||
{ slot: 14, es: 'Espalda', en: 'Back' },
|
||||
{ slot: 4, es: 'Pecho', en: 'Chest' },
|
||||
{ slot: 3, es: 'Camisa', en: 'Shirt' },
|
||||
{ slot: 18, es: 'Tabardo', en: 'Tabard' },
|
||||
{ slot: 8, es: 'Muñecas', en: 'Wrists' },
|
||||
{ slot: 9, es: 'Manos', en: 'Hands' },
|
||||
{ slot: 5, es: 'Cintura', en: 'Waist' },
|
||||
{ slot: 6, es: 'Piernas', en: 'Legs' },
|
||||
{ slot: 7, es: 'Pies', en: 'Feet' },
|
||||
{ slot: 10, es: 'Anillo', en: 'Ring' },
|
||||
{ slot: 11, es: 'Anillo', en: 'Ring' },
|
||||
{ slot: 12, es: 'Abalorio', en: 'Trinket' },
|
||||
{ slot: 13, es: 'Abalorio', en: 'Trinket' },
|
||||
{ slot: 15, es: 'Mano derecha', en: 'Main Hand' },
|
||||
{ slot: 16, es: 'Mano izquierda', en: 'Off Hand' },
|
||||
{ slot: 17, es: 'A distancia', en: 'Ranged' },
|
||||
]
|
||||
|
||||
export function raceName(id: number, locale: string): string {
|
||||
const r = RACES[id]
|
||||
return r ? (locale === 'en' ? r.en : r.es) : `#${id}`
|
||||
}
|
||||
export function className(id: number, locale: string): string {
|
||||
const c = CLASSES[id]
|
||||
return c ? (locale === 'en' ? c.en : c.es) : `#${id}`
|
||||
}
|
||||
export function classColor(id: number): string {
|
||||
return CLASSES[id]?.color ?? '#ffffff'
|
||||
}
|
||||
export function factionOf(race: number): 'alliance' | 'horde' | null {
|
||||
return RACES[race]?.faction ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user