I Want To Heal 2 web/server v1.0.0
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Phaser from 'phaser'
|
||||
import { BulldromeScene } from '../actionBoss/BulldromeScene'
|
||||
import {
|
||||
getActionDifficultyTier,
|
||||
type ActionDifficulty,
|
||||
type ActionRunMode,
|
||||
} from '../actionMode'
|
||||
import {
|
||||
createBulldromeState,
|
||||
getEncounterHp,
|
||||
getEncounterTitle,
|
||||
getEnemyFrames,
|
||||
getRaidFrames,
|
||||
SPELLS,
|
||||
type BulldromeState,
|
||||
type ActionDungeonId,
|
||||
type EnemyFrame,
|
||||
type RaidFrame,
|
||||
type SpellDefinition,
|
||||
type SpellSlot,
|
||||
} from '../actionBoss/bulldromeSimulation'
|
||||
|
||||
type BulldromeBossSliceProps = {
|
||||
dungeonId?: ActionDungeonId
|
||||
difficulty?: ActionDifficulty
|
||||
runMode?: ActionRunMode
|
||||
onExit: () => 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}`
|
||||
}
|
||||
|
||||
export function BulldromeBossSlice({
|
||||
dungeonId = 'bulldrome',
|
||||
difficulty = 'ilvl-1',
|
||||
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 completionSentRef = useRef(false)
|
||||
const rewardedBossKillsRef = useRef(0)
|
||||
const [state, setState] = useState<BulldromeState>(() => createBulldromeState(difficulty, dungeonId, runMode))
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) 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 (['1', '2', '3', '4', '5'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
sceneRef.current?.castSpell(Number(event.key) as SpellSlot)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [raidFrames])
|
||||
|
||||
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">
|
||||
<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-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>
|
||||
<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>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</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 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,
|
||||
}: {
|
||||
label: string
|
||||
max: number
|
||||
tone: 'player' | 'boss'
|
||||
value: number
|
||||
}) {
|
||||
const percent = Math.max(0, Math.min(100, (value / max) * 100))
|
||||
|
||||
return (
|
||||
<div className={`boss-meter ${tone}`}>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<span>{Math.ceil(value)} / {max}</span>
|
||||
</div>
|
||||
<i>
|
||||
<b style={{ width: `${percent}%` }} />
|
||||
</i>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user