()
+ for (const [id, effect] of effects) {
+ const age = Number(effect.userData.age ?? 0) + deltaSeconds
+ effect.userData.age = age
+ if (effect.userData.projectile) {
+ const lifetime = 0.62
+ if (age > lifetime) continue
+ active.add(id)
+ const t = Math.max(0, Math.min(1, age / lifetime))
+ const sourceX = Number(effect.userData.sourceX ?? 0)
+ const sourceZ = Number(effect.userData.sourceZ ?? 0)
+ const targetX = Number(effect.userData.targetX ?? sourceX)
+ const targetZ = Number(effect.userData.targetZ ?? sourceZ)
+ const arc = Number(effect.userData.arc ?? 0.42)
+ effect.position.set(
+ THREE.MathUtils.lerp(sourceX, targetX, t),
+ 0.68 + Math.sin(t * Math.PI) * arc,
+ THREE.MathUtils.lerp(sourceZ, targetZ, t),
+ )
+ effect.rotation.y = Math.atan2(targetX - sourceX, targetZ - sourceZ)
+ effect.scale.setScalar(0.72 + Math.sin(t * Math.PI) * 0.28)
+ setObjectOpacity(effect, Math.max(0, 1 - t))
+ continue
+ }
+ const target = getArenaBattleUnits(state).find((unit) => unit.id === effect.userData.targetId)
+ ?? getArenaBattleBosses(state).find((boss) => boss.id === effect.userData.targetId)
+ const lifetime = effect.userData.combatText ? 1.15 : 0.85
+ if (!target || age > lifetime || (effect.userData.stun && (!('stunTimer' in target) || target.stunTimer <= 0))) continue
+ active.add(id)
+ const point = toWorld(target.x, target.y)
+ if (effect.userData.combatText) {
+ const offset = Number(effect.userData.floatOffset ?? 0)
+ effect.position.set(point.x + offset, 1.65 + age * 0.75, point.z)
+ effect.lookAt(camera.position)
+ setObjectOpacity(effect, Math.max(0, 1 - age / lifetime))
+ continue
+ }
+ effect.position.set(point.x, effect.userData.stun ? 1.42 : 0.08, point.z)
+ effect.scale.setScalar(0.6 + age * 1.5)
+ effect.rotation.y += deltaSeconds * 4
+ setObjectOpacity(effect, Math.max(0, 1 - age / lifetime))
+ }
+ pruneObjectMap(effects, active)
+}
+
+function createFloatingCombatText(text: string, color: number) {
+ const canvas = document.createElement('canvas')
+ canvas.width = 256
+ canvas.height = 96
+ const context = canvas.getContext('2d')
+ if (context) {
+ context.clearRect(0, 0, canvas.width, canvas.height)
+ context.font = '700 44px monospace'
+ context.textAlign = 'center'
+ context.textBaseline = 'middle'
+ context.lineWidth = 8
+ context.strokeStyle = 'rgba(0, 0, 0, 0.9)'
+ context.strokeText(text, canvas.width / 2, canvas.height / 2)
+ context.lineWidth = 3
+ context.strokeStyle = 'rgba(255, 255, 255, 0.55)'
+ context.strokeText(text, canvas.width / 2, canvas.height / 2)
+ context.fillStyle = `#${color.toString(16).padStart(6, '0')}`
+ context.fillText(text, canvas.width / 2, canvas.height / 2)
+ }
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ const material = new THREE.MeshBasicMaterial({
+ depthTest: false,
+ map: texture,
+ side: THREE.DoubleSide,
+ transparent: true,
+ })
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.35, 0.5), material)
+ mesh.renderOrder = 12
+ const group = new THREE.Group()
+ group.add(mesh)
+ return group
+}
+
+function createAbilityProjectile(kind: 'ranged' | 'spell', color: number) {
+ const group = new THREE.Group()
+ if (kind === 'ranged') {
+ const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.78 })
+ const metal = new THREE.MeshStandardMaterial({ color: 0xf3f5ff, emissive: color, emissiveIntensity: 0.28, roughness: 0.32, metalness: 0.42 })
+ const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.035, 0.74), wood)
+ group.add(shaft)
+ const tip = new THREE.Mesh(new THREE.ConeGeometry(0.065, 0.16, 8), metal)
+ tip.position.z = -0.45
+ tip.rotation.x = -Math.PI / 2
+ group.add(tip)
+ return group
+ }
+
+ const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.92 })
+ const core = new THREE.Mesh(new THREE.SphereGeometry(0.13, 14, 10), material)
+ group.add(core)
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.014, 8, 24), material)
+ ring.rotation.x = Math.PI / 2
+ group.add(ring)
+ const trail = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.34, 10), material)
+ trail.position.z = 0.25
+ trail.rotation.x = Math.PI / 2
+ group.add(trail)
+ return group
+}
+
+function getAbilityEventColor(school: string) {
+ if (school === 'fire') return 0xff7043
+ if (school === 'frost') return 0x8edcff
+ if (school === 'arcane') return 0xb184ff
+ if (school === 'shadow') return 0x8e68c4
+ if (school === 'nature') return 0x7ed957
+ if (school === 'holy') return 0xfff1a8
+ return 0xe5b95f
+}
diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx
index 5931f4d..325eccc 100644
--- a/src/components/AuthScreen.tsx
+++ b/src/components/AuthScreen.tsx
@@ -7,10 +7,11 @@ import {
type AuthScreenProps = {
onAuthenticated: (session: AuthSession) => void
+ onPlayOffline: () => void
serverMessage?: string
}
-export function AuthScreen({ onAuthenticated, serverMessage = '' }: AuthScreenProps) {
+export function AuthScreen({ onAuthenticated, onPlayOffline, serverMessage = '' }: AuthScreenProps) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -117,6 +118,15 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: AuthScreenPr
+
+ Play Offline
+
+
{message || serverMessage || (
mode === 'register'
diff --git a/src/components/BulldromeBossSlice.tsx b/src/components/BulldromeBossSlice.tsx
index 1ec47ee..3a98c6c 100644
--- a/src/components/BulldromeBossSlice.tsx
+++ b/src/components/BulldromeBossSlice.tsx
@@ -1,123 +1,142 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import Phaser from 'phaser'
-import { BulldromeScene } from '../actionBoss/BulldromeScene'
+import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { ThreeActionSceneHandle } from './ThreeActionScene'
import {
- getActionDifficultyTier,
+ 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,
- getEnemyFrames,
getRaidFrames,
- SPELLS,
type BulldromeState,
type ActionDungeonId,
- type EnemyFrame,
- type RaidFrame,
- type SpellDefinition,
type SpellSlot,
-} from '../actionBoss/bulldromeSimulation'
+} 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
}
-function getRunTitle(dungeonId: ActionDungeonId, difficulty: ActionDifficulty, runMode: ActionRunMode) {
- const suffix = runMode === 'marathon' ? 'Marathon' : 'Hunt'
- const tier = getActionDifficultyTier(difficulty).label
- if (dungeonId === 'yian-kut-ku') return `${tier} Yian Kut-Ku ${suffix}`
- return `${tier} Bulldrome ${suffix}`
+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 mountRef = useRef(null)
- const gameRef = useRef(null)
- const sceneRef = useRef(null)
+ const combatProfile = useMemo(() => createActionCombatProfile(character), [character])
+ const threeSceneRef = useRef(null)
const completionSentRef = useRef(false)
const rewardedBossKillsRef = useRef(0)
- const [state, setState] = useState(() => createBulldromeState(difficulty, dungeonId, runMode))
+ 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 enemyFrames = useMemo(() => getEnemyFrames(state), [state])
const encounterHp = useMemo(() => getEncounterHp(state), [state])
const encounterTitle = useMemo(() => getEncounterTitle(state), [state])
-
- const resultLabel = useMemo(() => {
- if (state.result === 'win') return 'Hunt Complete'
- if (state.result === 'loss') return 'Carted'
- if (state.encounterStep === 'trash') return 'Bullfangos'
- return state.boss.phase === 'slamWindup'
- ? 'Slam'
- : state.boss.phase === 'mauling'
- ? 'Tank'
- : state.boss.phase === 'windup'
- ? 'Dodge'
- : state.boss.phase === 'recovering'
- ? 'Punish'
- : 'Fight'
- }, [state.boss.phase, state.encounterStep, state.result])
-
- useEffect(() => {
- if (!mountRef.current || gameRef.current) return
-
- const scene = new BulldromeScene({ difficulty, dungeonId, runMode, onStateChange: setState })
- sceneRef.current = scene
-
- const game = new Phaser.Game({
- type: Phaser.CANVAS,
- parent: mountRef.current,
- width: 960,
- height: 540,
- backgroundColor: '#11151c',
- scale: {
- mode: Phaser.Scale.FIT,
- autoCenter: Phaser.Scale.CENTER_BOTH,
- },
- scene: [scene],
- })
-
- gameRef.current = game
-
- return () => {
- game.destroy(true)
- gameRef.current = null
- sceneRef.current = null
- }
- }, [difficulty, dungeonId, runMode])
+ 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) sceneRef.current?.selectTarget(nextFrame.id)
+ if (nextFrame) {
+ playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 })
+ threeSceneRef.current?.selectTarget(nextFrame.id)
+ }
}
- if (['1', '2', '3', '4', '5'].includes(event.key)) {
+ if (event.key === 'Tab') {
event.preventDefault()
- sceneRef.current?.castSpell(Number(event.key) as SpellSlot)
+ 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)
- }, [raidFrames])
+ }, [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') {
@@ -133,214 +152,191 @@ export function BulldromeBossSlice({
}, [onRunComplete, runMode, state.bossKills, state.result])
return (
-
+
-
-
-
Action Boss Prototype
-
{getRunTitle(dungeonId, difficulty, runMode)}
-
-
Back
-
-
-
-
- {raidFrames.map((frame) => (
- sceneRef.current?.selectTarget(frame.id)}
- />
- ))}
-
+
-
- {encounterTitle}
- {Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}
-
-
-
-
- {state.player.currentCast && (
-
-
- {SPELLS[state.player.currentCast.spell].name}
- {state.player.currentCast.remaining.toFixed(1)}s
-
+
+
+ {
+ playCombatSound(DUNGEON_SFX.buff, { unlocked: audioUnlockedRef, volume: 0.22 })
+ threeSceneRef.current?.selectTarget(id)
+ }}
+ />
+
+
+ {encounterTitle}
+ {Math.ceil(encounterHp.hp)} / {encounterHp.maxHp}
-
+
- )}
-
-
-
-
-
State
-
{resultLabel}
-
{state.message}
-
-
-
-
- {enemyFrames.map((enemy) => (
-
- ))}
-
-
- {(Object.values(SPELLS) as SpellDefinition[]).map((spell) => (
-
sceneRef.current?.castSpell(spell.slot)}
- spell={spell}
+ {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
+ )}
-
-
-
-
Boss
- {state.boss.phase}
-
-
-
Time
- {state.elapsed.toFixed(1)}s
-
-
-
Stun
- {state.player.stunTimer > 0 ? `${state.player.stunTimer.toFixed(1)}s` : 'Clear'}
-
-
-
Target
- {raidFrames.find((frame) => frame.selected)?.name ?? 'None'}
-
-
-
-
- Controls
- WASD: move
- Up / Down: target frame
- 1-5: healing spells
- R: reset
-
-
+
)
}
-function EnemyRow({ enemy }: { enemy: EnemyFrame }) {
- const percent = Math.max(0, Math.min(100, (enemy.hp / enemy.maxHp) * 100))
-
- return (
-
-
- {enemy.name}
- {Math.ceil(enemy.hp)} / {enemy.maxHp}
-
-
-
-
-
- )
+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 PartyFrame({
- frame,
- onSelect,
-}: {
- frame: RaidFrame
- onSelect: () => void
-}) {
- const percent = Math.max(0, Math.min(100, (frame.hp / frame.maxHp) * 100))
- const shieldPercent = Math.max(0, Math.min(100 - percent, (frame.shield / frame.maxHp) * 100))
-
- return (
-
- {frame.role}
- {frame.name}
- {Math.ceil(frame.hp)} / {frame.maxHp}
-
-
- {frame.shield > 0 && (
-
- )}
-
- {frame.shield > 0 && Shield {Math.ceil(frame.shield)} }
- {frame.renewTimer > 0 && Renew {frame.renewTimer.toFixed(0)}s }
-
- )
-}
-
-function SpellButton({
- cooldown,
- onCast,
- spell,
-}: {
- cooldown: number
- onCast: () => void
- spell: SpellDefinition
-}) {
- const cooldownPercent = spell.cooldown > 0
- ? Math.max(0, Math.min(100, (cooldown / spell.cooldown) * 100))
- : 0
-
- return (
- 0 ? 'cooling' : ''}
- onClick={onCast}
- type="button"
- >
- {spell.slot}
- {spell.name}
- {cooldown > 0 && (
- <>
-
- {cooldown.toFixed(1)}s
- >
- )}
-
- )
-}
-
-function Meter({
- label,
- max,
- tone,
- value,
-}: {
+function getDungeonTargetFrame(state: BulldromeState): {
+ detail: string
+ hp: number
+ kind: 'ally' | 'enemy'
+ kindLabel: string
label: string
- max: number
- tone: 'player' | 'boss'
- value: number
-}) {
- const percent = Math.max(0, Math.min(100, (value / max) * 100))
+ 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',
+ }
+ }
- return (
-
-
- {label}
- {Math.ceil(value)} / {max}
-
-
-
-
-
- )
+ 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',
+ }
}
diff --git a/src/components/CombatAudio.ts b/src/components/CombatAudio.ts
new file mode 100644
index 0000000..987371f
--- /dev/null
+++ b/src/components/CombatAudio.ts
@@ -0,0 +1,60 @@
+import { useEffect, useState } from 'react'
+
+const COMBAT_AUDIO_STORAGE_KEY = 'actionModeSoundEnabled'
+const COMBAT_AUDIO_EVENT = 'action-mode-sound-enabled-change'
+
+let combatAudioEnabledFallback = false
+
+export function getCombatAudioEnabled() {
+ if (typeof window === 'undefined') return false
+ try {
+ const value = window.localStorage.getItem(COMBAT_AUDIO_STORAGE_KEY)
+ if (value === null) return combatAudioEnabledFallback
+ return value === 'true'
+ } catch {
+ return combatAudioEnabledFallback
+ }
+}
+
+export function setCombatAudioEnabled(enabled: boolean) {
+ combatAudioEnabledFallback = enabled
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(COMBAT_AUDIO_STORAGE_KEY, enabled ? 'true' : 'false')
+ } catch {
+ // WebViews can deny localStorage; module fallback still controls playback.
+ }
+ window.dispatchEvent(new CustomEvent(COMBAT_AUDIO_EVENT, { detail: enabled }))
+}
+
+export function useCombatAudioEnabled() {
+ const [enabled, setEnabled] = useState(() => getCombatAudioEnabled())
+
+ useEffect(() => {
+ const sync = (event?: Event) => {
+ if (event instanceof CustomEvent && typeof event.detail === 'boolean') {
+ setEnabled(event.detail)
+ return
+ }
+ setEnabled(getCombatAudioEnabled())
+ }
+ window.addEventListener(COMBAT_AUDIO_EVENT, sync)
+ window.addEventListener('storage', sync)
+ return () => {
+ window.removeEventListener(COMBAT_AUDIO_EVENT, sync)
+ window.removeEventListener('storage', sync)
+ }
+ }, [])
+
+ return [enabled, setCombatAudioEnabled] as const
+}
+
+export function playCombatSound(src: string, options: { unlocked?: { current: boolean }, volume?: number } = {}) {
+ if (typeof window === 'undefined' || !getCombatAudioEnabled()) return
+ if (options.unlocked) options.unlocked.current = true
+ const audio = new Audio(src)
+ audio.volume = options.volume ?? 0.32
+ audio.play().catch(() => {
+ // Browser blocked autoplay; direct user input will retry later.
+ })
+}
diff --git a/src/components/CombatHud.tsx b/src/components/CombatHud.tsx
new file mode 100644
index 0000000..91d029b
--- /dev/null
+++ b/src/components/CombatHud.tsx
@@ -0,0 +1,231 @@
+import { useState } from 'react'
+import { SPELL_GLOBAL_COOLDOWN_SECONDS, type HealOverTimeFrame } from '../actionCombatCore'
+
+export type CombatPartyFrameData = {
+ bleedTimer?: number
+ burnTimer?: number
+ damageDone?: number
+ healOverTimes: HealOverTimeFrame[]
+ hp: number
+ id: string
+ maxHp: number
+ name: string
+ renewTimer: number
+ role: string
+ selected: boolean
+ shield: number
+ specLabel?: string
+}
+
+export type CombatActionSlot = {
+ canCast: boolean
+ cooldown: number
+ cooldownBase?: number
+ globalClassName?: string
+ iconUrl: string
+ id: Slot
+ keybind?: string
+ name: string
+ tooltip: {
+ cast: string
+ cooldown?: string
+ cost: string
+ description: string
+ rank: string
+ school: string
+ target: string
+ }
+}
+
+export function CombatPartyFrames({
+ className = '',
+ compact = false,
+ frames,
+ manaByFrameId,
+ onSelect,
+}: {
+ className?: string
+ compact?: boolean
+ frames: CombatPartyFrameData[]
+ manaByFrameId?: Record
+ onSelect?: (id: string) => void
+}) {
+ return (
+
+ {frames.map((frame) => (
+ onSelect(frame.id) : undefined}
+ />
+ ))}
+
+ )
+}
+
+export function CombatPartyFrame({
+ compact = false,
+ frame,
+ mana,
+ onSelect,
+}: {
+ compact?: boolean
+ frame: CombatPartyFrameData
+ mana?: { value: number, max: number }
+ onSelect?: () => void
+}) {
+ const percent = Math.max(0, Math.min(100, (frame.hp / frame.maxHp) * 100))
+ const rawShieldPercent = Math.max(0, Math.min(100, (frame.shield / frame.maxHp) * 100))
+ const shieldLeft = percent >= 99.5 ? 0 : percent
+ const shieldPercent = percent >= 99.5 ? rawShieldPercent : Math.min(100 - percent, rawShieldPercent)
+ const manaPercent = mana ? Math.max(0, Math.min(100, (mana.value / mana.max) * 100)) : 0
+
+ return (
+
+ {frame.specLabel ?? frame.role}
+ {frame.name}
+ {Math.ceil(frame.hp)} / {frame.maxHp}
+ Damage {Math.round(frame.damageDone ?? 0).toLocaleString()}
+
+
+ {frame.shield > 0 && (
+
+ )}
+
+ {mana && (
+
+
+
+ )}
+ {(frame.shield > 0 || frame.healOverTimes.length > 0) && (
+
+ {frame.shield > 0 && Shield {Math.ceil(frame.shield)} }
+ {frame.healOverTimes.map((effect) => (
+
+ {effect.label} {effect.remaining.toFixed(0)}s
+
+ ))}
+
+ )}
+ {(frame.burnTimer ?? 0) > 0 && Burn {(frame.burnTimer ?? 0).toFixed(0)}s }
+ {(frame.bleedTimer ?? 0) > 0 && Bleed {(frame.bleedTimer ?? 0).toFixed(0)}s }
+
+ )
+}
+
+export function CombatTargetFrame({
+ action,
+ className = '',
+ detail,
+ hp,
+ kindLabel,
+ label,
+ maxHp,
+ tone = 'enemy',
+}: {
+ action?: { label: string, onClick: () => void }
+ className?: string
+ detail: string
+ hp: number
+ kindLabel: string
+ label: string
+ maxHp: number
+ tone?: 'ally' | 'enemy'
+}) {
+ return (
+
+
+ {kindLabel}
+ {label}
+ {detail}
+
+
+
+
+ {action &&
{action.label} }
+
+ )
+}
+
+export function CombatActionBar({
+ className = '',
+ columns,
+ globalCooldown,
+ onCast,
+ slots,
+}: {
+ className?: string
+ columns: number
+ globalCooldown: number
+ onCast: (slot: Slot) => void
+ slots: CombatActionSlot[]
+}) {
+ const [tooltipSlot, setTooltipSlot] = useState(null)
+ const tooltip = slots.find((slot) => slot.id === tooltipSlot)
+
+ return (
+
+ {slots.map((slot) => {
+ const visibleCooldown = Math.max(slot.cooldown, globalCooldown)
+ const cooldownBase = slot.cooldown > 0 ? (slot.cooldownBase ?? 1) : SPELL_GLOBAL_COOLDOWN_SECONDS
+ const cooldownPercent = visibleCooldown > 0 && cooldownBase > 0
+ ? Math.max(0, Math.min(100, (visibleCooldown / cooldownBase) * 100))
+ : 0
+ return (
+
0 ? 'cooling' : ''} ${globalCooldown > 0 && slot.cooldown <= 0 ? 'global-cooldown' : ''} ${slot.globalClassName ?? ''}`}
+ disabled={visibleCooldown > 0 || !slot.canCast}
+ key={slot.id}
+ onBlur={() => setTooltipSlot(null)}
+ onClick={() => onCast(slot.id)}
+ onFocus={() => setTooltipSlot(slot.id)}
+ onMouseEnter={() => setTooltipSlot(slot.id)}
+ onMouseLeave={() => setTooltipSlot(null)}
+ type="button"
+ >
+
+ {slot.keybind ?? slot.id}
+ {visibleCooldown > 0 && (
+ <>
+
+ {slot.cooldown > 0 && {slot.cooldown > 1 ? Math.ceil(slot.cooldown) : slot.cooldown.toFixed(1)} }
+ >
+ )}
+
+ )
+ })}
+ {tooltip && (
+
+
+
+
+ {tooltip.name}
+ {tooltip.tooltip.rank}
+
+
+
+
School {tooltip.tooltip.school}
+
Target {tooltip.tooltip.target}
+
Cost {tooltip.tooltip.cost}
+
Cast {tooltip.tooltip.cast}
+ {tooltip.tooltip.cooldown &&
Cooldown {tooltip.tooltip.cooldown} }
+
+
{tooltip.tooltip.description}
+ {tooltip.cooldown > 0 &&
{tooltip.cooldown.toFixed(1)}s remaining }
+
+ )}
+
+ )
+}
diff --git a/src/components/CombatPauseMenu.tsx b/src/components/CombatPauseMenu.tsx
new file mode 100644
index 0000000..1012d29
--- /dev/null
+++ b/src/components/CombatPauseMenu.tsx
@@ -0,0 +1,63 @@
+import { useCombatAudioEnabled } from './CombatAudio'
+
+export function PauseSettingsMenu({
+ exitLabel = 'Exit Round',
+ onOpenCharacter,
+ onOpenInventory,
+ onOpenTalents,
+ onExit,
+ onResume,
+}: {
+ exitLabel?: string
+ onOpenCharacter?: () => void
+ onOpenInventory?: () => void
+ onOpenTalents?: () => void
+ onExit: () => void
+ onResume: () => void
+}) {
+ const [combatAudioEnabled, setCombatAudioEnabled] = useCombatAudioEnabled()
+
+ return (
+
+
+
+
+
+ Camera Drag
+ Enabled
+
+
+ Action Bar
+ 1-0
+
+
+ Party Targeting
+ Arrow Keys
+
+
+ Sound
+ setCombatAudioEnabled(!combatAudioEnabled)}
+ role="switch"
+ type="button"
+ >
+ {combatAudioEnabled ? 'On' : 'Muted'}
+
+
+
+
+ {onOpenCharacter && Character }
+ {onOpenInventory && Inventory }
+ {onOpenTalents && Talents }
+ Resume
+ {exitLabel}
+
+
+
+ )
+}
diff --git a/src/components/ThreeActionScene.tsx b/src/components/ThreeActionScene.tsx
new file mode 100644
index 0000000..bebc909
--- /dev/null
+++ b/src/components/ThreeActionScene.tsx
@@ -0,0 +1,629 @@
+import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
+import * as THREE from 'three'
+import {
+ addBirdMesh,
+ addBoarMesh,
+ cancelCombatRenderLoads,
+ clearCombatRenderCaches,
+ addClaudeCraftBossModel,
+ addCombatUnitMesh,
+ addCyberDragonMesh,
+ animateCombatWeapon,
+ getEnemyModelYawOffset,
+ syncRangedCombatProjectiles,
+ updateCombatUnitModel,
+ updateBossModelAnimation,
+} from '../action3dCombatRender'
+import {
+ ACTION_ARENA_SCALE,
+ ACTION_PLAYER_KEYS,
+ ACTION_RENDER_QUALITY,
+ clearCombatActions,
+ createActionCombatCamera,
+ createActionCombatCameraController,
+ createHealBurstEffect,
+ getArcTelegraph,
+ getCircleTelegraph,
+ getLineTelegraph,
+ getOrCreateStunEffect,
+ pruneObjectMap,
+ readCombatInput,
+ resizeActionCombatCamera,
+ setObjectOpacity,
+ syncArcTelegraphGeometry,
+ setupActionSceneEnvironment,
+ syncLineTelegraphGeometry,
+ toActionWorld,
+ updateActionCombatCamera,
+} from '../action3dSceneKit'
+import {
+ createBulldromeState,
+ getAllEnemies,
+ getTargetableUnits,
+ updateBulldromeState,
+ type ActionDungeonId,
+ type BulldromeState,
+ type EnemyKind,
+ type SpellSlot,
+} from '../actionBoss/actionCombatSimulation'
+import type { ActionDifficulty, ActionRunMode } from '../actionMode'
+import type { ArenaClassId } from '../actionClassKits'
+import type { PlayerClass } from '../claudeCraftTypes'
+import type { TalentModifiers } from '../claudeCraftTalents'
+
+export type ThreeActionSceneHandle = {
+ castSpell: (slot: SpellSlot) => void
+ selectTarget: (targetId: string) => void
+}
+
+type ThreeActionSceneProps = {
+ difficulty: ActionDifficulty
+ dungeonId: ActionDungeonId
+ runMode: ActionRunMode
+ onStateChange: (state: BulldromeState) => void
+ onRenderUnavailable?: () => void
+ paused?: boolean
+ playerClassId?: PlayerClass
+ playerTalentModifiers?: TalentModifiers
+}
+
+type UnitMesh = THREE.Group & {
+ userData: {
+ defeatVisualTimer?: number
+ deathAnimationStarted?: boolean
+ kind?: EnemyKind
+ lastX?: number
+ lastY?: number
+ role?: string
+ }
+}
+
+const ENEMY_DEATH_VISUAL_SECONDS = 2.4
+
+export const ThreeActionScene = forwardRef(function ThreeActionScene({
+ difficulty,
+ dungeonId,
+ onRenderUnavailable,
+ paused = false,
+ playerClassId = 'priest',
+ playerTalentModifiers,
+ runMode,
+ onStateChange,
+}, ref) {
+ const mountRef = useRef(null)
+ const stateRef = useRef(createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers))
+ const queuedTargetRef = useRef(null)
+ const queuedSpellRef = useRef(null)
+ const pausedRef = useRef(paused)
+ const actionQueueRef = useRef({ reset: false })
+ const keysRef = useRef(new Set())
+ const cameraControllerRef = useRef(createActionCombatCameraController())
+
+ useImperativeHandle(ref, () => ({
+ castSpell: (slot) => {
+ queuedSpellRef.current = slot
+ },
+ selectTarget: (targetId) => {
+ queuedTargetRef.current = targetId
+ },
+ }))
+
+ useEffect(() => {
+ pausedRef.current = paused
+ if (paused) {
+ keysRef.current.clear()
+ queuedTargetRef.current = null
+ queuedSpellRef.current = null
+ clearCombatActions(actionQueueRef.current)
+ }
+ }, [paused])
+
+ useEffect(() => {
+ stateRef.current = createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers)
+ onStateChange(stateRef.current)
+ }, [difficulty, dungeonId, onStateChange, playerClassId, playerTalentModifiers, runMode])
+
+ useEffect(() => {
+ const mount = mountRef.current
+ if (!mount) return
+
+ const scene = new THREE.Scene()
+
+ let renderer: THREE.WebGLRenderer
+ try {
+ renderer = new THREE.WebGLRenderer({ antialias: ACTION_RENDER_QUALITY.antialias, powerPreference: 'high-performance' })
+ } catch {
+ onRenderUnavailable?.()
+ return
+ }
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ACTION_RENDER_QUALITY.maxPixelRatio))
+ renderer.shadowMap.enabled = ACTION_RENDER_QUALITY.shadows
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap
+ mount.appendChild(renderer.domElement)
+
+ const camera = createActionCombatCamera({ far: 120 })
+ const initialArena = stateRef.current.arena
+ setupActionSceneEnvironment(scene, {
+ worldHeight: (initialArena.height - initialArena.padding * 2) / ACTION_ARENA_SCALE,
+ worldWidth: (initialArena.width - initialArena.padding * 2) / ACTION_ARENA_SCALE,
+ })
+ void dungeonId
+
+ const units = new Map()
+ const fireballs = new Map()
+ const fireSpots = new Map()
+ const spinningBlades = new Map()
+ const telegraphs = new Map()
+ const arcTelegraphs = new Map()
+ const slamTelegraphs = new Map()
+ const partyEffects = new Map()
+ const healEffects = new Map()
+ const stunEffects = new Map()
+
+ const clock = new THREE.Clock()
+ let frameId = 0
+ let hudTimer = 0
+
+ const resize = () => {
+ const rect = mount.getBoundingClientRect()
+ const width = Math.max(1, rect.width)
+ const height = Math.max(1, rect.height)
+ renderer.setSize(width, height, false)
+ resizeActionCombatCamera(camera, width, height)
+ }
+ const observer = new ResizeObserver(resize)
+ observer.observe(mount)
+ resize()
+
+ const animate = () => {
+ const delta = Math.min(clock.getDelta(), 0.05)
+ if (pausedRef.current) {
+ renderer.render(scene, camera)
+ frameId = requestAnimationFrame(animate)
+ return
+ }
+ const input = readCombatInput(keysRef.current, cameraControllerRef.current.yaw, queuedTargetRef.current, queuedSpellRef.current, actionQueueRef.current, {
+ fallbackAim: { x: 0, y: -1 },
+ })
+ queuedTargetRef.current = null
+ queuedSpellRef.current = null
+ clearCombatActions(actionQueueRef.current)
+
+ stateRef.current = updateBulldromeState(stateRef.current, input, delta)
+ syncWorld(scene, stateRef.current, units, fireballs, fireSpots, spinningBlades, telegraphs, arcTelegraphs, slamTelegraphs, partyEffects, healEffects, stunEffects, delta, playerClassId)
+ updateActionCombatCamera(camera, cameraControllerRef.current.yaw, stateRef.current.player, (x, y) => toActionWorld(x, y, stateRef.current.arena))
+ renderer.render(scene, camera)
+
+ hudTimer -= delta
+ if (hudTimer <= 0 || stateRef.current.result !== 'playing' || stateRef.current.lastHit) {
+ onStateChange(stateRef.current)
+ hudTimer = 0.08
+ }
+
+ frameId = requestAnimationFrame(animate)
+ }
+ animate()
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (pausedRef.current) return
+ const key = event.key.toLowerCase()
+ if (ACTION_PLAYER_KEYS.has(key)) event.preventDefault()
+ keysRef.current.add(key)
+ if (!event.repeat && key === 'r') actionQueueRef.current.reset = true
+ }
+ const handleKeyUp = (event: KeyboardEvent) => {
+ keysRef.current.delete(event.key.toLowerCase())
+ }
+ const unbindCameraDrag = cameraControllerRef.current.bindDrag(renderer.domElement, {
+ isPaused: () => pausedRef.current,
+ })
+
+ window.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('keyup', handleKeyUp)
+
+ return () => {
+ cancelAnimationFrame(frameId)
+ observer.disconnect()
+ window.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('keyup', handleKeyUp)
+ unbindCameraDrag()
+ cancelCombatRenderLoads(scene)
+ scene.traverse((object) => {
+ if (object instanceof THREE.Mesh) {
+ object.geometry.dispose()
+ const material = object.material
+ if (Array.isArray(material)) material.forEach((item) => item.dispose())
+ else material.dispose()
+ }
+ })
+ renderer.dispose()
+ renderer.domElement.remove()
+ clearCombatRenderCaches()
+ }
+ }, [difficulty, dungeonId, onRenderUnavailable, onStateChange, playerClassId, playerTalentModifiers, runMode])
+
+ return (
+
+
+
+
WASD move | drag camera
+
+
+ )
+})
+
+export default ThreeActionScene
+
+function syncWorld(
+ scene: THREE.Scene,
+ state: BulldromeState,
+ units: Map,
+ fireballs: Map,
+ fireSpots: Map,
+ spinningBlades: Map,
+ telegraphs: Map,
+ arcTelegraphs: Map,
+ slamTelegraphs: Map,
+ partyEffects: Map