1585 lines
58 KiB
TypeScript
1585 lines
58 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,
|
|
DEFAULT_GROUP_HEAL_TARGETS,
|
|
partyDamageOutput,
|
|
tankPressureTargets,
|
|
type CombatLogEntry,
|
|
type PartyMember,
|
|
type Spell,
|
|
} from '../game'
|
|
import type {
|
|
CharacterProfile,
|
|
Difficulty,
|
|
Dungeon,
|
|
DungeonEncounter,
|
|
} from '../profile'
|
|
import {
|
|
chooseRandom,
|
|
clamp,
|
|
effectiveMaxHealth,
|
|
healMember,
|
|
toCombatSpell,
|
|
} from '../combat/rules'
|
|
import {
|
|
nextTargetGroupSelection,
|
|
} from '../combat/targeting'
|
|
import { buildRoguelikeSegment } from '../combat/encounters'
|
|
import {
|
|
buildSelfSlotUpgradeChoices,
|
|
summarizeChoiceStacks,
|
|
} from '../combat/roguelikeUpgrades'
|
|
import { createStackCounts, type StackCounts } from '../combat/stackCounts'
|
|
import {
|
|
hasSpellStack,
|
|
spellCooldownMultiplier,
|
|
spellExtraTargets,
|
|
spellPowerMultiplier,
|
|
spellResourceCost as modifiedSpellResourceCost,
|
|
} from '../combat/spellModifiers'
|
|
import { regenerateResource } from '../combat/combatTick'
|
|
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
|
|
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
|
|
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
|
|
import {
|
|
createPveCombatState,
|
|
createPveRoguelikeRunStart,
|
|
} from '../combat/pveRoguelikeRunSetup'
|
|
import type { RunBossCoinAward } from '../combat/rewardSummaries'
|
|
import {
|
|
appendCombatLog,
|
|
type BasicFloatingCombatText,
|
|
} from '../combat/combatPresentation'
|
|
import {
|
|
canCastSpell,
|
|
pruneExpiredCooldowns,
|
|
reduceCooldown,
|
|
} from '../combat/spellCasting'
|
|
import {
|
|
applySpellEffectProfile,
|
|
buildSpellTargetPlan,
|
|
type SpellEffectProfile,
|
|
} from '../combat/spellEffects'
|
|
import {
|
|
useGameAction,
|
|
useInput,
|
|
} from '../input'
|
|
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
|
|
import { useSpellSlots } from '../hooks/useSpellSlots'
|
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
|
import { PartyMemberFrame } from './PartyFrames'
|
|
import { ResourceBar, SpellBar } from './SpellBars'
|
|
import { barFillStyle } from './barStyles'
|
|
import { BonusItemReward, LootRollList, PvpRunLootList, RewardXpSummary } from './RewardPanels'
|
|
import { ResultScreen } from './ResultScreen'
|
|
import {
|
|
DualScreenTopCombat,
|
|
useDualScreen,
|
|
useDualScreenPublisher,
|
|
} 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' | '6'
|
|
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 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 buildRoguelikeUpgrades(
|
|
spells: Spell[],
|
|
labelMode: RoguelikeAbilityLabelMode,
|
|
): RoguelikeUpgrade[] {
|
|
const slotUpgrades = buildSelfSlotUpgradeChoices<RoguelikeUpgradeId>({
|
|
slots: ['1', '2', '3', '4', '5', '6'] as SlotKey[],
|
|
spells,
|
|
labelMode,
|
|
})
|
|
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[],
|
|
) {
|
|
return summarizeChoiceStacks(
|
|
upgrades.map((upgrade) => upgrade.id),
|
|
catalog,
|
|
)
|
|
}
|
|
|
|
function cooldownMultiplier(spell: Spell, upgrades: StackCounts<RoguelikeUpgradeId>) {
|
|
return spellCooldownMultiplier(spell, {
|
|
stacks: upgrades,
|
|
id: (slot) => `slot${slot as SlotKey}-cooldown-down` as RoguelikeUpgradeId,
|
|
})
|
|
}
|
|
|
|
function spellResourceCost(spell: Spell, upgrades: StackCounts<RoguelikeUpgradeId>, freeCastReady: boolean) {
|
|
return modifiedSpellResourceCost({
|
|
spell,
|
|
costDown: {
|
|
stacks: upgrades,
|
|
id: (slot) => `slot${slot as SlotKey}-cost-down` as RoguelikeUpgradeId,
|
|
},
|
|
freeCastReady,
|
|
freeCast: {
|
|
stacks: upgrades,
|
|
id: 'fifth-cast-free',
|
|
},
|
|
})
|
|
}
|
|
|
|
function getCurrentPart(encounterIndex: number) {
|
|
return Math.floor(encounterIndex / 3) + 1
|
|
}
|
|
|
|
function chooseOtherLivingMember(living: PartyMember[], member: PartyMember) {
|
|
if (living.length <= 1) return member
|
|
let remaining = Math.floor(Math.random() * (living.length - 1))
|
|
for (const candidate of living) {
|
|
if (candidate.id === member.id) continue
|
|
if (remaining === 0) return candidate
|
|
remaining -= 1
|
|
}
|
|
return member
|
|
}
|
|
|
|
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 mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
|
|
const stageOneDamageScale = 0.58 + (mode === 'raid' ? 0.13 : 0.1)
|
|
return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
|
|
pool,
|
|
stage,
|
|
mechanics,
|
|
trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
|
|
bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
|
|
healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
|
|
damageScale: difficulty.damageMultiplier * stageOneDamageScale,
|
|
partyDamageScale: 0.95,
|
|
idBase: 900000,
|
|
bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
|
|
extraFields: (_encounter, isBoss, selectedMechanics) => ({
|
|
roguelikeMechanics: isBoss ? selectedMechanics : [],
|
|
}),
|
|
}) as RoguelikeEncounter[]
|
|
}
|
|
|
|
export function CombatScreen({
|
|
difficulty,
|
|
dungeon,
|
|
hardMode = false,
|
|
marathonMode = false,
|
|
profile,
|
|
startPart = 1,
|
|
roguelikeMode,
|
|
roguelikeUpgradeTiming = 'boss',
|
|
roguelikeAbilityLabelMode = 'ability',
|
|
roguelikeEncounterPool,
|
|
onExit,
|
|
onMainMenu = onExit,
|
|
onProfileUpdated,
|
|
}: {
|
|
difficulty: Difficulty
|
|
dungeon: Dungeon
|
|
hardMode?: boolean
|
|
marathonMode?: boolean
|
|
profile: CharacterProfile
|
|
startPart?: number
|
|
roguelikeMode?: RoguelikeMode
|
|
roguelikeUpgradeTiming?: RoguelikeUpgradeTiming
|
|
roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
|
|
roguelikeEncounterPool?: DungeonEncounter[]
|
|
onExit: () => void
|
|
onMainMenu?: () => 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 = useMemo(() => {
|
|
const abilityById = new Map(gameClass.spells.map((ability) => [ability.id, ability]))
|
|
return profile.abilitySlots.flatMap((abilityId, index) => {
|
|
const ability = abilityId === null ? undefined : abilityById.get(abilityId)
|
|
return ability
|
|
? [toCombatSpell(ability, String(index + 1), healingPower)]
|
|
: []
|
|
})
|
|
}, [gameClass.spells, healingPower, profile.abilitySlots])
|
|
const spellByKey = useMemo(
|
|
() => new Map(spells.map((spell) => [spell.key, spell])),
|
|
[spells],
|
|
)
|
|
const spellByName = useMemo(
|
|
() => new Map(spells.map((spell) => [spell.name, spell])),
|
|
[spells],
|
|
)
|
|
const effectSpellByName = useMemo(
|
|
() => new Map(gameClass.spells.map((ability) => [
|
|
ability.name,
|
|
toCombatSpell(ability, `effect-${ability.id}`, healingPower),
|
|
])),
|
|
[gameClass.spells, 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' : 'Run'
|
|
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
|
const initialEncounterIndex = (startPart - 1) * 3
|
|
const enemyCount = hardMode ? 2 : 1
|
|
const initialCombatState = useMemo<SinglePlayerCombatState>(() => createPveCombatState({
|
|
partyTemplate,
|
|
maxResource,
|
|
encounter: encounters[initialEncounterIndex],
|
|
enemyCount,
|
|
}), [encounters, enemyCount, 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' | 'marathon-choice' | 'upgrade-choice'>('playing')
|
|
const [paused, setPaused] = useState(false)
|
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
|
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 [roguelikeBossCoins, setRoguelikeBossCoins] = useState<RunBossCoinAward[]>([])
|
|
const [showEndLog, setShowEndLog] = useState(false)
|
|
const {
|
|
floatingTexts,
|
|
floatingTextsByMember,
|
|
addFloatingText,
|
|
clearFloatingTexts,
|
|
} = useFloatingCombatText<BasicFloatingCombatText>()
|
|
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
|
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
|
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
|
|
const rewardClaimedRef = useRef(false)
|
|
const profileRefreshedRef = useRef(false)
|
|
const rolledEncounterIdsRef = useRef(new Set<string>())
|
|
const recordedBossKillIdsRef = useRef(new Set<string>())
|
|
const runTokenRef = useRef(crypto.randomUUID())
|
|
const resourceSpentRef = useRef(0)
|
|
const runStartedAtRef = useRef(0)
|
|
const partStartTimesRef = useRef<Record<number, number>>({})
|
|
const nextLogId = useRef(2)
|
|
const marathonBossesDefeatedRef = useRef(0)
|
|
const combatRef = useRef(initialCombatState)
|
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
|
const runCombatTickRef = useRef<() => void>(() => {})
|
|
const combatClockActiveRef = useRef(false)
|
|
const lastCombatTickAtRef = useRef(0)
|
|
const statusRef = useRef(status)
|
|
const pausedRef = useRef(paused)
|
|
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
|
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
|
const encounter = encounters[encounterIndex]
|
|
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
|
const currentPart = getCurrentPart(encounterIndex)
|
|
const completedSections = dungeon.contentType === 'raid'
|
|
? profile.completedRaidPhases
|
|
: profile.completedDungeonParts
|
|
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
|
|
const firstEncounterIndex = (startPart - 1) * 3
|
|
const expectedLootRolls = useMemo(
|
|
() => encounters
|
|
.slice(firstEncounterIndex, encounterIndex + 1)
|
|
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
|
.length * enemyCount,
|
|
[difficulty.id, encounters, encounterIndex, enemyCount, firstEncounterIndex],
|
|
)
|
|
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 roguelikeUpgradeCounts = useMemo(
|
|
() => createStackCounts(roguelikeUpgrades.map((upgrade) => upgrade.id)),
|
|
[roguelikeUpgrades],
|
|
)
|
|
const activeEffects = useMemo(
|
|
() => {
|
|
const effects = new Set<string>(
|
|
gameClass.talents
|
|
.filter((talent) => talent.rank > 0)
|
|
.map((talent) => talent.effectType),
|
|
)
|
|
if (!isRoguelike) {
|
|
profile.setBonuses
|
|
.filter((bonus) => bonus.active)
|
|
.forEach((bonus) => effects.add(bonus.effectType))
|
|
}
|
|
return effects
|
|
},
|
|
[gameClass.talents, isRoguelike, profile.setBonuses],
|
|
)
|
|
const {
|
|
bindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
lastDevice,
|
|
} = useInput()
|
|
const {
|
|
enabled: dualScreenEnabled,
|
|
} = useDualScreen()
|
|
const activeBindings = bindings[lastDevice]
|
|
|
|
useEffect(() => {
|
|
lastCombatTickAtRef.current = performance.now()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
statusRef.current = status
|
|
pausedRef.current = paused
|
|
speedMultiplierRef.current = speedMultiplier
|
|
}, [paused, speedMultiplier, status])
|
|
|
|
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) => appendCombatLog(current, entry))
|
|
}, [])
|
|
|
|
const addFloatingHeal = useCallback((memberId: string, value: number) => {
|
|
addFloatingText({ memberId, value })
|
|
}, [addFloatingText])
|
|
|
|
const requestLootRoll = useCallback(
|
|
(encounterId: number, rollIndex = 0) => {
|
|
const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
|
|
if (rolledEncounterIdsRef.current.has(rollKey)) return
|
|
rolledEncounterIdsRef.current.add(rollKey)
|
|
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
|
|
rollEncounterLoot(encounterId, difficulty.id, runToken)
|
|
.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',
|
|
)
|
|
if (result.petAwarded) {
|
|
addLog(
|
|
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
|
'loot',
|
|
)
|
|
}
|
|
})
|
|
.catch((reason: unknown) => {
|
|
addLog(
|
|
reason instanceof Error ? reason.message : 'The loot roll failed.',
|
|
'danger',
|
|
)
|
|
})
|
|
},
|
|
[addLog, difficulty.id],
|
|
)
|
|
|
|
const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
|
|
if (!isRoguelike || !encounter.isBoss) return
|
|
const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
|
|
if (recordedBossKillIdsRef.current.has(key)) return
|
|
recordedBossKillIdsRef.current.add(key)
|
|
completeRoguelike(
|
|
dungeon.id,
|
|
difficulty.id,
|
|
0,
|
|
0,
|
|
Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
|
|
{
|
|
bossesCleared: 0,
|
|
fightsCleared: 0,
|
|
lootSourceEncounterId: encounter.id,
|
|
roguelikeStage,
|
|
},
|
|
)
|
|
.then((result) => {
|
|
onProfileUpdated(result.profile)
|
|
if (result.bonusItem) {
|
|
setRoguelikeBossCoins((current) => [...current, {
|
|
...result.bonusItem!,
|
|
sourceLabel: encounter.enemyName,
|
|
}])
|
|
addLog(
|
|
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
|
'loot',
|
|
)
|
|
}
|
|
if (result.petAwarded) {
|
|
addLog(
|
|
`${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
|
|
'loot',
|
|
)
|
|
}
|
|
})
|
|
.catch((reason: unknown) => {
|
|
addLog(
|
|
reason instanceof Error ? reason.message : 'Unable to award boss coins.',
|
|
'danger',
|
|
)
|
|
})
|
|
}, [addLog, difficulty.id, dungeon.id, encounterIndex, isRoguelike, onProfileUpdated, roguelikeStage])
|
|
|
|
const resetRun = useCallback(() => {
|
|
const nextRoguelikeEncounters = roguelikeMode
|
|
? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
|
|
: []
|
|
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
|
const setup = createPveRoguelikeRunStart({
|
|
partyTemplate,
|
|
maxResource,
|
|
encounters: nextEncounters,
|
|
initialEncounterIndex,
|
|
enemyCount,
|
|
})
|
|
setCombat(setup.combatState)
|
|
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
|
setRoguelikeStage(setup.defaults.roguelikeStage)
|
|
setSelectedTargetId(setup.defaults.selectedId)
|
|
setEncounterIndex(setup.defaults.encounterIndex)
|
|
setStatus(setup.defaults.status)
|
|
setPaused(setup.defaults.paused)
|
|
setTargetGroup(setup.defaults.targetGroup)
|
|
setReward(setup.defaults.reward)
|
|
setRewardError(setup.defaults.rewardError)
|
|
setLootRolls(setup.defaults.lootRolls)
|
|
setRoguelikeBossCoins([])
|
|
setShowEndLog(setup.defaults.showEndLog)
|
|
clearFloatingTexts()
|
|
setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
|
|
setUpgradeChoices(setup.defaults.upgradeChoices)
|
|
setMarathonBossesDefeated(setup.defaults.marathonBossesDefeated)
|
|
rewardClaimedRef.current = false
|
|
profileRefreshedRef.current = false
|
|
rolledEncounterIdsRef.current = new Set()
|
|
recordedBossKillIdsRef.current = new Set()
|
|
runTokenRef.current = crypto.randomUUID()
|
|
marathonBossesDefeatedRef.current = 0
|
|
resourceSpentRef.current = setup.defaults.resourceSpent
|
|
runStartedAtRef.current = Date.now()
|
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
|
setLog([{ id: nextLogId.current++, ...setup.defaults.log }])
|
|
}, [clearFloatingTexts, difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
|
|
|
const castSpell = useCallback(
|
|
(spell: Spell) => {
|
|
const current = combatRef.current
|
|
const effectiveCost = spellResourceCost(spell, roguelikeUpgradeCounts, current.freeCastReady)
|
|
if (status !== 'playing' || !canCastSpell(spell, current.resource, current.cooldowns, 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 renewEffect = effectSpellByName.get('Renew') ?? null
|
|
const shieldEffect = effectSpellByName.get('Sun Ward') ?? null
|
|
const healingMultiplier = (member: PartyMember) =>
|
|
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
|
const extraTargets = spellExtraTargets(spell, {
|
|
stacks: roguelikeUpgradeCounts,
|
|
id: (slot) => `slot${slot as SlotKey}-extra-target` as RoguelikeUpgradeId,
|
|
})
|
|
const {
|
|
directTargets,
|
|
hotTargets,
|
|
shieldTargets,
|
|
groupTargets,
|
|
} = buildSpellTargetPlan({
|
|
party: current.party,
|
|
spell,
|
|
targetId,
|
|
extraTargets,
|
|
directTarget: true,
|
|
hotTarget: spell.kind === 'hot' || spell.effectType === 'direct_hot',
|
|
shieldTarget: spell.kind === 'shield',
|
|
groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
|
|
extraTargetMode: {
|
|
hot: 'hot',
|
|
shield: 'shield',
|
|
},
|
|
beforeExtraTargetBuckets: [
|
|
...(spell.name === 'Mend' && activeEffects.has('mend_extra_target') ? ['direct' as const] : []),
|
|
...(spell.name === 'Renew' && activeEffects.has('renew_extra_target') ? ['hot' as const] : []),
|
|
],
|
|
})
|
|
if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
|
|
hotTargets.add(targetId)
|
|
}
|
|
if (spell.name === 'Mend' && activeEffects.has('mend_applies_shield') && shieldEffect) {
|
|
directTargets.forEach((id) => shieldTargets.add(id))
|
|
}
|
|
|
|
const shieldBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'shield-boost' })
|
|
const groupHealBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'group-heal-boost' })
|
|
const spellEffectProfile: SpellEffectProfile = {
|
|
modeName: 'pve',
|
|
heal: healMember,
|
|
healingMultiplier,
|
|
power: {
|
|
direct: (source) => source.power,
|
|
cleanse: (source) => source.power,
|
|
groupHeal: (source) => Math.round(source.power * groupHealBoost),
|
|
groupAbsorb: (source) => Math.round(source.power * shieldBoost),
|
|
shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength * shieldBoost),
|
|
},
|
|
hot: {
|
|
mode: 'effects',
|
|
defaultTicks: 5,
|
|
groupTicks: 5,
|
|
radianceTicks: 3,
|
|
merge: 'replace',
|
|
},
|
|
effects: {
|
|
renewSpell: renewEffect,
|
|
shieldSpell: shieldEffect,
|
|
groupAbsorbOnly: (source) => source.effectType === 'party_absorb',
|
|
groupHotOnly: (source) => source.effectType === 'party_hot',
|
|
groupAppliesShield: (source) => source.effectType === 'party_absorb'
|
|
|| (source.name === 'Radiance' && activeEffects.has('radiance_applies_shield')),
|
|
groupAppliesHot: (source) => source.name === 'Radiance' && activeEffects.has('radiance_applies_renew'),
|
|
shieldAppliesHot: () => activeEffects.has('shield_applies_renew') && Boolean(renewEffect),
|
|
hotSpellForDirect: (source) => source.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
|
|
? renewEffect
|
|
: source,
|
|
},
|
|
ratios: {
|
|
groupShield: 0.3,
|
|
directShield: 0.5,
|
|
},
|
|
damageReductionTicks: 12,
|
|
floatingHeals: {
|
|
group: true,
|
|
direct: true,
|
|
cleanse: false,
|
|
},
|
|
bounceHeals: true,
|
|
}
|
|
const { party: nextParty, floatingHeals } = applySpellEffectProfile({
|
|
party: current.party,
|
|
spell,
|
|
targetId,
|
|
plan: {
|
|
directTargets,
|
|
hotTargets,
|
|
shieldTargets,
|
|
damageReductionTargets: new Set(),
|
|
groupTargets,
|
|
},
|
|
profile: spellEffectProfile,
|
|
})
|
|
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
|
|
resourceSpentRef.current += effectiveCost
|
|
let nextCooldowns = current.cooldowns
|
|
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
|
const radiance = spellByName.get('Radiance')
|
|
if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
|
|
}
|
|
setCombat(applyCastStateUpdate({
|
|
current,
|
|
party: nextParty,
|
|
cooldowns: nextCooldowns,
|
|
spell,
|
|
resourceCost: effectiveCost,
|
|
cooldownMultiplier: cooldownMultiplier(spell, roguelikeUpgradeCounts),
|
|
freeCast: {
|
|
enabled: hasSpellStack({ stacks: roguelikeUpgradeCounts, id: 'fifth-cast-free' }),
|
|
wasReady: current.freeCastReady,
|
|
},
|
|
}))
|
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
|
},
|
|
[activeEffects, addFloatingHeal, addLog, effectSpellByName, roguelikeUpgradeCounts, setCombat, spellByName, 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)],
|
|
hardMode,
|
|
)
|
|
.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, hardMode, 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 getTargetParty = useCallback(() => combatRef.current.party, [])
|
|
const {
|
|
selectRelativeTarget,
|
|
selectDirectionalTarget,
|
|
selectDirectTarget,
|
|
} = usePartyTargeting({
|
|
getParty: getTargetParty,
|
|
selectedIdRef,
|
|
setSelectedTargetId,
|
|
columns: dungeon.partySize >= 10 ? 6 : 3,
|
|
relativeLivingOnly: true,
|
|
directionalLivingOnly: false,
|
|
directLivingOnly: false,
|
|
directTargetGroup: dungeon.partySize > 6 ? targetGroup : 0,
|
|
})
|
|
|
|
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,
|
|
hotEffects: [],
|
|
bounceHeals: [],
|
|
damageReductionTicks: 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 * enemyCount,
|
|
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, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
|
|
|
useGameAction((action, device) => {
|
|
if (action === 'toggleSpeed') {
|
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
|
return
|
|
}
|
|
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 next = nextTargetGroupSelection(combatRef.current.party, selectedIdRef.current, current)
|
|
if (next.selectedId) setSelectedTargetId(next.selectedId)
|
|
return next.group as 0 | 1 | 2
|
|
})
|
|
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 = spellByKey.get(String(slot + 1))
|
|
if (spell) castSpell(spell)
|
|
})
|
|
|
|
const runCombatTick = useCallback(() => {
|
|
const current = combatRef.current
|
|
const nextElapsedTicks = current.elapsedTicks + 1
|
|
const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
|
|
let nextResource = regenerateResource(current.resource, 2.4, 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 tankPressure = tankPressureTargets(current.party)
|
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
|
const hasShieldedHealingBonus = activeEffects.has('shielded_healing_bonus')
|
|
const shieldedDamageMultiplier = activeEffects.has('shielded_damage_reduction') ? 0.8 : undefined
|
|
const pendingJumpHeals: Array<{
|
|
targetId: string
|
|
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
|
}> = []
|
|
const damagedParty = current.party.map((member) => {
|
|
if (member.health <= 0) return member
|
|
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
|
if (tankPressureIds.has(member.id)) {
|
|
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
|
|
}
|
|
if (tankBuster && tankPressureIds.has(member.id)) {
|
|
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
|
|
}
|
|
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
|
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
|
damage *= enemyCount
|
|
const healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1
|
|
const hasBounceHeals = Boolean(member.bounceHeals?.length)
|
|
const result = advanceMemberTick({
|
|
member,
|
|
party: current.party,
|
|
damage,
|
|
hotHealing: 0,
|
|
hotTicks: 'effects',
|
|
healingMultiplier,
|
|
shieldedDamageMultiplier,
|
|
applyDebuff: appliesDebuff && member.id === primaryTarget.id
|
|
? { label: 'Searing Mark', ticks: 8 }
|
|
: undefined,
|
|
applyPoisonStacks: appliesPoison && member.id === primaryTarget.id,
|
|
poisonDamage: (stacks) => Math.round((4 + stacks * 4) * difficulty.damageMultiplier),
|
|
applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 15 : undefined,
|
|
applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 15 : undefined,
|
|
decrementDamageReduction: true,
|
|
useBounceHeals: hasBounceHeals,
|
|
jumpTarget: hasBounceHeals ? () => chooseOtherLivingMember(living, member) : undefined,
|
|
})
|
|
if (result.floatingHeal > 0) addFloatingHeal(member.id, result.floatingHeal)
|
|
pendingJumpHeals.push(...result.jumpedBounceHeals)
|
|
return result.member
|
|
})
|
|
const nextParty = attachJumpedBounceHeals(damagedParty, pendingJumpHeals)
|
|
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 - partyDamageOutput(nextParty, 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)) {
|
|
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
|
|
requestLootRoll(encounter.id, rollIndex)
|
|
}
|
|
}
|
|
recordRoguelikeBossKill(encounter)
|
|
|
|
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) {
|
|
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
|
|
marathonBossesDefeatedRef.current = nextMarathonKills
|
|
setMarathonBossesDefeated(nextMarathonKills)
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: 0,
|
|
})
|
|
setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
|
|
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
|
return
|
|
}
|
|
|
|
if (encounterIndex === encounters.length - 1) {
|
|
if (marathonMode && encounter.isBoss) {
|
|
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
|
|
marathonBossesDefeatedRef.current = nextMarathonKills
|
|
setMarathonBossesDefeated(nextMarathonKills)
|
|
setCombat({
|
|
...current,
|
|
party: nextParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: nextElapsedTicks,
|
|
enemyHealth: 0,
|
|
})
|
|
setStatus('marathon-choice')
|
|
addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
|
|
return
|
|
}
|
|
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,
|
|
hotEffects: [],
|
|
bounceHeals: [],
|
|
damageReductionTicks: undefined,
|
|
}))
|
|
setEncounterIndex((value) => value + 1)
|
|
setCombat({
|
|
...current,
|
|
party: recoveredParty,
|
|
resource: nextResource,
|
|
cooldowns: nextCooldowns,
|
|
elapsedTicks: 0,
|
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
|
})
|
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
|
}, [
|
|
activeEffects,
|
|
addLog,
|
|
addFloatingHeal,
|
|
difficulty.damageMultiplier,
|
|
enemyCount,
|
|
encounter,
|
|
encounterIndex,
|
|
encounters,
|
|
finishRun,
|
|
finishRoguelikeRun,
|
|
difficulty.id,
|
|
isPartBoss,
|
|
isFinalBoss,
|
|
isRoguelike,
|
|
marathonMode,
|
|
upgradesEveryEncounter,
|
|
roguelikeUpgradeCatalog,
|
|
maxResource,
|
|
gameClass.resourceName,
|
|
requestLootRoll,
|
|
recordRoguelikeBossKill,
|
|
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 tickMs = TICK_MS / speedMultiplierRef.current
|
|
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
|
|
if (dueTicks <= 0) return
|
|
lastCombatTickAtRef.current += dueTicks * tickMs
|
|
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 / encounterMaxHealth) * 100
|
|
const enemyHealthSegments = useMemo(
|
|
() => Array.from({ length: enemyCount }, (_, index) => {
|
|
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
|
|
return {
|
|
index,
|
|
health: remaining,
|
|
percent: (remaining / encounter.maxHealth) * 100,
|
|
}
|
|
}).reverse(),
|
|
[enemyCount, enemyHealth, encounter.maxHealth],
|
|
)
|
|
const spellSlotCost = useCallback(
|
|
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
|
[freeCastReady, roguelikeUpgradeCounts],
|
|
)
|
|
const spellSlots = useSpellSlots({
|
|
spells,
|
|
abilitySlots: profile.abilitySlots,
|
|
cooldowns,
|
|
active: status === 'playing' && !paused,
|
|
cost: spellSlotCost,
|
|
})
|
|
const dualScreenState = useMemo(() => buildCombatDualScreenState({
|
|
difficultyName: difficulty.name,
|
|
dungeonName: dungeon.name,
|
|
contentName,
|
|
encounterName: encounter.enemyName,
|
|
encounterDescription: encounter.description,
|
|
encounterHealth: enemyHealth,
|
|
encounterMaxHealth,
|
|
encounterIsBoss: encounter.isBoss,
|
|
encounterIndex,
|
|
encounterCount: encounters.length,
|
|
party,
|
|
floatingTexts,
|
|
partySize: dungeon.partySize,
|
|
selectedId,
|
|
status,
|
|
resource,
|
|
maxResource,
|
|
resourceName: gameClass.resourceName,
|
|
playerIsAlive,
|
|
spells: spellSlots,
|
|
bindings: activeBindings,
|
|
controllerIconStyle,
|
|
directPartyTargeting,
|
|
paused,
|
|
targetGroup,
|
|
speedMultiplier,
|
|
}), [
|
|
activeBindings,
|
|
controllerIconStyle,
|
|
contentName,
|
|
difficulty.name,
|
|
dungeon.name,
|
|
dungeon.partySize,
|
|
directPartyTargeting,
|
|
encounter.description,
|
|
encounter.enemyName,
|
|
encounter.isBoss,
|
|
encounterMaxHealth,
|
|
enemyHealth,
|
|
encounterIndex,
|
|
encounters.length,
|
|
gameClass.resourceName,
|
|
maxResource,
|
|
paused,
|
|
party,
|
|
playerIsAlive,
|
|
resource,
|
|
selectedId,
|
|
spellSlots,
|
|
floatingTexts,
|
|
speedMultiplier,
|
|
status,
|
|
targetGroup,
|
|
])
|
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
|
|
|
return (
|
|
<main
|
|
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
|
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
|
|
>
|
|
{!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)} / {encounterMaxHealth}</span>
|
|
</div>
|
|
{hardMode ? (
|
|
<div className="hard-enemy-bars">
|
|
{enemyHealthSegments.map((segment) => (
|
|
<div className="bar enemy-health" key={segment.index}>
|
|
<span style={barFillStyle(segment.percent)} />
|
|
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
|
|
)}
|
|
<p>{encounter.description}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="combat-layout">
|
|
<section className="party-panel">
|
|
<div className="party-panel-top">
|
|
<ResourceBar
|
|
maxResource={maxResource}
|
|
resource={resource}
|
|
resourceName={gameClass.resourceName}
|
|
speedMultiplier={speedMultiplier}
|
|
unavailableText={playerIsAlive ? undefined : `${profile.character.name} is defeated`}
|
|
/>
|
|
</div>
|
|
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
|
|
{party.map((member) => (
|
|
<PartyMemberFrame
|
|
key={member.id}
|
|
member={member}
|
|
selected={selectedId === member.id}
|
|
floatingTexts={floatingTextsByMember.get(member.id) ?? []}
|
|
showTargetMarker
|
|
onSelect={setSelectedTargetId}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<aside className="combat-log combat-side-rail">
|
|
<div className="panel-heading">
|
|
<div><p className="eyebrow">Actions</p><h2>Skills</h2></div>
|
|
</div>
|
|
<SpellBar
|
|
bindings={activeBindings}
|
|
canCast={playerIsAlive && status === 'playing'}
|
|
iconStyle={controllerIconStyle}
|
|
onCast={castSpell}
|
|
resource={resource}
|
|
resourceName={gameClass.resourceName}
|
|
spells={spellSlots}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{dualScreenEnabled && (
|
|
<DualScreenTopCombat
|
|
state={dualScreenState}
|
|
onCastSpell={castSpell}
|
|
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. Continue the fight or return to the main menu.</p>
|
|
<button onClick={() => setPaused(false)} type="button">Continue</button>
|
|
<button className="secondary-result-button" onClick={onMainMenu} type="button">
|
|
Main Menu
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'upgrade-choice' && (
|
|
<div className="result-screen">
|
|
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
|
|
<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="pvp-choice-columns">
|
|
<div>
|
|
<strong>Run Buff</strong>
|
|
<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>
|
|
</div>
|
|
</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 !== 'marathon-choice' && status !== 'upgrade-choice' && (
|
|
<ResultScreen
|
|
actions={[
|
|
{ label: 'Run Again', onClick: resetRun },
|
|
{ label: `Leave ${contentName}`, onClick: onExit, className: 'secondary-result-button' },
|
|
]}
|
|
eyebrow={status === 'won' ? `${contentName} Complete` : 'Party Defeated'}
|
|
log={log}
|
|
onToggleLog={() => setShowEndLog((value) => !value)}
|
|
showLog={showEndLog}
|
|
title={status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}
|
|
>
|
|
{status === 'won' ? (
|
|
<div className="reward-summary">
|
|
{!reward && !rewardError && <p>Recording victory...</p>}
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
{reward && (
|
|
<>
|
|
{isRoguelike && <p>Run totals</p>}
|
|
<RewardXpSummary reward={reward} />
|
|
{isRoguelike && (
|
|
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
|
)}
|
|
{!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>
|
|
<LootRollList rolls={lootRolls} expectedRolls={expectedLootRolls} />
|
|
<BonusItemReward item={reward.bonusItem} eyebrow="Full Run Bonus" />
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : isRoguelike ? (
|
|
<div className="reward-summary">
|
|
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
|
|
{rewardError && <p className="reward-error">{rewardError}</p>}
|
|
{reward && (
|
|
<>
|
|
<RewardXpSummary reward={reward} />
|
|
<p>{encounterIndex} encounters cleared.</p>
|
|
<PvpRunLootList loot={roguelikeBossCoins} emptyLabel="No boss coins collected this run." />
|
|
<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>
|
|
)}
|
|
</ResultScreen>
|
|
)}
|
|
{status === 'marathon-choice' && (
|
|
<div className="result-screen">
|
|
<div>
|
|
<p className="eyebrow">Marathon</p>
|
|
<h2>{encounter.enemyName} Defeated</h2>
|
|
<p>
|
|
{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
|
|
Continue with current health and {gameClass.resourceName}, or end the hunt.
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
const current = combatRef.current
|
|
setCombat({
|
|
...current,
|
|
enemyHealth: encounter.maxHealth * enemyCount,
|
|
elapsedTicks: 0,
|
|
})
|
|
setStatus('playing')
|
|
addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
|
|
}}
|
|
type="button"
|
|
>
|
|
Continue Marathon
|
|
</button>
|
|
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
|
End
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{status === 'part-complete' && (
|
|
<div className="result-screen">
|
|
<div>
|
|
<p className="eyebrow">{sectionName} Complete</p>
|
|
<h2>{encounter.enemyName} Defeated</h2>
|
|
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Run checkpoint complete.'}</p>
|
|
{canContinueAfterPart && (
|
|
<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,
|
|
poisonStacks: undefined,
|
|
maxHealthPenaltyTicks: undefined,
|
|
healingReductionTicks: undefined,
|
|
hotEffects: [],
|
|
bounceHeals: [],
|
|
damageReductionTicks: undefined,
|
|
}))
|
|
setEncounterIndex(nextIndex)
|
|
setCombat({
|
|
...current,
|
|
party: recoveredParty,
|
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
|
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>
|
|
)
|
|
}
|