import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ThreeActionSceneHandle } from './ThreeActionScene' import { type ActionCharacter, type ActionDifficulty, type ActionRunMode, } from '../actionMode' import { createActionCombatProfile } from '../actionCombatProfile' import { getActionSpellbook, getActionSpellDefinition, getActionSpellIconUrl, getActionSpellManaCost, getActionSpellTarget, getActionSpellTooltip, } from '../actionCombatCore' import { createBulldromeState, getEncounterHp, getEncounterTitle, getRaidFrames, type BulldromeState, type ActionDungeonId, type SpellSlot, } from '../actionBoss/actionCombatSimulation' import { CombatActionBar, CombatPartyFrames, CombatTargetFrame, type CombatActionSlot } from './CombatHud' import { playCombatSound } from './CombatAudio' import { PauseSettingsMenu } from './CombatPauseMenu' import { ActionCharacterMenu, ActionInventoryMenu, ActionMenuOverlay, ActionTalentMenu } from './ActionEquipmentMenus' type BulldromeBossSliceProps = { character?: ActionCharacter dungeonId?: ActionDungeonId difficulty?: ActionDifficulty runMode?: ActionRunMode onEquipItem?: (itemId: string) => void onExit: () => void onCharacterChange?: (character: ActionCharacter) => void onRunComplete?: () => void } const ThreeActionScene = lazy(() => import('./ThreeActionScene')) const DUNGEON_SFX = { cast: '/audio/sfx/cast_holy.mp3', heal: '/audio/sfx/heal_impact.mp3', buff: '/audio/sfx/buff_apply.mp3', hit: '/audio/sfx/impact_flesh.mp3', hurt: '/audio/sfx/player_hurt.mp3', swing: '/audio/sfx/melee_swing_blade.mp3', } export function BulldromeBossSlice({ character, dungeonId = 'bulldrome', difficulty = 'ilvl-1', onEquipItem, onCharacterChange, runMode = 'hunt', onExit, onRunComplete, }: BulldromeBossSliceProps) { const combatProfile = useMemo(() => createActionCombatProfile(character), [character]) const threeSceneRef = useRef(null) const completionSentRef = useRef(false) const rewardedBossKillsRef = useRef(0) const [paused, setPaused] = useState(false) const [pausePanel, setPausePanel] = useState<'settings' | 'character' | 'inventory' | 'talents'>('settings') const [renderUnavailable, setRenderUnavailable] = useState(false) const [state, setState] = useState(() => createBulldromeState(difficulty, dungeonId, runMode, combatProfile.classId, combatProfile.talentModifiers)) const audioUnlockedRef = useRef(false) const seenHealEventIdsRef = useRef(new Set()) const previousLastHitRef = useRef(null) const handleRenderUnavailable = useCallback(() => { setRenderUnavailable(true) }, []) const raidFrames = useMemo(() => getRaidFrames(state), [state]) const encounterHp = useMemo(() => getEncounterHp(state), [state]) const encounterTitle = useMemo(() => getEncounterTitle(state), [state]) const selectedTarget = useMemo(() => getDungeonTargetFrame(state), [state]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat) return if (event.key === 'Escape') { event.preventDefault() setPausePanel('settings') setPaused((current) => !current) return } if (paused) return if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault() const selectedIndex = Math.max(0, raidFrames.findIndex((frame) => frame.selected)) const delta = event.key === 'ArrowDown' ? 1 : -1 const nextFrame = raidFrames[(selectedIndex + delta + raidFrames.length) % raidFrames.length] if (nextFrame) { playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 }) threeSceneRef.current?.selectTarget(nextFrame.id) } } if (event.key === 'Tab') { event.preventDefault() const target = [state.boss, ...state.adds].find((enemy) => enemy.hp > 0) if (target) { playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 }) threeSceneRef.current?.selectTarget(target.id) } } const spellKey = event.key === '0' ? 10 : Number(event.key) if (getActionSpellbook(state.player.classId).bar.includes(spellKey as SpellSlot)) { event.preventDefault() playCombatSound(DUNGEON_SFX.cast, { unlocked: audioUnlockedRef }) threeSceneRef.current?.castSpell(spellKey as SpellSlot) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [paused, raidFrames, state.adds, state.boss, state.player.classId]) useEffect(() => { for (const event of state.healEvents) { if (seenHealEventIdsRef.current.has(event.id)) continue seenHealEventIdsRef.current.add(event.id) playCombatSound(DUNGEON_SFX.heal, { unlocked: audioUnlockedRef, volume: 0.42 }) } if (state.result !== 'playing') return if (state.lastHit && state.lastHit !== previousLastHitRef.current) { if (state.lastHit === 'player') playCombatSound(DUNGEON_SFX.hurt, { unlocked: audioUnlockedRef, volume: 0.38 }) if (state.lastHit === 'boss') playCombatSound(DUNGEON_SFX.hit, { unlocked: audioUnlockedRef, volume: 0.35 }) } previousLastHitRef.current = state.lastHit }, [state.healEvents, state.lastHit, state.result]) useEffect(() => { if (runMode === 'marathon') { if (state.bossKills <= rewardedBossKillsRef.current) return rewardedBossKillsRef.current = state.bossKills onRunComplete?.() return } if (state.result !== 'win' || completionSentRef.current) return completionSentRef.current = true onRunComplete?.() }, [onRunComplete, runMode, state.bossKills, state.result]) return (
{ playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 }) threeSceneRef.current?.selectTarget(id) }} />
{encounterTitle} {Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}
{state.player.currentCast && (
{getActionSpellDefinition(state.player.classId, state.player.currentCast.spell, state.player.talentModifiers).name} {state.player.currentCast.remaining.toFixed(1)}s
)} {selectedTarget && ( )} { playCombatSound(DUNGEON_SFX.cast, { unlocked: audioUnlockedRef }) threeSceneRef.current?.castSpell(slot) }} slots={createDungeonActionSlots(state, selectedTarget?.kind ?? 'ally')} /> {renderUnavailable ? (
3D renderer unavailable.
) : ( Loading 3D hunt...
}> )} {paused && ( pausePanel === 'settings' ? ( setPausePanel('character') : undefined} onOpenInventory={character && onEquipItem ? () => setPausePanel('inventory') : undefined} onOpenTalents={character ? () => setPausePanel('talents') : undefined} onResume={() => setPaused(false)} /> ) : character && pausePanel === 'character' ? ( setPausePanel('settings')} onClose={() => setPaused(false)} title="Character" > setPausePanel('inventory') : undefined} onOpenTalents={() => setPausePanel('talents')} /> ) : character && pausePanel === 'talents' ? ( setPausePanel('settings')} onClose={() => setPaused(false)} title="Talents" > ) : character && onEquipItem ? ( setPausePanel('settings')} onClose={() => setPaused(false)} title="Inventory" > ) : null )}
) } function createDungeonActionSlots(state: BulldromeState, targetKind: 'ally' | 'enemy'): Array> { return getActionSpellbook(state.player.classId).bar.map((slot) => { const spell = getActionSpellDefinition(state.player.classId, slot, state.player.talentModifiers) const manaCost = getActionSpellManaCost(state.player.classId, slot, { freeCast: state.player.storedMomentumReady, talentMods: state.player.talentModifiers }) const canAfford = state.player.mana >= manaCost const spellTarget = getActionSpellTarget(state.player.classId, slot) const tooltip = getActionSpellTooltip(state.player.classId, slot) const needsEnemy = spellTarget === 'Enemy target' const hasTarget = needsEnemy ? targetKind === 'enemy' : targetKind === 'ally' return { canCast: canAfford && hasTarget, cooldown: state.player.spellCooldowns[slot], cooldownBase: spell.cooldown, globalClassName: `${canAfford ? '' : 'oom'} ${hasTarget ? '' : 'no-target'}`, iconUrl: getActionSpellIconUrl(state.player.classId, slot), id: slot, keybind: slot === 10 ? '0' : String(slot), name: spell.name, tooltip: { cast: spell.castTime > 0 ? `${spell.castTime}s` : 'Instant', cooldown: spell.cooldown > 0 ? `${spell.cooldown}s` : undefined, cost: `${manaCost} mana`, description: tooltip.description, rank: `Rank ${tooltip.rank} · highest at level 20`, school: tooltip.school, target: tooltip.target, }, } }) } function getDungeonTargetFrame(state: BulldromeState): { detail: string hp: number kind: 'ally' | 'enemy' kindLabel: string label: string maxHp: number tone: 'ally' | 'enemy' } | null { const ally = [state.player, ...state.party].find((unit) => unit.id === state.targetId) if (ally) { return { detail: `${ally.role.toUpperCase()} · ${Math.ceil(ally.hp)} / ${ally.maxHp}`, hp: ally.hp, kind: 'ally', kindLabel: 'Target Ally', label: ally.name, maxHp: ally.maxHp, tone: 'ally', } } const enemy = [state.boss, ...state.adds].find((unit) => unit.id === state.targetId) if (!enemy) return null return { detail: `${enemy.kind.toUpperCase()} · ${Math.ceil(enemy.hp)} / ${enemy.maxHp}`, hp: enemy.hp, kind: 'enemy', kindLabel: enemy.id === state.boss.id ? 'Target Boss' : 'Target Mob', label: enemy.name, maxHp: enemy.maxHp, tone: 'enemy', } }