forked from phenom/healer-man
95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
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);
|
|
}
|