Release v0.1.3 2026-07-11
This commit is contained in:
+43
-23
@@ -1,6 +1,6 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { clampToArena } from "./arena";
|
||||
import { CINDER_BREATH } from "./bosses/cindermaw";
|
||||
import { EMBER_MANTIS_SLASH } from "./bosses/emberMantis";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
@@ -34,25 +34,45 @@ const STACK_OFFSETS: Record<AiMemberId, WorldPosition> = {
|
||||
orin: [0.45, -0.4],
|
||||
vale: [0, 0.65],
|
||||
};
|
||||
const LANE_EVADE_MODES: readonly BossMotionState["mode"][] = [
|
||||
"mantis_line_telegraph",
|
||||
"mantis_cross_telegraph",
|
||||
"ram_charge_telegraph",
|
||||
"ram_charging",
|
||||
"ram_shatter",
|
||||
"cinderback_curl",
|
||||
"cinderback_ricochet",
|
||||
"sandglass_burrow_telegraph",
|
||||
"sandglass_burrowing",
|
||||
"crab_scuttle_telegraph",
|
||||
"crab_scuttling",
|
||||
"ghost_soul_cross",
|
||||
"ghost_soul_cross_followup",
|
||||
];
|
||||
const FORMATION_MODES: readonly BossMotionState["mode"][] = [
|
||||
"holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover",
|
||||
"ram_quake", "ram_recover", "cinderback_slam", "cinderback_recover", "sandglass_eruption", "sandglass_hourglass", "sandglass_recover",
|
||||
"crab_tidal_burst", "crab_recover", "ghost_haunting", "ghost_recover", "golem_shockwave", "golem_crownfall", "golem_recover",
|
||||
];
|
||||
const DASH_MODES: readonly BossMotionState["mode"][] = ["telegraph", "charging", "ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"];
|
||||
|
||||
const ARENA_BOUNDS = { minX: -7.2, maxX: 7.2, minZ: -4.8, maxZ: 7.2 } as const;
|
||||
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], boss[1] + 4.25],
|
||||
nia: [boss[0] - 3.3, boss[1] + 7.2],
|
||||
orin: [boss[0] + 3.3, boss[1] + 7.2],
|
||||
vale: [boss[0] + 1.75, boss[1] + 3.4],
|
||||
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]],
|
||||
};
|
||||
}
|
||||
|
||||
function clampToArena(position: WorldPosition): WorldPosition {
|
||||
return [
|
||||
Math.max(ARENA_BOUNDS.minX, Math.min(ARENA_BOUNDS.maxX, position[0])),
|
||||
Math.max(ARENA_BOUNDS.minZ, Math.min(ARENA_BOUNDS.maxZ, position[1])),
|
||||
];
|
||||
}
|
||||
|
||||
export const stackForPounceBehavior: PartyBehavior = {
|
||||
id: "stack-for-pounce",
|
||||
decide: ({ memberId, bossMotion }) => {
|
||||
@@ -126,10 +146,10 @@ export const avoidBreathBehavior: PartyBehavior = {
|
||||
export const evadeSlashLanesBehavior: PartyBehavior = {
|
||||
id: "evade-slash-lanes",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion }) => {
|
||||
if (bossMotion.mode !== "mantis_line_telegraph" && bossMotion.mode !== "mantis_cross_telegraph") return null;
|
||||
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) < EMBER_MANTIS_SLASH.aiClearance,
|
||||
pointToSegmentDistance(position, lane.start, lane.end) < lane.width * 0.5 + 0.7,
|
||||
);
|
||||
if (!unsafe(current) && !unsafe(formationTarget)) return null;
|
||||
|
||||
@@ -155,7 +175,7 @@ export const evadeSlashLanesBehavior: PartyBehavior = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { target: best, speed: EMBER_MANTIS_SLASH.aiEvadeSpeed };
|
||||
return { target: best, speed: 4.8 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -180,7 +200,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover"].includes(bossMotion.mode)) return null;
|
||||
if (!FORMATION_MODES.includes(bossMotion.mode)) return null;
|
||||
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
|
||||
},
|
||||
};
|
||||
@@ -202,6 +222,7 @@ export function updatePartyPositions(
|
||||
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;
|
||||
@@ -214,13 +235,10 @@ export function updatePartyPositions(
|
||||
};
|
||||
if (!activeMotions.length) return next;
|
||||
const formationOrigin: WorldPosition = [
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (motion.mode === "telegraph" || motion.mode === "charging" ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
|
||||
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
|
||||
];
|
||||
const formation = combatFormation(formationOrigin);
|
||||
// Vale fights from the boss-cluster midpoint so short-range cleaves can
|
||||
// connect with both targets when their hit volumes overlap.
|
||||
formation.vale = [formationOrigin[0], formationOrigin[1] + 1.7];
|
||||
|
||||
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
|
||||
const memberId = AI_MEMBER_IDS[index];
|
||||
@@ -238,12 +256,14 @@ export function updatePartyPositions(
|
||||
time,
|
||||
});
|
||||
if (!decision) continue;
|
||||
next[memberId] = moveToward(next[memberId], decision.target, decision.speed * delta);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user