2432 lines
87 KiB
TypeScript
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>
|
|
)
|
|
}
|