import { interactionGroups } from "@react-three/rapier"; import type { QueryFilterFlags, Ray, Vector, World, } from "@dimforge/rapier3d-compat"; interface RapierApi { readonly Ray: new (origin: Vector, direction: Vector) => Ray; readonly QueryFilterFlags: { readonly EXCLUDE_DYNAMIC: QueryFilterFlags; readonly EXCLUDE_KINEMATIC: QueryFilterFlags; }; } type WorldPosition = readonly [number, number, number]; export const DUNGEON_LINE_OF_SIGHT_GROUP = 15; export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0); 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_SIGHT_QUERY_GROUPS = interactionGroups( DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP, ); const ACTOR_SIGHT_HEIGHT = 1.05; const TARGET_MARGIN = 0.08; /** * Checks only the dedicated wall/pillar collision layer. Walkable terrain and * ceilings remain physical, but never participate in combat visibility. */ export function hasDungeonLineOfSight( rapier: RapierApi, world: World, start: WorldPosition, end: WorldPosition, ): 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 <= TARGET_MARGIN) return true; const ray = new rapier.Ray( { x: start[0], y: start[1] + ACTOR_SIGHT_HEIGHT, z: start[2] }, { x: dx / distance, y: dy / distance, z: dz / distance }, ); const hit = world.castRay( ray, distance, true, rapier.QueryFilterFlags.EXCLUDE_DYNAMIC | rapier.QueryFilterFlags.EXCLUDE_KINEMATIC, DUNGEON_SIGHT_QUERY_GROUPS, ); return !hit || hit.timeOfImpact >= distance - TARGET_MARGIN; }