Android build v1.1.12

This commit is contained in:
Warren H
2026-07-03 00:28:38 -04:00
parent c6e2c61d0a
commit b7cb572631
33 changed files with 1772 additions and 305 deletions
+202 -34
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
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
@@ -24,6 +25,17 @@ type CollectionItem = {
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+/)
@@ -45,7 +57,9 @@ function purplePetKey(encounterId: number) {
}
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
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
@@ -60,8 +74,8 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
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 = profile.hunterStats?.bossKills ?? {}
const bossPets = profile.hunterStats?.bossPets ?? {}
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],
@@ -117,7 +131,71 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
},
]
}, [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 {
@@ -143,13 +221,94 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
}, [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">
<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' : ''}`}
onClick={() => setActiveTab('stats')}
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'}
@@ -157,8 +316,11 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
Stats
</button>
<button
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
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'}
@@ -166,32 +328,32 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
Collection Log
</button>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
<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">
<article className="hunter-stat-tile">
<span>Total Boss Kills</span>
<strong>{totalBossKills}</strong>
</article>
<article className="hunter-stat-tile">
<span>Most Killed Boss</span>
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
</article>
<article className="hunter-stat-tile">
<span>PvP Matches</span>
<strong>{matchesPlayed}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Wins</span>
<strong>{matchesWon}</strong>
</article>
<article className="hunter-stat-tile">
<span>PvP Win Rate</span>
<strong>{winRate}%</strong>
</article>
{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>
)}
@@ -201,9 +363,12 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
{bosses.map((boss) => {
return (
<button
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
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={() => setSelectedBossId(boss.encounter.id)}
onClick={() => selectBoss(boss.encounter.id)}
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
type="button"
>
<span>{bossInitials(boss.encounter.enemyName)}</span>
@@ -218,10 +383,13 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
<div className="collection-drop-list">
{collectionItems.map((item) => (
<button
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
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>