forked from phenom/healer-man
Release Healer Man 0.1.34
This commit is contained in:
1 parent
05b25c4341
commit
46c272f889
19 files changed
+568
-121
No files matched your search
@@ -2258,3 +2258,50 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
run without output; its existing report files were not changed. Stockades
|
run without output; its existing report files were not changed. Stockades
|
||||||
navigation evidence comes from the actual shipping-asset regressions and
|
navigation evidence comes from the actual shipping-asset regressions and
|
||||||
the live smoke pass above, not a new fleet-wide functionality certification.
|
the live smoke pass above, not a new fleet-wide functionality certification.
|
||||||
|
|
||||||
|
## 2026-09-12 - Stockades stair-ramp follow-up
|
||||||
|
|
||||||
|
- Follow-up player feedback reported companions still sticking near stairs.
|
||||||
|
Actual collision profiles contain approximately 0.44-0.59m risers, above
|
||||||
|
the party sweep's 0.32m lower-leg clearance. Collision-backed regressions
|
||||||
|
reproduce uphill rejection on the original shipping collision GLB.
|
||||||
|
- Added three reviewed package-space traversal proxies for the entrance and
|
||||||
|
both prison-wing stair flights. Collision tread/riser vertices within the
|
||||||
|
reviewed stair relief are reshaped onto smooth ramp planes; upper walls,
|
||||||
|
ceilings, other floors, and geometry outside those footprints remain intact.
|
||||||
|
Source visual/collision GLBs are unchanged. Only prepared walking collision
|
||||||
|
and party navigation use the overrides; the rendered stairs remain steps.
|
||||||
|
- Navigation receives the same ramp planes with 0.05m surface clearance and
|
||||||
|
six new triangles stitching the exact source boundary portals. This replaces
|
||||||
|
the three vertex-only connections with filled triangle corridors, allowing
|
||||||
|
mid-stair replans and recalls to project onto the interior walking surface.
|
||||||
|
The reviewed graph now has 3,544 source triangles and 31 components, retaining
|
||||||
|
entrance-to-all-five-boss routes and discarding minor decorative islands.
|
||||||
|
- Live QA caught a second issue: terrain queries included the player's
|
||||||
|
kinematic capsule. A failing collision-backed regression confirmed false
|
||||||
|
route blockage through a nearby player. Party direct, route, and grounding
|
||||||
|
queries now exclude dynamic/kinematic actor bodies and sensors; crowd spacing
|
||||||
|
remains a separate party-separation concern. Static walls still block paths.
|
||||||
|
- Automated tests exercise shipping normalized/quantized GLBs through runtime
|
||||||
|
preparation, downhill/uphill party capsule checks on all three flights at
|
||||||
|
center and lateral offsets, repeated mid-stair replans, all boss routes with
|
||||||
|
live wall checks, tank melee approach with real collision validation, and
|
||||||
|
player capsule traversal down/back up all three ramps without jumping.
|
||||||
|
- Final desktop browser smoke pass at 1280 x 720 used offline level-15 Priest
|
||||||
|
Rolecheck and the same Blood DK/Combat Rogue/Unholy DK/Frost Mage party.
|
||||||
|
Attack began without ROUTE BLOCKED, companions changed position down the
|
||||||
|
entrance staircase, the tank moved out of the entrance view and took aggro,
|
||||||
|
and Backstab/Icy Touch/Plague Strike combat events appeared. Experience rose
|
||||||
|
from 6,576 to 11,098 as the party defeated trash with the player stationary.
|
||||||
|
Recall brought companions back to the entrance stairs. The unattended tank
|
||||||
|
eventually died during continued combat; this was not a healed full clear.
|
||||||
|
- Inline task screenshots captured the unchanged visible steps, equipped
|
||||||
|
character bodies, warm lighting, and party movement. Both wing flights have
|
||||||
|
collision-backed automated coverage; live end-to-end wing traversal, a full
|
||||||
|
dungeon clear, wipe/checkpoint behavior, and physical Android QA remain open.
|
||||||
|
- Validation: all 224 Vitest files passed, 1,380 tests passed, and six existing
|
||||||
|
tests remained skipped. Production TypeScript/Vite compilation passed using
|
||||||
|
the project's 8GB heap setting; texture/performance audits, static bundle
|
||||||
|
compression, and release-bundle verification passed. Existing build-size
|
||||||
|
advisories and deprecated physics initialization warnings remain. No hosted
|
||||||
|
release was published.
|
||||||
@@ -24,6 +24,41 @@
|
|||||||
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
|
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the south wing stairs."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"traversalRamps": [
|
||||||
|
{
|
||||||
|
"id": "entrance-stair-ramp",
|
||||||
|
"start": [-52.8, -18.16, 0.8],
|
||||||
|
"end": [-72.4, -25.609, 0.8],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "entrance-to-central-stairs",
|
||||||
|
"startPortal": [[-66.674328, -23.150503, -3.904271], [-66.674328, -23.150503, 5.501851]],
|
||||||
|
"endPortal": [[-67.664446, -23.755576, -2.895818], [-67.664446, -23.755576, 4.493397]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "north-wing-stair-ramp",
|
||||||
|
"start": [-129.17, -25.609, 11.7],
|
||||||
|
"end": [-129.17, -33.944, 32.85],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "central-to-north-wing-stairs",
|
||||||
|
"startPortal": [[-133.878601, -27.349338, 16.099782], [-124.46789, -27.349338, 16.099782]],
|
||||||
|
"endPortal": [[-132.668457, -27.95441, 17.0899], [-125.476344, -27.95441, 17.0899]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "south-wing-stair-ramp",
|
||||||
|
"start": [-129.17, -25.609, -10.3],
|
||||||
|
"end": [-129.17, -33.944, -31.45],
|
||||||
|
"width": 10.8,
|
||||||
|
"navigationBridge": {
|
||||||
|
"linkId": "central-to-south-wing-stairs",
|
||||||
|
"startPortal": [[-133.878601, -27.349338, -14.703893], [-124.46789, -27.349338, -14.703893]],
|
||||||
|
"endPortal": [[-132.668457, -27.95441, -15.694011], [-125.476344, -27.95441, -15.694011]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"background": "#171815",
|
"background": "#171815",
|
||||||
"fog": {
|
"fog": {
|
||||||
@@ -49,7 +84,7 @@
|
|||||||
"navigation": "green",
|
"navigation": "green",
|
||||||
"report": "PLAYTEST_NOTES.md",
|
"report": "PLAYTEST_NOTES.md",
|
||||||
"warnings": [
|
"warnings": [
|
||||||
"Three reviewed bidirectional links bridge the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
|
"Three reviewed smooth stair collision proxies and filled navigation bridges connect the entrance, central block, and both prison wings; minor decorative navmesh islands remain intentionally discarded.",
|
||||||
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
|
"Stockades uses a dungeon-specific warm indoor lighting profile because the imported WMO has no baked lightmap or vertex-lighting channel."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.33",
|
"version": "0.1.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.33",
|
"version": "0.1.34",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.33",
|
"version": "0.1.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.33",
|
"versionName": "0.1.34",
|
||||||
"versionCode": 1033
|
"versionCode": 1034
|
||||||
}
|
}
|
||||||
@@ -2011,6 +2011,19 @@ async function compile() {
|
|||||||
bidirectional: link.bidirectional !== false,
|
bidirectional: link.bidirectional !== false,
|
||||||
authoredReason: String(link.authoredReason),
|
authoredReason: String(link.authoredReason),
|
||||||
})),
|
})),
|
||||||
|
...(runtimeOverride.traversalRamps?.length ? {
|
||||||
|
traversalRamps: runtimeOverride.traversalRamps.map((ramp) => ({
|
||||||
|
id: String(ramp.id),
|
||||||
|
start: vector(ramp.start),
|
||||||
|
end: vector(ramp.end),
|
||||||
|
width: Number(ramp.width),
|
||||||
|
navigationBridge: {
|
||||||
|
linkId: String(ramp.navigationBridge.linkId),
|
||||||
|
startPortal: ramp.navigationBridge.startPortal.map(vector),
|
||||||
|
endPortal: ramp.navigationBridge.endPortal.map(vector),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
} : {}),
|
||||||
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
||||||
serverGameObjects,
|
serverGameObjects,
|
||||||
provenance: {
|
provenance: {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BufferGeometry, Float32BufferAttribute } from "three";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DungeonTraversalRamp } from "./dungeonTypes";
|
||||||
|
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||||
|
|
||||||
|
const RAMP: DungeonTraversalRamp = {
|
||||||
|
id: "stairs",
|
||||||
|
start: [0, 0, 0],
|
||||||
|
end: [0, 2, 6],
|
||||||
|
width: 4,
|
||||||
|
navigationBridge: {
|
||||||
|
linkId: "stair-seam",
|
||||||
|
startPortal: [[-1, 0.5, 2], [1, 0.5, 2]],
|
||||||
|
endPortal: [[-1, 1, 3], [1, 1, 3]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("reviewed dungeon stair proxies", () => {
|
||||||
|
it("smooths tread and riser vertices but preserves upper walls, ceilings and geometry outside the flight", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||||
|
0, 0.5, 2,
|
||||||
|
0, 1, 2,
|
||||||
|
0, 3, 2,
|
||||||
|
0, 5, 2,
|
||||||
|
3, 0.5, 2,
|
||||||
|
0, 0.5, -1,
|
||||||
|
], 3));
|
||||||
|
smoothDungeonStairGeometry(geometry, [RAMP]);
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
expect(position.getY(0)).toBeCloseTo(2 / 3);
|
||||||
|
expect(position.getY(1)).toBeCloseTo(2 / 3);
|
||||||
|
expect([2, 3, 4, 5].map((vertex) => position.getY(vertex))).toEqual([3, 5, 0.5, 0.5]);
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bridges exactly the existing reviewed portal vertices and rejects stale asset coordinates", () => {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||||
|
...RAMP.navigationBridge.startPortal.flat(),
|
||||||
|
...RAMP.navigationBridge.endPortal.flat(),
|
||||||
|
], 3));
|
||||||
|
geometry.setIndex([]);
|
||||||
|
smoothDungeonStairGeometry(geometry, [RAMP], 0.05);
|
||||||
|
bridgeDungeonStairNavigation(geometry, [RAMP], 0.05);
|
||||||
|
expect(Array.from(geometry.index!.array)).toEqual([0, 1, 3, 0, 3, 2]);
|
||||||
|
geometry.getAttribute("position").setX(0, -1.1);
|
||||||
|
expect(() => bridgeDungeonStairNavigation(geometry, [RAMP], 0.05))
|
||||||
|
.toThrow("reviewed navigation portal no longer matches the source GLB");
|
||||||
|
geometry.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { BufferGeometry } from "three";
|
||||||
|
import type { DungeonTraversalRamp, Vector3Tuple } from "./dungeonTypes";
|
||||||
|
|
||||||
|
// Only the stair relief is reshaped. Upper walls, ceilings and other floors
|
||||||
|
// remain untouched even where they share the staircase's horizontal footprint.
|
||||||
|
const MAX_STAIR_SURFACE_DELTA = 0.85;
|
||||||
|
const PORTAL_VERTEX_TOLERANCE = 0.002;
|
||||||
|
|
||||||
|
function rampHeight(
|
||||||
|
ramp: DungeonTraversalRamp,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
): number | null {
|
||||||
|
const dx = ramp.end[0] - ramp.start[0];
|
||||||
|
const dz = ramp.end[2] - ramp.start[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
if (
|
||||||
|
![...ramp.start, ...ramp.end, ramp.width].every(Number.isFinite)
|
||||||
|
|| length <= 0
|
||||||
|
|| ramp.width <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid dungeon traversal ramp: ${ramp.id}.`);
|
||||||
|
}
|
||||||
|
const offsetX = x - ramp.start[0];
|
||||||
|
const offsetZ = z - ramp.start[2];
|
||||||
|
const along = (offsetX * dx + offsetZ * dz) / length;
|
||||||
|
const across = (offsetX * dz - offsetZ * dx) / length;
|
||||||
|
if (along < 0 || along > length || Math.abs(across) > ramp.width * 0.5) return null;
|
||||||
|
return ramp.start[1] + (ramp.end[1] - ramp.start[1]) * along / length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten tread/riser relief onto a shared plane, without removing walls. */
|
||||||
|
export function smoothDungeonStairGeometry(
|
||||||
|
geometry: BufferGeometry,
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
surfaceClearance = 0,
|
||||||
|
): void {
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
for (let vertex = 0; vertex < position.count; vertex += 1) {
|
||||||
|
for (const ramp of ramps) {
|
||||||
|
const height = rampHeight(ramp, position.getX(vertex), position.getZ(vertex));
|
||||||
|
if (height === null || Math.abs(position.getY(vertex) - height) > MAX_STAIR_SURFACE_DELTA) continue;
|
||||||
|
position.setY(vertex, height + surfaceClearance);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stitch reviewed boundary edges, so routing can use filled triangles rather than vertex-only links. */
|
||||||
|
export function bridgeDungeonStairNavigation(
|
||||||
|
geometry: BufferGeometry,
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
surfaceClearance: number,
|
||||||
|
): void {
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
const indices = geometry.index
|
||||||
|
? Array.from(geometry.index.array)
|
||||||
|
: Array.from({ length: position.count }, (_, vertex) => vertex);
|
||||||
|
const portalVertex = (ramp: DungeonTraversalRamp, point: Vector3Tuple): number => {
|
||||||
|
const height = rampHeight(ramp, point[0], point[2]);
|
||||||
|
if (height === null) throw new Error(`${ramp.id}: navigation portal is outside the stair ramp.`);
|
||||||
|
for (let vertex = 0; vertex < position.count; vertex += 1) {
|
||||||
|
if (Math.hypot(
|
||||||
|
position.getX(vertex) - point[0],
|
||||||
|
position.getY(vertex) - height - surfaceClearance,
|
||||||
|
position.getZ(vertex) - point[2],
|
||||||
|
) <= PORTAL_VERTEX_TOLERANCE) return vertex;
|
||||||
|
}
|
||||||
|
throw new Error(`${ramp.id}: reviewed navigation portal no longer matches the source GLB.`);
|
||||||
|
};
|
||||||
|
for (const ramp of ramps) {
|
||||||
|
const [a, b] = ramp.navigationBridge.startPortal.map((point) => portalVertex(ramp, point));
|
||||||
|
const [c, d] = ramp.navigationBridge.endPortal.map((point) => portalVertex(ramp, point));
|
||||||
|
indices.push(a, b, d, a, d, c);
|
||||||
|
}
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
}
|
||||||
@@ -156,6 +156,19 @@ export interface DungeonNavigationLink {
|
|||||||
readonly authoredReason: string;
|
readonly authoredReason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reviewed package-space walking proxy; the rendered staircase is unchanged. */
|
||||||
|
export interface DungeonTraversalRamp {
|
||||||
|
readonly id: string;
|
||||||
|
readonly start: Vector3Tuple;
|
||||||
|
readonly end: Vector3Tuple;
|
||||||
|
readonly width: number;
|
||||||
|
readonly navigationBridge: {
|
||||||
|
readonly linkId: string;
|
||||||
|
readonly startPortal: readonly [Vector3Tuple, Vector3Tuple];
|
||||||
|
readonly endPortal: readonly [Vector3Tuple, Vector3Tuple];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface DungeonEncounterSpawnGroupDefinition {
|
export interface DungeonEncounterSpawnGroupDefinition {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly spawns: readonly StaticMobSpawnDefinition[];
|
readonly spawns: readonly StaticMobSpawnDefinition[];
|
||||||
@@ -241,6 +254,7 @@ export interface DungeonDefinition {
|
|||||||
readonly bosses: readonly DungeonBossObjective[];
|
readonly bosses: readonly DungeonBossObjective[];
|
||||||
readonly objectiveOrder: readonly DungeonBossObjective[];
|
readonly objectiveOrder: readonly DungeonBossObjective[];
|
||||||
readonly navigationLinks: readonly DungeonNavigationLink[];
|
readonly navigationLinks: readonly DungeonNavigationLink[];
|
||||||
|
readonly traversalRamps?: readonly DungeonTraversalRamp[];
|
||||||
/** Serializable dungeon-wide orchestration kept separate from render state. */
|
/** Serializable dungeon-wide orchestration kept separate from render state. */
|
||||||
readonly encounters?: readonly DungeonEncounterDefinition[];
|
readonly encounters?: readonly DungeonEncounterDefinition[];
|
||||||
/** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */
|
/** Declarative groups activated by encounter actions; renderer ownership stays outside the reducer. */
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Generated by npm run dungeon:catalog. Do not edit.
|
// Generated by npm run dungeon:catalog. Do not edit.
|
||||||
export default "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 truncated
|
export default "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 truncated
|
||||||
@@ -1 +1 @@
|
|||||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
|
import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three";
|
||||||
import type { DungeonNavigationLink } from "./dungeonTypes";
|
import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes";
|
||||||
|
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
|
||||||
import {
|
import {
|
||||||
buildPartyNavigationGraph,
|
buildPartyNavigationGraph,
|
||||||
type PartyNavigationGraph,
|
type PartyNavigationGraph,
|
||||||
@@ -9,14 +10,18 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
|
|||||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||||
|
|
||||||
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
|
function graphCacheKey(
|
||||||
|
url: string,
|
||||||
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
|
ramps: readonly DungeonTraversalRamp[],
|
||||||
|
): string {
|
||||||
const links = offMeshLinks.map((link) => [
|
const links = offMeshLinks.map((link) => [
|
||||||
link.id,
|
link.id,
|
||||||
link.start,
|
link.start,
|
||||||
link.end,
|
link.end,
|
||||||
link.bidirectional,
|
link.bidirectional,
|
||||||
]);
|
]);
|
||||||
return `${url}:${JSON.stringify(links)}`;
|
return `${url}:${JSON.stringify([links, ramps])}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigationGeometry(scene: Group): BufferGeometry {
|
function navigationGeometry(scene: Group): BufferGeometry {
|
||||||
@@ -54,8 +59,9 @@ export function preparePartyNavigationGraph(
|
|||||||
url: string,
|
url: string,
|
||||||
scene: Group,
|
scene: Group,
|
||||||
offMeshLinks: readonly DungeonNavigationLink[],
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
|
ramps: readonly DungeonTraversalRamp[] = [],
|
||||||
): PartyNavigationGraph {
|
): PartyNavigationGraph {
|
||||||
const cacheKey = graphCacheKey(url, offMeshLinks);
|
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
|
||||||
const cached = graphCache.get(cacheKey);
|
const cached = graphCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
graphCache.delete(cacheKey);
|
graphCache.delete(cacheKey);
|
||||||
@@ -66,9 +72,15 @@ export function preparePartyNavigationGraph(
|
|||||||
const geometry = navigationGeometry(scene);
|
const geometry = navigationGeometry(scene);
|
||||||
let graph: PartyNavigationGraph;
|
let graph: PartyNavigationGraph;
|
||||||
try {
|
try {
|
||||||
|
if (ramps.length) {
|
||||||
|
// Recast normally lifts its walkable surface slightly above collision.
|
||||||
|
smoothDungeonStairGeometry(geometry, ramps, 0.05);
|
||||||
|
bridgeDungeonStairNavigation(geometry, ramps, 0.05);
|
||||||
|
}
|
||||||
|
const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId));
|
||||||
graph = buildPartyNavigationGraph(geometry, {
|
graph = buildPartyNavigationGraph(geometry, {
|
||||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||||
offMeshLinks,
|
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NodeIO } from "@gltf-transform/core";
|
import { NodeIO } from "@gltf-transform/core";
|
||||||
|
import RAPIER from "@dimforge/rapier3d-compat";
|
||||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||||
import { MeshoptDecoder } from "meshoptimizer";
|
import { MeshoptDecoder } from "meshoptimizer";
|
||||||
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
||||||
@@ -15,14 +16,19 @@ import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
|||||||
import { planPartyRoute } from "./partyPathing";
|
import { planPartyRoute } from "./partyPathing";
|
||||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||||
|
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||||
|
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
|
||||||
|
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
|
||||||
|
|
||||||
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
|
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
|
||||||
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
||||||
|
|
||||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||||
let graph: PartyNavigationGraph;
|
let graph: PartyNavigationGraph;
|
||||||
|
let sourceCollision: Group;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
await RAPIER.init();
|
||||||
await MeshoptDecoder.ready;
|
await MeshoptDecoder.ready;
|
||||||
const document = await new NodeIO()
|
const document = await new NodeIO()
|
||||||
.registerExtensions(ALL_EXTENSIONS)
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
@@ -50,10 +56,45 @@ beforeAll(async () => {
|
|||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
|
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
|
||||||
// in the shipping runtime, not a separately pre-expanded test geometry.
|
// in the shipping runtime, not a separately pre-expanded test geometry.
|
||||||
graph = preparePartyNavigationGraph("/test/stockades-navigation.glb", scene, stockades.navigationLinks);
|
graph = preparePartyNavigationGraph(
|
||||||
|
"/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps,
|
||||||
|
);
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
|
|
||||||
|
const collisionDocument = await new NodeIO()
|
||||||
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
|
||||||
|
.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
|
||||||
|
sourceCollision = new Group();
|
||||||
|
for (const node of collisionDocument.getRoot().listNodes()) {
|
||||||
|
for (const collisionPrimitive of node.getMesh()?.listPrimitives() ?? []) {
|
||||||
|
const positions = collisionPrimitive.getAttribute("POSITION")!;
|
||||||
|
const values = positions.getArray();
|
||||||
|
if (!(values instanceof Int16Array)) throw new Error("Expected quantized Stockades collision positions.");
|
||||||
|
const collisionGeometry = new BufferGeometry();
|
||||||
|
collisionGeometry.setAttribute("position", new BufferAttribute(values, 3, positions.getNormalized()));
|
||||||
|
collisionGeometry.setIndex(Array.from(collisionPrimitive.getIndices()!.getArray()!));
|
||||||
|
const collisionMesh = new Mesh(collisionGeometry);
|
||||||
|
collisionMesh.matrixAutoUpdate = false;
|
||||||
|
collisionMesh.matrix.fromArray(node.getWorldMatrix());
|
||||||
|
sourceCollision.add(collisionMesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function collisionWorld(useRamps = true): 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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||||
|
world.step();
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
function distance(left: readonly number[], right: readonly number[]): number {
|
function distance(left: readonly number[], right: readonly number[]): number {
|
||||||
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
||||||
}
|
}
|
||||||
@@ -74,10 +115,12 @@ describe("Stormwind Stockade party navigation", () => {
|
|||||||
expect(stockades.navigationLinks).toHaveLength(3);
|
expect(stockades.navigationLinks).toHaveLength(3);
|
||||||
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
|
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
|
||||||
expect(graph).toMatchObject({
|
expect(graph).toMatchObject({
|
||||||
sourceTriangleCount: 3_538,
|
sourceTriangleCount: 3_544,
|
||||||
componentCount: 31,
|
componentCount: 31,
|
||||||
});
|
});
|
||||||
expect(graph.discardedNodeCount).toBeGreaterThan(0);
|
expect(graph.discardedNodeCount).toBeGreaterThan(0);
|
||||||
|
expect(graph.triangles?.length).toBeGreaterThan(0);
|
||||||
|
expect(graph.triangleNodes?.length).toBe(graph.triangles?.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projects every entrance formation slot onto the retained walkable surface", () => {
|
it("projects every entrance formation slot onto the retained walkable surface", () => {
|
||||||
@@ -95,6 +138,8 @@ describe("Stormwind Stockade party navigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("routes from the shipping entrance to every authored boss objective", () => {
|
it("routes from the shipping entrance to every authored boss objective", () => {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||||
const entrance = findNearestPartyNavigationSurface(
|
const entrance = findNearestPartyNavigationSurface(
|
||||||
graph,
|
graph,
|
||||||
stockades.entrance.footPosition,
|
stockades.entrance.footPosition,
|
||||||
@@ -109,12 +154,15 @@ describe("Stormwind Stockade party navigation", () => {
|
|||||||
graph,
|
graph,
|
||||||
entrance!.position,
|
entrance!.position,
|
||||||
approach!.position,
|
approach!.position,
|
||||||
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1 },
|
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed },
|
||||||
), objective.name).not.toBeNull();
|
), objective.name).not.toBeNull();
|
||||||
}
|
}
|
||||||
|
world.free();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives the tank a usable route into melee range of the first enemy", () => {
|
it("gives the tank a usable route into melee range of the first enemy", () => {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const segmentAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||||
const tankSlot = derivePartySpawnSlots({
|
const tankSlot = derivePartySpawnSlots({
|
||||||
footPosition: stockades.entrance.footPosition,
|
footPosition: stockades.entrance.footPosition,
|
||||||
yaw: stockades.entrance.yaw,
|
yaw: stockades.entrance.yaw,
|
||||||
@@ -146,7 +194,8 @@ describe("Stormwind Stockade party navigation", () => {
|
|||||||
maxOriginSnapDistance: 3,
|
maxOriginSnapDistance: 3,
|
||||||
maxDestinationSnapDistance: 5,
|
maxDestinationSnapDistance: 5,
|
||||||
localFinishDistance: TANK_RANGE.maximum,
|
localFinishDistance: TANK_RANGE.maximum,
|
||||||
directSegmentAllowed: () => true,
|
directSegmentAllowed: segmentAllowed,
|
||||||
|
routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(route.blocked).toBe(false);
|
expect(route.blocked).toBe(false);
|
||||||
@@ -170,5 +219,136 @@ describe("Stormwind Stockade party navigation", () => {
|
|||||||
TANK_RANGE.maximum,
|
TANK_RANGE.maximum,
|
||||||
desired,
|
desired,
|
||||||
)).toBe("hold");
|
)).toBe("hold");
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traverses all three shipping stair flights in both directions with the live capsule check", () => {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||||
|
expect(stockades.traversalRamps).toHaveLength(3);
|
||||||
|
for (const ramp of stockades.traversalRamps!) {
|
||||||
|
const point = (t: number, lateral: number): [number, number, number] => {
|
||||||
|
const dx = ramp.end[0] - ramp.start[0];
|
||||||
|
const dz = ramp.end[2] - ramp.start[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
return [
|
||||||
|
ramp.start[0] + dx * t + dz / length * lateral,
|
||||||
|
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
|
||||||
|
ramp.start[2] + dz * t - dx / length * lateral,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
for (const lateral of [-2, 0, 2]) {
|
||||||
|
const start = point(0.05, lateral);
|
||||||
|
const end = point(0.95, lateral);
|
||||||
|
expect(allowed(start, end), `${ramp.id}: downhill, offset ${lateral}`).toBe(true);
|
||||||
|
expect(allowed(end, start), `${ramp.id}: uphill, offset ${lateral}`).toBe(true);
|
||||||
|
}
|
||||||
|
const start = findNearestPartyNavigationSurface(graph, point(0.15, 0), 2)!.position;
|
||||||
|
const end = findNearestPartyNavigationSurface(graph, point(0.85, 0), 2)!.position;
|
||||||
|
expect(findPartyNavigationPath(graph, start, end, { maxSnapDistance: 0.1 }), ramp.id).not.toBeNull();
|
||||||
|
// Replanning midway through a stair must not require a blocked snap to
|
||||||
|
// a distant boundary vertex, as the previous off-mesh graph did.
|
||||||
|
for (const t of [0.2, 0.4, 0.6, 0.8]) {
|
||||||
|
const current = findNearestPartyNavigationSurface(graph, point(t, 0), 2)!.position;
|
||||||
|
const route = planPartyRoute(graph, ramp.id, current, end, t * 1_000, {
|
||||||
|
maxSnapDistance: 0.1,
|
||||||
|
directSegmentAllowed: allowed,
|
||||||
|
});
|
||||||
|
expect(route.blocked, `${ramp.id}: replan at ${t}`).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the old step-face rejection on the unmodified source collision", () => {
|
||||||
|
const world = collisionWorld(false);
|
||||||
|
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||||
|
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.95), point(0.05)), ramp.id).toBe(false);
|
||||||
|
}
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still blocks movement through the stairwell walls", () => {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||||
|
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||||
|
const ramp = stockades.traversalRamps![0];
|
||||||
|
const center: [number, number, number] = [
|
||||||
|
(ramp.start[0] + ramp.end[0]) / 2,
|
||||||
|
(ramp.start[1] + ramp.end[1]) / 2 + 0.05,
|
||||||
|
ramp.start[2],
|
||||||
|
];
|
||||||
|
const acrossWall: [number, number, number] = [center[0], center[1], center[2] + 9];
|
||||||
|
expect(directAllowed(center, acrossWall)).toBe(false);
|
||||||
|
expect(routeAllowed(center, acrossWall)).toBe(false);
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake the nearby player's kinematic capsule for stair terrain", () => {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const ramp = stockades.traversalRamps![0];
|
||||||
|
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],
|
||||||
|
];
|
||||||
|
const player = point(0.5);
|
||||||
|
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
|
||||||
|
player[0], player[1] + 0.84, player[2],
|
||||||
|
));
|
||||||
|
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body);
|
||||||
|
world.step();
|
||||||
|
expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
|
||||||
|
expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
|
||||||
|
world.free();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks the player capsule down and back up every ramp without jumping", () => {
|
||||||
|
for (const ramp of stockades.traversalRamps!) {
|
||||||
|
const world = collisionWorld();
|
||||||
|
const dx = ramp.end[0] - ramp.start[0];
|
||||||
|
const dz = ramp.end[2] - ramp.start[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
const centerHeight = 0.36 + 0.48;
|
||||||
|
const startT = 0.03;
|
||||||
|
const startY = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * startT;
|
||||||
|
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
|
||||||
|
ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT,
|
||||||
|
));
|
||||||
|
const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body);
|
||||||
|
world.step();
|
||||||
|
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
|
||||||
|
const progress = () => {
|
||||||
|
const position = body.translation();
|
||||||
|
return ((position.x - ramp.start[0]) * dx + (position.z - ramp.start[2]) * dz) / (length * length);
|
||||||
|
};
|
||||||
|
for (const direction of [1, -1]) {
|
||||||
|
// Rapier projects uphill movement onto the slope, reducing planar
|
||||||
|
// speed. Bound the walk by twice the flat-ground travel time.
|
||||||
|
const frames = Math.ceil(length * 2 / (4.8 / 60));
|
||||||
|
for (let frame = 0; frame < frames; frame += 1) {
|
||||||
|
if (direction === 1 ? progress() >= 0.95 : progress() <= 0.05) break;
|
||||||
|
controller.computeColliderMovement(collider, {
|
||||||
|
x: dx / length * direction * 4.8 / 60,
|
||||||
|
y: -0.5 / 60,
|
||||||
|
z: dz / length * direction * 4.8 / 60,
|
||||||
|
}, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC);
|
||||||
|
const position = body.translation();
|
||||||
|
const move = controller.computedMovement();
|
||||||
|
body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z });
|
||||||
|
world.step();
|
||||||
|
}
|
||||||
|
if (direction === 1) expect(progress(), `${ramp.id}: player downhill`).toBeGreaterThan(0.9);
|
||||||
|
else expect(progress(), `${ramp.id}: player uphill`).toBeLessThan(0.12);
|
||||||
|
}
|
||||||
|
world.removeCharacterController(controller);
|
||||||
|
world.free();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ import { useCombatStore } from "../game/combatStore";
|
|||||||
import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
|
import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
|
||||||
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
|
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
|
||||||
import type { InventoryItem } from "../game/lootTypes";
|
import type { InventoryItem } from "../game/lootTypes";
|
||||||
import type { DungeonNavigationLink } from "../game/dungeonTypes";
|
import type { DungeonNavigationLink, DungeonTraversalRamp } from "../game/dungeonTypes";
|
||||||
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
|
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
|
||||||
import { usePartyStore } from "../game/partyStore";
|
import { usePartyStore } from "../game/partyStore";
|
||||||
import { preloadTalentGameplayResources } from "../ui/talentGameplayResources";
|
import { preloadTalentGameplayResources } from "../ui/talentGameplayResources";
|
||||||
@@ -63,11 +63,13 @@ function WarmupCommit({ onSettled }: { readonly onSettled: () => void }) {
|
|||||||
|
|
||||||
function SceneAssetProbe({
|
function SceneAssetProbe({
|
||||||
navigationLinks,
|
navigationLinks,
|
||||||
|
traversalRamps,
|
||||||
navigationUrl,
|
navigationUrl,
|
||||||
urls,
|
urls,
|
||||||
onSettled,
|
onSettled,
|
||||||
}: {
|
}: {
|
||||||
readonly navigationLinks: readonly DungeonNavigationLink[];
|
readonly navigationLinks: readonly DungeonNavigationLink[];
|
||||||
|
readonly traversalRamps: readonly DungeonTraversalRamp[];
|
||||||
readonly navigationUrl: string | null;
|
readonly navigationUrl: string | null;
|
||||||
readonly urls: readonly string[];
|
readonly urls: readonly string[];
|
||||||
readonly onSettled: () => void;
|
readonly onSettled: () => void;
|
||||||
@@ -80,8 +82,9 @@ function SceneAssetProbe({
|
|||||||
navigationUrl,
|
navigationUrl,
|
||||||
gltfs[navigationIndex].scene,
|
gltfs[navigationIndex].scene,
|
||||||
navigationLinks,
|
navigationLinks,
|
||||||
|
traversalRamps,
|
||||||
);
|
);
|
||||||
}, [gltfs, navigationIndex, navigationLinks, navigationUrl]);
|
}, [gltfs, navigationIndex, navigationLinks, navigationUrl, traversalRamps]);
|
||||||
return <WarmupCommit onSettled={onSettled} />;
|
return <WarmupCommit onSettled={onSettled} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +251,13 @@ function PartyDataProbe({ onSettled }: { readonly onSettled: () => void }) {
|
|||||||
|
|
||||||
export function ActivityAssetWarmup({
|
export function ActivityAssetWarmup({
|
||||||
navigationLinks,
|
navigationLinks,
|
||||||
|
traversalRamps,
|
||||||
navigationUrl,
|
navigationUrl,
|
||||||
sceneAssetUrls,
|
sceneAssetUrls,
|
||||||
onReady,
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
readonly navigationLinks: readonly DungeonNavigationLink[];
|
readonly navigationLinks: readonly DungeonNavigationLink[];
|
||||||
|
readonly traversalRamps: readonly DungeonTraversalRamp[];
|
||||||
readonly navigationUrl: string | null;
|
readonly navigationUrl: string | null;
|
||||||
readonly sceneAssetUrls: readonly string[];
|
readonly sceneAssetUrls: readonly string[];
|
||||||
readonly onReady: () => void;
|
readonly onReady: () => void;
|
||||||
@@ -276,6 +281,7 @@ export function ActivityAssetWarmup({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<SceneAssetProbe
|
<SceneAssetProbe
|
||||||
navigationLinks={navigationLinks}
|
navigationLinks={navigationLinks}
|
||||||
|
traversalRamps={traversalRamps}
|
||||||
navigationUrl={navigationUrl}
|
navigationUrl={navigationUrl}
|
||||||
urls={sceneAssetUrls}
|
urls={sceneAssetUrls}
|
||||||
onSettled={markSceneReady}
|
onSettled={markSceneReady}
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export function GameScene() {
|
|||||||
<ActivityAssetWarmup
|
<ActivityAssetWarmup
|
||||||
key={`activity-warmup:${sessionRevision}`}
|
key={`activity-warmup:${sessionRevision}`}
|
||||||
navigationLinks={navigationLinks}
|
navigationLinks={navigationLinks}
|
||||||
|
traversalRamps={manastormAssetPackage ? [] : dungeon.traversalRamps ?? []}
|
||||||
navigationUrl={navigationUrl}
|
navigationUrl={navigationUrl}
|
||||||
sceneAssetUrls={activitySceneAssetUrls}
|
sceneAssetUrls={activitySceneAssetUrls}
|
||||||
onReady={markActivityAssetsReady}
|
onReady={markActivityAssetsReady}
|
||||||
|
|||||||
+24
-100
@@ -14,7 +14,7 @@ import {
|
|||||||
type ErrorInfo,
|
type ErrorInfo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Quaternion, type Group } from "three";
|
import { type Group } from "three";
|
||||||
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
|
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
|
||||||
import { CharacterModel } from "../avatar/CharacterModel";
|
import { CharacterModel } from "../avatar/CharacterModel";
|
||||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||||
@@ -25,7 +25,7 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
|
|||||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||||
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
import type { DungeonBossObjective } from "../game/dungeonTypes";
|
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
|
||||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
appendPartyBreadcrumb,
|
appendPartyBreadcrumb,
|
||||||
@@ -84,20 +84,19 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial";
|
|||||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||||
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
||||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||||
|
import {
|
||||||
|
createPartyDirectSegmentValidator,
|
||||||
|
createPartyRouteSegmentValidator,
|
||||||
|
PARTY_FLOOR_RAY_DEPTH,
|
||||||
|
PARTY_FLOOR_RAY_HEIGHT,
|
||||||
|
partyTerrainQueryFlags,
|
||||||
|
} from "./partyTraversal";
|
||||||
|
|
||||||
const PARTY_MOVE_SPEED = 5.25;
|
const PARTY_MOVE_SPEED = 5.25;
|
||||||
const PARTY_CATCH_UP_SPEED = 7.5;
|
const PARTY_CATCH_UP_SPEED = 7.5;
|
||||||
const BREADCRUMB_REACHED_DISTANCE = 0.04;
|
const BREADCRUMB_REACHED_DISTANCE = 0.04;
|
||||||
const PARTY_COLLISION_RADIUS = 0.34;
|
|
||||||
const PARTY_COLLISION_HALF_HEIGHT = 0.48;
|
|
||||||
const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04;
|
|
||||||
const PARTY_WALKABLE_STEP_HEIGHT = 0.32;
|
|
||||||
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
|
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
|
||||||
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
|
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
|
||||||
const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
|
|
||||||
const PARTY_FLOOR_RAY_HEIGHT = 1.25;
|
|
||||||
const PARTY_FLOOR_RAY_DEPTH = 2;
|
|
||||||
const PARTY_MAX_FLOOR_DELTA = 0.55;
|
|
||||||
const PARTY_MAX_GROUND_CORRECTION = 0.85;
|
const PARTY_MAX_GROUND_CORRECTION = 0.85;
|
||||||
const EMPTY_NAVIGATION_LINKS = [] as const;
|
const EMPTY_NAVIGATION_LINKS = [] as const;
|
||||||
|
|
||||||
@@ -201,16 +200,18 @@ class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, Na
|
|||||||
function PartyNavigationLoader({
|
function PartyNavigationLoader({
|
||||||
url,
|
url,
|
||||||
offMeshLinks,
|
offMeshLinks,
|
||||||
|
ramps,
|
||||||
onReady,
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"];
|
readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"];
|
||||||
|
readonly ramps: readonly DungeonTraversalRamp[];
|
||||||
readonly onReady: (graph: PartyNavigationGraph) => void;
|
readonly onReady: (graph: PartyNavigationGraph) => void;
|
||||||
}) {
|
}) {
|
||||||
const gltf = useGLTF(resolveContentUrl(url));
|
const gltf = useGLTF(resolveContentUrl(url));
|
||||||
const graph = useMemo(
|
const graph = useMemo(
|
||||||
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks),
|
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks, ramps),
|
||||||
[gltf.scene, offMeshLinks, url],
|
[gltf.scene, offMeshLinks, ramps, url],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => onReady(graph), [graph, onReady]);
|
useEffect(() => onReady(graph), [graph, onReady]);
|
||||||
@@ -289,16 +290,6 @@ function PartyActors({
|
|||||||
);
|
);
|
||||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||||
const { rapier, world } = useRapier();
|
const { rapier, world } = useRapier();
|
||||||
const navigationCapsule = useMemo(
|
|
||||||
// Trim the lower leg band while keeping the capsule top at normal head
|
|
||||||
// height. This supplies step clearance without demanding extra ceiling.
|
|
||||||
() => new rapier.Capsule(
|
|
||||||
PARTY_COLLISION_HALF_HEIGHT - PARTY_WALKABLE_STEP_HEIGHT * 0.5,
|
|
||||||
PARTY_COLLISION_RADIUS,
|
|
||||||
),
|
|
||||||
[rapier],
|
|
||||||
);
|
|
||||||
const navigationRotation = useMemo(() => new Quaternion(), []);
|
|
||||||
const presentationY = useCallback((position: PartyWorldPosition): number => {
|
const presentationY = useCallback((position: PartyWorldPosition): number => {
|
||||||
const ray = new rapier.Ray({
|
const ray = new rapier.Ray({
|
||||||
x: position[0],
|
x: position[0],
|
||||||
@@ -309,92 +300,23 @@ function PartyActors({
|
|||||||
ray,
|
ray,
|
||||||
PARTY_FLOOR_RAY_DEPTH,
|
PARTY_FLOOR_RAY_DEPTH,
|
||||||
true,
|
true,
|
||||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
partyTerrainQueryFlags(rapier),
|
||||||
);
|
);
|
||||||
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
|
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
|
||||||
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
|
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
|
||||||
}, [rapier, world]);
|
}, [rapier, world]);
|
||||||
const directSegmentAllowed = useCallback((
|
const directSegmentAllowed = useMemo(
|
||||||
start: PartyWorldPosition,
|
() => createPartyDirectSegmentValidator(rapier, world),
|
||||||
end: PartyWorldPosition,
|
[rapier, world],
|
||||||
): boolean => {
|
);
|
||||||
const dx = end[0] - start[0];
|
|
||||||
const dy = end[1] - start[1];
|
|
||||||
const dz = end[2] - start[2];
|
|
||||||
const distance = Math.hypot(dx, dy, dz);
|
|
||||||
if (!Number.isFinite(distance)) return false;
|
|
||||||
const planarDistance = Math.hypot(dx, dz);
|
|
||||||
// Collision and navigation surfaces commonly disagree by a few centimeters.
|
|
||||||
// Treat a same-column height correction as grounding, not a blocked move.
|
|
||||||
if (planarDistance <= 0.04) {
|
|
||||||
return Math.abs(dy) <= PARTY_WALKABLE_STEP_HEIGHT;
|
|
||||||
}
|
|
||||||
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
|
||||||
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
|
|
||||||
const obstacle = world.castShape(
|
|
||||||
{
|
|
||||||
x: start[0],
|
|
||||||
// Raise only the trimmed lower band over walkable relief. The floor
|
|
||||||
// samples below still reject real ledges and excessive elevation.
|
|
||||||
y: start[1] + PARTY_COLLISION_CENTER_HEIGHT + PARTY_WALKABLE_STEP_HEIGHT * 0.5,
|
|
||||||
z: start[2],
|
|
||||||
},
|
|
||||||
navigationRotation,
|
|
||||||
{ x: dx / distance, y: dy / distance, z: dz / distance },
|
|
||||||
navigationCapsule,
|
|
||||||
0.025,
|
|
||||||
distance,
|
|
||||||
false,
|
|
||||||
queryFlags,
|
|
||||||
);
|
|
||||||
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
|
|
||||||
|
|
||||||
// A clear chest-height cast is insufficient across a ledge. Require the
|
|
||||||
// collision floor to remain close to the planned segment at short samples.
|
|
||||||
const sampleCount = Math.max(1, Math.ceil(distance / PARTY_FLOOR_SAMPLE_SPACING));
|
|
||||||
for (let sample = 0; sample <= sampleCount; sample += 1) {
|
|
||||||
const progress = sample / sampleCount;
|
|
||||||
const expectedY = start[1] + dy * progress;
|
|
||||||
const ray = new rapier.Ray({
|
|
||||||
x: start[0] + dx * progress,
|
|
||||||
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
|
|
||||||
z: start[2] + dz * progress,
|
|
||||||
}, { x: 0, y: -1, z: 0 });
|
|
||||||
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, queryFlags);
|
|
||||||
if (!floor) return false;
|
|
||||||
const floorY = ray.origin.y - floor.timeOfImpact;
|
|
||||||
if (Math.abs(floorY - expectedY) > PARTY_MAX_FLOOR_DELTA) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, [navigationCapsule, navigationRotation, rapier, world]);
|
|
||||||
const hasStaticLineOfSight = useCallback((
|
const hasStaticLineOfSight = useCallback((
|
||||||
start: PartyWorldPosition,
|
start: PartyWorldPosition,
|
||||||
end: PartyWorldPosition,
|
end: PartyWorldPosition,
|
||||||
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
|
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
|
||||||
const routeSurfaceSegmentAllowed = useCallback((
|
const routeSurfaceSegmentAllowed = useMemo(
|
||||||
start: PartyWorldPosition,
|
() => createPartyRouteSegmentValidator(rapier, world),
|
||||||
end: PartyWorldPosition,
|
[rapier, world],
|
||||||
): boolean => {
|
);
|
||||||
const dx = end[0] - start[0];
|
|
||||||
const dy = end[1] - start[1];
|
|
||||||
const dz = end[2] - start[2];
|
|
||||||
const distance = Math.hypot(dx, dy, dz);
|
|
||||||
if (!Number.isFinite(distance)) return false;
|
|
||||||
if (distance <= 0.04) return true;
|
|
||||||
const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
|
||||||
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
|
|
||||||
// Portal-centered paths already stay inside walkable triangles. One
|
|
||||||
// body-center ray catches a true wall/overhang without treating the floor
|
|
||||||
// or a tessellation edge as missing agent-radius clearance. Failed
|
|
||||||
// triangles are excluded and A* searches another corridor.
|
|
||||||
const ray = new rapier.Ray({
|
|
||||||
x: start[0],
|
|
||||||
y: start[1] + 0.9,
|
|
||||||
z: start[2],
|
|
||||||
}, { x: dx / distance, y: dy / distance, z: dz / distance });
|
|
||||||
const hit = world.castRay(ray, distance, true, queryFlags);
|
|
||||||
return !hit || hit.timeOfImpact >= distance - 0.04;
|
|
||||||
}, [rapier, world]);
|
|
||||||
// Authored-link graphs use their eroded Recast node edges directly. Their
|
// Authored-link graphs use their eroded Recast node edges directly. Their
|
||||||
// boundary vertices are already agent-radius safe, while a center ray can
|
// boundary vertices are already agent-radius safe, while a center ray can
|
||||||
// falsely read the stair floor as a wall across a short vertical seam.
|
// falsely read the stair floor as a wall across a short vertical seam.
|
||||||
@@ -1542,6 +1464,7 @@ export function PartyPopulation({
|
|||||||
const navigationEnabled = Boolean(assets.navigation)
|
const navigationEnabled = Boolean(assets.navigation)
|
||||||
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage);
|
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage);
|
||||||
const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks;
|
const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks;
|
||||||
|
const traversalRamps = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.traversalRamps ?? EMPTY_NAVIGATION_LINKS;
|
||||||
const [navigationState, setNavigationState] = useState<{
|
const [navigationState, setNavigationState] = useState<{
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly graph: PartyNavigationGraph;
|
readonly graph: PartyNavigationGraph;
|
||||||
@@ -1589,6 +1512,7 @@ export function PartyPopulation({
|
|||||||
<PartyNavigationLoader
|
<PartyNavigationLoader
|
||||||
url={assets.navigation}
|
url={assets.navigation}
|
||||||
offMeshLinks={navigationLinks}
|
offMeshLinks={navigationLinks}
|
||||||
|
ramps={traversalRamps}
|
||||||
onReady={acceptNavigationGraph}
|
onReady={acceptNavigationGraph}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ function CollisionChunk({
|
|||||||
readonly registerPrepared: (url: string, ready: boolean) => void;
|
readonly registerPrepared: (url: string, ready: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const gltf = useGameGLTF(url);
|
const gltf = useGameGLTF(url);
|
||||||
const collision = useMemo(() => prepareCollision(gltf.scene), [gltf.scene]);
|
const collision = useMemo(
|
||||||
|
() => prepareCollision(gltf.scene, definition.traversalRamps),
|
||||||
|
[definition.traversalRamps, gltf.scene],
|
||||||
|
);
|
||||||
const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]);
|
const trimeshes = useMemo(() => collisionTrimeshes(collision), [collision]);
|
||||||
const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
|
const sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type RAPIER from "@dimforge/rapier3d-compat";
|
||||||
|
import type { World } from "@dimforge/rapier3d-compat";
|
||||||
|
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
|
||||||
|
|
||||||
|
const COLLISION_RADIUS = 0.34;
|
||||||
|
const COLLISION_HALF_HEIGHT = 0.48;
|
||||||
|
const COLLISION_CENTER_HEIGHT = COLLISION_RADIUS + COLLISION_HALF_HEIGHT + 0.04;
|
||||||
|
const WALKABLE_STEP_HEIGHT = 0.32;
|
||||||
|
export const PARTY_FLOOR_RAY_HEIGHT = 1.25;
|
||||||
|
export const PARTY_FLOOR_RAY_DEPTH = 2;
|
||||||
|
const FLOOR_SAMPLE_SPACING = 0.6;
|
||||||
|
const MAX_FLOOR_DELTA = 0.55;
|
||||||
|
|
||||||
|
export function partyTerrainQueryFlags(rapier: typeof RAPIER) {
|
||||||
|
// Actor capsules are not terrain. Crowd spacing is handled separately by
|
||||||
|
// party separation; counting the player here blocks recalls and replans.
|
||||||
|
return rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
||||||
|
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC
|
||||||
|
| rapier.QueryFilterFlags.EXCLUDE_KINEMATIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by live party routing and collision-backed traversal regressions. */
|
||||||
|
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||||
|
const capsule = new rapier.Capsule(
|
||||||
|
COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5,
|
||||||
|
COLLISION_RADIUS,
|
||||||
|
);
|
||||||
|
const rotation = { x: 0, y: 0, z: 0, w: 1 };
|
||||||
|
const flags = partyTerrainQueryFlags(rapier);
|
||||||
|
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dy = end[1] - start[1];
|
||||||
|
const dz = end[2] - start[2];
|
||||||
|
const distance = Math.hypot(dx, dy, dz);
|
||||||
|
if (!Number.isFinite(distance)) return false;
|
||||||
|
if (Math.hypot(dx, dz) <= 0.04) return Math.abs(dy) <= WALKABLE_STEP_HEIGHT;
|
||||||
|
const obstacle = world.castShape(
|
||||||
|
{ x: start[0], y: start[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: start[2] },
|
||||||
|
rotation,
|
||||||
|
{ x: dx / distance, y: dy / distance, z: dz / distance },
|
||||||
|
capsule,
|
||||||
|
0.025,
|
||||||
|
distance,
|
||||||
|
false,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
|
||||||
|
const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING));
|
||||||
|
for (let sample = 0; sample <= sampleCount; sample += 1) {
|
||||||
|
const progress = sample / sampleCount;
|
||||||
|
const expectedY = start[1] + dy * progress;
|
||||||
|
const ray = new rapier.Ray({
|
||||||
|
x: start[0] + dx * progress,
|
||||||
|
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
|
||||||
|
z: start[2] + dz * progress,
|
||||||
|
}, { x: 0, y: -1, z: 0 });
|
||||||
|
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags);
|
||||||
|
if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */
|
||||||
|
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||||
|
const flags = partyTerrainQueryFlags(rapier);
|
||||||
|
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dy = end[1] - start[1];
|
||||||
|
const dz = end[2] - start[2];
|
||||||
|
const distance = Math.hypot(dx, dy, dz);
|
||||||
|
if (!Number.isFinite(distance)) return false;
|
||||||
|
if (distance <= 0.04) return true;
|
||||||
|
const ray = new rapier.Ray({
|
||||||
|
x: start[0], y: start[1] + 0.9, z: start[2],
|
||||||
|
}, { x: dx / distance, y: dy / distance, z: dz / distance });
|
||||||
|
const hit = world.castRay(ray, distance, true, flags);
|
||||||
|
return !hit || hit.timeOfImpact >= distance - 0.04;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
Mesh,
|
Mesh,
|
||||||
type Object3D,
|
type Object3D,
|
||||||
} from "three";
|
} from "three";
|
||||||
|
import type { DungeonTraversalRamp } from "../game/dungeonTypes";
|
||||||
|
import { smoothDungeonStairGeometry } from "../game/dungeonTraversalRamps";
|
||||||
|
|
||||||
export interface PreparedCollisionTrimesh {
|
export interface PreparedCollisionTrimesh {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -15,7 +17,7 @@ export interface PreparedCollisionTrimesh {
|
|||||||
// This deliberately rejects floors, ramps, slopes, and ceilings.
|
// This deliberately rejects floors, ramps, slopes, and ceilings.
|
||||||
const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5;
|
const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5;
|
||||||
|
|
||||||
export function prepareCollision(root: Object3D): Group {
|
export function prepareCollision(root: Object3D, ramps: readonly DungeonTraversalRamp[] = []): Group {
|
||||||
root.updateWorldMatrix(true, true);
|
root.updateWorldMatrix(true, true);
|
||||||
const inverseRootMatrix = root.matrixWorld.clone().invert();
|
const inverseRootMatrix = root.matrixWorld.clone().invert();
|
||||||
const prepared = new Group();
|
const prepared = new Group();
|
||||||
@@ -40,6 +42,7 @@ export function prepareCollision(root: Object3D): Group {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld));
|
geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld));
|
||||||
|
if (ramps.length) smoothDungeonStairGeometry(geometry, ramps);
|
||||||
const preparedMesh = new Mesh(geometry, mesh.material);
|
const preparedMesh = new Mesh(geometry, mesh.material);
|
||||||
preparedMesh.name = mesh.name;
|
preparedMesh.name = mesh.name;
|
||||||
preparedMesh.visible = false;
|
preparedMesh.visible = false;
|
||||||
|
|||||||
Reference in new issue
Block a user