750 lines
23 KiB
TypeScript
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
|
|
}
|