1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/interiors/walker.ts
T

330 lines
11 KiB
TypeScript

/**
* Renderer-independent walking over a resolved office plan.
*
* Input is a direction on the office floor plane, not keys or stick events.
* The controller advances on a fixed clock, sweeps the walker's circular
* footprint against the exact collision segments produced by `Plan`, and
* projects blocked motion along a wall so diagonal input slides instead of
* stopping. Doors need no special case: the wall resolver has already left a
* gap in `LevelPlan.collision` for every passable opening.
*/
import type { Bounds, LevelPlan, Segment } from "./plan.ts";
import type { Point2 } from "./types.ts";
const EPSILON = 1e-8;
const BISECTION_STEPS = 24;
const SLIDE_PASSES = 3;
export const DEFAULT_WALKER_RADIUS = 0.3;
export const DEFAULT_WALKER_SPEED = 1.6;
export const DEFAULT_FIXED_STEP = 1 / 60;
export const DEFAULT_MAX_CATCH_UP_STEPS = 8;
/** A world-space direction on the office floor plane. */
export interface WalkerAction {
x: number;
z: number;
}
export interface WalkerSpawn {
levelId: string;
position: Point2;
}
export interface WalkerOptions extends WalkerSpawn {
/** Circular footprint radius, in metres. */
radius?: number;
/** Metres per second at full input. */
speed?: number;
/** Simulation seconds per movement step. */
fixedStep?: number;
/** Prevents a resumed/backgrounded tab from running an unbounded backlog. */
maxCatchUpSteps?: number;
}
export interface WalkerState {
levelId: string;
position: Point2;
/** Last non-zero normalized action; useful as a renderer-facing heading. */
facing: Point2;
/** Total successfully travelled distance, in metres, since the last reset. */
distance: number;
}
/** The small part of `Plan` movement depends on. A test or server can implement it too. */
export interface WalkerPlan {
level(id: string): Pick<LevelPlan, "bounds" | "collision"> | null;
blocked(levelId: string, from: Point2, to: Point2, radius?: number): boolean;
}
export interface WalkerController {
/** A defensive snapshot: callers cannot corrupt the simulation's finite state. */
state(): WalkerState;
/** Add real time; zero or more fixed simulation steps may run. */
tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
/** Return to the original spawn, or atomically adopt another valid spawn. */
reset(spawn?: WalkerSpawn): WalkerState;
}
/**
* Clamp arbitrary planar input to the unit disc. Non-finite input means idle;
* letting one bad gamepad sample become NaN would otherwise poison every frame.
*/
export function normalizeWalkerAction(action: WalkerAction): WalkerAction {
if (!finitePoint(action)) return { x: 0, z: 0 };
const length = Math.hypot(action.x, action.z);
if (length <= 1) return { x: action.x, z: action.z };
return { x: action.x / length, z: action.z / length };
}
export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerController {
const radius = positive(options.radius ?? DEFAULT_WALKER_RADIUS, "radius");
const speed = positive(options.speed ?? DEFAULT_WALKER_SPEED, "speed");
const fixedStep = positive(options.fixedStep ?? DEFAULT_FIXED_STEP, "fixedStep");
const maxCatchUpSteps = integer(options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS);
let spawn = checkedSpawn(plan, options, radius);
let position = copy(spawn.position);
let facing: Point2 = { x: 0, z: -1 };
let distance = 0;
let accumulator = 0;
function snapshot(): WalkerState {
return {
levelId: spawn.levelId,
position: copy(position),
facing: copy(facing),
distance,
};
}
function reset(next = spawn): WalkerState {
spawn = checkedSpawn(plan, next, radius);
position = copy(spawn.position);
facing = { x: 0, z: -1 };
distance = 0;
accumulator = 0;
return snapshot();
}
function tick(elapsedSeconds: number, rawAction: WalkerAction): WalkerState {
// The internals are private, but this also makes the recovery policy clear
// if a future refactor exposes a mutable transport/state object.
if (!finitePoint(position) || !validPosition(plan, spawn.levelId, position, radius)) reset();
if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();
const action = normalizeWalkerAction(rawAction);
if (Math.hypot(action.x, action.z) > EPSILON) facing = copy(action);
const maxBacklog = fixedStep * maxCatchUpSteps;
accumulator = Math.min(maxBacklog, accumulator + elapsedSeconds);
let steps = 0;
while (accumulator + EPSILON >= fixedStep && steps < maxCatchUpSteps) {
accumulator -= fixedStep;
if (accumulator < 0) accumulator = 0;
steps += 1;
const amount = speed * fixedStep;
const before = position;
position = moveWithSliding(plan, spawn.levelId, position, {
x: action.x * amount,
z: action.z * amount,
}, radius);
distance += Math.hypot(position.x - before.x, position.z - before.z);
}
return snapshot();
}
return { state: snapshot, tick, reset };
}
function moveWithSliding(
plan: WalkerPlan,
levelId: string,
start: Point2,
displacement: Point2,
radius: number,
): Point2 {
const level = plan.level(levelId);
if (!level) return copy(start);
// A configured high speed still cannot tunnel: no sweep is longer than half
// a radius. `Plan.blocked` is swept too; the subdivision primarily makes a
// corner followed by a slide behave consistently.
const length = Math.hypot(displacement.x, displacement.z);
const slices = Math.max(1, Math.ceil(length / Math.max(radius * 0.5, 0.01)));
const slice = { x: displacement.x / slices, z: displacement.z / slices };
let at = copy(start);
for (let index = 0; index < slices; index += 1) {
at = moveSlice(plan, levelId, level.bounds, level.collision, at, slice, radius);
}
return at;
}
function moveSlice(
plan: WalkerPlan,
levelId: string,
bounds: Bounds,
segments: readonly Segment[],
start: Point2,
initial: Point2,
radius: number,
): Point2 {
let at = copy(start);
let remaining = copy(initial);
for (let pass = 0; pass < SLIDE_PASSES; pass += 1) {
if (Math.hypot(remaining.x, remaining.z) <= EPSILON) break;
const target = bounded(add(at, remaining), bounds, radius);
const attempted = { x: target.x - at.x, z: target.z - at.z };
if (Math.hypot(attempted.x, attempted.z) <= EPSILON) break;
if (!plan.blocked(levelId, at, target, radius)) {
at = target;
break;
}
const fraction = clearFraction(plan, levelId, at, attempted, radius);
if (fraction > 0) at = add(at, scale(attempted, fraction));
const left = scale(attempted, 1 - fraction);
const wall = nearestBlockingSegment(at, add(at, left), segments, radius);
if (!wall) break;
const wx = wall.to.x - wall.from.x;
const wz = wall.to.z - wall.from.z;
const wallLength = Math.hypot(wx, wz);
if (wallLength <= EPSILON) break;
const tx = wx / wallLength;
const tz = wz / wallLength;
const along = left.x * tx + left.z * tz;
remaining = { x: tx * along, z: tz * along };
}
return at;
}
/** Largest prefix of a blocked displacement whose whole swept capsule is clear. */
function clearFraction(
plan: WalkerPlan,
levelId: string,
start: Point2,
displacement: Point2,
radius: number,
): number {
let low = 0;
let high = 1;
for (let index = 0; index < BISECTION_STEPS; index += 1) {
const middle = (low + high) / 2;
if (plan.blocked(levelId, start, add(start, scale(displacement, middle)), radius)) high = middle;
else low = middle;
}
// Stay microscopically on the clear side so the projected slide does not
// begin inside the wall because of a last-bit rounding difference.
return Math.max(0, low - 1e-7);
}
function nearestBlockingSegment(
from: Point2,
to: Point2,
segments: readonly Segment[],
radius: number,
): Segment | null {
let nearest: Segment | null = null;
let best = Infinity;
for (const segment of segments) {
const distance = segmentDistance(from, to, segment.from, segment.to);
const clearance = radius + segment.thickness / 2;
if (distance >= clearance + 1e-6 || distance >= best) continue;
best = distance;
nearest = segment;
}
return nearest;
}
function checkedSpawn(plan: WalkerPlan, spawn: WalkerSpawn, radius: number): WalkerSpawn {
if (!spawn.levelId || !finitePoint(spawn.position)) {
throw new RangeError("walker spawn must name a level and contain finite coordinates");
}
if (!validPosition(plan, spawn.levelId, spawn.position, radius)) {
throw new RangeError("walker spawn must be inside the level bounds and clear of walls");
}
return { levelId: spawn.levelId, position: copy(spawn.position) };
}
function validPosition(plan: WalkerPlan, levelId: string, point: Point2, radius: number): boolean {
const level = plan.level(levelId);
return level !== null && inside(point, level.bounds, radius) && !plan.blocked(levelId, point, point, radius);
}
function inside(point: Point2, bounds: Bounds, radius: number): boolean {
return (
point.x >= bounds.minX + radius && point.x <= bounds.maxX - radius &&
point.z >= bounds.minZ + radius && point.z <= bounds.maxZ - radius
);
}
function bounded(point: Point2, bounds: Bounds, radius: number): Point2 {
return {
x: Math.min(bounds.maxX - radius, Math.max(bounds.minX + radius, point.x)),
z: Math.min(bounds.maxZ - radius, Math.max(bounds.minZ + radius, point.z)),
};
}
function positive(value: number, name: string): number {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value;
}
function integer(value: number): number {
if (!Number.isInteger(value) || value < 1) throw new RangeError("maxCatchUpSteps must be a positive integer");
return value;
}
function finitePoint(point: Point2): boolean {
return Number.isFinite(point.x) && Number.isFinite(point.z);
}
function copy(point: Point2): Point2 {
return { x: point.x, z: point.z };
}
function add(a: Point2, b: Point2): Point2 {
return { x: a.x + b.x, z: a.z + b.z };
}
function scale(point: Point2, amount: number): Point2 {
return { x: point.x * amount, z: point.z * amount };
}
function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number {
if (segmentsCross(a1, a2, b1, b2)) return 0;
return Math.min(
pointSegmentDistance(a1, b1, b2),
pointSegmentDistance(a2, b1, b2),
pointSegmentDistance(b1, a1, a2),
pointSegmentDistance(b2, a1, a2),
);
}
function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
const ab1 = cross(a1, a2, b1);
const ab2 = cross(a1, a2, b2);
const ba1 = cross(b1, b2, a1);
const ba2 = cross(b1, b2, a2);
// Proper crossing only. Collinear, disjoint segments must fall through to
// endpoint distance; treating every collinear pair as a crossing would make
// a walker sliding parallel to a distant wall collide with it.
return ab1 * ab2 < 0 && ba1 * ba2 < 0;
}
function cross(a: Point2, b: Point2, point: Point2): number {
return (b.x - a.x) * (point.z - a.z) - (b.z - a.z) * (point.x - a.x);
}
function pointSegmentDistance(point: Point2, a: Point2, b: Point2): number {
const dx = b.x - a.x;
const dz = b.z - a.z;
const lengthSquared = dx * dx + dz * dz;
if (lengthSquared <= EPSILON) return Math.hypot(point.x - a.x, point.z - a.z);
const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.z - a.z) * dz) / lengthSquared));
return Math.hypot(point.x - (a.x + t * dx), point.z - (a.z + t * dz));
}