I Want To Heal 2 build v1.0.5 code
This commit is contained in:
@@ -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<HTMLDivElement | null>(null)
|
||||
const gameRef = useRef<Phaser.Game | null>(null)
|
||||
const sceneRef = useRef<BulldromeScene | null>(null)
|
||||
const combatProfile = useMemo(() => createActionCombatProfile(character), [character])
|
||||
const threeSceneRef = useRef<ThreeActionSceneHandle | null>(null)
|
||||
const completionSentRef = useRef(false)
|
||||
const rewardedBossKillsRef = useRef(0)
|
||||
const [state, setState] = useState<BulldromeState>(() => 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<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 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 (
|
||||
<main className="boss-slice-shell">
|
||||
<main className="boss-slice-shell dungeon-combat-shell">
|
||||
<section className="boss-slice-stage">
|
||||
<div className="boss-slice-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Action Boss Prototype</p>
|
||||
<h1>{getRunTitle(dungeonId, difficulty, runMode)}</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onExit} type="button">Back</button>
|
||||
</div>
|
||||
|
||||
<div className="boss-slice-layout">
|
||||
<aside className="boss-party-frames" aria-label="Party frames">
|
||||
{raidFrames.map((frame) => (
|
||||
<PartyFrame
|
||||
frame={frame}
|
||||
key={frame.id}
|
||||
onSelect={() => sceneRef.current?.selectTarget(frame.id)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
<div className="boss-slice-layout dungeon-combat-layout">
|
||||
<div className="boss-playfield-panel">
|
||||
<div className="boss-window-bossbar">
|
||||
<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>{SPELLS[state.player.currentCast.spell].name}</strong>
|
||||
<span>{state.player.currentCast.remaining.toFixed(1)}s</span>
|
||||
</div>
|
||||
<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, ((state.player.currentCast.total - state.player.currentCast.remaining) / state.player.currentCast.total) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
<b style={{ width: `${Math.max(0, Math.min(100, (encounterHp.hp / encounterHp.maxHp) * 100))}%` }} />
|
||||
</i>
|
||||
</div>
|
||||
)}
|
||||
<div className="boss-canvas-wrap" ref={mountRef} aria-label="Bulldrome boss fight canvas" />
|
||||
</div>
|
||||
<aside className="boss-hud">
|
||||
<div className="boss-hud-status">
|
||||
<p className="eyebrow">State</p>
|
||||
<h2>{resultLabel}</h2>
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
|
||||
<Meter label="Player" value={state.player.hp} max={state.player.maxHp} tone="player" />
|
||||
<div className="boss-enemy-list">
|
||||
{enemyFrames.map((enemy) => (
|
||||
<EnemyRow enemy={enemy} key={enemy.id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="boss-spellbar">
|
||||
{(Object.values(SPELLS) as SpellDefinition[]).map((spell) => (
|
||||
<SpellButton
|
||||
cooldown={state.player.spellCooldowns[spell.slot]}
|
||||
key={spell.slot}
|
||||
onCast={() => sceneRef.current?.castSpell(spell.slot)}
|
||||
spell={spell}
|
||||
{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>
|
||||
|
||||
<dl className="boss-stat-grid">
|
||||
<div>
|
||||
<dt>Boss</dt>
|
||||
<dd>{state.boss.phase}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Time</dt>
|
||||
<dd>{state.elapsed.toFixed(1)}s</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stun</dt>
|
||||
<dd>{state.player.stunTimer > 0 ? `${state.player.stunTimer.toFixed(1)}s` : 'Clear'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>{raidFrames.find((frame) => frame.selected)?.name ?? 'None'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="boss-controls">
|
||||
<strong>Controls</strong>
|
||||
<span>WASD: move</span>
|
||||
<span>Up / Down: target frame</span>
|
||||
<span>1-5: healing spells</span>
|
||||
<span>R: reset</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function EnemyRow({ enemy }: { enemy: EnemyFrame }) {
|
||||
const percent = Math.max(0, Math.min(100, (enemy.hp / enemy.maxHp) * 100))
|
||||
|
||||
return (
|
||||
<div className={`boss-enemy-row ${enemy.kind}`}>
|
||||
<div>
|
||||
<strong>{enemy.name}</strong>
|
||||
<span>{Math.ceil(enemy.hp)} / {enemy.maxHp}</span>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${percent}%` }} />
|
||||
</i>
|
||||
</div>
|
||||
)
|
||||
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 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 (
|
||||
<button
|
||||
className={`party-frame ${frame.selected ? 'selected' : ''} ${frame.hp <= 0 ? 'dead' : ''}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className={`role-chip ${frame.role}`}>{frame.role}</span>
|
||||
<strong>{frame.name}</strong>
|
||||
<small>{Math.ceil(frame.hp)} / {frame.maxHp}</small>
|
||||
<i>
|
||||
<span className="party-health-fill" style={{ width: `${percent}%` }} />
|
||||
{frame.shield > 0 && (
|
||||
<span
|
||||
className="party-shield-fill"
|
||||
style={{
|
||||
left: `${percent}%`,
|
||||
width: `${shieldPercent}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</i>
|
||||
{frame.shield > 0 && <em>Shield {Math.ceil(frame.shield)}</em>}
|
||||
{frame.renewTimer > 0 && <em>Renew {frame.renewTimer.toFixed(0)}s</em>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
className={cooldown > 0 ? 'cooling' : ''}
|
||||
onClick={onCast}
|
||||
type="button"
|
||||
>
|
||||
<strong>{spell.slot}</strong>
|
||||
<span>{spell.name}</span>
|
||||
{cooldown > 0 && (
|
||||
<>
|
||||
<i style={{ height: `${cooldownPercent}%` }} />
|
||||
<em>{cooldown.toFixed(1)}s</em>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`boss-meter ${tone}`}>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<span>{Math.ceil(value)} / {max}</span>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${percent}%` }} />
|
||||
</i>
|
||||
</div>
|
||||
)
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user