329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
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<MemberId, "aelia">;
|
|
|
|
export interface PartyBehaviorContext {
|
|
memberId: AiMemberId;
|
|
current: WorldPosition;
|
|
formationTarget: WorldPosition;
|
|
bossMotion: BossMotionState;
|
|
partyPositions: Record<MemberId, WorldPosition>;
|
|
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<AiMemberId, number> = { brann: 1.45, nia: 1.2, orin: 1.1, vale: 2.2 };
|
|
const EVADE_SIDES: Record<AiMemberId, -1 | 1> = { brann: -1, nia: -1, orin: 1, vale: 1 };
|
|
const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
|
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<AiMemberId, WorldPosition> = {
|
|
// 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<AiMemberId, WorldPosition> {
|
|
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<MemberId, WorldPosition>,
|
|
bossMotionOrMotions: BossMotionState | readonly BossMotionState[],
|
|
party: PartyMember[],
|
|
time: number,
|
|
delta: number,
|
|
behaviors: readonly PartyBehavior[] = DEFAULT_PARTY_BEHAVIORS,
|
|
moveSpeedMultipliers?: Partial<Record<AiMemberId, number>>,
|
|
) {
|
|
const bossMotions = Array.isArray(bossMotionOrMotions) ? bossMotionOrMotions : [bossMotionOrMotions];
|
|
const activeMotions = bossMotions;
|
|
const next: Record<MemberId, WorldPosition> = {
|
|
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;
|
|
}
|