Files
i-want-to-heal/src/modes/iwt2/screens/Iwt2ShellScreens.tsx
T
2026-07-05 22:48:56 -04:00

2432 lines
87 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { ControllerStylePreview } from '../../../components/ControllerIcons'
import {
useGameAction,
useInput,
type ControllerIconStyle,
type InputAction,
} from '../../../input'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../../../dualScreen'
import { getGameMode } from '../../../gameRepository'
import {
createDefaultIwt2Save,
loadIwt2OnlineSave,
setIwt2InfusionAbility,
updateIwt2CharacterSettings,
upgradeIwt2GearSlot,
canAffordIwt2Costs,
inventoryQuantity,
writeIwt2OnlineSave,
type Iwt2Save,
} from '../save/iwt2Repository'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import { IWT2_CLASS_METADATA, IWT2_PARTY_ORDER, type Iwt2PlayerClassId } from '../content/classes'
import {
findIwt2Difficulty,
isIwt2DifficultyUnlocked,
IWT2_DUNGEON_DIFFICULTIES,
IWT2_RAID_DIFFICULTIES,
type Iwt2Difficulty,
} from '../content/difficulties'
import {
iwt2BossCoinRewardFor,
iwt2BossCoinRewardsFor,
iwt2BossPetRewardFor,
type Iwt2BossCoinReward,
} from '../content/bossRewards'
import {
abilitiesForHealer,
IWT2_HEALER_METADATA,
IWT2_HEALER_ORDER,
} from '../content/healerAbilities'
import {
type Iwt2RoguelikeChoice,
type Iwt2RoguelikeContentType,
type Iwt2RoguelikeOpponentDebuffId,
type Iwt2RoguelikeSelfBuffId,
type Iwt2RoguelikeVariant,
} from '../content/roguelike'
import {
IWT2_GEAR_SLOT_LABELS,
IWT2_GEAR_SLOT_RECIPES,
IWT2_GEAR_SLOTS,
IWT2_GEAR_STAT_LABELS,
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
type Iwt2GearSlotId,
} from '../content/gear'
import {
IWT2_INFUSION_ABILITIES,
iwt2InfusionAbilitiesForClass,
type Iwt2InfusionAbilityId,
} from '../content/infusionAbilities'
type Iwt2NavAction = {
key: string
label: string
detail?: string
value?: string
disabled?: boolean
onConfirm: () => void
}
type Iwt2Mode = 'Raids'
type Iwt2ScreenShellProps = {
title: string
eyebrow?: string
onBack: () => void
children: ReactNode
}
type Iwt2ActionListProps = {
actions: Iwt2NavAction[]
}
type Iwt2DifficultyOption = Iwt2Difficulty & {
selected: boolean
locked: boolean
}
type Iwt2InfoRow = {
key: string
label: string
detail: string
value: string
}
type Iwt2NavPosition = {
column: number
row: number
}
type Iwt2UpgradeNavEntry =
| { kind: 'upgradeBuff', index: number, row: number, column: number }
| { kind: 'upgradeDebuff', index: number, row: number, column: number }
| { 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
encounter: (typeof IWT2_BOSS_METADATA)[Iwt2BossId]
kills: number
coins: Array<Iwt2BossCoinReward & { quantity: 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
}
type Iwt2GearNavEntry =
| { kind: 'back', key: string, row: number, column: number }
| { kind: 'class', key: string, row: number, column: number, classId: Iwt2PlayerClassId }
| { kind: 'slot', key: string, row: number, column: number, slotId: Iwt2GearSlotId }
| { kind: 'upgrade', key: string, row: number, column: number, disabled: boolean }
| { kind: 'infusion', key: string, row: number, column: number, abilityId: Iwt2InfusionAbilityId, disabled: boolean }
const IWT2_HUNTER_PROFILE_DROP_COLUMNS = 6
const IWT2_NAME_MAX_LENGTH = 18
const IWT2_NAME_EDITOR_COLUMNS = 6
const IWT2_NAME_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('')
function formatIwt2SaveTimestamp(updatedAt: number | null) {
if (!updatedAt) return 'No timestamp'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(updatedAt))
}
function formatIwt2SaveSummary(save: Iwt2Save | null) {
if (!save) return 'No save found'
return `Level ${save.character.level} - ${formatIwt2SaveTimestamp(save.updatedAt)}`
}
function withBackAction(actions: Iwt2NavAction[], onBack: () => void): Iwt2NavAction[] {
return [
{
key: 'back',
label: 'Back',
onConfirm: onBack,
},
...actions,
]
}
function useIwt2ActionList(actions: Iwt2NavAction[]) {
const [selectedIndex, setSelectedIndex] = useState(0)
const activeIndex = clampToEnabled(actions, selectedIndex)
useGameAction((action, device) => {
if (device !== 'controller' || actions.length === 0) return
if (action === 'navigateUp' || action === 'navigateLeft') {
setSelectedIndex((current) => nextEnabledIndex(actions, current, -1))
} else if (action === 'navigateDown' || action === 'navigateRight') {
setSelectedIndex((current) => nextEnabledIndex(actions, current, 1))
} else if (action === 'confirm') {
if (!actions[activeIndex]?.disabled) actions[activeIndex]?.onConfirm()
}
})
return { selectedIndex: activeIndex, setSelectedIndex }
}
const MODE_ACTIONS: Record<Iwt2Mode, Array<{
bossId: Iwt2BossId
label: string
detail: string
value: string
}>> = {
Raids: [
{
bossId: 'bulldrome',
label: 'Bristlemaw Raid Assignment',
detail: 'Shared arena entry with raid-role notes: tank opens aggro, party spreads for charge lanes, then collapses after ground slam.',
value: 'Start',
},
{
bossId: 'yian-kut-ku',
label: 'Emberquill Raid Assignment',
detail: 'Shared arena entry with raid-role notes: assign ranged pressure, clear bird waves, and keep fire puddles away from party frames.',
value: 'Start',
},
{
bossId: 'great-jaggi',
label: 'Packfang Alpha Raid Assignment',
detail: 'Shared arena entry with raid-role notes: hold center, dodge pack lanes, and stabilize the paladin during howl windows.',
value: 'Start',
},
{
bossId: 'khezu',
label: 'Palevolt Maw Raid Assignment',
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: 'Verdant Wyrm 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: 'Mirehorn 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: 'Stormtail 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',
playstation: 'PlayStation',
nintendo: 'Nintendo',
}
const IWT2_DUNGEON_ACTIONS: Array<Omit<Iwt2NavAction, 'onConfirm'> & { bossId: Iwt2BossId }> = [
{
bossId: 'bulldrome',
key: 'bulldrome',
label: 'Bristlemaw 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: 'Emberquill Arena',
detail: 'Bouncing fireballs, capped fire puddles, and bird waves at 90% and 40% health.',
value: 'Ready',
},
{
bossId: 'great-jaggi',
key: 'great-jaggi',
label: 'Packfang Alpha Arena',
detail: 'Pack howl calls slanted add lanes that punish standing still or grouping badly.',
value: 'Ready',
},
{
bossId: 'khezu',
key: 'khezu',
label: 'Palevolt Maw Arena',
detail: 'Thunder rings create safe bands while lightning circles lock onto weak allies.',
value: 'Ready',
},
{
bossId: 'rathian',
key: 'rathian',
label: 'Verdant Wyrm Arena',
detail: 'Poison tail arcs and targeted poison puddles punish stacked positioning.',
value: 'Ready',
},
{
bossId: 'barroth',
key: 'barroth',
label: 'Mirehorn Arena',
detail: 'Breakable mud armor softens incoming damage while mud spray creates slow zones.',
value: 'Ready',
},
{
bossId: 'tobi-kadachi',
key: 'tobi-kadachi',
label: 'Stormtail 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',
onBack,
title,
}: Iwt2ScreenShellProps) {
useGameAction((action, device) => {
if (device === 'controller' && action === 'back') onBack()
})
return (
<section className="content-screen iwt2-screen-shell" data-game-nav-active="true">
<div className="screen-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
</div>
<button className="back-button" data-controller-nav="skip" onClick={onBack} type="button">
Back
</button>
</div>
{children}
</section>
)
}
export function Iwt2ActionList({ actions }: Iwt2ActionListProps) {
const { selectedIndex, setSelectedIndex } = useIwt2ActionList(actions)
const rowRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
rowRefs.current[selectedIndex]?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}, [selectedIndex])
return (
<div className="iwt2-action-list">
{actions.map((action, index) => (
<button
className={`iwt2-action-row ${selectedIndex === index ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
disabled={action.disabled}
key={action.key}
onClick={action.onConfirm}
onPointerDown={() => {
if (!action.disabled) setSelectedIndex(index)
}}
ref={(element) => {
rowRefs.current[index] = element
}}
type="button"
>
<span>
<strong>{action.label}</strong>
{action.detail && <small>{action.detail}</small>}
</span>
{action.value && <em>{action.value}</em>}
</button>
))}
</div>
)
}
function Iwt2InfoList({ rows }: { rows: Iwt2InfoRow[] }) {
return (
<div className="iwt2-info-list">
{rows.map((row) => (
<div className="iwt2-info-row" key={row.key}>
<span>
<strong>{row.label}</strong>
<small>{row.detail}</small>
</span>
<em>{row.value}</em>
</div>
))}
</div>
)
}
function Iwt2DifficultyStrip({
difficulties,
onSelect,
selected,
}: {
difficulties: Iwt2DifficultyOption[]
onSelect: (difficulty: Iwt2Difficulty) => void
selected: (key: string) => boolean
}) {
return (
<div className="iwt2-difficulty-strip" aria-label="Difficulty options">
{difficulties.map((difficulty) => (
<button
className={`${difficulty.selected ? 'active' : ''} ${selected(`difficulty-${difficulty.slug}`) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(`difficulty-${difficulty.slug}`) ? 'true' : undefined}
disabled={difficulty.locked}
key={difficulty.slug}
onClick={() => {
if (!difficulty.locked) onSelect(difficulty)
}}
type="button"
>
<strong>{difficulty.name}</strong>
<small>{difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`}</small>
</button>
))}
</div>
)
}
export function Iwt2DungeonsScreen({
difficultySlug,
onBack,
onDifficultyChange,
onOpenBoss,
onPreviewBoss,
save,
}: {
difficultySlug: string
onBack: () => void
onDifficultyChange: (difficultySlug: string) => void
onOpenBoss: (bossId: Iwt2BossId, difficulty: Iwt2Difficulty) => void
onPreviewBoss: (bossId: Iwt2BossId) => void
save: Iwt2Save
}) {
const [page, setPage] = useState(0)
const [selectedKey, setSelectedKey] = useState('bulldrome')
const selectedDifficulty = findIwt2Difficulty(IWT2_DUNGEON_DIFFICULTIES, difficultySlug)
const difficultyLocked = !isIwt2DifficultyUnlocked(selectedDifficulty, save.character.level)
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 visibleBosses = useMemo(
() => IWT2_DUNGEON_ACTIONS.slice(pageStart, pageEnd),
[pageEnd, pageStart],
)
const difficultyOptions = useMemo<Iwt2DifficultyOption[]>(() => (
IWT2_DUNGEON_DIFFICULTIES.map((difficulty) => ({
...difficulty,
locked: !isIwt2DifficultyUnlocked(difficulty, save.character.level),
selected: difficulty.slug === selectedDifficulty.slug,
}))
), [save.character.level, selectedDifficulty.slug])
const navEntries = useMemo<Iwt2NavAction[]>(() => [
{
key: 'dungeons-page-prev',
label: 'Previous Page',
disabled: currentPage === 0,
onConfirm: () => setPage((activePage) => Math.max(0, activePage - 1)),
},
{
key: 'dungeons-page-next',
label: 'Next Page',
disabled: currentPage >= pageCount - 1,
onConfirm: () => setPage((activePage) => Math.min(pageCount - 1, activePage + 1)),
},
{
key: 'back',
label: 'Back',
onConfirm: onBack,
},
...difficultyOptions.map((difficulty) => ({
key: `difficulty-${difficulty.slug}`,
label: difficulty.name,
detail: difficulty.description,
value: difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`,
disabled: difficulty.locked,
onConfirm: () => onDifficultyChange(difficulty.slug),
})),
...visibleBosses.map((entry) => {
const reward = iwt2BossCoinRewardFor(entry.bossId, selectedDifficulty.slug)
return {
...entry,
detail: `${entry.detail} ${selectedDifficulty.name}: ${selectedDifficulty.healthMultiplier.toFixed(2)}x HP, ${selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: ${reward.name}.`,
disabled: difficultyLocked,
value: difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name,
onConfirm: () => onOpenBoss(entry.bossId, selectedDifficulty),
}
}),
], [
currentPage,
difficultyLocked,
difficultyOptions,
onBack,
onDifficultyChange,
onOpenBoss,
pageCount,
selectedDifficulty,
visibleBosses,
])
const activeEntry = navEntries.find((entry) => entry.key === selectedKey && !entry.disabled)
?? navEntries.find((entry) => entry.key === visibleBosses[0]?.key)
?? navEntries.find((entry) => !entry.disabled)
function selected(key: string) {
return activeEntry?.key === key
}
function openDungeonNavEntry(entry: Iwt2NavAction | undefined) {
if (!entry || entry.disabled) return
entry.onConfirm()
}
function moveDungeonSelection(action: InputAction) {
if (!action.startsWith('navigate')) return
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry?.key))
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const nextIndex = nextEnabledIndex(navEntries, activeIndex, direction)
const nextKey = navEntries[nextIndex]?.key ?? activeEntry?.key ?? 'back'
setSelectedKey(nextKey)
const nextBoss = visibleBosses.find((boss) => boss.key === nextKey)
if (nextBoss) onPreviewBoss(nextBoss.bossId)
}
useGameAction((action, device) => {
if (device !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
openDungeonNavEntry(activeEntry)
return
}
moveDungeonSelection(action)
})
return (
<section className="content-screen iwt2-screen-shell" data-game-nav-active="true">
<div className="screen-heading">
<div>
<p className="eyebrow">I Want To Heal 2</p>
<h1>Dungeons</h1>
</div>
<div className="iwt2-heading-actions">
<button
className={`iwt2-pager-button ${selected('dungeons-page-prev') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('dungeons-page-prev') ? 'true' : undefined}
disabled={currentPage === 0}
onClick={() => setPage((activePage) => Math.max(0, activePage - 1))}
onPointerDown={() => setSelectedKey('dungeons-page-prev')}
type="button"
>
Prev
</button>
<span className="iwt2-page-counter">{currentPage + 1} / {pageCount}</span>
<button
className={`iwt2-pager-button ${selected('dungeons-page-next') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('dungeons-page-next') ? 'true' : undefined}
disabled={currentPage >= pageCount - 1}
onClick={() => setPage((activePage) => Math.min(pageCount - 1, activePage + 1))}
onPointerDown={() => setSelectedKey('dungeons-page-next')}
type="button"
>
Next
</button>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setSelectedKey('back')}
type="button"
>
Back
</button>
</div>
</div>
<Iwt2DifficultyStrip
difficulties={difficultyOptions}
onSelect={(difficulty) => {
setSelectedKey(`difficulty-${difficulty.slug}`)
onDifficultyChange(difficulty.slug)
}}
selected={selected}
/>
<div className="iwt2-dungeon-list">
<div className="iwt2-action-list">
{visibleBosses.map((boss) => (
<button
className={`iwt2-action-row ${selected(boss.key) ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(boss.key) ? 'true' : undefined}
disabled={difficultyLocked}
key={boss.key}
onClick={() => onOpenBoss(boss.bossId, selectedDifficulty)}
onPointerDown={() => {
setSelectedKey(boss.key)
onPreviewBoss(boss.bossId)
}}
type="button"
>
<span>
<strong>{boss.label}</strong>
{boss.detail && (
<small>
{boss.detail} {selectedDifficulty.name}: {selectedDifficulty.healthMultiplier.toFixed(2)}x HP, {selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: {iwt2BossCoinRewardFor(boss.bossId, selectedDifficulty.slug).name}.
</small>
)}
</span>
<em>{difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name}</em>
</button>
))}
</div>
</div>
</section>
)
}
export function Iwt2ModeScreen({
difficultySlug,
mode,
onBack,
onDifficultyChange,
onOpenBoss,
onPreviewBoss,
save,
}: {
difficultySlug: string
mode: Iwt2Mode
onBack: () => void
onDifficultyChange: (difficultySlug: string) => void
onOpenBoss: (bossId: Iwt2BossId, difficulty: Iwt2Difficulty) => void
onPreviewBoss: (bossId: Iwt2BossId) => void
save: Iwt2Save
}) {
const [selectedKey, setSelectedKey] = useState('back')
const selectedDifficulty = findIwt2Difficulty(IWT2_RAID_DIFFICULTIES, difficultySlug)
const difficultyLocked = !isIwt2DifficultyUnlocked(selectedDifficulty, save.character.level)
const difficultyOptions = useMemo<Iwt2DifficultyOption[]>(() => (
IWT2_RAID_DIFFICULTIES.map((difficulty) => ({
...difficulty,
locked: !isIwt2DifficultyUnlocked(difficulty, save.character.level),
selected: difficulty.slug === selectedDifficulty.slug,
}))
), [save.character.level, selectedDifficulty.slug])
const actions = useMemo<Iwt2NavAction[]>(
() => withBackAction([
...difficultyOptions.map((difficulty) => ({
key: `difficulty-${difficulty.slug}`,
label: difficulty.name,
detail: difficulty.description,
value: difficulty.locked ? `Lv ${difficulty.unlockLevel}` : `${difficulty.healthMultiplier.toFixed(2)}x HP`,
disabled: difficulty.locked,
onConfirm: () => onDifficultyChange(difficulty.slug),
})),
...MODE_ACTIONS[mode].map((entry) => {
const reward = iwt2BossCoinRewardFor(entry.bossId, selectedDifficulty.slug)
return {
key: `${mode}-${entry.bossId}`,
label: entry.label,
detail: `${entry.detail} ${selectedDifficulty.name}: ${selectedDifficulty.healthMultiplier.toFixed(2)}x HP, ${selectedDifficulty.damageMultiplier.toFixed(2)}x damage. Reward: ${reward.name}.`,
value: difficultyLocked ? `Lv ${selectedDifficulty.unlockLevel}` : selectedDifficulty.name,
disabled: difficultyLocked,
onConfirm: () => onOpenBoss(entry.bossId, selectedDifficulty),
}
}),
], onBack),
[difficultyLocked, difficultyOptions, mode, onBack, onDifficultyChange, onOpenBoss, selectedDifficulty],
)
const activeEntry = actions.find((entry) => entry.key === selectedKey && !entry.disabled)
?? actions.find((entry) => !entry.disabled)
const selected = (key: string) => activeEntry?.key === key
useGameAction((action, device) => {
if (device !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action === 'confirm') {
activeEntry?.onConfirm()
return
}
if (!action.startsWith('navigate')) return
const activeIndex = Math.max(0, actions.findIndex((entry) => entry.key === activeEntry?.key))
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const nextIndex = nextEnabledIndex(actions, activeIndex, direction)
const nextKey = actions[nextIndex]?.key ?? activeEntry?.key ?? 'back'
setSelectedKey(nextKey)
const nextBoss = MODE_ACTIONS[mode].find((entry) => `${mode}-${entry.bossId}` === nextKey)
if (nextBoss) onPreviewBoss(nextBoss.bossId)
})
return (
<Iwt2ScreenShell title={mode} onBack={onBack}>
<Iwt2DifficultyStrip
difficulties={difficultyOptions}
onSelect={(difficulty) => {
setSelectedKey(`difficulty-${difficulty.slug}`)
onDifficultyChange(difficulty.slug)
}}
selected={selected}
/>
<div className="iwt2-action-list">
{actions.filter((action) => !action.key.startsWith('difficulty-')).map((action) => (
<button
className={`iwt2-action-row ${selected(action.key) ? 'game-selected selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected(action.key) ? 'true' : undefined}
disabled={action.disabled}
key={action.key}
onClick={action.onConfirm}
onPointerDown={() => {
if (action.disabled) return
setSelectedKey(action.key)
const boss = MODE_ACTIONS[mode].find((entry) => `${mode}-${entry.bossId}` === action.key)
if (boss) onPreviewBoss(boss.bossId)
}}
type="button"
>
<span>
<strong>{action.label}</strong>
{action.detail && <small>{action.detail}</small>}
</span>
{action.value && <em>{action.value}</em>}
</button>
))}
</div>
</Iwt2ScreenShell>
)
}
export function Iwt2RoguelikeScreen({
contentType,
onBack,
onCancelQueue,
onContentTypeChange,
onStart,
onVariantChange,
queueing = false,
queueMessage,
variant,
}: {
contentType: Iwt2RoguelikeContentType
onBack: () => void
onCancelQueue: () => void
onContentTypeChange: (contentType: Iwt2RoguelikeContentType) => void
onStart: () => void
onVariantChange: (variant: Iwt2RoguelikeVariant) => void
queueing?: boolean
queueMessage?: string
variant: Iwt2RoguelikeVariant
}) {
const gameMode = getGameMode()
const [selectedIndex, setSelectedIndex] = useState(0)
const actions = useMemo<Iwt2NavAction[]>(() => {
const base = [
{
key: 'back',
label: 'Back',
onConfirm: onBack,
},
{
key: 'variant-pve',
label: 'PvE',
onConfirm: () => onVariantChange('pve'),
},
{
key: 'variant-pvp',
label: 'PvP',
onConfirm: () => onVariantChange('pvp'),
},
] satisfies Iwt2NavAction[]
if (variant === 'pve') {
base.push(
{
key: 'pve-dungeon',
label: 'Dungeon',
onConfirm: () => onContentTypeChange('dungeon'),
},
{
key: 'pve-raid',
label: 'Raid',
onConfirm: () => onContentTypeChange('raid'),
},
{
key: 'pve-start',
label: 'Start Run',
onConfirm: onStart,
},
)
return base
}
base.push(
{
key: 'pvp-dungeon',
label: 'Dungeon',
onConfirm: () => onContentTypeChange('dungeon'),
},
{
key: 'pvp-raid',
label: 'Raid',
onConfirm: () => onContentTypeChange('raid'),
},
{
key: 'pvp-stadium',
label: 'Stadium',
onConfirm: () => onContentTypeChange('stadium'),
},
{
key: 'pvp-start',
label: 'Start Match',
onConfirm: onStart,
},
)
return base
}, [onBack, onContentTypeChange, onStart, onVariantChange, variant])
const activeIndex = Math.min(selectedIndex, actions.length - 1)
const selected = (key: string) => actions[activeIndex]?.key === key
const open = (key: string) => actions.find((action) => action.key === key)?.onConfirm()
const select = (key: string) => {
const nextIndex = actions.findIndex((action) => action.key === key)
if (nextIndex >= 0) setSelectedIndex(nextIndex)
}
const pveCardTitle = contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
const pveCardCopy = contentType === 'raid'
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'
const pvpCardTitle = gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'
const pvpCardCopy = contentType === 'stadium'
? 'Best-of-5 survival with dampening, equalized gear, and after-round buff buying.'
: gameMode === 'offline'
? 'Offline mode always places you against a random CPU 1-5.'
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'
useGameAction((action, device) => {
if (device !== 'controller') return
if (queueing) {
if (action === 'back' || action === 'confirm') onCancelQueue()
return
}
if (action === 'back') onBack()
else if (action.startsWith('navigate')) {
setSelectedIndex((current) => moveSpatialSelection(actions, current, action))
} else if (action === 'confirm') {
actions[activeIndex]?.onConfirm()
}
})
return (
<section className="content-screen roguelike-screen" data-game-nav-active="true">
<div className="screen-heading">
<div>
<p className="eyebrow">Endless Draft</p>
<h1>Roguelike</h1>
</div>
<button
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('back') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => select('back')}
type="button"
>
Back
</button>
</div>
<div className="roguelike-variant-row">
<button
className={`text-button ${variant === 'pve' ? 'active' : ''} ${selected('variant-pve') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('variant-pve') ? 'true' : undefined}
onClick={() => open('variant-pve')}
onPointerDown={() => select('variant-pve')}
type="button"
>
PvE
</button>
<button
className={`text-button ${variant === 'pvp' ? 'active' : ''} ${selected('variant-pvp') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('variant-pvp') ? 'true' : undefined}
onClick={() => open('variant-pvp')}
onPointerDown={() => select('variant-pvp')}
type="button"
>
PvP
</button>
</div>
{variant === 'pve' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Run Type</p>
<h2>PvE Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pve-dungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pve-dungeon') ? 'true' : undefined}
onClick={() => open('pve-dungeon')}
onPointerDown={() => select('pve-dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pve-raid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pve-raid') ? 'true' : undefined}
onClick={() => open('pve-raid')}
onPointerDown={() => select('pve-raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{contentType === 'raid' ? 'R' : 'D'}</span>
<div>
<strong>{pveCardTitle}</strong>
<small>{pveCardCopy}</small>
</div>
<button
className={`text-button ${selected('pve-start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pve-start') ? 'true' : undefined}
onClick={() => open('pve-start')}
onPointerDown={() => select('pve-start')}
type="button"
>
Start Run
</button>
</div>
</>
)}
{variant === 'pvp' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Match Type</p>
<h2>PvP Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${contentType === 'dungeon' ? 'active' : ''} ${selected('pvp-dungeon') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-dungeon') ? 'true' : undefined}
onClick={() => open('pvp-dungeon')}
onPointerDown={() => select('pvp-dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${contentType === 'raid' ? 'active' : ''} ${selected('pvp-raid') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-raid') ? 'true' : undefined}
onClick={() => open('pvp-raid')}
onPointerDown={() => select('pvp-raid')}
type="button"
>
Raid
</button>
<button
className={`text-button ${contentType === 'stadium' ? 'active' : ''} ${selected('pvp-stadium') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-stadium') ? 'true' : undefined}
onClick={() => open('pvp-stadium')}
onPointerDown={() => select('pvp-stadium')}
type="button"
>
Stadium
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
<div>
<strong>{pvpCardTitle}</strong>
<small>{queueMessage || pvpCardCopy}</small>
</div>
<button
className={`text-button ${selected('pvp-start') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={selected('pvp-start') ? 'true' : undefined}
onClick={() => open('pvp-start')}
onPointerDown={() => select('pvp-start')}
type="button"
>
Start Match
</button>
</div>
</>
)}
{queueing && (
<div className="iwt2-pvp-queue-overlay" data-game-nav-active="true" role="dialog" aria-modal="true">
<div className="iwt2-pvp-queue-panel">
<div className="placeholder-runes">P V P</div>
<p className="eyebrow">Matchmaking</p>
<h2>Queuing for PvP</h2>
<small>{queueMessage || 'Searching for an opponent...'}</small>
<button
className="iwt2-result-button is-secondary game-selected"
data-controller-nav="skip"
data-game-selected="true"
onClick={onCancelQueue}
type="button"
>
Cancel
</button>
</div>
</div>
)}
</section>
)
}
export function Iwt2RoguelikeUpgradeScreen({
activeBuffSummary,
activeDebuffSummary,
contentType,
debuffChoices,
onBack,
onChoose,
selfChoices,
stage,
variant,
}: {
activeBuffSummary?: string
activeDebuffSummary?: string
contentType: Iwt2RoguelikeContentType
debuffChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeOpponentDebuffId>>
onBack: () => void
onChoose: (buffId: Iwt2RoguelikeSelfBuffId, debuffId?: Iwt2RoguelikeOpponentDebuffId) => void
selfChoices: Array<Iwt2RoguelikeChoice<Iwt2RoguelikeSelfBuffId>>
stage: number
variant: Iwt2RoguelikeVariant
}) {
const [selectedBuffId, setSelectedBuffId] = useState<Iwt2RoguelikeSelfBuffId | undefined>()
const [selectedDebuffId, setSelectedDebuffId] = useState<Iwt2RoguelikeOpponentDebuffId | undefined>()
const [selectedIndex, setSelectedIndex] = useState(0)
const activeBuffId = selfChoices.some((choice) => choice.id === selectedBuffId) ? selectedBuffId : undefined
const activeDebuffId = debuffChoices.some((choice) => choice.id === selectedDebuffId) ? selectedDebuffId : undefined
const entries = useMemo<Iwt2UpgradeNavEntry[]>(() => {
if (variant !== 'pvp') {
return [
...selfChoices.map((_, index) => ({
kind: 'upgradeBuff' as const,
index,
row: index,
column: 0,
})),
{
kind: 'upgradeLeave' as const,
row: selfChoices.length,
column: 0,
},
]
}
const nextEntries: Iwt2UpgradeNavEntry[] = [
...selfChoices.map((_, index) => ({
kind: 'upgradeBuff' as const,
index,
row: index,
column: 0,
})),
...debuffChoices.map((_, index) => ({
kind: 'upgradeDebuff' as const,
index,
row: index,
column: 1,
})),
{
kind: 'upgradeContinue' as const,
row: Math.max(selfChoices.length, debuffChoices.length),
column: 1,
disabled: !activeBuffId || !activeDebuffId,
},
]
return nextEntries
}, [activeBuffId, activeDebuffId, debuffChoices, selfChoices, variant])
const activeEntry = activeUpgradeEntry(entries, selectedIndex)
const pvpUpgrade = variant === 'pvp'
const buffHeader = pvpUpgrade ? 'Self Buff' : 'Run Buff'
const headerEyebrow = pvpUpgrade && contentType === 'stadium' ? 'Stadium Buy Round' : 'Choose Edge'
const title = pvpUpgrade
? contentType === 'stadium' ? `Round ${stage} Complete` : `Stage ${stage} Boss Cleared`
: `Roguelike Stage ${stage} Complete`
const continueLabel = contentType === 'stadium' ? 'Finished Buying' : 'Continue'
function entrySelected(kind: Iwt2UpgradeNavEntry['kind'], index?: number) {
if (!activeEntry || activeEntry.kind !== kind) return false
if ('index' in activeEntry || index !== undefined) return 'index' in activeEntry && activeEntry.index === index
return true
}
function setCursor(kind: Iwt2UpgradeNavEntry['kind'], index?: number) {
const nextIndex = entries.findIndex((entry) => {
if (entry.kind !== kind) return false
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
return true
})
if (nextIndex >= 0) setSelectedIndex(nextIndex)
}
function openEntry(entry: Iwt2UpgradeNavEntry | undefined) {
if (!entry || upgradeEntryDisabled(entry)) return
if (entry.kind === 'upgradeBuff') {
const choice = selfChoices[entry.index]
if (!choice) return
if (pvpUpgrade) setSelectedBuffId(choice.id)
else onChoose(choice.id)
} else if (entry.kind === 'upgradeDebuff') {
const choice = debuffChoices[entry.index]
if (choice) setSelectedDebuffId(choice.id)
} else if (entry.kind === 'upgradeContinue') {
if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId)
} else if (entry.kind === 'upgradeLeave') {
onBack()
}
}
useGameAction((action, device) => {
if (device !== 'controller') return
if (action === 'confirm') {
openEntry(activeEntry)
return
}
if (action.startsWith('navigate')) {
setSelectedIndex((current) => moveUpgradeSelection(entries, current, action))
return
}
if (action === 'back' || action === 'pause') return
})
return (
<div className="result-screen" data-game-nav-active="true">
<div className={`pvp-upgrade-dialog ${pvpUpgrade ? '' : 'pve-upgrade-dialog'}`}>
{pvpUpgrade ? (
<div className="pvp-upgrade-header">
<div>
<p className="eyebrow">{headerEyebrow}</p>
<h2>{title}</h2>
</div>
</div>
) : (
<>
<p className="eyebrow">{title}</p>
<h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p>
</>
)}
<div className="pvp-choice-columns">
<div>
<strong>{buffHeader}</strong>
<div className="upgrade-choice-grid">
{selfChoices.map((choice, index) => (
<button
className={`${activeBuffId === choice.id && pvpUpgrade ? 'selected-upgrade' : ''} ${entrySelected('upgradeBuff', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeBuff', index) ? 'true' : undefined}
key={choice.id}
onClick={() => {
if (pvpUpgrade) setSelectedBuffId(choice.id)
else onChoose(choice.id)
}}
onPointerDown={() => setCursor('upgradeBuff', index)}
type="button"
>
<strong>{choice.name}</strong>
<small>{choice.description}</small>
</button>
))}
</div>
</div>
{pvpUpgrade && (
<div>
<strong>Opponent Debuff</strong>
<div className="upgrade-choice-grid">
{debuffChoices.map((choice, index) => (
<button
className={`${activeDebuffId === choice.id ? 'selected-upgrade' : ''} ${entrySelected('upgradeDebuff', index) ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeDebuff', index) ? 'true' : undefined}
key={choice.id}
onClick={() => setSelectedDebuffId(choice.id)}
onPointerDown={() => setCursor('upgradeDebuff', index)}
type="button"
>
<strong>{choice.name}</strong>
<small>{choice.description}</small>
</button>
))}
</div>
</div>
)}
</div>
{!pvpUpgrade && activeBuffSummary && (
<p className="roguelike-upgrade-list">
Active: {activeBuffSummary}
</p>
)}
{pvpUpgrade && (activeBuffSummary || activeDebuffSummary) && (
<p className="roguelike-upgrade-list">
Buffs: {activeBuffSummary || 'None'} | Debuffs: {activeDebuffSummary || 'None'}
</p>
)}
{pvpUpgrade ? (
<button
className={`secondary-result-button ${entrySelected('upgradeContinue') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeContinue') ? 'true' : undefined}
disabled={!activeBuffId || !activeDebuffId}
onClick={() => {
if (activeBuffId && activeDebuffId) onChoose(activeBuffId, activeDebuffId)
}}
onPointerDown={() => setCursor('upgradeContinue')}
type="button"
>
{continueLabel}
</button>
) : (
<button
className={`secondary-result-button ${entrySelected('upgradeLeave') ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={entrySelected('upgradeLeave') ? 'true' : undefined}
onClick={onBack}
onPointerDown={() => setCursor('upgradeLeave')}
type="button"
>
Leave Roguelike
</button>
)}
</div>
</div>
)
}
export function Iwt2HunterProfileScreen({
onBack,
save,
}: {
onBack: () => void
save: Iwt2Save
}) {
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 coins = iwt2BossCoinRewardsFor(bossId).map((coin) => ({
...coin,
quantity: save.collectionLog.dropsFound[coin.id] ?? 0,
}))
const pet = iwt2BossPetRewardFor(bossId)
return {
bossId,
coins,
drops: coins.reduce((total, coin) => total + coin.quantity, 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,
},
...selectedBoss.coins.map((coin) => ({
chance: `Guaranteed 1-3 | iLvl ${coin.itemLevel}`,
glyph: coin.glyph,
key: `drop:${coin.id}`,
name: coin.name,
quantity: coin.quantity,
rarity: coin.rarity,
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: 'stat:total-kills',
label: 'Total Boss Kills',
value: totalBossKills,
},
{
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: 'stat:hunter-level',
label: 'Hunter Level',
value: save.character.level,
},
{
key: 'stat:experience',
label: 'Experience',
value: `${save.character.experience} XP`,
},
{
key: 'stat:collection-drops',
label: 'Collection Drops',
value: totalDrops + totalPets,
detail: `${totalPets} pets, ${save.inventory.length} inventory slots`,
},
], [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 (
<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 bossInitialsForProfile(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
export function Iwt2CustomizeCharacterScreen({
onBack,
onSaveUpdated,
save,
}: {
onBack: () => void
onSaveUpdated: (save: Iwt2Save) => void
save: Iwt2Save
}) {
const [editingName, setEditingName] = useState(false)
const activeAbilities = abilitiesForHealer(save.character.healerStyle)
const abilityRows = activeAbilities.map((ability) => ({
key: ability.id,
label: ability.name,
detail: `${ability.manaCost} resource. ${ability.cooldownSeconds}s cooldown.`,
value: ability.icon,
}))
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'profile-name',
label: 'Profile Name',
detail: 'IWT2 hunter callsign shown in header.',
value: save.character.name,
onConfirm: () => setEditingName(true),
},
...IWT2_HEALER_ORDER.map((healerId) => {
const healer = IWT2_HEALER_METADATA[healerId]
return {
key: `class-${healerId}`,
label: healer.name,
detail: healer.description,
value: save.character.healerStyle === healerId ? 'Selected' : healer.icon,
onConfirm: () => onSaveUpdated(updateIwt2CharacterSettings(save, {
healerStyle: healerId,
})),
}
}),
], onBack), [onBack, onSaveUpdated, save])
if (editingName) {
return (
<Iwt2ScreenShell eyebrow="Character Workshop" title="Profile Name" onBack={() => setEditingName(false)}>
<Iwt2ProfileNameEditor
name={save.character.name}
onCancel={() => setEditingName(false)}
onSave={(name) => {
onSaveUpdated(updateIwt2CharacterSettings(save, { name }))
setEditingName(false)
}}
/>
</Iwt2ScreenShell>
)
}
return (
<Iwt2ScreenShell eyebrow="Character Workshop" title="Customize Character" onBack={onBack}>
<div className="iwt2-customize-layout">
<div>
<p className="eyebrow">Healing Class</p>
<Iwt2ActionList actions={actions} />
</div>
<div className="iwt2-loadout-preview">
<div>
<p className="eyebrow">Active Loadout</p>
<h2>Ability Bar</h2>
<span>Select a class, then start a run.</span>
</div>
<Iwt2InfoList rows={abilityRows} />
</div>
</div>
</Iwt2ScreenShell>
)
}
function Iwt2ProfileNameEditor({
name,
onCancel,
onSave,
}: {
name: string
onCancel: () => void
onSave: (name: string) => void
}) {
const [draft, setDraft] = useState(name)
const [selectedIndex, setSelectedIndex] = useState(0)
const entries = useMemo(() => [
...IWT2_NAME_CHARACTERS.map((character) => ({
key: `char-${character}`,
label: character,
onConfirm: () => setDraft((current) => `${current}${character}`.slice(0, IWT2_NAME_MAX_LENGTH)),
})),
{
key: 'space',
label: 'Space',
onConfirm: () => setDraft((current) => `${current} `.slice(0, IWT2_NAME_MAX_LENGTH)),
},
{
key: 'dash',
label: '-',
onConfirm: () => setDraft((current) => `${current}-`.slice(0, IWT2_NAME_MAX_LENGTH)),
},
{
key: 'erase',
label: 'Erase',
onConfirm: () => setDraft((current) => current.slice(0, -1)),
},
{
key: 'save',
label: 'Save',
onConfirm: () => onSave(draft),
},
{
key: 'cancel',
label: 'Cancel',
onConfirm: onCancel,
},
], [draft, onCancel, onSave])
useGameAction((action, device) => {
if (device !== 'controller') return
if (action === 'back') {
onCancel()
return
}
if (action === 'confirm') {
entries[selectedIndex]?.onConfirm()
return
}
if (action === 'navigateLeft') {
setSelectedIndex((current) => Math.max(0, current - 1))
} else if (action === 'navigateRight') {
setSelectedIndex((current) => Math.min(entries.length - 1, current + 1))
} else if (action === 'navigateUp') {
setSelectedIndex((current) => Math.max(0, current - IWT2_NAME_EDITOR_COLUMNS))
} else if (action === 'navigateDown') {
setSelectedIndex((current) => Math.min(entries.length - 1, current + IWT2_NAME_EDITOR_COLUMNS))
}
})
return (
<div className="iwt2-name-editor">
<label>
<span>Profile Name</span>
<input
maxLength={IWT2_NAME_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value.slice(0, IWT2_NAME_MAX_LENGTH))}
value={draft}
/>
</label>
<div className="iwt2-name-grid">
{entries.map((entry, index) => (
<button
className={selectedIndex === index ? 'game-selected selected' : ''}
data-controller-nav="skip"
data-game-selected={selectedIndex === index ? 'true' : undefined}
key={entry.key}
onClick={entry.onConfirm}
onPointerDown={() => setSelectedIndex(index)}
type="button"
>
{entry.label}
</button>
))}
</div>
</div>
)
}
export function Iwt2CloudSaveScreen({
onBack,
onSaveUpdated,
onlineBackupsAvailable,
save,
}: {
onBack: () => void
onSaveUpdated: (save: Iwt2Save) => void
onlineBackupsAvailable: boolean
save: Iwt2Save
}) {
const [onlineSave, setOnlineSave] = useState<Iwt2Save | null>(null)
const [syncingOnlineSave, setSyncingOnlineSave] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
let cancelled = false
if (!onlineBackupsAvailable) {
setOnlineSave(null)
setMessage('Online save unavailable in offline mode.')
return () => {
cancelled = true
}
}
setSyncingOnlineSave(true)
setMessage('Checking online save...')
loadIwt2OnlineSave()
.then((result) => {
if (cancelled) return
setOnlineSave(result.save)
setMessage(result.save ? 'Online save loaded.' : 'No online save yet.')
})
.catch((reason) => {
if (cancelled) return
setOnlineSave(null)
setMessage(reason instanceof Error ? reason.message : 'Unable to check online save.')
})
.finally(() => {
if (!cancelled) setSyncingOnlineSave(false)
})
return () => {
cancelled = true
}
}, [onlineBackupsAvailable])
const useLocalSave = useCallback(async () => {
if (!onlineBackupsAvailable) {
setMessage('Using local save. Sign in online to update the server copy.')
return
}
setSyncingOnlineSave(true)
setMessage('Uploading local save...')
try {
const result = await writeIwt2OnlineSave(save)
setOnlineSave(result.save)
setMessage('Online save now uses local progress.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to upload local save.')
} finally {
setSyncingOnlineSave(false)
}
}, [onlineBackupsAvailable, save])
const useOnlineSave = useCallback(() => {
if (!onlineSave) return
onSaveUpdated(onlineSave)
setMessage('Local save now uses online progress.')
}, [onlineSave, onSaveUpdated])
const useNewSave = useCallback(() => {
onSaveUpdated(createDefaultIwt2Save())
setMessage('Started a new local IWT2 save.')
}, [onSaveUpdated])
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'use-local',
label: 'Use Local Save',
detail: formatIwt2SaveSummary(save),
value: onlineBackupsAvailable ? 'Upload' : 'Current',
disabled: syncingOnlineSave,
onConfirm: () => {
void useLocalSave()
},
},
{
key: 'use-online',
label: 'Use Online Save',
detail: syncingOnlineSave ? 'Checking online save...' : formatIwt2SaveSummary(onlineSave),
value: onlineSave ? 'Download' : 'Empty',
disabled: syncingOnlineSave || !onlineSave,
onConfirm: useOnlineSave,
},
{
key: 'new-save',
label: 'Use New Save File',
detail: 'Start over with a fresh IWT2 character. Online save is not overwritten until you choose local save.',
value: 'New',
onConfirm: useNewSave,
},
], onBack), [
onlineBackupsAvailable,
onlineSave,
onBack,
save,
syncingOnlineSave,
useLocalSave,
useNewSave,
useOnlineSave,
])
return (
<Iwt2ScreenShell title="Backup Slot" onBack={onBack}>
{message && <p className="iwt2-screen-note">{message}</p>}
<Iwt2ActionList actions={actions} />
</Iwt2ScreenShell>
)
}
export function Iwt2GearUpgradeScreen({
onBack,
onSaveUpdated,
save,
}: {
onBack: () => void
onSaveUpdated: (save: Iwt2Save) => void
save: Iwt2Save
}) {
const [selectedClassId, setSelectedClassId] = useState<Iwt2PlayerClassId>('healer')
const [selectedSlotId, setSelectedSlotId] = useState<Iwt2GearSlotId>('weapon')
const [selectedIndex, setSelectedIndex] = useState(1)
const [message, setMessage] = useState('Gear upgrades use IWT2 boss coins only.')
const classProgress = save.gearProgress[selectedClassId]
const selectedSlot = classProgress.slots[selectedSlotId]
const selectedRecipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][selectedSlotId]
const selectedClassName = gearClassDisplayName(selectedClassId, save)
const upgradeCosts = iwt2GearUpgradeCosts(selectedClassId, selectedSlotId, selectedSlot.level)
const canUpgrade = selectedSlot.level < 5 && canAffordIwt2Costs(save, upgradeCosts)
const infusionUnlocked = isIwt2InfusionUnlocked(classProgress)
const infusionAnchorSlot = selectedSlot.level >= 5
? selectedSlotId
: IWT2_GEAR_SLOTS.find((slotId) => classProgress.slots[slotId].level >= 5) ?? selectedSlotId
const infusionAbilities = iwt2InfusionAbilitiesForClass(selectedClassId)
const navEntries = useMemo<Iwt2GearNavEntry[]>(() => {
const entries: Iwt2GearNavEntry[] = [{ kind: 'back', key: 'back', row: 0, column: 0 }]
IWT2_PARTY_ORDER.forEach((classId, index) => {
entries.push({ kind: 'class', key: `class:${classId}`, row: index + 1, column: 0, classId })
})
IWT2_GEAR_SLOTS.forEach((slotId, index) => {
entries.push({ kind: 'slot', key: `slot:${slotId}`, row: index + 1, column: 1, slotId })
})
entries.push({ kind: 'upgrade', key: 'upgrade', row: 6, column: 1, disabled: !canUpgrade })
infusionAbilities.forEach((ability, index) => {
const costs = iwt2InfusionCosts(selectedClassId, infusionAnchorSlot, ability.id)
const selected = classProgress.infusionAbilityId === ability.id
entries.push({
kind: 'infusion',
key: `infusion:${ability.id}`,
row: index + 1,
column: 2,
abilityId: ability.id,
disabled: selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs),
})
})
return entries
}, [canUpgrade, classProgress.infusionAbilityId, infusionAbilities, infusionAnchorSlot, infusionUnlocked, save, selectedClassId])
const activeEntry = navEntries[Math.min(selectedIndex, navEntries.length - 1)] ?? navEntries[0]
useGameAction((action, device) => {
if (device !== 'controller') return
if (action === 'back') {
onBack()
return
}
if (action.startsWith('navigate')) {
setSelectedIndex((current) => moveGearSelection(navEntries, current, action))
return
}
if (action === 'confirm' && activeEntry) {
activateEntry(activeEntry)
}
})
function activateEntry(entry: Iwt2GearNavEntry) {
if (entry.kind === 'back') {
onBack()
return
}
if (entry.kind === 'class') {
setSelectedClassId(entry.classId)
return
}
if (entry.kind === 'slot') {
setSelectedSlotId(entry.slotId)
return
}
if (entry.kind === 'upgrade') {
if (entry.disabled) return
try {
const nextSave = upgradeIwt2GearSlot(save, selectedClassId, selectedSlotId)
onSaveUpdated(nextSave)
setMessage(`${selectedClassName} ${IWT2_GEAR_SLOT_LABELS[selectedSlotId]} upgraded to +${selectedSlot.level + 1}.`)
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Upgrade failed.')
}
return
}
if (entry.kind === 'infusion') {
if (entry.disabled) return
try {
const nextSave = setIwt2InfusionAbility(save, selectedClassId, infusionAnchorSlot, entry.abilityId)
onSaveUpdated(nextSave)
setMessage(`${IWT2_INFUSION_ABILITIES[entry.abilityId].name} infused into slot 6.`)
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Infusion failed.')
}
}
}
return (
<section className="content-screen iwt2-screen-shell iwt2-gear-screen" data-game-nav-active="true">
<div className="iwt2-gear-layout">
<section className="iwt2-gear-column">
<div className="iwt2-gear-class-list">
{IWT2_PARTY_ORDER.map((classId) => {
const metadata = IWT2_CLASS_METADATA[classId]
const selected = selectedClassId === classId
const focused = activeEntry?.kind === 'class' && activeEntry.classId === classId
const classInfusion = save.gearProgress[classId].infusionAbilityId
const highest = Math.max(...IWT2_GEAR_SLOTS.map((slotId) => save.gearProgress[classId].slots[slotId].level))
const className = gearClassDisplayName(classId, save)
const classSubtitle = gearClassSubtitle(classId, save)
return (
<button
className={`iwt2-gear-class ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={focused ? 'true' : undefined}
key={classId}
onClick={() => setSelectedClassId(classId)}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `class:${classId}`)}
type="button"
>
<span style={{ '--class-color': metadata.color } as CSSProperties}>{metadata.icon}</span>
<div>
<strong>{className}</strong>
<small>{classSubtitle}</small>
<small>Top +{highest}{classInfusion ? ' | Slot 6 set' : ''}</small>
</div>
</button>
)
})}
</div>
</section>
<section className="iwt2-gear-column iwt2-gear-slots-panel">
<div className="iwt2-gear-slot-list">
{IWT2_GEAR_SLOTS.map((slotId) => {
const recipe = IWT2_GEAR_SLOT_RECIPES[selectedClassId][slotId]
const slot = classProgress.slots[slotId]
const focused = activeEntry?.kind === 'slot' && activeEntry.slotId === slotId
const selected = selectedSlotId === slotId
return (
<button
className={`iwt2-gear-slot ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={focused ? 'true' : undefined}
key={slotId}
onClick={() => setSelectedSlotId(slotId)}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `slot:${slotId}`)}
type="button"
>
<span>
<strong>{IWT2_GEAR_SLOT_LABELS[slotId]}</strong>
<small>{IWT2_GEAR_STAT_LABELS[recipe.statId]}</small>
</span>
<em>+{slot.level}</em>
</button>
)
})}
</div>
<button
className={`iwt2-gear-upgrade-button ${activeEntry?.kind === 'upgrade' ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={activeEntry?.kind === 'upgrade' ? 'true' : undefined}
disabled={!canUpgrade}
onClick={() => activateEntry({ kind: 'upgrade', key: 'upgrade', row: 6, column: 1, disabled: !canUpgrade })}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, 'upgrade')}
type="button"
>
Upgrade to +{Math.min(5, selectedSlot.level + 1)}
</button>
</section>
<section className="iwt2-gear-column iwt2-gear-detail-panel">
<div className="iwt2-gear-detail">
<p>
<strong>{IWT2_GEAR_SLOT_LABELS[selectedSlotId]} +{selectedSlot.level}</strong>
<small>{IWT2_GEAR_STAT_LABELS[selectedRecipe.statId]} from {bossName(selectedRecipe.primaryBossId)} and {bossName(selectedRecipe.secondaryBossId)} coins.</small>
</p>
<div className="iwt2-gear-cost-list">
{upgradeCosts.length === 0 ? (
<span>Max rank reached.</span>
) : upgradeCosts.map((cost) => {
const owned = inventoryQuantity(save.inventory, cost.itemId)
return (
<span className={owned >= cost.quantity ? 'met' : 'missing'} key={cost.itemId}>
{owned}/{cost.quantity} {cost.itemName}
</span>
)
})}
</div>
</div>
<div className="iwt2-infusion-list">
{infusionAbilities.map((ability) => {
const selected = classProgress.infusionAbilityId === ability.id
const focused = activeEntry?.kind === 'infusion' && activeEntry.abilityId === ability.id
const costs = iwt2InfusionCosts(selectedClassId, infusionAnchorSlot, ability.id)
const disabled = selected || !infusionUnlocked || !canAffordIwt2Costs(save, costs)
return (
<button
className={`iwt2-infusion-row ${selected ? 'active' : ''} ${focused ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={focused ? 'true' : undefined}
disabled={disabled}
key={ability.id}
onClick={() => activateEntry({ kind: 'infusion', key: `infusion:${ability.id}`, row: 0, column: 2, abilityId: ability.id, disabled })}
onPointerDown={() => selectGearEntry(navEntries, setSelectedIndex, `infusion:${ability.id}`)}
type="button"
>
<span>{ability.icon}</span>
<div>
<strong>{ability.name}</strong>
<small>{ability.description}</small>
<small>{selected ? 'Selected' : infusionUnlocked ? infusionCostText(save, costs) : 'Unlock: any slot to +5'}</small>
</div>
</button>
)
})}
</div>
<footer className="iwt2-gear-message">{message}</footer>
</section>
</div>
</section>
)
}
function gearClassDisplayName(classId: Iwt2PlayerClassId, save: Iwt2Save): string {
if (classId === 'healer') return save.character.name
return IWT2_CLASS_METADATA[classId].name
}
function gearClassSubtitle(classId: Iwt2PlayerClassId, save: Iwt2Save): string {
if (classId === 'healer') return IWT2_HEALER_METADATA[save.character.healerStyle].name
const role = IWT2_CLASS_METADATA[classId].role
return role === 'damage' ? 'Damage' : 'Tank'
}
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
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) {
if (actions.length === 0) return 0
let index = current
for (let steps = 0; steps < actions.length; steps += 1) {
index = Math.min(actions.length - 1, Math.max(0, index + direction))
if (!actions[index]?.disabled) return index
if (index === 0 || index === actions.length - 1) break
}
return clampToEnabled(actions, current)
}
function moveGearSelection(entries: Iwt2GearNavEntry[], current: number, action: string): number {
if (entries.length === 0) return 0
const active = entries[Math.min(current, entries.length - 1)] ?? entries[0]
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry, index }) => {
if (index === current) return false
if (action === 'navigateLeft') return entry.column < active.column
if (action === 'navigateRight') return entry.column > active.column
if (action === 'navigateUp') return entry.column === active.column && entry.row < active.row
if (action === 'navigateDown') return entry.column === active.column && entry.row > active.row
return false
})
if (candidates.length === 0) return current
candidates.sort((a, b) => {
const primaryA = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.column - active.column)
: Math.abs(a.entry.row - active.row)
const primaryB = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.column - active.column)
: Math.abs(b.entry.row - active.row)
if (primaryA !== primaryB) return primaryA - primaryB
const secondaryA = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.entry.row - active.row)
: Math.abs(a.entry.column - active.column)
const secondaryB = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.entry.row - active.row)
: Math.abs(b.entry.column - active.column)
return secondaryA - secondaryB
})
return candidates[0]?.index ?? current
}
function selectGearEntry(
entries: Iwt2GearNavEntry[],
setSelectedIndex: (value: number) => void,
key: string,
) {
const index = entries.findIndex((entry) => entry.key === key)
if (index >= 0) setSelectedIndex(index)
}
function bossName(bossId: Iwt2BossId): string {
return IWT2_BOSS_METADATA[bossId].name
}
function infusionCostText(save: Iwt2Save, costs: Array<{ itemId: string, itemName: string, quantity: number }>): string {
return costs
.map((cost) => `${inventoryQuantity(save.inventory, cost.itemId)}/${cost.quantity} ${cost.itemName}`)
.join(' | ')
}
function activeUpgradeEntry(entries: Iwt2UpgradeNavEntry[], index: number) {
if (entries.length === 0) return undefined
return entries[Math.min(index, entries.length - 1)]
}
function upgradeEntryDisabled(entry: Iwt2UpgradeNavEntry) {
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
}
function moveUpgradeSelection(entries: Iwt2UpgradeNavEntry[], current: number, action: string) {
if (entries.length === 0) return 0
const bounded = Math.min(current, entries.length - 1)
const active = entries[bounded]
const continueIndex = entries.findIndex((entry) => entry.kind === 'upgradeContinue' || entry.kind === 'upgradeLeave')
const lastChoiceIndex = Math.max(0, continueIndex >= 0 ? continueIndex - 1 : entries.length - 1)
if (action === 'navigateRight') return Math.min(lastChoiceIndex, bounded + 1)
if (action === 'navigateLeft') return Math.max(0, bounded - 1)
if (action === 'navigateDown') return continueIndex >= 0 ? continueIndex : bounded
if (action === 'navigateUp' && active?.kind === 'upgradeContinue') return lastChoiceIndex
if (action === 'navigateUp' && active?.kind === 'upgradeLeave') return lastChoiceIndex
return bounded
}
function moveSpatialSelection(actions: Iwt2NavAction[], current: number, action: string) {
if (actions.length === 0) return 0
const bounded = Math.min(current, actions.length - 1)
const active = actions[bounded]
if (!active) return bounded
const activePosition = roguelikeActionPosition(active.key)
const candidates = actions
.map((entry, index) => ({ entry, index, position: roguelikeActionPosition(entry.key) }))
.filter(({ index, position }) => {
if (index === bounded) return false
if (action === 'navigateLeft') return position.row === activePosition.row && position.column < activePosition.column
if (action === 'navigateRight') return position.row === activePosition.row && position.column > activePosition.column
if (action === 'navigateUp') return position.row < activePosition.row
return position.row > activePosition.row
})
if (candidates.length === 0) return bounded
candidates.sort((a, b) => {
const aPrimary = Math.abs(a.position.row - activePosition.row)
+ Math.abs(a.position.column - activePosition.column)
const bPrimary = Math.abs(b.position.row - activePosition.row)
+ Math.abs(b.position.column - activePosition.column)
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(a.position.column - activePosition.column)
: Math.abs(a.position.row - activePosition.row)
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
? Math.abs(b.position.column - activePosition.column)
: Math.abs(b.position.row - activePosition.row)
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
})
return candidates[0]?.index ?? bounded
}
function roguelikeActionPosition(key: string): Iwt2NavPosition {
if (key === 'back') return { row: 0, column: 0 }
if (key === 'variant-pve') return { row: 1, column: 0 }
if (key === 'variant-pvp') return { row: 1, column: 1 }
if (key === 'pve-dungeon' || key === 'pvp-dungeon') return { row: 2, column: 0 }
if (key === 'pve-raid' || key === 'pvp-raid') return { row: 2, column: 1 }
if (key === 'pvp-stadium') return { row: 2, column: 2 }
if (key === 'pvp-start') return { row: 3, column: 1 }
return { row: 3, column: 0 }
}
export function Iwt2SettingsScreen({ onBack }: { onBack: () => void }) {
const {
controllerIconStyle,
directPartyTargeting,
setControllerIconStyle,
setDirectPartyTargeting,
} = useInput()
const actions = useMemo<Iwt2NavAction[]>(() => withBackAction([
{
key: 'direct-targeting',
label: 'Target Party Members With Bindings',
detail: 'When enabled, party frames show direct target button icons instead of D-pad cycling.',
value: directPartyTargeting ? 'On' : 'Off',
onConfirm: () => setDirectPartyTargeting(!directPartyTargeting),
},
...CONTROLLER_ICON_OPTIONS.map((style) => ({
key: `icons-${style}`,
label: `${CONTROLLER_ICON_LABELS[style]} Button Icons`,
detail: 'Changes controller glyphs shown on IWT2 frames and spell controls.',
value: controllerIconStyle === style ? 'Selected' : '',
onConfirm: () => setControllerIconStyle(style),
})),
], onBack), [
controllerIconStyle,
directPartyTargeting,
onBack,
setControllerIconStyle,
setDirectPartyTargeting,
])
return (
<Iwt2ScreenShell title="Settings" onBack={onBack}>
<div className="iwt2-settings-panel">
<Iwt2ActionList actions={actions} />
<div className="iwt2-controller-preview" aria-label="Current controller icon style">
<span>{CONTROLLER_ICON_LABELS[controllerIconStyle]}</span>
<ControllerStylePreview iconStyle={controllerIconStyle} />
</div>
</div>
</Iwt2ScreenShell>
)
}