343 lines
14 KiB
TypeScript
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',
|
|
}
|
|
}
|