Files
i-want-to-heal/src/modes/iwt2/sim/khezuAi.ts
T
2026-07-04 17:41:45 -04:00

259 lines
8.2 KiB
TypeScript

import { KHEZU_BOSS_METADATA } from '../content/bosses'
import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicCircleState,
Iwt2PartyEntityState,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createDonutIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
clampVec2ToArena,
distanceVec2,
moveToward,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
export function tickKhezu(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withKhezuIndicators({ boss, party, events })
if (boss.attackPhase === 'thunderRingWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyThunderRing(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'thunderRingRecover',
phaseSecondsRemaining: 0.45,
}
}
return withKhezuIndicators({ boss, party, events })
}
if (boss.attackPhase === 'lightningStrikeWindup') {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyLightningStrikes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'lightningStrikeRecover',
phaseSecondsRemaining: 0.38,
}
}
return withKhezuIndicators({ boss, party, events })
}
if (boss.attackPhase === 'thunderRingRecover' || boss.attackPhase === 'lightningStrikeRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withKhezuIndicators({ boss, party, events })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'thunderRingWindup',
chargeCooldownRemaining: KHEZU_BOSS_METADATA.thunderRingCooldown!,
phaseSecondsRemaining: KHEZU_BOSS_METADATA.thunderRingWindup!,
velocity: { x: 0, y: 0 },
}
return withKhezuIndicators({ boss, party, events })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: 'lightningStrikeWindup',
fireballCooldownRemaining: KHEZU_BOSS_METADATA.lightningStrikeCooldown!,
mechanicCircles: createLightningTargets(party),
phaseSecondsRemaining: KHEZU_BOSS_METADATA.lightningStrikeWindup!,
velocity: { x: 0, y: 0 },
}
return withKhezuIndicators({ boss, party, events })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withKhezuIndicators({ boss, party, events })
const nextPosition = clampVec2ToArena(
moveToward(boss.position, target.position, KHEZU_BOSS_METADATA.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
}
return withKhezuIndicators({ boss, party, events })
}
function createLightningTargets(party: Iwt2PartyEntityState[]): Iwt2MechanicCircleState[] {
return [...party]
.filter((member) => member.health > 0)
.sort((a, b) => (a.health / a.maxHealth) - (b.health / b.maxHealth))
.slice(0, KHEZU_BOSS_METADATA.lightningStrikeCount!)
.map((member, index) => ({
id: `lightning-${index}`,
position: { ...member.position },
radius: KHEZU_BOSS_METADATA.lightningStrikeRadius!,
}))
}
function applyThunderRing(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'donut',
position: boss.position,
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
}, {
damage: KHEZU_BOSS_METADATA.thunderRingDamage!,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: KHEZU_BOSS_METADATA.thunderRingStunSeconds!,
time,
})
return { party: result.party, events: result.events }
}
function applyLightningStrikes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: KHEZU_BOSS_METADATA.lightningStrikeDamage!,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: KHEZU_BOSS_METADATA.lightningStrikeStunSeconds!,
time,
})
nextParty = result.party
events.push(...result.events)
}
return { party: nextParty, events }
}
function getBossTarget(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState | undefined {
const livingTank = party.find((member) => member.classId === 'paladin' && member.health > 0)
if (livingTank) return livingTank
return party.find((member) => member.health > 0)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
time: number,
): { boss: Iwt2BossEntityState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > KHEZU_BOSS_METADATA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: KHEZU_BOSS_METADATA.meleeRange,
}, {
damage: KHEZU_BOSS_METADATA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: KHEZU_BOSS_METADATA.meleeCooldown },
party: result.party,
events: result.events,
}
}
function withKhezuIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createKhezuIndicators(result.boss),
}
}
function createKhezuIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
if (boss.attackPhase === 'thunderRingWindup' || boss.attackPhase === 'thunderRingRecover') {
indicators.push(createDonutIndicator({
color: '#77d9ff',
id: `${boss.id}:thunder-ring`,
innerRadius: KHEZU_BOSS_METADATA.thunderRingInnerRadius!,
mechanicId: 'khezu-thunder-ring',
outerRadius: KHEZU_BOSS_METADATA.thunderRingOuterRadius!,
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'thunderRingWindup',
boss.attackPhase === 'thunderRingRecover',
),
position: boss.position,
sourceId: boss.id,
}))
}
if (boss.attackPhase === 'lightningStrikeWindup' || boss.attackPhase === 'lightningStrikeRecover') {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: '#9be8ff',
id: `${boss.id}:${circle.id}`,
mechanicId: 'khezu-lightning-strike',
phase: indicatorPhaseFromAttack(
boss.attackPhase === 'lightningStrikeWindup',
boss.attackPhase === 'lightningStrikeRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}