409 lines
15 KiB
TypeScript
409 lines
15 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
|
import { useGameAction, type InputAction } from '../input'
|
|
|
|
type HunterProfileScreenProps = {
|
|
profile: CharacterProfile
|
|
onBack: () => void
|
|
}
|
|
|
|
type BossEntry = {
|
|
encounter: DungeonEncounter
|
|
dungeonId: number
|
|
dungeonName: string
|
|
contentType: 'dungeon' | 'raid'
|
|
}
|
|
|
|
type CollectionItem = {
|
|
key: string
|
|
glyph: string
|
|
name: string
|
|
chance: string
|
|
quantity: number
|
|
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
|
source: string
|
|
}
|
|
|
|
type HunterProfileTab = 'stats' | 'collection'
|
|
|
|
type HunterProfileNavEntry =
|
|
| { kind: 'back'; key: string; row: number; column: number }
|
|
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
|
|
| { kind: 'stat'; key: string; row: number; column: number; index: number }
|
|
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
|
|
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
|
|
|
|
const HUNTER_PROFILE_DROP_COLUMNS = 6
|
|
|
|
function bossInitials(name: string) {
|
|
return name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
.join('')
|
|
}
|
|
|
|
function dropChanceLabel(chance: number) {
|
|
if (chance <= 0) return 'Unavailable'
|
|
const denominator = Math.round(1 / chance)
|
|
if (denominator >= 10) return `1 in ${denominator}`
|
|
return `${Math.round(chance * 100)}%`
|
|
}
|
|
|
|
function purplePetKey(encounterId: number) {
|
|
return `purple:${encounterId}`
|
|
}
|
|
|
|
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
|
|
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
|
|
const [selectedIndex, setSelectedIndex] = useState(1)
|
|
const screenRef = useRef<HTMLElement | null>(null)
|
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
|
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
|
|
dungeon.encounters
|
|
.filter((encounter) => encounter.isBoss)
|
|
.map((encounter) => ({
|
|
encounter,
|
|
dungeonId: dungeon.id,
|
|
dungeonName: dungeon.name,
|
|
contentType: dungeon.contentType,
|
|
})),
|
|
), [profile.dungeons])
|
|
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
|
|
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
|
|
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
|
|
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
|
|
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
|
|
const inventoryQuantities = useMemo(
|
|
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
|
|
[profile.inventory],
|
|
)
|
|
const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0)
|
|
const mostKilledBoss = bosses.reduce<BossEntry | null>((best, boss) => {
|
|
if (!best) return boss
|
|
return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0)
|
|
? boss
|
|
: best
|
|
}, null)
|
|
const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0
|
|
const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0
|
|
const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0
|
|
const collectionItems = useMemo<CollectionItem[]>(() => {
|
|
if (!selectedBoss) return []
|
|
return [
|
|
{
|
|
key: `kills:${selectedBoss.encounter.id}`,
|
|
glyph: 'K',
|
|
name: 'Boss Kills',
|
|
chance: 'Defeats',
|
|
quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0,
|
|
rarity: 'stat',
|
|
source: selectedBoss.encounter.enemyName,
|
|
},
|
|
...selectedBoss.encounter.lootTables.map((drop) => ({
|
|
key: `drop:${drop.difficultyId}:${drop.id}`,
|
|
glyph: drop.glyph,
|
|
name: drop.name,
|
|
chance: dropChanceLabel(drop.dropChance),
|
|
quantity: inventoryQuantities.get(drop.id) ?? 0,
|
|
rarity: drop.rarity,
|
|
source: selectedBoss.encounter.enemyName,
|
|
})),
|
|
{
|
|
key: `pet:${selectedBoss.encounter.id}`,
|
|
glyph: '*',
|
|
name: `${selectedBoss.encounter.enemyName} Pet`,
|
|
chance: '1 in 500',
|
|
quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0,
|
|
rarity: 'legendary',
|
|
source: selectedBoss.encounter.enemyName,
|
|
},
|
|
{
|
|
key: `purple-pet:${selectedBoss.encounter.id}`,
|
|
glyph: 'P',
|
|
name: `Purple ${selectedBoss.encounter.enemyName} Pet`,
|
|
chance: '1 in 500',
|
|
quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0,
|
|
rarity: 'epic',
|
|
source: `${selectedBoss.encounter.enemyName} Roguelike`,
|
|
},
|
|
]
|
|
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
|
|
const statTiles = useMemo(() => [
|
|
{
|
|
key: 'stat:total-kills',
|
|
label: 'Total Boss Kills',
|
|
value: totalBossKills,
|
|
},
|
|
{
|
|
key: 'stat:most-killed',
|
|
label: 'Most Killed Boss',
|
|
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
|
|
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
|
|
},
|
|
{
|
|
key: 'stat:pvp-matches',
|
|
label: 'PvP Matches',
|
|
value: matchesPlayed,
|
|
},
|
|
{
|
|
key: 'stat:pvp-wins',
|
|
label: 'PvP Wins',
|
|
value: matchesWon,
|
|
},
|
|
{
|
|
key: 'stat:pvp-win-rate',
|
|
label: 'PvP Win Rate',
|
|
value: `${winRate}%`,
|
|
},
|
|
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
|
|
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
|
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
|
|
const entries: HunterProfileNavEntry[] = [
|
|
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
|
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
|
|
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
|
|
]
|
|
if (activeTab === 'stats') {
|
|
entries.push(...statTiles.map((tile, index) => ({
|
|
kind: 'stat' as const,
|
|
key: tile.key,
|
|
row: 1,
|
|
column: index,
|
|
index,
|
|
})))
|
|
} else {
|
|
entries.push(
|
|
...bosses.map((boss, index) => ({
|
|
kind: 'boss' as const,
|
|
key: `boss:${boss.encounter.id}`,
|
|
row: index + 1,
|
|
column: 0,
|
|
bossId: boss.encounter.id,
|
|
})),
|
|
...collectionItems.map((item, index) => ({
|
|
kind: 'item' as const,
|
|
key: `item:${item.key}`,
|
|
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
|
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
|
itemKey: item.key,
|
|
})),
|
|
)
|
|
}
|
|
return entries
|
|
}, [activeTab, bosses, collectionItems, statTiles])
|
|
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
|
|
const activeEntry = navEntries[activeIndex]
|
|
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
|
|
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
|
|
return {
|
|
mode: 'collection',
|
|
title: focusedItem.name,
|
|
subtitle: selectedBoss.encounter.enemyName,
|
|
summary: `Owned x${focusedItem.quantity}`,
|
|
items: [
|
|
{
|
|
glyph: focusedItem.glyph,
|
|
title: 'Drop Rate',
|
|
meta: focusedItem.chance,
|
|
detail: focusedItem.source,
|
|
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
|
|
},
|
|
{
|
|
title: 'Boss Kills',
|
|
meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`,
|
|
detail: selectedBoss.dungeonName,
|
|
},
|
|
],
|
|
}
|
|
}, [activeTab, bossKills, focusedItem, selectedBoss])
|
|
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
|
|
|
|
function selected(entryKey: string) {
|
|
return activeEntry?.key === entryKey
|
|
}
|
|
|
|
function selectKey(entryKey: string) {
|
|
const index = navEntries.findIndex((entry) => entry.key === entryKey)
|
|
if (index >= 0) setSelectedIndex(index)
|
|
}
|
|
|
|
function selectTab(tab: HunterProfileTab) {
|
|
setActiveTab(tab)
|
|
setSelectedIndex(tab === 'stats' ? 1 : 2)
|
|
}
|
|
|
|
function selectBoss(bossId: number) {
|
|
setSelectedBossId(bossId)
|
|
setFocusedItemKey(null)
|
|
}
|
|
|
|
function moveSelection(action: InputAction) {
|
|
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
|
setSelectedIndex((current) => {
|
|
const bounded = Math.min(current, navEntries.length - 1)
|
|
const active = navEntries[bounded]
|
|
if (!active) return 0
|
|
const candidates = navEntries
|
|
.map((entry, index) => ({ entry, index }))
|
|
.filter(({ index }) => index !== bounded)
|
|
.filter(({ entry }) => {
|
|
if (action === 'navigateLeft') return entry.column < active.column
|
|
if (action === 'navigateRight') return entry.column > active.column
|
|
if (action === 'navigateUp') return entry.row < active.row
|
|
return entry.row > active.row
|
|
})
|
|
if (candidates.length === 0) return bounded
|
|
candidates.sort((a, b) => {
|
|
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
|
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
? Math.abs(a.entry.row - active.row)
|
|
: Math.abs(a.entry.column - active.column)
|
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
? Math.abs(b.entry.row - active.row)
|
|
: Math.abs(b.entry.column - active.column)
|
|
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
|
})
|
|
return candidates[0]?.index ?? bounded
|
|
})
|
|
}
|
|
|
|
function openEntry(entry: HunterProfileNavEntry | undefined) {
|
|
if (!entry) return
|
|
if (entry.kind === 'back') onBack()
|
|
else if (entry.kind === 'tab') selectTab(entry.tab)
|
|
else if (entry.kind === 'boss') selectBoss(entry.bossId)
|
|
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!activeEntry) return
|
|
screenRef.current
|
|
?.querySelector<HTMLElement>('[data-game-selected="true"]')
|
|
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
}, [activeEntry])
|
|
|
|
useGameAction((action, inputDevice) => {
|
|
if (inputDevice !== 'controller') return
|
|
if (action === 'back') {
|
|
onBack()
|
|
return
|
|
}
|
|
if (action === 'confirm') {
|
|
openEntry(activeEntry)
|
|
return
|
|
}
|
|
moveSelection(action)
|
|
})
|
|
|
|
return (
|
|
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
|
|
<div className="screen-heading hunter-profile-heading">
|
|
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
|
|
<button
|
|
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
|
|
data-controller-nav="skip"
|
|
data-game-selected={selected('tab:stats') ? 'true' : undefined}
|
|
onClick={() => selectTab('stats')}
|
|
onPointerDown={() => selectKey('tab:stats')}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'stats'}
|
|
>
|
|
Stats
|
|
</button>
|
|
<button
|
|
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
|
|
data-controller-nav="skip"
|
|
data-game-selected={selected('tab:collection') ? 'true' : undefined}
|
|
onClick={() => selectTab('collection')}
|
|
onPointerDown={() => selectKey('tab:collection')}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'collection'}
|
|
>
|
|
Collection Log
|
|
</button>
|
|
</div>
|
|
<button
|
|
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
|
data-controller-nav="skip"
|
|
data-game-selected={selected('back') ? 'true' : undefined}
|
|
onClick={onBack}
|
|
onPointerDown={() => selectKey('back')}
|
|
type="button"
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'stats' && (
|
|
<div className="hunter-stat-grid">
|
|
{statTiles.map((tile) => (
|
|
<article
|
|
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
|
|
data-game-selected={selected(tile.key) ? 'true' : undefined}
|
|
key={tile.key}
|
|
onPointerDown={() => selectKey(tile.key)}
|
|
>
|
|
<span>{tile.label}</span>
|
|
<strong>{tile.value}</strong>
|
|
{tile.detail && <small>{tile.detail}</small>}
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'collection' && (
|
|
<div className="collection-log-layout">
|
|
<div className="collection-boss-list" aria-label="Bosses">
|
|
{bosses.map((boss) => {
|
|
return (
|
|
<button
|
|
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
|
|
data-controller-nav="skip"
|
|
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
|
|
key={boss.encounter.id}
|
|
onClick={() => selectBoss(boss.encounter.id)}
|
|
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
|
|
type="button"
|
|
>
|
|
<span>{bossInitials(boss.encounter.enemyName)}</span>
|
|
<strong>{boss.encounter.enemyName}</strong>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{selectedBoss && (
|
|
<article className="collection-boss-detail">
|
|
<div className="collection-drop-list">
|
|
{collectionItems.map((item) => (
|
|
<button
|
|
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
|
|
data-controller-nav="skip"
|
|
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
|
|
key={item.key}
|
|
onClick={() => setFocusedItemKey(item.key)}
|
|
onFocus={() => setFocusedItemKey(item.key)}
|
|
onPointerDown={() => selectKey(`item:${item.key}`)}
|
|
type="button"
|
|
>
|
|
<span>{item.glyph}</span>
|
|
<strong>{item.name}</strong>
|
|
<small>{item.chance}</small>
|
|
<b>x{item.quantity}</b>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</article>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|