I Want To Heal 2 web/server v1.0.0

This commit is contained in:
Warren H
2026-06-23 21:11:43 -04:00
commit 58e131d0b2
28 changed files with 9840 additions and 0 deletions
+346
View File
@@ -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>
)
}