Files
i-want-to-heal-2/src/actionBoss/actionCombatSimulation.ts
T

2070 lines
66 KiB
TypeScript

import {
getActionAiAvoidance,
getActionAttack,
getEnabledActionAttack,
type ActionAiAvoidanceConfig,
type ActionAttackKind,
} from './actionEncounterConfig'
import {
ACTION_CONTROL_RULES,
ACTION_MOVEMENT_RULES,
createSpellCooldowns,
createSpellResourceState,
addShield,
applyHealOverTimeTimers,
applyShieldedDamage,
getActiveHealOverTimeFrames,
getActionSpellDefinition,
getActionSpellReach,
getActionSpellTarget,
getActionTalentAdjustedAmount,
getHealOverTimeEffectId,
RANGED_ATTACK_RANGE,
recordSpellCast,
ARENA_PRIEST_SPELL_BAR,
DUNGEON_PRIEST_SPELL_BAR,
SPELL_GLOBAL_COOLDOWN_SECONDS,
SPELLS,
spendActionSpellMana,
tickHealOverTimes,
tickSpellCooldowns as tickSharedSpellCooldowns,
tickSpellResource,
type CastState,
type HealOverTimeEffect,
type PartyRole,
type SpellDefinition,
type SpellResourceState,
type SpellSlot,
type ActionSpellReachResult,
} from '../actionCombatCore'
import { getArenaAbility, getArenaClassVitals, type ArenaClassId } from '../actionClassKits'
import type { TalentModifiers } from '../claudeCraftTalents'
import { createEmptyTalentModifiers } from '../actionCombatProfile'
import {
type BossInput,
type BossPhase,
type Point,
type ArcTelegraph,
type RaidFrame,
type Telegraph,
} from '../actionCombatTypes'
import {
getEnemyBodyDamage,
getEnemyChargeDamage,
getEnemyChargeSeconds,
getEnemyChargeSpeed,
getEnemyRecoverSeconds,
getEnemyTankFocusSeconds,
getEnemyTankSwingDamage,
getEnemyTankSwingSeconds,
getEnemyTrackSeconds,
getEnemyWindupSeconds,
} from './actionEnemyRuntime'
import { getActionDifficultyTier, type ActionDifficulty, type ActionDungeonId, type ActionRunMode } from '../actionMode'
import type { PlayerClass } from '../claudeCraftTypes'
export type { ActionDungeonId, ActionRunMode } from '../actionMode'
export { ARENA_PRIEST_SPELL_BAR, DUNGEON_PRIEST_SPELL_BAR, SPELLS }
export type { CastState, PartyRole, SpellDefinition, SpellSlot }
export type EnemyKind =
| 'bulldrome'
| 'bullfango'
| 'yian-kut-ku'
| 'bird'
| 'cyber-dragon'
| 'claudecraft-mob'
| 'morthen-the-gravecaller'
| 'vael-the-mistcaller'
| 'korzul-the-gravewyrm'
export type EncounterStep = 'trash' | 'boss'
type BirdFlightStep = 'ground' | 'toLeft' | 'across' | 'toTank'
export type FighterState = {
x: number
y: number
radius: number
hp: number
maxHp: number
}
export type TargetableState = FighterState & {
id: string
name: string
role: PartyRole
shield: number
stunTimer: number
invulnerableTimer: number
healOverTimes?: HealOverTimeEffect[]
renewTimer: number
renewTickTimer: number
burnTimer: number
burnTickTimer: number
bleedTimer: number
bleedTickTimer: number
}
export type PartyMemberState = TargetableState & {
aiSlot: number
}
export type EnemyState = FighterState & {
id: string
name: string
kind: EnemyKind
phase: BossPhase
phaseTimer: number
chargeVector: Point
targetId: string
chargeCount: number
tankSwingTimer: number
flightStep: BirdFlightStep
flightY: number
orbitAngle: number
mechanicTimers: Partial<Record<ActionAttackKind, number>>
arcTelegraph: ArcTelegraph
telegraph: Telegraph
slamTelegraph: {
active: boolean
radius: number
}
}
export type BulldromeState = {
dungeonId: ActionDungeonId
difficulty: ActionDifficulty
runMode: ActionRunMode
encounterStep: EncounterStep
arena: {
width: number
height: number
padding: number
}
player: TargetableState & {
classId: PlayerClass
currentCast: CastState | null
facingX: number
facingY: number
globalCooldown: number
spellCooldowns: Record<SpellSlot, number>
talentModifiers: TalentModifiers
} & SpellResourceState
party: PartyMemberState[]
targetId: string
boss: EnemyState
adds: EnemyState[]
fireballs: FireballState[]
fireSpots: FireSpotState[]
spinningBlades: SpinningBladeState[]
nextHazardId: number
damageDoneByUnit: Record<string, number>
bossKills: number
dungeonWaveIndex: number
dungeonWaveTotal: number
respawnTimer: number
healEvents: HealTextEvent[]
noticeEvents: NoticeTextEvent[]
elapsed: number
message: string
result: 'playing' | 'win' | 'loss'
lastHit: 'player' | 'party' | 'boss' | 'heal' | null
}
export type FireballState = {
id: string
x: number
y: number
radius: number
vx: number
vy: number
bouncesRemaining: number
}
export type FireSpotState = {
id: string
x: number
y: number
radius: number
remaining: number
}
export type SpinningBladeState = {
id: string
sourceKind: EnemyKind
x: number
y: number
radius: number
speed: number
remaining: number
targetId: string
rotation: number
}
export type EnemyFrame = {
id: string
name: string
kind: EnemyKind
hp: number
maxHp: number
phase: BossPhase
}
export type HealTextEvent = {
id: string
targetId: string
amount: number
}
export type NoticeTextEvent = {
id: string
targetId: string
text: string
}
type CombatSpellTarget = TargetableState | EnemyState
const BOSS_SLAM_CAST_SECONDS = 1.25
const BOSS_SLAM_RADIUS = 142
const DUNGEON_ARENA_HEIGHT = 648
const DUNGEON_ARENA_PADDING = 42
const DUNGEON_ARENA_WIDTH = 1152
const YIAN_FIREBALL_WINDUP_SECONDS = 1
const FIREBALL_SPEED = 190
const FIREBALL_DAMAGE = 16
const FIRE_DOT_SECONDS = 5
const FIRE_DOT_TICK_SECONDS = 1
const FIRE_DOT_DAMAGE = 5
const FIRE_SPOT_SECONDS = 20
const MAX_FIRE_SPOTS = 8
const CYBER_BLADE_SPAWN_SECONDS = 10
const CYBER_BLADE_SECONDS = 15
const CYBER_BLADE_SPEED = 96
const CYBER_BLADE_RADIUS = 24
const CYBER_BLEED_SECONDS = 8
const CYBER_BLEED_TICK_SECONDS = 1
const CYBER_BLEED_DAMAGE = 3
const CYBER_CIRCLE_SECONDS = 1.35
const CYBER_CIRCLE_RADIUS = 124
const CYBER_CHARGE_SECONDS = 0.72
const CYBER_CHARGE_STUN_SECONDS = 1
const AI_MOVE_SPEED = 132
const TANK_DPS = 4
const MELEE_DPS = 10.5
const RANGED_DPS = 8.75
function getInitialArena(dungeonId: ActionDungeonId) {
void dungeonId
return {
width: DUNGEON_ARENA_WIDTH,
height: DUNGEON_ARENA_HEIGHT,
padding: DUNGEON_ARENA_PADDING,
}
}
function getInitialPartyStart(dungeonId: ActionDungeonId) {
void dungeonId
const centerX = DUNGEON_ARENA_WIDTH / 2
const centerY = DUNGEON_ARENA_HEIGHT / 2
return {
player: { x: centerX, y: centerY + 134 },
tank: { x: centerX, y: centerY - 92 },
melee: { x: centerX, y: centerY - 50 },
ranged1: { x: centerX - 120, y: centerY + 106 },
ranged2: { x: centerX + 120, y: centerY + 106 },
}
}
function toArenaClassId(classId: PlayerClass): ArenaClassId {
if (classId === 'hunter') return 'ranger'
if (classId === 'warrior' || classId === 'mage' || classId === 'rogue' || classId === 'paladin' || classId === 'shaman' || classId === 'druid' || classId === 'priest') return classId
return 'priest'
}
export function createBulldromeState(
difficulty: ActionDifficulty = 'ilvl-1',
dungeonId: ActionDungeonId = 'bulldrome',
runMode: ActionRunMode = 'hunt',
playerClassId: PlayerClass = 'priest',
playerTalentModifiers?: TalentModifiers,
): BulldromeState {
const dungeonWaveTotal = 0
const arena = getInitialArena(dungeonId)
const partyStart = getInitialPartyStart(dungeonId)
const playerVitals = getArenaClassVitals(toArenaClassId(playerClassId))
const state: BulldromeState = {
dungeonId,
difficulty,
runMode,
encounterStep: 'boss',
arena,
player: {
id: 'player',
name: 'You',
classId: playerClassId,
role: 'healer',
x: partyStart.player.x,
y: partyStart.player.y,
radius: 15,
hp: playerVitals.maxHp,
maxHp: playerVitals.maxHp,
shield: 0,
stunTimer: 0,
invulnerableTimer: 0,
healOverTimes: [],
renewTimer: 0,
renewTickTimer: 0,
burnTimer: 0,
burnTickTimer: 0,
bleedTimer: 0,
bleedTickTimer: 0,
currentCast: null,
facingX: 0,
facingY: -1,
globalCooldown: 0,
spellCooldowns: createSpellCooldowns(),
talentModifiers: playerTalentModifiers ?? createEmptyTalentModifiers(),
...createSpellResourceState(playerClassId),
},
party: [
createPartyMember('tank', 'Brakka', 'tank', getArenaClassVitals('warrior_protection').maxHp, partyStart.tank.x, partyStart.tank.y, 0),
createPartyMember('melee-1', 'Kael', 'melee', getArenaClassVitals('rogue_combat').maxHp, partyStart.melee.x, partyStart.melee.y, 1),
createPartyMember('ranged-1', 'Sera', 'ranged', getArenaClassVitals('mage_fire').maxHp, partyStart.ranged1.x, partyStart.ranged1.y, 3),
createPartyMember('ranged-2', 'Nix', 'ranged', getArenaClassVitals('hunter_marksmanship').maxHp, partyStart.ranged2.x, partyStart.ranged2.y, 4),
],
targetId: 'tank',
boss: createInitialEnemy(dungeonId),
adds: [],
fireballs: [],
fireSpots: [],
spinningBlades: [],
nextHazardId: 0,
damageDoneByUnit: createDamageDoneTracker(),
bossKills: 0,
dungeonWaveIndex: 0,
dungeonWaveTotal,
respawnTimer: 0,
healEvents: [],
noticeEvents: [],
elapsed: 0,
message: getDungeonStartMessage(dungeonId),
result: 'playing',
lastHit: null,
}
applyDifficultyScaling(state)
return state
}
export function updateBulldromeState(
state: BulldromeState,
input: BossInput,
deltaSeconds: number,
): BulldromeState {
if (input.reset) return createBulldromeState(state.difficulty, state.dungeonId, state.runMode, state.player.classId, state.player.talentModifiers)
const next = cloneState(state)
next.elapsed += deltaSeconds
next.lastHit = null
next.healEvents = []
next.noticeEvents = []
if (input.targetId) setTarget(next, input.targetId)
if (input.targetDelta !== 0) cycleTarget(next, input.targetDelta)
if (next.result !== 'playing') return next
tickTargetable(next, next.player, deltaSeconds)
tickPlayerGlobalCooldown(next, deltaSeconds)
tickSpellCooldowns(next.player.spellCooldowns, deltaSeconds)
tickSpellResource(next.player, deltaSeconds)
tickCurrentCast(next, deltaSeconds)
for (const member of next.party) tickTargetable(next, member, deltaSeconds)
movePlayer(next, input, deltaSeconds)
if (next.respawnTimer > 0) {
next.respawnTimer = Math.max(0, next.respawnTimer - deltaSeconds)
if (next.respawnTimer <= 0) respawnBossEncounter(next)
else next.message = `Marathon break. Next boss in ${Math.ceil(next.respawnTimer)}.`
startPlayerSpell(next, input.castSpell)
resolveResult(next)
return next
}
updatePartyAi(next, deltaSeconds)
resolvePartyDamage(next, deltaSeconds)
updateEnemies(next, deltaSeconds)
updateFireHazards(next, deltaSeconds)
updateSpinningBlades(next, deltaSeconds)
resolveContact(next)
startPlayerSpell(next, input.castSpell)
resolveResult(next)
return next
}
export function getRaidFrames(state: BulldromeState): RaidFrame[] {
return [state.player, ...state.party].map((unit) => ({
id: unit.id,
name: unit.name,
role: unit.role,
hp: unit.hp,
maxHp: unit.maxHp,
healOverTimes: getActiveHealOverTimeFrames(unit),
renewTimer: unit.renewTimer,
burnTimer: unit.burnTimer,
bleedTimer: unit.bleedTimer,
shield: unit.shield,
selected: unit.id === state.targetId,
damageDone: state.damageDoneByUnit[unit.id] ?? 0,
}))
}
export function getEnemyFrames(state: BulldromeState): EnemyFrame[] {
return getLivingEnemies(state).map((enemy) => ({
id: enemy.id,
name: enemy.name,
kind: enemy.kind,
hp: enemy.hp,
maxHp: enemy.maxHp,
phase: enemy.phase,
}))
}
export function getEncounterTitle(state: BulldromeState) {
if (state.dungeonId === 'cyber-dragon') return 'Cyber Dragon'
if (state.dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku'
return 'Bulldrome'
}
export function getEncounterHp(state: BulldromeState) {
const enemies = getAllEnemies(state)
const hp = enemies.reduce((sum, enemy) => sum + Math.max(0, enemy.hp), 0)
const maxHp = enemies.reduce((sum, enemy) => sum + enemy.maxHp, 0)
return { hp, maxHp }
}
export function getTargetableUnits(state: BulldromeState): TargetableState[] {
return [state.player, ...state.party]
}
export function getAllEnemies(state: BulldromeState): EnemyState[] {
return [state.boss, ...state.adds]
}
export function getLivingEnemies(state: BulldromeState): EnemyState[] {
return getAllEnemies(state).filter((enemy) => enemy.hp > 0)
}
export function distance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function createPartyMember(
id: string,
name: string,
role: Exclude<PartyRole, 'healer'>,
maxHp: number,
x: number,
y: number,
aiSlot: number,
): PartyMemberState {
return {
id,
name,
role,
x,
y,
radius: role === 'tank' ? 17 : 14,
hp: maxHp,
maxHp,
shield: 0,
stunTimer: 0,
invulnerableTimer: 0,
healOverTimes: [],
renewTimer: 0,
renewTickTimer: 0,
burnTimer: 0,
burnTickTimer: 0,
bleedTimer: 0,
bleedTickTimer: 0,
aiSlot,
}
}
function createBulldrome(): EnemyState {
return createEnemy('bulldrome', 'Bulldrome', 'bulldrome', 1320, 34, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0)
}
function createYianKutKu(): EnemyState {
return createEnemy('yian-kut-ku', 'Yian Kut-Ku', 'yian-kut-ku', 1540, 32, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0)
}
function createCyberDragon(): EnemyState {
return createEnemy('cyber-dragon', 'Cyber Dragon', 'cyber-dragon', 1880, 36, DUNGEON_ARENA_WIDTH / 2, DUNGEON_ARENA_HEIGHT / 2 - 170, 0)
}
function createInitialEnemy(dungeonId: ActionDungeonId): EnemyState {
if (dungeonId === 'cyber-dragon') return createCyberDragon()
if (dungeonId === 'yian-kut-ku') return createYianKutKu()
return createBulldrome()
}
function getDungeonStartMessage(dungeonId: ActionDungeonId) {
if (dungeonId === 'cyber-dragon') return 'Cyber Dragon boots its blade array.'
if (dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku watches the roost.'
return 'Bulldrome blocks the hunting grounds.'
}
function createEnemy(
id: string,
name: string,
kind: EnemyKind,
maxHp: number,
radius: number,
x: number,
y: number,
timerOffset: number,
): EnemyState {
const trackSeconds = getTrackSeconds(kind)
return {
id,
name,
kind,
x,
y,
radius,
hp: maxHp,
maxHp,
phase: 'tracking',
phaseTimer: trackSeconds + timerOffset,
chargeVector: { x: 0, y: 1 },
targetId: 'tank',
chargeCount: 0,
tankSwingTimer: getTankSwingSeconds(kind),
flightStep: 'ground',
flightY: y,
orbitAngle: 0,
mechanicTimers: {
spinningBlade: getAttackFrequency(kind, 'spinningBlade', CYBER_BLADE_SPAWN_SECONDS),
},
arcTelegraph: {
active: false,
center: { x, y },
radius: 0,
startAngle: 0,
endAngle: 0,
width: 0,
},
telegraph: {
active: false,
start: { x, y },
end: { x, y: 470 },
width: kind === 'bulldrome' ? 86 : 48,
},
slamTelegraph: {
active: false,
radius: BOSS_SLAM_RADIUS,
},
}
}
function tickSpellCooldowns(cooldowns: Record<SpellSlot, number>, deltaSeconds: number) {
tickSharedSpellCooldowns(cooldowns, deltaSeconds)
}
function tickPlayerGlobalCooldown(state: BulldromeState, deltaSeconds: number) {
state.player.globalCooldown = Math.max(0, state.player.globalCooldown - deltaSeconds)
}
function triggerPlayerGlobalCooldown(state: BulldromeState) {
state.player.globalCooldown = SPELL_GLOBAL_COOLDOWN_SECONDS
}
function tickCurrentCast(state: BulldromeState, deltaSeconds: number) {
const cast = state.player.currentCast
if (!cast) return
cast.remaining = Math.max(0, cast.remaining - deltaSeconds)
state.message = `Casting ${getDungeonSpell(state, cast.spell).name} on ${findTarget(state, cast.targetId)?.name ?? 'target'}.`
if (cast.remaining > 0) return
const completed = { ...cast }
state.player.currentCast = null
completePlayerSpell(state, completed.spell, completed.targetId)
}
function tickTargetable(state: BulldromeState, unit: TargetableState, deltaSeconds: number) {
unit.stunTimer = Math.max(0, unit.stunTimer - deltaSeconds)
unit.invulnerableTimer = Math.max(0, unit.invulnerableTimer - deltaSeconds)
if (unit.burnTimer > 0) {
unit.burnTimer = Math.max(0, unit.burnTimer - deltaSeconds)
unit.burnTickTimer -= deltaSeconds
if (unit.burnTickTimer <= 0) {
applyDamage(unit, FIRE_DOT_DAMAGE)
unit.burnTickTimer += FIRE_DOT_TICK_SECONDS
}
}
if (unit.bleedTimer > 0) {
unit.bleedTimer = Math.max(0, unit.bleedTimer - deltaSeconds)
unit.bleedTickTimer -= deltaSeconds
if (unit.bleedTickTimer <= 0) {
applyDamage(unit, CYBER_BLEED_DAMAGE)
unit.bleedTickTimer += CYBER_BLEED_TICK_SECONDS
}
}
if (unit.healOverTimes?.length || unit.renewTimer > 0) {
tickHealOverTimes(unit, deltaSeconds, (effect) => {
healTarget(state, unit, effect.tickHeal)
addHealEvent(state, unit, 0)
})
}
}
function movePlayer(state: BulldromeState, input: BossInput, deltaSeconds: number) {
if (state.player.stunTimer > 0) {
state.message = 'Stunned. Heal after the stars clear.'
return
}
const length = Math.hypot(input.xAxis, input.yAxis)
if (length <= 0.01) return
state.player.facingX = input.xAxis / length
state.player.facingY = input.yAxis / length
state.player.x += state.player.facingX * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds
state.player.y += state.player.facingY * ACTION_MOVEMENT_RULES.baseSpeed * deltaSeconds
clampToArena(state, state.player)
}
function updatePartyAi(state: BulldromeState, deltaSeconds: number) {
for (const member of state.party) {
if (member.hp <= 0 || member.stunTimer > 0) continue
const hazardAvoidance = getDamagingEffectAvoidance(state, member)
if (hazardAvoidance) {
moveToward(state, member, hazardAvoidance.point, hazardAvoidance.moveSpeed * deltaSeconds)
continue
}
const desired = getAiDesiredPoint(state, member)
moveToward(state, member, desired, AI_MOVE_SPEED * deltaSeconds)
}
}
function getAiDesiredPoint(state: BulldromeState, member: PartyMemberState): Point {
const target = getAiTargetForMember(state, member)
if (!target) return { x: member.x, y: member.y }
if (member.role === 'ranged') {
const enemyDistance = distance(member, target)
if (enemyDistance > RANGED_ATTACK_RANGE - 18) {
const dx = member.x - target.x
const dy = member.y - target.y
const length = Math.max(1, Math.hypot(dx, dy))
return clampPointToArena(state, {
x: target.x + (dx / length) * (RANGED_ATTACK_RANGE - 36),
y: target.y + (dy / length) * (RANGED_ATTACK_RANGE - 36),
}, member.radius)
}
const home = member.aiSlot === 3
? { x: 320, y: 376 }
: { x: 640, y: 376 }
return distance(home, target) <= RANGED_ATTACK_RANGE ? home : { x: member.x, y: member.y }
}
const bossDistance = distance(member, target)
if (member.role === 'tank') {
if (bossDistance >= 46 && bossDistance <= 86) return { x: member.x, y: member.y }
return offsetFromEnemy(state, target, Math.PI / 2, target.radius + 28)
}
if (bossDistance >= 54 && bossDistance <= 98) return { x: member.x, y: member.y }
const side = member.aiSlot === 1 ? 2.45 : 0.7
return offsetFromEnemy(state, target, side, target.radius + 42)
}
function offsetFromEnemy(state: BulldromeState, enemy: FighterState, angle: number, range: number): Point {
const point = {
x: enemy.x + Math.cos(angle) * range,
y: enemy.y + Math.sin(angle) * range,
}
return clampPointToArena(state, point, 14)
}
function moveToward(state: BulldromeState, unit: TargetableState, point: Point, maxDistance: number) {
const dx = point.x - unit.x
const dy = point.y - unit.y
const length = Math.hypot(dx, dy)
if (length <= 2) return
const distanceThisFrame = Math.min(length, maxDistance)
unit.x += (dx / length) * distanceThisFrame
unit.y += (dy / length) * distanceThisFrame
clampToArena(state, unit)
}
function updateEnemies(state: BulldromeState, deltaSeconds: number) {
for (const enemy of getLivingEnemies(state)) {
updateEnemy(state, enemy, deltaSeconds)
}
}
function updateEnemy(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) {
enemy.phaseTimer -= deltaSeconds
if (enemy.kind === 'yian-kut-ku') {
updateYianKutKu(state, enemy, deltaSeconds)
return
}
if (enemy.kind === 'bird') {
updateBird(state, enemy, deltaSeconds)
return
}
if (enemy.kind === 'cyber-dragon') {
updateCyberDragon(state, enemy, deltaSeconds)
return
}
if (enemy.phase === 'tracking') {
enemy.targetId = getTankTargetId(state, enemy)
const target = findTarget(state, enemy.targetId) ?? state.player
driftTowardTarget(state, enemy, target, deltaSeconds)
enemy.telegraph.active = false
state.message = `${enemy.name} tracks ${target.name}.`
if (enemy.phaseTimer <= 0) {
if (getEnabledActionAttack(enemy.kind, 'charge')) {
enemy.targetId = selectChargeTarget(state, enemy)
startWindup(state, enemy)
} else {
startTankFocus(enemy)
}
}
return
}
if (enemy.phase === 'mauling') {
enemy.telegraph.active = false
enemy.slamTelegraph.active = false
enemy.targetId = 'tank'
const tank = findTarget(state, 'tank') ?? state.player
driftTowardTarget(state, enemy, tank, deltaSeconds)
enemy.tankSwingTimer -= deltaSeconds
state.message = `${enemy.name} attacks ${tank.name}.`
if (enemy.tankSwingTimer <= 0) {
applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind)))
tank.invulnerableTimer = 0.12
state.lastHit = tank.id === 'player' ? 'player' : 'party'
state.message = `${enemy.name} hit ${tank.name}.`
enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind)
}
if (enemy.phaseTimer <= 0) {
const slam = getEnabledActionAttack(enemy.kind, 'groundSlam')
const charge = getEnabledActionAttack(enemy.kind, 'charge')
if (
slam
&& enemy.chargeCount > 0
&& enemy.chargeCount % Math.max(1, Math.round(slam.everyNthCharge ?? 3)) === 0
) {
startSlam(enemy)
} else if (charge) {
enemy.targetId = selectChargeTarget(state, enemy)
startWindup(state, enemy)
} else {
enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
}
}
return
}
if (enemy.phase === 'windup') {
enemy.telegraph.active = true
const target = findTarget(state, enemy.targetId)
state.message = `${enemy.name} charges ${target?.name ?? 'the party'}.`
if (enemy.phaseTimer <= 0) {
enemy.phase = 'charging'
enemy.phaseTimer = getChargeSeconds(enemy.kind)
enemy.telegraph.active = false
state.message = `${enemy.name} charges.`
}
return
}
if (enemy.phase === 'charging') {
enemy.telegraph.active = true
enemy.x += enemy.chargeVector.x * getChargeSpeed(enemy.kind) * deltaSeconds
enemy.y += enemy.chargeVector.y * getChargeSpeed(enemy.kind) * deltaSeconds
clampToArena(state, enemy)
if (enemy.phaseTimer <= 0 || hitArenaWall(state, enemy)) {
enemy.phase = 'recovering'
enemy.phaseTimer = getRecoverSeconds(enemy.kind)
state.message = `${enemy.name} is recovering.`
}
return
}
if (enemy.phase === 'slamWindup') {
enemy.telegraph.active = false
enemy.slamTelegraph.active = true
state.message = 'Ground slam. Get away from Bulldrome.'
if (enemy.phaseTimer <= 0) {
resolveGroundSlam(state, enemy)
enemy.slamTelegraph.active = false
enemy.targetId = selectChargeTarget(state, enemy)
startWindup(state, enemy)
}
return
}
if (enemy.phase === 'recovering') {
enemy.telegraph.active = false
enemy.slamTelegraph.active = false
state.message = `${enemy.name} is recovering.`
if (enemy.phaseTimer <= 0) {
startTankFocus(enemy)
}
}
}
function updateYianKutKu(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) {
const fireballAttack = getEnabledActionAttack(enemy.kind, 'fireballVolley')
if (enemy.phase === 'tracking') {
enemy.targetId = getTankTargetId(state, enemy)
const tank = findTarget(state, enemy.targetId) ?? state.player
driftTowardTarget(state, enemy, tank, deltaSeconds)
state.message = 'Yian Kut-Ku watches the party.'
if (enemy.phaseTimer <= 0) {
if (!fireballAttack) {
startTankFocus(enemy)
return
}
state.fireballs = []
enemy.phase = 'windup'
enemy.phaseTimer = fireballAttack.windupSeconds ?? YIAN_FIREBALL_WINDUP_SECONDS
state.message = 'Yian Kut-Ku inhales. Fireballs incoming.'
}
return
}
if (enemy.phase === 'windup') {
state.message = 'Fireballs: northeast and northwest.'
if (enemy.phaseTimer <= 0) {
spawnYianFireballs(state, enemy)
enemy.phase = 'recovering'
enemy.phaseTimer = getRecoverSeconds(enemy.kind)
state.message = 'Yian Kut-Ku spews bouncing fireballs.'
}
return
}
if (enemy.phase === 'mauling') {
enemy.targetId = 'tank'
const tank = findTarget(state, 'tank') ?? state.player
driftTowardTarget(state, enemy, tank, deltaSeconds)
enemy.tankSwingTimer -= deltaSeconds
state.message = `Yian Kut-Ku pecks ${tank.name}.`
if (enemy.tankSwingTimer <= 0) {
applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind)))
tank.invulnerableTimer = 0.12
state.lastHit = tank.id === 'player' ? 'player' : 'party'
enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind)
}
if (enemy.phaseTimer <= 0) {
enemy.phase = 'tracking'
enemy.phaseTimer = getTrackSeconds(enemy.kind)
}
return
}
if (enemy.phase === 'recovering') {
state.message = 'Yian Kut-Ku recovers from the fireball spray.'
if (enemy.phaseTimer <= 0) {
enemy.phase = 'mauling'
enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
enemy.tankSwingTimer = 0.35
}
}
}
function spawnYianFireballs(state: BulldromeState, enemy: EnemyState) {
const fireballAttack = getEnabledActionAttack(enemy.kind, 'fireballVolley')
if (!fireballAttack) return
const directions = [
{ x: 0.72, y: -0.7 },
{ x: -0.72, y: -0.7 },
]
for (const direction of directions) {
state.fireballs.push({
id: `fireball-${state.nextHazardId}`,
x: enemy.x,
y: enemy.y,
radius: 13,
vx: direction.x * (fireballAttack.speed ?? FIREBALL_SPEED),
vy: direction.y * (fireballAttack.speed ?? FIREBALL_SPEED),
bouncesRemaining: 9999,
})
state.nextHazardId += 1
}
}
function updateBird(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) {
enemy.telegraph.active = false
enemy.slamTelegraph.active = false
const diveAttack = getEnabledActionAttack(enemy.kind, 'birdDive')
if (enemy.phase === 'mauling' || enemy.phase === 'tracking') {
enemy.flightStep = 'ground'
enemy.targetId = 'tank'
const tank = findTarget(state, 'tank') ?? state.player
driftTowardTarget(state, enemy, tank, deltaSeconds)
enemy.tankSwingTimer -= deltaSeconds
state.message = `${enemy.name} claws ${tank.name}.`
if (enemy.tankSwingTimer <= 0) {
applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind)))
tank.invulnerableTimer = 0.12
state.lastHit = tank.id === 'player' ? 'player' : 'party'
enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind)
}
if (enemy.phaseTimer <= 0) {
if (diveAttack) startBirdFlight(state, enemy)
else enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
}
return
}
if (enemy.phase === 'windup') {
enemy.flightStep = 'toLeft'
moveEnemyTowardPoint(state, enemy, {
x: state.arena.padding + enemy.radius,
y: enemy.flightY,
}, getChargeSpeed(enemy.kind) * deltaSeconds)
state.message = `${enemy.name} lifts off.`
if (enemy.x <= state.arena.padding + enemy.radius + 3) {
enemy.phase = 'charging'
enemy.flightStep = 'across'
enemy.chargeVector = { x: 1, y: 0 }
enemy.phaseTimer = 12
enemy.telegraph = {
active: true,
start: { x: state.arena.padding, y: enemy.y },
end: { x: state.arena.width - state.arena.padding, y: enemy.y },
width: 42,
}
}
return
}
if (enemy.phase === 'charging') {
enemy.flightStep = 'across'
enemy.telegraph.active = true
enemy.x += getChargeSpeed(enemy.kind) * deltaSeconds
enemy.y = enemy.flightY
clampToArena(state, enemy)
state.message = `${enemy.name} dives across the arena.`
if (enemy.x >= state.arena.width - state.arena.padding - enemy.radius - 3) {
enemy.phase = 'recovering'
enemy.flightStep = 'toTank'
enemy.telegraph.active = false
}
return
}
if (enemy.phase === 'recovering') {
enemy.flightStep = 'toTank'
const tank = findTarget(state, 'tank') ?? state.player
moveEnemyTowardPoint(state, enemy, tank, getChargeSpeed(enemy.kind) * deltaSeconds)
state.message = `${enemy.name} returns to ${tank.name}.`
if (distance(enemy, tank) <= enemy.radius + tank.radius + 18) {
enemy.phase = 'mauling'
enemy.flightStep = 'ground'
enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
enemy.tankSwingTimer = 0.35
}
}
}
function updateCyberDragon(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) {
updateSpinningBladeSpawner(state, enemy, deltaSeconds)
const circularCharge = getEnabledActionAttack(enemy.kind, 'circularCharge')
enemy.slamTelegraph.active = false
if (enemy.phase === 'tracking' || enemy.phase === 'mauling') {
enemy.telegraph.active = false
enemy.arcTelegraph.active = false
enemy.targetId = getTankTargetId(state, enemy)
const tank = findTarget(state, enemy.targetId) ?? state.player
driftTowardTarget(state, enemy, tank, deltaSeconds)
enemy.tankSwingTimer -= deltaSeconds
state.message = `Cyber Dragon claws ${tank.name}.`
if (enemy.tankSwingTimer <= 0) {
applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind)))
tank.invulnerableTimer = 0.12
state.lastHit = tank.id === 'player' ? 'player' : 'party'
enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind)
}
if (enemy.phaseTimer <= 0) {
if (circularCharge) startCircularCharge(state, enemy)
else enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
}
return
}
if (enemy.phase === 'circling') {
enemy.telegraph.active = false
enemy.orbitAngle += Math.PI * 1.7 * deltaSeconds
const center = getArenaCenter(state)
const radius = circularCharge?.radius ?? CYBER_CIRCLE_RADIUS
syncCircularChargeTelegraph(enemy, center, radius)
enemy.x = center.x + Math.cos(enemy.orbitAngle) * radius
enemy.y = center.y + Math.sin(enemy.orbitAngle) * radius
clampToArena(state, enemy)
state.message = 'Cyber Dragon circles the room.'
if (enemy.phaseTimer <= 0) startCyberChargeWindup(state, enemy)
return
}
if (enemy.phase === 'windup') {
enemy.arcTelegraph.active = false
enemy.telegraph.active = true
state.message = 'Cyber Dragon locks a charge lane.'
if (enemy.phaseTimer <= 0) {
enemy.phase = 'charging'
enemy.phaseTimer = CYBER_CHARGE_SECONDS
state.message = 'Cyber Dragon charges.'
}
return
}
if (enemy.phase === 'charging') {
enemy.arcTelegraph.active = false
enemy.telegraph.active = true
enemy.x += enemy.chargeVector.x * getChargeSpeed(enemy.kind) * deltaSeconds
enemy.y += enemy.chargeVector.y * getChargeSpeed(enemy.kind) * deltaSeconds
clampToArena(state, enemy)
if (enemy.phaseTimer <= 0 || hitArenaWall(state, enemy)) {
enemy.phase = 'recovering'
enemy.phaseTimer = getRecoverSeconds(enemy.kind)
enemy.telegraph.active = false
state.message = 'Cyber Dragon vents heat.'
}
return
}
if (enemy.phase === 'recovering') {
enemy.telegraph.active = false
enemy.arcTelegraph.active = false
state.message = 'Cyber Dragon vents heat.'
if (enemy.phaseTimer <= 0) {
enemy.phase = 'mauling'
enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
enemy.tankSwingTimer = 0.35
}
}
}
function updateSpinningBladeSpawner(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) {
const attack = getEnabledActionAttack(enemy.kind, 'spinningBlade')
if (!attack || enemy.hp <= 0) return
const timer = (enemy.mechanicTimers.spinningBlade ?? attack.frequencySeconds) - deltaSeconds
if (timer > 0) {
enemy.mechanicTimers.spinningBlade = timer
return
}
spawnSpinningBlade(state, enemy, attack)
enemy.mechanicTimers.spinningBlade = attack.frequencySeconds
}
function spawnSpinningBlade(state: BulldromeState, enemy: EnemyState, attack: NonNullable<ReturnType<typeof getEnabledActionAttack>>) {
const target = chooseRandomLivingTarget(state)
if (!target) return
state.spinningBlades.push({
id: `spinning-blade-${state.nextHazardId}`,
sourceKind: enemy.kind,
x: enemy.x,
y: enemy.y,
radius: attack.radius ?? CYBER_BLADE_RADIUS,
speed: attack.speed ?? CYBER_BLADE_SPEED,
remaining: attack.recoverSeconds ?? attack.durationSeconds ?? CYBER_BLADE_SECONDS,
targetId: target.id,
rotation: 0,
})
state.nextHazardId += 1
state.message = `Spinning blade targets ${target.name}.`
}
function updateSpinningBlades(state: BulldromeState, deltaSeconds: number) {
const nextBlades: SpinningBladeState[] = []
for (const blade of state.spinningBlades) {
const attack = getEnabledActionAttack(blade.sourceKind, 'spinningBlade')
if (!attack) continue
const next = {
...blade,
remaining: blade.remaining - deltaSeconds,
rotation: blade.rotation + deltaSeconds * 9,
}
if (next.remaining <= 0) continue
let target = findTarget(state, next.targetId)
if (!target || target.hp <= 0) {
target = chooseRandomLivingTarget(state)
if (target) next.targetId = target.id
}
if (target) {
const dx = target.x - next.x
const dy = target.y - next.y
const length = Math.hypot(dx, dy)
if (length > 1) {
const move = Math.min(length, next.speed * deltaSeconds)
next.x += (dx / length) * move
next.y += (dy / length) * move
}
}
for (const unit of getTargetableUnits(state)) {
if (unit.hp <= 0 || distance(unit, next) > unit.radius + next.radius) continue
applyDamage(unit, scaleDamageForDifficulty(state, attack.damage) * deltaSeconds)
applyBleed(unit)
state.lastHit = unit.id === 'player' ? 'player' : 'party'
state.message = `${unit.name} is clipped by a spinning blade.`
}
nextBlades.push(next)
}
state.spinningBlades = nextBlades
}
function applyBleed(unit: TargetableState) {
unit.bleedTimer = CYBER_BLEED_SECONDS
unit.bleedTickTimer = Math.max(0.15, Math.min(unit.bleedTickTimer || CYBER_BLEED_TICK_SECONDS, CYBER_BLEED_TICK_SECONDS))
}
function chooseRandomLivingTarget(state: BulldromeState) {
const living = getTargetableUnits(state).filter((unit) => unit.hp > 0)
if (living.length <= 0) return undefined
return living[Math.floor(Math.random() * living.length)]
}
function startCircularCharge(state: BulldromeState, enemy: EnemyState) {
const attack = getEnabledActionAttack(enemy.kind, 'circularCharge')
const center = getArenaCenter(state)
enemy.phase = 'circling'
enemy.phaseTimer = attack?.windupSeconds ?? CYBER_CIRCLE_SECONDS
enemy.orbitAngle = Math.atan2(enemy.y - center.y, enemy.x - center.x)
enemy.telegraph.active = false
syncCircularChargeTelegraph(enemy, center, attack?.radius ?? CYBER_CIRCLE_RADIUS)
state.message = 'Cyber Dragon starts a circular charge.'
}
function syncCircularChargeTelegraph(enemy: EnemyState, center: Point, radius: number) {
enemy.arcTelegraph = {
active: true,
center,
radius,
startAngle: enemy.orbitAngle,
endAngle: enemy.orbitAngle + Math.PI,
width: 72,
}
}
function startCyberChargeWindup(state: BulldromeState, enemy: EnemyState) {
const target = selectNearestTarget(state, enemy) ?? state.player
const dx = target.x - enemy.x
const dy = target.y - enemy.y
const length = Math.max(1, Math.hypot(dx, dy))
const chargeVector = { x: dx / length, y: dy / length }
const end = projectToArenaWall(state, enemy, chargeVector)
enemy.phase = 'windup'
enemy.phaseTimer = 0.42
enemy.targetId = target.id
enemy.chargeVector = chargeVector
enemy.telegraph = {
active: true,
start: { x: enemy.x, y: enemy.y },
end,
width: 72,
}
enemy.chargeCount += 1
}
function selectNearestTarget(state: BulldromeState, enemy: EnemyState) {
return getTargetableUnits(state)
.filter((unit) => unit.hp > 0)
.sort((a, b) => distance(a, enemy) - distance(b, enemy))[0] ?? null
}
function getArenaCenter(state: BulldromeState): Point {
return { x: state.arena.width / 2, y: state.arena.height / 2 }
}
function startBirdFlight(state: BulldromeState, enemy: EnemyState) {
enemy.phase = 'windup'
enemy.flightStep = 'toLeft'
enemy.telegraph.active = true
enemy.telegraph = {
active: true,
start: { x: state.arena.padding, y: enemy.flightY },
end: { x: state.arena.width - state.arena.padding, y: enemy.flightY },
width: 42,
}
state.message = `${enemy.name} starts a dive path.`
}
function startTankFocus(enemy: EnemyState) {
enemy.phase = 'mauling'
enemy.phaseTimer = getTankFocusSeconds(enemy.kind)
enemy.targetId = 'tank'
enemy.tankSwingTimer = 0.35
enemy.telegraph.active = false
enemy.slamTelegraph.active = false
}
function startSlam(enemy: EnemyState) {
const slam = getEnabledActionAttack(enemy.kind, 'groundSlam')
enemy.phase = 'slamWindup'
enemy.phaseTimer = slam?.windupSeconds ?? BOSS_SLAM_CAST_SECONDS
enemy.slamTelegraph.radius = slam?.radius ?? BOSS_SLAM_RADIUS
enemy.slamTelegraph.active = true
enemy.telegraph.active = false
}
function resolveGroundSlam(state: BulldromeState, enemy: EnemyState) {
const slam = getEnabledActionAttack(enemy.kind, 'groundSlam')
if (!slam) return
let hitCount = 0
for (const unit of getTargetableUnits(state)) {
if (unit.hp <= 0 || distance(unit, enemy) > (slam.radius ?? BOSS_SLAM_RADIUS)) continue
applyDamage(unit, scaleDamageForDifficulty(state, slam.damage))
unit.stunTimer = 0.35
unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds
hitCount += 1
}
state.lastHit = hitCount > 0 ? 'party' : null
state.message = hitCount > 0 ? `Ground slam hit ${hitCount}.` : 'Ground slam missed.'
}
function resolvePartyDamage(state: BulldromeState, deltaSeconds: number) {
if (state.result !== 'playing') return
for (const member of state.party) {
if (member.hp <= 0 || member.stunTimer > 0) continue
const target = getAiTargetForMember(state, member)
if (!target) continue
const enemyDistance = distance(member, target)
let damage = 0
if (member.role === 'tank' && enemyDistance <= target.radius + 58) damage = TANK_DPS * deltaSeconds
if (member.role === 'melee' && enemyDistance <= target.radius + 72) damage = MELEE_DPS * deltaSeconds
if (member.role === 'ranged' && enemyDistance <= RANGED_ATTACK_RANGE) damage = RANGED_DPS * deltaSeconds
if (damage <= 0) continue
damageEnemy(state, target, damage, member.id)
if (target.hp <= 0) {
target.phase = 'defeated'
target.telegraph.active = false
target.slamTelegraph.active = false
state.message = `${target.name} defeated.`
}
}
}
function updateFireHazards(state: BulldromeState, deltaSeconds: number) {
state.fireSpots = state.fireSpots
.map((spot) => ({ ...spot, remaining: spot.remaining - deltaSeconds }))
.filter((spot) => spot.remaining > 0)
const nextFireballs: FireballState[] = []
for (const fireball of state.fireballs) {
const next = { ...fireball }
next.x += next.vx * deltaSeconds
next.y += next.vy * deltaSeconds
let bounced = false
const minX = state.arena.padding + next.radius
const maxX = state.arena.width - state.arena.padding - next.radius
const minY = state.arena.padding + next.radius
const maxY = state.arena.height - state.arena.padding - next.radius
if (next.x <= minX || next.x >= maxX) {
next.x = Math.max(minX, Math.min(maxX, next.x))
next.vx *= -1
bounced = true
}
if (next.y <= minY || next.y >= maxY) {
next.y = Math.max(minY, Math.min(maxY, next.y))
next.vy *= -1
bounced = true
}
if (bounced) {
addFireSpot(state, next.x, next.y)
}
for (const unit of getTargetableUnits(state)) {
if (unit.hp <= 0 || unit.invulnerableTimer > 0 || distance(unit, next) > unit.radius + next.radius) continue
applyDamage(unit, scaleDamageForDifficulty(state, getAttackDamage('yian-kut-ku', 'fireballVolley', FIREBALL_DAMAGE)))
igniteUnit(unit)
addFireSpot(state, next.x, next.y)
bounceFireballOffUnit(next, unit)
unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds
state.lastHit = unit.id === 'player' ? 'player' : 'party'
state.message = `${unit.name} was hit by a fireball.`
}
nextFireballs.push(next)
}
state.fireballs = nextFireballs
for (const spot of state.fireSpots) {
for (const unit of getTargetableUnits(state)) {
if (unit.hp <= 0 || unit.burnTimer > 0 || distance(unit, spot) > unit.radius + spot.radius) continue
igniteUnit(unit)
state.lastHit = unit.id === 'player' ? 'player' : 'party'
state.message = `${unit.name} is standing in fire.`
}
}
}
function bounceFireballOffUnit(fireball: FireballState, unit: TargetableState) {
const dx = fireball.x - unit.x
const dy = fireball.y - unit.y
const length = Math.max(1, Math.hypot(dx, dy))
const nx = dx / length
const ny = dy / length
const dot = fireball.vx * nx + fireball.vy * ny
fireball.vx -= 2 * dot * nx
fireball.vy -= 2 * dot * ny
fireball.x = unit.x + nx * (unit.radius + fireball.radius + 2)
fireball.y = unit.y + ny * (unit.radius + fireball.radius + 2)
}
function addFireSpot(state: BulldromeState, x: number, y: number) {
state.fireSpots.push({
id: `firespot-${state.nextHazardId}`,
x,
y,
radius: 34,
remaining: FIRE_SPOT_SECONDS,
})
state.nextHazardId += 1
if (state.fireSpots.length > MAX_FIRE_SPOTS) {
state.fireSpots = state.fireSpots.slice(-MAX_FIRE_SPOTS)
}
}
function igniteUnit(unit: TargetableState) {
unit.burnTimer = FIRE_DOT_SECONDS
unit.burnTickTimer = FIRE_DOT_TICK_SECONDS
}
function getAiTargetForMember(state: BulldromeState, member: PartyMemberState): EnemyState | null {
const enemies = getLivingEnemies(state)
if (enemies.length === 0) return null
if (member.role === 'tank') return getPrimaryTankEnemy(state)
void member
return state.boss.hp > 0 ? state.boss : enemies[0]
}
function getPrimaryTankEnemy(state: BulldromeState): EnemyState | null {
const enemies = getLivingEnemies(state)
return state.boss.hp > 0 ? state.boss : enemies[0] ?? null
}
function getTankTargetId(state: BulldromeState, enemy: EnemyState) {
const tank = findTarget(state, 'tank')
return tank && tank.hp > 0 ? tank.id : selectChargeTarget(state, enemy)
}
function driftTowardTarget(
state: BulldromeState,
enemy: EnemyState,
target: TargetableState,
deltaSeconds: number,
) {
const dx = target.x - enemy.x
const dy = target.y - enemy.y
const length = Math.hypot(dx, dy)
if (length <= 1) return
enemy.chargeVector = { x: dx / length, y: dy / length }
const driftSpeed = (enemy.kind === 'bulldrome' ? 82 : enemy.kind === 'cyber-dragon' ? 74 : enemy.kind === 'yian-kut-ku' ? 72 : 62) * deltaSeconds
enemy.x += (dx / length) * driftSpeed
enemy.y += (dy / length) * driftSpeed
clampToArena(state, enemy)
}
function moveEnemyTowardPoint(state: BulldromeState, enemy: EnemyState, point: Point, maxDistance: number) {
const dx = point.x - enemy.x
const dy = point.y - enemy.y
const length = Math.hypot(dx, dy)
if (length <= 2) return
const distanceThisFrame = Math.min(length, maxDistance)
enemy.x += (dx / length) * distanceThisFrame
enemy.y += (dy / length) * distanceThisFrame
clampToArena(state, enemy)
}
function startWindup(state: BulldromeState, enemy: EnemyState) {
const target = findTarget(state, enemy.targetId) ?? state.player
const dx = target.x - enemy.x
const dy = target.y - enemy.y
const length = Math.max(1, Math.hypot(dx, dy))
const chargeVector = { x: dx / length, y: dy / length }
const end = projectToArenaWall(state, enemy, chargeVector)
enemy.phase = 'windup'
enemy.phaseTimer = getWindupSeconds(enemy.kind)
enemy.chargeVector = chargeVector
enemy.telegraph = {
active: true,
start: { x: enemy.x, y: enemy.y },
end,
width: enemy.kind === 'bulldrome' ? 86 : 48,
}
enemy.chargeCount += 1
}
function selectChargeTarget(state: BulldromeState, enemy: EnemyState) {
const living = getTargetableUnits(state).filter((unit) => unit.hp > 0)
const preferred = enemy.kind === 'bulldrome' || enemy.kind === 'cyber-dragon'
? ['player', 'tank', 'melee-1', 'ranged-1', 'ranged-2']
: ['tank', 'melee-1', 'player', 'ranged-1', 'ranged-2']
const offset = enemy.chargeCount % preferred.length
const ordered = preferred.slice(offset).concat(preferred.slice(0, offset))
return ordered.find((id) => living.some((unit) => unit.id === id)) ?? 'player'
}
function resolveContact(state: BulldromeState) {
for (const enemy of getLivingEnemies(state)) {
for (const unit of getTargetableUnits(state)) {
const overlap = unit.hp > 0 && distance(unit, enemy) < unit.radius + enemy.radius
if (!overlap || unit.invulnerableTimer > 0) continue
const isCharge = enemy.phase === 'charging' || enemy.phase === 'circling'
applyDamage(unit, scaleDamageForDifficulty(state, isCharge ? getChargeDamage(enemy.kind) : getBodyDamage(enemy.kind)))
unit.stunTimer = isCharge && enemy.kind === 'cyber-dragon' ? CYBER_CHARGE_STUN_SECONDS : isCharge ? ACTION_CONTROL_RULES.stunSeconds : 0.25
unit.invulnerableTimer = ACTION_CONTROL_RULES.invulnerableSeconds
state.lastHit = unit.id === 'player' ? 'player' : 'party'
state.message = isCharge
? `${unit.name} ate ${enemy.name}'s charge.`
: `${unit.name} was trampled.`
const dx = unit.x - enemy.x
const dy = unit.y - enemy.y
const length = Math.max(1, Math.hypot(dx, dy))
unit.x += (dx / length) * 34
unit.y += (dy / length) * 34
clampToArena(state, unit)
}
}
}
function getDungeonSpell(state: BulldromeState, spell: SpellSlot): SpellDefinition {
return getActionSpellDefinition(state.player.classId, spell, state.player.talentModifiers)
}
function startPlayerSpell(state: BulldromeState, spell: SpellSlot | null) {
if (!spell || state.player.currentCast || state.player.globalCooldown > 0 || state.player.spellCooldowns[spell] > 0 || state.player.stunTimer > 0) return
const target = findCombatTarget(state, state.targetId)
if (!target || target.hp <= 0) {
state.message = 'No living target selected.'
return
}
const damageSpell = isDungeonDamageSpell(state, spell)
const enemyTarget = isEnemyTarget(state, target)
if (damageSpell && !enemyTarget) {
showNotice(state, state.player.id, 'Enemy target')
state.message = `${getDungeonSpell(state, spell).name} requires an enemy target.`
return
}
if (!damageSpell && enemyTarget) {
showNotice(state, state.player.id, 'Ally target')
state.message = `${getDungeonSpell(state, spell).name} requires a friendly target.`
return
}
const reach = getDungeonSpellReach(state, target, spell, { includeTargetRadius: damageSpell })
if (!reach.ok) {
setDungeonSpellReachFailure(state, reach)
return
}
const spend = spendActionSpellMana(state.player, state.player.classId, spell, { talentMods: state.player.talentModifiers })
if (!spend.ok) {
showNotice(state, state.player.id, 'Mana')
state.message = `Need ${spend.cost} mana.`
return
}
const definition = getDungeonSpell(state, spell)
triggerPlayerGlobalCooldown(state)
if (definition.castTime > 0) {
state.player.currentCast = {
spell,
targetId: target.id,
remaining: definition.castTime,
total: definition.castTime,
}
state.message = `Casting ${definition.name} on ${target.name}.`
return
}
completePlayerSpell(state, spell, target.id)
}
function completePlayerSpell(state: BulldromeState, spell: SpellSlot, targetId: string) {
const target = findCombatTarget(state, targetId)
if (!target || target.hp <= 0) {
state.message = 'Spell fizzled. Target is down.'
return
}
const damageSpell = isDungeonDamageSpell(state, spell)
const enemyTarget = isEnemyTarget(state, target)
if (damageSpell && !enemyTarget) {
state.message = `${getDungeonSpell(state, spell).name} requires an enemy target.`
return
}
if (!damageSpell && enemyTarget) {
state.message = `${getDungeonSpell(state, spell).name} requires a friendly target.`
return
}
const reach = getDungeonSpellReach(state, target, spell, { includeTargetRadius: damageSpell })
if (!reach.ok) {
setDungeonSpellReachFailure(state, reach)
return
}
state.player.spellCooldowns[spell] = getDungeonSpell(state, spell).cooldown
recordSpellCast(state.player)
if (damageSpell) {
completePlayerDamageSpell(state, spell, target as EnemyState)
return
}
const friendlyTarget = target as TargetableState
const ability = getArenaAbility(getDungeonSpell(state, spell).abilityId)
if (ability.shield || ability.kind === 'buff') {
const shield = getActionTalentAdjustedAmount(state.player.classId, spell, ability.shield ?? (ability.id === 'blessing_of_might' ? 80 : 90), 'shield', state.player.talentModifiers)
addShield(friendlyTarget, shield, { cap: Math.max(shield, friendlyTarget.maxHp) })
addHealEvent(state, friendlyTarget, 0)
state.lastHit = 'heal'
state.message = `${ability.name} protected ${friendlyTarget.name}.`
return
}
const healOverTimeId = getHealOverTimeEffectId(ability.id)
if (healOverTimeId) {
applyHealOverTimeTimers(friendlyTarget, healOverTimeId)
healTarget(state, friendlyTarget, getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 28, 'heal', state.player.talentModifiers))
state.lastHit = 'heal'
state.message = `${ability.name} on ${friendlyTarget.name}.`
return
}
healTarget(state, friendlyTarget, getActionTalentAdjustedAmount(state.player.classId, spell, ability.heal ?? 120, 'heal', state.player.talentModifiers))
addHealEvent(state, friendlyTarget, 0)
state.lastHit = 'heal'
state.message = `${ability.name} healed ${friendlyTarget.name}.`
}
function completePlayerDamageSpell(state: BulldromeState, spell: SpellSlot, target: EnemyState) {
const damage = getDungeonDamageSpellAmount(state, spell)
damageEnemy(state, target, damage, state.player.id)
state.lastHit = 'boss'
const spellDef = getDungeonSpell(state, spell)
state.message = `${spellDef.name} hit ${target.name}.`
if (spellDef.abilityId !== 'smite') return
const ally = getTargetableUnits(state)
.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && isInHealRange(state, unit, spell))
.sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0]
if (!ally) return
healTarget(state, ally, damage * 0.5)
addHealEvent(state, ally, 0)
state.lastHit = 'heal'
state.message = `Smite hit ${target.name} and healed ${ally.name}.`
}
function getDungeonDamageSpellAmount(state: BulldromeState, spell: SpellSlot) {
const ability = getArenaAbility(getDungeonSpell(state, spell).abilityId)
const dotPulse = ability.dot ? ability.dot.damage / Math.max(1, ability.dot.seconds / 3) : 0
return getActionTalentAdjustedAmount(state.player.classId, spell, (ability.damage ?? 0) + dotPulse, 'damage', state.player.talentModifiers)
}
function isDungeonDamageSpell(state: BulldromeState, spell: SpellSlot) {
return getActionSpellTarget(state.player.classId, spell) === 'Enemy target'
}
function healTarget(state: BulldromeState, target: TargetableState, amount: number) {
const before = target.hp
target.hp = Math.min(target.maxHp, target.hp + amount)
const healed = target.hp - before
if (healed <= 0) return
addHealEvent(state, target, healed)
}
function addHealEvent(state: BulldromeState, target: TargetableState, amount: number) {
if (state.healEvents.some((event) => event.targetId === target.id)) return
state.healEvents.push({
id: `${state.elapsed.toFixed(3)}-${target.id}-${state.healEvents.length}`,
targetId: target.id,
amount,
})
}
function isInHealRange(state: BulldromeState, target: TargetableState, spell: SpellSlot) {
return getDungeonSpellReach(state, target, spell).ok
}
function getDungeonSpellReach(
state: BulldromeState,
target: CombatSpellTarget,
spell: SpellSlot,
options?: { includeTargetRadius?: boolean },
) {
return getActionSpellReach({
caster: state.player,
target,
classId: state.player.classId,
spell,
talentMods: state.player.talentModifiers,
includeTargetRadius: options?.includeTargetRadius,
hasLineOfSight: () => hasDungeonLineOfSight(),
})
}
function hasDungeonLineOfSight() {
return true
}
function setDungeonSpellReachFailure(state: BulldromeState, reach: ActionSpellReachResult) {
if (reach.ok) return
const blocked = reach.reason === 'lineOfSight'
showNotice(state, state.player.id, blocked ? 'Blocked' : 'Range')
state.message = blocked ? 'Line of sight blocked.' : 'Not in range.'
}
function showNotice(state: BulldromeState, targetId: string, text: string) {
state.noticeEvents.push({
id: `${state.elapsed.toFixed(3)}-${targetId}-${state.noticeEvents.length}-${text}`,
targetId,
text,
})
}
function createDamageDoneTracker(): Record<string, number> {
return {
player: 0,
tank: 0,
'melee-1': 0,
'ranged-1': 0,
'ranged-2': 0,
}
}
function damageEnemy(state: BulldromeState, target: EnemyState, amount: number, sourceId: string) {
const before = target.hp
target.hp = Math.max(0, target.hp - amount)
const dealt = Math.max(0, before - target.hp)
if (dealt > 0) state.damageDoneByUnit[sourceId] = (state.damageDoneByUnit[sourceId] ?? 0) + dealt
}
function applyDamage(unit: TargetableState, amount: number) {
applyShieldedDamage(unit, amount)
}
function getDamagingEffectAvoidance(state: BulldromeState, member: PartyMemberState): { point: Point, moveSpeed: number } | null {
let escapeX = 0
let escapeY = 0
let moveSpeed = AI_MOVE_SPEED
let avoidDistance = 1
const fireAreaAvoidance = getActionAiAvoidance('yian-kut-ku', 'fireballVolley', 'persistentArea')
for (const spot of state.fireSpots) {
if (!fireAreaAvoidance) continue
const avoidRadius = member.radius + spot.radius + fireAreaAvoidance.buffer
addRepulsionFromPoint(member, spot, fireAreaAvoidance, (avoidRadius - distance(member, spot)) / avoidRadius)
}
const fireballAvoidance = getActionAiAvoidance('yian-kut-ku', 'fireballVolley', 'projectile')
for (const fireball of state.fireballs) {
if (!fireballAvoidance) continue
const avoidRadius = member.radius + fireball.radius + fireballAvoidance.buffer
addRepulsionFromPoint(member, fireball, fireballAvoidance, (avoidRadius - distance(member, fireball)) / avoidRadius)
}
for (const blade of state.spinningBlades) {
const bladeAvoidance = getActionAiAvoidance(blade.sourceKind, 'spinningBlade', 'persistentArea')
if (!bladeAvoidance) continue
const avoidRadius = member.radius + blade.radius + bladeAvoidance.buffer
addRepulsionFromPoint(member, blade, bladeAvoidance, (avoidRadius - distance(member, blade)) / avoidRadius)
}
for (const enemy of getLivingEnemies(state)) {
const slamAvoidance = getActionAiAvoidance(enemy.kind, 'groundSlam', 'circleTelegraph')
if (enemy.slamTelegraph.active) {
if (slamAvoidance) {
const avoidRadius = enemy.slamTelegraph.radius + member.radius + slamAvoidance.buffer
addRepulsionFromPoint(member, enemy, slamAvoidance, (avoidRadius - distance(member, enemy)) / avoidRadius)
}
}
const lineAttackKind: ActionAttackKind = enemy.kind === 'bird'
? 'birdDive'
: enemy.kind === 'cyber-dragon'
? 'circularCharge'
: 'charge'
const lineAvoidance = getActionAiAvoidance(enemy.kind, lineAttackKind, 'lineTelegraph')
if (
lineAvoidance
&& enemy.telegraph.active
&& enemy.phase === 'windup'
&& isPointNearSegment(
member,
enemy.telegraph.start,
enemy.telegraph.end,
enemy.telegraph.width * (lineAvoidance.lineWidthMultiplier ?? 0.42) + lineAvoidance.buffer,
)
) {
const perpendicular = { x: -enemy.chargeVector.y, y: enemy.chargeVector.x }
const side = member.aiSlot % 2 === 0 ? 1 : -1
escapeX += perpendicular.x * side
escapeY += perpendicular.y * side
moveSpeed = Math.max(moveSpeed, lineAvoidance.moveSpeed)
avoidDistance = Math.max(avoidDistance, lineAvoidance.avoidDistance)
}
}
const escapeLength = Math.hypot(escapeX, escapeY)
if (escapeLength <= 0.01) return null
return {
point: clampPointToArena(state, {
x: member.x + (escapeX / escapeLength) * avoidDistance,
y: member.y + (escapeY / escapeLength) * avoidDistance,
}, member.radius),
moveSpeed,
}
function addRepulsionFromPoint(
unit: TargetableState,
hazard: Point,
avoidance: ActionAiAvoidanceConfig,
weight: number,
) {
if (weight <= 0) return
moveSpeed = Math.max(moveSpeed, avoidance.moveSpeed)
avoidDistance = Math.max(avoidDistance, avoidance.avoidDistance)
const dx = unit.x - hazard.x
const dy = unit.y - hazard.y
const length = Math.hypot(dx, dy)
if (length <= 0.01) {
const angle = member.aiSlot * 1.7
escapeX += Math.cos(angle) * weight
escapeY += Math.sin(angle) * weight
return
}
escapeX += (dx / length) * weight
escapeY += (dy / length) * weight
}
}
function isPointNearSegment(point: Point, start: Point, end: Point, width: number) {
const dx = end.x - start.x
const dy = end.y - start.y
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= 0) return distance(point, start) <= width
const t = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared))
const projected = {
x: start.x + dx * t,
y: start.y + dy * t,
}
return distance(point, projected) <= width
}
function resolveResult(state: BulldromeState) {
if (state.respawnTimer > 0) return
const livingEnemies = getLivingEnemies(state)
if (livingEnemies.length <= 0) {
if (state.runMode === 'marathon') {
state.bossKills += 1
state.respawnTimer = 5
state.fireballs = []
state.fireSpots = []
state.spinningBlades = []
state.damageDoneByUnit = createDamageDoneTracker()
state.message = `Boss ${state.bossKills} down. Next boss in 5.`
return
}
state.result = 'win'
state.message = state.dungeonId === 'cyber-dragon'
? 'Cyber Dragon disabled. Hunt complete.'
: state.dungeonId === 'yian-kut-ku'
? 'Yian Kut-Ku defeated. Hunt complete.'
: 'Bulldrome broken. Hunt complete.'
}
if (state.player.hp <= 0) {
for (const enemy of getLivingEnemies(state)) {
enemy.phase = 'victory'
enemy.telegraph.active = false
enemy.slamTelegraph.active = false
}
state.result = 'loss'
state.message = 'You were carted. Reset and keep yourself alive.'
}
}
function respawnBossEncounter(state: BulldromeState) {
state.encounterStep = 'boss'
if (state.dungeonId === 'cyber-dragon') {
state.boss = createCyberDragon()
state.adds = []
scaleEnemyForDifficulty(state.difficulty, state.boss)
state.message = 'Marathon continues. Cyber Dragon reboots.'
} else if (state.dungeonId === 'yian-kut-ku') {
state.boss = createYianKutKu()
state.adds = []
scaleEnemyForDifficulty(state.difficulty, state.boss)
state.message = 'Marathon continues. Yian Kut-Ku returns.'
} else {
state.boss = createBulldrome()
state.adds = []
scaleEnemyForDifficulty(state.difficulty, state.boss)
state.message = 'Marathon continues. Bulldrome returns.'
}
state.fireballs = []
state.fireSpots = []
state.spinningBlades = []
state.respawnTimer = 0
}
function findTarget(state: BulldromeState, id: string) {
return getTargetableUnits(state).find((unit) => unit.id === id)
}
function findEnemyTarget(state: BulldromeState, id: string) {
return getAllEnemies(state).find((enemy) => enemy.id === id)
}
function findCombatTarget(state: BulldromeState, id: string): CombatSpellTarget | undefined {
return findTarget(state, id) ?? findEnemyTarget(state, id)
}
function isEnemyTarget(state: BulldromeState, target: CombatSpellTarget): target is EnemyState {
return getAllEnemies(state).some((enemy) => enemy.id === target.id)
}
function setTarget(state: BulldromeState, id: string) {
const target = findCombatTarget(state, id)
if (target) state.targetId = target.id
}
function cycleTarget(state: BulldromeState, delta: -1 | 1) {
const frames = getRaidFrames(state)
const currentIndex = Math.max(0, frames.findIndex((frame) => frame.id === state.targetId))
const nextIndex = (currentIndex + delta + frames.length) % frames.length
state.targetId = frames[nextIndex].id
}
function clampToArena(state: BulldromeState, fighter: FighterState) {
fighter.x = Math.max(
state.arena.padding + fighter.radius,
Math.min(state.arena.width - state.arena.padding - fighter.radius, fighter.x),
)
fighter.y = Math.max(
state.arena.padding + fighter.radius,
Math.min(state.arena.height - state.arena.padding - fighter.radius, fighter.y),
)
}
function clampPointToArena(state: BulldromeState, point: Point, radius: number): Point {
const clamped = {
x: Math.max(
state.arena.padding + radius,
Math.min(state.arena.width - state.arena.padding - radius, point.x),
),
y: Math.max(
state.arena.padding + radius,
Math.min(state.arena.height - state.arena.padding - radius, point.y),
),
}
return clamped
}
function hitArenaWall(state: BulldromeState, fighter: FighterState) {
return (
fighter.x <= state.arena.padding + fighter.radius + 1
|| fighter.x >= state.arena.width - state.arena.padding - fighter.radius - 1
|| fighter.y <= state.arena.padding + fighter.radius + 1
|| fighter.y >= state.arena.height - state.arena.padding - fighter.radius - 1
)
}
function projectToArenaWall(state: BulldromeState, start: Point, vector: Point): Point {
const minX = state.arena.padding
const maxX = state.arena.width - state.arena.padding
const minY = state.arena.padding
const maxY = state.arena.height - state.arena.padding
const candidates: number[] = []
if (vector.x > 0) candidates.push((maxX - start.x) / vector.x)
if (vector.x < 0) candidates.push((minX - start.x) / vector.x)
if (vector.y > 0) candidates.push((maxY - start.y) / vector.y)
if (vector.y < 0) candidates.push((minY - start.y) / vector.y)
const distanceToWall = Math.min(...candidates.filter((candidate) => candidate > 0))
return {
x: start.x + vector.x * distanceToWall,
y: start.y + vector.y * distanceToWall,
}
}
function getWindupSeconds(kind: EnemyKind) {
return getEnemyWindupSeconds(kind)
}
function getChargeSeconds(kind: EnemyKind) {
return getEnemyChargeSeconds(kind)
}
function getChargeSpeed(kind: EnemyKind) {
return getEnemyChargeSpeed(kind)
}
function getRecoverSeconds(kind: EnemyKind) {
return getEnemyRecoverSeconds(kind)
}
function getTankFocusSeconds(kind: EnemyKind) {
return getEnemyTankFocusSeconds(kind)
}
function getTankSwingSeconds(kind: EnemyKind) {
return getEnemyTankSwingSeconds(kind)
}
function getTankSwingDamage(kind: EnemyKind) {
return getEnemyTankSwingDamage(kind)
}
function getChargeDamage(kind: EnemyKind) {
return getEnemyChargeDamage(kind)
}
function getBodyDamage(kind: EnemyKind) {
return getEnemyBodyDamage(kind)
}
function getTrackSeconds(kind: EnemyKind) {
return getEnemyTrackSeconds(kind)
}
function getAttackFrequency(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) {
return getActionAttack(kind, attackKind)?.frequencySeconds ?? fallback
}
function getAttackDamage(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) {
const attack = getActionAttack(kind, attackKind)
if (!attack) return fallback
return attack.enabled ? attack.damage : 0
}
function applyDifficultyScaling(state: BulldromeState) {
scaleCurrentEnemiesForDifficulty(state)
}
function scaleCurrentEnemiesForDifficulty(state: BulldromeState) {
for (const enemy of getAllEnemies(state)) {
scaleEnemyForDifficulty(state.difficulty, enemy)
}
}
function scaleEnemyForDifficulty(difficulty: ActionDifficulty, enemy: EnemyState) {
const multiplier = getActionDifficultyTier(difficulty).healthMultiplier
enemy.maxHp = Math.ceil(enemy.maxHp * multiplier)
enemy.hp = enemy.maxHp
}
function scaleDamageForDifficulty(state: BulldromeState, damage: number) {
return Math.ceil(damage * getActionDifficultyTier(state.difficulty).damageMultiplier)
}
function cloneEnemy(enemy: EnemyState): EnemyState {
return {
...enemy,
chargeVector: { ...enemy.chargeVector },
mechanicTimers: { ...enemy.mechanicTimers },
arcTelegraph: {
active: enemy.arcTelegraph.active,
center: { ...enemy.arcTelegraph.center },
radius: enemy.arcTelegraph.radius,
startAngle: enemy.arcTelegraph.startAngle,
endAngle: enemy.arcTelegraph.endAngle,
width: enemy.arcTelegraph.width,
},
slamTelegraph: { ...enemy.slamTelegraph },
telegraph: {
active: enemy.telegraph.active,
start: { ...enemy.telegraph.start },
end: { ...enemy.telegraph.end },
width: enemy.telegraph.width,
},
}
}
function cloneState(state: BulldromeState): BulldromeState {
return {
dungeonId: state.dungeonId,
difficulty: state.difficulty,
runMode: state.runMode,
encounterStep: state.encounterStep,
arena: { ...state.arena },
player: {
...state.player,
currentCast: state.player.currentCast ? { ...state.player.currentCast } : null,
healOverTimes: state.player.healOverTimes?.map((effect) => ({ ...effect })),
spellCooldowns: { ...state.player.spellCooldowns },
},
party: state.party.map((member) => ({
...member,
healOverTimes: member.healOverTimes?.map((effect) => ({ ...effect })),
})),
targetId: state.targetId,
boss: cloneEnemy(state.boss),
adds: state.adds.map(cloneEnemy),
fireballs: state.fireballs.map((fireball) => ({ ...fireball })),
fireSpots: state.fireSpots.map((spot) => ({ ...spot })),
spinningBlades: state.spinningBlades.map((blade) => ({ ...blade })),
nextHazardId: state.nextHazardId,
damageDoneByUnit: { ...state.damageDoneByUnit },
bossKills: state.bossKills,
dungeonWaveIndex: state.dungeonWaveIndex,
dungeonWaveTotal: state.dungeonWaveTotal,
respawnTimer: state.respawnTimer,
healEvents: state.healEvents.map((event) => ({ ...event })),
noticeEvents: state.noticeEvents.map((event) => ({ ...event })),
elapsed: state.elapsed,
message: state.message,
result: state.result,
lastHit: state.lastHit,
}
}