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() 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 }