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; }; }