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

683 lines
22 KiB
TypeScript

import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import {
applyPartyDamageInShape,
createCircleIndicator,
createDonutIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const CRYSTAL_BAT_PHASE = {
shardRecover: 'crystalBatShardRecover',
shardWindup: 'crystalBatShardWindup',
sonicRecover: 'crystalBatSonicRecover',
sonicWindup: 'crystalBatSonicWindup',
swoopRecover: 'crystalBatSwoopRecover',
swoopWindup: 'crystalBatSwoopWindup',
swooping: 'crystalBatSwooping',
} as const
type CrystalBatPhase = typeof CRYSTAL_BAT_PHASE[keyof typeof CRYSTAL_BAT_PHASE]
const CRYSTAL_BAT_TUNING = {
clusterRadius: 84,
hoverDistance: 138,
meleeCooldown: 1.18,
meleeDamage: 10,
meleeRange: 54,
moveSpeed: 138,
relocateCooldownFloor: 1.2,
relocateSeconds: 1.25,
relocateSpeed: 184,
shardCooldown: 5.8,
shardDamage: 34,
shardLaneWidth: 30,
shardRecover: 0.58,
shardStunSeconds: 0.35,
shardWindup: 0.82,
sonicCooldown: 6.4,
sonicDamage: 30,
sonicRecover: 0.42,
sonicRingRadius: 58,
sonicStunSeconds: 0.32,
sonicWindup: 0.78,
swoopCooldown: 4.9,
swoopDamage: 42,
swoopLaneWidth: 44,
swoopRecover: 0.62,
swoopSpeed: 430,
swoopStunSeconds: 0.48,
swoopWindup: 0.56,
wallContactLimit: 0.7,
wallMargin: 62,
} as const
const SONIC_COLOR = '#d9b7ff'
const SHARD_COLOR = '#9de9ff'
const SWOOP_COLOR = '#f6d36b'
export function tickCrystalBatMatriarch(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let party = state.party
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 },
}
const activePhase = crystalBatPhase(boss)
boss = {
...boss,
wallContactSeconds: activePhase !== 'idle'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) return withCrystalBatIndicators({ boss, events, party })
const phase = crystalBatPhase(boss)
if (phase === '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, CRYSTAL_BAT_TUNING.relocateCooldownFloor),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applySonicRings(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.sonicRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicCircles: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
const result = applyShardLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardRecover,
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.shardRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', mechanicLanes: [], phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopWindup) {
boss = { ...boss, facing: withFallbackFacing(subtractVec2(boss.chargeEnd, boss.position), boss.facing) }
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swooping),
chargeHitEntityIds: [],
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swooping) {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.chargeEnd, CRYSTAL_BAT_TUNING.swoopSpeed * dt),
boss.radius,
state.bounds,
)
const hitResult = applySwoopHits(party, boss, boss.position, nextPosition, state.time + dt)
party = hitResult.party
events.push(...hitResult.events)
boss = {
...boss,
chargeHitEntityIds: hitResult.hitEntityIds,
facing: withFallbackFacing(subtractVec2(nextPosition, boss.position), boss.facing),
position: nextPosition,
velocity: scaleVec2(subtractVec2(nextPosition, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: 0,
}
if (distanceVec2(nextPosition, boss.chargeEnd) <= 2) {
boss = {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopRecover),
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopRecover,
velocity: { x: 0, y: 0 },
}
}
return withCrystalBatIndicators({ boss, events, party })
}
if (phase === CRYSTAL_BAT_PHASE.swoopRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withCrystalBatIndicators({ boss, events, party })
}
const cluster = findBestCluster(party)
if (boss.wallContactSeconds >= CRYSTAL_BAT_TUNING.wallContactLimit) {
boss = beginRelocation(boss, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (cluster.count >= 2 && boss.fireballCooldownRemaining <= 0) {
boss = beginSwoop(boss, cluster.center, state)
return withCrystalBatIndicators({ boss, events, party })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginSonicRings(boss, party, state)
: beginShardLanes(boss, party, state)
return withCrystalBatIndicators({ boss, events, party })
}
const meleeResult = maybeApplyMelee(party, boss, target, state.time + dt)
party = meleeResult.party
events.push(...meleeResult.events)
boss = meleeResult.boss
if (events.length > 0) return withCrystalBatIndicators({ boss, events, party })
boss = moveBossToHoverPoint(boss, state, target, dt)
return withCrystalBatIndicators({ boss, events, party })
}
function beginSonicRings(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const circles = party
.filter((member) => member.health > 0)
.map((member) => ({
id: `crystal-sonic-${member.id}`,
position: clampVec2ToArena(member.position, CRYSTAL_BAT_TUNING.sonicRingRadius, state.bounds),
radius: CRYSTAL_BAT_TUNING.sonicRingRadius,
}))
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.sonicWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.sonicCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: circles,
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.sonicWindup,
velocity: { x: 0, y: 0 },
}
}
function beginShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const lanes = createReflectedShardLanes(boss, party, state)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.shardWindup),
chargeCooldownRemaining: CRYSTAL_BAT_TUNING.shardCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: lanes,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.shardWindup,
velocity: { x: 0, y: 0 },
}
}
function beginSwoop(
boss: Iwt2BossEntityState,
targetPosition: Iwt2Vec2,
state: Iwt2ArenaState,
): Iwt2BossEntityState {
const facing = withFallbackFacing(subtractVec2(targetPosition, boss.position), boss.facing)
const end = clampVec2ToArena(addVec2(targetPosition, scaleVec2(facing, 170)), boss.radius, state.bounds)
return {
...boss,
attackPhase: asBossPhase(CRYSTAL_BAT_PHASE.swoopWindup),
chargeEnd: end,
chargeHitEntityIds: [],
chargeStart: { ...boss.position },
facing,
fireballCooldownRemaining: CRYSTAL_BAT_TUNING.swoopCooldown,
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.swoopWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRelocation(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2BossEntityState {
return {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: CRYSTAL_BAT_TUNING.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
}
function createReflectedShardLanes(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
): Iwt2MechanicLaneState[] {
const living = party.filter((member) => member.health > 0)
const cluster = findBestCluster(party)
const priorityTargets = [
cluster.center,
living.find((member) => member.aiRole === 'ranged')?.position ?? getBossTarget(party)?.position ?? arenaCenter(state),
]
const mirrors = mirrorPointsForPattern(boss, state)
const lanes: Iwt2MechanicLaneState[] = []
for (let index = 0; index < mirrors.length; index += 1) {
const mirror = mirrors[index]
const target = priorityTargets[index] ?? cluster.center
const reflectedDirection = withFallbackFacing(subtractVec2(target, mirror), boss.facing)
const reflectedEnd = clampVec2ToArena(
addVec2(mirror, scaleVec2(reflectedDirection, 380)),
CRYSTAL_BAT_TUNING.shardLaneWidth,
state.bounds,
)
lanes.push({
end: mirror,
id: `crystal-shard-${index}-in`,
start: boss.position,
width: CRYSTAL_BAT_TUNING.shardLaneWidth * 0.78,
})
lanes.push({
end: reflectedEnd,
id: `crystal-shard-${index}-out`,
start: mirror,
width: CRYSTAL_BAT_TUNING.shardLaneWidth,
})
}
return lanes
}
function mirrorPointsForPattern(boss: Iwt2BossEntityState, state: Iwt2ArenaState): Iwt2Vec2[] {
const center = arenaCenter(state)
const verticalFirst = boss.chargeCount % 4 < 2
if (verticalFirst) {
return [
{ x: clamp(center.x + 210, 80, state.bounds.width - 80), y: 42 },
{ x: clamp(center.x - 180, 80, state.bounds.width - 80), y: state.bounds.height - 42 },
]
}
return [
{ x: 44, y: clamp(center.y - 120, 74, state.bounds.height - 74) },
{ x: state.bounds.width - 44, y: clamp(center.y + 110, 74, state.bounds.height - 74) },
]
}
function applySonicRings(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const circle of boss.mechanicCircles) {
const ownerId = sonicRingOwnerId(circle)
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: CRYSTAL_BAT_TUNING.sonicDamage,
excludedEntityIds: ownerId ? [ownerId, ...hitEntityIds] : hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.sonicStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applyShardLanes(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: Iwt2EntityId[] = []
for (const lane of boss.mechanicLanes) {
const result = applyPartyDamageInShape(nextParty, {
end: lane.end,
kind: 'lane',
start: lane.start,
width: lane.width,
}, {
damage: CRYSTAL_BAT_TUNING.shardDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.shardStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { events, party: nextParty }
}
function applySwoopHits(
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: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}, {
damage: CRYSTAL_BAT_TUNING.swoopDamage,
damageEventType: 'bossChargeHit',
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
sourceId: boss.id,
stunSeconds: CRYSTAL_BAT_TUNING.swoopStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
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) > CRYSTAL_BAT_TUNING.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: CRYSTAL_BAT_TUNING.meleeRange,
}, {
damage: CRYSTAL_BAT_TUNING.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: CRYSTAL_BAT_TUNING.meleeCooldown },
events: result.events,
party: result.party,
}
}
function moveBossToHoverPoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + CRYSTAL_BAT_TUNING.wallMargin, state)
const awayFromTarget = withFallbackFacing(subtractVec2(center, target.position), { x: -1, y: 0 })
const desired = targetNearWall
? addVec2(target.position, scaleVec2(awayFromTarget, CRYSTAL_BAT_TUNING.hoverDistance))
: addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(boss.position, target.position)), CRYSTAL_BAT_TUNING.hoverDistance))
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, desired, CRYSTAL_BAT_TUNING.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 moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveTowardLimited(boss.position, boss.relocateTarget, CRYSTAL_BAT_TUNING.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 moveTowardLimited(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 findBestCluster(party: Iwt2PartyEntityState[]): { center: Iwt2Vec2, count: number } {
const living = party.filter((member) => member.health > 0)
if (living.length <= 0) return { center: { x: 0, y: 0 }, count: 0 }
let bestMembers = [living[0]]
for (const member of living) {
const nearby = living.filter((other) => distanceVec2(member.position, other.position) <= CRYSTAL_BAT_TUNING.clusterRadius)
if (nearby.length > bestMembers.length) bestMembers = nearby
}
return { center: partyCenter(bestMembers), count: bestMembers.length }
}
function partyCenter(party: Iwt2PartyEntityState[]): Iwt2Vec2 {
if (party.length <= 0) return { x: 0, y: 0 }
const total = party.reduce((sum, member) => addVec2(sum, member.position), { x: 0, y: 0 })
return scaleVec2(total, 1 / party.length)
}
function relocationTarget(boss: Iwt2BossEntityState, 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, 88)), boss.radius, state.bounds)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return isPositionNearWall(boss.position, boss.radius + CRYSTAL_BAT_TUNING.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 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 sonicRingOwnerId(circle: Iwt2MechanicCircleState): Iwt2EntityId | undefined {
const prefix = 'crystal-sonic-'
return circle.id.startsWith(prefix) ? circle.id.slice(prefix.length) : undefined
}
function withCrystalBatIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createCrystalBatIndicators(result.boss),
}
}
function createCrystalBatIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = crystalBatPhase(boss)
const indicators: Iwt2ArenaIndicator[] = []
if (phase === CRYSTAL_BAT_PHASE.sonicWindup || phase === CRYSTAL_BAT_PHASE.sonicRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.sonicWindup,
phase === CRYSTAL_BAT_PHASE.sonicRecover,
)
for (const circle of boss.mechanicCircles) {
indicators.push(createDonutIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:ring`,
innerRadius: Math.max(12, circle.radius * 0.34),
mechanicId: 'crystal-bat-sonic-spacing-ring',
outerRadius: circle.radius,
phase: indicatorPhase,
position: circle.position,
sourceId: boss.id,
}))
indicators.push(createCircleIndicator({
color: SONIC_COLOR,
id: `${boss.id}:${circle.id}:center`,
mechanicId: 'crystal-bat-sonic-overlap-zone',
phase: indicatorPhase,
position: circle.position,
radius: circle.radius * 0.22,
sourceId: boss.id,
}))
}
}
if (phase === CRYSTAL_BAT_PHASE.shardWindup || phase === CRYSTAL_BAT_PHASE.shardRecover) {
const indicatorPhase = indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.shardWindup,
phase === CRYSTAL_BAT_PHASE.shardRecover,
)
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: SHARD_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: lane.id.endsWith('-out') ? 'crystal-bat-reflected-shard' : 'crystal-bat-mirror-shard',
phase: indicatorPhase,
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (
phase === CRYSTAL_BAT_PHASE.swoopWindup
|| phase === CRYSTAL_BAT_PHASE.swooping
|| phase === CRYSTAL_BAT_PHASE.swoopRecover
) {
indicators.push(createLaneIndicator({
color: SWOOP_COLOR,
end: boss.chargeEnd,
id: `${boss.id}:crystal-bat-swoop`,
mechanicId: 'crystal-bat-cluster-swoop',
phase: indicatorPhaseFromAttack(
phase === CRYSTAL_BAT_PHASE.swoopWindup,
phase === CRYSTAL_BAT_PHASE.swooping,
),
sourceId: boss.id,
start: boss.chargeStart,
width: CRYSTAL_BAT_TUNING.swoopLaneWidth,
}))
}
return indicators
}
function crystalBatPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: CrystalBatPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}