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
@@ -2,7 +2,7 @@ import { NodeIO } from "@gltf-transform/core";
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
@@ -16,10 +16,13 @@ import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||
import { planPartyRoute } from "./partyPathing";
|
||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
|
||||
import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, hasDungeonLineOfSight } from "../scene/dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator } from "../scene/partyTraversal";
|
||||
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision";
|
||||
import { choosePartyStuckRecoveryPosition } from "./partyStuckRecovery";
|
||||
import { createMobPursuit } from "./mobPursuit";
|
||||
|
||||
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
|
||||
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
||||
@@ -83,13 +86,20 @@ beforeAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function collisionWorld(useRamps = true): RAPIER.World {
|
||||
function collisionWorld(useRamps = true, includeSight = false): RAPIER.World {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []);
|
||||
for (const trimesh of collisionTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
));
|
||||
).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
}
|
||||
if (includeSight) {
|
||||
for (const trimesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS).setSensor(true));
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
world.step();
|
||||
@@ -101,6 +111,91 @@ function distance(left: readonly number[], right: readonly number[]): number {
|
||||
}
|
||||
|
||||
describe("Stormwind Stockade party navigation", () => {
|
||||
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const prepared = prepareCollision(sourceCollision, stockades.traversalRamps);
|
||||
let checkedWalls = 0;
|
||||
for (const mesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) {
|
||||
const a = Number(mesh.indices[offset]) * 3;
|
||||
const b = Number(mesh.indices[offset + 1]) * 3;
|
||||
const c = Number(mesh.indices[offset + 2]) * 3;
|
||||
const v = mesh.vertices;
|
||||
const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])];
|
||||
const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])];
|
||||
const normalX = ab[1] * ac[2] - ab[2] * ac[1];
|
||||
const normalZ = ab[0] * ac[1] - ab[1] * ac[0];
|
||||
const length = Math.hypot(normalX, normalZ);
|
||||
if (length < 0.1) continue;
|
||||
const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3);
|
||||
const first = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position;
|
||||
const second = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position;
|
||||
if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5
|
||||
|| Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15
|
||||
|| Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3
|
||||
|| Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue;
|
||||
const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ;
|
||||
const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ;
|
||||
if (sideA * sideB >= 0) continue;
|
||||
// Nearby decorative faces/door-frame triangles are not room walls
|
||||
// unless the actors' chest ray actually crosses this source face.
|
||||
const origin = new Vector3(first[0], first[1] + 1.05, first[2]);
|
||||
const destination = new Vector3(second[0], second[1] + 1.05, second[2]);
|
||||
const ray = new Ray(origin, destination.clone().sub(origin).normalize());
|
||||
const intersection = ray.intersectTriangle(
|
||||
new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])),
|
||||
new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])),
|
||||
new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(),
|
||||
);
|
||||
if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue;
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false);
|
||||
checkedWalls += 1;
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
expect(checkedWalls).toBeGreaterThanOrEqual(8);
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true);
|
||||
}
|
||||
} finally { world.free(); }
|
||||
}, 20_000);
|
||||
|
||||
it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed,
|
||||
hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b),
|
||||
validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) });
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) {
|
||||
const initialRoute = planPartyRoute(graph, "probe", start, target, 0, {
|
||||
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||
directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed,
|
||||
destinationReached: (surface, destination) => distance(surface, destination) <= 1.5
|
||||
&& hasDungeonLineOfSight(RAPIER, world, surface, destination),
|
||||
});
|
||||
expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false);
|
||||
const position: [number, number, number] = [...start];
|
||||
for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) {
|
||||
const before = [...position];
|
||||
expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position,
|
||||
target, home: start, phase: "chasing", health: 100, engaged: true, active: true,
|
||||
casting: false, controlled: false, attackRange: 1.5, leashRange: 40,
|
||||
maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none");
|
||||
}
|
||||
expect(distance(position, target)).toBeLessThanOrEqual(1.5);
|
||||
}
|
||||
}
|
||||
} finally { world.free(); }
|
||||
});
|
||||
|
||||
it("uses the reviewed warm indoor lighting profile", () => {
|
||||
expect(stockades.presentation).toMatchObject({
|
||||
background: "#171815",
|
||||
@@ -310,6 +405,42 @@ describe("Stormwind Stockade party navigation", () => {
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("finds grounded recovery landings on all three stair ramps", () => {
|
||||
const world = collisionWorld();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph);
|
||||
for (const ramp of stockades.traversalRamps!) {
|
||||
const point = (t: number): [number, number, number] => [
|
||||
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
|
||||
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
|
||||
ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t,
|
||||
];
|
||||
expect(allowed(point(0.5), point(0.52)), ramp.id).not.toBeNull();
|
||||
}
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("recovers from boss-room approaches onto a validated visited trail near the group", () => {
|
||||
const world = collisionWorld();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const entrance = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position;
|
||||
for (const objective of stockades.objectiveOrder) {
|
||||
const current = findNearestPartyNavigationSurface(graph, objective.position, 15)!.position;
|
||||
const path = findPartyNavigationPath(graph, entrance, current, { routeSegmentAllowed: routeAllowed })!;
|
||||
const trail = path.slice(1).map((point, index): [number, number, number] => [
|
||||
(path[index][0] + point[0]) / 2, (path[index][1] + point[1]) / 2, (path[index][2] + point[2]) / 2,
|
||||
]).filter((point) => distance(point, current) >= 2 && distance(point, current) <= 12
|
||||
&& Math.abs(point[1] - current[1]) <= 1.25 && allowed(point, point));
|
||||
expect(trail.length, objective.name).toBeGreaterThan(0);
|
||||
const anchor = trail.at(-1)!;
|
||||
const landing = choosePartyStuckRecoveryPosition({ current, player: entrance, groupAnchor: anchor,
|
||||
trailPoints: trail, validate: allowed });
|
||||
expect(landing?.source, objective.name).toBe("trail");
|
||||
expect(allowed(landing!.position, anchor), objective.name).not.toBeNull();
|
||||
}
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("walks the player capsule down and back up every ramp without jumping", () => {
|
||||
for (const ramp of stockades.traversalRamps!) {
|
||||
const world = collisionWorld();
|
||||
|
||||
Reference in new issue
Block a user