import type { PartyNavigationGraph } from "./partyNavigation"; export type CombatSpatialPosition = readonly [x: number, y: number, z: number]; export interface CombatSpatialActorSnapshot { readonly id: string; readonly position: CombatSpatialPosition; /** World-space yaw in radians; +Z is forward. */ readonly yaw: number; } export interface CombatSpatialProvider { /** The already-loaded, reviewed dungeon surface shared by actor navigation. */ readonly navigationGraph?: () => PartyNavigationGraph | null; readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null; readonly hasLineOfSight: ( source: CombatSpatialPosition, target: CombatSpatialPosition, ) => boolean; readonly projectGroundPoint: ( requested: CombatSpatialPosition, maximumDistance: number, origin?: CombatSpatialPosition, ) => CombatSpatialPosition | null; } let provider: CombatSpatialProvider | null = null; /** Registers the loaded dungeon's read-only spatial query seam. */ export function registerCombatSpatialProvider(next: CombatSpatialProvider): () => void { provider = next; return () => { if (provider === next) provider = null; }; } export function resetCombatSpatialProvider(): void { provider = null; } export function combatSpatialActor(actorId: string): CombatSpatialActorSnapshot | null { return provider?.actor(actorId) ?? null; } export function combatHasLineOfSight( source: CombatSpatialPosition, target: CombatSpatialPosition, ): boolean { return provider?.hasLineOfSight(source, target) ?? true; } export function combatNavigationGraph(): PartyNavigationGraph | null { return provider?.navigationGraph?.() ?? null; } export function projectCombatGroundPoint( requested: CombatSpatialPosition, maximumDistance: number, origin?: CombatSpatialPosition, ): CombatSpatialPosition | null { if (!requested.every(Number.isFinite) || !Number.isFinite(maximumDistance) || maximumDistance < 0) return null; return provider ? provider.projectGroundPoint(requested, maximumDistance, origin) : requested; } function normalizedPlanarDirection( from: CombatSpatialPosition, to: CombatSpatialPosition, ): readonly [number, number] | null { const dx = to[0] - from[0]; const dz = to[2] - from[2]; const length = Math.hypot(dx, dz); return length > 1e-5 ? [dx / length, dz / length] : null; } export function isPositionInFacingArc( source: CombatSpatialActorSnapshot, target: CombatSpatialPosition, arcRadians = Math.PI, ): boolean { const direction = normalizedPlanarDirection(source.position, target); if (!direction) return true; const forwardX = Math.sin(source.yaw); const forwardZ = Math.cos(source.yaw); const cosine = forwardX * direction[0] + forwardZ * direction[1]; return cosine >= Math.cos(Math.max(0, Math.min(Math.PI * 2, arcRadians)) / 2); } export function isSourceBehindTarget( source: CombatSpatialPosition, target: CombatSpatialActorSnapshot, rearArcRadians = Math.PI, ): boolean { return !isPositionInFacingArc(target, source, Math.PI * 2 - rearArcRadians); }