737 lines
23 KiB
TypeScript
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']
|
|
}
|