510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
import { BULLDROME_BOSS_METADATA, IWT2_BOSS_METADATA } from '../content/bosses'
|
|
import type { Iwt2ArenaState, Iwt2HostileAddState, Iwt2PartyEntityState, Iwt2Vec2 } from './types'
|
|
import { partyMemberIntersectsShape, type Iwt2HitShape } from './mechanics'
|
|
import {
|
|
addVec2,
|
|
clampVec2ToArena,
|
|
distanceVec2,
|
|
dotVec2,
|
|
lengthSqVec2,
|
|
moveToward,
|
|
normalizeVec2,
|
|
scaleVec2,
|
|
subtractVec2,
|
|
withFallbackFacing,
|
|
} from './vector'
|
|
|
|
const NO_CENTER_LEASH_BOSS_IDS = new Set<string>()
|
|
const CENTER_LEASH_START_DISTANCE = 230
|
|
const CENTER_LEASH_WALL_MARGIN = 96
|
|
const CENTER_LEASH_EXTRA_DISTANCE = 36
|
|
const PARTY_ARRIVAL_RADIUS = 3.5
|
|
const PARTY_SAFE_DESTINATION_PADDING = 34
|
|
const PARTY_STEER_ANGLES = [0, 0.42, -0.42, 0.82, -0.82, 1.22, -1.22, 1.7, -1.7, 2.25, -2.25, Math.PI]
|
|
const HAZARD_ROUTE_BUFFER = 54
|
|
const INDICATOR_ROUTE_PENALTY = 24
|
|
const BOSS_ROUTE_BUFFER = 22
|
|
const PARTY_ROUTE_BUFFER = 8
|
|
const DANGER_CENTER_WEIGHT = 0.32
|
|
|
|
export function tickPartyMember(
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
inputMove: Iwt2Vec2,
|
|
dt: number,
|
|
): Iwt2PartyEntityState {
|
|
const status = {
|
|
stunnedSeconds: Math.max(0, member.status.stunnedSeconds - dt),
|
|
knockedDownSeconds: Math.max(0, member.status.knockedDownSeconds - dt),
|
|
invulnerableSeconds: Math.max(0, member.status.invulnerableSeconds - dt),
|
|
slowedSeconds: Math.max(0, member.status.slowedSeconds - dt),
|
|
}
|
|
const attackCooldownRemaining = Math.max(0, member.attackCooldownRemaining - dt)
|
|
const castSecondsRemaining = Math.max(0, member.castSecondsRemaining - dt)
|
|
if (member.health <= 0) {
|
|
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
|
|
}
|
|
if (status.stunnedSeconds > 0 || status.knockedDownSeconds > 0) {
|
|
return { ...member, velocity: { x: 0, y: 0 }, attackCooldownRemaining, castSecondsRemaining: 0, attackReady: false, status }
|
|
}
|
|
|
|
if (member.aiRole === 'player') {
|
|
const normalizedInput = lengthSqVec2(inputMove) > 1 ? normalizeVec2(inputMove) : inputMove
|
|
const velocity = scaleVec2(normalizedInput, member.moveSpeed * movementSpeedMultiplier(status.slowedSeconds))
|
|
const position = clampVec2ToArena(
|
|
{
|
|
x: member.position.x + velocity.x * dt,
|
|
y: member.position.y + velocity.y * dt,
|
|
},
|
|
member.radius,
|
|
state.bounds,
|
|
)
|
|
return {
|
|
...member,
|
|
position,
|
|
velocity,
|
|
facing: withFallbackFacing(velocity, member.facing),
|
|
attackCooldownRemaining,
|
|
castSecondsRemaining: 0,
|
|
attackReady: false,
|
|
status,
|
|
}
|
|
}
|
|
|
|
const dangerDestination = getDangerAvoidancePosition(member, state)
|
|
const attackTarget = getPriorityAttackTarget(member, state)
|
|
const canCast = member.aiRole === 'ranged'
|
|
&& attackCooldownRemaining <= 0
|
|
&& !!attackTarget
|
|
&& canPartyMemberHitTarget({ ...member, attackCooldownRemaining, castSecondsRemaining }, attackTarget.position, attackTarget.radius)
|
|
if (!dangerDestination && member.aiRole === 'ranged' && (member.castSecondsRemaining > 0 || canCast)) {
|
|
const nextCastSecondsRemaining = member.castSecondsRemaining > 0
|
|
? castSecondsRemaining
|
|
: member.castTime
|
|
return {
|
|
...member,
|
|
velocity: { x: 0, y: 0 },
|
|
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, member.position), member.facing),
|
|
attackCooldownRemaining,
|
|
castSecondsRemaining: nextCastSecondsRemaining,
|
|
attackReady: member.castSecondsRemaining > 0 && nextCastSecondsRemaining <= 0,
|
|
status,
|
|
}
|
|
}
|
|
|
|
const decisionSecondsRemaining = Math.max(0, member.decisionSecondsRemaining - dt)
|
|
const desired = dangerDestination ?? getPartyDesiredPosition(member, state)
|
|
const maxDistance = member.moveSpeed * movementSpeedMultiplier(status.slowedSeconds) * dt
|
|
const distanceToDesired = distanceVec2(member.position, desired)
|
|
const movingToDesired = distanceToDesired > PARTY_ARRIVAL_RADIUS
|
|
const position = movingToDesired
|
|
? choosePartyStepPosition(member, state, desired, maxDistance)
|
|
: member.position
|
|
const velocity = movingToDesired
|
|
? scaleVec2(subtractVec2(position, member.position), dt > 0 ? 1 / dt : 0)
|
|
: { x: 0, y: 0 }
|
|
return {
|
|
...member,
|
|
position,
|
|
velocity,
|
|
facing: withFallbackFacing(subtractVec2(attackTarget?.position ?? getPrimaryBoss(state).position, position), member.facing),
|
|
attackCooldownRemaining,
|
|
castSecondsRemaining: 0,
|
|
attackReady: false,
|
|
status,
|
|
decisionSecondsRemaining: decisionSecondsRemaining <= 0
|
|
? nextDecisionInterval(member)
|
|
: decisionSecondsRemaining,
|
|
}
|
|
}
|
|
|
|
function choosePartyStepPosition(
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
desired: Iwt2Vec2,
|
|
maxDistance: number,
|
|
): Iwt2Vec2 {
|
|
if (maxDistance <= 0) return member.position
|
|
|
|
const direct = withFallbackFacing(subtractVec2(desired, member.position), member.facing)
|
|
const currentDanger = routeDangerPenalty(member.position, member, state)
|
|
let best = clampVec2ToArena(moveToward(member.position, desired, maxDistance), member.radius, state.bounds)
|
|
let bestScore = scoreRoutePosition(best, member, state, desired, currentDanger)
|
|
|
|
for (const angle of PARTY_STEER_ANGLES) {
|
|
const direction = rotateVec2(direct, angle)
|
|
const candidate = clampVec2ToArena(addVec2(member.position, scaleVec2(direction, maxDistance)), member.radius, state.bounds)
|
|
const score = scoreRoutePosition(candidate, member, state, desired, currentDanger)
|
|
+ (angle === 0 ? 0 : Math.abs(angle) * 1.8)
|
|
if (score < bestScore) {
|
|
best = candidate
|
|
bestScore = score
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
function scoreRoutePosition(
|
|
position: Iwt2Vec2,
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
desired: Iwt2Vec2,
|
|
currentDanger: number,
|
|
): number {
|
|
const progress = distanceVec2(position, desired)
|
|
const centerPull = currentDanger > 0 ? distanceVec2(position, arenaCenter(state)) * DANGER_CENTER_WEIGHT : 0
|
|
const progressWeight = currentDanger > 0 ? 0.28 : 1
|
|
const danger = routeDangerPenalty(position, member, state)
|
|
const crowd = routeCrowdPenalty(position, member, state)
|
|
const wall = routeWallPenalty(position, member, state)
|
|
const stayingInDanger = currentDanger > 0 && distanceVec2(position, member.position) <= 0.75 ? 90 : 0
|
|
return progress * progressWeight + centerPull + danger * 100 + crowd * 42 + wall + stayingInDanger
|
|
}
|
|
|
|
function routeDangerPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
|
let penalty = 0
|
|
for (const hazard of state.hazards) {
|
|
const distance = distanceVec2(position, hazard.position)
|
|
const damageRadius = hazard.radius + member.radius
|
|
const avoidRadius = damageRadius + HAZARD_ROUTE_BUFFER
|
|
if (distance <= damageRadius) {
|
|
penalty += 18 + (damageRadius - distance) * 0.7
|
|
} else if (distance < avoidRadius) {
|
|
const ratio = (avoidRadius - distance) / HAZARD_ROUTE_BUFFER
|
|
penalty += ratio * ratio * 8
|
|
}
|
|
}
|
|
|
|
for (const indicator of state.indicators) {
|
|
if (indicator.phase === 'recover') continue
|
|
const shape = indicatorAvoidanceShape(indicator)
|
|
if (!partyMemberIntersectsShape({ ...member, position }, shape)) continue
|
|
penalty += indicator.phase === 'active' ? INDICATOR_ROUTE_PENALTY * 1.4 : INDICATOR_ROUTE_PENALTY
|
|
}
|
|
|
|
return penalty
|
|
}
|
|
|
|
function routeCrowdPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
|
let penalty = 0
|
|
for (const boss of state.bosses) {
|
|
if (boss.health <= 0) continue
|
|
const distance = distanceVec2(position, boss.position)
|
|
const avoidRadius = boss.radius + member.radius + BOSS_ROUTE_BUFFER
|
|
if (distance < avoidRadius) penalty += (avoidRadius - distance) * 1.8
|
|
}
|
|
|
|
for (const other of state.party) {
|
|
if (other.id === member.id || other.health <= 0) continue
|
|
const distance = distanceVec2(position, other.position)
|
|
const avoidRadius = other.radius + member.radius + PARTY_ROUTE_BUFFER
|
|
if (distance < avoidRadius) penalty += avoidRadius - distance
|
|
}
|
|
return penalty
|
|
}
|
|
|
|
function routeWallPenalty(position: Iwt2Vec2, member: Iwt2PartyEntityState, state: Iwt2ArenaState): number {
|
|
const clearance = Math.min(
|
|
position.x - member.radius,
|
|
state.bounds.width - member.radius - position.x,
|
|
position.y - member.radius,
|
|
state.bounds.height - member.radius - position.y,
|
|
)
|
|
if (clearance >= PARTY_SAFE_DESTINATION_PADDING) return 0
|
|
return (PARTY_SAFE_DESTINATION_PADDING - clearance) * (clearance <= 4 ? 12 : 4.8)
|
|
}
|
|
|
|
function rotateVec2(vector: Iwt2Vec2, angle: number): Iwt2Vec2 {
|
|
const cos = Math.cos(angle)
|
|
const sin = Math.sin(angle)
|
|
return {
|
|
x: vector.x * cos - vector.y * sin,
|
|
y: vector.x * sin + vector.y * cos,
|
|
}
|
|
}
|
|
|
|
export function canPartyMemberHitTarget(member: Iwt2PartyEntityState, targetPosition: Iwt2Vec2, targetRadius = 0): boolean {
|
|
if (member.health <= 0) return false
|
|
if (member.status.stunnedSeconds > 0 || member.status.knockedDownSeconds > 0) return false
|
|
if (member.attackDamage <= 0) return false
|
|
return distanceVec2(member.position, targetPosition) <= member.attackRange + member.radius + targetRadius
|
|
}
|
|
|
|
function getPartyDesiredPosition(
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
): Iwt2Vec2 {
|
|
const drift = decisionDrift(member, state.time)
|
|
const attackTarget = getPriorityAttackTarget(member, state)
|
|
const anchor = attackTarget?.position ?? getPrimaryBoss(state).position
|
|
const centerLeash = getTankCenterLeashPosition(member, state)
|
|
if (centerLeash) return centerLeash
|
|
return clampPartyDestination({
|
|
x: anchor.x + member.preferredOffset.x + drift.x,
|
|
y: anchor.y + member.preferredOffset.y + drift.y,
|
|
}, member, state)
|
|
}
|
|
|
|
function clampPartyDestination(
|
|
position: Iwt2Vec2,
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
): Iwt2Vec2 {
|
|
return clampVec2ToArena(position, member.radius + PARTY_SAFE_DESTINATION_PADDING, state.bounds)
|
|
}
|
|
|
|
function getTankCenterLeashPosition(
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
): Iwt2Vec2 | null {
|
|
if (member.aiRole !== 'tank') return null
|
|
const boss = getCenterLeashBoss(member, state)
|
|
if (!boss) return null
|
|
|
|
const center = arenaCenter(state)
|
|
const bossMetadata = IWT2_BOSS_METADATA[boss.bossId]
|
|
const towardCenter = withFallbackFacing(subtractVec2(center, boss.position), {
|
|
x: boss.position.x < center.x ? 1 : -1,
|
|
y: boss.position.y < center.y ? 0.35 : -0.35,
|
|
})
|
|
const leashDistance = bossMetadata.meleeRange + boss.radius + member.radius + CENTER_LEASH_EXTRA_DISTANCE
|
|
return clampPartyDestination(
|
|
addVec2(boss.position, scaleVec2(towardCenter, leashDistance)),
|
|
member,
|
|
state,
|
|
)
|
|
}
|
|
|
|
function getCenterLeashBoss(
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
): Iwt2ArenaState['boss'] | null {
|
|
const center = arenaCenter(state)
|
|
const candidates = state.bosses.filter((boss) => {
|
|
if (boss.health <= 0 || NO_CENTER_LEASH_BOSS_IDS.has(boss.bossId)) return false
|
|
return isBossNearWall(boss, state) || distanceVec2(boss.position, center) >= CENTER_LEASH_START_DISTANCE
|
|
})
|
|
if (candidates.length === 0) return null
|
|
return candidates.reduce((best, boss) => (
|
|
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
|
), candidates[0])
|
|
}
|
|
|
|
function arenaCenter(state: Iwt2ArenaState): Iwt2Vec2 {
|
|
return {
|
|
x: state.bounds.width * 0.5,
|
|
y: state.bounds.height * 0.5,
|
|
}
|
|
}
|
|
|
|
function isBossNearWall(boss: Iwt2ArenaState['boss'], state: Iwt2ArenaState): boolean {
|
|
const margin = boss.radius + CENTER_LEASH_WALL_MARGIN
|
|
return (
|
|
boss.position.x <= margin
|
|
|| boss.position.x >= state.bounds.width - margin
|
|
|| boss.position.y <= margin
|
|
|| boss.position.y >= state.bounds.height - margin
|
|
)
|
|
}
|
|
|
|
function getDangerAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
|
const indicatorEscape = getIndicatorAvoidancePosition(member, state)
|
|
if (indicatorEscape) {
|
|
return indicatorEscape
|
|
}
|
|
|
|
const hazardEscape = getHazardAvoidancePosition(member, state)
|
|
if (hazardEscape) {
|
|
return hazardEscape
|
|
}
|
|
|
|
for (const boss of state.bosses) {
|
|
if (boss.health <= 0) continue
|
|
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'slamWindup' || boss.attackPhase === 'slamRecover')) {
|
|
const distance = distanceVec2(member.position, boss.position)
|
|
const dangerRadius = BULLDROME_BOSS_METADATA.slamRadius + member.radius + 34
|
|
if (distance < dangerRadius) {
|
|
const away = normalizeVec2(subtractVec2(member.position, boss.position))
|
|
return clampPartyDestination(addVec2(member.position, scaleVec2(away, dangerRadius - distance + 40)), member, state)
|
|
}
|
|
}
|
|
|
|
if (boss.bossId === 'bulldrome' && (boss.attackPhase === 'chargeWindup' || boss.attackPhase === 'charging')) {
|
|
const danger = chargeDanger(member.position, boss)
|
|
if (danger.inside || danger.ahead) {
|
|
const perpendicular = { x: -danger.direction.y, y: danger.direction.x }
|
|
const side = dotVec2(subtractVec2(member.position, boss.chargeStart), perpendicular) >= 0 ? 1 : -1
|
|
const escape = addVec2(member.position, scaleVec2(perpendicular, side * (boss.radius * 2.8 + member.radius)))
|
|
return clampPartyDestination(escape, member, state)
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function getIndicatorAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
|
let escape = { x: 0, y: 0 }
|
|
let pressure = 0
|
|
|
|
for (const indicator of state.indicators) {
|
|
if (indicator.phase === 'recover') continue
|
|
const shape = indicatorAvoidanceShape(indicator)
|
|
if (!partyMemberIntersectsShape(member, shape)) continue
|
|
const origin = indicator.kind === 'lane'
|
|
? closestPointOnSegment(member.position, indicator.start, indicator.end)
|
|
: indicator.kind === 'cone'
|
|
? indicator.origin
|
|
: indicator.position
|
|
const away = withFallbackFacing(subtractVec2(member.position, origin), {
|
|
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
|
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
|
|
})
|
|
escape = addVec2(escape, scaleVec2(away, indicator.phase === 'active' ? 96 : 72))
|
|
pressure += indicator.phase === 'active' ? 1.35 : 1
|
|
}
|
|
|
|
if (pressure <= 0) return null
|
|
return clampPartyDestination(addVec2(member.position, escape), member, state)
|
|
}
|
|
|
|
function indicatorAvoidanceShape(indicator: Iwt2ArenaState['indicators'][number]): Iwt2HitShape {
|
|
if (indicator.kind === 'lane') {
|
|
return { kind: 'lane', start: indicator.start, end: indicator.end, width: indicator.width + 22 }
|
|
}
|
|
if (indicator.kind === 'circle') {
|
|
return { kind: 'circle', position: indicator.position, radius: indicator.radius + 18 }
|
|
}
|
|
if (indicator.kind === 'donut') {
|
|
return {
|
|
kind: 'donut',
|
|
innerRadius: indicator.innerRadius,
|
|
outerRadius: indicator.outerRadius + 18,
|
|
position: indicator.position,
|
|
}
|
|
}
|
|
if (indicator.kind === 'arc') {
|
|
return {
|
|
kind: 'arc',
|
|
angleRadians: indicator.angleRadians,
|
|
direction: indicator.direction,
|
|
innerRadius: Math.max(0, indicator.innerRadius - 8),
|
|
outerRadius: indicator.outerRadius + 24,
|
|
position: indicator.position,
|
|
}
|
|
}
|
|
return {
|
|
kind: 'cone',
|
|
angleRadians: indicator.angleRadians,
|
|
direction: indicator.direction,
|
|
origin: indicator.origin,
|
|
range: indicator.range + 24,
|
|
}
|
|
}
|
|
|
|
function getHazardAvoidancePosition(member: Iwt2PartyEntityState, state: Iwt2ArenaState): Iwt2Vec2 | null {
|
|
let escape = { x: 0, y: 0 }
|
|
let maxNeededDistance = 0
|
|
|
|
for (const hazard of state.hazards) {
|
|
const offset = subtractVec2(member.position, hazard.position)
|
|
const distance = distanceVec2(member.position, hazard.position)
|
|
const dangerRadius = hazard.radius + member.radius + 46
|
|
if (distance >= dangerRadius) continue
|
|
|
|
const fallback = withFallbackFacing(subtractVec2(member.position, getPrimaryBoss(state).position), {
|
|
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
|
y: member.position.y < state.bounds.height * 0.5 ? -0.35 : 0.35,
|
|
})
|
|
const away = distance > 0.001 ? normalizeVec2(offset) : fallback
|
|
const urgency = dangerRadius - distance
|
|
escape = addVec2(escape, scaleVec2(away, urgency))
|
|
maxNeededDistance = Math.max(maxNeededDistance, urgency)
|
|
}
|
|
|
|
if (maxNeededDistance <= 0) return null
|
|
|
|
const direction = withFallbackFacing(escape, {
|
|
x: member.position.x < state.bounds.width * 0.5 ? -1 : 1,
|
|
y: 0,
|
|
})
|
|
const destination = clampPartyDestination(
|
|
addVec2(member.position, scaleVec2(direction, maxNeededDistance + 96)),
|
|
member,
|
|
state,
|
|
)
|
|
if (distanceVec2(destination, member.position) > PARTY_ARRIVAL_RADIUS) return destination
|
|
|
|
const centerDirection = withFallbackFacing(subtractVec2(arenaCenter(state), member.position), direction)
|
|
return clampPartyDestination(
|
|
addVec2(member.position, scaleVec2(centerDirection, maxNeededDistance + 120)),
|
|
member,
|
|
state,
|
|
)
|
|
}
|
|
|
|
function getPriorityAttackTarget(
|
|
member: Iwt2PartyEntityState,
|
|
state: Iwt2ArenaState,
|
|
): (Iwt2ArenaState['boss'] | Iwt2HostileAddState) | undefined {
|
|
if (member.health <= 0) return undefined
|
|
const livingAdds = state.hostileAdds.filter((add) => add.health > 0)
|
|
if (livingAdds.length > 0) {
|
|
return livingAdds.reduce((best, add) => (
|
|
distanceVec2(member.position, add.position) < distanceVec2(member.position, best.position) ? add : best
|
|
), livingAdds[0])
|
|
}
|
|
const livingBosses = state.bosses.filter((boss) => boss.health > 0)
|
|
if (livingBosses.length === 0) return undefined
|
|
return livingBosses.reduce((best, boss) => (
|
|
distanceVec2(member.position, boss.position) < distanceVec2(member.position, best.position) ? boss : best
|
|
), livingBosses[0])
|
|
}
|
|
|
|
function chargeDanger(position: Iwt2Vec2, boss: Iwt2ArenaState['boss']) {
|
|
const segment = subtractVec2(boss.chargeEnd, boss.chargeStart)
|
|
const lengthSq = Math.max(1, lengthSqVec2(segment))
|
|
const direction = normalizeVec2(segment)
|
|
const offset = subtractVec2(position, boss.chargeStart)
|
|
const along = dotVec2(offset, segment) / lengthSq
|
|
const closest = addVec2(boss.chargeStart, scaleVec2(segment, Math.min(1, Math.max(0, along))))
|
|
const distance = distanceVec2(position, closest)
|
|
const width = boss.radius + 22
|
|
return {
|
|
direction,
|
|
inside: along >= -0.08 && along <= 1.08 && distance <= width,
|
|
ahead: boss.attackPhase === 'chargeWindup' && along > -0.12 && along < 1.05 && distance <= width + 38,
|
|
}
|
|
}
|
|
|
|
function closestPointOnSegment(position: Iwt2Vec2, start: Iwt2Vec2, end: Iwt2Vec2): Iwt2Vec2 {
|
|
const segment = subtractVec2(end, start)
|
|
const lengthSq = Math.max(1, lengthSqVec2(segment))
|
|
const t = Math.min(1, Math.max(0, dotVec2(subtractVec2(position, start), segment) / lengthSq))
|
|
return addVec2(start, scaleVec2(segment, t))
|
|
}
|
|
|
|
function movementSpeedMultiplier(slowedSeconds: number): number {
|
|
return slowedSeconds > 0 ? 0.58 : 1
|
|
}
|
|
|
|
function getPrimaryBoss(state: Iwt2ArenaState) {
|
|
return state.bosses.find((boss) => boss.health > 0) ?? state.bosses[0] ?? state.boss
|
|
}
|
|
|
|
function decisionDrift(member: Iwt2PartyEntityState, time: number): Iwt2Vec2 {
|
|
const seed = member.id.length * 17
|
|
return {
|
|
x: Math.sin(time * 0.7 + seed) * 16,
|
|
y: Math.cos(time * 0.9 + seed) * 14,
|
|
}
|
|
}
|
|
|
|
function nextDecisionInterval(member: Iwt2PartyEntityState): number {
|
|
if (member.aiRole === 'tank') return 0.34
|
|
if (member.aiRole === 'ranged') return 0.48
|
|
if (member.aiRole === 'flanker') return 0.28
|
|
return 0.38
|
|
}
|