Files
i-want-to-heal/src/modes/iwt2/sim/cinderbackAi.ts
T
2026-07-05 22:48:56 -04:00

606 lines
19 KiB
TypeScript

import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addFirePuddle } from './hazards'
import {
applyPartyDamageInShape,
createArenaEvent,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CINDERBACK_PHASE = {
armorSlamRecover: 'cinderbackArmorSlamRecover',
armorSlamWindup: 'cinderbackArmorSlamWindup',
ricochetWindup: 'cinderbackRicochetWindup',
ricocheting: 'cinderbackRicocheting',
} as const
type CinderbackPhase = typeof CINDERBACK_PHASE[keyof typeof CINDERBACK_PHASE]
const CINDERBACK_TUNING = {
armorSlamDamage: 44,
armorSlamRadius: 118,
armorSlamRecover: 1.05,
armorSlamStunSeconds: 0.7,
armorSlamWindup: 0.62,
centerDisengageSpeed: 152,
firePuddleDamage: 15,
firePuddleRadius: 34,
firePuddleSeconds: 6.4,
lavaTrailInterval: 0.26,
meleeCooldown: 1.12,
meleeDamage: 11,
meleeRange: 58,
moveSpeed: 104,
ricochetCooldown: 7.2,
ricochetDamage: 40,
ricochetDuration: 3.1,
ricochetMaxBounces: 4,
ricochetSpeed: 470,
ricochetStunSeconds: 0.42,
ricochetWidth: 46,
ricochetWindup: 0.72,
wallContactLimit: 0.48,
wallMargin: 64,
}
export function tickCinderbackRicochet(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = cinderbackPhase(state.boss)
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: incomingPhase === CINDERBACK_PHASE.ricocheting
|| incomingPhase === CINDERBACK_PHASE.armorSlamWindup
|| incomingPhase === CINDERBACK_PHASE.armorSlamRecover
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = cinderbackPhase(boss)
if (phase === CINDERBACK_PHASE.ricochetWindup) {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricocheting),
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: 0,
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetDuration,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.ricocheting) {
const roll = moveRicochet(boss, state, dt)
const hitResult = applyRicochetHits(party, boss, boss.position, roll.position, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
const trailResult = maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position: roll.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
const bounceCount = boss.mechanicEnergy + roll.bounces
const wallContactSeconds = roll.bounces > 0 || roll.cornerBounce
? 0
: isBossNearWall({ ...boss, position: roll.position }, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2)
boss = {
...boss,
chargeEnd: nextWallImpact(roll.position, roll.facing, boss.radius, state),
chargeHitEntityIds: hitResult.hitEntityIds,
chargeStart: { ...roll.position },
facing: roll.facing,
fireballCooldownRemaining: trailResult.nextTrailInSeconds,
mechanicEnergy: bounceCount,
position: roll.position,
velocity: roll.velocity,
wallContactSeconds,
}
if (
boss.phaseSecondsRemaining <= 0
|| bounceCount >= CINDERBACK_TUNING.ricochetMaxBounces
|| wallContactSeconds >= CINDERBACK_TUNING.wallContactLimit
|| roll.cornerBounce
) {
boss = beginArmorSlam(boss, state)
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamWindup) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
const slamResult = applyArmorSlam(party, boss, state.time + dt)
const puddleResult = addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time: state.time + dt,
})
party = slamResult.party
hazards = puddleResult.hazards
nextHazardId = puddleResult.nextHazardId
events.push(...slamResult.events)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamRecover),
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamRecover,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === CINDERBACK_PHASE.armorSlamRecover) {
boss = { ...boss, velocity: { x: 0, y: 0 } }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, 1.4),
phaseSecondsRemaining: 0,
}
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= 0.68) {
boss = moveBossTowardCenter(boss, state, CINDERBACK_TUNING.centerDisengageSpeed, dt)
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const facing = wallSafeRollDirection(boss, target.position, state)
boss = {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.ricochetWindup),
chargeCooldownRemaining: CINDERBACK_TUNING.ricochetCooldown,
chargeEnd: nextWallImpact(boss.position, facing, boss.radius, state),
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
phaseSecondsRemaining: CINDERBACK_TUNING.ricochetWindup,
velocity: { x: 0, y: 0 },
}
events.push(createArenaEvent(0, state.time + dt, 'bossChargeStart', boss.id, target.id))
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, target.position, CINDERBACK_TUNING.moveSpeed * dt),
boss.radius,
state.bounds,
)
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
return withCinderbackIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginArmorSlam(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
const position = clampVec2ToArena(disengageFromWall(boss.position, boss.radius, state), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CINDERBACK_PHASE.armorSlamWindup),
chargeEnd: { ...position },
chargeStart: { ...position },
mechanicEnergy: 0,
phaseSecondsRemaining: CINDERBACK_TUNING.armorSlamWindup,
position,
velocity: { x: 0, y: 0 },
wallContactSeconds: 0,
}
}
function moveRicochet(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { bounces: number, cornerBounce: boolean, facing: Iwt2Vec2, position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const minX = boss.radius
const maxX = state.bounds.width - boss.radius
const minY = boss.radius
const maxY = state.bounds.height - boss.radius
let facing = withFallbackFacing(boss.facing, { x: 1, y: 0 })
let position = addVec2(boss.position, scaleVec2(facing, CINDERBACK_TUNING.ricochetSpeed * dt))
let xBounce = false
let yBounce = false
if (position.x < minX) {
position = { ...position, x: minX + (minX - position.x) }
facing = { ...facing, x: Math.abs(facing.x) }
xBounce = true
} else if (position.x > maxX) {
position = { ...position, x: maxX - (position.x - maxX) }
facing = { ...facing, x: -Math.abs(facing.x) }
xBounce = true
}
if (position.y < minY) {
position = { ...position, y: minY + (minY - position.y) }
facing = { ...facing, y: Math.abs(facing.y) }
yBounce = true
} else if (position.y > maxY) {
position = { ...position, y: maxY - (position.y - maxY) }
facing = { ...facing, y: -Math.abs(facing.y) }
yBounce = true
}
position = clampVec2ToArena(position, boss.radius, state.bounds)
const cornerBounce = xBounce && yBounce
if (cornerBounce) {
facing = withFallbackFacing(subtractVec2(arenaCenter(state), position), facing)
position = disengageFromWall(position, boss.radius, state)
}
facing = normalizeVec2(facing)
return {
bounces: Number(xBounce) + Number(yBounce),
cornerBounce,
facing,
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddLavaTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.fireballCooldownRemaining > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.fireballCooldownRemaining }
}
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards,
nextHazardId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: CINDERBACK_TUNING.lavaTrailInterval,
}
}
function addArmorSlamPuddles({
boss,
hazards,
nextHazardId,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
state: Iwt2ArenaState
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
const angles = [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]
for (const angle of angles) {
const position = clampVec2ToArena({
x: boss.position.x + Math.cos(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
y: boss.position.y + Math.sin(angle) * CINDERBACK_TUNING.armorSlamRadius * 0.72,
}, CINDERBACK_TUNING.firePuddleRadius, state.bounds)
const result = addFirePuddle({
damage: CINDERBACK_TUNING.firePuddleDamage,
duration: CINDERBACK_TUNING.firePuddleSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position,
radius: CINDERBACK_TUNING.firePuddleRadius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
function applyRicochetHits(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
start: Iwt2Vec2,
end: Iwt2Vec2,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
end,
kind: 'lane',
start,
width: CINDERBACK_TUNING.ricochetWidth,
}, {
damage: CINDERBACK_TUNING.ricochetDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.ricochetStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyArmorSlam(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
}, {
damage: CINDERBACK_TUNING.armorSlamDamage,
knockdownSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
sourceId: boss.id,
stunSeconds: CINDERBACK_TUNING.armorSlamStunSeconds,
time,
})
return {
party: result.party,
events: [
createArenaEvent(0, time, 'bossSlam', boss.id, undefined, CINDERBACK_TUNING.armorSlamRadius),
...result.events,
],
}
}
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) > CINDERBACK_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CINDERBACK_TUNING.meleeRange,
}, {
damage: CINDERBACK_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CINDERBACK_TUNING.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardCenter(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
speed: number,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const nextPosition = clampVec2ToArena(
moveTowardKeepingDistance(boss.position, center, speed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(center, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveTowardKeepingDistance(current: Iwt2Vec2, target: Iwt2Vec2, maxDistance: number): Iwt2Vec2 {
const offset = subtractVec2(target, current)
const distance = Math.hypot(offset.x, offset.y)
if (distance <= maxDistance || distance <= 0.0001) return { ...target }
return addVec2(current, scaleVec2(offset, maxDistance / distance))
}
function nextWallImpact(
position: Iwt2Vec2,
direction: Iwt2Vec2,
radius: number,
state: Iwt2ArenaState,
): Iwt2Vec2 {
const facing = withFallbackFacing(direction, { x: 1, y: 0 })
const times: number[] = []
if (Math.abs(facing.x) > 0.0001) {
times.push(((facing.x > 0 ? state.bounds.width - radius : radius) - position.x) / facing.x)
}
if (Math.abs(facing.y) > 0.0001) {
times.push(((facing.y > 0 ? state.bounds.height - radius : radius) - position.y) / facing.y)
}
const impactDistance = Math.max(0, Math.min(...times.filter((value) => value > 0)))
return clampVec2ToArena(addVec2(position, scaleVec2(facing, impactDistance)), radius, state.bounds)
}
function wallSafeRollDirection(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2Vec2 {
if (!isBossNearWall(boss, state)) return withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const targetBias = normalizeVec2(subtractVec2(targetPosition, boss.position))
return withFallbackFacing(addVec2(scaleVec2(centerBias, 1.25), scaleVec2(targetBias, 0.55)), centerBias)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
const margin = boss.radius + CINDERBACK_TUNING.wallMargin
return (
boss.position.x <= margin
|| boss.position.x >= state.bounds.width - margin
|| boss.position.y <= margin
|| boss.position.y >= state.bounds.height - margin
)
}
function disengageFromWall(position: Iwt2Vec2, radius: number, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const margin = radius + CINDERBACK_TUNING.wallMargin * 0.55
const clamped = {
x: Math.min(state.bounds.width - margin, Math.max(margin, position.x)),
y: Math.min(state.bounds.height - margin, Math.max(margin, position.y)),
}
if (clamped.x !== position.x || clamped.y !== position.y) return clamped
return addVec2(position, scaleVec2(normalizeVec2(subtractVec2(center, position)), 18))
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
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 withCinderbackIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCinderbackIndicators(result.boss),
}
}
function createCinderbackIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = cinderbackPhase(boss)
if (
phase === CINDERBACK_PHASE.ricochetWindup
|| phase === CINDERBACK_PHASE.ricocheting
) {
indicators.push(createLaneIndicator({
color: phase === CINDERBACK_PHASE.ricocheting ? '#ff6a2a' : '#ffd166',
end: boss.chargeEnd,
id: `${boss.id}:cinderback-ricochet-lane`,
mechanicId: 'cinderback-ricochet',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.ricochetWindup,
phase === CINDERBACK_PHASE.ricocheting,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CINDERBACK_TUNING.ricochetWidth,
}))
}
if (
phase === CINDERBACK_PHASE.armorSlamWindup
|| phase === CINDERBACK_PHASE.armorSlamRecover
) {
indicators.push(createCircleIndicator({
color: '#ff7a2f',
id: `${boss.id}:cinderback-armor-slam`,
mechanicId: 'cinderback-armor-slam',
phase: indicatorPhaseFromAttack(
phase === CINDERBACK_PHASE.armorSlamWindup,
phase === CINDERBACK_PHASE.armorSlamRecover,
),
position: boss.position,
radius: CINDERBACK_TUNING.armorSlamRadius,
sourceId: boss.id,
}))
}
return indicators
}
function cinderbackPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CinderbackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}