Compare commits

..
3 Commits
Author SHA1 Message Date
phenom 40d9f346d8 Release Healer Man 0.1.35 2026-09-12 16:02:15 -04:00
phenom 46c272f889 Release Healer Man 0.1.34 2026-09-12 15:14:56 -04:00
phenom 05b25c4341 Release Healer Man 0.1.33 2026-09-12 14:25:23 -04:00
33 changed files with 1325 additions and 256 deletions

No files matched your search

+136
View File
@@ -2212,3 +2212,139 @@ Vite's existing large-chunk advisory remains informational.
Effects without source mappings retain fallback. This is representative Effects without source mappings retain fallback. This is representative
desktop browser QA, not exhaustive spell-by-spell fidelity or a physical desktop browser QA, not exhaustive spell-by-spell fidelity or a physical
Android/GPU performance test. No hosted release was published. Android/GPU performance test. No hosted release was published.
## 2026-09-12 - Stockades party routing and indoor lighting
- Fixed quantized navigation preparation: normalized integer positions now
expand to floats before the GLB node transform is baked. Previously the
Stockades world coordinates clamped into roughly [-1, 1], leaving the live
party unable to attach to its graph even when an offline route test passed.
- Added three reviewed bidirectional links across the 1.537m Recast seams at
the entrance/central stairs and the central/north and central/south stairs.
The shipping 3,538-triangle graph connects the entrance and all five boss
approaches while discarding the remaining small decorative islands.
- Added navigation-only review state and compiler-preserved runtime overrides.
Stockades navigation is green; its wider encounter, wipe/reset, checkpoint,
and completion validation remains unvalidated.
- Leader and follower formation paths now use the graph when a breadcrumb
rejoin is blocked. Fallback comfort goals and recalls attach to the reviewed
surface, preventing an off-boundary stair target from causing a false stuck
loop. Combat approach connectors respect each member's personalized range.
- Replaced the generic dark expansion palette only for Stockades with warmer,
stronger ambient/hemisphere fill and a warm directional light. No dungeon
textures or source meshes were changed, and other dungeon palettes remain
unchanged pending individual visual review.
- Live desktop browser check at 1280 x 720 used offline level-15 Priest
Rolecheck as healer with Blood DK tank Morvane, Combat Rogue Vale, Unholy DK
Thorne, and Frost Mage Ilyra. Fresh Defend mode showed PARTY 5/5, Recall on the
entrance stairs recovered without a route warning, and Attack resumed with
visible party movement down the stairs. The tank advanced into combat and
took Defias Basic Attack/Kick/Backhand aggro; Backstab and Icy Touch events
appeared while the player remained at the entrance.
- Inline task screenshots captured the warm, readable stonework/stairs and
equipped party silhouettes in Defend and Recall-to-Attack states. The final
runtime produced no new navigation errors; the existing deprecated physics
initialization warning remains. Temporary diagnostic logging was removed.
- Automated validation: all 223 Vitest files passed, 1,373 tests passed, and
six existing tests remained skipped. Regressions cover the actual quantized
Stockades GLB through the shipping asset-preparation path, normalized integer
transforms, formation projection, entrance-to-boss routing, and tank melee
approach. This is an entrance/combat smoke pass, not a complete dungeon clear
or physical Android test.
- Production TypeScript/Vite build, texture/performance audits, bundle
verification, and the final release candidate check passed. Existing
bundle-size/import advisories remain. No hosted release was published.
- The optional all-dungeon functionality audit was stopped after an extended
run without output; its existing report files were not changed. Stockades
navigation evidence comes from the actual shipping-asset regressions and
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.
## 2026-09-12 - Non-blocking players and combat actors
- Removed party soft separation and crowd-triggered combat repositioning.
Companions may overlap or cross one another without push-apart corrections
changing their routes. Normal follow formations and role-specific attack
ranges remain; a tank already in melee range does not move just because an
ally occupies the same position.
- Added a shared actor collision layer that interacts only with physical
terrain. The player capsule uses this layer. Player controller movement,
ground probes, camera sweeps, party direct/route validation, party grounding,
and combat ground-position queries use terrain-only filters. Actor body type
cannot turn an actor into a floor or movement obstruction. Party members,
remote players, mobs, and bosses currently render without physical colliders;
any future actor capsule must use the shared actor layer.
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
retain their terrain collision. Target-selection meshes, combat visibility,
damage, and attack-range checks were not removed.
- Ten new Rapier regressions cover overlapping actors with no contacts or
displacement, movement through fixed/kinematic/dynamic actors (including an
overlapped starting position), continued wall/floor collision, and ignoring
actors in ground, party-route, and camera queries. A terrain-layer control
confirms real actor/terrain contacts are still detected. Stockades shipping-
asset regressions now use the live player collision policy and still pass
down/up all three ramps without jumping.
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
and dungeon screenshots show character bodies, weapons, unchanged visible
stairs, and the existing warm lighting. Attack moved companions down from
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
and melee/caster combat events continued. Experience rose from 11,098 to
13,359. Stop followed by Recall returned the party to the nearby player.
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
a manual traversal of both wings. Actor overlap and passage guarantees are
backed by the physics regressions; physical Android and multiplayer QA remain
open.
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
tests remained skipped. The final focused checks passed again after cleanup.
Production TypeScript/Vite build, texture/performance audits, static bundle
compression, and release-bundle verification passed. Existing chunk-size
advisories and deprecated physics initialization warnings remain. No hosted
release was published.
@@ -39,11 +39,11 @@
"agentMaxSlope": 50 "agentMaxSlope": 50
}, },
"review": { "review": {
"entrance": "required", "entrance": "approved",
"bossOrder": "required", "bossOrder": "required",
"optionalEncounters": "required", "optionalEncounters": "required",
"presentation": "required", "presentation": "approved",
"offMeshLinks": "required" "offMeshLinks": "approved"
}, },
"exceptions": [] "exceptions": []
} }
@@ -0,0 +1,91 @@
{
"schemaVersion": 1,
"dungeonId": "stormwind-stockade",
"navigationLinks": [
{
"id": "entrance-to-central-stairs",
"start": [-66.674328, -23.150503, 5.501851],
"end": [-67.664446, -23.755576, 4.493397],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam between the source entrance stairs and the central cell block."
},
{
"id": "central-to-north-wing-stairs",
"start": [-124.46789, -27.349338, 16.099782],
"end": [-125.476344, -27.95441, 17.0899],
"bidirectional": true,
"authoredReason": "Bridges the reviewed 1.537m Recast seam from the central cell block to the north wing stairs."
},
{
"id": "central-to-south-wing-stairs",
"start": [-124.46789, -27.349338, -14.703893],
"end": [-125.476344, -27.95441, -15.694011],
"bidirectional": true,
"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": {
"background": "#171815",
"fog": {
"color": "#1b1c18",
"near": 44
},
"ambientLight": {
"color": "#d8c9aa",
"intensity": 0.58
},
"hemisphereLight": {
"skyColor": "#cfc3a5",
"groundColor": "#292820",
"intensity": 0.86
},
"directionalLight": {
"color": "#ffe0a8",
"intensity": 1.1,
"offset": [10, 18, -5]
}
},
"validation": {
"navigation": "green",
"report": "PLAYTEST_NOTES.md",
"warnings": [
"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."
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.32", "version": "0.1.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.32", "version": "0.1.35",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.32", "version": "0.1.35",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.32", "versionName": "0.1.35",
"versionCode": 1032 "versionCode": 1035
} }
@@ -8,6 +8,7 @@ import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes"); const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
const runtimeOverridesRoot = path.join(projectRoot, "dungeon-pipeline", "runtime-overrides");
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons")); const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json"); const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const gameObjectAssetManifestFile = path.join( const gameObjectAssetManifestFile = path.join(
@@ -1445,13 +1446,13 @@ function finiteBounds(points) {
}; };
} }
function presentationFor(recipe, assetPackage, environmentMode, bounds) { function presentationFor(recipe, assetPackage, environmentMode, bounds, override = {}) {
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic; const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
const span = Math.max( const span = Math.max(
bounds.max[0] - bounds.min[0], bounds.max[0] - bounds.min[0],
bounds.max[2] - bounds.min[2], bounds.max[2] - bounds.min[2],
); );
return { const presentation = {
location: assetPackage.location || recipe.title, location: assetPackage.location || recipe.title,
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon", theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`, summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
@@ -1480,6 +1481,16 @@ function presentationFor(recipe, assetPackage, environmentMode, bounds) {
], ],
}, },
}; };
return {
...presentation,
...override,
fog: { ...presentation.fog, ...override.fog },
ambientLight: { ...presentation.ambientLight, ...override.ambientLight },
hemisphereLight: { ...presentation.hemisphereLight, ...override.hemisphereLight },
directionalLight: { ...presentation.directionalLight, ...override.directionalLight },
materials: presentation.materials,
map: presentation.map,
};
} }
async function compile() { async function compile() {
@@ -1535,6 +1546,19 @@ async function compile() {
const importsBySlug = new Map( const importsBySlug = new Map(
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]), (fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
); );
const runtimeOverrides = new Map();
for (const recipe of recipes) {
const file = path.join(runtimeOverridesRoot, `${recipe.slug}.json`);
if (!await exists(file)) continue;
const override = await readJson(file);
if (override.schemaVersion !== 1 || override.dungeonId !== recipe.slug) {
throw new Error(`${recipe.slug}: runtime override identity or schema is invalid.`);
}
if (override.validation?.navigation === "green" && !(override.navigationLinks?.length > 0)) {
throw new Error(`${recipe.slug}: green navigation requires at least one reviewed link.`);
}
runtimeOverrides.set(recipe.slug, override);
}
const drafts = new Map(); const drafts = new Map();
const wantedSpellIds = new Set(); const wantedSpellIds = new Set();
for (const recipe of recipes) { for (const recipe of recipes) {
@@ -1584,6 +1608,7 @@ async function compile() {
const coverage = []; const coverage = [];
for (const recipe of recipes) { for (const recipe of recipes) {
const dungeonImport = importsBySlug.get(recipe.slug); const dungeonImport = importsBySlug.get(recipe.slug);
const runtimeOverride = runtimeOverrides.get(recipe.slug) ?? {};
// Manastorm packages contain the complete optimized dungeon geometry. // Manastorm packages contain the complete optimized dungeon geometry.
// Reuse them for full dungeons so the install ships one map package. // Reuse them for full dungeons so the install ships one map package.
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment; const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
@@ -1951,7 +1976,7 @@ async function compile() {
difficultyVariants: recipe.difficultyVariants, difficultyVariants: recipe.difficultyVariants,
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal", defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
availability: "available", availability: "available",
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds), presentation: presentationFor(recipe, assetPackage, environmentMode, bounds, runtimeOverride.presentation),
assets: { assets: {
visual: assetPackage.visual.map(assetChunk), visual: assetPackage.visual.map(assetChunk),
collision: assetPackage.collision.map(assetChunk), collision: assetPackage.collision.map(assetChunk),
@@ -1979,7 +2004,26 @@ async function compile() {
roamingPacks, roamingPacks,
bosses, bosses,
objectiveOrder: bosses, objectiveOrder: bosses,
navigationLinks: [], navigationLinks: (runtimeOverride.navigationLinks ?? []).map((link) => ({
id: String(link.id),
start: vector(link.start),
end: vector(link.end),
bidirectional: link.bidirectional !== false,
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: {
@@ -2009,8 +2053,17 @@ async function compile() {
}, },
validation: { validation: {
status: "unvalidated", status: "unvalidated",
...(runtimeOverride.validation?.navigation
? { navigation: runtimeOverride.validation.navigation }
: {}),
...(runtimeOverride.validation?.report
? { report: runtimeOverride.validation.report }
: {}),
blockers: [], blockers: [],
warnings: sourceWarnings, warnings: [
...sourceWarnings,
...(runtimeOverride.validation?.warnings ?? []),
],
}, },
}); });
coverage.push({ coverage.push({
+52
View File
@@ -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();
});
});
+77
View File
@@ -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);
}
+16
View File
@@ -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[];
@@ -209,6 +222,8 @@ export interface DungeonSourceProvenance {
export interface DungeonValidationStatus { export interface DungeonValidationStatus {
readonly status: "green" | "blocked" | "unvalidated"; readonly status: "green" | "blocked" | "unvalidated";
/** Navigation-only review state; permits routing without overstating encounter validation. */
readonly navigation?: "green" | "blocked" | "unvalidated";
readonly report?: string; readonly report?: string;
readonly validatedAt?: string; readonly validatedAt?: string;
readonly blockers: readonly string[]; readonly blockers: readonly string[];
@@ -239,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 "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine 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
+7 -11
View File
@@ -12,7 +12,6 @@ import {
partyNaturalFollowPosition, partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal, partyPersonalizedCombatMovementGoal,
partyPresentationY, partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck, partyShouldRecallForStuck,
partyShouldMoveToComfortTarget, partyShouldMoveToComfortTarget,
PARTY_STUCK_RECALL_DISTANCE, PARTY_STUCK_RECALL_DISTANCE,
@@ -102,7 +101,7 @@ describe("party world movement", () => {
} }
}); });
it("uses a stable combat bearing and separates overlapping allies", () => { it("uses a stable combat bearing without forcing in-range actors to reposition", () => {
const out: [number, number, number] = [0, 0, 0]; const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal( expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0], [20, 0, 0],
@@ -110,23 +109,20 @@ describe("party world movement", () => {
true, true,
{ minimum: 10, preferred: 14, maximum: 18 }, { minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2, Math.PI / 2,
false,
out, out,
)).toBe("approach"); )).toBe("approach");
expect(out[0]).toBeCloseTo(14); expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0); expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0); expect(out[2]).toBeCloseTo(0);
const separated: [number, number, number] = [0, 0, 0]; expect(partyPersonalizedCombatMovementGoal(
expect(partySeparationGoal(
[1, 0, 1], [1, 0, 1],
[2, 0, 2], [0, 0, 0],
[[1, 0, 1]], true,
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
Math.PI / 2, Math.PI / 2,
separated, out,
)).toBe(true); )).toBe("hold");
expect(separated[0]).toBeGreaterThan(2);
expect(separated[2]).toBeCloseTo(2);
}); });
it("recalls only distant allies that should be moving and stopped making progress", () => { it("recalls only distant allies that should be moving and stopped making progress", () => {
+3 -42
View File
@@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null; breadcrumbId: number | null;
} }
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition"; export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export interface PartyMovementProfile { export interface PartyMovementProfile {
readonly followDistanceJitter: number; readonly followDistanceJitter: number;
@@ -427,44 +427,6 @@ export function personalizedPartyCombatRangeBand(
}; };
} }
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/** /**
* Chooses an individual combat destination. Melee only closes distance; * Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band. * ranged attackers also back out when a target collapses their preferred band.
@@ -524,7 +486,6 @@ export function partyPersonalizedCombatMovementGoal(
targetVisible: boolean, targetVisible: boolean,
rangeBand: PartyCombatRangeLike, rangeBand: PartyCombatRangeLike,
preferredBearing: number, preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition, out: MutablePartyWorldPosition,
): PartyCombatMovementIntent { ): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal( const intent = partyCombatMovementGoal(
@@ -538,10 +499,10 @@ export function partyPersonalizedCombatMovementGoal(
Math.sin(preferredBearing), Math.sin(preferredBearing),
Math.cos(preferredBearing), Math.cos(preferredBearing),
); );
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent; if (!targetVisible || intent === "hold") return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred; out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1]; out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred; out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent; return intent;
} }
+4
View File
@@ -126,6 +126,10 @@ describe("party navigation graph", () => {
expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 }); expect(graph).toMatchObject({ componentCount: 1, discardedNodeCount: 0 });
expect(graph.triangles).toBeUndefined(); expect(graph.triangles).toBeUndefined();
expect(findNearestPartyNavigationSurface(graph, [0.25, 0.5, 0.25], 1)).toMatchObject({
triangleId: -1,
distance: expect.any(Number),
});
expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull(); expect(findPartyNavigationPath(graph, [0, 0, 0], [11, 0, 10])).not.toBeNull();
expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull(); expect(findPartyNavigationPath(graph, [11, 0, 10], [0, 0, 0])).not.toBeNull();
}); });
+10 -1
View File
@@ -570,7 +570,16 @@ export function findNearestPartyNavigationSurface(
maxDistance = Number.POSITIVE_INFINITY, maxDistance = Number.POSITIVE_INFINITY,
): PartyNavigationSurfaceMatch | null { ): PartyNavigationSurfaceMatch | null {
const triangles = graph.triangles; const triangles = graph.triangles;
if (!triangles?.length) return null; if (!triangles?.length) {
const nearest = findNearestPartyNavigationNode(graph, point, maxDistance);
return nearest
? Object.freeze({
position: nearest.node.position,
triangleId: -1,
distance: nearest.distance,
})
: null;
}
const target = pointComponents(point); const target = pointComponents(point);
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY; const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
let nearestPosition: PartyNavigationPosition | null = null; let nearestPosition: PartyNavigationPosition | null = null;
+29 -1
View File
@@ -1,4 +1,10 @@
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three"; import {
BufferAttribute,
BufferGeometry,
Float32BufferAttribute,
Group,
Mesh,
} from "three";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache"; import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
@@ -33,4 +39,26 @@ describe("party navigation asset cache", () => {
}]); }]);
expect(second).not.toBe(first); expect(second).not.toBe(first);
}); });
it("expands normalized quantized positions before baking the node transform", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Int16Array([
0, 0, 0,
32_767, 0, 0,
0, 0, 32_767,
]), 3, true));
const mesh = new Mesh(geometry);
mesh.position.set(10, 2, -5);
mesh.scale.setScalar(100);
const scene = new Group();
scene.add(mesh);
const graph = preparePartyNavigationGraph("/navigation/quantized-transform.glb", scene, []);
expect(graph.nodes.map((node) => node.position)).toEqual([
[10, 2, -5],
[110, 2, -5],
[10, 2, 95],
]);
});
}); });
+31 -6
View File
@@ -1,5 +1,6 @@
import { BufferGeometry, 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 {
@@ -33,6 +38,19 @@ function navigationGeometry(scene: Group): BufferGeometry {
}); });
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry."); if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone(); const geometry = (sourceMesh as Mesh).geometry.clone();
const position = geometry.getAttribute("position");
// BufferAttribute.applyMatrix4 writes back through the source component
// type. Quantized normalized integers therefore clamp world coordinates to
// [-1, 1] while baking their node transform. Expand them to floats first.
if (position.normalized) {
const expanded = new Float32Array(position.count * 3);
for (let index = 0; index < position.count; index += 1) {
expanded[index * 3] = position.getX(index);
expanded[index * 3 + 1] = position.getY(index);
expanded[index * 3 + 2] = position.getZ(index);
}
geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3));
}
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld); geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry; return geometry;
} }
@@ -41,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);
@@ -53,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();
+9 -3
View File
@@ -102,7 +102,10 @@ describe("party navigation fleet safety", () => {
if (shouldLoadPartyNavigation("dungeon", dungeon, null)) { if (shouldLoadPartyNavigation("dungeon", dungeon, null)) {
enabled.push(dungeon.id); enabled.push(dungeon.id);
expect(dungeon.validation.status, dungeon.id).toBe("green"); expect(
dungeon.validation.status === "green" || dungeon.validation.navigation === "green",
dungeon.id,
).toBe(true);
expect(actualTriangles, dungeon.id) expect(actualTriangles, dungeon.id)
.toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES); .toBeLessThanOrEqual(MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES);
} else { } else {
@@ -113,8 +116,11 @@ describe("party navigation fleet safety", () => {
// Raksha Temple and The Origin now ship their full source-assembled // Raksha Temple and The Origin now ship their full source-assembled
// navigation geometry, which correctly moves both above the synchronous // navigation geometry, which correctly moves both above the synchronous
// graph-build ceiling and onto the breadcrumb fallback path. // graph-build ceiling and onto the breadcrumb fallback path.
expect(enabled).toHaveLength(29); // Stockades has a reviewed navigation override without claiming that its
expect(fallback).toHaveLength(55); // broader encounter/reset validation is complete.
expect(enabled).toContain("stormwind-stockade");
expect(enabled).toHaveLength(30);
expect(fallback).toHaveLength(54);
}); });
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => { it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
+10 -1
View File
@@ -25,6 +25,7 @@ function manastormPackageWithNavigationTriangles(
function dungeonWithNavigation( function dungeonWithNavigation(
status: DungeonDefinition["validation"]["status"], status: DungeonDefinition["validation"]["status"],
triangleCount: number, triangleCount: number,
navigation?: DungeonDefinition["validation"]["navigation"],
): Pick<DungeonDefinition, "assets" | "validation"> { ): Pick<DungeonDefinition, "assets" | "validation"> {
return { return {
assets: { assets: {
@@ -32,7 +33,7 @@ function dungeonWithNavigation(
collision: [], collision: [],
navigation: { id: "navigation", fileName: "navigation.glb", triangleCount }, navigation: { id: "navigation", fileName: "navigation.glb", triangleCount },
}, },
validation: { status, blockers: [], warnings: [] }, validation: { status, navigation, blockers: [], warnings: [] },
}; };
} }
@@ -61,6 +62,14 @@ describe("party navigation loading policy", () => {
)).toBe(false); )).toBe(false);
}); });
it("loads an independently reviewed dungeon navigation graph", () => {
expect(shouldLoadPartyNavigation(
"dungeon",
dungeonWithNavigation("unvalidated", 1_000, "green"),
null,
)).toBe(true);
});
it("protects Manastorm from oversized or unmeasured synchronous graphs", () => { it("protects Manastorm from oversized or unmeasured synchronous graphs", () => {
const fallbackDungeon = requireDungeonDefinition("wailing-caverns"); const fallbackDungeon = requireDungeonDefinition("wailing-caverns");
+7 -3
View File
@@ -21,9 +21,13 @@ export function shouldLoadPartyNavigation(
: dungeon.assets.navigation; : dungeon.assets.navigation;
if (!navigation) return false; if (!navigation) return false;
// Imported dungeon packs already use local/breadcrumb following until their // Navigation can be reviewed independently from encounter/checkpoint content.
// routes are reviewed, so building their navigation graph provides no value. // Do not make safe melee routing wait for unrelated dungeon validation work.
if (gameMode === "dungeon" && dungeon.validation.status !== "green") return false; if (
gameMode === "dungeon"
&& dungeon.validation.status !== "green"
&& dungeon.validation.navigation !== "green"
) return false;
const triangleCount = navigation.triangleCount; const triangleCount = navigation.triangleCount;
return typeof triangleCount === "number" return typeof triangleCount === "number"
+1 -1
View File
@@ -145,7 +145,7 @@ describe("player movement", () => {
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1); expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
}); });
it("walks back up the real Stockades entrance stairs without a jump impulse", () => { it("walks back up the Stockades interior stairs without a jump impulse", () => {
const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()); const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
world.createCollider( world.createCollider(
+355
View File
@@ -0,0 +1,355 @@
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 { beforeAll, describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
findNearestPartyNavigationNode,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
type PartyNavigationGraph,
} from "./partyNavigation";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
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 { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision";
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 stockades = requireDungeonDefinition("stormwind-stockade");
let graph: PartyNavigationGraph;
let sourceCollision: Group;
beforeAll(async () => {
await RAPIER.init();
await MeshoptDecoder.ready;
const document = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read(STOCKADES_NAVIGATION);
const meshNode = document.getRoot().listNodes().find((node) => node.getMesh());
const primitive = meshNode?.getMesh()?.listPrimitives()[0];
const accessor = primitive?.getAttribute("POSITION");
const sourcePositions = accessor?.getArray();
if (!meshNode || !accessor || !sourcePositions) {
throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface.");
}
if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) {
throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions.");
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized()));
const sourceIndices = primitive?.getIndices()?.getArray();
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
const mesh = new Mesh(geometry);
mesh.matrixAutoUpdate = false;
mesh.matrix.fromArray(meshNode.getWorldMatrix());
const scene = new Group();
scene.add(mesh);
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
// in the shipping runtime, not a separately pre-expanded test geometry.
graph = preparePartyNavigationGraph(
"/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps,
);
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 {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
describe("Stormwind Stockade party navigation", () => {
it("uses the reviewed warm indoor lighting profile", () => {
expect(stockades.presentation).toMatchObject({
background: "#171815",
fog: { color: "#1b1c18", near: 44 },
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
});
});
it("loads its independently reviewed, budgeted navigation graph", () => {
expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" });
expect(stockades.navigationLinks).toHaveLength(3);
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
expect(graph).toMatchObject({
sourceTriangleCount: 3_544,
componentCount: 31,
});
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", () => {
const slots = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
});
expect(slots).toHaveLength(5);
expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance)
.toBeLessThanOrEqual(6);
for (const slot of slots) {
expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull();
}
});
it("routes from the shipping entrance to every authored boss objective", () => {
const world = collisionWorld();
const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world);
const entrance = findNearestPartyNavigationSurface(
graph,
stockades.entrance.footPosition,
6,
);
expect(entrance).not.toBeNull();
for (const objective of stockades.objectiveOrder) {
const approach = findNearestPartyNavigationSurface(graph, objective.position, 15);
expect(approach, objective.name).not.toBeNull();
expect(findPartyNavigationPath(
graph,
entrance!.position,
approach!.position,
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed },
), objective.name).not.toBeNull();
}
world.free();
});
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({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
}, 2)[1];
const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6);
expect(tankStart).not.toBeNull();
const firstEnemy = [...stockades.staticSpawns].sort((left, right) => (
distance(left.position, stockades.entrance.footPosition)
- distance(right.position, stockades.entrance.footPosition)
))[0];
const desired: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
tankStart!.position,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("approach");
const route = planPartyRoute(
graph,
`tank:${firstEnemy.id}`,
tankStart!.position,
desired,
0,
{
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: TANK_RANGE.maximum,
directSegmentAllowed: segmentAllowed,
routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world),
},
);
expect(route.blocked).toBe(false);
expect(route.waypoints.length).toBeGreaterThan(1);
let current: [number, number, number] = [...tankStart!.position];
const next: [number, number, number] = [0, 0, 0];
for (const waypoint of route.waypoints) {
while (distance(current, waypoint) > 0.01) {
stepPartyPosition(current, waypoint, 0.5, next);
current = [...next];
}
}
expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum);
expect(partyCombatMovementGoal(
current,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).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).setCollisionGroups(ACTOR_COLLISION_GROUPS), 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).setCollisionGroups(ACTOR_COLLISION_GROUPS), 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, ACTOR_TERRAIN_QUERY_GROUPS);
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();
}
});
});
+8 -2
View File
@@ -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}
+1
View File
@@ -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}
+183 -159
View File
@@ -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,
@@ -38,7 +38,6 @@ import {
partyNaturalFollowPosition, partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal, partyPersonalizedCombatMovementGoal,
partyPresentationY, partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck, partyShouldRecallForStuck,
partyShouldMoveToComfortTarget, partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand, personalizedPartyCombatRangeBand,
@@ -84,19 +83,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 { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import {
createPartyDirectSegmentValidator,
createPartyRouteSegmentValidator,
PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT,
} 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_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
const PARTY_COLLISION_HALF_HEIGHT = 0.48; const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
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 = 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;
@@ -200,16 +199,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]);
@@ -288,16 +289,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],
@@ -308,92 +299,30 @@ function PartyActors({
ray, ray,
PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_DEPTH,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
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]; // Authored-link graphs use their eroded Recast node edges directly. Their
const dy = end[1] - start[1]; // boundary vertices are already agent-radius safe, while a center ray can
const dz = end[2] - start[2]; // falsely read the stair floor as a wall across a short vertical seam.
const distance = Math.hypot(dx, dy, dz); const navigationRouteSegmentAllowed = navigationGraph?.triangles?.length
if (!Number.isFinite(distance)) return false; ? routeSurfaceSegmentAllowed
if (distance <= 0.04) return true; : undefined;
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]);
const actorRefs = useRef<Array<Group | null>>([]); const actorRefs = useRef<Array<Group | null>>([]);
const bodyRefs = useRef<Array<Group | null>>([]); const bodyRefs = useRef<Array<Group | null>>([]);
const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>()); const positionsRef = useRef(new Map<string, MutablePartyWorldPosition>());
@@ -465,21 +394,30 @@ function PartyActors({
ray, ray,
20, 20,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null; return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
}, },
}), [hasStaticLineOfSight, memberIdKey, rapier, world]); }), [hasStaticLineOfSight, memberIdKey, rapier, world]);
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => { const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState(); const party = usePartyStore.getState();
const navigationRecall = navigationGraphRef.current
? findNearestPartyNavigationSurface(
navigationGraphRef.current,
player,
PARTY_NAVIGATION_SNAP_DISTANCE,
)
: null;
const recallPosition = navigationRecall?.position ?? player;
party.members.forEach((member, index) => { party.members.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
// The player's exact point is the only location guaranteed to be on the // Attach a recalled squad to the reviewed surface just as initial
// same side of a shortcut, doorway, or ledge. The breadcrumb system // spawns do. An exact visual-floor point can sit between Recast stair
// spreads the party back into a trail once the player moves. // vertices and leave the first route connector falsely blocked.
position[0] = player[0]; position[0] = recallPosition[0];
position[1] = player[1]; position[1] = recallPosition[1];
position[2] = player[2]; position[2] = recallPosition[2];
positionsRef.current.set(member.id, position); positionsRef.current.set(member.id, position);
updatePartyRuntimePosition(member.id, position[0], position[1], position[2]); updatePartyRuntimePosition(member.id, position[0], position[1], position[2]);
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]); actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
@@ -495,7 +433,7 @@ function PartyActors({
const firstLiving = party.members.find((member) => member.health > 0); const firstLiving = party.members.find((member) => member.health > 0);
resetPartyBreadcrumbTrail(playerTrailRef.current, player); resetPartyBreadcrumbTrail(playerTrailRef.current, player);
leaderPlayerCursorRef.current.breadcrumbId = null; leaderPlayerCursorRef.current.breadcrumbId = null;
resetPartyBreadcrumbTrail(leaderTrailRef.current, player); resetPartyBreadcrumbTrail(leaderTrailRef.current, recallPosition);
trailLeaderIdRef.current = firstLiving?.id ?? null; trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState(); routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear(); combatRoutesRef.current.clear();
@@ -699,11 +637,9 @@ function PartyActors({
const nextPosition: MutablePartyWorldPosition = [0, 0, 0]; const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0]; const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0]; const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1) .map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0); .filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => { const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex)); const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`; const key = `${memberId}|${livingRank}`;
@@ -714,13 +650,6 @@ function PartyActors({
} }
return profile; return profile;
}; };
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false; let leaderHasWaypoint = false;
let leaderMovementExpected = false; let leaderMovementExpected = false;
let leaderFollowingPlayer = false; let leaderFollowingPlayer = false;
@@ -756,12 +685,14 @@ function PartyActors({
const leader = leaderMember; const leader = leaderMember;
let goalKey: string; let goalKey: string;
let hasDestination = true; let hasDestination = true;
let leaderLocalFinishDistance = 3;
if (targetPosition && leaderTargetId) { if (targetPosition && leaderTargetId) {
const profile = movementProfileFor(leader.id, leaderIndex); const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand( const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader), partyCombatRangeBand(leader),
profile, profile,
); );
leaderLocalFinishDistance = Math.max(leaderLocalFinishDistance, rangeBand.maximum);
let combatAnchor = combatAnchorsRef.current.get(leader.id); let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) { if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = { combatAnchor = {
@@ -773,15 +704,12 @@ function PartyActors({
}; };
combatAnchorsRef.current.set(leader.id, combatAnchor); combatAnchorsRef.current.set(leader.id, combatAnchor);
} }
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal( const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition, leaderPosition,
targetPosition, targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition), hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand, rangeBand,
combatAnchor.bearing, combatAnchor.bearing,
crowded,
leaderDestination, leaderDestination,
); );
if (movementIntent === "hold") { if (movementIntent === "hold") {
@@ -817,7 +745,6 @@ function PartyActors({
&& fullDistance(leaderPosition, leaderDestination) > 0.18; && fullDistance(leaderPosition, leaderDestination) > 0.18;
if (leaderFollowingPlayer) { if (leaderFollowingPlayer) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const cursor = leaderPlayerCursorRef.current; const cursor = leaderPlayerCursorRef.current;
const profile = movementProfileFor(leader.id, leaderIndex); const profile = movementProfileFor(leader.id, leaderIndex);
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0]; const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
@@ -835,6 +762,7 @@ function PartyActors({
leaderDestination, leaderDestination,
); );
if (directSegmentAllowed(leaderPosition, leaderDestination)) { if (directSegmentAllowed(leaderPosition, leaderDestination)) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false; const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget( leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition, leaderPosition,
@@ -877,9 +805,63 @@ function PartyActors({
leaderHasWaypoint = false; leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true; leaderPlayerTrailBlocked = true;
} }
if (!leaderHasWaypoint && navigationGraph) {
const navigationFollowDestination = findNearestPartyNavigationSurface(
navigationGraph,
leaderDestination,
PARTY_NAVIGATION_SNAP_DISTANCE,
)?.position ?? leaderDestination;
if (partyRouteNeedsPlanning(
routeRef.current,
goalKey,
leaderPosition,
navigationFollowDestination,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
routeRef.current = planPartyRoute(
navigationGraph,
goalKey,
leaderPosition,
navigationFollowDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
}
leaderHasWaypoint = nextPartyRouteWaypoint(
routeRef.current,
leaderPosition,
desired,
);
if (!leaderHasWaypoint && routeRef.current.blocked) {
leaderHasWaypoint = nextPartyRouteRecoveryWaypoint(
routeRef.current,
leaderPosition,
desired,
directSegmentAllowed,
);
}
leaderPlayerTrailBlocked = !leaderHasWaypoint && routeRef.current.blocked;
}
leaderMovementExpected = leaderHasWaypoint leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation) || (
> personalizedTrailingDistance + profile.comfortSlack; (!navigationGraph || leaderPlayerTrailBlocked)
&& planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack
);
} }
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) { } else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if ( if (
@@ -902,11 +884,11 @@ function PartyActors({
leaderDestination, leaderDestination,
routeNow, routeNow,
{ {
maxOriginSnapDistance: 3, maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5, maxDestinationSnapDistance: 5,
localFinishDistance: 3, localFinishDistance: leaderLocalFinishDistance,
directSegmentAllowed, directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed,
}, },
); );
} }
@@ -1046,15 +1028,12 @@ function PartyActors({
}; };
combatAnchorsRef.current.set(member.id, combatAnchor); combatAnchorsRef.current.set(member.id, combatAnchor);
} }
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal( const movementIntent = partyPersonalizedCombatMovementGoal(
current, current,
memberTargetPosition, memberTargetPosition,
canSeeActiveTarget, canSeeActiveTarget,
rangeBand, rangeBand,
combatAnchor.bearing, combatAnchor.bearing,
crowded,
desired, desired,
); );
movementExpected = movementIntent !== "hold"; movementExpected = movementIntent !== "hold";
@@ -1082,14 +1061,14 @@ function PartyActors({
desired, desired,
routeNow, routeNow,
{ {
maxOriginSnapDistance: 3, maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5, maxDestinationSnapDistance: 5,
localFinishDistance: 3, localFinishDistance: Math.max(3, rangeBand.maximum),
replanDistance: 1.25, replanDistance: 1.25,
replanIntervalMs: 350, replanIntervalMs: 350,
blockedRetryMs: 800, blockedRetryMs: 800,
directSegmentAllowed, directSegmentAllowed,
routeSegmentAllowed: routeSurfaceSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed,
}, },
); );
combatRoutesRef.current.set(member.id, route); combatRoutesRef.current.set(member.id, route);
@@ -1112,7 +1091,6 @@ function PartyActors({
} }
} }
} else { } else {
combatRoutesRef.current.delete(member.id);
let cursor = followerCursorsRef.current.get(member.id); let cursor = followerCursorsRef.current.get(member.id);
if (!cursor) { if (!cursor) {
cursor = createPartyBreadcrumbCursor(); cursor = createPartyBreadcrumbCursor();
@@ -1137,6 +1115,7 @@ function PartyActors({
naturalFollowTarget, naturalFollowTarget,
); );
if (directSegmentAllowed(current, naturalFollowTarget)) { if (directSegmentAllowed(current, naturalFollowTarget)) {
combatRoutesRef.current.delete(member.id);
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false; const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget( hasDesired = partyShouldMoveToComfortTarget(
current, current,
@@ -1158,6 +1137,7 @@ function PartyActors({
movementExpected = hasDesired; movementExpected = hasDesired;
} else { } else {
directFollowMovingRef.current.set(member.id, false); directFollowMovingRef.current.set(member.id, false);
let followerTrailBlocked = false;
const previousBreadcrumbId = cursor.breadcrumbId; const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId); || !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
@@ -1177,11 +1157,68 @@ function PartyActors({
) { ) {
cursor.breadcrumbId = previousBreadcrumbId; cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false; hasDesired = false;
frameRouteBlocked = true; followerTrailBlocked = true;
} }
if (!hasDesired && navigationGraph) {
const goalKey = `member:${member.id}|leader-follow`;
const navigationFollowDestination = findNearestPartyNavigationSurface(
navigationGraph,
naturalFollowTarget,
PARTY_NAVIGATION_SNAP_DISTANCE,
)?.position ?? naturalFollowTarget;
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
if (partyRouteNeedsPlanning(
route,
goalKey,
current,
navigationFollowDestination,
routeNow,
{
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
},
)) {
route = planPartyRoute(
navigationGraph,
goalKey,
current,
navigationFollowDestination,
routeNow,
{
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
maxDestinationSnapDistance: 5,
localFinishDistance: 3,
replanDistance: 1.25,
replanIntervalMs: 350,
blockedRetryMs: 800,
directSegmentAllowed,
routeSegmentAllowed: navigationRouteSegmentAllowed,
},
);
combatRoutesRef.current.set(member.id, route);
}
hasDesired = nextPartyRouteWaypoint(route, current, desired);
const routePrefixRecovery = !hasDesired && route.blocked
? nextPartyRouteRecoveryWaypoint(
route,
current,
desired,
directSegmentAllowed,
)
: false;
if (routePrefixRecovery) hasDesired = true;
followerTrailBlocked = !hasDesired && route.blocked;
} else if (hasDesired) {
combatRoutesRef.current.delete(member.id);
}
if (followerTrailBlocked) frameRouteBlocked = true;
movementExpected = hasDesired movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation) || (
> leaderTrailingDistance + profile.comfortSlack; (!navigationGraph || followerTrailBlocked)
&& planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack
);
} }
} }
if (memberTargetPosition) { if (memberTargetPosition) {
@@ -1194,23 +1231,6 @@ function PartyActors({
} }
const movementProfile = movementProfileFor(member.id, index); const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation); const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && party.command !== "stop" && hasDesired) { if (member.health > 0 && party.command !== "stop" && hasDesired) {
@@ -1382,6 +1402,8 @@ export function PartyPopulation({
const wailingPhase = useWailingEncounterStore((state) => state.phase); const wailingPhase = useWailingEncounterStore((state) => state.phase);
const manastormEncounter = useManastormStore((state) => state.currentEncounter); const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const dungeon = requireDungeonDefinition(activeDungeonId); const dungeon = requireDungeonDefinition(activeDungeonId);
const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green";
const manastormPackage = gameMode === "manastorm" const manastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage() ? activeManastormStageAssetPackage()
: null; : null;
@@ -1393,9 +1415,9 @@ export function PartyPopulation({
}] }]
// Generated packages preserve complete objective metadata, but their // Generated packages preserve complete objective metadata, but their
// fragmented Recast islands do not claim autonomous route fidelity. Until // fragmented Recast islands do not claim autonomous route fidelity. Until
// reviewed off-mesh links make the pack green, the player leads and the // reviewed links make navigation green, the player leads and the party
// party follows/engages locally instead of displaying a false blocked route. // follows/engages locally instead of displaying a false blocked route.
: dungeon.validation.status === "green" : navigationReviewed
? activeDungeonId === "wailing-caverns" ? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus" && wailingPhase !== "mutanus"
&& wailingPhase !== "complete" && wailingPhase !== "complete"
@@ -1411,6 +1433,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;
@@ -1450,7 +1473,7 @@ export function PartyPopulation({
navigationGraph={navigationGraph} navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !navigationEnabled} navigationFailed={navigationFailed || !navigationEnabled}
objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)} objectives={partyObjectivesForNavigation(Boolean(navigationGraph), objectives)}
stackAtEntrance={gameMode === "manastorm" || dungeon.validation.status !== "green"} stackAtEntrance={gameMode === "manastorm" || !navigationReviewed}
/> />
{navigationEnabled && assets.navigation ? ( {navigationEnabled && assets.navigation ? (
<NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}> <NavigationAssetBoundary key={assets.navigation} onFailure={rejectNavigationGraph}>
@@ -1458,6 +1481,7 @@ export function PartyPopulation({
<PartyNavigationLoader <PartyNavigationLoader
url={assets.navigation} url={assets.navigation}
offMeshLinks={navigationLinks} offMeshLinks={navigationLinks}
ramps={traversalRamps}
onReady={acceptNavigationGraph} onReady={acceptNavigationGraph}
/> />
</Suspense> </Suspense>
+13 -7
View File
@@ -60,6 +60,7 @@ import {
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
const PLAYER_RADIUS = 0.36; const PLAYER_RADIUS = 0.36;
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48; const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
@@ -212,8 +213,8 @@ function PlayerController({
groundRay, groundRay,
groundProbeLength, groundProbeLength,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
undefined, ACTOR_TERRAIN_QUERY_GROUPS,
undefined, undefined,
body, body,
); );
@@ -267,7 +268,8 @@ function PlayerController({
y: verticalVelocity * movementDeltaSeconds, y: verticalVelocity * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementDeltaSeconds, z: worldZ * MOVE_SPEED * movementDeltaSeconds,
}, },
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
const computedMovement = characterController.computedMovement(); const computedMovement = characterController.computedMovement();
const nextTranslation = { const nextTranslation = {
@@ -347,7 +349,11 @@ function PlayerController({
spawn.footPosition[2], spawn.footPosition[2],
]} ]}
> >
<CapsuleCollider args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]} friction={0} /> <CapsuleCollider
args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]}
friction={0}
collisionGroups={ACTOR_COLLISION_GROUPS}
/>
<group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}> <group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}>
<CharacterModel <CharacterModel
identity={identity} identity={identity}
@@ -408,7 +414,7 @@ function ThirdPersonCamera({
// Cast a small sphere instead of a single ray. This behaves like a camera // Cast a small sphere instead of a single ray. This behaves like a camera
// spring arm and keeps the whole near-camera volume out of walls, pillars, // spring arm and keeps the whole near-camera volume out of walls, pillars,
// and low ceilings. The player's own capsule is explicitly ignored. // and low ceilings. All actor capsules are ignored, including the player.
const hit = world.castShape( const hit = world.castShape(
target, target,
shapeRotation, shapeRotation,
@@ -417,8 +423,8 @@ function ThirdPersonCamera({
CAMERA_WALL_GAP, CAMERA_WALL_GAP,
fullDistance, fullDistance,
false, false,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
undefined, ACTOR_TERRAIN_QUERY_GROUPS,
undefined, undefined,
body, body,
); );
+4 -1
View File
@@ -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(() => () => {
+114
View File
@@ -0,0 +1,114 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "../game/playerMovement";
import { isWalkableGroundHit } from "../game/playerJump";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
const BODY_TYPES = ["fixed", "kinematic", "dynamic"] as const;
type BodyType = typeof BODY_TYPES[number];
beforeAll(async () => {
await RAPIER.init();
});
function actor(world: RAPIER.World, bodyType: BodyType, z = 0, groups = ACTOR_COLLISION_GROUPS) {
const description = bodyType === "fixed"
? RAPIER.RigidBodyDesc.fixed()
: bodyType === "kinematic"
? RAPIER.RigidBodyDesc.kinematicPositionBased()
: RAPIER.RigidBodyDesc.dynamic();
const body = world.createRigidBody(description.setTranslation(0, 0.84, z));
const collider = world.createCollider(
RAPIER.ColliderDesc.capsule(0.48, 0.36)
.setCollisionGroups(groups)
.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL),
body,
);
return { body, collider };
}
function terrain(world: RAPIER.World, wall = false) {
const floor = world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 8)
.setTranslation(0, -0.1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
if (wall) world.createCollider(RAPIER.ColliderDesc.cuboid(5, 2, 0.1)
.setTranslation(0, 2, 2.5).setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
return floor;
}
describe("non-blocking combat actors", () => {
it.each(BODY_TYPES)("has no physical contacts with overlapping %s actors", (bodyType) => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const player = actor(world, "dynamic");
// Party members, mobs, bosses, and remote players use the same actor policy.
const others = ["party", "mob", "boss", "remote-player"].map(() => actor(world, bodyType));
world.step();
for (const other of others) {
world.contactPair(player.collider, other.collider, () => {
throw new Error("Actors must not produce physical contacts");
});
expect(other.body.translation().z).toBeCloseTo(0);
}
expect(player.body.translation().z).toBeCloseTo(0);
world.free();
});
it("detects contacts when an actor mistakenly uses the terrain layer", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const player = actor(world, "dynamic");
const obstacle = actor(world, "fixed", 0.4, DUNGEON_PHYSICAL_COLLISION_GROUPS);
world.step();
let contacts = 0;
world.contactPair(player.collider, obstacle.collider, (manifold) => { contacts += manifold.numContacts(); });
expect(contacts).toBeGreaterThan(0);
world.free();
});
it.each(BODY_TYPES)("walks through %s actors but still stops at a dungeon wall", (bodyType) => {
for (const hasWall of [false, true]) {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
terrain(world, hasWall);
actor(world, bodyType);
actor(world, bodyType, -2); // Start overlapped: no depenetration or detour.
const player = actor(world, "kinematic", -2);
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
world.step();
for (let frame = 0; frame < 90; frame += 1) {
controller.computeColliderMovement(player.collider, { x: 0, y: -0.5 / 60, z: 4.8 / 60 },
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
const position = player.body.translation();
const movement = controller.computedMovement();
player.body.setNextKinematicTranslation({ x: position.x + movement.x, y: position.y + movement.y, z: position.z + movement.z });
world.step();
}
const position = player.body.translation();
if (hasWall) {
expect(position.z).toBeGreaterThan(1.8);
expect(position.z).toBeLessThan(2.1);
} else expect(position.z).toBeGreaterThan(5);
expect(position.x).toBeCloseTo(0);
expect(position.y).toBeCloseTo(0.84, 1);
world.removeCharacterController(controller);
world.free();
}
});
it.each(BODY_TYPES)("never treats a %s actor as ground, a route obstacle, or a camera wall", (bodyType) => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const floor = terrain(world);
actor(world, bodyType);
world.step();
const ground = world.castRayAndGetNormal(new RAPIER.Ray({ x: 0, y: 4, z: 0 }, { x: 0, y: -1, z: 0 }),
5, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
expect(ground?.collider.handle).toBe(floor.handle);
expect(ground?.timeOfImpact).toBeCloseTo(4);
expect(isWalkableGroundHit(ground, 5)).toBe(true);
expect(createPartyDirectSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
expect(world.castShape({ x: 0, y: 0.9, z: -1.5 }, { x: 0, y: 0, z: 0, w: 1 },
{ x: 0, y: 0, z: 1 }, new RAPIER.Ball(0.24), 0.08, 3, false,
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS)).toBeNull();
world.free();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { interactionGroups } from "@react-three/rapier";
export const TERRAIN_COLLISION_GROUP = 0;
export const ACTOR_COLLISION_GROUP = 1;
// Players, companions, mobs, and bosses never block or push one another.
// Any actor capsule added in future must use this layer, not the terrain layer.
export const ACTOR_COLLISION_GROUPS = interactionGroups(
ACTOR_COLLISION_GROUP,
TERRAIN_COLLISION_GROUP,
);
// Movement, ground probes, and camera sweeps see only physical terrain,
// regardless of whether an actor's body is fixed, kinematic, or dynamic.
export const ACTOR_TERRAIN_QUERY_GROUPS = ACTOR_COLLISION_GROUPS;
+3 -2
View File
@@ -1,4 +1,5 @@
import { interactionGroups } from "@react-three/rapier"; import { interactionGroups } from "@react-three/rapier";
import { TERRAIN_COLLISION_GROUP } from "./actorCollision";
import type { import type {
QueryFilterFlags, QueryFilterFlags,
Ray, Ray,
@@ -17,9 +18,9 @@ interface RapierApi {
type WorldPosition = readonly [number, number, number]; type WorldPosition = readonly [number, number, number];
export const DUNGEON_LINE_OF_SIGHT_GROUP = 15; export const DUNGEON_LINE_OF_SIGHT_GROUP = 15;
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0); export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(TERRAIN_COLLISION_GROUP);
export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP); export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP);
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]); export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([TERRAIN_COLLISION_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP]);
export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups( export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups(
DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP,
DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP,
+74
View File
@@ -0,0 +1,74 @@
import type RAPIER from "@dimforge/rapier3d-compat";
import type { World } from "@dimforge/rapier3d-compat";
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
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;
/** 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 = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
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,
ACTOR_TERRAIN_QUERY_GROUPS,
);
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, ACTOR_TERRAIN_QUERY_GROUPS);
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 = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
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, ACTOR_TERRAIN_QUERY_GROUPS);
return !hit || hit.timeOfImpact >= distance - 0.04;
};
}
+4 -1
View File
@@ -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;