Release Healer Man 0.1.34

This commit is contained in:
phenom committed 2026-09-12 15:14:56 -04:00
1 parent 05b25c4341
commit 46c272f889
19 files changed
+568 -121

No files matched your search

+8 -2
View File
@@ -24,7 +24,7 @@ import { useCombatStore } from "../game/combatStore";
import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
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 { usePartyStore } from "../game/partyStore";
import { preloadTalentGameplayResources } from "../ui/talentGameplayResources";
@@ -63,11 +63,13 @@ function WarmupCommit({ onSettled }: { readonly onSettled: () => void }) {
function SceneAssetProbe({
navigationLinks,
traversalRamps,
navigationUrl,
urls,
onSettled,
}: {
readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps: readonly DungeonTraversalRamp[];
readonly navigationUrl: string | null;
readonly urls: readonly string[];
readonly onSettled: () => void;
@@ -80,8 +82,9 @@ function SceneAssetProbe({
navigationUrl,
gltfs[navigationIndex].scene,
navigationLinks,
traversalRamps,
);
}, [gltfs, navigationIndex, navigationLinks, navigationUrl]);
}, [gltfs, navigationIndex, navigationLinks, navigationUrl, traversalRamps]);
return <WarmupCommit onSettled={onSettled} />;
}
@@ -248,11 +251,13 @@ function PartyDataProbe({ onSettled }: { readonly onSettled: () => void }) {
export function ActivityAssetWarmup({
navigationLinks,
traversalRamps,
navigationUrl,
sceneAssetUrls,
onReady,
}: {
readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps: readonly DungeonTraversalRamp[];
readonly navigationUrl: string | null;
readonly sceneAssetUrls: readonly string[];
readonly onReady: () => void;
@@ -276,6 +281,7 @@ export function ActivityAssetWarmup({
<Suspense fallback={null}>
<SceneAssetProbe
navigationLinks={navigationLinks}
traversalRamps={traversalRamps}
navigationUrl={navigationUrl}
urls={sceneAssetUrls}
onSettled={markSceneReady}
+1
View File
@@ -347,6 +347,7 @@ export function GameScene() {
<ActivityAssetWarmup
key={`activity-warmup:${sessionRevision}`}
navigationLinks={navigationLinks}
traversalRamps={manastormAssetPackage ? [] : dungeon.traversalRamps ?? []}
navigationUrl={navigationUrl}
sceneAssetUrls={activitySceneAssetUrls}
onReady={markActivityAssetsReady}
+24 -100
View File
@@ -14,7 +14,7 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
import { Quaternion, type Group } from "three";
import { type Group } from "three";
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
@@ -25,7 +25,7 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { DungeonBossObjective } from "../game/dungeonTypes";
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import {
appendPartyBreadcrumb,
@@ -84,20 +84,19 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import {
createPartyDirectSegmentValidator,
createPartyRouteSegmentValidator,
PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT,
partyTerrainQueryFlags,
} from "./partyTraversal";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
const BREADCRUMB_REACHED_DISTANCE = 0.04;
const PARTY_COLLISION_RADIUS = 0.34;
const PARTY_COLLISION_HALF_HEIGHT = 0.48;
const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04;
const PARTY_WALKABLE_STEP_HEIGHT = 0.32;
const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15;
const PARTY_NAVIGATION_SNAP_DISTANCE = 6;
const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
const PARTY_FLOOR_RAY_HEIGHT = 1.25;
const PARTY_FLOOR_RAY_DEPTH = 2;
const PARTY_MAX_FLOOR_DELTA = 0.55;
const PARTY_MAX_GROUND_CORRECTION = 0.85;
const EMPTY_NAVIGATION_LINKS = [] as const;
@@ -201,16 +200,18 @@ class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, Na
function PartyNavigationLoader({
url,
offMeshLinks,
ramps,
onReady,
}: {
readonly url: string;
readonly offMeshLinks: ReturnType<typeof requireDungeonDefinition>["navigationLinks"];
readonly ramps: readonly DungeonTraversalRamp[];
readonly onReady: (graph: PartyNavigationGraph) => void;
}) {
const gltf = useGLTF(resolveContentUrl(url));
const graph = useMemo(
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks),
[gltf.scene, offMeshLinks, url],
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks, ramps),
[gltf.scene, offMeshLinks, ramps, url],
);
useEffect(() => onReady(graph), [graph, onReady]);
@@ -289,16 +290,6 @@ function PartyActors({
);
const resetRevision = useGameStore((state) => state.resetRevision);
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 ray = new rapier.Ray({
x: position[0],
@@ -309,92 +300,23 @@ function PartyActors({
ray,
PARTY_FLOOR_RAY_DEPTH,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
partyTerrainQueryFlags(rapier),
);
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
}, [rapier, world]);
const directSegmentAllowed = useCallback((
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;
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 directSegmentAllowed = useMemo(
() => createPartyDirectSegmentValidator(rapier, world),
[rapier, world],
);
const hasStaticLineOfSight = useCallback((
start: PartyWorldPosition,
end: PartyWorldPosition,
): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]);
const routeSurfaceSegmentAllowed = useCallback((
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 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 routeSurfaceSegmentAllowed = useMemo(
() => createPartyRouteSegmentValidator(rapier, world),
[rapier, world],
);
// Authored-link graphs use their eroded Recast node edges directly. Their
// boundary vertices are already agent-radius safe, while a center ray can
// falsely read the stair floor as a wall across a short vertical seam.
@@ -1542,6 +1464,7 @@ export function PartyPopulation({
const navigationEnabled = Boolean(assets.navigation)
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage);
const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks;
const traversalRamps = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.traversalRamps ?? EMPTY_NAVIGATION_LINKS;
const [navigationState, setNavigationState] = useState<{
readonly url: string;
readonly graph: PartyNavigationGraph;
@@ -1589,6 +1512,7 @@ export function PartyPopulation({
<PartyNavigationLoader
url={assets.navigation}
offMeshLinks={navigationLinks}
ramps={traversalRamps}
onReady={acceptNavigationGraph}
/>
</Suspense>
+4 -1
View File
@@ -116,7 +116,10 @@ function CollisionChunk({
readonly registerPrepared: (url: string, ready: boolean) => void;
}) {
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 sightBlockers = useMemo(() => collisionLineOfSightTrimeshes(collision), [collision]);
useEffect(() => () => {
+80
View File
@@ -0,0 +1,80 @@
import type RAPIER from "@dimforge/rapier3d-compat";
import type { World } from "@dimforge/rapier3d-compat";
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
const COLLISION_RADIUS = 0.34;
const COLLISION_HALF_HEIGHT = 0.48;
const COLLISION_CENTER_HEIGHT = COLLISION_RADIUS + COLLISION_HALF_HEIGHT + 0.04;
const WALKABLE_STEP_HEIGHT = 0.32;
export const PARTY_FLOOR_RAY_HEIGHT = 1.25;
export const PARTY_FLOOR_RAY_DEPTH = 2;
const FLOOR_SAMPLE_SPACING = 0.6;
const MAX_FLOOR_DELTA = 0.55;
export function partyTerrainQueryFlags(rapier: typeof RAPIER) {
// Actor capsules are not terrain. Crowd spacing is handled separately by
// party separation; counting the player here blocks recalls and replans.
return rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC
| rapier.QueryFilterFlags.EXCLUDE_KINEMATIC;
}
/** Shared by live party routing and collision-backed traversal regressions. */
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
const capsule = new rapier.Capsule(
COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5,
COLLISION_RADIUS,
);
const rotation = { x: 0, y: 0, z: 0, w: 1 };
const flags = partyTerrainQueryFlags(rapier);
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (Math.hypot(dx, dz) <= 0.04) return Math.abs(dy) <= WALKABLE_STEP_HEIGHT;
const obstacle = world.castShape(
{ x: start[0], y: start[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: start[2] },
rotation,
{ x: dx / distance, y: dy / distance, z: dz / distance },
capsule,
0.025,
distance,
false,
flags,
);
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING));
for (let sample = 0; sample <= sampleCount; sample += 1) {
const progress = sample / sampleCount;
const expectedY = start[1] + dy * progress;
const ray = new rapier.Ray({
x: start[0] + dx * progress,
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
z: start[2] + dz * progress,
}, { x: 0, y: -1, z: 0 });
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags);
if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false;
}
return true;
};
}
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
const flags = partyTerrainQueryFlags(rapier);
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false;
if (distance <= 0.04) return true;
const ray = new rapier.Ray({
x: start[0], y: start[1] + 0.9, z: start[2],
}, { x: dx / distance, y: dy / distance, z: dz / distance });
const hit = world.castRay(ray, distance, true, flags);
return !hit || hit.timeOfImpact >= distance - 0.04;
};
}
+4 -1
View File
@@ -4,6 +4,8 @@ import {
Mesh,
type Object3D,
} from "three";
import type { DungeonTraversalRamp } from "../game/dungeonTypes";
import { smoothDungeonStairGeometry } from "../game/dungeonTraversalRamps";
export interface PreparedCollisionTrimesh {
readonly id: string;
@@ -15,7 +17,7 @@ export interface PreparedCollisionTrimesh {
// This deliberately rejects floors, ramps, slopes, and ceilings.
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);
const inverseRootMatrix = root.matrixWorld.clone().invert();
const prepared = new Group();
@@ -40,6 +42,7 @@ export function prepareCollision(root: Object3D): Group {
);
}
geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld));
if (ramps.length) smoothDungeonStairGeometry(geometry, ramps);
const preparedMesh = new Mesh(geometry, mesh.material);
preparedMesh.name = mesh.name;
preparedMesh.visible = false;