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
+530
View File
@@ -0,0 +1,530 @@
import Phaser from 'phaser'
import type { ActionDifficulty } from '../actionMode'
import {
type ActionDungeonId,
type ActionRunMode,
createBulldromeState,
getAllEnemies,
getTargetableUnits,
updateBulldromeState,
type BossInput,
type BulldromeState,
type EnemyState,
type HealTextEvent,
type NoticeTextEvent,
type TargetableState,
} from './bulldromeSimulation'
type SceneCallbacks = {
difficulty: ActionDifficulty
dungeonId: ActionDungeonId
runMode: ActionRunMode
onStateChange: (state: BulldromeState) => void
}
type KeyMap = {
W: Phaser.Input.Keyboard.Key
A: Phaser.Input.Keyboard.Key
S: Phaser.Input.Keyboard.Key
D: Phaser.Input.Keyboard.Key
R: Phaser.Input.Keyboard.Key
ONE: Phaser.Input.Keyboard.Key
TWO: Phaser.Input.Keyboard.Key
THREE: Phaser.Input.Keyboard.Key
FOUR: Phaser.Input.Keyboard.Key
FIVE: Phaser.Input.Keyboard.Key
}
export class BulldromeScene extends Phaser.Scene {
private callbacks: SceneCallbacks
private state: BulldromeState
private keys: KeyMap | null = null
private arenaGraphics?: Phaser.GameObjects.Graphics
private telegraphGraphics?: Phaser.GameObjects.Graphics
private hazardGraphics?: Phaser.GameObjects.Graphics
private bossGraphics?: Phaser.GameObjects.Graphics
private partyGraphics?: Phaser.GameObjects.Graphics
private playerGraphics?: Phaser.GameObjects.Graphics
private fxGraphics?: Phaser.GameObjects.Graphics
private statusText?: Phaser.GameObjects.Text
private lastResetDown = false
private lastOneDown = false
private lastTwoDown = false
private lastThreeDown = false
private lastFourDown = false
private lastFiveDown = false
private queuedTargetId: string | null = null
private queuedSpell: 1 | 2 | 3 | 4 | 5 | null = null
private hudPublishTimer = 0
private seenHealEventIds = new Set<string>()
private seenNoticeEventIds = new Set<string>()
constructor(callbacks: SceneCallbacks) {
super('BulldromeScene')
this.callbacks = callbacks
this.state = createBulldromeState(callbacks.difficulty, callbacks.dungeonId, callbacks.runMode)
}
create() {
this.keys = this.input.keyboard?.addKeys('W,A,S,D,R,ONE,TWO,THREE,FOUR,FIVE') as KeyMap
this.arenaGraphics = this.add.graphics()
this.telegraphGraphics = this.add.graphics()
this.hazardGraphics = this.add.graphics()
this.bossGraphics = this.add.graphics()
this.partyGraphics = this.add.graphics()
this.playerGraphics = this.add.graphics()
this.fxGraphics = this.add.graphics()
this.statusText = this.add.text(238, 58, '', {
color: '#f4eed8',
fontFamily: 'monospace',
fontSize: '16px',
})
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
this.selectTargetAt(pointer.worldX, pointer.worldY)
})
this.cameras.main.setRoundPixels(true)
this.callbacks.onStateChange(this.state)
}
selectTarget(targetId: string) {
this.queuedTargetId = targetId
}
castSpell(slot: 1 | 2 | 3 | 4 | 5) {
this.queuedSpell = slot
}
override update(_time: number, delta: number) {
const input = this.readInput()
this.state = updateBulldromeState(this.state, input, Math.min(delta / 1000, 0.05))
if (input.reset) {
this.seenHealEventIds.clear()
this.seenNoticeEventIds.clear()
}
this.spawnHealTexts()
this.spawnNoticeTexts()
this.renderState()
this.hudPublishTimer -= delta
if (this.hudPublishTimer <= 0 || this.state.result !== 'playing' || this.state.lastHit || this.state.noticeEvents.length > 0) {
this.callbacks.onStateChange(this.state)
this.hudPublishTimer = 80
}
}
private readInput(): BossInput {
const keys = this.keys
if (!keys) {
return {
xAxis: 0,
yAxis: 0,
reset: false,
targetDelta: 0,
targetId: this.consumeQueuedTarget(),
castSpell: this.consumeQueuedSpell(),
}
}
const resetDown = keys.R.isDown
const oneDown = keys.ONE.isDown
const twoDown = keys.TWO.isDown
const threeDown = keys.THREE.isDown
const fourDown = keys.FOUR.isDown
const fiveDown = keys.FIVE.isDown
const pressedSpell = this.consumeQueuedSpell()
?? (oneDown && !this.lastOneDown ? 1
: twoDown && !this.lastTwoDown ? 2
: threeDown && !this.lastThreeDown ? 3
: fourDown && !this.lastFourDown ? 4
: fiveDown && !this.lastFiveDown ? 5
: null)
const input = {
xAxis: Number(keys.D.isDown) - Number(keys.A.isDown),
yAxis: Number(keys.S.isDown) - Number(keys.W.isDown),
reset: resetDown && !this.lastResetDown,
targetDelta: 0 as const,
targetId: this.consumeQueuedTarget(),
castSpell: pressedSpell,
}
this.lastResetDown = resetDown
this.lastOneDown = oneDown
this.lastTwoDown = twoDown
this.lastThreeDown = threeDown
this.lastFourDown = fourDown
this.lastFiveDown = fiveDown
return input
}
private renderState() {
this.drawArena()
this.drawHazards()
this.drawTelegraph()
this.drawBoss()
this.drawParty()
this.drawPlayer()
this.drawFx()
this.statusText?.setText(this.state.message)
}
private spawnHealTexts() {
for (const event of this.state.healEvents) {
if (this.seenHealEventIds.has(event.id)) continue
this.seenHealEventIds.add(event.id)
this.spawnHealText(event)
}
}
private spawnHealText(event: HealTextEvent) {
const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId)
if (!target) return
const text = this.add.text(target.x, target.y - target.radius - 18, `+${Math.ceil(event.amount)}`, {
color: '#7dff9d',
fontFamily: 'monospace',
fontSize: '18px',
fontStyle: 'bold',
stroke: '#07110b',
strokeThickness: 4,
})
text.setOrigin(0.5)
this.tweens.add({
targets: text,
y: text.y - 30,
alpha: 0,
duration: 850,
ease: 'Cubic.easeOut',
onComplete: () => text.destroy(),
})
}
private spawnNoticeTexts() {
for (const event of this.state.noticeEvents) {
if (this.seenNoticeEventIds.has(event.id)) continue
this.seenNoticeEventIds.add(event.id)
this.spawnNoticeText(event)
}
}
private spawnNoticeText(event: NoticeTextEvent) {
const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId) ?? this.state.player
const text = this.add.text(target.x, target.y - target.radius - 24, event.text, {
color: '#ffd36d',
fontFamily: 'monospace',
fontSize: '16px',
fontStyle: 'bold',
stroke: '#120b03',
strokeThickness: 4,
})
text.setOrigin(0.5)
this.tweens.add({
targets: text,
y: text.y - 24,
alpha: 0,
duration: 950,
ease: 'Cubic.easeOut',
onComplete: () => text.destroy(),
})
}
private consumeQueuedTarget() {
const targetId = this.queuedTargetId
this.queuedTargetId = null
return targetId
}
private consumeQueuedSpell() {
const spell = this.queuedSpell
this.queuedSpell = null
return spell
}
private selectTargetAt(x: number, y: number) {
const units = [this.state.player, ...this.state.party].filter((unit) => unit.hp > 0)
const target = units
.map((unit) => ({ unit, distance: Phaser.Math.Distance.Between(x, y, unit.x, unit.y) }))
.filter(({ unit, distance }) => distance <= unit.radius + 14)
.sort((a, b) => a.distance - b.distance)[0]?.unit
if (target) this.selectTarget(target.id)
}
private drawArena() {
const graphics = this.arenaGraphics
if (!graphics) return
const { width, height, padding } = this.state.arena
graphics.clear()
graphics.fillStyle(0x11151c, 1)
graphics.fillRect(0, 0, width, height)
graphics.fillStyle(0x18202a, 1)
graphics.fillRect(padding, padding, width - padding * 2, height - padding * 2)
graphics.lineStyle(3, 0x565066, 1)
graphics.strokeRect(padding, padding, width - padding * 2, height - padding * 2)
graphics.lineStyle(1, 0x252b35, 0.65)
for (let x = padding + 48; x < width - padding; x += 48) {
graphics.lineBetween(x, padding, x, height - padding)
}
for (let y = padding + 48; y < height - padding; y += 48) {
graphics.lineBetween(padding, y, width - padding, y)
}
}
private drawTelegraph() {
const graphics = this.telegraphGraphics
if (!graphics) return
graphics.clear()
for (const enemy of getAllEnemies(this.state)) {
if (enemy.hp <= 0) continue
const telegraph = enemy.telegraph
const slam = enemy.slamTelegraph
if (slam.active) {
graphics.fillStyle(0xdc5162, 0.18)
graphics.fillCircle(enemy.x, enemy.y, slam.radius)
graphics.lineStyle(4, 0xff8b8b, 0.9)
graphics.strokeCircle(enemy.x, enemy.y, slam.radius)
}
if (!telegraph.active) continue
graphics.lineStyle(telegraph.width, 0xdc5162, 0.22)
graphics.lineBetween(telegraph.start.x, telegraph.start.y, telegraph.end.x, telegraph.end.y)
graphics.lineStyle(3, 0xff8b8b, 0.82)
graphics.lineBetween(telegraph.start.x, telegraph.start.y, telegraph.end.x, telegraph.end.y)
}
}
private drawHazards() {
const graphics = this.hazardGraphics
if (!graphics) return
graphics.clear()
for (const spot of this.state.fireSpots) {
const alpha = Math.max(0.18, Math.min(0.48, spot.remaining / 20))
graphics.fillStyle(0xff7a1a, alpha)
graphics.fillCircle(spot.x, spot.y, spot.radius)
graphics.lineStyle(2, 0xffd36d, 0.72)
graphics.strokeCircle(spot.x, spot.y, spot.radius)
}
for (const fireball of this.state.fireballs) {
graphics.fillStyle(0xffd36d, 1)
graphics.fillCircle(fireball.x, fireball.y, fireball.radius + 4)
graphics.fillStyle(0xdc5162, 1)
graphics.fillCircle(fireball.x, fireball.y, fireball.radius)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeCircle(fireball.x, fireball.y, fireball.radius + 4)
}
}
private drawBoss() {
const graphics = this.bossGraphics
if (!graphics) return
graphics.clear()
for (const enemy of getAllEnemies(this.state)) {
if (enemy.hp <= 0) continue
this.drawEnemy(graphics, enemy)
}
}
private drawEnemy(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
if (enemy.kind === 'yian-kut-ku') {
this.drawYianKutKu(graphics, enemy)
return
}
if (enemy.kind === 'bird') {
this.drawBird(graphics, enemy)
return
}
const isCharging = enemy.phase === 'charging'
const isRecovering = enemy.phase === 'recovering'
const isSlam = enemy.phase === 'slamWindup'
const isBullfango = enemy.kind === 'bullfango'
const bodyWidth = isBullfango ? enemy.radius * 2.25 : enemy.radius * 2.15
const bodyHeight = isBullfango ? enemy.radius * 1.55 : enemy.radius * 1.65
const hornScale = isBullfango ? 0.68 : 1
graphics.fillStyle(isCharging ? 0xdc5162 : isSlam ? 0xe5b95f : isRecovering ? 0x8e68c4 : 0x7d4b38, 1)
graphics.fillEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight)
graphics.fillStyle(0x3b211b, 1)
graphics.fillTriangle(
enemy.x - 20 * hornScale,
enemy.y - 18 * hornScale,
enemy.x - 44 * hornScale,
enemy.y - 34 * hornScale,
enemy.x - 28 * hornScale,
enemy.y - 5 * hornScale,
)
graphics.fillTriangle(
enemy.x + 20 * hornScale,
enemy.y - 18 * hornScale,
enemy.x + 44 * hornScale,
enemy.y - 34 * hornScale,
enemy.x + 28 * hornScale,
enemy.y - 5 * hornScale,
)
graphics.fillStyle(0xf4eed8, 1)
graphics.fillCircle(enemy.x - 12 * hornScale, enemy.y - 9 * hornScale, isBullfango ? 3 : 4)
graphics.fillCircle(enemy.x + 12 * hornScale, enemy.y - 9 * hornScale, isBullfango ? 3 : 4)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight)
}
private drawYianKutKu(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
const isCasting = enemy.phase === 'windup'
graphics.fillStyle(isCasting ? 0xff9d3d : 0xd8772f, 1)
graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 2.05, enemy.radius * 1.85)
graphics.fillStyle(0xffd36d, 1)
graphics.fillTriangle(enemy.x, enemy.y - 8, enemy.x + 45, enemy.y - 2, enemy.x, enemy.y + 10)
graphics.fillStyle(0xb83b2f, 1)
graphics.fillTriangle(enemy.x - 18, enemy.y - 8, enemy.x - 42, enemy.y - 25, enemy.x - 30, enemy.y + 7)
graphics.fillTriangle(enemy.x + 10, enemy.y - 22, enemy.x + 22, enemy.y - 44, enemy.x + 31, enemy.y - 12)
graphics.fillStyle(0xf4eed8, 1)
graphics.fillCircle(enemy.x + 14, enemy.y - 9, 4)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 2.05, enemy.radius * 1.85)
}
private drawBird(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) {
const isFlying = enemy.phase === 'windup' || enemy.phase === 'charging' || enemy.phase === 'recovering'
graphics.fillStyle(isFlying ? 0xf4eed8 : 0xb8e7ff, 1)
graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25)
graphics.fillStyle(0x5d91ff, 1)
graphics.fillTriangle(enemy.x - 8, enemy.y, enemy.x - 34, enemy.y - 12, enemy.x - 22, enemy.y + 10)
graphics.fillTriangle(enemy.x + 8, enemy.y, enemy.x + 34, enemy.y - 12, enemy.x + 22, enemy.y + 10)
graphics.fillStyle(0xffd36d, 1)
graphics.fillTriangle(enemy.x + 13, enemy.y - 2, enemy.x + 28, enemy.y + 2, enemy.x + 13, enemy.y + 7)
graphics.fillStyle(0x090a0d, 1)
graphics.fillCircle(enemy.x + 7, enemy.y - 5, 3)
graphics.lineStyle(2, 0x090a0d, 1)
graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25)
}
private drawPlayer() {
const graphics = this.playerGraphics
if (!graphics) return
const player = this.state.player
graphics.clear()
this.drawTargetRing(graphics, player)
this.drawUnitIcon(graphics, player, player.stunTimer > 0 ? 0xe5b95f : 0x30ff7a)
if (player.shield > 0) {
graphics.lineStyle(2, 0xb8e7ff, 0.85)
graphics.strokeCircle(player.x, player.y, player.radius + 9)
}
if (player.stunTimer > 0) {
graphics.fillStyle(0xe5b95f, 1)
graphics.fillCircle(player.x - 10, player.y - 24, 3)
graphics.fillCircle(player.x + 2, player.y - 29, 3)
graphics.fillCircle(player.x + 13, player.y - 23, 3)
}
}
private drawParty() {
const graphics = this.partyGraphics
if (!graphics) return
graphics.clear()
for (const member of this.state.party) {
this.drawTargetRing(graphics, member)
this.drawUnitIcon(graphics, member, this.getUnitColor(member), member.hp > 0 ? 1 : 0.38)
if (member.renewTimer > 0) {
graphics.lineStyle(2, 0x3f9a66, 0.9)
graphics.strokeCircle(member.x, member.y, member.radius + 5)
}
if (member.shield > 0) {
graphics.lineStyle(2, 0xb8e7ff, 0.85)
graphics.strokeCircle(member.x, member.y, member.radius + 9)
}
if (member.stunTimer > 0) {
graphics.fillStyle(0xe5b95f, 1)
graphics.fillCircle(member.x - 8, member.y - 22, 3)
graphics.fillCircle(member.x + 6, member.y - 24, 3)
}
}
}
private drawTargetRing(graphics: Phaser.GameObjects.Graphics, unit: TargetableState) {
if (this.state.targetId !== unit.id) return
graphics.lineStyle(3, 0xe5b95f, 1)
graphics.strokeCircle(unit.x, unit.y, unit.radius + 9)
}
private drawUnitIcon(
graphics: Phaser.GameObjects.Graphics,
unit: TargetableState,
color: number,
alpha = 1,
) {
graphics.fillStyle(color, alpha)
graphics.fillCircle(unit.x, unit.y, unit.radius)
graphics.lineStyle(2, unit.invulnerableTimer > 0 ? 0xffffff : 0x090a0d, alpha)
graphics.strokeCircle(unit.x, unit.y, unit.radius)
if (unit.role === 'healer') {
graphics.lineStyle(3, 0x082815, alpha)
graphics.lineBetween(unit.x - 7, unit.y, unit.x + 7, unit.y)
graphics.lineBetween(unit.x, unit.y - 7, unit.x, unit.y + 7)
return
}
if (unit.role === 'tank') {
graphics.fillStyle(0x3b2d12, alpha)
graphics.fillTriangle(unit.x, unit.y - 10, unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4)
graphics.fillTriangle(unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4, unit.x, unit.y + 10)
graphics.lineStyle(2, 0xfff0a8, alpha)
graphics.strokeTriangle(unit.x, unit.y - 10, unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4)
graphics.lineBetween(unit.x - 9, unit.y - 4, unit.x, unit.y + 10)
graphics.lineBetween(unit.x + 9, unit.y - 4, unit.x, unit.y + 10)
return
}
if (unit.role === 'melee') {
graphics.lineStyle(3, 0x16090b, alpha)
graphics.lineBetween(unit.x - 7, unit.y + 7, unit.x + 7, unit.y - 7)
graphics.lineBetween(unit.x - 4, unit.y - 7, unit.x + 7, unit.y + 4)
return
}
graphics.lineStyle(3, 0x10091c, alpha)
graphics.strokeCircle(unit.x, unit.y, 7)
graphics.lineBetween(unit.x - 9, unit.y, unit.x + 9, unit.y)
}
private getUnitColor(unit: TargetableState) {
if (unit.role === 'tank') return 0xe5b95f
if (unit.id === 'melee-1') return 0xdc5162
if (unit.id === 'melee-2') return 0xf08a4b
if (unit.id === 'ranged-1') return 0x8e68c4
if (unit.id === 'ranged-2') return 0x5d91ff
return 0x30ff7a
}
private drawFx() {
const graphics = this.fxGraphics
if (!graphics) return
graphics.clear()
if (this.state.lastHit === 'boss') {
graphics.lineStyle(4, 0xb8e7ff, 0.85)
for (const enemy of getAllEnemies(this.state)) {
if (enemy.hp > 0) graphics.strokeCircle(enemy.x, enemy.y, enemy.radius + 12)
}
}
if (this.state.lastHit === 'player') {
graphics.lineStyle(5, 0xdc5162, 0.8)
graphics.strokeCircle(this.state.player.x, this.state.player.y, this.state.player.radius + 16)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
import type { EnemyKind } from './bulldromeSimulation'
export type ActionAttackKind =
| 'tankMelee'
| 'charge'
| 'groundSlam'
| 'fireballVolley'
| 'birdDive'
| 'bodyContact'
export type ActionAttackConfig = {
id: string
label: string
kind: ActionAttackKind
enabled: boolean
frequencySeconds: number
damage: number
windupSeconds?: number
recoverSeconds?: number
speed?: number
radius?: number
everyNthCharge?: number
}
export type ActionEnemyMechanicConfig = {
kind: EnemyKind
label: string
role: 'boss' | 'mob'
attacks: ActionAttackConfig[]
}
export type ActionMechanicConfig = Record<EnemyKind, ActionEnemyMechanicConfig>
export const ACTION_MECHANIC_CONFIG_KEY = 'i-want-to-heal:action-mode-mechanics:v1'
export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = {
bulldrome: {
kind: 'bulldrome',
label: 'Bulldrome',
role: 'boss',
attacks: [
createAttack('bulldrome-tank-melee', 'Tank Melee', 'tankMelee', 0.9, 13),
createAttack('bulldrome-charge', 'Charge', 'charge', 2.8, 24, {
windupSeconds: 0.82,
recoverSeconds: 0.86,
speed: 650,
}),
createAttack('bulldrome-ground-slam', 'Ground Slam', 'groundSlam', 3, 28, {
windupSeconds: 1.25,
radius: 142,
everyNthCharge: 3,
}),
createAttack('bulldrome-body-contact', 'Body Contact', 'bodyContact', 0, 10),
],
},
bullfango: {
kind: 'bullfango',
label: 'Bullfango',
role: 'mob',
attacks: [
createAttack('bullfango-tank-melee', 'Tank Melee', 'tankMelee', 1.35, 6),
createAttack('bullfango-charge', 'Charge', 'charge', 1.6, 13, {
windupSeconds: 0.68,
recoverSeconds: 1.05,
speed: 510,
}),
createAttack('bullfango-body-contact', 'Body Contact', 'bodyContact', 0, 4),
],
},
'yian-kut-ku': {
kind: 'yian-kut-ku',
label: 'Yian Kut-Ku',
role: 'boss',
attacks: [
createAttack('yian-tank-melee', 'Tank Peck', 'tankMelee', 1, 11),
createAttack('yian-fireballs', 'Fireball Volley', 'fireballVolley', 2.4, 16, {
windupSeconds: 1,
recoverSeconds: 1.1,
speed: 275,
}),
createAttack('yian-body-contact', 'Body Contact', 'bodyContact', 0, 8),
],
},
bird: {
kind: 'bird',
label: 'Bird',
role: 'mob',
attacks: [
createAttack('bird-tank-melee', 'Tank Claw', 'tankMelee', 1.15, 5),
createAttack('bird-dive', 'Dive Flight', 'birdDive', 3.2, 12, {
speed: 340,
}),
createAttack('bird-body-contact', 'Body Contact', 'bodyContact', 0, 4),
],
},
}
export function loadActionMechanicConfig(): ActionMechanicConfig {
if (typeof window === 'undefined') return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
const saved = window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY)
if (!saved) return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
try {
return mergeMechanicConfig(JSON.parse(saved) as Partial<ActionMechanicConfig>)
} catch {
return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
}
}
export function saveActionMechanicConfig(config: ActionMechanicConfig) {
if (typeof window === 'undefined') return
window.localStorage.setItem(ACTION_MECHANIC_CONFIG_KEY, JSON.stringify(config))
}
export function resetActionMechanicConfig() {
const config = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
saveActionMechanicConfig(config)
return config
}
export function getActionEnemyMechanic(kind: EnemyKind) {
return loadActionMechanicConfig()[kind]
}
export function getActionAttack(kind: EnemyKind, attackKind: ActionAttackKind) {
return getActionEnemyMechanic(kind).attacks.find((attack) => attack.kind === attackKind)
}
export function getEnabledActionAttack(kind: EnemyKind, attackKind: ActionAttackKind) {
const attack = getActionAttack(kind, attackKind)
return attack?.enabled ? attack : null
}
function createAttack(
id: string,
label: string,
kind: ActionAttackKind,
frequencySeconds: number,
damage: number,
options: Partial<Omit<ActionAttackConfig, 'id' | 'label' | 'kind' | 'enabled' | 'frequencySeconds' | 'damage'>> = {},
): ActionAttackConfig {
return {
id,
label,
kind,
enabled: true,
frequencySeconds,
damage,
...options,
}
}
function mergeMechanicConfig(saved: Partial<ActionMechanicConfig>) {
const merged = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG)
for (const kind of Object.keys(merged) as EnemyKind[]) {
const savedEnemy = saved[kind]
if (!savedEnemy) continue
const savedAttacks = Array.isArray(savedEnemy.attacks) ? savedEnemy.attacks : []
merged[kind] = {
...merged[kind],
...savedEnemy,
kind,
attacks: merged[kind].attacks.map((attack) => ({
...attack,
...savedAttacks.find((candidate) => candidate.id === attack.id),
})),
}
}
return merged
}
function cloneConfig(config: ActionMechanicConfig): ActionMechanicConfig {
return structuredClone(config)
}
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
export type ActionDifficulty = 'ilvl-1' | 'ilvl-10' | 'ilvl-20' | 'ilvl-30'
export type ActionDungeonId = 'bulldrome' | 'yian-kut-ku' | 'rathian'
export type ActionRunMode = 'hunt' | 'marathon'
export type ActionGearSource = 'bulldrome' | 'yian-kut-ku'
export type ActionCoinColor = 'white' | 'green' | 'blue' | 'purple'
export type ActionCoinWallet = Record<ActionGearSource, Record<ActionCoinColor, number>>
export type ActionDifficultyTier = {
id: ActionDifficulty
label: string
itemLevel: 1 | 10 | 20 | 30
coinColor: ActionCoinColor
coinLabel: string
healthMultiplier: number
damageMultiplier: number
lootMultiplier: number
experience: number
}
export type ActionGearSlot =
| 'weapon'
| 'helmet'
| 'chest'
| 'gloves'
| 'boots'
| 'pants'
| 'ring'
| 'necklace'
| 'trinket'
export type ActionGearPiece = {
id: string
slug: string
name: string
source: ActionGearSource
slot: ActionGearSlot
itemLevel: number
}
export type ActionGearStats = {
healingPower: number
stamina: number
}
export type ActionRunReward = {
coins: number
coinName: string
experience: number
gear: ActionGearPiece[]
leveledUp: boolean
}
export type ActionCharacter = {
id: string
name: string
level: number
experience: number
actionCoins: ActionCoinWallet
bulldromeCoins: number
yianKutKuCoins: number
bulldromeNormalClears: number
bulldromeHardClears: number
yianKutKuNormalClears: number
yianKutKuHardClears: number
inventory: ActionGearPiece[]
}
export const ACTION_GEAR_SLOTS: Array<{
slot: ActionGearSlot
label: string
glyph: string
}> = [
{ slot: 'weapon', label: 'Weapon', glyph: '/' },
{ slot: 'helmet', label: 'Helmet', glyph: 'H' },
{ slot: 'chest', label: 'Chest', glyph: 'C' },
{ slot: 'gloves', label: 'Gloves', glyph: 'G' },
{ slot: 'boots', label: 'Boots', glyph: 'B' },
{ slot: 'pants', label: 'Pants', glyph: 'P' },
{ slot: 'ring', label: 'Ring', glyph: 'O' },
{ slot: 'necklace', label: 'Necklace', glyph: 'N' },
{ slot: 'trinket', label: 'Trinket', glyph: 'T' },
]
export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [
{
id: 'ilvl-1',
label: 'iLvl 1',
itemLevel: 1,
coinColor: 'white',
coinLabel: 'White Coins',
healthMultiplier: 1,
damageMultiplier: 1,
lootMultiplier: 1,
experience: 60,
},
{
id: 'ilvl-10',
label: 'iLvl 10',
itemLevel: 10,
coinColor: 'green',
coinLabel: 'Green Coins',
healthMultiplier: 1.55,
damageMultiplier: 1.28,
lootMultiplier: 2,
experience: 110,
},
{
id: 'ilvl-20',
label: 'iLvl 20',
itemLevel: 20,
coinColor: 'blue',
coinLabel: 'Blue Coins',
healthMultiplier: 2.25,
damageMultiplier: 1.62,
lootMultiplier: 3,
experience: 180,
},
{
id: 'ilvl-30',
label: 'iLvl 30',
itemLevel: 30,
coinColor: 'purple',
coinLabel: 'Purple Coins',
healthMultiplier: 3.1,
damageMultiplier: 2.05,
lootMultiplier: 4,
experience: 280,
},
]
const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2'
const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1'
const MAX_ACTION_LEVEL = 25
export const BULLDROME_UPGRADE_COST = 5
export const ACTION_GEAR_UPGRADE_COST = 5
const DEFAULT_ACTION_CHARACTER: ActionCharacter = {
id: 'action-local-1',
name: 'Action Healer',
level: 1,
experience: 0,
actionCoins: createEmptyCoinWallet(),
bulldromeCoins: 0,
yianKutKuCoins: 0,
bulldromeNormalClears: 0,
bulldromeHardClears: 0,
yianKutKuNormalClears: 0,
yianKutKuHardClears: 0,
inventory: [],
}
export function loadActionCharacter(): ActionCharacter {
const saved = window.localStorage.getItem(ACTION_SAVE_KEY)
?? window.localStorage.getItem(LEGACY_ACTION_SAVE_KEY)
if (!saved) return DEFAULT_ACTION_CHARACTER
try {
const parsed = JSON.parse(saved) as Partial<ActionCharacter>
const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience)
return {
...DEFAULT_ACTION_CHARACTER,
...parsed,
id: DEFAULT_ACTION_CHARACTER.id,
experience,
level: getActionLevel(experience),
actionCoins: normalizeCoinWallet(parsed),
bulldromeCoins: Number(parsed.bulldromeCoins ?? DEFAULT_ACTION_CHARACTER.bulldromeCoins),
yianKutKuCoins: Number(parsed.yianKutKuCoins ?? DEFAULT_ACTION_CHARACTER.yianKutKuCoins),
bulldromeNormalClears: Number(parsed.bulldromeNormalClears ?? DEFAULT_ACTION_CHARACTER.bulldromeNormalClears),
bulldromeHardClears: Number(parsed.bulldromeHardClears ?? DEFAULT_ACTION_CHARACTER.bulldromeHardClears),
yianKutKuNormalClears: Number(parsed.yianKutKuNormalClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuNormalClears),
yianKutKuHardClears: Number(parsed.yianKutKuHardClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuHardClears),
inventory: Array.isArray(parsed.inventory) ? parsed.inventory.map(normalizeGearPiece) : [],
}
} catch {
return DEFAULT_ACTION_CHARACTER
}
}
export function saveActionCharacter(character: ActionCharacter) {
window.localStorage.setItem(ACTION_SAVE_KEY, JSON.stringify(character))
}
export function completeBulldromeHunt(
character: ActionCharacter,
difficulty: ActionDifficulty,
): { character: ActionCharacter, reward: ActionRunReward } {
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
const lootMultiplier = tier.lootMultiplier
const coinReward = coinRoll * lootMultiplier
const experienceReward = tier.experience
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
const gear = rollBulldromeGear(lootMultiplier, tier.itemLevel)
const actionCoins = addActionCoins(character.actionCoins, 'bulldrome', tier.coinColor, coinReward)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
actionCoins,
bulldromeCoins: actionCoins.bulldrome.white,
bulldromeNormalClears: character.bulldromeNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
bulldromeHardClears: character.bulldromeHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
inventory: [...character.inventory, ...gear],
},
reward: {
coins: coinReward,
experience: experienceReward,
gear,
coinName: `${tier.coinLabel.replace(' Coins', '')} Bulldrome Coins`,
leveledUp: nextLevel > character.level,
},
}
}
export function completeActionDungeonHunt(
character: ActionCharacter,
dungeonId: ActionDungeonId,
difficulty: ActionDifficulty,
): { character: ActionCharacter, reward: ActionRunReward } {
if (dungeonId === 'bulldrome') return completeBulldromeHunt(character, difficulty)
if (dungeonId === 'yian-kut-ku') {
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
const lootMultiplier = tier.lootMultiplier
const coinReward = coinRoll * lootMultiplier
const experienceReward = Math.ceil(tier.experience * 1.25)
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
const gear = rollDungeonGear('yian-kut-ku', lootMultiplier, tier.itemLevel)
const actionCoins = addActionCoins(character.actionCoins, 'yian-kut-ku', tier.coinColor, coinReward)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
actionCoins,
yianKutKuCoins: actionCoins['yian-kut-ku'].white,
yianKutKuNormalClears: character.yianKutKuNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
yianKutKuHardClears: character.yianKutKuHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
inventory: [...character.inventory, ...gear],
},
reward: {
coins: coinReward,
coinName: `${tier.coinLabel.replace(' Coins', '')} Yian Kut-Ku Coins`,
experience: experienceReward,
gear,
leveledUp: nextLevel > character.level,
},
}
}
const experienceReward = getActionDifficultyTier(difficulty).experience
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
},
reward: {
coins: 0,
coinName: 'Coins',
experience: experienceReward,
gear: [],
leveledUp: nextLevel > character.level,
},
}
}
export function upgradeBulldromeGear(character: ActionCharacter, itemId: string): ActionCharacter {
const item = character.inventory.find((candidate) => candidate.id === itemId)
if (!item || item.itemLevel >= getActionGearUpgradeCap(item) || getUpgradeCoinCount(character, item) < ACTION_GEAR_UPGRADE_COST) return character
return {
...character,
...spendUpgradeCoins(character, item),
inventory: character.inventory.map((candidate) => (
candidate.id === itemId
? { ...candidate, itemLevel: candidate.itemLevel + 1 }
: candidate
)),
}
}
export function getUpgradeCoinCount(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
const fullItem = item as Partial<Pick<ActionGearPiece, 'itemLevel'>>
const coinColor = getCoinColorForItemLevel(fullItem.itemLevel ?? 1)
return getActionCoinCount(character, item.source, coinColor)
}
export function getUpgradeCoinName(item: Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>) {
const tier = getTierForItemLevel(item.itemLevel ?? 1)
const sourceName = item.source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
return `${tier.coinLabel.replace(' Coins', '')} ${sourceName} Coins`
}
export function getActionGearStats(item: Pick<ActionGearPiece, 'slot' | 'itemLevel'>): ActionGearStats {
const slotWeight = item.slot === 'weapon'
? 2
: item.slot === 'chest' || item.slot === 'helmet' || item.slot === 'pants'
? 1.5
: 1
const healingPower = Math.ceil(item.itemLevel * slotWeight)
const stamina = item.slot === 'ring' || item.slot === 'necklace' || item.slot === 'trinket'
? item.itemLevel * 2
: item.itemLevel
return { healingPower, stamina }
}
export function getActionLevel(experience: number) {
return Math.min(MAX_ACTION_LEVEL, 1 + Math.floor(Math.max(0, experience) / 220))
}
export function getActionLevelProgress(character: ActionCharacter) {
const currentLevelStart = (character.level - 1) * 220
const nextLevelStart = character.level * 220
const earnedThisLevel = Math.max(0, character.experience - currentLevelStart)
const neededThisLevel = Math.max(1, nextLevelStart - currentLevelStart)
return {
currentLevelStart,
nextLevelStart,
percent: character.level >= MAX_ACTION_LEVEL
? 100
: Math.max(0, Math.min(100, (earnedThisLevel / neededThisLevel) * 100)),
}
}
export function getActionDifficultyTier(difficulty: ActionDifficulty) {
return ACTION_DIFFICULTY_TIERS.find((tier) => tier.id === difficulty) ?? ACTION_DIFFICULTY_TIERS[0]
}
export function getActionCoinCount(
character: Pick<ActionCharacter, 'actionCoins' | 'bulldromeCoins' | 'yianKutKuCoins'>,
source: ActionGearSource,
color: ActionCoinColor,
) {
if (color === 'white') return source === 'yian-kut-ku' ? character.yianKutKuCoins : character.bulldromeCoins
return character.actionCoins?.[source]?.[color] ?? 0
}
export function getTierForItemLevel(itemLevel: number) {
return [...ACTION_DIFFICULTY_TIERS]
.reverse()
.find((tier) => itemLevel >= tier.itemLevel) ?? ACTION_DIFFICULTY_TIERS[0]
}
export function getActionGearUpgradeCap(item: Pick<ActionGearPiece, 'itemLevel'>) {
return getTierForItemLevel(item.itemLevel).itemLevel + 4
}
function rollBulldromeGear(rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
return rollDungeonGear('bulldrome', rolls, itemLevel)
}
function rollDungeonGear(source: ActionGearSource, rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
const gear: ActionGearPiece[] = []
for (let index = 0; index < rolls; index += 1) {
if (Math.random() > 0.75) continue
const slot = ACTION_GEAR_SLOTS[randomInt(0, ACTION_GEAR_SLOTS.length - 1)]
gear.push(createDungeonGear(source, slot.slot, itemLevel))
}
return gear
}
function createDungeonGear(source: ActionGearSource, slot: ActionGearSlot, itemLevel: ActionDifficultyTier['itemLevel']): ActionGearPiece {
const slotMeta = ACTION_GEAR_SLOTS.find((candidate) => candidate.slot === slot)!
const sourceName = source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
return {
id: `${source}-${slot}-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
slug: `${source}-${slot}`,
name: `${sourceName} ${slotMeta.label}`,
source,
slot,
itemLevel,
}
}
function normalizeGearPiece(item: ActionGearPiece): ActionGearPiece {
const source = item.source ?? (item.slug?.startsWith('yian-kut-ku') ? 'yian-kut-ku' : 'bulldrome')
return {
...item,
source,
}
}
function spendUpgradeCoins(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
const itemWithLevel = item as Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>
const coinColor = getCoinColorForItemLevel(itemWithLevel.itemLevel ?? 1)
const actionCoins = addActionCoins(character.actionCoins, item.source, coinColor, -ACTION_GEAR_UPGRADE_COST)
if (item.source === 'yian-kut-ku' && coinColor === 'white') {
return { actionCoins, yianKutKuCoins: actionCoins['yian-kut-ku'].white }
}
if (item.source === 'bulldrome' && coinColor === 'white') {
return { actionCoins, bulldromeCoins: actionCoins.bulldrome.white }
}
return { actionCoins }
}
function getCoinColorForItemLevel(itemLevel: number) {
return getTierForItemLevel(itemLevel).coinColor
}
function createEmptyCoinWallet(): ActionCoinWallet {
return {
bulldrome: { white: 0, green: 0, blue: 0, purple: 0 },
'yian-kut-ku': { white: 0, green: 0, blue: 0, purple: 0 },
}
}
function normalizeCoinWallet(parsed: Partial<ActionCharacter>) {
const wallet = createEmptyCoinWallet()
const saved = parsed.actionCoins
for (const source of ['bulldrome', 'yian-kut-ku'] as const) {
for (const color of ['white', 'green', 'blue', 'purple'] as const) {
wallet[source][color] = Number(saved?.[source]?.[color] ?? 0)
}
}
wallet.bulldrome.white = Number(parsed.bulldromeCoins ?? wallet.bulldrome.white)
wallet['yian-kut-ku'].white = Number(parsed.yianKutKuCoins ?? wallet['yian-kut-ku'].white)
return wallet
}
function addActionCoins(
wallet: ActionCoinWallet,
source: ActionGearSource,
color: ActionCoinColor,
amount: number,
) {
return {
...wallet,
[source]: {
...wallet[source],
[color]: Math.max(0, wallet[source][color] + amount),
},
}
}
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
+790
View File
@@ -0,0 +1,790 @@
import { useEffect, useMemo, useState } from 'react'
import {
loadActionMechanicConfig,
resetActionMechanicConfig,
saveActionMechanicConfig,
type ActionAttackConfig,
type ActionMechanicConfig,
} from '../actionBoss/actionEncounterConfig'
import type { EnemyKind } from '../actionBoss/bulldromeSimulation'
import {
ACTION_DIFFICULTY_TIERS,
ACTION_GEAR_SLOTS,
ACTION_GEAR_UPGRADE_COST,
completeActionDungeonHunt,
getActionCoinCount,
getActionDifficultyTier,
getActionGearStats,
getActionLevelProgress,
getUpgradeCoinCount,
getUpgradeCoinName,
loadActionCharacter,
saveActionCharacter,
upgradeBulldromeGear,
type ActionCharacter,
type ActionDifficulty,
type ActionDungeonId,
type ActionGearPiece,
type ActionGearSlot,
type ActionGearSource,
type ActionRunMode,
type ActionRunReward,
} from '../actionMode'
import { BulldromeBossSlice } from './BulldromeBossSlice'
type ActionModeScreenProps = {
onBack?: () => void
}
type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings'
type ActionHubScreen = 'menu' | ActionHubTab
type PlayableActionDungeonId = Exclude<ActionDungeonId, 'rathian'>
const ACTION_HUB_ITEMS: Array<{
id: ActionHubTab
label: string
glyph: string
description: string
}> = [
{ id: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Run action dungeons and earn gear.' },
{ id: 'raids', label: 'Raids', glyph: 'R', description: 'Large action encounters.' },
{ id: 'pvp', label: 'PVP', glyph: 'P', description: 'Action healer competitions.' },
{ id: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through action fights.' },
{ id: 'customize', label: 'Customize Character', glyph: 'C', description: 'Manage Bulldrome gear and upgrades.' },
{ id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' },
]
export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
const [character, setCharacter] = useState<ActionCharacter>(() => loadActionCharacter())
const [activeDifficulty, setActiveDifficulty] = useState<ActionDifficulty | null>(null)
const [activeDungeonId, setActiveDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
const [activeRunMode, setActiveRunMode] = useState<ActionRunMode>('hunt')
const [activeScreen, setActiveScreen] = useState<ActionHubScreen>('menu')
const [lastReward, setLastReward] = useState<ActionRunReward | null>(null)
const [runKey, setRunKey] = useState(0)
const [message, setMessage] = useState('')
const progress = useMemo(() => getActionLevelProgress(character), [character])
useEffect(() => {
saveActionCharacter(character)
}, [character])
if (activeDifficulty) {
return (
<div className="action-run-shell">
<BulldromeBossSlice
dungeonId={activeDungeonId}
difficulty={activeDifficulty}
runMode={activeRunMode}
key={`${activeDungeonId}-${activeDifficulty}-${activeRunMode}-${runKey}`}
onExit={() => {
setLastReward(null)
setActiveDifficulty(null)
setActiveScreen('dungeons')
}}
onRunComplete={() => {
if (lastReward && activeRunMode === 'hunt') return
const { character: nextCharacter, reward } = completeActionDungeonHunt(character, activeDungeonId, activeDifficulty)
setCharacter(nextCharacter)
if (activeRunMode === 'hunt') setLastReward(reward)
setMessage('')
}}
/>
{lastReward && (
<RunRewardModal
dungeonId={activeDungeonId}
difficulty={activeDifficulty}
reward={lastReward}
onGoAgain={() => {
setLastReward(null)
setRunKey((current) => current + 1)
}}
onMainMenu={() => {
setLastReward(null)
setActiveDifficulty(null)
setActiveScreen('menu')
}}
/>
)}
</div>
)
}
return (
<main className="game-shell action-mode-shell">
<section className="content-screen action-mode-screen">
<div className="screen-heading action-screen-heading">
<div>
<p className="eyebrow">Action Mode</p>
<h1>{getHubTitle(activeScreen)}</h1>
</div>
<div className="action-heading-meta">
<div className="action-character-strip">
<strong>{character.name}</strong>
<small>Healer</small>
<small>Level {character.level}</small>
<div className="header-xp" title={`${character.experience} action experience`}>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
{(activeScreen !== 'menu' || onBack) && (
<button
className="back-button"
onClick={() => {
if (activeScreen === 'menu') onBack?.()
else setActiveScreen('menu')
}}
type="button"
>
Back
</button>
)}
</div>
</div>
{activeScreen === 'menu' && (
<nav className="action-hub-nav" aria-label="Action mode sections">
{ACTION_HUB_ITEMS.map((item) => (
<button
className="menu-card"
key={item.id}
onClick={() => setActiveScreen(item.id)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</nav>
)}
{message && <p className="action-mode-message">{message}</p>}
{activeScreen === 'dungeons' && (
<DungeonsPanel
character={character}
onStart={(dungeonId, difficulty) => {
setActiveRunMode('hunt')
setLastReward(null)
setActiveDungeonId(dungeonId)
setRunKey((current) => current + 1)
setActiveDifficulty(difficulty)
}}
onStartMarathon={(dungeonId, difficulty) => {
setActiveRunMode('marathon')
setLastReward(null)
setActiveDungeonId(dungeonId)
setRunKey((current) => current + 1)
setActiveDifficulty(difficulty)
}}
/>
)}
{activeScreen === 'customize' && (
<CustomizePanel
character={character}
onUpgrade={(itemId) => {
const before = character.inventory.find((item) => item.id === itemId)
const nextCharacter = upgradeBulldromeGear(character, itemId)
const coinCount = before ? getUpgradeCoinCount(character, before) : 0
setCharacter(nextCharacter)
setMessage(
before && before.itemLevel < 5 && coinCount >= ACTION_GEAR_UPGRADE_COST
? `${before.name} upgraded to item level ${before.itemLevel + 1}.`
: 'Upgrade unavailable.',
)
}}
/>
)}
{activeScreen === 'settings' && (
<ActionMechanicsAdmin />
)}
{activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && (
<section className="action-placeholder-panel">
<p className="eyebrow">{getHubTitle(activeScreen)}</p>
<h2>Coming Soon</h2>
<p>This section has its own Action Mode button now. Content hooks can land here without touching Normal Mode.</p>
</section>
)}
</section>
</main>
)
}
function ActionMechanicsAdmin() {
const [config, setConfig] = useState<ActionMechanicConfig>(() => loadActionMechanicConfig())
const [enemyPage, setEnemyPage] = useState(0)
const [attackPageByEnemy, setAttackPageByEnemy] = useState<Partial<Record<EnemyKind, number>>>({})
function updateAttack(enemyKind: EnemyKind, attackId: string, patch: Partial<ActionAttackConfig>) {
const next = {
...config,
[enemyKind]: {
...config[enemyKind],
attacks: config[enemyKind].attacks.map((attack) => (
attack.id === attackId ? { ...attack, ...patch } : attack
)),
},
}
setConfig(next)
saveActionMechanicConfig(next)
}
function resetConfig() {
setConfig(resetActionMechanicConfig())
setEnemyPage(0)
setAttackPageByEnemy({})
}
const enemyKinds = Object.keys(config) as EnemyKind[]
const enemyKind = enemyKinds[Math.min(enemyPage, enemyKinds.length - 1)] ?? enemyKinds[0]
const enemy = config[enemyKind]
const enabledAttacks = enemy.attacks.filter((attack) => attack.enabled)
const disabledAttacks = enemy.attacks.filter((attack) => !attack.enabled)
const attackPage = Math.min(attackPageByEnemy[enemyKind] ?? 0, Math.max(0, enabledAttacks.length - 1))
const activeAttack = enabledAttacks[attackPage] ?? null
function setAttackPage(enemyKind: EnemyKind, page: number) {
setAttackPageByEnemy((current) => ({ ...current, [enemyKind]: page }))
}
return (
<section className="action-mechanics-admin">
<header>
<div>
<p className="eyebrow">Admin</p>
<h2>Mob And Boss Mechanics</h2>
</div>
<button className="back-button" onClick={resetConfig} type="button">Reset Defaults</button>
</header>
<article className="action-mechanic-card" key={enemy.kind}>
<div className="action-mechanic-heading">
<div>
<p className="eyebrow">{enemy.role}</p>
<h3>{enemy.label}</h3>
</div>
<span>{enemyPage + 1} / {enemyKinds.length}</span>
</div>
<div className="action-page-controls" aria-label="Mechanic pages">
<button onClick={() => setEnemyPage((page) => Math.max(0, page - 1))} disabled={enemyPage === 0} type="button">
Prev Mob
</button>
<button
onClick={() => setEnemyPage((page) => Math.min(enemyKinds.length - 1, page + 1))}
disabled={enemyPage >= enemyKinds.length - 1}
type="button"
>
Next Mob
</button>
</div>
{activeAttack ? (
<div className="action-attack-list">
<div className="action-page-controls" aria-label="Attack pages">
<button onClick={() => setAttackPage(enemyKind, Math.max(0, attackPage - 1))} disabled={attackPage === 0} type="button">
Prev Attack
</button>
<span>{attackPage + 1} / {enabledAttacks.length}</span>
<button
onClick={() => setAttackPage(enemyKind, Math.min(enabledAttacks.length - 1, attackPage + 1))}
disabled={attackPage >= enabledAttacks.length - 1}
type="button"
>
Next Attack
</button>
</div>
<AttackEditor
attack={activeAttack}
key={activeAttack.id}
onChange={(patch) => updateAttack(enemyKind, activeAttack.id, patch)}
/>
</div>
) : (
<p className="action-empty-note">No active attacks.</p>
)}
{disabledAttacks.length > 0 && (
<div className="action-disabled-attacks">
<strong>Add Attack</strong>
{disabledAttacks.map((attack) => (
<button
key={attack.id}
onClick={() => {
updateAttack(enemyKind, attack.id, { enabled: true })
setAttackPage(enemyKind, enabledAttacks.length)
}}
type="button"
>
{attack.label}
</button>
))}
</div>
)}
</article>
</section>
)
}
function AttackEditor({
attack,
onChange,
}: {
attack: ActionAttackConfig
onChange: (patch: Partial<ActionAttackConfig>) => void
}) {
return (
<section className="action-attack-editor">
<header>
<div>
<strong>{attack.label}</strong>
<small>{attack.kind}</small>
</div>
<button onClick={() => onChange({ enabled: false })} type="button">Remove</button>
</header>
<div className="action-attack-fields">
<label>
Frequency
<input
min="0"
onChange={(event) => onChange({ frequencySeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.frequencySeconds}
/>
</label>
<label>
Damage
<input
min="0"
onChange={(event) => onChange({ damage: Number(event.target.value) })}
step="1"
type="number"
value={attack.damage}
/>
</label>
{attack.windupSeconds !== undefined && (
<label>
Windup
<input
min="0"
onChange={(event) => onChange({ windupSeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.windupSeconds}
/>
</label>
)}
{attack.recoverSeconds !== undefined && (
<label>
Recover
<input
min="0"
onChange={(event) => onChange({ recoverSeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.recoverSeconds}
/>
</label>
)}
{attack.speed !== undefined && (
<label>
Speed
<input
min="0"
onChange={(event) => onChange({ speed: Number(event.target.value) })}
step="10"
type="number"
value={attack.speed}
/>
</label>
)}
{attack.radius !== undefined && (
<label>
Radius
<input
min="0"
onChange={(event) => onChange({ radius: Number(event.target.value) })}
step="1"
type="number"
value={attack.radius}
/>
</label>
)}
{attack.everyNthCharge !== undefined && (
<label>
Every Charges
<input
min="1"
onChange={(event) => onChange({ everyNthCharge: Number(event.target.value) })}
step="1"
type="number"
value={attack.everyNthCharge}
/>
</label>
)}
</div>
</section>
)
}
function DungeonsPanel({
character,
onStart,
onStartMarathon,
}: {
character: ActionCharacter
onStart: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
onStartMarathon: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
}) {
const [selectedDungeonId, setSelectedDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
const [selectedDifficulty, setSelectedDifficulty] = useState<ActionDifficulty>('ilvl-1')
const selectedDungeon = ACTION_DUNGEONS.find((dungeon) => dungeon.id === selectedDungeonId) ?? ACTION_DUNGEONS[0]
const selectedTier = getActionDifficultyTier(selectedDifficulty)
const selectedCoinCount = getActionCoinCount(character, selectedDungeon.source, selectedTier.coinColor)
return (
<div className="action-dungeon-board">
<section className="action-dungeon-list" aria-label="Action dungeons">
{ACTION_DUNGEONS.map((dungeon) => {
const selected = dungeon.id === selectedDungeonId
const locked = dungeon.locked
return (
<button
className={`action-dungeon-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={dungeon.id}
onClick={() => {
if (!locked) setSelectedDungeonId(dungeon.id as PlayableActionDungeonId)
}}
type="button"
>
<span className="action-dungeon-glyph">{dungeon.glyph}</span>
<span>
<small>{dungeon.eyebrow}</small>
<strong>{dungeon.name}</strong>
<i>{dungeon.summary}</i>
</span>
</button>
)
})}
</section>
<aside className="action-dungeon-setup">
<section className="action-dungeon-selected">
<p className="eyebrow">Selected Run</p>
<h2>{selectedDungeon.name}</h2>
<p>{selectedDungeon.description}</p>
<div className="tag-row">
<span>{selectedTier.label}</span>
<span>{selectedTier.healthMultiplier}x HP</span>
<span>{selectedTier.damageMultiplier}x damage</span>
<span>{selectedTier.coinLabel}</span>
</div>
<dl>
<div><dt>Tier Coins</dt><dd>{selectedCoinCount}</dd></div>
<div><dt>Gear Drop</dt><dd>iLvl {selectedTier.itemLevel}</dd></div>
<div><dt>XP</dt><dd>{selectedTier.experience}</dd></div>
</dl>
</section>
<section className="action-dungeon-tier">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<div className="action-tier-grid">
{ACTION_DIFFICULTY_TIERS.map((tier) => (
<button
className={`${selectedDifficulty === tier.id ? 'selected' : ''} coin-${tier.coinColor}`}
key={tier.id}
onClick={() => setSelectedDifficulty(tier.id)}
type="button"
>
<strong>{tier.label}</strong>
<span>{tier.coinLabel}</span>
</button>
))}
</div>
</section>
<section className="action-dungeon-start">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<div className="action-dungeon-actions">
<button className="primary-button" onClick={() => onStart(selectedDungeonId, selectedDifficulty)} type="button">
Start Hunt
</button>
<button className="primary-button" onClick={() => onStartMarathon(selectedDungeonId, selectedDifficulty)} type="button">
Start Marathon
</button>
</div>
<p>Marathon respawns the boss and extra mobs after a 5 second break.</p>
</section>
</aside>
</div>
)
}
const ACTION_DUNGEONS: Array<{
id: ActionDungeonId
eyebrow: string
glyph: string
name: string
summary: string
description: string
source: ActionGearSource
coinLabel: string
locked?: boolean
}> = [
{
id: 'bulldrome',
eyebrow: 'Dungeon 1',
glyph: 'B',
name: 'Bulldrome Hunting Grounds',
summary: 'Charges, slams, and Bullfango pressure.',
description: 'Bulldrome drops White Bulldrome Coins and item level 1 Bulldrome gear.',
source: 'bulldrome',
coinLabel: 'White Bulldrome Coins',
},
{
id: 'yian-kut-ku',
eyebrow: 'Dungeon 2',
glyph: 'Y',
name: 'Yian Kut-Ku Roost',
summary: 'Bouncing fireballs and dive birds.',
description: 'Yian Kut-Ku fireballs bounce until the next cast and leave fire on walls or players.',
source: 'yian-kut-ku',
coinLabel: 'White Yian Kut-Ku Coins',
},
{
id: 'rathian',
eyebrow: 'Dungeon 3',
glyph: 'R',
name: 'Rathian Nest',
summary: 'Coming next.',
description: 'Rathian mechanics will be built after Yian Kut-Ku.',
source: 'bulldrome',
coinLabel: 'Rathian Coins',
locked: true,
},
]
function RunRewardModal({
dungeonId,
difficulty,
onGoAgain,
onMainMenu,
reward,
}: {
dungeonId: PlayableActionDungeonId
difficulty: ActionDifficulty
onGoAgain: () => void
onMainMenu: () => void
reward: ActionRunReward
}) {
return (
<div className="action-run-reward-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-run-reward-title">
<section className="action-run-reward-modal">
<p className="eyebrow">Dungeon Complete</p>
<h1 id="action-run-reward-title">
{getRunRewardTitle(dungeonId, difficulty)}
</h1>
<div className="action-run-reward-summary">
<div>
<dt>XP</dt>
<dd>{reward.experience}</dd>
</div>
<div>
<dt>{reward.coinName}</dt>
<dd>{reward.coins}</dd>
</div>
<div>
<dt>Level</dt>
<dd>{reward.leveledUp ? 'Up' : 'No Change'}</dd>
</div>
</div>
<div className="action-run-loot-list">
<strong>Loot</strong>
{reward.gear.length === 0 ? (
<p>No gear dropped.</p>
) : (
reward.gear.map((item) => (
<span key={item.id}>{item.name} · ilvl {item.itemLevel}</span>
))
)}
</div>
<div className="action-run-reward-actions">
<button className="primary-button" onClick={onGoAgain} type="button">Go Again</button>
<button className="back-button" onClick={onMainMenu} type="button">Main Menu</button>
</div>
</section>
</div>
)
}
function CustomizePanel({
character,
onUpgrade,
}: {
character: ActionCharacter
onUpgrade: (itemId: string) => void
}) {
const [selectedSlot, setSelectedSlot] = useState<ActionGearSlot>('weapon')
const slotMeta = ACTION_GEAR_SLOTS.find((slot) => slot.slot === selectedSlot) ?? ACTION_GEAR_SLOTS[0]
const filteredItems = useMemo(() => (
character.inventory
.filter((item) => item.slot === selectedSlot)
.sort((a, b) => b.itemLevel - a.itemLevel || a.name.localeCompare(b.name))
), [character.inventory, selectedSlot])
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? filteredItems[0] ?? null
return (
<section className="action-gear-panel">
<article className="action-gear-summary">
<p className="eyebrow">Craft Table</p>
<h2>Bulldrome Gear</h2>
<p>
Pick a gear slot, inspect that slot inventory, then preview the upgrade
cost and stat gain before spending coins.
</p>
</article>
<div className="action-customize-layout">
<div className="action-slot-grid" aria-label="Gear slots">
{ACTION_GEAR_SLOTS.map((slot) => {
const count = character.inventory.filter((item) => item.slot === slot.slot).length
return (
<button
className={selectedSlot === slot.slot ? 'selected' : ''}
key={slot.slot}
onClick={() => {
setSelectedSlot(slot.slot)
setSelectedItemId(null)
}}
type="button"
>
<span>{slot.glyph}</span>
<strong>{slot.label}</strong>
<small>{count} owned</small>
</button>
)
})}
</div>
<aside className="action-inventory-panel">
<header>
<div>
<p className="eyebrow">Inventory</p>
<h2>{slotMeta.label}</h2>
</div>
<span>{selectedItem ? `${getUpgradeCoinCount(character, selectedItem)} Coins` : `${character.bulldromeCoins} / ${character.yianKutKuCoins} Coins`}</span>
</header>
<div className="action-inventory-list">
{filteredItems.length === 0 ? (
<p>No {slotMeta.label} pieces yet.</p>
) : (
filteredItems.map((item) => (
<button
className={selectedItem?.id === item.id ? 'selected' : ''}
key={item.id}
onClick={() => setSelectedItemId(item.id)}
type="button"
>
<strong>{item.name}</strong>
<small>ilvl {item.itemLevel}</small>
</button>
))
)}
</div>
<GearDetail
coins={selectedItem ? getUpgradeCoinCount(character, selectedItem) : 0}
item={selectedItem}
onUpgrade={() => {
if (selectedItem) onUpgrade(selectedItem.id)
}}
/>
</aside>
</div>
</section>
)
}
function GearDetail({
coins,
item,
onUpgrade,
}: {
coins: number
item: ActionGearPiece | null
onUpgrade: () => void
}) {
if (!item) {
return (
<section className="action-gear-detail empty">
<p>Select a slot with gear to see stats and upgrade costs.</p>
</section>
)
}
const currentStats = getActionGearStats(item)
const nextItem = { ...item, itemLevel: Math.min(5, item.itemLevel + 1) }
const nextStats = getActionGearStats(nextItem)
const coinName = getUpgradeCoinName(item)
const canUpgrade = coins >= ACTION_GEAR_UPGRADE_COST && item.itemLevel < 5
return (
<section className="action-gear-detail">
<header>
<div>
<p className="eyebrow">Selected</p>
<h2>{item.name}</h2>
</div>
<span>ilvl {item.itemLevel}</span>
</header>
<dl className="action-stat-compare">
<div>
<dt>Healing</dt>
<dd>{currentStats.healingPower} {nextStats.healingPower}</dd>
</div>
<div>
<dt>Stamina</dt>
<dd>{currentStats.stamina} {nextStats.stamina}</dd>
</div>
<div>
<dt>Upgrade Cost</dt>
<dd>{item.itemLevel >= 5 ? 'Max' : `${ACTION_GEAR_UPGRADE_COST} ${coinName}`}</dd>
</div>
<div>
<dt>You Have</dt>
<dd>{coins} {coinName}</dd>
</div>
</dl>
<button disabled={!canUpgrade} onClick={onUpgrade} type="button">
{item.itemLevel >= 5 ? 'Max Level' : `Upgrade to ilvl ${item.itemLevel + 1}`}
</button>
</section>
)
}
function getHubTitle(screen: ActionHubScreen) {
if (screen === 'menu') return 'Action Mode'
return ACTION_HUB_ITEMS.find((item) => item.id === screen)?.label ?? 'Action Mode'
}
function getRunRewardTitle(dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) {
const dungeonName = dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku Hunt' : 'Bulldrome Hunt'
return `${getActionDifficultyTier(difficulty).label} ${dungeonName}`
}
+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>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ActionModeScreen } from './components/ActionModeScreen'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ActionModeScreen />
</StrictMode>,
)
+2171
View File
File diff suppressed because it is too large Load Diff