Release Healer Man 0.1.34
This commit is contained in:
1 parent
05b25c4341
commit
46c272f889
19 files changed
+568
-121
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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(() => () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type RAPIER from "@dimforge/rapier3d-compat";
|
||||
import type { World } from "@dimforge/rapier3d-compat";
|
||||
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
|
||||
|
||||
const COLLISION_RADIUS = 0.34;
|
||||
const COLLISION_HALF_HEIGHT = 0.48;
|
||||
const COLLISION_CENTER_HEIGHT = COLLISION_RADIUS + COLLISION_HALF_HEIGHT + 0.04;
|
||||
const WALKABLE_STEP_HEIGHT = 0.32;
|
||||
export const PARTY_FLOOR_RAY_HEIGHT = 1.25;
|
||||
export const PARTY_FLOOR_RAY_DEPTH = 2;
|
||||
const FLOOR_SAMPLE_SPACING = 0.6;
|
||||
const MAX_FLOOR_DELTA = 0.55;
|
||||
|
||||
export function partyTerrainQueryFlags(rapier: typeof RAPIER) {
|
||||
// Actor capsules are not terrain. Crowd spacing is handled separately by
|
||||
// party separation; counting the player here blocks recalls and replans.
|
||||
return rapier.QueryFilterFlags.EXCLUDE_SENSORS
|
||||
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC
|
||||
| rapier.QueryFilterFlags.EXCLUDE_KINEMATIC;
|
||||
}
|
||||
|
||||
/** Shared by live party routing and collision-backed traversal regressions. */
|
||||
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||
const capsule = new rapier.Capsule(
|
||||
COLLISION_HALF_HEIGHT - WALKABLE_STEP_HEIGHT * 0.5,
|
||||
COLLISION_RADIUS,
|
||||
);
|
||||
const rotation = { x: 0, y: 0, z: 0, w: 1 };
|
||||
const flags = partyTerrainQueryFlags(rapier);
|
||||
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const dz = end[2] - start[2];
|
||||
const distance = Math.hypot(dx, dy, dz);
|
||||
if (!Number.isFinite(distance)) return false;
|
||||
if (Math.hypot(dx, dz) <= 0.04) return Math.abs(dy) <= WALKABLE_STEP_HEIGHT;
|
||||
const obstacle = world.castShape(
|
||||
{ x: start[0], y: start[1] + COLLISION_CENTER_HEIGHT + WALKABLE_STEP_HEIGHT * 0.5, z: start[2] },
|
||||
rotation,
|
||||
{ x: dx / distance, y: dy / distance, z: dz / distance },
|
||||
capsule,
|
||||
0.025,
|
||||
distance,
|
||||
false,
|
||||
flags,
|
||||
);
|
||||
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
|
||||
const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING));
|
||||
for (let sample = 0; sample <= sampleCount; sample += 1) {
|
||||
const progress = sample / sampleCount;
|
||||
const expectedY = start[1] + dy * progress;
|
||||
const ray = new rapier.Ray({
|
||||
x: start[0] + dx * progress,
|
||||
y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
|
||||
z: start[2] + dz * progress,
|
||||
}, { x: 0, y: -1, z: 0 });
|
||||
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags);
|
||||
if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */
|
||||
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
|
||||
const flags = partyTerrainQueryFlags(rapier);
|
||||
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const dz = end[2] - start[2];
|
||||
const distance = Math.hypot(dx, dy, dz);
|
||||
if (!Number.isFinite(distance)) return false;
|
||||
if (distance <= 0.04) return true;
|
||||
const ray = new rapier.Ray({
|
||||
x: start[0], y: start[1] + 0.9, z: start[2],
|
||||
}, { x: dx / distance, y: dy / distance, z: dz / distance });
|
||||
const hit = world.castRay(ray, distance, true, flags);
|
||||
return !hit || hit.timeOfImpact >= distance - 0.04;
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Mesh,
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user