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

750 lines
23 KiB
TypeScript

import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addMudPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clamp,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
type SandglassScorpionAttackPhase =
| Iwt2BossEntityState['attackPhase']
| 'sandglassBurrowWindup'
| 'sandglassBurrowing'
| 'sandglassEruptionWindup'
| 'sandglassEruptionRecover'
| 'sandglassHourglassSafe'
| 'sandglassHourglassHazard'
| 'sandglassHourglassRecover'
const SANDGLASS = {
burrowCooldown: 6.6,
burrowSeconds: 1.05,
burrowSpeed: 285,
burrowTrailInterval: 0.18,
burrowTrailRadius: 28,
burrowTrailSeconds: 4.2,
burrowTrailWidth: 44,
burrowWindup: 0.48,
disengageSeconds: 0.72,
disengageSpeed: 148,
eruptionDamage: 70,
eruptionRadius: 50,
eruptionRecover: 0.52,
eruptionStunSeconds: 0.42,
eruptionWindup: 0.76,
hourglassCooldown: 8.1,
hourglassDamage: 35,
hourglassHazardSeconds: 2.2,
hourglassRadius: 66,
hourglassRecover: 0.48,
hourglassSafeSeconds: 1.05,
hourglassTickSeconds: 0.5,
meleeCooldown: 1.25,
meleeDamage: 22,
meleeRange: 58,
moveSpeed: 94,
wallMargin: 70,
zoneCount: 3,
}
const BURROW_COLOR = '#c8944e'
const ERUPTION_COLOR = '#f2c15b'
const HOURGLASS_SAFE_COLOR = '#68d8ff'
const HOURGLASS_HAZARD_COLOR = '#d49a42'
export function tickSandglassScorpion(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
let party = state.party
const incomingPhase = state.boss.attackPhase as SandglassScorpionAttackPhase
let boss = {
...state.boss,
chargeCooldownRemaining: Math.max(0, state.boss.chargeCooldownRemaining - dt),
fireballCooldownRemaining: Math.max(0, state.boss.fireballCooldownRemaining - dt),
mechanicEnergy: Math.max(0, state.boss.mechanicEnergy - 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 === 'sandglassBurrowing'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
const phase = boss.attackPhase as SandglassScorpionAttackPhase
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, 1.1),
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(boss.relocateTarget, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowing'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.burrowSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassBurrowing') {
const burrow = moveBurrowingBoss(boss, state, dt)
const trailResult = maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position: burrow.position,
time: state.time + dt,
})
hazards = trailResult.hazards
nextHazardId = trailResult.nextHazardId
boss = {
...boss,
mechanicEnergy: trailResult.nextTrailInSeconds,
position: burrow.position,
velocity: burrow.velocity,
wallContactSeconds: 0,
}
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 7) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionWindup'),
mechanicCircles: createStingerEruptions(state, party, boss),
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: SANDGLASS.eruptionWindup,
velocity: { x: 0, y: 0 },
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionWindup') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
const result = applyStingerEruptions(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase('sandglassEruptionRecover'),
phaseSecondsRemaining: SANDGLASS.eruptionRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassEruptionRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassSafe') {
boss = {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing),
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassHazard'),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassHazardSeconds,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassHazard') {
const tickResult = maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time: state.time + dt,
})
hazards = tickResult.hazards
nextHazardId = tickResult.nextHazardId
party = tickResult.party
events.push(...tickResult.events)
boss = {
...boss,
mechanicEnergy: tickResult.nextTickInSeconds,
velocity: { x: 0, y: 0 },
}
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassRecover'),
phaseSecondsRemaining: SANDGLASS.hourglassRecover,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'sandglassHourglassRecover') {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= SANDGLASS.disengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 1.25,
relocateTarget: centerRelocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = {
...boss,
attackPhase: asBossPhase('sandglassHourglassSafe'),
fireballCooldownRemaining: SANDGLASS.hourglassCooldown,
mechanicCircles: createHourglassZones(state, party, boss),
mechanicEnergy: 0,
phaseSecondsRemaining: SANDGLASS.hourglassSafeSeconds,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
const exit = chooseBurrowExit(state, party, boss, target)
boss = {
...boss,
attackPhase: asBossPhase('sandglassBurrowWindup'),
chargeCooldownRemaining: SANDGLASS.burrowCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: { ...exit },
chargeStart: { ...boss.position },
mechanicCircles: [],
mechanicLanes: [createBurrowLane(boss.position, exit)],
phaseSecondsRemaining: SANDGLASS.burrowWindup,
relocateTarget: exit,
velocity: { x: 0, y: 0 },
}
return withSandglassIndicators({ 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 withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveBossTowardEngagePoint(boss, state, target, dt)
return withSandglassIndicators({ boss, party, hazards, events, nextHazardId })
}
function asBossPhase(phase: SandglassScorpionAttackPhase): Iwt2BossEntityState['attackPhase'] {
return phase as Iwt2BossEntityState['attackPhase']
}
function moveBurrowingBoss(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
dt: number,
): { position: Iwt2Vec2, velocity: Iwt2Vec2 } {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.burrowSpeed * dt),
boss.radius,
state.bounds,
)
return {
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function maybeAddBurrowTrail({
boss,
hazards,
nextHazardId,
position,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
position: Iwt2Vec2
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number, nextTrailInSeconds: number } {
if (boss.mechanicEnergy > 0) {
return { hazards, nextHazardId, nextTrailInSeconds: boss.mechanicEnergy }
}
const result = addMudPuddle({
duration: SANDGLASS.burrowTrailSeconds,
hazards,
nextHazardId,
position,
radius: SANDGLASS.burrowTrailRadius,
sourceId: boss.id,
time,
})
return {
hazards: result.hazards,
nextHazardId: result.nextHazardId,
nextTrailInSeconds: SANDGLASS.burrowTrailInterval,
}
}
function maybeApplyHourglassZones({
boss,
hazards,
nextHazardId,
party,
state,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
party: Iwt2PartyEntityState[]
state: Iwt2ArenaState
time: number
}): {
events: Iwt2ArenaEvent[]
hazards: Iwt2GroundHazardState[]
nextHazardId: number
nextTickInSeconds: number
party: Iwt2PartyEntityState[]
} {
if (boss.mechanicEnergy > 0) {
return { events: [], hazards, nextHazardId, nextTickInSeconds: boss.mechanicEnergy, party }
}
let nextParty = party
let nextHazards = hazards
let nextId = nextHazardId
const events: Iwt2ArenaEvent[] = []
for (const circle of boss.mechanicCircles) {
const damage = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.hourglassDamage,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0.18,
time,
})
nextParty = damage.party
events.push(...damage.events)
const puddle = addMudPuddle({
duration: SANDGLASS.hourglassTickSeconds + 0.45,
hazards: nextHazards,
nextHazardId: nextId,
position: clampVec2ToArena(circle.position, circle.radius, state.bounds),
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = puddle.hazards
nextId = puddle.nextHazardId
}
return {
events,
hazards: nextHazards,
nextHazardId: nextId,
nextTickInSeconds: SANDGLASS.hourglassTickSeconds,
party: nextParty,
}
}
function createStingerEruptions(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = party.filter((member) => member.health > 0)
const marked = [
getBossTarget(living),
...living.filter((member) => member.aiRole === 'ranged' || member.aiRole === 'flanker'),
...living,
].filter((member): member is Iwt2PartyEntityState => Boolean(member))
const circles: Iwt2MechanicCircleState[] = []
const seen = new Set<string>()
for (const member of marked) {
if (seen.has(member.id)) continue
seen.add(member.id)
const offset = offsetFromBoss(member.position, boss.position, 28 + circles.length * 10)
circles.push({
id: `sandglass-stinger-${circles.length}`,
position: clampVec2ToArena(addVec2(member.position, offset), SANDGLASS.eruptionRadius, state.bounds),
radius: SANDGLASS.eruptionRadius,
})
if (circles.length >= SANDGLASS.zoneCount) break
}
return circles
}
function createHourglassZones(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const center = arenaCenter(state)
const partyCenterPoint = partyCenter(party.filter((member) => member.health > 0), center)
const angleBase = Math.atan2(partyCenterPoint.y - center.y, partyCenterPoint.x - center.x)
const circles: Iwt2MechanicCircleState[] = []
for (let index = 0; index < SANDGLASS.zoneCount; index += 1) {
const angle = angleBase + (Math.PI * 2 * index) / SANDGLASS.zoneCount + boss.chargeCount * 0.28
const radius = index === 0 ? 54 : 128
circles.push({
id: `sandglass-hourglass-${index}`,
position: clampVec2ToArena({
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius * 0.68,
}, SANDGLASS.hourglassRadius, state.bounds),
radius: SANDGLASS.hourglassRadius,
})
}
return circles
}
function applyStingerEruptions(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], events: Iwt2ArenaEvent[] } {
let nextParty = party
const events: Iwt2ArenaEvent[] = []
const hitEntityIds: string[] = []
for (const circle of boss.mechanicCircles) {
const result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: SANDGLASS.eruptionDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0.2,
sourceId: boss.id,
stunSeconds: SANDGLASS.eruptionStunSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, 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) > SANDGLASS.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: SANDGLASS.meleeRange,
}, {
damage: SANDGLASS.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: SANDGLASS.meleeCooldown },
party: result.party,
events: result.events,
}
}
function moveBossTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const desiredDistance = 70
const offset = subtractVec2(boss.position, target.position)
const distance = distanceVec2(boss.position, target.position)
const toward = withFallbackFacing(subtractVec2(target.position, boss.position), boss.facing)
const away = withFallbackFacing(offset, scaleVec2(toward, -1))
const centerBias = normalizeVec2(subtractVec2(arenaCenter(state), boss.position))
const destination = distance < desiredDistance
? addVec2(target.position, scaleVec2(away, desiredDistance))
: addVec2(target.position, scaleVec2(centerBias, 18))
const position = clampVec2ToArena(
moveToward(boss.position, destination, SANDGLASS.moveSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const position = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, SANDGLASS.disengageSpeed * dt),
boss.radius,
state.bounds,
)
return {
...boss,
facing: withFallbackFacing(subtractVec2(target.position, position), boss.facing),
position,
velocity: scaleVec2(subtractVec2(position, boss.position), dt > 0 ? 1 / dt : 0),
wallContactSeconds: isBossNearWall({ ...boss, position }, state) ? boss.wallContactSeconds : 0,
}
}
function chooseBurrowExit(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
target: Iwt2PartyEntityState,
): Iwt2Vec2 {
const center = arenaCenter(state)
const living = party.filter((member) => member.health > 0)
const partyCenterPoint = partyCenter(living, target.position)
const baseDirection = withFallbackFacing(subtractVec2(partyCenterPoint, boss.position), boss.facing)
const flank = { x: -baseDirection.y, y: baseDirection.x }
const side = boss.chargeCount % 2 === 0 ? 1 : -1
const candidates = [
addVec2(target.position, scaleVec2(flank, 118 * side)),
addVec2(partyCenterPoint, scaleVec2(flank, -124 * side)),
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), -76)),
center,
]
let best = clampVec2ToArena(candidates[0], boss.radius + SANDGLASS.wallMargin, state.bounds)
let bestScore = -Infinity
for (const candidate of candidates) {
const clamped = clampVec2ToArena(candidate, boss.radius + SANDGLASS.wallMargin, state.bounds)
const wall = wallClearance(clamped, state, boss.radius)
const targetDistance = distanceVec2(clamped, target.position)
const centerDistance = distanceVec2(clamped, center)
const bossDistance = distanceVec2(clamped, boss.position)
const score = wall * 1.6
+ clamp(targetDistance, 72, 210) * 0.55
- centerDistance * 0.18
+ clamp(bossDistance, 90, 280) * 0.25
if (score > bestScore) {
best = clamped
bestScore = score
}
}
return best
}
function centerRelocationTarget(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, 54)),
boss.radius + SANDGLASS.wallMargin,
state.bounds,
)
}
function createBurrowLane(start: Iwt2Vec2, end: Iwt2Vec2): Iwt2MechanicLaneState {
return {
end: { ...end },
id: 'sandglass-burrow-trail',
start: { ...start },
width: SANDGLASS.burrowTrailWidth,
}
}
function offsetFromBoss(position: Iwt2Vec2, bossPosition: Iwt2Vec2, distance: number): Iwt2Vec2 {
const direction = withFallbackFacing(subtractVec2(position, bossPosition), { x: 1, y: 0 })
return scaleVec2(direction, distance)
}
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
return {
x: state.bounds.width * 0.5,
y: state.bounds.height * 0.5,
}
}
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 wallClearance(position: Iwt2Vec2, state: Iwt2ArenaState, radius: number): number {
return Math.min(
position.x - radius,
state.bounds.width - radius - position.x,
position.y - radius,
state.bounds.height - radius - position.y,
)
}
function isBossNearWall(boss: Iwt2BossEntityState, state: Iwt2ArenaState): boolean {
return wallClearance(boss.position, state, boss.radius) <= SANDGLASS.wallMargin
}
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 withSandglassIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createSandglassIndicators(result.boss),
}
}
function createSandglassIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const phase = boss.attackPhase as SandglassScorpionAttackPhase
const indicators: Iwt2ArenaIndicator[] = []
if (phase === 'sandglassBurrowWindup' || phase === 'sandglassBurrowing') {
indicators.push(...boss.mechanicLanes.map((lane) => createLaneIndicator({
color: BURROW_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'sandglass-burrow-trail',
phase: indicatorPhaseFromAttack(
phase === 'sandglassBurrowWindup',
phase === 'sandglassBurrowing',
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
})))
}
if (phase === 'sandglassEruptionWindup' || phase === 'sandglassEruptionRecover') {
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color: ERUPTION_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-stinger-eruption',
phase: indicatorPhaseFromAttack(
phase === 'sandglassEruptionWindup',
phase === 'sandglassEruptionRecover',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
if (
phase === 'sandglassHourglassSafe'
|| phase === 'sandglassHourglassHazard'
|| phase === 'sandglassHourglassRecover'
) {
const color = phase === 'sandglassHourglassSafe' ? HOURGLASS_SAFE_COLOR : HOURGLASS_HAZARD_COLOR
indicators.push(...boss.mechanicCircles.map((circle) => createCircleIndicator({
color,
id: `${boss.id}:${circle.id}`,
mechanicId: 'sandglass-hourglass-zone',
phase: indicatorPhaseFromAttack(
phase === 'sandglassHourglassSafe',
phase === 'sandglassHourglassHazard',
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
})))
}
return indicators
}