forked from phenom/healer-man
Release Healer Man 0.1.36
This commit is contained in:
1 parent
40d9f346d8
commit
03a4e3f51c
22 files changed
+1389
-153
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
import { distanceSquared, moveMobToward, type MobAiPhase, type MobAiPosition, type MutableMobAiPosition } from "./mobAi";
|
||||
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute } from "./partyPathing";
|
||||
import { createPartyStuckRecoveryState, markPartyStuckRecovered, updatePartyStuckRecovery, type PartyStuckRecoveryState } from "./partyStuckRecovery";
|
||||
|
||||
export const MOB_NEAR_RECOVERY_DISTANCE = 10;
|
||||
const SAME_FLOOR_TOLERANCE = 1.25;
|
||||
|
||||
export interface MobPursuitSample {
|
||||
readonly id: string;
|
||||
readonly targetId: string | null;
|
||||
readonly position: MutableMobAiPosition;
|
||||
readonly target: MobAiPosition;
|
||||
readonly home: MobAiPosition;
|
||||
readonly phase: MobAiPhase;
|
||||
readonly health: number;
|
||||
readonly engaged: boolean;
|
||||
readonly active: boolean;
|
||||
readonly casting: boolean;
|
||||
readonly controlled: boolean;
|
||||
readonly attackRange: number;
|
||||
readonly leashRange: number;
|
||||
readonly maximumStep: number;
|
||||
readonly nowMs: number;
|
||||
}
|
||||
|
||||
export interface MobPursuitSpatial {
|
||||
readonly graph: () => PartyNavigationGraph | null;
|
||||
readonly hasLineOfSight: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||
readonly directAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||
readonly routeAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||
readonly validateLanding: (point: MobAiPosition, anchor: MobAiPosition) => MobAiPosition | null;
|
||||
}
|
||||
|
||||
/** Never blindly land inside the target, a wall, or an overlapping dungeon floor. */
|
||||
export function chooseMobPursuitLanding(sample: MobPursuitSample, spatial: MobPursuitSpatial): MobAiPosition | null {
|
||||
const { position, target, home } = sample;
|
||||
if (distanceSquared(position, target) > MOB_NEAR_RECOVERY_DISTANCE ** 2
|
||||
|| Math.abs(position[1] - target[1]) > SAME_FLOOR_TOLERANCE
|
||||
|| distanceSquared(home, target) > sample.leashRange ** 2) return null;
|
||||
const bearing = Math.atan2(position[0] - target[0], position[2] - target[2]);
|
||||
const radius = Math.max(0.85, Math.min(2, sample.attackRange * 0.6));
|
||||
for (let offset = 0; offset < 16; offset += 1) {
|
||||
const angle = bearing + offset * Math.PI / 8;
|
||||
const candidate: MobAiPosition = [target[0] + Math.sin(angle) * radius, target[1], target[2] + Math.cos(angle) * radius];
|
||||
const landing = spatial.validateLanding(candidate, target);
|
||||
if (landing?.every(Number.isFinite) && distanceSquared(landing, candidate) <= 0.85 ** 2
|
||||
&& distanceSquared(landing, target) <= sample.attackRange ** 2
|
||||
&& distanceSquared(landing, home) <= sample.leashRange ** 2
|
||||
&& Math.abs(landing[1] - target[1]) <= SAME_FLOOR_TOLERANCE
|
||||
&& spatial.hasLineOfSight(landing, target)) return [...landing];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shared imperative adapter for static spawns, roaming packs, and summoned enemies. */
|
||||
export function createMobPursuit(spatial: MobPursuitSpatial) {
|
||||
const states = new Map<string, {
|
||||
route: ReturnType<typeof emptyPartyRouteState>;
|
||||
watchdog: PartyStuckRecoveryState;
|
||||
graph: PartyNavigationGraph | null;
|
||||
}>();
|
||||
const desired: MutableMobAiPosition = [0, 0, 0];
|
||||
const next: MutableMobAiPosition = [0, 0, 0];
|
||||
return {
|
||||
clear: () => states.clear(),
|
||||
step(sample: MobPursuitSample): "none" | "teleported" | "reset" | "home" {
|
||||
const { position, target, home, nowMs } = sample;
|
||||
if (![...position, ...target, ...home, nowMs, sample.attackRange, sample.leashRange, sample.maximumStep].every(Number.isFinite)) return "none";
|
||||
let state = states.get(sample.id);
|
||||
if (!state) {
|
||||
state = { route: emptyPartyRouteState(), watchdog: createPartyStuckRecoveryState(position, nowMs), graph: spatial.graph() };
|
||||
states.set(sample.id, state);
|
||||
}
|
||||
const returning = sample.phase === "returning";
|
||||
const goal = returning ? home : target;
|
||||
const visible = spatial.hasLineOfSight(position, target);
|
||||
const inRange = visible && distanceSquared(position, target) <= sample.attackRange ** 2;
|
||||
const movementExpected = returning ? distanceSquared(position, home) > 0.2 ** 2 : sample.engaged && !inRange;
|
||||
const excluded = !sample.active || sample.health <= 0 || sample.controlled || sample.casting;
|
||||
const goalKey = returning ? "return-home" : `target:${sample.targetId}`;
|
||||
const graph = spatial.graph();
|
||||
if (state.graph !== graph) {
|
||||
state.graph = graph;
|
||||
state.route = emptyPartyRouteState();
|
||||
}
|
||||
let waypoint: MobAiPosition | null = null;
|
||||
let approvedRouteStep = false;
|
||||
if (movementExpected && !excluded) {
|
||||
if (spatial.directAllowed(position, goal)) waypoint = goal;
|
||||
else {
|
||||
if (partyRouteNeedsPlanning(state.route, goalKey, position, goal, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||
state.route = planPartyRoute(graph, goalKey, position, goal, nowMs, {
|
||||
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||
directSegmentAllowed: spatial.directAllowed,
|
||||
routeSegmentAllowed: graph?.triangles?.length ? spatial.routeAllowed : undefined,
|
||||
destinationReached: (surface, destination) => distanceSquared(surface, destination)
|
||||
<= (returning ? 0.2 : sample.attackRange) ** 2
|
||||
&& (returning || spatial.hasLineOfSight(surface, destination)),
|
||||
});
|
||||
}
|
||||
if (nextPartyRouteWaypoint(state.route, position, desired)) {
|
||||
waypoint = desired;
|
||||
approvedRouteStep = true;
|
||||
} else if (nextPartyRouteRecoveryWaypoint(state.route, position, desired, spatial.directAllowed)) waypoint = desired;
|
||||
}
|
||||
if (waypoint) {
|
||||
moveMobToward(position, waypoint, sample.maximumStep, next);
|
||||
// Recast edges are already agent-radius eroded. A second full-radius
|
||||
// sweep at boundary vertices can reject a valid stair corridor;
|
||||
// use the same center/wall check as companions on approved edges.
|
||||
// Off-surface connectors and direct shortcuts still need body sweeps.
|
||||
if (spatial.directAllowed(position, next) || (approvedRouteStep && spatial.routeAllowed(position, next))) {
|
||||
position[0] = next[0]; position[1] = next[1]; position[2] = next[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
const recovery = updatePartyStuckRecovery(state.watchdog, {
|
||||
position, waypoint, goalKey, movementExpected, nowMs, health: sample.health,
|
||||
paused: !sample.active, stopped: false, casting: sample.casting, controlled: sample.controlled,
|
||||
});
|
||||
if (returning && !excluded && !movementExpected) return "home";
|
||||
if (recovery !== "none") state.route = emptyPartyRouteState();
|
||||
if (recovery !== "recover") return "none";
|
||||
const landing = returning ? spatial.validateLanding(home, home) : chooseMobPursuitLanding(sample, spatial);
|
||||
if (landing && (!returning || distanceSquared(landing, home) <= 0.85 ** 2)) {
|
||||
position[0] = landing[0]; position[1] = landing[1]; position[2] = landing[2];
|
||||
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||
return returning ? "home" : "teleported";
|
||||
}
|
||||
if (!returning) {
|
||||
const homeLanding = spatial.validateLanding(home, home);
|
||||
if (homeLanding?.every(Number.isFinite) && distanceSquared(homeLanding, home) <= 0.85 ** 2) {
|
||||
position[0] = homeLanding[0]; position[1] = homeLanding[1]; position[2] = homeLanding[2];
|
||||
}
|
||||
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||
return "reset";
|
||||
}
|
||||
return "none";
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user