Files
healer-man/src/game/partyRuntimeRegistry.ts
T
2026-10-04 13:13:35 -04:00

224 lines
7.0 KiB
TypeScript

export type PartyWorldPosition = readonly [x: number, y: number, z: number];
export type MutablePartyWorldPosition = [x: number, y: number, z: number];
const positions = new Map<string, MutablePartyWorldPosition>();
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
const combatRelocations = new Map<string, { position: PartyWorldPosition; yaw: number }>();
/** One-shot movement authored by combat, without an attached return-to-player task. */
export function requestPartyRuntimeCombatRelocation(
id: string,
position: PartyWorldPosition,
yaw: number,
): void {
const destination: PartyWorldPosition = [finite(position[0]), finite(position[1]), finite(position[2])];
const facing = finite(yaw);
combatRelocations.set(id, { position: destination, yaw: facing });
updatePartyRuntimePosition(id, destination[0], destination[1], destination[2]);
}
/** Returns and clears a pending combat relocation so the scene applies it once. */
export function consumePartyRuntimeCombatRelocation(
id: string,
): { readonly position: PartyWorldPosition; readonly yaw: number } | null {
const relocation = combatRelocations.get(id);
if (!relocation) return null;
combatRelocations.delete(id);
return { position: [...relocation.position], yaw: relocation.yaw };
}
/** A one-shot scene relocation followed by an individual return-to-player task. */
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
combatRelocations.delete(id);
rejoins.set(id, { position: [...position], pending: true });
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
targetVisibility.delete(id);
}
export function consumePartyRuntimeRejoin(id: string): PartyWorldPosition | null {
const rejoin = rejoins.get(id);
if (!rejoin?.pending) return null;
rejoin.pending = false;
return [...rejoin.position];
}
export function partyRuntimeIsRejoining(id: string): boolean {
return rejoins.has(id);
}
export function finishPartyRuntimeRejoin(id: string): void {
rejoins.delete(id);
}
let attackCorridor: {
readonly origin: MutablePartyWorldPosition;
readonly waypoints: readonly PartyWorldPosition[];
readonly cursor: number;
} | null = null;
function finite(value: number): number {
return Number.isFinite(value) ? value : 0;
}
export function registerPartyRuntimePosition(
id: string,
initial: PartyWorldPosition,
): () => void {
positions.set(id, [finite(initial[0]), finite(initial[1]), finite(initial[2])]);
return () => {
positions.delete(id);
targetVisibility.delete(id);
rejoins.delete(id);
combatRelocations.delete(id);
};
}
/** Scene-authored static-collision visibility used by the combat tick. */
export function updatePartyRuntimeTargetVisibility(
memberId: string,
targetId: string | null,
visible: boolean,
): void {
targetVisibility.set(memberId, { targetId, visible: Boolean(targetId && visible) });
}
export function partyRuntimeTargetVisible(memberId: string, targetId: string): boolean {
const access = targetVisibility.get(memberId);
return access?.targetId === targetId && access.visible;
}
/** Publishes the remaining approved Attack route for route-local add selection. */
export function updatePartyRuntimeAttackCorridor(
origin: PartyWorldPosition,
waypoints: readonly PartyWorldPosition[],
cursor: number,
): void {
attackCorridor = {
origin: [finite(origin[0]), finite(origin[1]), finite(origin[2])],
waypoints,
cursor: Math.max(0, Math.min(waypoints.length, Math.trunc(cursor))),
};
}
export function clearPartyRuntimeAttackCorridor(): void {
attackCorridor = null;
}
/** Rejects side branches and vertically overlapping floors outside the road. */
export function partyRuntimePositionAlongAttackCorridor(
position: PartyWorldPosition,
maximumPlanarDistance = 10,
maximumVerticalDistance = 4,
): boolean {
const corridor = attackCorridor;
if (!corridor) return false;
const planarLimitSquared = Math.max(0, maximumPlanarDistance) ** 2;
const verticalLimit = Math.max(0, maximumVerticalDistance);
let start: PartyWorldPosition = corridor.origin;
const lastIndex = Math.max(corridor.cursor, corridor.waypoints.length - 1);
for (let index = corridor.cursor; index <= lastIndex; index += 1) {
const end = corridor.waypoints[index] ?? start;
const segmentX = end[0] - start[0];
const segmentZ = end[2] - start[2];
const lengthSquared = segmentX * segmentX + segmentZ * segmentZ;
const progress = lengthSquared > 1e-9
? Math.max(0, Math.min(1, (
(position[0] - start[0]) * segmentX
+ (position[2] - start[2]) * segmentZ
) / lengthSquared))
: 0;
const projectedX = start[0] + segmentX * progress;
const projectedY = start[1] + (end[1] - start[1]) * progress;
const projectedZ = start[2] + segmentZ * progress;
const dx = position[0] - projectedX;
const dz = position[2] - projectedZ;
if (
dx * dx + dz * dz <= planarLimitSquared
&& Math.abs(position[1] - projectedY) <= verticalLimit
) return true;
start = end;
}
return false;
}
export function updatePartyRuntimePosition(
id: string,
x: number,
y: number,
z: number,
): void {
const position = positions.get(id);
if (position) {
position[0] = finite(x);
position[1] = finite(y);
position[2] = finite(z);
} else {
positions.set(id, [finite(x), finite(y), finite(z)]);
}
}
export function removePartyRuntimePosition(id: string): void {
positions.delete(id);
targetVisibility.delete(id);
rejoins.delete(id);
combatRelocations.delete(id);
}
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
const position = positions.get(id);
return position ? [...position] as MutablePartyWorldPosition : null;
}
export function partyRuntimeDistanceSquared(
id: string,
origin: PartyWorldPosition,
): number {
const position = positions.get(id);
if (!position) return Number.POSITIVE_INFINITY;
const dx = position[0] - origin[0];
const dy = position[1] - origin[1];
const dz = position[2] - origin[2];
return dx * dx + dy * dy + dz * dz;
}
export function readPartyRuntimePosition(
id: string,
out: MutablePartyWorldPosition,
): boolean {
const position = positions.get(id);
if (!position) return false;
out[0] = position[0];
out[1] = position[1];
out[2] = position[2];
return true;
}
export function partyRuntimeCenter(
memberIds: readonly string[],
fallback: PartyWorldPosition,
): PartyWorldPosition {
let x = 0;
let y = 0;
let z = 0;
let count = 0;
for (const id of memberIds) {
const position = positions.get(id);
if (!position) continue;
x += position[0];
y += position[1];
z += position[2];
count += 1;
}
return count > 0
? [x / count, y / count, z / count]
: [fallback[0], fallback[1], fallback[2]];
}
export function clearPartyRuntimeRegistry(): void {
positions.clear();
targetVisibility.clear();
rejoins.clear();
combatRelocations.clear();
attackCorridor = null;
}