2070 lines
66 KiB
TypeScript
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,
|
|
}
|
|
}
|