Release v0.1.3 2026-07-11

This commit is contained in:
Warren H
2026-07-11 23:23:02 -04:00
parent 076f6cf97c
commit b48b3a4f8f
103 changed files with 6708 additions and 454 deletions
+43 -23
View File
@@ -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;
}