Files
i-want-to-heal-2/src/components/BulldromeBossSlice.tsx
T

343 lines
14 KiB
TypeScript

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<ThreeActionSceneHandle | null>(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<BulldromeState>(() => createBulldromeState(difficulty, dungeonId, runMode, combatProfile.classId, combatProfile.talentModifiers))
const audioUnlockedRef = useRef(false)
const seenHealEventIdsRef = useRef(new Set<string>())
const previousLastHitRef = useRef<BulldromeState['lastHit']>(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 (
<main className="boss-slice-shell dungeon-combat-shell">
<section className="boss-slice-stage">
<div className="boss-slice-layout dungeon-combat-layout">
<div className="boss-playfield-panel">
<div className="boss-playfield-frame">
<div className="arena-boss-window-party dungeon-window-party" aria-label="Party frames">
<CombatPartyFrames
compact
frames={raidFrames}
onSelect={(id) => {
playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 })
threeSceneRef.current?.selectTarget(id)
}}
/>
</div>
<div className="boss-window-bossbar dungeon-encounter-frame">
<strong>{encounterTitle}</strong>
<span>{Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}</span>
<i>
<b style={{ width: `${Math.max(0, Math.min(100, (encounterHp.hp / encounterHp.maxHp) * 100))}%` }} />
</i>
</div>
{state.player.currentCast && (
<div className="boss-castbar boss-field-castbar">
<div>
<strong>{getActionSpellDefinition(state.player.classId, state.player.currentCast.spell, state.player.talentModifiers).name}</strong>
<span>{state.player.currentCast.remaining.toFixed(1)}s</span>
</div>
<i>
<b
style={{
width: `${Math.max(0, Math.min(100, ((state.player.currentCast.total - state.player.currentCast.remaining) / state.player.currentCast.total) * 100))}%`,
}}
/>
</i>
</div>
)}
{selectedTarget && (
<CombatTargetFrame
className="dungeon-target-chip"
detail={selectedTarget.detail}
hp={selectedTarget.hp}
kindLabel={selectedTarget.kindLabel}
label={selectedTarget.label}
maxHp={selectedTarget.maxHp}
tone={selectedTarget.tone}
/>
)}
<CombatActionBar
className="dungeon-actionbar"
columns={getActionSpellbook(state.player.classId).bar.length}
globalCooldown={state.player.globalCooldown}
onCast={(slot) => {
playCombatSound(DUNGEON_SFX.cast, { unlocked: audioUnlockedRef })
threeSceneRef.current?.castSpell(slot)
}}
slots={createDungeonActionSlots(state, selectedTarget?.kind ?? 'ally')}
/>
{renderUnavailable ? (
<div className="boss-render-loading">3D renderer unavailable.</div>
) : (
<Suspense fallback={<div className="boss-render-loading">Loading 3D hunt...</div>}>
<ThreeActionScene
difficulty={difficulty}
dungeonId={dungeonId}
onStateChange={setState}
onRenderUnavailable={handleRenderUnavailable}
paused={paused}
playerClassId={combatProfile.classId}
playerTalentModifiers={combatProfile.talentModifiers}
ref={threeSceneRef}
runMode={runMode}
/>
</Suspense>
)}
{paused && (
pausePanel === 'settings' ? (
<PauseSettingsMenu
exitLabel="Exit Run"
onExit={onExit}
onOpenCharacter={character ? () => setPausePanel('character') : undefined}
onOpenInventory={character && onEquipItem ? () => setPausePanel('inventory') : undefined}
onOpenTalents={character ? () => setPausePanel('talents') : undefined}
onResume={() => setPaused(false)}
/>
) : character && pausePanel === 'character' ? (
<ActionMenuOverlay
onBack={() => setPausePanel('settings')}
onClose={() => setPaused(false)}
title="Character"
>
<ActionCharacterMenu
character={character}
onOpenInventory={onEquipItem ? () => setPausePanel('inventory') : undefined}
onOpenTalents={() => setPausePanel('talents')}
/>
</ActionMenuOverlay>
) : character && pausePanel === 'talents' ? (
<ActionMenuOverlay
onBack={() => setPausePanel('settings')}
onClose={() => setPaused(false)}
title="Talents"
>
<ActionTalentMenu character={character} onCharacterChange={onCharacterChange} />
</ActionMenuOverlay>
) : character && onEquipItem ? (
<ActionMenuOverlay
onBack={() => setPausePanel('settings')}
onClose={() => setPaused(false)}
title="Inventory"
>
<ActionInventoryMenu character={character} onEquip={onEquipItem} />
</ActionMenuOverlay>
) : null
)}
</div>
</div>
</div>
</section>
</main>
)
}
function createDungeonActionSlots(state: BulldromeState, targetKind: 'ally' | 'enemy'): Array<CombatActionSlot<SpellSlot>> {
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',
}
}