Android build v1.1.22
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ControllerStylePreview } from '../../../components/ControllerIcons'
|
||||
import { useGameAction, useInput, type ControllerIconStyle } from '../../../input'
|
||||
import {
|
||||
useGameAction,
|
||||
useInput,
|
||||
type ControllerIconStyle,
|
||||
type InputAction,
|
||||
} from '../../../input'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
|
||||
import { getGameMode } from '../../../gameRepository'
|
||||
import {
|
||||
restoreIwt2CloudSlot,
|
||||
@@ -9,6 +15,7 @@ import {
|
||||
type Iwt2Save,
|
||||
} from '../save/iwt2Repository'
|
||||
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
|
||||
import { iwt2BossMaterialRewardFor, iwt2BossPetRewardFor } from '../content/bossRewards'
|
||||
import {
|
||||
abilitiesForHealer,
|
||||
IWT2_HEALER_METADATA,
|
||||
@@ -62,6 +69,39 @@ type Iwt2UpgradeNavEntry =
|
||||
| { kind: 'upgradeContinue', row: number, column: number, disabled?: boolean }
|
||||
| { kind: 'upgradeLeave', row: number, column: number }
|
||||
|
||||
type Iwt2HunterProfileTab = 'stats' | 'collection'
|
||||
|
||||
type Iwt2HunterProfileNavEntry =
|
||||
| { kind: 'back', key: string, row: number, column: number }
|
||||
| { kind: 'tab', key: string, row: number, column: number, tab: Iwt2HunterProfileTab }
|
||||
| { kind: 'stat', key: string, row: number, column: number, index: number }
|
||||
| { kind: 'boss', key: string, row: number, column: number, bossId: Iwt2BossId }
|
||||
| { kind: 'item', key: string, row: number, column: number, itemKey: string }
|
||||
|
||||
type Iwt2HunterProfileBossEntry = {
|
||||
bossId: Iwt2BossId
|
||||
dropId: string
|
||||
dropName: string
|
||||
encounter: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
|
||||
kills: number
|
||||
petId: string
|
||||
petName: string
|
||||
pets: number
|
||||
drops: number
|
||||
}
|
||||
|
||||
type Iwt2HunterCollectionItem = {
|
||||
key: string
|
||||
glyph: string
|
||||
name: string
|
||||
chance: string
|
||||
quantity: number
|
||||
rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
source: string
|
||||
}
|
||||
|
||||
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||
|
||||
function withBackAction(actions: Iwt2NavAction[], onBack: () => void): Iwt2NavAction[] {
|
||||
return [
|
||||
{
|
||||
@@ -122,10 +162,83 @@ const MODE_ACTIONS: Record<Iwt2Mode, Array<{
|
||||
detail: 'Shared arena entry with raid-role notes: watch lightning markers, rotate through safe bands, and recover stunned allies fast.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'rathian',
|
||||
label: 'Rathian Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: dodge tail arcs, keep poison puddles out of the party path, and recover after sweeps.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'barroth',
|
||||
label: 'Barroth Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: break mud armor, sidestep mud cones, and reposition through slow zones.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'tobi-kadachi',
|
||||
label: 'Tobi Kadachi Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: track static charge, bait line pounces, and spread before chain shock.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'rimebastion',
|
||||
label: 'Rimebastion Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: read ice wall lanes, dodge shard cones, and keep the tank away from corners.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'ember-mantis-duelist',
|
||||
label: 'Ember Mantis Duelist Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: track sidesteps, cut through slash lanes, and spread before crossed blades.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'cinderback-ricochet',
|
||||
label: 'Cinderback Ricochet Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: bait ricochet lanes, avoid lava trails, and recover after armor slam.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'obsidian-ram-golem',
|
||||
label: 'Obsidian Ram Golem Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: bait charge lines, avoid quake fractures, and break armor safely.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'stormcoil-wyrm',
|
||||
label: 'Stormcoil Wyrm Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: spread for chain lightning and rotate through charged ring gaps.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'venom-orchid-hydra',
|
||||
label: 'Venom Orchid Hydra Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: dodge staggered poison cones and keep root lanes off the party.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'sandglass-scorpion',
|
||||
label: 'Sandglass Scorpion Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: track burrows, wait out stinger eruptions, and avoid sinking sand.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'crystal-bat-matriarch',
|
||||
label: 'Crystal Bat Matriarch Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: space for sonic rings, watch mirror lanes, and dodge swoops.',
|
||||
value: 'Start',
|
||||
},
|
||||
{
|
||||
bossId: 'hollowcrown-revenant',
|
||||
label: 'Hollowcrown Revenant Raid Assignment',
|
||||
detail: 'Shared arena entry with raid-role notes: avoid cursed hoof zones and sidestep ghost-antler charges.',
|
||||
value: 'Start',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const CONTROLLER_ICON_OPTIONS: ControllerIconStyle[] = ['playstation', 'xbox', 'nintendo']
|
||||
const IWT2_DUNGEON_BOSS_PAGE_SIZE = 5
|
||||
|
||||
const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
|
||||
xbox: 'Xbox',
|
||||
@@ -133,6 +246,121 @@ const CONTROLLER_ICON_LABELS: Record<ControllerIconStyle, string> = {
|
||||
nintendo: 'Nintendo',
|
||||
}
|
||||
|
||||
const IWT2_DUNGEON_ACTIONS: Array<Omit<Iwt2NavAction, 'onConfirm'> & { bossId: Iwt2BossId }> = [
|
||||
{
|
||||
bossId: 'bulldrome',
|
||||
key: 'bulldrome',
|
||||
label: 'Bulldrome Arena',
|
||||
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'yian-kut-ku',
|
||||
key: 'yian-kut-ku',
|
||||
label: 'Yian Kut Ku Arena',
|
||||
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'great-jaggi',
|
||||
key: 'great-jaggi',
|
||||
label: 'Great Jaggi Arena',
|
||||
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'khezu',
|
||||
key: 'khezu',
|
||||
label: 'Khezu Arena',
|
||||
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'rathian',
|
||||
key: 'rathian',
|
||||
label: 'Rathian Arena',
|
||||
detail: 'Poison tail arcs and targeted poison puddles punish stacked positioning.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'barroth',
|
||||
key: 'barroth',
|
||||
label: 'Barroth Arena',
|
||||
detail: 'Breakable mud armor softens incoming damage while mud spray creates slow zones.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'tobi-kadachi',
|
||||
key: 'tobi-kadachi',
|
||||
label: 'Tobi Kadachi Arena',
|
||||
detail: 'Static builds into line pounces while chain shock punishes grouped allies.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'rimebastion',
|
||||
key: 'rimebastion',
|
||||
label: 'Rimebastion Arena',
|
||||
detail: 'Ice walls split the room, then shard lanes and cones punish trapped routes.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'ember-mantis-duelist',
|
||||
key: 'ember-mantis-duelist',
|
||||
label: 'Ember Mantis Duelist Arena',
|
||||
detail: 'Fast sidesteps lead into line slashes and crossed lane attacks.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'cinderback-ricochet',
|
||||
key: 'cinderback-ricochet',
|
||||
label: 'Cinderback Ricochet Arena',
|
||||
detail: 'Wall-bounce rolls leave lava trails before a heavy armor slam.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'obsidian-ram-golem',
|
||||
key: 'obsidian-ram-golem',
|
||||
label: 'Obsidian Ram Golem Arena',
|
||||
detail: 'Volcanic charge lines crack armor, then quake fractures split the floor.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'stormcoil-wyrm',
|
||||
key: 'stormcoil-wyrm',
|
||||
label: 'Stormcoil Wyrm Arena',
|
||||
detail: 'Charged rings and chain lightning punish clustered party movement.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'venom-orchid-hydra',
|
||||
key: 'venom-orchid-hydra',
|
||||
label: 'Venom Orchid Hydra Arena',
|
||||
detail: 'Three heads sweep poison cones while roots and pods pressure escape paths.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'sandglass-scorpion',
|
||||
key: 'sandglass-scorpion',
|
||||
label: 'Sandglass Scorpion Arena',
|
||||
detail: 'Burrows, delayed stinger eruptions, and shifting sand zones control space.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'crystal-bat-matriarch',
|
||||
key: 'crystal-bat-matriarch',
|
||||
label: 'Crystal Bat Matriarch Arena',
|
||||
detail: 'Sonic rings, mirror shard lanes, and swoop dives punish bad spacing.',
|
||||
value: 'Ready',
|
||||
},
|
||||
{
|
||||
bossId: 'hollowcrown-revenant',
|
||||
key: 'hollowcrown-revenant',
|
||||
label: 'Hollowcrown Revenant Arena',
|
||||
detail: 'Cursed hoof zones and ghost-antler dash trails cut across the arena.',
|
||||
value: 'Ready',
|
||||
},
|
||||
]
|
||||
|
||||
export function Iwt2ScreenShell({
|
||||
children,
|
||||
eyebrow = 'I Want To Heal 2',
|
||||
@@ -222,40 +450,39 @@ export function Iwt2DungeonsScreen({
|
||||
onBack: () => void
|
||||
onOpenBoss: (bossId: Iwt2BossId) => void
|
||||
}) {
|
||||
const [page, setPage] = useState(0)
|
||||
const pageCount = Math.max(1, Math.ceil(IWT2_DUNGEON_ACTIONS.length / IWT2_DUNGEON_BOSS_PAGE_SIZE))
|
||||
const currentPage = Math.min(page, pageCount - 1)
|
||||
const pageStart = currentPage * IWT2_DUNGEON_BOSS_PAGE_SIZE
|
||||
const pageEnd = Math.min(IWT2_DUNGEON_ACTIONS.length, pageStart + IWT2_DUNGEON_BOSS_PAGE_SIZE)
|
||||
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
||||
{
|
||||
key: 'bulldrome',
|
||||
label: 'Bulldrome Arena',
|
||||
detail: 'Level 1 field boss. Charge lanes, tank pressure, and periodic ground slam.',
|
||||
value: 'Ready',
|
||||
onConfirm: () => onOpenBoss('bulldrome'),
|
||||
key: 'dungeons-page-prev',
|
||||
label: 'Previous Page',
|
||||
detail: `Bosses ${pageStart + 1}-${pageEnd} of ${IWT2_DUNGEON_ACTIONS.length}`,
|
||||
disabled: currentPage === 0,
|
||||
value: 'Prev',
|
||||
onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)),
|
||||
},
|
||||
{
|
||||
key: 'yian-kut-ku',
|
||||
label: 'Yian Kut Ku Arena',
|
||||
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
|
||||
value: 'Ready',
|
||||
onConfirm: () => onOpenBoss('yian-kut-ku'),
|
||||
key: 'dungeons-page-next',
|
||||
label: 'Next Page',
|
||||
detail: `Page ${currentPage + 1}/${pageCount}`,
|
||||
disabled: currentPage >= pageCount - 1,
|
||||
value: 'Next',
|
||||
onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)),
|
||||
},
|
||||
{
|
||||
key: 'great-jaggi',
|
||||
label: 'Great Jaggi Arena',
|
||||
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
|
||||
value: 'Ready',
|
||||
onConfirm: () => onOpenBoss('great-jaggi'),
|
||||
},
|
||||
{
|
||||
key: 'khezu',
|
||||
label: 'Khezu Arena',
|
||||
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
|
||||
value: 'Ready',
|
||||
onConfirm: () => onOpenBoss('khezu'),
|
||||
},
|
||||
], onBack), [onBack, onOpenBoss])
|
||||
...IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd).map((entry) => ({
|
||||
...entry,
|
||||
onConfirm: () => onOpenBoss(entry.bossId),
|
||||
})),
|
||||
], onBack), [currentPage, onBack, onOpenBoss, pageCount, pageEnd, pageStart])
|
||||
|
||||
return (
|
||||
<Iwt2ScreenShell title="Dungeons" onBack={onBack}>
|
||||
<Iwt2ActionList actions={actions} />
|
||||
<div className="iwt2-dungeon-list">
|
||||
<Iwt2ActionList actions={actions} />
|
||||
</div>
|
||||
</Iwt2ScreenShell>
|
||||
)
|
||||
}
|
||||
@@ -778,63 +1005,352 @@ export function Iwt2HunterProfileScreen({
|
||||
onBack: () => void
|
||||
save: Iwt2Save
|
||||
}) {
|
||||
const bossRows = (Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => ({
|
||||
bossId,
|
||||
drops: save.collectionLog.dropsFound[bossDropIdForProfile(bossId)] ?? 0,
|
||||
kills: save.collectionLog.bossKills[bossId] ?? 0,
|
||||
name: IWT2_BOSS_METADATA[bossId].name,
|
||||
}))
|
||||
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
|
||||
const [activeTab, setActiveTab] = useState<Iwt2HunterProfileTab>('stats')
|
||||
const [selectedIndex, setSelectedIndex] = useState(1)
|
||||
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
|
||||
const screenRef = useRef<HTMLElement | null>(null)
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const bosses = useMemo<Iwt2HunterProfileBossEntry[]>(() => (
|
||||
(Object.keys(IWT2_BOSS_METADATA) as Iwt2BossId[]).map((bossId) => {
|
||||
const drop = iwt2BossMaterialRewardFor(bossId)
|
||||
const pet = iwt2BossPetRewardFor(bossId)
|
||||
return {
|
||||
bossId,
|
||||
dropId: drop.id,
|
||||
dropName: drop.name,
|
||||
drops: save.collectionLog.dropsFound[drop.id] ?? 0,
|
||||
encounter: IWT2_BOSS_METADATA[bossId],
|
||||
kills: save.collectionLog.bossKills[bossId] ?? 0,
|
||||
petId: pet.id,
|
||||
petName: pet.name,
|
||||
pets: save.collectionLog.bossPets[pet.id] ?? 0,
|
||||
}
|
||||
})
|
||||
), [save.collectionLog.bossKills, save.collectionLog.bossPets, save.collectionLog.dropsFound])
|
||||
const [selectedBossId, setSelectedBossId] = useState<Iwt2BossId>(() => bosses[0]?.bossId ?? 'bulldrome')
|
||||
const selectedBoss = bosses.find((boss) => boss.bossId === selectedBossId) ?? bosses[0]
|
||||
const totalBossKills = bosses.reduce((total, boss) => total + boss.kills, 0)
|
||||
const totalDrops = bosses.reduce((total, boss) => total + boss.drops, 0)
|
||||
const totalPets = bosses.reduce((total, boss) => total + boss.pets, 0)
|
||||
const mostKilledBoss = bosses.reduce<Iwt2HunterProfileBossEntry | null>((best, boss) => {
|
||||
if (!best) return boss
|
||||
return boss.kills > best.kills ? boss : best
|
||||
}, null)
|
||||
const collectionItems = useMemo<Iwt2HunterCollectionItem[]>(() => {
|
||||
if (!selectedBoss) return []
|
||||
return [
|
||||
{
|
||||
chance: 'Defeats',
|
||||
glyph: 'K',
|
||||
key: `kills:${selectedBoss.bossId}`,
|
||||
name: 'Boss Kills',
|
||||
quantity: selectedBoss.kills,
|
||||
rarity: 'stat',
|
||||
source: selectedBoss.encounter.name,
|
||||
},
|
||||
{
|
||||
chance: 'Guaranteed',
|
||||
glyph: selectedBoss.encounter.icon,
|
||||
key: `drop:${selectedBoss.dropId}`,
|
||||
name: selectedBoss.dropName,
|
||||
quantity: selectedBoss.drops,
|
||||
rarity: 'common',
|
||||
source: selectedBoss.encounter.name,
|
||||
},
|
||||
{
|
||||
chance: '1 in 500',
|
||||
glyph: '*',
|
||||
key: `pet:${selectedBoss.petId}`,
|
||||
name: selectedBoss.petName,
|
||||
quantity: selectedBoss.pets,
|
||||
rarity: 'legendary',
|
||||
source: selectedBoss.encounter.name,
|
||||
},
|
||||
]
|
||||
}, [selectedBoss])
|
||||
const statTiles = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
value: save.character.name,
|
||||
onConfirm: () => {},
|
||||
key: 'stat:total-kills',
|
||||
label: 'Total Boss Kills',
|
||||
value: totalBossKills,
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: 'Level',
|
||||
value: String(save.character.level),
|
||||
onConfirm: () => {},
|
||||
detail: totalBossKills > 0 && mostKilledBoss ? `${mostKilledBoss.kills} kills` : '0 kills',
|
||||
key: 'stat:most-killed',
|
||||
label: 'Most Killed Boss',
|
||||
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.name : 'None',
|
||||
},
|
||||
{
|
||||
key: 'experience',
|
||||
key: 'stat:hunter-level',
|
||||
label: 'Hunter Level',
|
||||
value: save.character.level,
|
||||
},
|
||||
{
|
||||
key: 'stat:experience',
|
||||
label: 'Experience',
|
||||
value: `${save.character.experience} XP`,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
{
|
||||
key: 'class',
|
||||
label: 'Healing Class',
|
||||
value: IWT2_HEALER_METADATA[save.character.healerStyle].name,
|
||||
onConfirm: () => {},
|
||||
key: 'stat:collection-drops',
|
||||
label: 'Collection Drops',
|
||||
value: totalDrops + totalPets,
|
||||
detail: `${totalPets} pets, ${save.inventory.length} inventory slots`,
|
||||
},
|
||||
...bossRows.map((row) => ({
|
||||
key: `boss-${row.bossId}`,
|
||||
label: row.name,
|
||||
value: `${row.kills} kills / ${row.drops} drops`,
|
||||
onConfirm: () => {},
|
||||
})),
|
||||
{
|
||||
key: 'inventory',
|
||||
label: 'IWT2 Inventory',
|
||||
value: `${save.inventory.length} slots`,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
], onBack), [bossRows, onBack, save.character.experience, save.character.healerStyle, save.character.level, save.character.name, save.inventory.length])
|
||||
], [mostKilledBoss, save.character.experience, save.character.level, save.inventory.length, totalBossKills, totalDrops, totalPets])
|
||||
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
||||
const navEntries = useMemo<Iwt2HunterProfileNavEntry[]>(() => {
|
||||
const entries: Iwt2HunterProfileNavEntry[] = [
|
||||
{ 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) => ({
|
||||
column: index,
|
||||
index,
|
||||
key: tile.key,
|
||||
kind: 'stat' as const,
|
||||
row: 1,
|
||||
})))
|
||||
} else {
|
||||
entries.push(
|
||||
...bosses.map((boss, index) => ({
|
||||
bossId: boss.bossId,
|
||||
column: 0,
|
||||
key: `boss:${boss.bossId}`,
|
||||
kind: 'boss' as const,
|
||||
row: index + 1,
|
||||
})),
|
||||
...collectionItems.map((item, index) => ({
|
||||
column: (index % IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||
itemKey: item.key,
|
||||
key: `item:${item.key}`,
|
||||
kind: 'item' as const,
|
||||
row: Math.floor(index / IWT2_HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||
})),
|
||||
)
|
||||
}
|
||||
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 {
|
||||
items: [
|
||||
{
|
||||
detail: focusedItem.source,
|
||||
glyph: focusedItem.glyph,
|
||||
meta: focusedItem.chance,
|
||||
status: focusedItem.quantity > 0 ? 'Collected' : 'Missing',
|
||||
title: 'Drop Rate',
|
||||
},
|
||||
{
|
||||
detail: 'I Want To Heal 2',
|
||||
meta: `${selectedBoss.kills}`,
|
||||
status: `${selectedBoss.drops} drops`,
|
||||
title: 'Boss Kills',
|
||||
},
|
||||
],
|
||||
mode: 'collection',
|
||||
subtitle: selectedBoss.encounter.name,
|
||||
summary: `Owned x${focusedItem.quantity}`,
|
||||
title: focusedItem.name,
|
||||
}
|
||||
}, [activeTab, 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: Iwt2HunterProfileTab) {
|
||||
setActiveTab(tab)
|
||||
setSelectedIndex(tab === 'stats' ? 1 : 2)
|
||||
}
|
||||
|
||||
function selectBoss(bossId: Iwt2BossId) {
|
||||
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: Iwt2HunterProfileNavEntry | 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 (
|
||||
<Iwt2ScreenShell title="Hunter Profile" onBack={onBack}>
|
||||
<Iwt2ActionList actions={actions} />
|
||||
</Iwt2ScreenShell>
|
||||
<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
|
||||
aria-selected={activeTab === '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')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
aria-selected={activeTab === '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')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
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) => (
|
||||
<button
|
||||
className={`collection-boss-button ${selectedBoss?.bossId === boss.bossId ? 'selected' : ''} ${selected(`boss:${boss.bossId}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`boss:${boss.bossId}`) ? 'true' : undefined}
|
||||
key={boss.bossId}
|
||||
onClick={() => selectBoss(boss.bossId)}
|
||||
onPointerDown={() => selectKey(`boss:${boss.bossId}`)}
|
||||
type="button"
|
||||
>
|
||||
<span>{bossInitialsForProfile(boss.encounter.name)}</span>
|
||||
<strong>{boss.encounter.name}</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>
|
||||
)
|
||||
}
|
||||
|
||||
function bossDropIdForProfile(bossId: Iwt2BossId): string {
|
||||
if (bossId === 'yian-kut-ku') return 'yian-kut-ku-scale'
|
||||
if (bossId === 'great-jaggi') return 'great-jaggi-hide'
|
||||
if (bossId === 'khezu') return 'khezu-pearl'
|
||||
return 'raw-bulldrome-coin'
|
||||
function bossInitialsForProfile(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function Iwt2CustomizeCharacterScreen({
|
||||
@@ -938,8 +1454,13 @@ function clampToEnabled(actions: Iwt2NavAction[], index: number) {
|
||||
if (actions.length === 0) return 0
|
||||
const bounded = Math.min(Math.max(0, index), actions.length - 1)
|
||||
if (!actions[bounded]?.disabled) return bounded
|
||||
const firstEnabled = actions.findIndex((action) => !action.disabled)
|
||||
return firstEnabled >= 0 ? firstEnabled : 0
|
||||
for (let offset = 1; offset < actions.length; offset += 1) {
|
||||
const previous = bounded - offset
|
||||
const next = bounded + offset
|
||||
if (previous >= 0 && !actions[previous]?.disabled) return previous
|
||||
if (next < actions.length && !actions[next]?.disabled) return next
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function nextEnabledIndex(actions: Iwt2NavAction[], current: number, direction: -1 | 1) {
|
||||
|
||||
Reference in New Issue
Block a user