1121 lines
42 KiB
TypeScript
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(', ')
|
|
}
|