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