Files
i-want-to-heal/src/modes/iwt2/screens/BossArenaScreen.tsx
T
2026-07-06 23:49:14 -04:00

1121 lines
42 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import type { MovementVector } from '../../../input'
import { useGameAction, useInput, useMovementVectorRef } from '../../../input'
import {
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../../../dualScreen'
import type { PartyMember, Role, Spell } from '../../../game'
import {
createInitialIwt2ArenaState,
tickIwt2Arena,
type Iwt2ArenaState,
} from '../sim/arenaState'
import type { Iwt2ArenaBounds, Iwt2EntityId } from '../sim'
import { castIwt2HealerAbility } from '../sim'
import { PhaserArena } from '../render/PhaserArena'
import {
recordIwt2BossKillReward,
type Iwt2BossDropAward,
type Iwt2BossPetAward,
type Iwt2Save,
} from '../save/iwt2Repository'
import { AbilityBar } from '../components/AbilityBar'
import { BossHud } from '../components/BossHud'
import { PartyFrames } from '../components/PartyFrames'
import { IWT2_CLASS_METADATA } from '../content/classes'
import { IWT2_ABILITY_ACTIONS, IWT2_TARGET_ACTIONS } from '../content/controls'
import type { Iwt2Difficulty } from '../content/difficulties'
import { abilitiesForHealer, type Iwt2HealerAbility, type Iwt2TriggeredAbilityEffect } from '../content/healerAbilities'
import { IWT2_BOSS_METADATA, type Iwt2BossId } from '../content/bosses'
import type {
Iwt2RoguelikeContentType,
Iwt2RoguelikeOpponentDebuffId,
Iwt2RoguelikeSelfBuffId,
Iwt2RoguelikeVariant,
} from '../content/roguelike'
import {
createRoguelikePressureState,
roguelikeIncomingDamageScale,
} from '../sim/roguelikePressure'
import { applyIwt2PveGearStats, applyIwt2PveGearToHealerAbilities } from '../sim/equipmentStats'
import {
IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID,
IWT2_BARKSKIN_HOT_BONUS_BUFF_ID,
IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID,
} from '../content/roguelike'
type ArenaStatus = 'playing' | 'paused' | 'victory' | 'defeat'
type OverlayAction = 'primary' | 'requeue' | 'menu'
type OverlayNavEntry = {
action: OverlayAction
row: number
}
const DEFAULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'menu', row: 1 },
]
const PVP_RESULT_OVERLAY_NAV_ENTRIES: OverlayNavEntry[] = [
{ action: 'primary', row: 0 },
{ action: 'requeue', row: 1 },
{ action: 'menu', row: 2 },
]
const EMPTY_ROGUELIKE_BUFFS: Iwt2RoguelikeSelfBuffId[] = []
const IWT2_PVP_BOSS_HEALTH_MULTIPLIER = 0.7
const IWT2_TOP_PARTY_RAIL_WIDTH = 172
const IWT2_THOR_TOP_PARTY_RAIL_WIDTH = 154
const IWT2_THOR_TOP_BREAKPOINT_WIDTH = 1000
const IWT2_THOR_TOP_BREAKPOINT_HEIGHT = 620
type BossArenaScreenProps = {
bossId: Iwt2BossId
bossIds?: Iwt2BossId[]
difficulty?: Iwt2Difficulty
modeLabel?: string
save: Iwt2Save
onBack: () => void
onMainMenu?: () => void
onPvpRequeue?: () => void
onSaveUpdated: (save: Iwt2Save) => void
roguelikeRun?: {
buffs: Iwt2RoguelikeSelfBuffId[]
contentType: Iwt2RoguelikeContentType
debuffs: Iwt2RoguelikeOpponentDebuffId[]
onVictory: () => void
stage: number
variant: Iwt2RoguelikeVariant
}
}
export function BossArenaScreen({ bossId, bossIds, difficulty, modeLabel, save, onBack, onMainMenu, onPvpRequeue, onSaveUpdated, roguelikeRun }: BossArenaScreenProps) {
const bossMetadata = IWT2_BOSS_METADATA[bossId]
const pvpRoguelike = roguelikeRun?.variant === 'pvp'
const pveGearActive = roguelikeRun?.variant !== 'pvp'
const bossHealthScale = roguelikeRun ? roguelikeBossHealthScale(roguelikeRun.stage) : 1
const difficultyHealthScale = difficulty?.healthMultiplier ?? 1
const difficultyDamageScale = difficulty?.damageMultiplier ?? 1
const roguelikeStage = roguelikeRun?.stage
const roguelikeContentType = roguelikeRun?.contentType
const roguelikeBuffs = roguelikeRun?.buffs ?? EMPTY_ROGUELIKE_BUFFS
const roguelikeDamageScale = roguelikeRun
? roguelikeIncomingDamageScale(roguelikeRun.stage, roguelikeRun.contentType)
: 1
const experienceMultiplier = difficulty?.experienceMultiplier ?? 1
const difficultySlug = difficulty?.slug ?? 'initiate'
const pvpBossHealthScale = pvpRoguelike ? IWT2_PVP_BOSS_HEALTH_MULTIPLIER : 1
const combinedBossHealthScale = bossHealthScale * difficultyHealthScale * pvpBossHealthScale
const combinedDamageScale = difficultyDamageScale * roguelikeDamageScale
const arenaBounds = useMemo(() => createTopScreenArenaBounds(), [])
const [arenaState, setArenaState] = useState<Iwt2ArenaState>(() => createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
roguelikeBuffs,
createPressureState(roguelikeStage, roguelikeContentType),
pveGearActive ? save.gearProgress : undefined,
arenaBounds,
))
const [opponentArenaState, setOpponentArenaState] = useState<Iwt2ArenaState | null>(() => (
pvpRoguelike
? createInitialIwt2ArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
createPressureState(roguelikeStage, roguelikeContentType),
arenaBounds,
)
: null
))
const [abilityCooldowns, setAbilityCooldowns] = useState<Record<string, number>>({})
const [status, setStatus] = useState<ArenaStatus>('playing')
const [selectedOverlayAction, setSelectedOverlayAction] = useState<OverlayAction>('primary')
const [selectedPartyId, setSelectedPartyId] = useState<Iwt2EntityId>('player-healer')
const [dropAwards, setDropAwards] = useState<Iwt2BossDropAward[]>([])
const [petAwards, setPetAwards] = useState<Iwt2BossPetAward[]>([])
const stateRef = useRef(arenaState)
const opponentStateRef = useRef<Iwt2ArenaState | null>(opponentArenaState)
const statusRef = useRef(status)
const selectedOverlayActionRef = useRef<OverlayAction>(selectedOverlayAction)
const selectedPartyIdRef = useRef<Iwt2EntityId>(selectedPartyId)
const saveRef = useRef(save)
const recordedKillIdsRef = useRef<Set<Iwt2BossId>>(new Set())
const lastPublishTimeRef = useRef(0)
const lastHudSignatureRef = useRef('')
const abilityCooldownsRef = useRef<Record<string, number>>({})
const movementRef = useMovementVectorRef(status === 'playing')
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const { enabled: dualScreenEnabled } = useDualScreen()
const activeBindings = bindings[lastDevice]
useEffect(() => {
stateRef.current = arenaState
}, [arenaState])
useEffect(() => {
opponentStateRef.current = opponentArenaState
}, [opponentArenaState])
useEffect(() => {
statusRef.current = status
}, [status])
useEffect(() => {
selectedOverlayActionRef.current = selectedOverlayAction
}, [selectedOverlayAction])
useEffect(() => {
selectedPartyIdRef.current = selectedPartyId
}, [selectedPartyId])
useEffect(() => {
saveRef.current = save
}, [save])
const resetArena = useCallback(() => {
const pressureState = createPressureState(roguelikeStage, roguelikeContentType)
const next = createArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
roguelikeBuffs,
pressureState,
pveGearActive ? save.gearProgress : undefined,
arenaBounds,
)
const nextOpponentState = pvpRoguelike
? createInitialIwt2ArenaState(
bossId,
bossIds,
combinedBossHealthScale,
combinedDamageScale,
pressureState,
arenaBounds,
)
: null
recordedKillIdsRef.current = new Set()
abilityCooldownsRef.current = {}
lastHudSignatureRef.current = arenaHudSignature(next)
stateRef.current = next
opponentStateRef.current = nextOpponentState
setArenaState(next)
setOpponentArenaState(nextOpponentState)
setAbilityCooldowns({})
setDropAwards([])
setPetAwards([])
setSelectedOverlayAction('primary')
setStatus('playing')
}, [arenaBounds, bossId, bossIds, combinedBossHealthScale, combinedDamageScale, pveGearActive, pvpRoguelike, roguelikeBuffs, roguelikeContentType, roguelikeStage, save.gearProgress])
const showOverlay = useCallback((nextStatus: Exclude<ArenaStatus, 'playing'>) => {
setSelectedOverlayAction('primary')
statusRef.current = nextStatus
setStatus(nextStatus)
}, [])
const resumeArena = useCallback(() => {
statusRef.current = 'playing'
setStatus('playing')
}, [])
const abilities = useMemo(
() => {
const baseAbilities = abilitiesForHealer(
save.character.healerStyle,
pveGearActive ? save.gearProgress.healer.infusionAbilityId : null,
)
const gearAbilities = pveGearActive
? applyIwt2PveGearToHealerAbilities(baseAbilities, save.gearProgress)
: baseAbilities
return applyRoguelikeModifiers(
gearAbilities,
roguelikeRun?.buffs ?? [],
roguelikeRun?.debuffs ?? [],
)
},
[pveGearActive, roguelikeRun?.buffs, roguelikeRun?.debuffs, save.character.healerStyle, save.gearProgress],
)
const castAbility = useCallback((ability: Iwt2HealerAbility) => {
if (statusRef.current !== 'playing') return
if ((abilityCooldownsRef.current[ability.id] ?? 0) > 0) return
const result = castIwt2HealerAbility(stateRef.current, ability, selectedPartyIdRef.current)
if (!result.cast) return
const nextCooldowns = {
...abilityCooldownsRef.current,
[ability.id]: ability.cooldownSeconds,
}
abilityCooldownsRef.current = nextCooldowns
stateRef.current = result.state
lastHudSignatureRef.current = arenaHudSignature(result.state)
setAbilityCooldowns(nextCooldowns)
setArenaState(result.state)
}, [])
const moveOverlaySelection = useCallback((action: string) => {
setSelectedOverlayAction((current) => {
const entries = overlayNavEntriesFor(statusRef.current, pvpRoguelike)
const active = entries.find((entry) => entry.action === current) ?? entries[0]
const candidates = entries.filter((entry) => {
if (entry.action === current) return false
if (action === 'navigateUp') return entry.row < active.row
if (action === 'navigateDown') return entry.row > active.row
return false
})
if (candidates.length === 0) return current
candidates.sort((a, b) => Math.abs(a.row - active.row) - Math.abs(b.row - active.row))
return candidates[0]?.action ?? current
})
}, [pvpRoguelike])
const activateOverlayAction = useCallback((overlayAction = selectedOverlayActionRef.current) => {
if (overlayAction === 'requeue') {
onPvpRequeue?.()
return
}
if (overlayAction === 'menu') {
if (statusRef.current === 'paused') {
const returnToMainMenu = onMainMenu ?? onBack
returnToMainMenu()
return
}
onBack()
return
}
if (statusRef.current === 'paused') {
resumeArena()
return
}
if (statusRef.current === 'victory' && roguelikeRun) {
roguelikeRun.onVictory()
return
}
resetArena()
}, [onBack, onMainMenu, onPvpRequeue, resetArena, resumeArena, roguelikeRun])
useGameAction((action, device) => {
if (statusRef.current !== 'playing' && (device === 'controller' || device === 'pc')) {
if (action.startsWith('navigate')) {
moveOverlaySelection(action)
return
}
if (action === 'confirm') {
activateOverlayAction()
return
}
if (action === 'back') {
if (statusRef.current === 'paused') resumeArena()
return
}
}
if (action === 'pause' || action === 'back') {
if (statusRef.current === 'playing') showOverlay('paused')
else if (statusRef.current === 'paused') resumeArena()
return
}
if (statusRef.current === 'playing' && action.startsWith('ability')) {
const ability = abilities[IWT2_ABILITY_ACTIONS.indexOf(action)]
if (ability) castAbility(ability)
return
}
if (
device === 'controller'
&& statusRef.current === 'playing'
&& !directPartyTargeting
&& (
action === 'navigateUp'
|| action === 'navigateLeft'
|| action === 'previousTarget'
|| action === 'navigateDown'
|| action === 'navigateRight'
|| action === 'nextTarget'
)
) {
const previous = action === 'navigateUp' || action === 'navigateLeft' || action === 'previousTarget'
setSelectedPartyId((current) => nextTargetId(stateRef.current, current, previous ? -1 : 1))
return
}
if (action.startsWith('targetParty')) {
if (statusRef.current !== 'playing') return
const index = Number(action.replace('targetParty', '')) - 1
const target = stateRef.current.party[index]
if (target) setSelectedPartyId(target.id)
return
}
})
const onStep = useCallback((movement: MovementVector, dtSeconds: number) => {
if (statusRef.current !== 'playing') return stateRef.current
abilityCooldownsRef.current = tickCooldowns(abilityCooldownsRef.current, dtSeconds)
const next = tickIwt2Arena(stateRef.current, {
moveX: movement.x,
moveY: movement.y,
}, dtSeconds)
stateRef.current = next
let nextOpponentState = opponentStateRef.current
if (pvpRoguelike && nextOpponentState) {
const opponentMovement = iwt2OpponentMovement(nextOpponentState)
nextOpponentState = tickIwt2Arena(
nextOpponentState,
{
moveX: opponentMovement.x,
moveY: opponentMovement.y,
},
dtSeconds,
)
opponentStateRef.current = nextOpponentState
}
const newlyDefeatedBosses = next.bosses.filter((boss) => boss.health <= 0 && !recordedKillIdsRef.current.has(boss.bossId))
if (newlyDefeatedBosses.length > 0) {
const nextRecordedIds = new Set(recordedKillIdsRef.current)
let updatedSave = saveRef.current
const newDropAwards: Iwt2BossDropAward[] = []
const newPetAwards: Iwt2BossPetAward[] = []
for (const defeatedBoss of newlyDefeatedBosses) {
nextRecordedIds.add(defeatedBoss.bossId)
const reward = recordIwt2BossKillReward(updatedSave, defeatedBoss.bossId, {
difficultySlug,
experienceMultiplier,
})
updatedSave = reward.save
newDropAwards.push(reward.dropAwarded)
if (reward.petAwarded) newPetAwards.push(reward.petAwarded)
}
recordedKillIdsRef.current = nextRecordedIds
saveRef.current = updatedSave
if (newDropAwards.length > 0) setDropAwards((current) => [...current, ...newDropAwards])
if (newPetAwards.length > 0) setPetAwards((current) => [...current, ...newPetAwards])
onSaveUpdated(updatedSave)
}
if (next.bosses.every((boss) => boss.health <= 0)) {
if (pvpRoguelike && roguelikeRun) {
statusRef.current = 'victory'
setStatus('victory')
roguelikeRun.onVictory()
} else {
showOverlay('victory')
}
} else if (next.party.every((member) => member.health <= 0)) {
showOverlay('defeat')
}
const hudSignature = arenaHudSignature(next)
if (
hudSignature !== lastHudSignatureRef.current
|| next.time - lastPublishTimeRef.current >= 0.08
|| next.bosses.some((boss) => boss.health <= 0)
) {
lastHudSignatureRef.current = hudSignature
lastPublishTimeRef.current = next.time
setArenaState(next)
if (pvpRoguelike) setOpponentArenaState(nextOpponentState)
setAbilityCooldowns(abilityCooldownsRef.current)
}
return next
}, [difficultySlug, experienceMultiplier, onSaveUpdated, pvpRoguelike, roguelikeRun, showOverlay])
const targetBindings = directPartyTargeting
? IWT2_TARGET_ACTIONS.map((action) => activeBindings[action] ?? null)
: undefined
const playerMana = arenaState.party.find((member) => member.id === 'player-healer')?.mana ?? 0
const alivePartyCount = arenaState.party.filter((member) => member.health > 0).length
const totalPartyDamage = arenaState.party.reduce((total, member) => total + member.damageDone, 0)
const defeatedBossCount = arenaState.bosses.filter((boss) => boss.health <= 0).length
const victoryExperience = Math.round(arenaState.bosses.length * 125 * experienceMultiplier)
const bossTitle = formatBossEncounterTitle(arenaState.bosses)
const overlayPrimaryLabel = status === 'paused'
? 'Resume'
: status === 'victory' && roguelikeRun
? 'Choose Upgrade'
: pvpRoguelike && status !== 'playing'
? 'Rematch'
: 'Restart'
const overlayTitle = status === 'victory'
? `${bossTitle} Down`
: status === 'defeat'
? 'Party Defeated'
: 'Arena Paused'
const overlayEyebrow = status === 'victory'
? 'Hunt Complete'
: status === 'defeat'
? 'Recovery'
: 'Paused'
const overlayTone = status === 'victory'
? 'is-victory'
: status === 'defeat'
? 'is-defeat'
: 'is-paused'
const pauseTitle = arenaPauseTitle(roguelikeRun, modeLabel ?? bossMetadata.name)
const pauseCopy = roguelikeRun?.variant === 'pvp'
? undefined
: 'Combat is stopped. Continue the fight or return to the main menu.'
const dualScreenState = useMemo(
() => buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings: activeBindings,
cooldowns: abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel: modeLabel ?? 'Arena',
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}),
[
abilities,
activeBindings,
arenaState,
abilityCooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
],
)
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
className="game-shell iwt2-arena-shell"
data-combat-active={status === 'playing' ? 'true' : 'false'}
data-continuous-movement-active={status === 'playing' ? 'true' : undefined}
>
<section className="iwt2-arena-layout">
<div className="iwt2-arena-stage">
<BossHud bosses={arenaState.bosses} />
<PartyFrames
controllerIconStyle={controllerIconStyle}
onTarget={setSelectedPartyId}
party={arenaState.party}
selectedPartyId={selectedPartyId}
targetBindings={targetBindings}
/>
<PhaserArena
movementRef={movementRef}
onStep={onStep}
selectedPartyIdRef={selectedPartyIdRef}
stateRef={stateRef}
/>
{status === 'paused' && (
<div className="pause-screen iwt2-arena-overlay is-paused" data-game-nav-active="true" role="dialog" aria-modal="true">
<div>
<p className="eyebrow">Paused</p>
<h2>{pauseTitle}</h2>
{pauseCopy && <p>{pauseCopy}</p>}
<button
className={selectedOverlayAction === 'primary' ? 'game-selected' : ''}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
Continue
</button>
<button
className={`secondary-result-button ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
Main Menu
</button>
</div>
</div>
)}
{status !== 'playing' && status !== 'paused' && (
<div className={`pause-screen iwt2-arena-overlay ${overlayTone}`} data-game-nav-active="true">
<div className="iwt2-result-panel">
<div className="iwt2-result-crest" style={{ '--boss-color': bossMetadata.color, '--boss-accent': bossMetadata.accentColor } as CSSProperties}>
<span>{arenaState.bosses.map((boss) => IWT2_BOSS_METADATA[boss.bossId].icon).join('')}</span>
</div>
<p className="eyebrow">{overlayEyebrow}</p>
<h1>{overlayTitle}</h1>
{roguelikeRun && (
<p className="iwt2-result-hint">
{roguelikeRun.variant.toUpperCase()} {formatRoguelikeContentType(roguelikeRun.contentType)} Stage {roguelikeRun.stage}
</p>
)}
<div className="iwt2-result-summary" aria-label="Arena result summary">
<span>
<strong>{formatArenaTime(arenaState.time)}</strong>
Clear
</span>
<span>
<strong>{alivePartyCount}/{arenaState.party.length}</strong>
Standing
</span>
<span>
<strong>{defeatedBossCount}/{arenaState.bosses.length}</strong>
Bosses
</span>
<span>
<strong>{Math.round(totalPartyDamage)}</strong>
Damage
</span>
</div>
{status === 'victory' && (
<div className="iwt2-result-reward">
<span>+{victoryExperience} XP</span>
<span>Log +{arenaState.bosses.length}</span>
{dropAwards.map((award) => (
<span key={`${award.dropId}:${award.quantityAfter}`}>
{award.dropName}{award.quantity > 1 ? ` x${award.quantity}` : ''}
</span>
))}
{petAwards.map((award) => (
<span key={`${award.petId}:${award.quantityAfter}`}>
{award.petName}{award.duplicate ? ` x${award.quantityAfter}` : ''}
</span>
))}
</div>
)}
<div className="iwt2-overlay-actions">
<button
className={`iwt2-result-button is-primary ${selectedOverlayAction === 'primary' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'primary' ? 'true' : undefined}
onClick={() => activateOverlayAction('primary')}
onPointerDown={() => setSelectedOverlayAction('primary')}
type="button"
>
{overlayPrimaryLabel}
</button>
{pvpRoguelike && (
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'requeue' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'requeue' ? 'true' : undefined}
onClick={() => activateOverlayAction('requeue')}
onPointerDown={() => setSelectedOverlayAction('requeue')}
type="button"
>
Requeue
</button>
)}
<button
className={`iwt2-result-button is-secondary ${selectedOverlayAction === 'menu' ? 'game-selected' : ''}`}
data-game-selected={selectedOverlayAction === 'menu' ? 'true' : undefined}
onClick={() => activateOverlayAction('menu')}
onPointerDown={() => setSelectedOverlayAction('menu')}
type="button"
>
Menu
</button>
</div>
{lastDevice === 'controller' && (
<small className="iwt2-result-hint">D-pad selects, A confirms</small>
)}
</div>
</div>
)}
<AbilityBar
abilities={abilities}
cooldowns={abilityCooldowns}
mana={playerMana}
onCast={castAbility}
/>
</div>
</section>
</main>
)
}
function formatArenaTime(seconds: number): string {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
const remainder = safeSeconds % 60
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
function roguelikeBossHealthScale(stage: number): number {
return 1 + Math.max(0, stage - 1) * 0.1
}
function overlayNavEntriesFor(status: ArenaStatus, pvpRoguelike: boolean): OverlayNavEntry[] {
if (pvpRoguelike && (status === 'victory' || status === 'defeat')) return PVP_RESULT_OVERLAY_NAV_ENTRIES
return DEFAULT_OVERLAY_NAV_ENTRIES
}
function arenaHudSignature(state: Iwt2ArenaState): string {
return [
...state.bosses.map((boss) => [
boss.id,
Math.ceil(boss.health),
boss.attackPhase,
].join(':')),
state.hostileAdds.length,
state.hazards.length,
...state.party.map((member) => [
member.id,
Math.ceil(member.health),
Math.ceil(member.shield),
Math.ceil(member.mana),
Math.ceil(Math.max(member.status.stunnedSeconds, member.status.knockedDownSeconds) * 10),
member.hotEffects.map((effect) => `${effect.id}:${Math.ceil(effect.remainingSeconds)}:${Math.ceil(effect.nextTickInSeconds * 10)}`).join(','),
].join(':')),
].join('|')
}
function formatBossEncounterTitle(bosses: Iwt2ArenaState['bosses']): string {
return bosses
.map((boss) => IWT2_BOSS_METADATA[boss.bossId].name)
.join(' + ')
}
function nextTargetId(state: Iwt2ArenaState, currentId: Iwt2EntityId, direction: -1 | 1): Iwt2EntityId {
const living = state.party.filter((member) => member.health > 0)
const targets = living.length > 0 ? living : state.party
const currentIndex = Math.max(0, targets.findIndex((member) => member.id === currentId))
const nextIndex = (currentIndex + direction + targets.length) % targets.length
return targets[nextIndex]?.id ?? currentId
}
function tickCooldowns(cooldowns: Record<string, number>, dt: number) {
let changed = false
const next: Record<string, number> = {}
for (const [id, remaining] of Object.entries(cooldowns)) {
const value = Math.max(0, remaining - dt)
if (value !== remaining) changed = true
if (value > 0) next[id] = value
}
return changed ? next : cooldowns
}
function createArenaState(
bossId: Iwt2BossId,
bossIds: Iwt2BossId[] | undefined,
bossHealthScale: number,
partyDamageTakenScale: number,
buffs: Iwt2RoguelikeSelfBuffId[],
roguelikePressure: ReturnType<typeof createPressureState>,
gearProgress?: Iwt2Save['gearProgress'],
bounds?: Iwt2ArenaBounds,
): Iwt2ArenaState {
const baseState = createInitialIwt2ArenaState(
bossId,
bossIds,
bossHealthScale,
partyDamageTakenScale,
roguelikePressure,
bounds,
)
const state = gearProgress ? applyIwt2PveGearStats(baseState, gearProgress) : baseState
const shieldedDamageTakenMultiplier = shieldedDamageTakenMultiplierForBuffs(buffs)
const shieldedHotHealingMultiplier = buffs.includes(IWT2_BARKSKIN_HOT_BONUS_BUFF_ID) ? 1.25 : undefined
if (shieldedDamageTakenMultiplier === undefined && shieldedHotHealingMultiplier === undefined) return state
return {
...state,
party: state.party.map((member) => ({
...member,
shieldedDamageTakenMultiplier,
shieldedHotHealingMultiplier,
})),
}
}
function createTopScreenArenaBounds(): Iwt2ArenaBounds {
if (typeof window === 'undefined') return { width: 960, height: 540 }
const thorTopLayout = window.innerWidth <= IWT2_THOR_TOP_BREAKPOINT_WIDTH
&& window.innerHeight <= IWT2_THOR_TOP_BREAKPOINT_HEIGHT
const railWidth = thorTopLayout ? IWT2_THOR_TOP_PARTY_RAIL_WIDTH : IWT2_TOP_PARTY_RAIL_WIDTH
return {
width: Math.max(320, Math.round(window.innerWidth - railWidth)),
height: Math.max(240, Math.round(window.innerHeight)),
}
}
function createPressureState(
stage: number | undefined,
contentType: Iwt2RoguelikeContentType | undefined,
) {
return stage && contentType ? createRoguelikePressureState(stage, contentType) : undefined
}
function shieldedDamageTakenMultiplierForBuffs(buffs: Iwt2RoguelikeSelfBuffId[]) {
const multipliers: number[] = []
if (buffs.includes(IWT2_SUN_WARD_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.5)
if (buffs.includes(IWT2_AEGIS_SCRIPT_DAMAGE_REDUCTION_BUFF_ID)) multipliers.push(0.7)
return multipliers.length > 0 ? Math.min(...multipliers) : undefined
}
function applyRoguelikeModifiers(
abilities: Iwt2HealerAbility[],
buffs: Iwt2RoguelikeSelfBuffId[],
debuffs: Iwt2RoguelikeOpponentDebuffId[],
): Iwt2HealerAbility[] {
if (buffs.length === 0 && debuffs.length === 0) return abilities
const abilityById = new Map(abilities.map((ability) => [ability.id, ability]))
const renewEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-renew'))
const sunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'))
const halfSunWardEffect = triggeredEffectFromAbility(abilityById.get('dawnweaver-sun-ward'), 0.5)
const seedOfLifeEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-seed-of-life'))
const barkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'))
const halfBarkskinEffect = triggeredEffectFromAbility(abilityById.get('lifebinder-barkskin'), 0.5)
const mendingRuneEffect = triggeredEffectFromAbility(abilityById.get('runesage-mending-rune'))
const aegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'))
const halfAegisScriptEffect = triggeredEffectFromAbility(abilityById.get('runesage-aegis-script'), 0.5)
return abilities.map((ability) => {
const slot = String(ability.slot)
const costDown = countStacks(buffs, `slot${slot}-cost-down`)
const costUp = countStacks(debuffs, `opp-slot${slot}-cost-up`)
const cooldownDown = countStacks(buffs, `slot${slot}-cooldown-down`)
const cooldownUp = countStacks(debuffs, `opp-slot${slot}-cooldown-up`)
const extraTargets = countStacks(buffs, `slot${slot}-extra-target`)
const triggeredEffects = iwt2TriggeredEffectsForAbility({
ability,
aegisScriptEffect,
barkskinEffect,
buffs,
halfAegisScriptEffect,
halfBarkskinEffect,
halfSunWardEffect,
mendingRuneEffect,
renewEffect,
seedOfLifeEffect,
sunWardEffect,
})
if (costDown === 0 && costUp === 0 && cooldownDown === 0 && cooldownUp === 0 && extraTargets === 0 && triggeredEffects.length === 0) return ability
return {
...ability,
cooldownSeconds: roundModifier(ability.cooldownSeconds * 0.75 ** cooldownDown * 1.25 ** cooldownUp),
extraTargets: (ability.extraTargets ?? 0) + extraTargets,
manaCost: Math.max(1, Math.ceil(ability.manaCost * 0.75 ** costDown * 1.25 ** costUp)),
triggeredEffects: triggeredEffects.length > 0 ? triggeredEffects : ability.triggeredEffects,
}
})
}
function iwt2TriggeredEffectsForAbility({
ability,
aegisScriptEffect,
barkskinEffect,
buffs,
halfAegisScriptEffect,
halfBarkskinEffect,
halfSunWardEffect,
mendingRuneEffect,
renewEffect,
seedOfLifeEffect,
sunWardEffect,
}: {
ability: Iwt2HealerAbility
aegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
barkskinEffect: Iwt2TriggeredAbilityEffect | undefined
buffs: Iwt2RoguelikeSelfBuffId[]
halfAegisScriptEffect: Iwt2TriggeredAbilityEffect | undefined
halfBarkskinEffect: Iwt2TriggeredAbilityEffect | undefined
halfSunWardEffect: Iwt2TriggeredAbilityEffect | undefined
mendingRuneEffect: Iwt2TriggeredAbilityEffect | undefined
renewEffect: Iwt2TriggeredAbilityEffect | undefined
seedOfLifeEffect: Iwt2TriggeredAbilityEffect | undefined
sunWardEffect: Iwt2TriggeredAbilityEffect | undefined
}): Iwt2TriggeredAbilityEffect[] {
const effects = [...ability.triggeredEffects ?? []]
const addEffect = (effect: Iwt2TriggeredAbilityEffect | undefined) => {
if (effect && !effects.some((existing) => existing.id === effect.id && existing.power === effect.power)) {
effects.push(effect)
}
}
if (ability.id === 'dawnweaver-mend') {
if (buffs.includes('dawnweaver-mend-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-mend-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'dawnweaver-renew') {
if (buffs.includes('dawnweaver-renew-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'dawnweaver-radiance') {
if (buffs.includes('dawnweaver-radiance-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-radiance-applies-sun-ward')) addEffect(halfSunWardEffect)
} else if (ability.id === 'dawnweaver-sun-ward') {
if (buffs.includes('dawnweaver-sun-ward-applies-renew')) addEffect(renewEffect)
} else if (ability.id === 'dawnweaver-purify') {
if (buffs.includes('dawnweaver-purify-applies-renew')) addEffect(renewEffect)
if (buffs.includes('dawnweaver-purify-applies-sun-ward')) addEffect(sunWardEffect)
} else if (ability.id === 'lifebinder-verdant-touch') {
if (buffs.includes('lifebinder-verdant-touch-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-verdant-touch-applies-barkskin')) addEffect(halfBarkskinEffect)
} else if (ability.id === 'lifebinder-seed-of-life') {
if (buffs.includes('lifebinder-seed-of-life-applies-barkskin')) addEffect(barkskinEffect)
} else if (ability.id === 'lifebinder-wild-growth') {
if (buffs.includes('lifebinder-wild-growth-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-wild-growth-applies-barkskin')) addEffect(halfBarkskinEffect)
} else if (ability.id === 'lifebinder-barkskin') {
if (buffs.includes('lifebinder-barkskin-applies-seed-of-life')) addEffect(seedOfLifeEffect)
} else if (ability.id === 'lifebinder-purging-sap') {
if (buffs.includes('lifebinder-purging-sap-applies-seed-of-life')) addEffect(seedOfLifeEffect)
if (buffs.includes('lifebinder-purging-sap-applies-barkskin')) addEffect(barkskinEffect)
} else if (ability.id === 'runesage-etched-mend') {
if (buffs.includes('runesage-etched-mend-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-etched-mend-applies-aegis-script')) addEffect(halfAegisScriptEffect)
} else if (ability.id === 'runesage-mending-rune') {
if (buffs.includes('runesage-mending-rune-applies-aegis-script')) addEffect(aegisScriptEffect)
} else if (ability.id === 'runesage-concordance') {
if (buffs.includes('runesage-concordance-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-concordance-applies-aegis-script')) addEffect(halfAegisScriptEffect)
} else if (ability.id === 'runesage-aegis-script') {
if (buffs.includes('runesage-aegis-script-applies-mending-rune')) addEffect(mendingRuneEffect)
} else if (ability.id === 'runesage-unravel') {
if (buffs.includes('runesage-unravel-applies-mending-rune')) addEffect(mendingRuneEffect)
if (buffs.includes('runesage-unravel-applies-aegis-script')) addEffect(aegisScriptEffect)
}
return effects
}
function triggeredEffectFromAbility(
ability: Iwt2HealerAbility | undefined,
strength = 1,
): Iwt2TriggeredAbilityEffect | undefined {
if (!ability) return undefined
return {
effectType: ability.effectType,
id: ability.id,
kind: ability.kind,
name: ability.name,
power: Math.max(1, Math.round(ability.power * strength)),
}
}
function countStacks(items: readonly string[], id: string) {
return items.filter((item) => item === id).length
}
function roundModifier(value: number) {
return Math.max(0.1, Math.round(value * 100) / 100)
}
function formatRoguelikeContentType(contentType: Iwt2RoguelikeContentType) {
if (contentType === 'raid') return 'Raid'
if (contentType === 'stadium') return 'Stadium'
return 'Dungeon'
}
function arenaPauseTitle(
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined,
fallbackTitle: string,
) {
if (!roguelikeRun) return fallbackTitle
if (roguelikeRun.contentType === 'stadium') return 'Stadium'
if (roguelikeRun.variant === 'pvp') {
return roguelikeRun.contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'
}
return roguelikeRun.contentType === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'
}
function buildIwt2DualScreenCombatState({
abilities,
arenaState,
bindings,
cooldowns,
controllerIconStyle,
directPartyTargeting,
modeLabel,
opponentArenaState,
playerMana,
roguelikeRun,
selectedPartyId,
status,
}: {
abilities: Iwt2HealerAbility[]
arenaState: Iwt2ArenaState
bindings: DualScreenCombatState['bindings']
cooldowns: Record<string, number>
controllerIconStyle: DualScreenCombatState['controllerIconStyle']
directPartyTargeting: boolean
modeLabel: string
opponentArenaState: Iwt2ArenaState | null
playerMana: number
roguelikeRun: BossArenaScreenProps['roguelikeRun'] | undefined
selectedPartyId: Iwt2EntityId
status: ArenaStatus
}): DualScreenCombatState {
const opponentHealer = opponentArenaState?.party.find((member) => member.id === 'player-healer')
const arenaBossHealth = totalBossHealth(arenaState)
const arenaBossMaxHealth = totalBossMaxHealth(arenaState)
const pvpOpponentState = roguelikeRun?.variant === 'pvp' && opponentArenaState
? {
opponentBuffSummary: `Stage ${roguelikeRun.stage}`,
opponentClassName: `CPU Healer | ${formatRoguelikeContentType(roguelikeRun.contentType)}`,
opponentDebuffSummary: formatIwt2DebuffSummary(roguelikeRun.debuffs),
opponentArena: toDualScreenOpponentArena(opponentArenaState),
opponentEnemyHealth: totalBossHealth(opponentArenaState),
opponentMaxResource: opponentHealer?.maxMana ?? 100,
opponentName: 'CPU Rival',
opponentParty: opponentArenaState.party.map((member) => toDualScreenPartyMember(member, true)),
opponentResource: opponentHealer?.mana ?? 100,
opponentResourceName: 'Mana',
}
: {}
return {
...pvpOpponentState,
bindings,
contentName: modeLabel,
controllerIconStyle,
difficultyName: 'IWT2',
directPartyTargeting,
dungeonName: `${formatBossEncounterTitle(arenaState.bosses)} Arena`,
encounterCount: 1,
encounterDescription: arenaState.boss.attackPhase,
encounterHealth: arenaBossHealth,
encounterIndex: 0,
encounterIsBoss: true,
encounterMaxHealth: arenaBossMaxHealth,
encounterName: formatBossEncounterTitle(arenaState.bosses),
floatingTexts: [],
maxResource: 100,
party: arenaState.party.map((member) => toDualScreenPartyMember(member)),
partySize: arenaState.party.length,
paused: status === 'paused',
playerIsAlive: (arenaState.party.find((member) => member.id === 'player-healer')?.health ?? 0) > 0,
resource: playerMana,
resourceName: 'Mana',
selectedId: selectedPartyId,
speedMultiplier: 1,
spells: abilities.map((ability, slotIndex) => toDualScreenSpell(ability, slotIndex, cooldowns[ability.id] ?? 0)),
status: status === 'victory' ? 'won' : status === 'defeat' ? 'lost' : 'playing',
targetGroup: 0,
}
}
function totalBossHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + Math.max(0, boss.health), 0)
}
function totalBossMaxHealth(state: Iwt2ArenaState): number {
return state.bosses.reduce((total, boss) => total + boss.maxHealth, 0)
}
function toDualScreenOpponentArena(state: Iwt2ArenaState): NonNullable<DualScreenCombatState['opponentArena']> {
return {
bounds: state.bounds,
bosses: state.bosses.map((boss) => {
const metadata = IWT2_BOSS_METADATA[boss.bossId]
return {
id: boss.id,
name: metadata.name,
icon: metadata.icon,
color: metadata.color,
x: boss.position.x,
y: boss.position.y,
radius: boss.radius,
health: boss.health,
maxHealth: boss.maxHealth,
}
}),
party: state.party.map((member) => {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
id: member.id,
icon: metadata.icon,
color: metadata.color,
x: member.position.x,
y: member.position.y,
radius: member.radius,
health: member.health,
maxHealth: member.maxHealth,
isHealer: member.id === 'player-healer',
}
}),
}
}
function toDualScreenPartyMember(member: Iwt2ArenaState['party'][number], opponent = false): PartyMember {
const metadata = IWT2_CLASS_METADATA[member.classId]
return {
bounceHeals: [],
health: member.health,
hotEffects: member.hotEffects.map((effect) => ({
id: effect.id,
label: effect.label,
power: effect.power,
spellId: effect.id,
ticks: Math.ceil(effect.remainingSeconds),
})),
hotTicks: member.hotEffects.length,
id: opponent ? `opponent-${member.id}` : member.id,
maxHealth: member.maxHealth,
name: opponent ? opponentPartyName(metadata.name) : metadata.name,
role: toDualScreenRole(metadata.role),
shield: member.shield,
}
}
function opponentPartyName(name: string): string {
if (name === 'Player Healer') return 'CPU Healer'
return `CPU ${name.replace(' Tank', '')}`
}
function toDualScreenRole(role: (typeof IWT2_CLASS_METADATA)[keyof typeof IWT2_CLASS_METADATA]['role']): Role {
if (role === 'tank') return 'Tank'
if (role === 'healer') return 'Healer'
return 'Damage'
}
function toDualScreenSpell(
ability: Iwt2HealerAbility,
slotIndex: number,
remaining: number,
): Spell & { slotIndex: number; remaining: number } {
return {
cooldown: ability.cooldownSeconds,
cost: ability.manaCost,
description: 'IWT2 arena ability',
effectType: ability.effectType,
glyph: ability.icon,
id: ability.id,
key: String(ability.slot),
kind: ability.kind,
name: ability.name,
power: ability.power,
remaining,
slotIndex,
}
}
function iwt2OpponentMovement(state: Iwt2ArenaState): MovementVector {
const healer = state.party.find((member) => member.id === 'player-healer')
if (!healer || healer.health <= 0) return { x: 0, y: 0 }
const orbitSeconds = Math.max(1, state.time)
return normalizedArenaMovement({
x: Math.cos(orbitSeconds * 0.75),
y: Math.sin(orbitSeconds * 0.75) * 0.55,
})
}
function normalizedArenaMovement(vector: MovementVector): MovementVector {
const magnitude = Math.hypot(vector.x, vector.y)
if (magnitude <= 1) return vector
return {
x: vector.x / magnitude,
y: vector.y / magnitude,
}
}
function formatIwt2DebuffSummary(debuffs: readonly string[]): string {
if (debuffs.length === 0) return 'none'
return debuffs
.map((debuff) => debuff
.replace('opp-', '')
.replaceAll('-', ' '))
.join(', ')
}