Files
healer-man/src/game/combatSpatial.ts
T
2026-09-12 19:16:01 -04:00

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