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

579 lines
18 KiB
TypeScript

import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaIndicatorPhase,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type RimebastionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'iceWallWindup'
| 'shardShatterWindup'
| 'shardShatterRecover'
type RimebastionBossState = Omit<Iwt2BossEntityState, 'attackPhase'> & {
attackPhase: RimebastionAttackPhase
}
type ShardCone = {
id: string
origin: Iwt2Vec2
direction: Iwt2Vec2
range: number
angleRadians: number
}
const RIMEBASTION_BALANCE = {
iceWallCooldown: 7.2,
iceWallDamage: 18,
iceWallStunSeconds: 0.25,
iceWallWindup: 1,
iceCircleCount: 2,
iceCircleRadius: 58,
iceWallLaneCount: 3,
iceWallWidth: 22,
meleeCooldown: 1.45,
meleeDamage: 10,
meleeRange: 66,
moveSpeed: 64,
relocateCooldownFloor: 1.3,
relocateSpeed: 112,
relocateSeconds: 1.6,
shatterConeAngleRadians: Math.PI * 0.34,
shatterConeDamage: 30,
shatterConeRange: 158,
shatterLaneDamage: 34,
shatterLaneWidth: 34,
shatterRecover: 0.52,
shatterStunSeconds: 0.4,
shatterWindup: 0.72,
wallDisengageSeconds: 0.7,
wallMargin: 68,
}
const WALL_COLOR = '#a7ecff'
const SHARD_COLOR = '#e8fbff'
export function tickRimebastion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
let boss: RimebastionBossState = {
...state.boss,
meleeCooldownRemaining: Math.max(0, state.boss.meleeCooldownRemaining - dt),
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
phaseSecondsRemaining: Math.max(0, state.boss.phaseSecondsRemaining - dt),
velocity: { x: 0, y: 0 },
}
boss = {
...boss,
wallContactSeconds: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withRimebastionIndicators({ boss, party, events, state })
if (boss.attackPhase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
chargeCooldownRemaining: Math.max(boss.chargeCooldownRemaining, RIMEBASTION_BALANCE.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'iceWallWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyIceWalls(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterWindup',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterWindup,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardShatter(party, boss, state, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: 'shardShatterRecover',
phaseSecondsRemaining: RIMEBASTION_BALANCE.shatterRecover,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'shardShatterRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.attackPhase === 'idle' && boss.wallContactSeconds >= RIMEBASTION_BALANCE.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: RIMEBASTION_BALANCE.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
if (boss.chargeCooldownRemaining <= 0) {
const pattern = createIceWallPattern(state, party, boss)
boss = {
...boss,
attackPhase: 'iceWallWindup',
chargeCooldownRemaining: RIMEBASTION_BALANCE.iceWallCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: pattern.circles,
mechanicLanes: pattern.lanes,
phaseSecondsRemaining: RIMEBASTION_BALANCE.iceWallWindup,
velocity: { x: 0, y: 0 },
}
return withRimebastionIndicators({ boss, party, events, state })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withRimebastionIndicators({ boss, party, events, state })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withRimebastionIndicators({ boss, party, events, state })
}
function createIceWallPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
): { lanes: Iwt2MechanicLaneState[], circles: Iwt2MechanicCircleState[] } {
const living = party.filter((member) => member.health > 0)
const center = partyCenter(living, arenaCenter(state))
const vertical = boss.chargeCount % 2 === 0
const lanes: Iwt2MechanicLaneState[] = []
const offsets = [-96, 0, 96]
for (let index = 0; index < RIMEBASTION_BALANCE.iceWallLaneCount; index += 1) {
const offset = offsets[index] ?? 0
if (vertical) {
const x = clamp(center.x + offset, 82, state.bounds.width - 82)
lanes.push({
end: { x, y: state.bounds.height - 38 },
id: `rime-wall-lane-${index}`,
start: { x, y: 38 },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
} else {
const y = clamp(center.y + offset * 0.72, 74, state.bounds.height - 74)
lanes.push({
end: { x: state.bounds.width - 38, y },
id: `rime-wall-lane-${index}`,
start: { x: 38, y },
width: RIMEBASTION_BALANCE.iceWallWidth,
})
}
}
const circles = living
.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker' || member.aiRole === 'player')
.slice(0, RIMEBASTION_BALANCE.iceCircleCount)
.map((member, index) => ({
id: `rime-wall-circle-${index}`,
position: clampVec2ToArena(member.position, RIMEBASTION_BALANCE.iceCircleRadius, state.bounds),
radius: RIMEBASTION_BALANCE.iceCircleRadius,
}))
return { circles, lanes }
}
function applyIceWalls(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: lane.width,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: RIMEBASTION_BALANCE.iceWallDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.iceWallStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyShardShatter(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
state: Iwt2ArenaState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'lane',
end: lane.end,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}, {
damage: RIMEBASTION_BALANCE.shatterLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
for (const cone of createShardCones(boss, state)) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'cone',
angleRadians: cone.angleRadians,
direction: cone.direction,
origin: cone.origin,
range: cone.range,
}, {
damage: RIMEBASTION_BALANCE.shatterConeDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
sourceId: boss.id,
stunSeconds: RIMEBASTION_BALANCE.shatterStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function createShardCones(boss: RimebastionBossState, state: Iwt2ArenaState): ShardCone[] {
const cones: ShardCone[] = []
const center = arenaCenter(state)
for (const lane of boss.mechanicLanes) {
const midpoint = {
x: (lane.start.x + lane.end.x) * 0.5,
y: (lane.start.y + lane.end.y) * 0.5,
}
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(center, midpoint), boss.facing),
id: `${lane.id}-shard-cone`,
origin: midpoint,
range: RIMEBASTION_BALANCE.shatterConeRange,
})
}
for (const circle of boss.mechanicCircles) {
cones.push({
angleRadians: RIMEBASTION_BALANCE.shatterConeAngleRadians,
direction: withFallbackFacing(subtractVec2(circle.position, boss.position), boss.facing),
id: `${circle.id}-shard-cone`,
origin: circle.position,
range: RIMEBASTION_BALANCE.shatterConeRange * 0.86,
})
}
return cones
}
function moveBossTowardRelocationTarget(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, RIMEBASTION_BALANCE.relocateSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
}
function moveBossTowardEngagePoint(
boss: RimebastionBossState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): RimebastionBossState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + RIMEBASTION_BALANCE.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), RIMEBASTION_BALANCE.meleeRange * 0.9))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, RIMEBASTION_BALANCE.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, nextPosition), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function relocationTarget(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2Vec2 {
const center = arenaCenter(state)
const awayFromWall = normalizeVec2({
x: boss.position.x < state.bounds.width * 0.5 ? 1 : -1,
y: boss.position.y < state.bounds.height * 0.5 ? 1 : -1,
})
return clampVec2ToArena(addVec2(center, scaleVec2(awayFromWall, 58)), boss.radius, state.bounds)
}
function maybeApplyMelee(
party: Iwt2PartyEntityState[],
boss: RimebastionBossState,
target: Iwt2PartyEntityState,
time: number,
): { boss: RimebastionBossState, party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
if (boss.meleeCooldownRemaining > 0) return { boss, party, events: [] }
if (distanceVec2(boss.position, target.position) > RIMEBASTION_BALANCE.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: RIMEBASTION_BALANCE.meleeRange,
}, {
damage: RIMEBASTION_BALANCE.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: RIMEBASTION_BALANCE.meleeCooldown },
events: result.events,
party: result.party,
}
}
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 partyCenter(party: Iwt2PartyEntityState[], fallback: Iwt2Vec2): Iwt2Vec2 {
if (party.length <= 0) return fallback
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Pick<Iwt2BossEntityState, 'position' | 'radius'>, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + RIMEBASTION_BALANCE.wallMargin, state)
}
function isPositionNearWall(position: Iwt2Vec2, margin: number, state: Iwt2ArenaState): boolean {
return (
position.x <= margin
|| position.x >= state.bounds.width - margin
|| position.y <= margin
|| position.y >= state.bounds.height - margin
)
}
function withRimebastionIndicators(result: {
boss: RimebastionBossState
party: Iwt2PartyEntityState[]
events: Iwt2ArenaEvent[]
state: Iwt2ArenaState
}): Iwt2BossTickResult {
const { state, ...rest } = result
return {
...rest,
boss: result.boss as Iwt2BossEntityState,
indicators: createRimebastionIndicators(result.boss, state),
}
}
function createRimebastionIndicators(boss: RimebastionBossState, state: Iwt2ArenaState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const showWalls = (
boss.attackPhase === 'iceWallWindup'
|| boss.attackPhase === 'shardShatterWindup'
|| boss.attackPhase === 'shardShatterRecover'
)
if (showWalls) {
const wallPhase: Iwt2ArenaIndicatorPhase = boss.attackPhase === 'iceWallWindup'
? 'windup'
: boss.attackPhase === 'shardShatterWindup'
? 'active'
: 'recover'
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: WALL_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'rimebastion-ice-wall-lane',
phase: wallPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: WALL_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'rimebastion-ice-wall-circle',
phase: wallPhase,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
if (boss.attackPhase === 'shardShatterWindup' || boss.attackPhase === 'shardShatterRecover') {
const shardPhase = indicatorPhaseFromAttack(
boss.attackPhase === 'shardShatterWindup',
boss.attackPhase === 'shardShatterRecover',
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}:shatter`,
mechanicId: 'rimebastion-shard-lane',
phase: shardPhase,
sourceId: boss.id,
start: lane.start,
width: RIMEBASTION_BALANCE.shatterLaneWidth,
}))
}
for (const cone of createShardCones(boss, state)) {
indicators.push(createConeIndicator({
angleRadians: cone.angleRadians,
color: SHARD_COLOR,
direction: cone.direction,
id: `${boss.id}:${cone.id}`,
mechanicId: 'rimebastion-shard-cone',
origin: cone.origin,
phase: shardPhase,
range: cone.range,
sourceId: boss.id,
}))
}
}
return indicators
}