import { clampToArena } from "./arena"; import { BULL_CHARGE, SKY_SWEEPER_BREATH } from "./bosses/mechanicPool"; import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry"; import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types"; export type AiMemberId = Exclude; export interface PartyBehaviorContext { memberId: AiMemberId; current: WorldPosition; formationTarget: WorldPosition; bossMotion: BossMotionState; partyPositions: Record; time: number; } export interface PartyBehaviorDecision { target: WorldPosition; speed: number; } export interface PartyBehavior { id: string; decide: (context: PartyBehaviorContext) => PartyBehaviorDecision | null; } const AI_MEMBER_IDS: readonly AiMemberId[] = ["brann", "nia", "orin", "vale"]; const MOVE_SPEEDS: Record = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 }; const EVADE_SIDES: Record = { brann: -1, nia: -1, orin: 1, vale: 1 }; const STACK_OFFSETS: Record = { brann: [-0.55, 0], nia: [0.45, 0.4], orin: [0.45, -0.4], vale: [0, 0.65], }; const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [ "mantis_line_telegraph", "mantis_cross_telegraph", ]; const FORMATION_MODES: readonly BossMotionState["mode"][] = [ "holding", "telegraph", "tethering", "venom_cast", "skyfall", "cinderback_slam", "sandglass_hourglass", "golem_shockwave", "golem_crownfall", ]; const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging"]; const FORMATION_SLOTS: Record = { // Bosses face Brann during normal uptime, making positive Z their front. brann: [0, 4.25], nia: [-3.3, 7.2], orin: [3.3, 7.2], vale: [0, -1.7], }; export function combatFormation(boss: WorldPosition): Record { return { brann: [boss[0] + FORMATION_SLOTS.brann[0], boss[1] + FORMATION_SLOTS.brann[1]], nia: [boss[0] + FORMATION_SLOTS.nia[0], boss[1] + FORMATION_SLOTS.nia[1]], orin: [boss[0] + FORMATION_SLOTS.orin[0], boss[1] + FORMATION_SLOTS.orin[1]], vale: [boss[0] + FORMATION_SLOTS.vale[0], boss[1] + FORMATION_SLOTS.vale[1]], }; } export const stackForPounceBehavior: PartyBehavior = { id: "stack-for-pounce", decide: ({ memberId, bossMotion }) => { if (bossMotion.mode !== "stacking") return null; const offset = STACK_OFFSETS[memberId]; return { target: memberId === bossMotion.pounceTargetId ? [bossMotion.pounceCenter[0], bossMotion.pounceCenter[1]] : [bossMotion.pounceCenter[0] + offset[0], bossMotion.pounceCenter[1] + offset[1]], speed: 3.4, }; }, }; export const stackForPooledSoakBehavior: PartyBehavior = { id: "stack-for-pooled-soak", decide: ({ memberId, bossMotion, time }) => { const soak = bossMotion.poolTelegraphs.find((telegraph) => telegraph.kind === "soak" && !telegraph.resolved && telegraph.activatesAt > time && telegraph.activatesAt - time <= 2.2, ); if (!soak) return null; const offset = STACK_OFFSETS[memberId]; return { target: [soak.center[0] + offset[0], soak.center[1] + offset[1]], speed: 3.8, }; }, }; export const breakTetherBehavior: PartyBehavior = { id: "break-tether", decide: ({ memberId, current, bossMotion, partyPositions }) => { if (bossMotion.mode !== "tethering" || !bossMotion.tetherIds.includes(memberId)) return null; const otherId = bossMotion.tetherIds.find((id) => id !== memberId); if (!otherId) return null; const other = partyPositions[otherId]; const dx = current[0] - other[0]; const dz = current[1] - other[1]; const length = Math.hypot(dx, dz); const fallback = EVADE_SIDES[memberId] * Math.PI * 0.5; const target = length < 0.01 ? [current[0] + Math.sin(fallback) * bossMotion.tetherBreakDistance, current[1] + Math.cos(fallback) * bossMotion.tetherBreakDistance] as WorldPosition : [current[0] + (dx / length) * bossMotion.tetherBreakDistance, current[1] + (dz / length) * bossMotion.tetherBreakDistance] as WorldPosition; return { target: clampToArena(target), speed: 3.7 }; }, }; export const evadeChargeBehavior: PartyBehavior = { id: "evade-charge", decide: ({ memberId, current, formationTarget, bossMotion }) => { if (bossMotion.mode !== "telegraph" && bossMotion.mode !== "charging") return null; const { chargeStart, chargeEnd } = bossMotion; const currentUnsafe = pointToSegmentDistance(current, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance; const formationUnsafe = pointToSegmentDistance(formationTarget, chargeStart, chargeEnd) < BULL_CHARGE.aiClearance; if (!currentUnsafe && !formationUnsafe) return null; // Derive from the stable formation slot so the target cannot flip sides as the member moves. const evadeTarget = pointOutsideLane( formationTarget, chargeStart, chargeEnd, BULL_CHARGE.aiClearance, EVADE_SIDES[memberId], ); return { target: clampToArena(evadeTarget), speed: BULL_CHARGE.aiEvadeSpeed }; }, }; export const avoidBreathBehavior: PartyBehavior = { id: "avoid-breath", decide: ({ memberId, bossMotion }) => { if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null; const side = EVADE_SIDES[memberId]; const safeAngle = bossMotion.breathAngle + side * (SKY_SWEEPER_BREATH.halfAngle + Math.PI * 0.42); const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4; return { target: clampToArena([ bossMotion.position[0] + Math.sin(safeAngle) * radius, bossMotion.position[1] + Math.cos(safeAngle) * radius, ]), speed: 4.1, }; }, }; export const evadeSlashLanesBehavior: PartyBehavior = { id: "evade-slash-lanes", decide: ({ memberId, current, formationTarget, bossMotion }) => { if (!LANE_EVADE_MODES.includes(bossMotion.mode)) return null; if (!bossMotion.slashLanes.length) return null; const unsafe = (position: WorldPosition) => bossMotion.slashLanes.some((lane) => pointToSegmentDistance(position, lane.start, lane.end) < lane.width * 0.5 + 0.7, ); if (!unsafe(current) && !unsafe(formationTarget)) return null; const origin = unsafe(formationTarget) ? formationTarget : current; const memberOffset = AI_MEMBER_IDS.indexOf(memberId) * (Math.PI / 4); let best = current; let bestScore = Number.NEGATIVE_INFINITY; for (const radius of [2.2, 3.4]) { for (let index = 0; index < 8; index += 1) { const angle = memberOffset + (index / 8) * Math.PI * 2; const candidate = clampToArena([ origin[0] + Math.sin(angle) * radius, origin[1] + Math.cos(angle) * radius, ]); let laneDistance = Number.POSITIVE_INFINITY; for (const lane of bossMotion.slashLanes) { laneDistance = Math.min(laneDistance, pointToSegmentDistance(candidate, lane.start, lane.end)); } const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08; const score = laneDistance - travelPenalty; if (score > bestScore) { best = candidate; bestScore = score; } } } return { target: best, speed: 4.8 }; }, }; export const avoidCircleHazardsBehavior: PartyBehavior = { id: "avoid-circle-hazards", decide: ({ memberId, current, formationTarget, bossMotion, time }) => { for (let index = 0; index < bossMotion.hazards.length; index += 1) { const hazard = bossMotion.hazards[index]; if (hazard.expiresAt <= time || hazard.activatesAt - time > 2.2) continue; const clearance = hazard.radius + 0.55; const currentUnsafe = Math.hypot(current[0] - hazard.center[0], current[1] - hazard.center[1]) < clearance; const formationUnsafe = Math.hypot(formationTarget[0] - hazard.center[0], formationTarget[1] - hazard.center[1]) < clearance; if (!currentUnsafe && !formationUnsafe) continue; const source = formationUnsafe ? formationTarget : current; const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2; return { target: clampToArena(pointOutsideCircle(source, hazard.center, clearance, fallbackAngle)), speed: 4 }; } return null; }, }; export const reactToPooledTelegraphsBehavior: PartyBehavior = { id: "react-to-pooled-telegraphs", decide: ({ memberId, current, formationTarget, bossMotion, time }) => { for (const telegraph of bossMotion.poolTelegraphs) { if (telegraph.resolved || telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.activatesAt - time > 2.2) continue; const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2; if (telegraph.kind === "beam" && telegraph.start && telegraph.end) { const clearance = (telegraph.width ?? 0) * 0.5 + 0.7; const currentUnsafe = pointToSegmentDistance(current, telegraph.start, telegraph.end) < clearance; const formationUnsafe = pointToSegmentDistance(formationTarget, telegraph.start, telegraph.end) < clearance; if (!currentUnsafe && !formationUnsafe) continue; const source = formationUnsafe ? formationTarget : current; return { target: clampToArena(pointOutsideLane(source, telegraph.start, telegraph.end, clearance, EVADE_SIDES[memberId])), speed: 4.8, }; } const distanceFromCenter = (position: WorldPosition) => Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]); if (telegraph.kind === "donut") { const innerSafeRadius = Math.max(0.5, (telegraph.innerRadius ?? 0) - 0.45); const isUnsafe = (position: WorldPosition) => { const distance = distanceFromCenter(position); return distance >= innerSafeRadius && distance <= telegraph.radius + 0.45; }; if (!isUnsafe(current) && !isUnsafe(formationTarget)) continue; const source = isUnsafe(formationTarget) ? formationTarget : current; const sourceDistance = distanceFromCenter(source); const direction = sourceDistance > 0.01 ? [(source[0] - telegraph.center[0]) / sourceDistance, (source[1] - telegraph.center[1]) / sourceDistance] as WorldPosition : [Math.sin(fallbackAngle), Math.cos(fallbackAngle)] as WorldPosition; return { target: clampToArena([ telegraph.center[0] + direction[0] * innerSafeRadius, telegraph.center[1] + direction[1] * innerSafeRadius, ]), speed: 4.3, }; } const clearance = telegraph.radius + 0.55; const currentUnsafe = distanceFromCenter(current) < clearance; const formationUnsafe = distanceFromCenter(formationTarget) < clearance; if (!currentUnsafe && !formationUnsafe) continue; const source = formationUnsafe ? formationTarget : current; return { target: clampToArena(pointOutsideCircle(source, telegraph.center, clearance, fallbackAngle)), speed: 4.1 }; } return null; }, }; export const maintainFormationBehavior: PartyBehavior = { id: "maintain-formation", decide: ({ formationTarget, bossMotion, memberId }) => { if (!FORMATION_MODES.includes(bossMotion.mode)) return null; return { target: formationTarget, speed: MOVE_SPEEDS[memberId] }; }, }; export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [ breakTetherBehavior, stackForPounceBehavior, stackForPooledSoakBehavior, evadeChargeBehavior, evadeSlashLanesBehavior, avoidBreathBehavior, reactToPooledTelegraphsBehavior, avoidCircleHazardsBehavior, maintainFormationBehavior, ]; export function updatePartyPositions( current: Record, bossMotionOrMotions: BossMotionState | readonly BossMotionState[], party: PartyMember[], time: number, delta: number, behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS, moveSpeedMultipliers?: Partial>, ) { const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions]; const activeMotions = bossMotions; const next: Record = { aelia: [current.aelia[0], current.aelia[1]], brann: [current.brann[0], current.brann[1]], nia: [current.nia[0], current.nia[1]], orin: [current.orin[0], current.orin[1]], vale: [current.vale[0], current.vale[1]], }; if (!activeMotions.length) return next; let formationX = 0; let formationZ = 0; for (const motion of activeMotions) { const origin = DASH_MODES.includes(motion.mode) ? motion.chargeStart : motion.position; formationX += origin[0]; formationZ += origin[1]; } const formationOrigin: WorldPosition = [formationX / activeMotions.length, formationZ / activeMotions.length]; const formation = combatFormation(formationOrigin); for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) { const memberId = AI_MEMBER_IDS[index]; const member = party[index + 1]; if (!member || member.hp <= 0 || member.knockedUntil > time) continue; for (const behavior of behaviors) { let handled = false; for (const bossMotion of activeMotions) { const decision = behavior.decide({ memberId, current: next[memberId], formationTarget: formation[memberId], bossMotion, partyPositions: next, time, }); if (!decision) continue; next[memberId] = clampToArena(moveToward(next[memberId], decision.target, decision.speed * (moveSpeedMultipliers?.[memberId] ?? 1) * delta)); handled = true; break; } if (handled) break; } } for (const memberId of AI_MEMBER_IDS) next[memberId] = clampToArena(next[memberId]); next.aelia = clampToArena(next.aelia); return next; }