1536 lines
59 KiB
TypeScript
1536 lines
59 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
completeDungeon,
|
|
completeRoguelike,
|
|
loadProfile,
|
|
type DungeonReward,
|
|
rollEncounterLoot,
|
|
type LootRoll,
|
|
} from '../profile'
|
|
import {
|
|
INITIAL_PARTY,
|
|
RAID_PARTY,
|
|
type CombatLogEntry,
|
|
type PartyMember,
|
|
type Spell,
|
|
} from '../game'
|
|
import type {
|
|
Ability,
|
|
CharacterProfile,
|
|
Difficulty,
|
|
Dungeon,
|
|
DungeonEncounter,
|
|
} from '../profile'
|
|
import {
|
|
useGameAction,
|
|
useInput,
|
|
type InputAction,
|
|
} from '../input'
|
|
import { ControllerBindingLabel } from './ControllerIcons'
|
|
import {
|
|
DualScreenTopCombat,
|
|
useDualScreen,
|
|
useDualScreenPublisher,
|
|
type DualScreenCombatState,
|
|
} from '../dualScreen'
|
|
|
|
const TICK_MS = 700
|
|
|
|
type RoguelikeMode = 'dungeon' | 'raid'
|
|
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
|
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
|
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
|
type RoguelikeMechanic =
|
|
| 'party-pulse'
|
|
| 'searing-mark'
|
|
| 'max-health-cut'
|
|
| 'healing-reduction'
|
|
| 'tank-buster'
|
|
| 'resource-drain'
|
|
| 'ramping-poison'
|
|
|
|
type RoguelikeEncounter = DungeonEncounter & {
|
|
roguelikeMechanics?: RoguelikeMechanic[]
|
|
}
|
|
|
|
type RoguelikeUpgradeId =
|
|
| `slot${SlotKey}-extra-target`
|
|
| `slot${SlotKey}-cost-down`
|
|
| `slot${SlotKey}-cooldown-down`
|
|
| 'fifth-cast-free'
|
|
| 'group-heal-boost'
|
|
| 'shield-boost'
|
|
|
|
type RoguelikeUpgrade = {
|
|
id: RoguelikeUpgradeId
|
|
name: string
|
|
description: string
|
|
}
|
|
|
|
type FloatingCombatText = {
|
|
id: number
|
|
memberId: string
|
|
value: number
|
|
}
|
|
|
|
type SinglePlayerCombatState = {
|
|
party: PartyMember[]
|
|
resource: number
|
|
enemyHealth: number
|
|
cooldowns: Record<string, number>
|
|
elapsedTicks: number
|
|
castsTowardFree: number
|
|
freeCastReady: boolean
|
|
}
|
|
|
|
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
|
|
'party-pulse',
|
|
'searing-mark',
|
|
'max-health-cut',
|
|
'healing-reduction',
|
|
'tank-buster',
|
|
'resource-drain',
|
|
'ramping-poison',
|
|
]
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value))
|
|
}
|
|
|
|
function effectiveMaxHealth(member: PartyMember) {
|
|
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
|
}
|
|
|
|
function healAmount(member: PartyMember, amount: number) {
|
|
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
|
|
}
|
|
|
|
function healMember(member: PartyMember, amount: number) {
|
|
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
|
}
|
|
|
|
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
|
return upgrades.filter((upgrade) => upgrade.id === id).length
|
|
}
|
|
|
|
function slotLabel(slot: SlotKey, spells: Spell[], labelMode: RoguelikeAbilityLabelMode) {
|
|
const spell = spells.find((candidate) => candidate.key === slot)
|
|
if (labelMode === 'ability' && spell) return spell.name
|
|
return `Slot ${slot}`
|
|
}
|
|
|
|
function buildRoguelikeUpgrades(
|
|
spells: Spell[],
|
|
labelMode: RoguelikeAbilityLabelMode,
|
|
): RoguelikeUpgrade[] {
|
|
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
|
const label = slotLabel(slot, spells, labelMode)
|
|
return [
|
|
{
|
|
id: `slot${slot}-extra-target` as RoguelikeUpgradeId,
|
|
name: `${label}: +1 target`,
|
|
description: `${label} affects 1 additional ally when possible.`,
|
|
},
|
|
{
|
|
id: `slot${slot}-cost-down` as RoguelikeUpgradeId,
|
|
name: `${label}: -25% cost`,
|
|
description: `${label} costs 25% less resource.`,
|
|
},
|
|
{
|
|
id: `slot${slot}-cooldown-down` as RoguelikeUpgradeId,
|
|
name: `${label}: -25% cooldown`,
|
|
description: `${label} recharges 25% faster.`,
|
|
},
|
|
]
|
|
})
|
|
return [
|
|
...slotUpgrades,
|
|
{
|
|
id: 'fifth-cast-free',
|
|
name: 'Stored Momentum',
|
|
description: 'After 5 spell casts, your next cast is free.',
|
|
},
|
|
{
|
|
id: 'group-heal-boost',
|
|
name: 'Wide Radiance',
|
|
description: 'Party healing is 25% stronger.',
|
|
},
|
|
{
|
|
id: 'shield-boost',
|
|
name: 'Dense Shields',
|
|
description: 'Shield absorbs are 25% stronger.',
|
|
},
|
|
]
|
|
}
|
|
|
|
function summarizeUpgradeStacks(
|
|
upgrades: RoguelikeUpgrade[],
|
|
catalog: RoguelikeUpgrade[],
|
|
) {
|
|
const counts = new Map<RoguelikeUpgradeId, number>()
|
|
upgrades.forEach((upgrade) => counts.set(upgrade.id, (counts.get(upgrade.id) ?? 0) + 1))
|
|
return Array.from(counts.entries())
|
|
.map(([id, count]) => {
|
|
const name = catalog.find((upgrade) => upgrade.id === id)?.name ?? id
|
|
return count > 1 ? `${name} x${count}` : name
|
|
})
|
|
.join(', ')
|
|
}
|
|
|
|
function cooldownMultiplier(spell: Spell, upgrades: RoguelikeUpgrade[]) {
|
|
return 0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cooldown-down` as RoguelikeUpgradeId)
|
|
}
|
|
|
|
function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastReady: boolean) {
|
|
const adjustedCost = Math.ceil(
|
|
spell.cost * (0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cost-down` as RoguelikeUpgradeId)),
|
|
)
|
|
return freeCastReady && upgradeStackCount(upgrades, 'fifth-cast-free') > 0 ? 0 : adjustedCost
|
|
}
|
|
|
|
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
|
const kinds: Record<string, Spell['kind']> = {
|
|
direct_heal: 'direct',
|
|
heal_over_time: 'hot',
|
|
party_heal: 'group',
|
|
absorb: 'shield',
|
|
cleanse: 'cleanse',
|
|
}
|
|
return {
|
|
id: String(ability.id),
|
|
key,
|
|
name: ability.name,
|
|
description: ability.description,
|
|
cost: ability.cost,
|
|
cooldown: ability.cooldown,
|
|
power: ability.power + healingPower,
|
|
glyph: ability.glyph,
|
|
kind: kinds[ability.spellType] ?? 'direct',
|
|
}
|
|
}
|
|
|
|
function getCurrentPart(encounterIndex: number) {
|
|
return Math.floor(encounterIndex / 3) + 1
|
|
}
|
|
|
|
function chooseRandom<T>(items: T[], count: number) {
|
|
const pool = [...items]
|
|
const result: T[] = []
|
|
while (pool.length > 0 && result.length < count) {
|
|
const index = Math.floor(Math.random() * pool.length)
|
|
result.push(pool.splice(index, 1)[0])
|
|
}
|
|
return result
|
|
}
|
|
|
|
function formatEffectTime(ticks: number) {
|
|
const seconds = (ticks * TICK_MS) / 1000
|
|
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
|
}
|
|
|
|
function mechanicLabel(mechanic: RoguelikeMechanic) {
|
|
const labels: Record<RoguelikeMechanic, string> = {
|
|
'party-pulse': 'party pulse',
|
|
'searing-mark': 'damage mark',
|
|
'max-health-cut': 'max health cut',
|
|
'healing-reduction': 'healing reduction',
|
|
'tank-buster': 'tank buster',
|
|
'resource-drain': 'resource drain',
|
|
'ramping-poison': 'ramping poison',
|
|
}
|
|
return labels[mechanic]
|
|
}
|
|
|
|
function makeRoguelikeSegment(
|
|
pool: DungeonEncounter[],
|
|
stage: number,
|
|
difficulty: Difficulty,
|
|
mode: RoguelikeMode,
|
|
): RoguelikeEncounter[] {
|
|
const encounterThreat = (encounter: DungeonEncounter) => (
|
|
encounter.maxHealth
|
|
+ encounter.damage * 18
|
|
+ encounter.tankDamage * 10
|
|
+ encounter.partyDamage * 18
|
|
)
|
|
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
|
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
|
const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
|
|
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
|
|
const trashCandidateCount = Math.min(trashPool.length, 4 + stage * (mode === 'raid' ? 1 : 2))
|
|
const bossCandidateCount = Math.min(bossPool.length, 2 + Math.floor((stage + 1) / 2))
|
|
const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount), 2)
|
|
const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount), 1)[0] ?? trashPool[0] ?? pool[0]
|
|
const healthScale = 0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)
|
|
const damageScale = 0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)
|
|
const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
|
return [...selectedTrash, selectedBoss].map((encounter, index) => {
|
|
const isBoss = index === 2
|
|
return {
|
|
...encounter,
|
|
id: 900000 + stage * 10 + index,
|
|
sequence: (stage - 1) * 3 + index + 1,
|
|
isBoss,
|
|
encounterType: isBoss ? 'boss' : 'trash',
|
|
enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName,
|
|
description: isBoss
|
|
? `Roguelike boss with ${mechanics.map(mechanicLabel).join(', ')}.`
|
|
: encounter.description,
|
|
maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier * healthScale),
|
|
damage: Math.round(encounter.damage * difficulty.damageMultiplier * damageScale),
|
|
tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier * damageScale),
|
|
partyDamage: Math.round(encounter.partyDamage * (0.9 + stage * 0.05)),
|
|
lootTables: [],
|
|
roguelikeMechanics: isBoss ? mechanics : [],
|
|
}
|
|
})
|
|
}
|
|
|
|
export function CombatScreen({
|
|
difficulty,
|
|
dungeon,
|
|
profile,
|
|
startPart = 1,
|
|
roguelikeMode,
|
|
roguelikeUpgradeTiming = 'boss',
|
|
roguelikeAbilityLabelMode = 'ability',
|
|
roguelikeEncounterPool,
|
|
onExit,
|
|
onProfileUpdated,
|
|
}: {
|
|
difficulty: Difficulty
|
|
dungeon: Dungeon
|
|
profile: CharacterProfile
|
|
startPart?: number
|
|
roguelikeMode?: RoguelikeMode
|
|
roguelikeUpgradeTiming?: RoguelikeUpgradeTiming
|
|
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
|
roguelikeEncounterPool?: DungeonEncounter[]
|
|
onExit: () => void
|
|
onProfileUpdated: (profile: CharacterProfile) => void
|
|
}) {
|
|
const staticEncounters = useMemo(
|
|
() => dungeon.encounters.map((encounter) => ({
|
|
...encounter,
|
|
maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier),
|
|
damage: Math.round(encounter.damage * difficulty.damageMultiplier),
|
|
tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier),
|
|
})),
|
|
[difficulty.damageMultiplier, difficulty.healthMultiplier, dungeon.encounters],
|
|
)
|
|
const isRoguelike = Boolean(roguelikeMode)
|
|
const roguelikePool = roguelikeEncounterPool ?? dungeon.encounters
|
|
const [roguelikeStage, setRoguelikeStage] = useState(1)
|
|
const [roguelikeEncounters, setRoguelikeEncounters] = useState<RoguelikeEncounter[]>(() =>
|
|
roguelikeMode ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) : [],
|
|
)
|
|
const encounters = isRoguelike ? roguelikeEncounters : staticEncounters
|
|
const gameClass = profile.classes.find(
|
|
(candidate) => candidate.id === profile.character.classId,
|
|
)!
|
|
const healingPower = isRoguelike ? 0 : profile.gearStats.healingPower
|
|
const spells = profile.abilitySlots.flatMap((abilityId, index) => {
|
|
const ability = gameClass.spells.find((candidate) => candidate.id === abilityId)
|
|
return ability
|
|
? [toCombatSpell(ability, String(index + 1), healingPower)]
|
|
: []
|
|
})
|
|
const roguelikeUpgradeCatalog = useMemo(
|
|
() => buildRoguelikeUpgrades(spells, roguelikeAbilityLabelMode),
|
|
[roguelikeAbilityLabelMode, spells],
|
|
)
|
|
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
|
|
const partyTemplate = useMemo(
|
|
() => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
|
...member,
|
|
name: member.id === 'mira' ? profile.character.name : member.name,
|
|
})),
|
|
[dungeon.partySize, profile.character.name],
|
|
)
|
|
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
|
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
|
const initialEncounterIndex = (startPart - 1) * 3
|
|
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
|
party: partyTemplate,
|
|
resource: maxResource,
|
|
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
|
cooldowns: {},
|
|
elapsedTicks: 0,
|
|
castsTowardFree: 0,
|
|
freeCastReady: false,
|
|
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
|
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
|
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
|
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
|
const [paused, setPaused] = useState(false)
|
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
|
const [log, setLog] = useState<CombatLogEntry[]>([
|
|
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
|
])
|
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
|
const [rewardError, setRewardError] = useState('')
|
|
const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
|
|
const [showEndLog, setShowEndLog] = useState(false)
|
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
|
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
|
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
|
const rewardClaimedRef = useRef(false)
|
|
const profileRefreshedRef = useRef(false)
|
|
const rolledEncounterIdsRef = useRef(new Set<number>())
|
|
const runTokenRef = useRef(crypto.randomUUID())
|
|
const resourceSpentRef = useRef(0)
|
|
const runStartedAtRef = useRef(0)
|
|
const partStartTimesRef = useRef<Record<number, number>>({})
|
|
const nextLogId = useRef(2)
|
|
const nextFloatingTextId = useRef(1)
|
|
const combatRef = useRef(initialCombatState)
|
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
|
const runCombatTickRef = useRef<() => void>(() => {})
|
|
const combatClockActiveRef = useRef(false)
|
|
const lastCombatTickAtRef = useRef(performance.now())
|
|
const statusRef = useRef(status)
|
|
const pausedRef = useRef(paused)
|
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
|
const encounter = encounters[encounterIndex]
|
|
const currentPart = getCurrentPart(encounterIndex)
|
|
const firstEncounterIndex = (startPart - 1) * 3
|
|
const expectedLootRolls = encounters
|
|
.slice(firstEncounterIndex, encounterIndex + 1)
|
|
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
|
.length
|
|
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
|
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
|
const playerHealer = party.find((member) => member.id === 'mira')
|
|
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
|
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
|
const activeSetEffects = useMemo(
|
|
() => isRoguelike
|
|
? new Set<string>()
|
|
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
|
|
[isRoguelike, profile.setBonuses],
|
|
)
|
|
const {
|
|
bindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
lastDevice,
|
|
} = useInput()
|
|
const {
|
|
enabled: dualScreenEnabled,
|
|
} = useDualScreen()
|
|
|
|
statusRef.current = status
|
|
pausedRef.current = paused
|
|
|
|
useEffect(() => {
|
|
const now = Date.now()
|
|
runStartedAtRef.current = now
|
|
partStartTimesRef.current = { [startPart]: now }
|
|
}, [startPart])
|
|
|
|
useEffect(() => {
|
|
if (!paused) return
|
|
window.requestAnimationFrame(() => {
|
|
document.querySelector<HTMLButtonElement>('.pause-screen button')?.focus({ preventScroll: true })
|
|
})
|
|
}, [paused])
|
|
|
|
const setCombat = useCallback((
|
|
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
|
|
) => {
|
|
const next = typeof nextState === 'function'
|
|
? nextState(combatRef.current)
|
|
: nextState
|
|
combatRef.current = next
|
|
setSelectedId(selectedIdRef.current)
|
|
setCombatState(next)
|
|
}, [])
|
|
|
|
const syncSelectedTargetDom = useCallback((id: string) => {
|
|
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
|
|
const selected = button.dataset.partyMemberId === id
|
|
button.classList.toggle('selected', selected)
|
|
button.setAttribute('aria-pressed', String(selected))
|
|
})
|
|
}, [])
|
|
|
|
const setSelectedTargetId = useCallback((id: string) => {
|
|
if (selectedIdRef.current === id) return
|
|
selectedIdRef.current = id
|
|
syncSelectedTargetDom(id)
|
|
}, [syncSelectedTargetDom])
|
|
|
|
useEffect(() => {
|
|
syncSelectedTargetDom(selectedIdRef.current)
|
|
}, [combatState, syncSelectedTargetDom])
|
|
|
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
|
const entry = { id: nextLogId.current++, text, tone }
|
|
setLog((current) => [entry, ...current].slice(0, 60))
|
|
}, [])
|
|
|
|
const addFloatingHeal = useCallback((memberId: string, value: number) => {
|
|
if (value <= 0) return
|
|
const id = nextFloatingTextId.current++
|
|
setFloatingTexts((current) => [...current, { id, memberId, value }])
|
|
window.setTimeout(() => {
|
|
setFloatingTexts((current) => current.filter((entry) => entry.id !== id))
|
|
}, 900)
|
|
}, [])
|
|
|
|
const requestLootRoll = useCallback(
|
|
(encounterId: number) => {
|
|
if (rolledEncounterIdsRef.current.has(encounterId)) return
|
|
rolledEncounterIdsRef.current.add(encounterId)
|
|
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
|
|
.then((result) => {
|
|
setLootRolls((current) => [...current, result])
|
|
const awarded = result.items
|
|
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
|
|
.join(', ')
|
|
addLog(
|
|
result.dropped && awarded
|
|
? `${result.encounterName} awarded ${awarded}.`
|
|
: `${result.encounterName} dropped no components.`,
|
|
result.dropped ? 'loot' : 'system',
|
|
)
|
|
})
|
|
.catch((reason: unknown) => {
|
|
addLog(
|
|
reason instanceof Error ? reason.message : 'The loot roll failed.',
|
|
'danger',
|
|
)
|
|
})
|
|
},
|
|
[addLog, difficulty.id],
|
|
)
|
|
|
|
const resetRun = useCallback(() => {
|
|
const nextRoguelikeEncounters = roguelikeMode
|
|
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
|
|
: []
|
|
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
|
const freshParty = partyTemplate.map((member) => ({ ...member }))
|
|
setCombat({
|
|
party: freshParty,
|
|
resource: maxResource,
|
|
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
|
cooldowns: {},
|
|
elapsedTicks: 0,
|
|
castsTowardFree: 0,
|
|
freeCastReady: false,
|
|
})
|
|
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
|
setRoguelikeStage(1)
|
|
setSelectedTargetId(partyTemplate[0].id)
|
|
setEncounterIndex(initialEncounterIndex)
|
|
setStatus('playing')
|
|
setPaused(false)
|
|
setTargetGroup(0)
|
|
setReward(null)
|
|
setRewardError('')
|
|
setLootRolls([])
|
|
setShowEndLog(false)
|
|
setFloatingTexts([])
|
|
setRoguelikeUpgrades([])
|
|
setUpgradeChoices([])
|
|
rewardClaimedRef.current = false
|
|
profileRefreshedRef.current = false
|
|
rolledEncounterIdsRef.current = new Set()
|
|
runTokenRef.current = crypto.randomUUID()
|
|
resourceSpentRef.current = 0
|
|
runStartedAtRef.current = Date.now()
|
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
|
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
|
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
|
|
|
const castSpell = useCallback(
|
|
(spell: Spell) => {
|
|
const current = combatRef.current
|
|
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady)
|
|
if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return
|
|
const healer = current.party.find((member) => member.id === 'mira')
|
|
if (!healer || healer.health <= 0) return
|
|
const targetId = selectedIdRef.current
|
|
const selected = current.party.find((member) => member.id === targetId)
|
|
if (!selected || selected.health <= 0) return
|
|
const extraTarget = (blockedIds: string[]) => current.party
|
|
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
|
const directTargets = new Set([targetId])
|
|
const hotTargets = new Set<string>()
|
|
const shieldTargets = new Set<string>()
|
|
if (spell.kind === 'hot') hotTargets.add(targetId)
|
|
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
|
const extra = extraTarget([targetId])
|
|
if (extra) directTargets.add(extra.id)
|
|
}
|
|
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
|
const extra = extraTarget([targetId])
|
|
if (extra) hotTargets.add(extra.id)
|
|
}
|
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
|
hotTargets.add(targetId)
|
|
}
|
|
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
|
for (let index = 0; index < extraTargets; index += 1) {
|
|
if (spell.kind === 'group') break
|
|
if (spell.kind === 'hot') {
|
|
const extra = extraTarget([...hotTargets])
|
|
if (extra) hotTargets.add(extra.id)
|
|
continue
|
|
}
|
|
if (spell.kind === 'shield') {
|
|
const extra = extraTarget([...shieldTargets])
|
|
if (extra) shieldTargets.add(extra.id)
|
|
continue
|
|
}
|
|
const extra = extraTarget([...directTargets])
|
|
if (extra) directTargets.add(extra.id)
|
|
}
|
|
|
|
const nextParty = current.party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
if (spell.kind === 'group') {
|
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
|
const nextHealth = healMember(member, power)
|
|
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
|
return { ...member, health: nextHealth }
|
|
}
|
|
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
|
if (spell.kind === 'shield') {
|
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
|
return { ...member, shield: Math.max(member.shield, power) }
|
|
}
|
|
if (spell.kind === 'cleanse') {
|
|
return {
|
|
...member,
|
|
health: healMember(member, spell.power),
|
|
debuff: undefined,
|
|
debuffTicks: undefined,
|
|
poisonStacks: undefined,
|
|
maxHealthPenaltyTicks: undefined,
|
|
healingReductionTicks: undefined,
|
|
}
|
|
}
|
|
const nextHealth = directTargets.has(member.id)
|
|
? healMember(member, spell.power)
|
|
: member.health
|
|
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
|
|
return {
|
|
...member,
|
|
health: nextHealth,
|
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
|
}
|
|
})
|
|
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
|
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady
|
|
? false
|
|
: current.freeCastReady
|
|
const nextCastsTowardFree = freeCastStacks > 0
|
|
? current.freeCastReady
|
|
? 0
|
|
: current.castsTowardFree + 1 >= 5
|
|
? 0
|
|
: current.castsTowardFree + 1
|
|
: current.castsTowardFree
|
|
const gainedFreeCast = freeCastStacks > 0
|
|
&& !current.freeCastReady
|
|
&& current.castsTowardFree + 1 >= 5
|
|
resourceSpentRef.current += effectiveCost
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: current.resource - effectiveCost,
|
|
cooldowns: {
|
|
...current.cooldowns,
|
|
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
|
},
|
|
castsTowardFree: nextCastsTowardFree,
|
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
|
})
|
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
|
},
|
|
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
|
)
|
|
|
|
const finishRun = useCallback(
|
|
(completedPart: number, runStartPart: number) => {
|
|
if (rewardClaimedRef.current) return
|
|
rewardClaimedRef.current = true
|
|
const now = Date.now()
|
|
const pTimes = partStartTimesRef.current
|
|
const partDuration = (part: number) => {
|
|
const start = pTimes[part]
|
|
if (!start) return 0
|
|
const next = pTimes[part + 1] ?? now
|
|
return Math.max(1, Math.round((next - start) / 1000))
|
|
}
|
|
completeDungeon(
|
|
dungeon.id,
|
|
difficulty.id,
|
|
resourceSpentRef.current,
|
|
Math.max(1, Math.round((now - runStartedAtRef.current) / 1000)),
|
|
completedPart,
|
|
runStartPart,
|
|
[partDuration(1), partDuration(2), partDuration(3)],
|
|
)
|
|
.then((result) => {
|
|
setReward(result)
|
|
onProfileUpdated(result.profile)
|
|
setStatus('won')
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setRewardError(
|
|
reason instanceof Error ? reason.message : 'Unable to award experience.',
|
|
)
|
|
})
|
|
},
|
|
[difficulty.id, dungeon.id, onProfileUpdated],
|
|
)
|
|
|
|
const finishRoguelikeRun = useCallback(
|
|
(encountersCleared: number) => {
|
|
if (rewardClaimedRef.current) return
|
|
rewardClaimedRef.current = true
|
|
completeRoguelike(
|
|
dungeon.id,
|
|
difficulty.id,
|
|
encountersCleared,
|
|
resourceSpentRef.current,
|
|
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
|
|
)
|
|
.then((result) => {
|
|
setReward(result)
|
|
onProfileUpdated(result.profile)
|
|
})
|
|
.catch((reason: unknown) => {
|
|
setRewardError(
|
|
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
|
)
|
|
})
|
|
},
|
|
[difficulty.id, dungeon.id, onProfileUpdated],
|
|
)
|
|
|
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
|
const living = combatRef.current.party.filter((member) => member.health > 0)
|
|
if (living.length === 0) return
|
|
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
|
const nextIndex = currentIndex < 0
|
|
? 0
|
|
: (currentIndex + direction + living.length) % living.length
|
|
setSelectedTargetId(living[nextIndex].id)
|
|
}, [setSelectedTargetId])
|
|
|
|
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
|
const columns = dungeon.partySize >= 10 ? 6 : 3
|
|
const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
|
if (currentIndex < 0) {
|
|
setSelectedTargetId(combatRef.current.party[0].id)
|
|
return
|
|
}
|
|
const currentRow = Math.floor(currentIndex / columns)
|
|
const currentColumn = currentIndex % columns
|
|
const candidates = combatRef.current.party
|
|
.map((member, index) => ({
|
|
member,
|
|
index,
|
|
row: Math.floor(index / columns),
|
|
column: index % columns,
|
|
}))
|
|
.filter(({ index, row, column }) => {
|
|
if (index === currentIndex) return false
|
|
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
|
|
if (action === 'navigateRight') return row === currentRow && column > currentColumn
|
|
if (action === 'navigateUp') return row < currentRow
|
|
return row > currentRow
|
|
})
|
|
.sort((a, b) => {
|
|
const aPrimary = action === 'navigateLeft' || action === 'navigateRight'
|
|
? Math.abs(a.column - currentColumn)
|
|
: Math.abs(a.row - currentRow)
|
|
const bPrimary = action === 'navigateLeft' || action === 'navigateRight'
|
|
? Math.abs(b.column - currentColumn)
|
|
: Math.abs(b.row - currentRow)
|
|
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
? 0
|
|
: Math.abs(a.column - currentColumn)
|
|
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
|
? 0
|
|
: Math.abs(b.column - currentColumn)
|
|
return aPrimary - bPrimary || aSecondary - bSecondary
|
|
})
|
|
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
|
}, [dungeon.partySize, setSelectedTargetId])
|
|
|
|
const selectDirectTarget = useCallback((slot: number) => {
|
|
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
|
const member = combatRef.current.party[index]
|
|
if (member) setSelectedTargetId(member.id)
|
|
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
|
|
|
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
|
if (!roguelikeMode) return
|
|
const current = combatRef.current
|
|
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
|
|
const recoveredParty = current.party.map((member) => ({
|
|
...member,
|
|
health: member.health <= 0
|
|
? 0
|
|
: clamp(member.health + Math.round(member.maxHealth * 0.35), 0, member.maxHealth),
|
|
debuff: undefined,
|
|
debuffTicks: undefined,
|
|
poisonStacks: undefined,
|
|
maxHealthPenaltyTicks: undefined,
|
|
healingReductionTicks: undefined,
|
|
}))
|
|
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
|
const nextSegment = clearedBoss
|
|
? makeRoguelikeSegment(roguelikePool, nextStage, difficulty, roguelikeMode)
|
|
: []
|
|
const nextEncounter = clearedBoss
|
|
? nextSegment[0]
|
|
: encounters[encounterIndex + 1]
|
|
if (!nextEncounter) return
|
|
setRoguelikeUpgrades((current) => [...current, upgrade])
|
|
if (clearedBoss) {
|
|
setRoguelikeStage(nextStage)
|
|
setRoguelikeEncounters((current) => [...current, ...nextSegment])
|
|
}
|
|
setEncounterIndex((current) => current + 1)
|
|
setCombat({
|
|
...current,
|
|
party: recoveredParty,
|
|
enemyHealth: nextEncounter.maxHealth,
|
|
elapsedTicks: 0,
|
|
cooldowns: {},
|
|
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
|
})
|
|
setUpgradeChoices([])
|
|
setStatus('playing')
|
|
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
|
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
|
|
|
useGameAction((action, device) => {
|
|
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
|
if (status === 'playing') setPaused((value) => !value)
|
|
return
|
|
}
|
|
if (paused || status !== 'playing') return
|
|
if (action.startsWith('navigate')) {
|
|
selectDirectionalTarget(action)
|
|
return
|
|
}
|
|
if (action.startsWith('targetParty')) {
|
|
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
|
|
return
|
|
}
|
|
if (action === 'toggleTargetGroup') {
|
|
if (dungeon.partySize <= 6) return
|
|
setTargetGroup((current) => {
|
|
const groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6))
|
|
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
|
const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
|
const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
|
if (nextMember) setSelectedTargetId(nextMember.id)
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
if (action === 'previousTarget') {
|
|
selectRelativeTarget(-1)
|
|
return
|
|
}
|
|
if (action === 'nextTarget') {
|
|
selectRelativeTarget(1)
|
|
return
|
|
}
|
|
if (!action.startsWith('ability')) return
|
|
const slot = Number(action.slice('ability'.length)) - 1
|
|
const spell = spells.find((candidate) => candidate.key === String(slot + 1))
|
|
if (spell) castSpell(spell)
|
|
})
|
|
|
|
const runCombatTick = useCallback(() => {
|
|
const current = combatRef.current
|
|
const nextElapsedTicks = current.elapsedTicks + 1
|
|
const nextCooldowns = Object.fromEntries(
|
|
Object.entries(current.cooldowns).map(([id, seconds]) => [
|
|
id,
|
|
Math.max(0, seconds - TICK_MS / 1000),
|
|
]),
|
|
)
|
|
let nextResource = clamp(current.resource + 2.4, 0, maxResource)
|
|
|
|
const living = current.party.filter((member) => member.health > 0)
|
|
if (living.length === 0) {
|
|
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
|
setStatus('lost')
|
|
addLog('The party has fallen.', 'danger')
|
|
return
|
|
}
|
|
|
|
const primaryTarget = living[Math.floor(Math.random() * living.length)]
|
|
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
|
|
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
|
|
const bossPulse = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 7 === 0
|
|
&& (useDefaultBossMechanics || mechanics.includes('party-pulse'))
|
|
const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
|
|
&& (useDefaultBossMechanics || mechanics.includes('searing-mark'))
|
|
const appliesMaxHealthCut = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 13 === 0
|
|
&& mechanics.includes('max-health-cut')
|
|
const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
|
|
&& mechanics.includes('healing-reduction')
|
|
const tankBuster = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 8 === 0
|
|
&& mechanics.includes('tank-buster')
|
|
const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
|
|
&& mechanics.includes('resource-drain')
|
|
const appliesPoison = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 12 === 0
|
|
&& mechanics.includes('ramping-poison')
|
|
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
|
|
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
|
|
if (appliesPoison) addLog(`${primaryTarget.name} is poisoned. Dispel it before it ramps.`, 'danger')
|
|
if (appliesMaxHealthCut) addLog(`${primaryTarget.name}'s max health is reduced.`, 'danger')
|
|
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
|
|
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
|
|
if (resourceDrain) {
|
|
nextResource = clamp(nextResource - 8, 0, maxResource)
|
|
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
|
|
}
|
|
|
|
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
|
const nextParty = current.party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
|
if (member.role === 'Tank') damage += encounter.tankDamage
|
|
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
|
|
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
|
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
|
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
|
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
|
: member.poisonStacks ?? 0
|
|
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
|
const absorbed = Math.min(member.shield, damage)
|
|
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0
|
|
if (healing > 0) addFloatingHeal(member.id, healing)
|
|
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
|
? 15
|
|
: Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
|
|
const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id
|
|
? 15
|
|
: Math.max(0, (member.healingReductionTicks ?? 0) - 1)
|
|
const nextEffectiveMaxHealth = Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
|
const nextDebuffTicks = appliesDebuff && member.id === primaryTarget.id
|
|
? 8
|
|
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
|
return {
|
|
...member,
|
|
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
|
|
shield: Math.max(0, member.shield - damage),
|
|
hotTicks: Math.max(0, member.hotTicks - 1),
|
|
debuff: nextDebuffTicks > 0
|
|
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
|
: undefined,
|
|
debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
|
|
poisonStacks: nextPoisonStacks,
|
|
maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
|
|
healingReductionTicks: nextHealingReductionTicks,
|
|
}
|
|
})
|
|
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
|
|
|
if (
|
|
healerBeforeDamage
|
|
&& healerBeforeDamage.health > 0
|
|
&& healerAfterDamage
|
|
&& healerAfterDamage.health <= 0
|
|
) {
|
|
addLog(`${profile.character.name} has fallen. Healing is no longer available.`, 'danger')
|
|
}
|
|
|
|
if (nextParty.every((member) => member.health <= 0)) {
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: current.enemyHealth,
|
|
})
|
|
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
|
setStatus('lost')
|
|
addLog('The party has fallen.', 'danger')
|
|
return
|
|
}
|
|
|
|
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
|
|
if (nextEnemyHealth > 0) {
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: nextEnemyHealth,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
|
requestLootRoll(encounter.id)
|
|
}
|
|
|
|
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: 0,
|
|
})
|
|
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
|
|
setStatus('upgrade-choice')
|
|
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
|
|
return
|
|
}
|
|
|
|
if (isPartBoss && !isFinalBoss) {
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: 0,
|
|
})
|
|
setStatus('part-complete')
|
|
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
|
return
|
|
}
|
|
|
|
if (encounterIndex === encounters.length - 1) {
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: 0,
|
|
})
|
|
finishRun(currentPart, startPart)
|
|
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
|
|
return
|
|
}
|
|
|
|
const nextEncounter = encounters[encounterIndex + 1]
|
|
const recoveredParty = nextParty.map((member) => ({
|
|
...member,
|
|
health: member.health <= 0
|
|
? 0
|
|
: clamp(member.health + 35, 0, effectiveMaxHealth(member)),
|
|
debuff: undefined,
|
|
debuffTicks: undefined,
|
|
poisonStacks: undefined,
|
|
maxHealthPenaltyTicks: undefined,
|
|
healingReductionTicks: undefined,
|
|
}))
|
|
setEncounterIndex((value) => value + 1)
|
|
setCombat({
|
|
...current,
|
|
party: recoveredParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: 0,
|
|
enemyHealth: nextEncounter.maxHealth,
|
|
})
|
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
|
}, [
|
|
addLog,
|
|
addFloatingHeal,
|
|
difficulty.damageMultiplier,
|
|
encounter,
|
|
encounterIndex,
|
|
encounters,
|
|
finishRun,
|
|
finishRoguelikeRun,
|
|
isPartBoss,
|
|
isFinalBoss,
|
|
isRoguelike,
|
|
upgradesEveryEncounter,
|
|
roguelikeUpgradeCatalog,
|
|
roguelikeUpgrades,
|
|
maxResource,
|
|
gameClass.resourceName,
|
|
requestLootRoll,
|
|
profile.character.name,
|
|
setCombat,
|
|
startPart,
|
|
currentPart,
|
|
])
|
|
|
|
useEffect(() => {
|
|
runCombatTickRef.current = runCombatTick
|
|
}, [runCombatTick])
|
|
|
|
useEffect(() => {
|
|
if (status === 'playing' && !paused) {
|
|
if (!combatClockActiveRef.current) {
|
|
lastCombatTickAtRef.current = performance.now()
|
|
combatClockActiveRef.current = true
|
|
}
|
|
return
|
|
}
|
|
combatClockActiveRef.current = false
|
|
}, [paused, status])
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => {
|
|
if (
|
|
!combatClockActiveRef.current
|
|
|| statusRef.current !== 'playing'
|
|
|| pausedRef.current
|
|
) return
|
|
const now = performance.now()
|
|
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
|
|
if (dueTicks <= 0) return
|
|
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
|
for (let index = 0; index < dueTicks; index += 1) {
|
|
if (statusRef.current !== 'playing' || pausedRef.current) return
|
|
runCombatTickRef.current()
|
|
}
|
|
}, 50)
|
|
return () => window.clearInterval(timer)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!reward
|
|
|| lootRolls.length < expectedLootRolls
|
|
|| profileRefreshedRef.current
|
|
) return
|
|
profileRefreshedRef.current = true
|
|
loadProfile()
|
|
.then(onProfileUpdated)
|
|
.catch(() => {
|
|
profileRefreshedRef.current = false
|
|
})
|
|
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
|
|
|
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100
|
|
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
|
difficultyName: difficulty.name,
|
|
dungeonName: dungeon.name,
|
|
contentName,
|
|
encounterName: encounter.enemyName,
|
|
encounterDescription: encounter.description,
|
|
encounterHealth: enemyHealth,
|
|
encounterMaxHealth: encounter.maxHealth,
|
|
encounterIsBoss: encounter.isBoss,
|
|
encounterIndex,
|
|
encounterCount: encounters.length,
|
|
party,
|
|
partySize: dungeon.partySize,
|
|
selectedId,
|
|
log,
|
|
status,
|
|
resource,
|
|
maxResource,
|
|
resourceName: gameClass.resourceName,
|
|
playerIsAlive,
|
|
spells: profile.abilitySlots.map((abilityId, slotIndex) => {
|
|
const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1))
|
|
return abilityId && spell
|
|
? {
|
|
...spell,
|
|
cost: spellResourceCost(spell, roguelikeUpgrades, freeCastReady),
|
|
slotIndex,
|
|
remaining: cooldowns[spell.id] ?? 0,
|
|
}
|
|
: null
|
|
}),
|
|
activeDevice: lastDevice,
|
|
bindings: bindings[lastDevice],
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
paused,
|
|
targetGroup,
|
|
}), [
|
|
bindings,
|
|
controllerIconStyle,
|
|
cooldowns,
|
|
contentName,
|
|
difficulty.name,
|
|
dungeon.name,
|
|
dungeon.partySize,
|
|
directPartyTargeting,
|
|
encounter.description,
|
|
encounter.enemyName,
|
|
encounter.isBoss,
|
|
encounter.maxHealth,
|
|
enemyHealth,
|
|
encounterIndex,
|
|
encounters.length,
|
|
gameClass.resourceName,
|
|
log,
|
|
lastDevice,
|
|
maxResource,
|
|
paused,
|
|
party,
|
|
playerIsAlive,
|
|
profile.abilitySlots,
|
|
resource,
|
|
selectedId,
|
|
spells,
|
|
freeCastReady,
|
|
roguelikeUpgrades,
|
|
status,
|
|
targetGroup,
|
|
])
|
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
|
|
|
return (
|
|
<main
|
|
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
|
data-combat-active={paused ? 'false' : 'true'}
|
|
>
|
|
{!dualScreenEnabled && <header className="topbar">
|
|
<div>
|
|
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
|
|
<h1>{dungeon.name}</h1>
|
|
</div>
|
|
<div className="combat-header-actions">
|
|
<div className="run-progress" aria-label={`${contentName} progress`}>
|
|
{encounters.map((item, index) => (
|
|
<span className={index < encounterIndex ? 'complete' : index === encounterIndex ? 'active' : ''} key={item.id}>
|
|
{index + 1}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button className="back-button" onClick={() => setPaused(true)} type="button">Pause</button>
|
|
</div>
|
|
</header>}
|
|
|
|
{!dualScreenEnabled && (
|
|
<>
|
|
<section className="enemy-card">
|
|
<div className="enemy-portrait" aria-hidden="true">
|
|
{encounter.isBoss ? <img src={encounter.imageUrl} alt="" /> : 'M'}
|
|
</div>
|
|
<div className="enemy-info">
|
|
<div className="bar-label">
|
|
<strong>{encounter.enemyName}</strong>
|
|
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
|
|
</div>
|
|
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
|
<p>{encounter.description}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="combat-layout">
|
|
<section className="party-panel">
|
|
<div className="party-panel-top">
|
|
<div className="mana-wrap party-mana-wrap">
|
|
<span>
|
|
{playerIsAlive
|
|
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
|
: `${profile.character.name} is defeated`}
|
|
</span>
|
|
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
|
</div>
|
|
</div>
|
|
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
|
|
{party.map((member) => (
|
|
<button
|
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
|
data-party-member-id={member.id}
|
|
key={member.id}
|
|
onClick={() => setSelectedTargetId(member.id)}
|
|
aria-pressed={selectedId === member.id}
|
|
type="button"
|
|
>
|
|
<span className="target-marker" aria-hidden="true">
|
|
<i />
|
|
Target
|
|
</span>
|
|
<div className="member-header">
|
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
|
<strong>{member.name}</strong>
|
|
<small>{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</small>
|
|
</div>
|
|
<div className="bar member-health">
|
|
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
|
|
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
|
|
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
|
|
</div>
|
|
<div className="floating-combat-texts" aria-hidden="true">
|
|
{floatingTexts
|
|
.filter((entry) => entry.memberId === member.id)
|
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
|
</div>
|
|
<div className="member-effects">
|
|
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
|
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
|
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
|
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
|
{member.healingReductionTicks && member.healingReductionTicks > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks)}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<aside className="combat-log combat-side-rail">
|
|
<div className="panel-heading">
|
|
<div><p className="eyebrow">Actions</p><h2>Skills</h2></div>
|
|
</div>
|
|
<div className="spell-bar six-slots vertical-spell-bar">
|
|
{profile.abilitySlots.map((abilityId, slotIndex) => {
|
|
const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1))
|
|
if (!abilityId || !spell) {
|
|
return (
|
|
<div className="spell empty-spell" key={`rail-empty-${slotIndex}`}>
|
|
<kbd>{slotIndex + 1}</kbd><strong>Empty</strong>
|
|
</div>
|
|
)
|
|
}
|
|
const remaining = cooldowns[spell.id] ?? 0
|
|
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady)
|
|
return (
|
|
<button
|
|
className="spell"
|
|
disabled={!playerIsAlive || resource < effectiveCost || remaining > 0 || status !== 'playing'}
|
|
key={`rail-${spell.id}`}
|
|
onClick={() => castSpell(spell)}
|
|
title={spell.description}
|
|
type="button"
|
|
>
|
|
<kbd>
|
|
<ControllerBindingLabel
|
|
binding={bindings[lastDevice][`ability${slotIndex + 1}` as InputAction]}
|
|
compact
|
|
iconStyle={controllerIconStyle}
|
|
/>
|
|
</kbd>
|
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
|
<strong>{spell.name}</strong>
|
|
<small>{effectiveCost} {gameClass.resourceName}</small>
|
|
{remaining > 0 && <i>{remaining.toFixed(1)}</i>}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{dualScreenEnabled && (
|
|
<DualScreenTopCombat
|
|
state={dualScreenState}
|
|
onSelectTarget={setSelectedTargetId}
|
|
/>
|
|
)}
|
|
|
|
{paused && status === 'playing' && (
|
|
<div className="pause-screen" role="dialog" aria-modal="true">
|
|
<div>
|
|
<p className="eyebrow">Game Paused</p>
|
|
<h2>{dungeon.name}</h2>
|
|
<p>Combat is stopped. Resume the fight or leave the current run.</p>
|
|
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
|
<button className="secondary-result-button" onClick={onExit} type="button">
|
|
Leave {contentName}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'upgrade-choice' && (
|
|
<div className="result-screen">
|
|
<div>
|
|
<p className="eyebrow">
|
|
{encounter.isBoss
|
|
? `Roguelike Stage ${roguelikeStage} Complete`
|
|
: `Encounter ${encounterIndex + 1} Complete`}
|
|
</p>
|
|
<h2>Choose Upgrade</h2>
|
|
<p>Pick one upgrade before the next fight.</p>
|
|
<div className="upgrade-choice-grid">
|
|
{upgradeChoices.map((upgrade) => (
|
|
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
|
<strong>{upgrade.name}</strong>
|
|
<small>{upgrade.description}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{roguelikeUpgrades.length > 0 && (
|
|
<p className="roguelike-upgrade-list">
|
|
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
|
|
</p>
|
|
)}
|
|
<button className="secondary-result-button" onClick={onExit} type="button">Leave Roguelike</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && (
|
|
<div className="result-screen">
|
|
<div>
|
|
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
|
|
<h2>{status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}</h2>
|
|
{status === 'won' ? (
|
|
<div className="reward-summary">
|
|
{!reward && !rewardError && <p>Recording victory...</p>}
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
{reward && (
|
|
<>
|
|
<p>+{reward.experienceGained} XP</p>
|
|
{reward.levelsGained > 0 && (
|
|
<p className="level-gain">
|
|
Level {reward.previousLevel} to {reward.newLevel}
|
|
<small>+{reward.talentPointsGained} talent point</small>
|
|
</p>
|
|
)}
|
|
{reward.unlockedAbilities.map((ability) => (
|
|
<p className="ability-unlock" key={ability.id}>
|
|
<span>{ability.glyph}</span>
|
|
Ability Unlocked: {ability.name}
|
|
</p>
|
|
))}
|
|
{!isRoguelike && (
|
|
<>
|
|
<p>Component tier: item level {reward.droppedItemLevel}.</p>
|
|
<p className="efficiency-result">
|
|
{reward.resourceSpent} {gameClass.resourceName} spent
|
|
<small>
|
|
{reward.durationSeconds}s - iLvl {reward.averageItemLevel.toFixed(1)}
|
|
</small>
|
|
</p>
|
|
<div className="run-loot-rolls">
|
|
{lootRolls.map((roll) => (
|
|
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
|
|
<strong>{roll.encounterName}</strong>
|
|
<span>
|
|
{roll.items.length > 0
|
|
? roll.items
|
|
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
|
|
.join(', ')
|
|
: 'No components dropped'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{lootRolls.length < expectedLootRolls && (
|
|
<small>Finishing loot rolls...</small>
|
|
)}
|
|
</div>
|
|
{reward.bonusItem && (
|
|
<div className="bonus-item">
|
|
<p className="eyebrow">Full Run Bonus</p>
|
|
<div className="bonus-item-detail">
|
|
<span>{reward.bonusItem.glyph}</span>
|
|
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
|
|
<small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
|
|
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : isRoguelike ? (
|
|
<div className="reward-summary">
|
|
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
{reward && (
|
|
<>
|
|
<p>+{reward.experienceGained} XP</p>
|
|
<p>{encounterIndex} encounters cleared.</p>
|
|
{reward.levelsGained > 0 && (
|
|
<p className="level-gain">
|
|
Level {reward.previousLevel} to {reward.newLevel}
|
|
<small>+{reward.talentPointsGained} talent point</small>
|
|
</p>
|
|
)}
|
|
{reward.unlockedAbilities.map((ability) => (
|
|
<p className="ability-unlock" key={ability.id}>
|
|
<span>{ability.glyph}</span>
|
|
Ability Unlocked: {ability.name}
|
|
</p>
|
|
))}
|
|
<p className="efficiency-result">
|
|
{reward.resourceSpent} {gameClass.resourceName} spent
|
|
<small>{reward.durationSeconds}s survived</small>
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p>Balance efficient healing, shields, and cleansing to survive.</p>
|
|
)}
|
|
{log.length > 0 && (
|
|
<>
|
|
<button className="secondary-result-button" onClick={() => setShowEndLog((value) => !value)} type="button">
|
|
{showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
|
|
</button>
|
|
{showEndLog && (
|
|
<div className="result-log">
|
|
{log.slice().reverse().map((entry) => (
|
|
<div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<button onClick={resetRun} type="button">Run Again</button>
|
|
<button className="secondary-result-button" onClick={onExit} type="button">Leave {contentName}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{status === 'part-complete' && (
|
|
<div className="result-screen">
|
|
<div>
|
|
<p className="eyebrow">{sectionName} Complete</p>
|
|
<h2>{encounter.enemyName} Defeated</h2>
|
|
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
|
|
<button
|
|
onClick={() => {
|
|
const nextIndex = encounterIndex + 1
|
|
partStartTimesRef.current[currentPart + 1] = Date.now()
|
|
const nextEncounter = encounters[nextIndex]
|
|
const current = combatRef.current
|
|
const recoveredParty = current.party.map((member) => ({
|
|
...member,
|
|
health: clamp(member.health + 35, 0, member.maxHealth),
|
|
debuff: undefined,
|
|
debuffTicks: undefined,
|
|
}))
|
|
setEncounterIndex(nextIndex)
|
|
setCombat({
|
|
...current,
|
|
party: recoveredParty,
|
|
enemyHealth: nextEncounter.maxHealth,
|
|
elapsedTicks: 0,
|
|
})
|
|
setStatus('playing')
|
|
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
|
}}
|
|
type="button"
|
|
>
|
|
Continue to {sectionName} {currentPart + 1}
|
|
</button>
|
|
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
|
End Run
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|