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

737 lines
23 KiB
TypeScript

import type { Iwt2BossTickResult } from './bossAi'
import type {
Iwt2ArenaEvent,
Iwt2ArenaIndicator,
Iwt2ArenaState,
Iwt2BossEntityState,
Iwt2EntityId,
Iwt2GroundHazardState,
Iwt2MechanicCircleState,
Iwt2MechanicLaneState,
Iwt2PartyEntityState,
Iwt2Vec2,
} from './types'
import { addPoisonPuddle } from './hazards'
import {
applyPartyDamageInShape,
createCircleIndicator,
createConeIndicator,
createLaneIndicator,
indicatorPhaseFromAttack,
} from './mechanics'
import {
addVec2,
clampVec2ToArena,
distanceVec2,
moveToward,
normalizeVec2,
scaleVec2,
subtractVec2,
withFallbackFacing,
} from './vector'
const VENOM_ORCHID_HYDRA_PHASE = {
bloomPodRecover: 'venomOrchidHydraBloomPodRecover',
bloomPodWindup: 'venomOrchidHydraBloomPodWindup',
coneRecover: 'venomOrchidHydraConeRecover',
coneWindup: 'venomOrchidHydraConeWindup',
rootSnareRecover: 'venomOrchidHydraRootSnareRecover',
rootSnareWindup: 'venomOrchidHydraRootSnareWindup',
} as const
type VenomOrchidHydraPhase = typeof VENOM_ORCHID_HYDRA_PHASE[keyof typeof VENOM_ORCHID_HYDRA_PHASE]
const VENOM_ORCHID_HYDRA = {
bloomBurstDamage: 18,
bloomCooldown: 8.6,
bloomPodCount: 3,
bloomPodDamage: 10,
bloomPodRadius: 30,
bloomPodSeconds: 1.4,
bloomRecover: 0.5,
bloomWindup: 0.9,
coneAngleRadians: Math.PI * 0.34,
coneCooldown: 5.7,
coneDamage: 28,
coneRange: 178,
coneRecover: 0.62,
coneStaggerWindup: 0.48,
coneStunSeconds: 0.18,
headOffset: 22,
meleeCooldown: 1.35,
meleeDamage: 10,
meleeRange: 58,
moveSpeed: 58,
relocateSpeed: 114,
relocateSeconds: 1.45,
rootLaneDamage: 22,
rootLaneLengthInset: 36,
rootLaneWidth: 28,
rootRecover: 0.58,
rootSnareSeconds: 0.85,
rootWindup: 0.82,
wallDisengageSeconds: 0.72,
wallMargin: 70,
}
const VENOM_COLOR = '#b7ff4f'
const ROOT_COLOR = '#d68b49'
const BLOOM_COLOR = '#ff70d7'
type PoisonConePlan = {
direction: Iwt2Vec2
end: Iwt2Vec2
origin: Iwt2Vec2
}
export function tickVenomOrchidHydra(state: Iwt2ArenaState, dt: number): Iwt2BossTickResult {
const events: Iwt2ArenaEvent[] = []
let hazards = state.hazards
let nextHazardId = state.nextHazardId
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 phase = venomOrchidHydraPhase(boss)
boss = {
...boss,
wallContactSeconds: phase === 'relocating'
? 0
: isBossNearWall(boss, state)
? boss.wallContactSeconds + dt
: Math.max(0, boss.wallContactSeconds - dt * 2),
}
const target = getBossTarget(party)
if (boss.health <= 0 || !target) {
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === 'relocating') {
boss = moveBossTowardRelocationTarget(boss, state, target, dt)
if (boss.phaseSecondsRemaining <= 0 || distanceVec2(boss.position, boss.relocateTarget) <= 8) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: 0,
velocity: { x: 0, y: 0 },
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyPoisonCone(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
const nextHeadIndex = Math.floor(boss.mechanicEnergy) + 1
if (nextHeadIndex < 3) {
const cone = createPoisonConePlan(boss, party, state, nextHeadIndex)
boss = {
...boss,
chargeEnd: cone.end,
chargeHitEntityIds: result.hitEntityIds,
chargeStart: cone.origin,
facing: cone.direction,
mechanicEnergy: nextHeadIndex,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
}
} else {
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneRecover),
chargeHitEntityIds: [],
mechanicEnergy: 0,
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneRecover,
}
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = { ...boss, attackPhase: 'idle', phaseSecondsRemaining: 0 }
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const result = applyRootSnareLanes(party, boss, state.time + dt)
party = result.party
events.push(...result.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicLanes: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup) {
if (boss.phaseSecondsRemaining <= 0) {
const burst = applyBloomBursts(party, boss, state.time + dt)
const pods = addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time: state.time + dt,
})
party = burst.party
hazards = pods.hazards
nextHazardId = pods.nextHazardId
events.push(...burst.events)
boss = {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomRecover,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
if (boss.phaseSecondsRemaining <= 0) {
boss = {
...boss,
attackPhase: 'idle',
mechanicCircles: [],
phaseSecondsRemaining: 0,
}
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.wallContactSeconds >= VENOM_ORCHID_HYDRA.wallDisengageSeconds) {
boss = {
...boss,
attackPhase: 'relocating',
mechanicCircles: [],
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.relocateSeconds,
relocateTarget: relocationTarget(boss, state),
velocity: { x: 0, y: 0 },
}
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.fireballCooldownRemaining <= 0) {
boss = beginBloomPods(boss, state, party)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
if (boss.chargeCooldownRemaining <= 0) {
boss = boss.chargeCount % 2 === 0
? beginStaggeredPoisonCones(boss, state, party)
: beginRootSnareLanes(boss, state, party)
return withVenomOrchidHydraIndicators({ 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 withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
boss = moveHydraTowardEngagePoint(boss, state, target, dt)
return withVenomOrchidHydraIndicators({ boss, party, hazards, events, nextHazardId })
}
function beginStaggeredPoisonCones(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
const cone = createPoisonConePlan(boss, party, state, 0)
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.coneWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
chargeEnd: cone.end,
chargeHitEntityIds: [],
chargeStart: cone.origin,
facing: cone.direction,
mechanicCircles: [],
mechanicEnergy: 0,
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.coneStaggerWindup,
velocity: { x: 0, y: 0 },
}
}
function beginRootSnareLanes(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup),
chargeCooldownRemaining: VENOM_ORCHID_HYDRA.coneCooldown,
chargeCount: boss.chargeCount + 1,
mechanicCircles: [],
mechanicLanes: createRootSnarePattern(state, party),
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.rootWindup,
velocity: { x: 0, y: 0 },
}
}
function beginBloomPods(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2BossEntityState {
return {
...boss,
attackPhase: asBossPhase(VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup),
fireballCooldownRemaining: VENOM_ORCHID_HYDRA.bloomCooldown,
mechanicCircles: createBloomPodPattern(state, party, boss),
mechanicLanes: [],
phaseSecondsRemaining: VENOM_ORCHID_HYDRA.bloomWindup,
velocity: { x: 0, y: 0 },
}
}
function createPoisonConePlan(
boss: Iwt2BossEntityState,
party: Iwt2PartyEntityState[],
state: Iwt2ArenaState,
headIndex: number,
): PoisonConePlan {
const living = livingParty(party)
const fallbackTarget = getBossTarget(party)?.position ?? arenaCenter(state)
const target = living[(headIndex + boss.chargeCount) % Math.max(1, living.length)]?.position ?? fallbackTarget
const headOffset = (headIndex - 1) * VENOM_ORCHID_HYDRA.headOffset
const baseDirection = withFallbackFacing(subtractVec2(target, boss.position), boss.facing)
const direction = rotateVec2(baseDirection, (headIndex - 1) * 0.28)
const perpendicular = { x: -baseDirection.y, y: baseDirection.x }
const origin = clampVec2ToArena(
addVec2(boss.position, scaleVec2(perpendicular, headOffset)),
8,
state.bounds,
)
return {
direction,
end: addVec2(origin, scaleVec2(direction, VENOM_ORCHID_HYDRA.coneRange)),
origin,
}
}
function createRootSnarePattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
): Iwt2MechanicLaneState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const selected = [
getPriorityMember(living, 'player')?.position ?? center,
getPriorityMember(living, 'ranged')?.position ?? center,
getPriorityMember(living, 'flanker')?.position ?? center,
]
const directions = [
{ x: 1, y: 0 },
{ x: 0, y: 1 },
normalizeVec2({ x: state.bounds.width >= state.bounds.height ? 1 : 0.8, y: 0.72 }),
]
return selected.map((position, index) => {
const lane = createArenaLaneThroughPoint(position, directions[index], state)
return {
...lane,
id: `venom-orchid-root-snare-${index}`,
width: VENOM_ORCHID_HYDRA.rootLaneWidth,
}
})
}
function createBloomPodPattern(
state: Iwt2ArenaState,
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
): Iwt2MechanicCircleState[] {
const living = livingParty(party)
const center = partyCenter(living, arenaCenter(state))
const chosen = [
getPriorityMember(living, 'ranged')?.position,
getPriorityMember(living, 'player')?.position,
getPriorityMember(living, 'flanker')?.position,
addVec2(center, scaleVec2(normalizeVec2(subtractVec2(center, boss.position)), 54)),
].filter((position): position is Iwt2Vec2 => Boolean(position))
return chosen.slice(0, VENOM_ORCHID_HYDRA.bloomPodCount).map((position, index) => ({
id: `venom-orchid-bloom-pod-${index}`,
position: clampVec2ToArena(position, VENOM_ORCHID_HYDRA.bloomPodRadius, state.bounds),
radius: VENOM_ORCHID_HYDRA.bloomPodRadius,
}))
}
function applyPoisonCone(
party: Iwt2PartyEntityState[],
boss: Iwt2BossEntityState,
time: number,
): { party: Iwt2PartyEntityState[], hitEntityIds: Iwt2EntityId[], events: Iwt2ArenaEvent[] } {
const result = applyPartyDamageInShape(party, {
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
direction: boss.facing,
kind: 'cone',
origin: boss.chargeStart,
range: VENOM_ORCHID_HYDRA.coneRange,
}, {
damage: VENOM_ORCHID_HYDRA.coneDamage,
excludedEntityIds: boss.chargeHitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.coneStunSeconds,
time,
})
return {
...result,
hitEntityIds: [...boss.chargeHitEntityIds, ...result.hitEntityIds],
}
}
function applyRootSnareLanes(
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: VENOM_ORCHID_HYDRA.rootLaneDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: VENOM_ORCHID_HYDRA.rootSnareSeconds,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function applyBloomBursts(
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 result = applyPartyDamageInShape(nextParty, {
kind: 'circle',
position: circle.position,
radius: circle.radius,
}, {
damage: VENOM_ORCHID_HYDRA.bloomBurstDamage,
excludedEntityIds: hitEntityIds,
knockdownSeconds: 0,
sourceId: boss.id,
stunSeconds: 0,
time,
})
nextParty = result.party
hitEntityIds.push(...result.hitEntityIds)
events.push(...result.events)
}
return { party: nextParty, events }
}
function addBloomPoisonPods({
boss,
hazards,
nextHazardId,
time,
}: {
boss: Iwt2BossEntityState
hazards: Iwt2GroundHazardState[]
nextHazardId: number
time: number
}): { hazards: Iwt2GroundHazardState[], nextHazardId: number } {
let nextHazards = hazards
let nextId = nextHazardId
for (const circle of boss.mechanicCircles) {
const result = addPoisonPuddle({
damage: VENOM_ORCHID_HYDRA.bloomPodDamage,
duration: VENOM_ORCHID_HYDRA.bloomPodSeconds,
hazards: nextHazards,
nextHazardId: nextId,
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
time,
})
nextHazards = result.hazards
nextId = result.nextHazardId
}
return { hazards: nextHazards, nextHazardId: nextId }
}
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) > VENOM_ORCHID_HYDRA.meleeRange + target.radius) {
return { boss, party, events: [] }
}
const result = applyPartyDamageInShape(party, {
kind: 'circle',
position: boss.position,
radius: VENOM_ORCHID_HYDRA.meleeRange,
}, {
damage: VENOM_ORCHID_HYDRA.meleeDamage,
sourceId: boss.id,
time,
})
return {
boss: { ...boss, meleeCooldownRemaining: VENOM_ORCHID_HYDRA.meleeCooldown, velocity: { x: 0, y: 0 } },
events: result.events,
party: result.party,
}
}
function moveBossTowardRelocationTarget(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const nextPosition = clampVec2ToArena(
moveToward(boss.position, boss.relocateTarget, VENOM_ORCHID_HYDRA.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 moveHydraTowardEngagePoint(
boss: Iwt2BossEntityState,
state: Iwt2ArenaState,
target: Iwt2PartyEntityState,
dt: number,
): Iwt2BossEntityState {
const center = arenaCenter(state)
const targetNearWall = isPositionNearWall(target.position, target.radius + VENOM_ORCHID_HYDRA.wallMargin, state)
const desired = targetNearWall
? addVec2(target.position, scaleVec2(normalizeVec2(subtractVec2(center, target.position)), 106))
: target.position
const nextPosition = clampVec2ToArena(
moveToward(boss.position, desired, VENOM_ORCHID_HYDRA.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: 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, 66)), boss.radius, state.bounds)
}
function createArenaLaneThroughPoint(
point: Iwt2Vec2,
direction: Iwt2Vec2,
state: Iwt2ArenaState,
): { start: Iwt2Vec2, end: Iwt2Vec2 } {
const dir = withFallbackFacing(direction, { x: 1, y: 0 })
const inset = VENOM_ORCHID_HYDRA.rootLaneLengthInset
const minX = inset
const maxX = state.bounds.width - inset
const minY = inset
const maxY = state.bounds.height - inset
const candidates: number[] = []
if (Math.abs(dir.x) > 0.0001) {
candidates.push((minX - point.x) / dir.x, (maxX - point.x) / dir.x)
}
if (Math.abs(dir.y) > 0.0001) {
candidates.push((minY - point.y) / dir.y, (maxY - point.y) / dir.y)
}
const valid = candidates.filter((t) => {
const position = addVec2(point, scaleVec2(dir, t))
return position.x >= minX - 0.1 && position.x <= maxX + 0.1 && position.y >= minY - 0.1 && position.y <= maxY + 0.1
})
const startT = Math.min(...valid)
const endT = Math.max(...valid)
return {
end: addVec2(point, scaleVec2(dir, endT)),
start: addVec2(point, scaleVec2(dir, startT)),
}
}
function rotateVec2(vector: Iwt2Vec2, radians: number): Iwt2Vec2 {
const sin = Math.sin(radians)
const cos = Math.cos(radians)
return normalizeVec2({
x: vector.x * cos - vector.y * sin,
y: vector.x * sin + vector.y * cos,
})
}
function livingParty(party: Iwt2PartyEntityState[]): Iwt2PartyEntityState[] {
return party.filter((member) => member.health > 0)
}
function getPriorityMember(
party: Iwt2PartyEntityState[],
role: Iwt2PartyEntityState['aiRole'],
): Iwt2PartyEntityState | undefined {
return party.find((member) => member.aiRole === role)
}
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 + VENOM_ORCHID_HYDRA.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 withVenomOrchidHydraIndicators(result: Omit<Iwt2BossTickResult, 'indicators'>): Iwt2BossTickResult {
return {
...result,
indicators: createVenomOrchidHydraIndicators(result.boss),
}
}
function createVenomOrchidHydraIndicators(boss: Iwt2BossEntityState): Iwt2ArenaIndicator[] {
const indicators: Iwt2ArenaIndicator[] = []
const phase = venomOrchidHydraPhase(boss)
if (phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup || phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover) {
indicators.push(createConeIndicator({
angleRadians: VENOM_ORCHID_HYDRA.coneAngleRadians,
color: VENOM_COLOR,
direction: boss.facing,
id: `${boss.id}:venom-orchid-hydra-cone-${Math.floor(boss.mechanicEnergy)}`,
mechanicId: 'venom-orchid-hydra-poison-cone',
origin: boss.chargeStart,
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.coneWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.coneRecover,
),
range: VENOM_ORCHID_HYDRA.coneRange,
sourceId: boss.id,
}))
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup || phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover) {
for (const lane of boss.mechanicLanes) {
indicators.push(createLaneIndicator({
color: ROOT_COLOR,
end: lane.end,
id: `${boss.id}:${lane.id}`,
mechanicId: 'venom-orchid-hydra-root-snare',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.rootSnareRecover,
),
sourceId: boss.id,
start: lane.start,
width: lane.width,
}))
}
}
if (phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup || phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover) {
for (const circle of boss.mechanicCircles) {
indicators.push(createCircleIndicator({
color: BLOOM_COLOR,
id: `${boss.id}:${circle.id}`,
mechanicId: 'venom-orchid-hydra-bloom-pod',
phase: indicatorPhaseFromAttack(
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodWindup,
phase === VENOM_ORCHID_HYDRA_PHASE.bloomPodRecover,
),
position: circle.position,
radius: circle.radius,
sourceId: boss.id,
}))
}
}
return indicators
}
function venomOrchidHydraPhase(boss: Iwt2BossEntityState): string {
return boss.attackPhase as string
}
function asBossPhase(phase: VenomOrchidHydraPhase): Iwt2BossEntityState['attackPhase'] {
return phase as unknown as Iwt2BossEntityState['attackPhase']
}