224 lines
7.0 KiB
TypeScript
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;
|
|
}
|