2d87d9f354
**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.
**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.
**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.
That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.
**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.
**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.
**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.
Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1798 lines
84 KiB
TypeScript
1798 lines
84 KiB
TypeScript
/**
|
||
* Optimus robots walking around the office.
|
||
*
|
||
* This is `presence.ts`'s noisy cousin and it is deliberately a different shape,
|
||
* because it is solving a different problem. A presence is a person *at a seat*:
|
||
* it has an id, it comes from an API, it never moves, and the whole design
|
||
* effort went into making sure the geometry and the people stay on opposite
|
||
* sides of a line. A robot is nobody. It has no id worth publishing, it comes
|
||
* from nowhere, and it exists to make a still building look like a place where
|
||
* something is happening. So there is no palette, no `colorKey`, no binding to
|
||
* anything in the pack, and nothing here can leak: the only inputs are a `Plan`
|
||
* and a list of levels.
|
||
*
|
||
* ### What it costs
|
||
*
|
||
* Four robots is the budget and roughly the right number — one is a mascot, ten
|
||
* is a warehouse.
|
||
*
|
||
* - **18 draw calls each, 72 for four.** This is the whole cost and it is not
|
||
* small; the office shell and its furniture together draw in about forty.
|
||
* The reason is in `assets/office/optimus.ts`: a figure that has to bend at
|
||
* eleven joints cannot be one merged mesh, so it is eighteen small merged
|
||
* meshes instead. If a floor needs the budget back, drop the count — the
|
||
* cost is exactly linear in it, and a pack that only wants a robot standing
|
||
* somewhere should place the `tera:robot.optimus` asset, which is two.
|
||
* - **One set of geometry, 7.1k triangles, however many robots there are.**
|
||
* `buildOptimus` runs once and every figure after the first is a
|
||
* `cloneOptimus`, which shares every buffer and both materials.
|
||
* - **About 20 µs per tick for the crowd**, measured over thirty simulated
|
||
* minutes on the reference office — roughly 0.1% of a 60 Hz frame. Most of a
|
||
* tick is `plan.blocked`, which is linear in the level's collision segments
|
||
* (fifty-four on that floor); a robot spends one or two calls a frame
|
||
* steering and up to fifteen on the frames where it is boxed in and fanning
|
||
* out. Nothing here is worth caching.
|
||
*
|
||
* It used to be 24 µs, and errands made it *cheaper* rather than dearer.
|
||
* Choosing a destination with a reason costs one `blocked` call; choosing one
|
||
* at random cost up to forty-eight, because every candidate had to be tested
|
||
* for standing room and then again for line of sight. Knowing where you are
|
||
* going is less work than not.
|
||
* - **Picking a destination costs one `blocked` call** in the normal case and
|
||
* at most `SHORTLIST` of them, plus one `roomAt` — and only on the frame a
|
||
* robot arrives somewhere, which is every few seconds. The fallbacks are the
|
||
* old prices: up to two `blocked` calls on each of `PICK_ATTEMPTS` random
|
||
* candidates, then a pass over the doors. A robot that finds nowhere to go
|
||
* waits `RETRY_PAUSE` before trying again, so even one sealed into a cupboard
|
||
* costs a burst every second and a half rather than one every frame.
|
||
* - **An address book costs about 2.5 ms per level, once.** 224 places on the
|
||
* reference ground floor, each checked for standing room and a run-in, built
|
||
* the first time a robot on that level picks a destination and never again.
|
||
* It is deliberately not built at construction: a level with no robots on it
|
||
* never needs one, and a hitch during the load is a hitch nobody attributes
|
||
* correctly.
|
||
*
|
||
* ### Errands: where a robot goes, and why it is there
|
||
*
|
||
* The difference between a robot that is working and a robot that is patrolling
|
||
* is not the walk. It is the destination and the arrival.
|
||
*
|
||
* This used to pick a uniformly random reachable point, walk to it, stop at
|
||
* whatever angle it happened to be facing, wait between 1.4 and 4.6 seconds, and
|
||
* repeat. Every part of that is defensible on its own and the sum of it is a
|
||
* security guard: nowhere it goes is a place, nothing it does when it gets there
|
||
* is different from anything else it does, and the only thing distinguishing one
|
||
* stop from the next is a random number.
|
||
*
|
||
* So a destination is now an **address** — somewhere `Plan` already has a name
|
||
* for — and an address comes with an angle and a reason to linger:
|
||
*
|
||
* - a **seat**, approached from behind and held at the seat's own `facing`, so
|
||
* the robot stands at somebody's desk looking at the desk;
|
||
* - a **fixture** — an authored prop, which is to say a whiteboard, a locker,
|
||
* a shelf, a meeting chair — stood in front of and looked at;
|
||
* - a **room's centre**, which is the one that sends a robot to the kitchen.
|
||
*
|
||
* `ErrandKind` covers the choosing and `Address` the arriving. Three things
|
||
* about it are worth knowing before changing any of it:
|
||
*
|
||
* - **The kind is drawn before the address**, so the pack cannot decide the
|
||
* mix by how many desks it happens to author. See `ERRAND_MIX`.
|
||
* - **Candidates are shortlisted and scored, not taken first-fit**, on how
|
||
* recently anyone went there, how close it is to another robot or to where
|
||
* another robot is heading, and whether it is out of the room this robot is
|
||
* already standing in. That is what spreads four robots over a building
|
||
* instead of letting them pool. See `scoreAddress`.
|
||
* - **The last stretch is walked along the angle the robot will hold**, via a
|
||
* waypoint behind the destination, so it arrives lined up rather than
|
||
* stopping crooked and pivoting. See `APPROACH_RUNS`.
|
||
*
|
||
* Measured over ten thirty-minute runs on each reference pack, against the same
|
||
* harness running the version this replaced: a robot arriving somewhere with an
|
||
* angle to hold now arrives a median of 9° off it rather than stopping wherever,
|
||
* and the crowd enters 15 to 18 of the reference building's 26 rooms in half an
|
||
* hour rather than 12 to 17 — and all six of the second pack's every single run,
|
||
* rather than four to six.
|
||
*
|
||
* It also stands still more: 42% of the session against 29%. That is the point
|
||
* rather than a regression. The old 29% was a robot with nothing to do having
|
||
* nothing to do; the new 42% is four robots holding at desks, at whiteboards and
|
||
* in doorways, and the number to watch is not that one but the one below it —
|
||
* how much of the standing is a robot that has genuinely failed to find anywhere
|
||
* to go, which is what the watchdog and `pickDoor` exist to keep near zero.
|
||
*
|
||
* ### Navigation: rejection sampling, not a navmesh
|
||
*
|
||
* Underneath the errands, and still the whole of the fallback.
|
||
*
|
||
* Building a navmesh for an office would mean a floor decomposition, a portal
|
||
* graph, A*, string-pulling and a funnel — several hundred lines, a new build
|
||
* product to keep in step with `Plan`, and a whole second definition of "where
|
||
* can you stand" beside the one the wall split already produces. All of that to
|
||
* decide which way a decorative robot walks round a desk.
|
||
*
|
||
* So: pick a point on the floor, keep it if `plan.roomAt` says it is indoors and
|
||
* `plan.blocked` says the straight line from here to there crosses no wall, and
|
||
* walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch one robot for
|
||
* a minute and it looks like it is wandering; watch the algorithm and it is
|
||
* playing join-the-dots with its own line of sight. Both readings are correct
|
||
* and only one of them is visible.
|
||
*
|
||
* Two refinements on top of that, and both exist because the plain version was
|
||
* measured and found wanting rather than because they seemed like good ideas.
|
||
* Each is documented where it lives:
|
||
*
|
||
* - Candidates are drawn from a **room chosen by area**, not from the level's
|
||
* bounding box, because a level's rooms cover a fraction of its bounds and
|
||
* most candidates were landing in the void outside the walls (`samplerFor`).
|
||
* - A robot that can see nowhere to go walks to a **doorway** instead, via a
|
||
* waypoint in the opening itself, because line of sight out of a small room
|
||
* through a 0.9 m gap almost never exists and three of four robots spent
|
||
* nineteen simulated minutes parked in one (`pickDoor`).
|
||
*
|
||
* Together those took the crowd from 82% of the session standing still to under
|
||
* 30%, which is the difference between an office with robots in it and an office
|
||
* with four statues. Both still run, and both still matter: an errand needs an
|
||
* address it can see, and the two things that produce a robot which cannot see
|
||
* one — a small room, and a pack with nothing in it — are exactly what these two
|
||
* were built for.
|
||
*
|
||
* Two things this deliberately does not know about:
|
||
*
|
||
* - **Furniture.** `plan.blocked` is the wall collider and nothing else, so
|
||
* robots walk through desks. Fixing it means asking the asset registry for
|
||
* every prop's footprint and building a second collider, which is a real
|
||
* feature with a real cost and is not this. If it ever matters, the place to
|
||
* put it is `Plan`, next to the wall split, so that the walk controller and
|
||
* the robots get the same answer.
|
||
*
|
||
* Errands make this more conspicuous rather than less, because a robot now
|
||
* walks *up to* furniture on purpose. What keeps that looking right is that
|
||
* it stops short of it: `DESK_STANDOFF` and `FIXTURE_STANDOFF` are the two
|
||
* places in this file that know a prop takes up room, and both are stated as
|
||
* distances rather than looked up, precisely so that this stays true.
|
||
* - **Stairs.** A robot belongs to one level for its whole life. Levels are
|
||
* connected by nothing in the office contract, so there is nowhere for it to
|
||
* go, and a robot that walked off a mezzanine would be a bug rather than a
|
||
* feature.
|
||
*
|
||
* ### Getting unstuck, which is the part that actually needs care
|
||
*
|
||
* A straight line that was clear when the destination was chosen can stop being
|
||
* clear, because a robot turns on an arc rather than pivoting on the spot. So
|
||
* every step is re-checked, and the machinery for that is three rules that have
|
||
* to hold together — each of them broke on its own during development, and each
|
||
* failure looked like a robot standing still and thinking:
|
||
*
|
||
* 1. **One desired heading, one turn.** The heading is chosen by fanning out
|
||
* from the direction the destination wants, and then the yaw is turned
|
||
* toward it once, rate-limited. Turning toward the destination *and* toward
|
||
* the probe result in the same frame gives two rate-limited turns that
|
||
* cancel exactly, and a robot locked at a fixed angle off course forever.
|
||
* 2. **Walk only what was tested.** The rate limit means that after turning,
|
||
* the robot faces somewhere between where it was and where it probed. That
|
||
* direction has not been checked, and walking it is how a robot ends up
|
||
* inside the clearance band.
|
||
* 3. **Probe a fixed lookahead, never the step length.** `plan.blocked` is
|
||
* true when the capsule comes within `radius` of a wall *including at its
|
||
* start*, so a robot already closer to a wall than its own radius has every
|
||
* direction blocked, away from the wall included. `PROBE_AHEAD` keeps it
|
||
* well clear of that band, and `PROBE_RELIEF` gets it back out if it ever
|
||
* gets in.
|
||
*
|
||
* And because none of that is a proof, there is a watchdog on top: a robot that
|
||
* covers less than `STUCK_DISTANCE` in `STUCK_WINDOW` seconds throws its
|
||
* destination away and picks another. That is what guarantees the worst failure
|
||
* is a robot standing still and looking thoughtful, rather than one buzzing
|
||
* against a partition until somebody closes the tab. It also breaks the one
|
||
* deadlock the yielding rule can produce, where two robots stop nose to nose and
|
||
* politely wait for each other.
|
||
*
|
||
* Soaked over ten thirty-minute runs across both reference packs, four robots
|
||
* each, with a four-second frame thrown in every fifty seconds to imitate a tab
|
||
* waking up: no robot left a room and none entered the clearance band. Re-run
|
||
* unchanged after errands arrived, since a destination with a name is still just
|
||
* a point as far as everything below here is concerned — same result.
|
||
*
|
||
* ### The walk cycle runs on distance, not on time
|
||
*
|
||
* `phase = distance / STRIDE`, never `phase += dt`. This is the difference
|
||
* between feet that push the floor and feet that skate on it: a robot slowing
|
||
* into a turn takes shorter steps rather than the same steps more slowly, and a
|
||
* stopped robot's cycle stops with it instead of running on the spot.
|
||
*
|
||
* That is necessary and it is not sufficient. The shape of the swing has to
|
||
* match the distance too, which is what `legAngle` and `STRIDE` are about and is
|
||
* worth reading before touching either — a hand-picked stride against a
|
||
* sinusoidal hip measured 65% of distance travelled coming back out as foot
|
||
* slip, and the fix was arithmetic rather than taste. It now measures 5%.
|
||
*
|
||
* Stopping is the other half. A frozen phase is a frozen mid-stride, so the
|
||
* whole pose is *interpolated* toward the rest stance by `gait`, which eases to
|
||
* zero over about a quarter of a second. The phase freezes and the amplitude
|
||
* drains out of it, settling the figure from wherever it was without moving a
|
||
* foot across the floor. Fading the pose out is the only way to stop that does
|
||
* not slide; running the cycle on to the end of the stride is the way that does.
|
||
*
|
||
* One consequence, and it is the price of the arrival turn. A robot settling
|
||
* onto its seat's facing rotates with `gait` at zero and its feet planted — the
|
||
* whole figure swings about its own axis, because there is no shuffle to play
|
||
* and faking one out of the walk joints would be a stride taken on the spot,
|
||
* which is the exact thing the paragraph above is about. `SETTLE_RATE` makes
|
||
* that slow enough to read as deliberate, and `APPROACH_RUNS` makes it small
|
||
* enough to mostly not happen. A shuffle would need turn-in-place footwork the
|
||
* rig has never had, and it would have to be driven by yaw the way the walk is
|
||
* driven by distance, or it would skate for the same reason.
|
||
*/
|
||
|
||
import * as THREE from "three";
|
||
import { createAssetContext } from "../assets/kit.ts";
|
||
import type { MaterialRegistry } from "../assets/materials.ts";
|
||
import {
|
||
buildOptimus,
|
||
cloneOptimus,
|
||
disposeOptimus,
|
||
OPTIMUS,
|
||
OPTIMUS_REST,
|
||
restOptimus,
|
||
type OptimusJoints,
|
||
type OptimusRig,
|
||
} from "../assets/office/optimus.ts";
|
||
import { seededRandom } from "../engine/world.ts";
|
||
import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom } from "./plan.ts";
|
||
import type { Point2 } from "./types.ts";
|
||
|
||
// ---- Tuning ---------------------------------------------------------------
|
||
|
||
/** Metres per second on the straight. A brisk indoor human walk. */
|
||
const CRUISE = 1.2;
|
||
|
||
/**
|
||
* How wide a robot is to the collider.
|
||
*
|
||
* The number to size this against is not the shoulder span. `optimus.ts` puts
|
||
* the shoulder *pivots* 0.35 m apart and this comment used to quote that, which
|
||
* made 0.28 look like a radius with 100 mm of slack in it. Measured off the
|
||
* built figure, a standing Optimus is 0.520 m across at its widest — the
|
||
* shoulder drums, with the splayed hands 4 mm inside them — so 0.56 is 20 mm of
|
||
* slack a side, not 100.
|
||
*
|
||
* That is still the right answer, because a doorway `Plan` calls passable is
|
||
* 0.9 m and 0.56 goes through one with room to turn in it. But it is the number
|
||
* to think with if anything about the arms, the shoulders or the stance
|
||
* changes, and there is far less room in it than the old comment implied: the
|
||
* figure is within 40 mm of its own collider, so a wider robot silently starts
|
||
* clipping door frames rather than failing.
|
||
*/
|
||
const RADIUS = 0.28;
|
||
|
||
/** Radians per second of yaw. About a second and a half for a half turn. */
|
||
const TURN_RATE = 2.2;
|
||
|
||
/**
|
||
* Hip to ankle in the rest pose — the length of the pendulum the whole gait is.
|
||
* Two numbers below are derived from it rather than dialled in, which is the
|
||
* only reason the feet stay on the floor.
|
||
*/
|
||
const LEG = OPTIMUS.hipY - OPTIMUS.ankleY;
|
||
|
||
/** How close counts as arrived. Inside this the robot stops looking for the point. */
|
||
const ARRIVE = 0.35;
|
||
/**
|
||
* How close counts as having reached a waypoint. Tighter than `ARRIVE`, because
|
||
* the only waypoint there is is a doorway and the whole point of going there is
|
||
* to end up lined up with it.
|
||
*/
|
||
const REACHED = 0.22;
|
||
|
||
/** A destination has to be at least this far away, or a robot shuffles on the spot. */
|
||
const MIN_TRIP = 1.8;
|
||
/**
|
||
* How far a doorway has to be before it counts as somewhere to go.
|
||
*
|
||
* Small on purpose. The obvious value is something like 1.4 m — far enough that
|
||
* a robot cannot immediately turn round and go back through the door it just
|
||
* came out of — and it is wrong, because a phone booth is 2.0 × 1.8 m and its
|
||
* own door is never more than 1.3 m from anywhere inside it. Set it high and the
|
||
* one room a robot most needs help escaping from is the one room it cannot. The
|
||
* doubling-back problem is solved by remembering the last door instead, which is
|
||
* what `Robot.lastDoor` is for.
|
||
*/
|
||
const MIN_DOOR = 0.45;
|
||
/** How far past a doorway to aim, so a robot ends up in the next space and not in the gap. */
|
||
const THROUGH_DOOR = 0.85;
|
||
|
||
const PICK_ATTEMPTS = 24;
|
||
/** Seconds to wait after failing to find anywhere to go. */
|
||
const RETRY_PAUSE = 1.5;
|
||
/**
|
||
* Seconds a robot stands still after arriving somewhere that was **not** an
|
||
* errand — a random point on the floor, or the far side of a doorway. An errand
|
||
* sets its own dwell from `DWELL`; this is the shrug.
|
||
*
|
||
* Also the spread on the initial stagger, so four robots do not all set off on
|
||
* the same frame.
|
||
*/
|
||
const PAUSE_MIN = 1.4;
|
||
const PAUSE_MAX = 4.6;
|
||
|
||
/** How far ahead a step is tested. See the header — this must not be the step length. */
|
||
const PROBE_AHEAD = 0.34;
|
||
/** Headings tried, in order, when the way ahead is blocked. Radians off course. */
|
||
const PROBE_TURNS = [0, 0.45, -0.45, 0.95, -0.95, 1.5, -1.5];
|
||
/**
|
||
* Fractions of the collision radius the probe will settle for, in order. The
|
||
* second one only ever comes into play for a robot that is already wedged; see
|
||
* the note at the probe.
|
||
*/
|
||
const PROBE_RELIEF = [1, 0.4];
|
||
|
||
const STUCK_WINDOW = 1.6;
|
||
const STUCK_DISTANCE = 0.15;
|
||
|
||
/** Another robot this close and roughly ahead makes this one wait. */
|
||
const YIELD_RANGE = 0.95;
|
||
const YIELD_CONE = 0.4;
|
||
|
||
/** Seconds for the walk pose to fade in or out when a robot starts or stops. */
|
||
const GAIT_EASE = 0.24;
|
||
|
||
/**
|
||
* The biggest step a single tick may take, in seconds.
|
||
*
|
||
* A backgrounded tab hands back a `dt` of whole seconds when it wakes, and an
|
||
* unclamped robot would move several metres in one step — through a wall, since
|
||
* the collider is a capsule test against that step and a step that long sweeps
|
||
* across whole rooms. Clamping means a robot that was in a background tab is
|
||
* simply where it was, which is right: nobody was watching.
|
||
*/
|
||
const MAX_STEP = 0.1;
|
||
|
||
// ---- Errands --------------------------------------------------------------
|
||
|
||
/**
|
||
* What a robot went somewhere *for*.
|
||
*
|
||
* It decides exactly two things — how often that kind of place gets picked, and
|
||
* how long a robot stands there once it arrives — and those two are most of the
|
||
* difference between a crowd that is working and a crowd that is patrolling.
|
||
* Nothing about the walk itself branches on it.
|
||
*
|
||
* - **`desk`** is a seat. `Plan` publishes every one of them with a `facing`,
|
||
* which is the whole reason this exists: arriving at a named spot and
|
||
* turning to the angle that spot says is the single detail that reads as
|
||
* purpose. See `DESK_STANDOFF` for why the robot stops short of the seat
|
||
* rather than on it.
|
||
* - **`fixture`** is an authored prop — a whiteboard, a locker, a shelf, a
|
||
* meeting chair. Identified by what it is *not*: not bound to a seat and not
|
||
* generated by a desk bank, so it is something the pack author put there on
|
||
* purpose rather than the second half of a workstation.
|
||
* - **`room`** is a room's centroid. The only kind with no facing, and the
|
||
* only kind that is about the building rather than about the furniture —
|
||
* it is what sends a robot to the kitchen or across the commons.
|
||
*/
|
||
type ErrandKind = "desk" | "fixture" | "room";
|
||
|
||
/**
|
||
* How the three kinds are mixed, as relative weights.
|
||
*
|
||
* **The kind is drawn first and the address second**, and that ordering is the
|
||
* point. Drawing uniformly over one flat list of addresses would let the pack
|
||
* decide the mix by accident: the reference office resolves 76 seats, roughly
|
||
* 150 fixtures and 17 rooms on its ground floor, so a flat draw would send a
|
||
* robot to a room's centre about 9% of the time and to a desk about 31% —
|
||
* neither of which anybody chose. Picking the kind first fixes the *behaviour*
|
||
* and lets the pack decide only which whiteboard.
|
||
*
|
||
* Weighted toward desks because a desk is the strongest read and because there
|
||
* are enough of them that four robots do not visibly repeat. Kinds a level has
|
||
* none of are skipped and their weight goes to the others, so a pack with no
|
||
* authored props still gets desks and rooms rather than a stalled robot.
|
||
*/
|
||
const ERRAND_MIX: readonly (readonly [ErrandKind, number])[] = [
|
||
["desk", 0.5],
|
||
["fixture", 0.2],
|
||
["room", 0.3],
|
||
];
|
||
|
||
/**
|
||
* Seconds spent standing at each kind, low and high of a uniform spread.
|
||
*
|
||
* A robot that pauses for the same length of time everywhere reads as a state
|
||
* machine no matter how good the destinations are, so the dwell is the errand's
|
||
* and not the walk's. The ordering is the story: you stand at a desk because you
|
||
* are doing something there, you look at a whiteboard for a moment, and a room's
|
||
* centre is somewhere you are passing through.
|
||
*
|
||
* The upper end matters more than it looks. Four robots with a mean dwell around
|
||
* six seconds and trips that take rather longer than that leaves most of them
|
||
* walking at any instant, which is the balance that reads as an office; push the
|
||
* desk dwell to half a minute and you get four robots standing about.
|
||
*/
|
||
const DWELL: Record<ErrandKind, readonly [number, number]> = {
|
||
desk: [5, 12],
|
||
fixture: [3, 7],
|
||
room: [1.5, 4],
|
||
};
|
||
|
||
/** Seconds to stand after stepping through a doorway. Short: the point was to leave. */
|
||
const DWELL_DOOR: readonly [number, number] = [0.4, 1.2];
|
||
|
||
/**
|
||
* How far behind a seat a robot stops, in metres.
|
||
*
|
||
* **A robot must never stand on a seat**, and that is a hard rule rather than a
|
||
* preference: `presence.ts` puts a person mesh at exactly `seat.position` with
|
||
* exactly `seat.facing`, so a robot that treated the seat as its own destination
|
||
* would stand inside whoever is sitting there. Seats are addresses for
|
||
* occupants; a robot visiting one is a visitor.
|
||
*
|
||
* The number is sized off the chair rather than picked. `seating.ts` gives
|
||
* `tera:seat.task-chair` a 0.64 m footprint — the star base, which is its widest
|
||
* part — so 0.32 m from the seat centre to the chair's edge, plus the 0.28 m
|
||
* robot radius, is 0.60 m before the two touch. 0.75 leaves 150 mm of air.
|
||
*
|
||
* That is cosmetic and not a collision guarantee: `plan.blocked` is the wall
|
||
* collider and knows nothing about furniture, as the header says. It is the
|
||
* difference between a robot standing at somebody's desk and a robot standing
|
||
* in their chair, which is visible from every camera angle in the building.
|
||
*
|
||
* The offset direction falls out of `plan.ts`: a bank puts its seat at the desk
|
||
* centre plus `(sin f, cos f) · seatOffset`, so stepping further along that same
|
||
* ray is further from the desk — behind the occupant, looking the way they look.
|
||
*/
|
||
const DESK_STANDOFF = 0.75;
|
||
|
||
/**
|
||
* How far in front of a fixture a robot stops.
|
||
*
|
||
* Chosen rather than derived, because deriving it would mean asking the asset
|
||
* registry for every prop's footprint, and this file deliberately does not know
|
||
* that the registry exists — the same line the header draws around furniture
|
||
* collision. 0.9 m is the distance a person stands from a whiteboard, and it is
|
||
* far enough that the error on a fixture with a deeper footprint than expected
|
||
* is a robot standing a little close rather than a robot standing inside it.
|
||
*
|
||
* The facing convention is the desk's, taken from `plan.ts` and not invented
|
||
* here: a prop's front is its local **+Z**, `(sin r, cos r)`, because that is the
|
||
* side a desk bank puts its seat on. A prop authored with a meaningless rotation
|
||
* — a rug — gets a meaningless standing spot, which costs a robot a few seconds
|
||
* looking at the floor and breaks nothing.
|
||
*/
|
||
const FIXTURE_STANDOFF = 0.9;
|
||
|
||
/**
|
||
* How far back along its own facing an errand's run-in starts, longest first.
|
||
*
|
||
* The last leg of an approach is walked *along* the facing, so the robot arrives
|
||
* already lined up instead of stopping at a random angle and then pivoting. The
|
||
* first entry is a turn budget: a quarter turn at `TURN_RATE` takes (π/2)/2.2 =
|
||
* 0.71 s, which at `CRUISE` is 0.86 m — so 0.9 m is one right-angle's worth of
|
||
* turning, and better than that in practice, because a turning robot walks
|
||
* slower and therefore turns further per metre.
|
||
*
|
||
* The second entry is there because the first one alone is not available often
|
||
* enough, and this is the measurement that says so. A run-in has to be somewhere
|
||
* a robot could stand, and 0.9 m behind the standing spot is 1.65 m behind the
|
||
* seat itself — which on the reference ground floor is inside a wall for 27 of
|
||
* 72 desks and outside every room for 9 more, because that is what a meeting
|
||
* room is. Only 36 desks got a run-in at all. Falling back to 0.45 m takes it to
|
||
* 62, and the arrivals it buys are as well aligned as the long ones.
|
||
*
|
||
* Note what 0.45 does *not* buy, so nobody re-derives it as a bug: `REACHED` is
|
||
* 0.22 and `ARRIVE` is 0.35, so a robot that clears a 0.45 m run-in can already
|
||
* be inside the arrival radius and stop on the spot without walking a step of
|
||
* the final leg. It still helps, because the alignment mostly comes from having
|
||
* steered at a point on the destination's own axis rather than from the metre
|
||
* after it. Measured against dropping the fallback entirely, it moves the 75th
|
||
* percentile of arrival error from 97° to 86°; both against 90° for a robot that
|
||
* simply stops where it gets to.
|
||
*
|
||
* Same shape as `PROBE_RELIEF`, and for the same reason: a value that is right
|
||
* when there is room for it and a smaller one that is better than nothing.
|
||
*/
|
||
const APPROACH_RUNS = [0.9, 0.45];
|
||
|
||
/**
|
||
* A prop whose base sits higher than this is not something you walk up to.
|
||
*
|
||
* `OPTIMUS.shoulderY` rather than a number, because the question this is asking
|
||
* is "is this thing in front of the robot or above it". It exists because the
|
||
* "authored prop" test catches ceiling lights: the reference ground floor
|
||
* authors 81 troffers and 22 pendants, none bound to a seat, and every one of
|
||
* them would otherwise be a place to stand and stare upward.
|
||
*
|
||
* Measured across both reference packs, the split is not close: the highest
|
||
* floor-standing fixture base is a wall display at 1.15 m and the lowest light
|
||
* is a troffer at 2.30 m, so the cutoff sits in the middle of a metre-wide gap
|
||
* and no plausible pack lands on the boundary.
|
||
*/
|
||
const FIXTURE_MAX_BASE = OPTIMUS.shoulderY;
|
||
|
||
/** How many addresses are scored before one is committed to. See `pickErrand`. */
|
||
const SHORTLIST = 6;
|
||
|
||
/**
|
||
* Seconds before somewhere a robot went is fully interesting again.
|
||
*
|
||
* Without this the crowd converges: the score is the same every time it is
|
||
* asked, so the best desk in the building is the best desk for every robot for
|
||
* the whole session. A minute is long enough that a repeat is a coincidence
|
||
* rather than a rut, and the floor below keeps a just-visited address merely
|
||
* unlikely rather than banned — on a level with three addresses and four robots,
|
||
* banning is how you get a robot with nowhere to go.
|
||
*/
|
||
const REVISIT_COOLDOWN = 60;
|
||
const COOL_FLOOR = 0.05;
|
||
|
||
/**
|
||
* Distance from the nearest other robot at which a destination stops being
|
||
* penalised for crowding, and the floor under that penalty.
|
||
*
|
||
* "Nearest other robot" counts where they *are* and where they are *going*, so
|
||
* two robots do not set off for the same whiteboard from opposite ends of the
|
||
* floor and discover the problem on arrival.
|
||
*/
|
||
const SPREAD_FULL = 7;
|
||
const SPREAD_FLOOR = 0.15;
|
||
|
||
/**
|
||
* What a destination in the room the robot is already standing in is worth,
|
||
* against one somewhere else.
|
||
*
|
||
* This is the term that actually spreads the crowd through the building rather
|
||
* than round one floor plate, and it is nearly free: every address knows its
|
||
* room from the check that admitted it, so the only cost is one `roomAt` for the
|
||
* robot itself, once per errand.
|
||
*/
|
||
const SAME_ROOM = 0.35;
|
||
|
||
/**
|
||
* Radians per second of yaw while standing still.
|
||
*
|
||
* Slower than `TURN_RATE` on purpose. The figure has no pivot-in-place
|
||
* animation — the gait is driven by distance travelled, so a robot turning
|
||
* without moving has its feet planted and swings the whole body — and the
|
||
* faster that happens the more it looks like a turntable. At this rate a half
|
||
* turn on the spot takes π/(2.2 · 0.55) = 2.6 s, which reads as settling.
|
||
*
|
||
* It is usually a small turn anyway, because `APPROACH_RUN` has the robot walk
|
||
* the last stretch along the angle it is going to hold.
|
||
*/
|
||
const SETTLE_RATE = TURN_RATE * 0.55;
|
||
|
||
// ---- Gait -----------------------------------------------------------------
|
||
|
||
/** Peak hip angle, radians. Everything else about the stride follows from it. */
|
||
const HIP_SWING = 0.34;
|
||
|
||
/**
|
||
* Half the ground a planted foot covers, and the ground covered by one full
|
||
* two-step cycle.
|
||
*
|
||
* These are derived from the swing rather than chosen, and that is what decides
|
||
* whether the feet push the floor or skate on it. A planted foot sits at
|
||
* `LEG · sin θ` in front of the hips, so the ground one step covers is fixed by
|
||
* the swing amplitude and the length of the leg, and the body has to move
|
||
* exactly that far in the same time or the foot makes up the difference by
|
||
* sliding. Hand-set at 1.32 m against a 0.4 rad swing, the measured slip was 65%
|
||
* of distance travelled — the robots were gliding with their legs waving.
|
||
*
|
||
* At 1.2 m/s this is about 129 steps a minute, which is a brisk walk and the
|
||
* right read for a machine with somewhere to be.
|
||
*/
|
||
const HALF_STEP = LEG * Math.sin(HIP_SWING);
|
||
const STRIDE = 4 * HALF_STEP;
|
||
|
||
const KNEE_BEND = 0.58;
|
||
const ARM_SWING = 0.3;
|
||
const ELBOW_SWING = 0.22;
|
||
const SWAY = 0.05;
|
||
const TWIST = 0.055;
|
||
/** Forward lean at full speed. Small, but it is what stops a walk looking passive. */
|
||
const LEAN = 0.035;
|
||
|
||
/**
|
||
* A leg's hip angle at `psi`, its own phase in `[0, 2π)`: stance for the first
|
||
* half, swing for the second.
|
||
*
|
||
* **The stance half is an arcsine and not a sine, and that is the whole point of
|
||
* this function.** A sine looks like the obvious choice and it is wrong for a
|
||
* reason that is easy to miss: a planted foot has to travel backwards under the
|
||
* body at *exactly* walking speed, which means its position is linear in time,
|
||
* which means the hip angle is the arcsine of a straight line. Drive the hip
|
||
* with a sine instead and the foot's backward speed is fastest as the leg passes
|
||
* vertical and zero at the ends of the stance, so it matches the body's speed at
|
||
* one instant per step and slides for the rest of it. Worse, both legs pass
|
||
* vertical at the same moment, so there is no instant at which either foot is
|
||
* genuinely planted. Measured: 30% of distance travelled came out as foot slip
|
||
* with everything else already tuned, and no amount of adjusting the stride
|
||
* length got it below about a quarter, because the shape was wrong rather than
|
||
* the scale.
|
||
*
|
||
* The swing half is a cubic Hermite from the back of the stride to the front
|
||
* whose end slopes are the stance's own — `−2 tan(HIP_SWING)` at both — so the
|
||
* thigh does not visibly jerk at toe-off or at heel strike. Nothing about the
|
||
* swing affects foot slip, because the foot is in the air for all of it; it only
|
||
* has to be smooth and to arrive in the right place.
|
||
*/
|
||
function legAngle(psi: number): number {
|
||
if (psi < Math.PI) {
|
||
const u = psi / Math.PI;
|
||
return Math.asin(Math.sin(HIP_SWING) * (1 - 2 * u));
|
||
}
|
||
const v = (psi - Math.PI) / Math.PI;
|
||
const slope = -2 * Math.tan(HIP_SWING);
|
||
const v2 = v * v;
|
||
const v3 = v2 * v;
|
||
return (
|
||
(2 * v3 - 3 * v2 + 1) * -HIP_SWING +
|
||
(v3 - 2 * v2 + v) * slope +
|
||
(-2 * v3 + 3 * v2) * HIP_SWING +
|
||
(v3 - v2) * slope
|
||
);
|
||
}
|
||
|
||
/**
|
||
* How bent a leg's knee is at `psi`, as a fraction of `KNEE_BEND`.
|
||
*
|
||
* Zero for the whole of stance, and that is deliberate rather than lazy: a bent
|
||
* stance knee shortens the leg, and the body's height is computed from the
|
||
* stance leg being straight. Bend it and the planted foot either floats or sinks
|
||
* by the difference. So the knee does all of its work in the air, which is also
|
||
* the only place it is doing anything useful — lifting the foot over the floor.
|
||
*
|
||
* `sin²` rather than `sin` so the bend starts and ends with zero rate and there
|
||
* is no kink at toe-off.
|
||
*/
|
||
function kneeFlex(psi: number): number {
|
||
if (psi < Math.PI) return 0;
|
||
const wave = Math.sin(psi - Math.PI);
|
||
return wave * wave;
|
||
}
|
||
|
||
/**
|
||
* Pose one figure for a distance travelled and a gait strength.
|
||
*
|
||
* `distance` is metres since the robot was created — monotonic, never reset, so
|
||
* the cycle is continuous across every stop and start. `gait` is 0 for standing
|
||
* still and 1 for walking, and every joint is *interpolated* between its rest
|
||
* value and its walking value by it, so `gait === 0` reproduces `restOptimus`
|
||
* exactly and a robot easing to a halt settles rather than snapping.
|
||
*
|
||
* Signs, all of which are the header of `optimus.ts` applied: hips, shoulders
|
||
* and elbows bend positive, knees bend **negative**, positive `rotation.y` turns
|
||
* left and positive `rotation.z` leans left.
|
||
*/
|
||
function pose(j: OptimusJoints, distance: number, gait: number): void {
|
||
const phase = (distance / STRIDE) * Math.PI * 2;
|
||
/**
|
||
* Two waves, a quarter cycle apart, and using the wrong one is the mistake
|
||
* this comment exists to stop somebody making a second time.
|
||
*
|
||
* `s` peaks when the legs are **passing each other** — mid-stance. Lateral
|
||
* sway and the head's counter-lean belong on it, because that is genuinely
|
||
* when a walking body is furthest over its planted foot.
|
||
*
|
||
* `swing` peaks when the legs are **furthest apart** — heel strike. Anything
|
||
* that counter-balances the legs belongs on it: the arms, the elbows, and the
|
||
* twist through the waist.
|
||
*
|
||
* They were all on `s` at first, which put the arms a quarter cycle early: at
|
||
* the instant the left leg reached full forward the left shoulder was at dead
|
||
* neutral, and both arms hit their extremes as the legs passed vertical
|
||
* together. It reads as a figure whose arms are swinging to a different beat
|
||
* from its legs, which is uncanny in a way that is hard to name until it is
|
||
* pointed at.
|
||
*
|
||
* `swing` is derived from the leg angle itself rather than restated as
|
||
* `cos(phase)`, so the two cannot drift apart if `legAngle` is ever reshaped.
|
||
*/
|
||
const s = Math.sin(phase);
|
||
|
||
// The left leg's own phase, and the right exactly half a cycle behind it —
|
||
// so one leg is always in stance and the other always in swing, and there is
|
||
// no moment when the robot is standing on neither.
|
||
const psiL = ((phase % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||
const psiR = (psiL + Math.PI) % (Math.PI * 2);
|
||
const hipL = legAngle(psiL);
|
||
const hipR = legAngle(psiR);
|
||
// −1..1, in step with the legs. See the note on `s` above. `HIP_SWING` is a
|
||
// non-zero literal, so no guard is needed and TypeScript will say so if that
|
||
// ever stops being true.
|
||
const swing = hipL / HIP_SWING;
|
||
|
||
j.hipL.rotation.x = OPTIMUS_REST.hipX + gait * (hipL - OPTIMUS_REST.hipX);
|
||
j.hipR.rotation.x = OPTIMUS_REST.hipX + gait * (hipR - OPTIMUS_REST.hipX);
|
||
const kneeL = -KNEE_BEND * kneeFlex(psiL);
|
||
const kneeR = -KNEE_BEND * kneeFlex(psiR);
|
||
j.kneeL.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeL - OPTIMUS_REST.kneeX);
|
||
j.kneeR.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeR - OPTIMUS_REST.kneeX);
|
||
|
||
// Arms counter-swing to the legs: the left arm goes forward with the right
|
||
// leg. Get this backwards and the figure paces like a soldier at attention,
|
||
// which is a surprisingly strong and surprisingly wrong-looking effect.
|
||
j.shoulderL.rotation.x = OPTIMUS_REST.shoulderX - gait * ARM_SWING * swing;
|
||
j.shoulderR.rotation.x = OPTIMUS_REST.shoulderX + gait * ARM_SWING * swing;
|
||
// And an elbow closes a little further on the forward stroke, which is what
|
||
// stops the arms reading as two pendulums bolted to a box. `-swing` is the
|
||
// left arm's own forward stroke, since it swings against the left leg.
|
||
j.elbowL.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, -swing);
|
||
j.elbowR.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, swing);
|
||
|
||
// The body's height is not a bob that was dialled in. It is where the hips
|
||
// have to be for the straight, planted, stance leg to reach the floor:
|
||
// `LEG · cos θ`, exactly. That puts the body at its highest as the stance leg
|
||
// passes vertical and at its lowest at heel strike and toe-off, twice per
|
||
// cycle, which is what a real gait does and is not something this had to be
|
||
// told. Damping it — an earlier version scaled it to a third, to stop an
|
||
// imagined pogo — was most of that 65% of foot slip. It does not pogo: the
|
||
// whole travel is 48 mm, about what a walking person's head does.
|
||
const stance = psiL < Math.PI ? hipL : hipR;
|
||
j.pelvis.position.y = OPTIMUS.hipY - gait * LEG * (1 - Math.cos(stance));
|
||
|
||
// One consequence of all this, stated so nobody spends an afternoon on it:
|
||
// the figure has no ankle joint, so a foot pitches with its shin and the toe
|
||
// passes about 50 mm under the floor plane at the ends of each stride. That is
|
||
// hidden by the floor slab, and what remains visible above it is a heel strike
|
||
// and a toe-off the rig never had to be given. Correcting it would need a
|
||
// twelfth joint and would cost every robot two more meshes.
|
||
|
||
// Hips twist one way, shoulders the other. `torso` is a child of `pelvis`, so
|
||
// its rotation adds: −2× puts the shoulders at −1× in world space.
|
||
j.pelvis.rotation.y = gait * TWIST * swing;
|
||
j.torso.rotation.y = -gait * TWIST * 2 * swing;
|
||
j.torso.rotation.z = gait * SWAY * s;
|
||
j.torso.rotation.x = gait * LEAN;
|
||
// The head keeps about half the lean instead of all of it. A head that stays
|
||
// perfectly level looks gimballed; one that swings with the chest looks
|
||
// drunk.
|
||
j.head.rotation.z = -gait * SWAY * 0.55 * s;
|
||
}
|
||
|
||
// ---- Layer ----------------------------------------------------------------
|
||
|
||
/** Where one robot lives. A robot belongs to its level for its whole life. */
|
||
export interface RobotSpec {
|
||
levelId: string;
|
||
/** For the caller's own bookkeeping. Defaults to `robot-1`, `robot-2`, … */
|
||
id?: string;
|
||
}
|
||
|
||
export interface RobotLayerOptions {
|
||
/** The office's material registry. Its `paper` and `screenBezel` roles are used. */
|
||
materials: MaterialRegistry;
|
||
/** One entry per robot. About four is the budget; see the header. */
|
||
robots: readonly RobotSpec[];
|
||
/**
|
||
* Seeds where the robots start and where they wander. Change it to reshuffle
|
||
* the whole crowd; leave it and a reload puts them back where they were, which
|
||
* is the same discipline the rest of the scene keeps.
|
||
*/
|
||
seed?: number;
|
||
/** Metres per second on the straight. Defaults to 1.2. */
|
||
speed?: number;
|
||
/** Collision radius. Defaults to 0.28 — see `RADIUS`. */
|
||
radius?: number;
|
||
}
|
||
|
||
/** One robot's world position, live. See `RobotLayer.robots`. */
|
||
export interface RobotView {
|
||
id: string;
|
||
levelId: string;
|
||
/**
|
||
* Office-world metres, at the robot's feet. **Updated in place** every tick —
|
||
* hold the reference, read it, and do not write to it.
|
||
*/
|
||
position: THREE.Vector3;
|
||
}
|
||
|
||
export interface RobotLayer {
|
||
group: THREE.Group;
|
||
tick(dt: number): void;
|
||
/**
|
||
* Every robot, for anything that wants to react to one — a ceiling light
|
||
* brightening as one passes under it, a minimap dot, an occupancy heatmap.
|
||
*
|
||
* The array and the `Vector3`s in it are **stable and live**: the same objects
|
||
* come back every call and their contents change under you. That is
|
||
* deliberate, because the caller for this is a per-frame loop and allocating
|
||
* four vectors sixty times a second to answer the same question is exactly the
|
||
* kind of garbage that shows up as a stutter and not as a profile entry. If
|
||
* you need a snapshot, clone what you take.
|
||
*/
|
||
robots(): readonly RobotView[];
|
||
dispose(): void;
|
||
}
|
||
|
||
/**
|
||
* A direction a robot may walk in, and the clearance the probe settled for to
|
||
* find it. There is exactly one of these per layer and it is scratch — see
|
||
* `chooseHeading`.
|
||
*/
|
||
interface Heading {
|
||
heading: number;
|
||
clearance: number;
|
||
}
|
||
|
||
/** Everything about one robot that changes. */
|
||
interface Robot {
|
||
view: RobotView;
|
||
level: LevelPlan;
|
||
rig: OptimusRig;
|
||
yaw: number;
|
||
target: Point2 | null;
|
||
/** Seconds left of the current stand-still. Only meaningful with no target. */
|
||
wait: number;
|
||
/**
|
||
* A yaw to turn to while standing still, or nothing to stand as it stopped.
|
||
* **Only meaningful with no target**, and cleared once reached, so a robot
|
||
* that settles onto its seat's facing and then waits out the rest of its dwell
|
||
* is doing no work at all.
|
||
*/
|
||
settle: number | null;
|
||
/**
|
||
* The errand's plan for the moment of arrival: the yaw to hold and the seconds
|
||
* to hold it for. Chosen when the destination is, spent when it is reached —
|
||
* `arriveFacing` becomes `settle` and `arriveDwell` becomes `wait`.
|
||
*
|
||
* Two fields rather than one small object because a destination is picked
|
||
* every few seconds per robot and this file allocates only where it must.
|
||
*/
|
||
arriveFacing: number | null;
|
||
arriveDwell: number;
|
||
/** 0 standing, 1 walking. Eased, never snapped. See the header. */
|
||
gait: number;
|
||
/** Metres travelled ever. Drives the walk cycle and is never reset. */
|
||
distance: number;
|
||
/** Metres travelled since the watchdog last looked, and how long ago that was. */
|
||
sinceCheck: number;
|
||
checkAge: number;
|
||
/**
|
||
* An intermediate point to reach before `target`, or nothing.
|
||
*
|
||
* There is at most one, ever, and that is the rule that keeps this from
|
||
* becoming a path — see `pickDoor` for why one is enough and two would need a
|
||
* graph. It is either a **doorway**, when a robot could see nowhere to go and
|
||
* is leaving the room, or an errand's **run-in**, when the last stretch is
|
||
* walked along the angle the robot is going to hold on arrival. Both are
|
||
* checked as two independent legs, which is the only reason either works.
|
||
*/
|
||
waypoint: Point2 | null;
|
||
/** The opening this robot last walked through, so it does not turn straight round. */
|
||
lastDoor: string | null;
|
||
rand: () => number;
|
||
}
|
||
|
||
export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotLayer {
|
||
const group = new THREE.Group();
|
||
group.name = "robots";
|
||
|
||
const speed = options.speed ?? CRUISE;
|
||
const radius = options.radius ?? RADIUS;
|
||
const seed = options.seed ?? 0x0117;
|
||
|
||
// One figure is built and the rest are clones of it, so the crowd costs draw
|
||
// calls and no memory. The prototype is never added to the scene; it exists
|
||
// only to be cloned from and to be disposed at the end, because it is the one
|
||
// that owns the geometry every clone points at.
|
||
const ctx = createAssetContext({ materials: options.materials });
|
||
const prototype = buildOptimus(ctx);
|
||
|
||
const robots: Robot[] = [];
|
||
const views: RobotView[] = [];
|
||
|
||
// Scratch, reused every frame. Four robots at sixty frames is 240 chances a
|
||
// second to allocate a `Point2` for nothing. `chosen` is the same discipline
|
||
// applied to the one place it had been forgotten; see `chooseHeading`, which
|
||
// is the only thing allowed to write to it.
|
||
const from: Point2 = { x: 0, z: 0 };
|
||
const to: Point2 = { x: 0, z: 0 };
|
||
const chosen: Heading = { heading: 0, clearance: 0 };
|
||
|
||
/**
|
||
* A level's rooms with a running area total, so a candidate point can be
|
||
* drawn from the *floor* rather than from the level's bounding box.
|
||
*
|
||
* Sampling the bounding box was the first version, and it is why this exists.
|
||
* A level's bounds are the whole building's extent, a level's rooms cover
|
||
* maybe a third of it and a mezzanine covers a tenth, so most candidates
|
||
* landed in the void outside the walls and most picks failed. Measured over
|
||
* twenty simulated minutes in the reference office, the crowd spent 82% of it
|
||
* standing still waiting to retry — and worst on exactly the small upper floor
|
||
* where a single robot is most conspicuous, which walked 49 m to the ground
|
||
* floor's 393. Choosing a room first puts nearly every candidate somewhere a
|
||
* robot could actually stand.
|
||
*
|
||
* Weighted by area rather than uniformly over rooms, because uniform sends a
|
||
* robot into the 6 m² phone booth as often as into the 400 m² floor plate,
|
||
* and what that looks like is four robots queueing for a cupboard.
|
||
*/
|
||
interface Sampler {
|
||
rooms: readonly ResolvedRoom[];
|
||
/** Running area totals, one per room; the last one is `total`. */
|
||
cumulative: readonly number[];
|
||
total: number;
|
||
/** Every opening a walker fits through. `Plan` has already decided which. */
|
||
doors: readonly ResolvedOpening[];
|
||
}
|
||
|
||
const samplers = new Map<string, Sampler>();
|
||
function samplerFor(level: LevelPlan): Sampler {
|
||
const hit = samplers.get(level.id);
|
||
if (hit) return hit;
|
||
const cumulative: number[] = [];
|
||
let total = 0;
|
||
for (const room of level.rooms) {
|
||
total += room.area;
|
||
cumulative.push(total);
|
||
}
|
||
const made: Sampler = {
|
||
rooms: level.rooms,
|
||
cumulative,
|
||
total,
|
||
doors: level.openings.filter((opening) => opening.passable),
|
||
};
|
||
samplers.set(level.id, made);
|
||
return made;
|
||
}
|
||
|
||
/**
|
||
* One candidate: somewhere a robot could stand — inside a room, and clear of
|
||
* every wall by its own radius.
|
||
*
|
||
* A room's `bounds` are its bounding box and a room need not be rectangular,
|
||
* so the `roomAt` check is not made redundant by having chosen a room first:
|
||
* it is what rejects the missing corner of an L-shaped floor plate. It also
|
||
* *re-resolves* which room the point is in, and later rooms win, which is the
|
||
* answer you want — a point that lands inside a meeting room while sampling
|
||
* the open floor's bounding box is a point in the meeting room, and standing
|
||
* there is fine.
|
||
*
|
||
* `blocked` with the same point at both ends is a degenerate capsule, which is
|
||
* exactly a point-to-wall distance test. Reusing it rather than writing one
|
||
* keeps the "how much room does a robot need" arithmetic in the one place
|
||
* `Plan` already documents it.
|
||
*/
|
||
function trySample(sampler: Sampler, levelId: string, rand: () => number, into: Point2): boolean {
|
||
if (!(sampler.total > 0)) return false;
|
||
const roll = rand() * sampler.total;
|
||
let index = sampler.cumulative.length - 1;
|
||
for (let i = 0; i < sampler.cumulative.length; i++) {
|
||
if (roll <= (sampler.cumulative[i] ?? 0)) {
|
||
index = i;
|
||
break;
|
||
}
|
||
}
|
||
const room = sampler.rooms[index];
|
||
if (!room) return false;
|
||
|
||
into.x = room.bounds.minX + rand() * room.bounds.width;
|
||
into.z = room.bounds.minZ + rand() * room.bounds.depth;
|
||
if (!plan.roomAt(levelId, into)) return false;
|
||
return !plan.blocked(levelId, into, into, radius);
|
||
}
|
||
|
||
/** Somewhere on this level a robot could stand, or nothing. Used to place one. */
|
||
function samplePoint(level: LevelPlan, rand: () => number, into: Point2): boolean {
|
||
const sampler = samplerFor(level);
|
||
for (let i = 0; i < PICK_ATTEMPTS; i++) {
|
||
if (trySample(sampler, level.id, rand, into)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---- The address book ---------------------------------------------------
|
||
|
||
/**
|
||
* Somewhere worth going, and what to do on arrival.
|
||
*
|
||
* Every field except `visitedAt` is decided once, when the level's book is
|
||
* built, and never changes — which is what makes an errand cost one `blocked`
|
||
* call to commit to instead of four. `at` has already been checked to be
|
||
* indoors and clear of every wall, and `approach` has been checked the same way
|
||
* *plus* the leg between the two, so by the time a robot is choosing, the only
|
||
* open question is whether it can see the thing from where it is standing.
|
||
*/
|
||
interface Address {
|
||
kind: ErrandKind;
|
||
/** Where the robot ends up standing. Never on a seat; see `DESK_STANDOFF`. */
|
||
at: Point2;
|
||
/** The yaw to hold once there, or nothing to stop on the arrival heading. */
|
||
facing: number | null;
|
||
/**
|
||
* Where the last leg starts, so the robot walks in already lined up. Absent
|
||
* when there is no facing to line up with, or when the run-in does not fit —
|
||
* a desk in an alcove with its back 0.5 m from a wall, say, which is still a
|
||
* perfectly good place to stand and simply gets approached from wherever.
|
||
*/
|
||
approach: Point2 | null;
|
||
/** Which room `at` is in. Falls out of the check that admitted it; see `SAME_ROOM`. */
|
||
roomId: string;
|
||
/** Layer clock when a robot last set out for here. See `REVISIT_COOLDOWN`. */
|
||
visitedAt: number;
|
||
}
|
||
|
||
/** One level's addresses, split by kind because the kind is drawn first. */
|
||
interface AddressBook {
|
||
desk: Address[];
|
||
fixture: Address[];
|
||
room: Address[];
|
||
}
|
||
|
||
/**
|
||
* Seconds of simulated time since the layer was made, advanced by the same
|
||
* clamped `dt` the robots move on — so a tab that was asleep for a minute does
|
||
* not come back to a crowd whose cooldowns have all expired at once, for the
|
||
* same reason it does not come back to robots three rooms away.
|
||
*/
|
||
let clock = 0;
|
||
|
||
const books = new Map<string, AddressBook>();
|
||
|
||
/** The id of the room a robot could stand at this point in, or nothing. */
|
||
function standable(levelId: string, point: Point2): string | null {
|
||
const room = plan.roomAt(levelId, point);
|
||
if (!room) return null;
|
||
return plan.blocked(levelId, point, point, radius) ? null : room.id;
|
||
}
|
||
|
||
/**
|
||
* One address, if a robot can stand at it.
|
||
*
|
||
* The rejections here are the whole reason this is done once per level rather
|
||
* than per pick: a desk pushed against a wall, a whiteboard in a stairwell, a
|
||
* fixture whose front is inside a partition, a centroid outside its own
|
||
* L-shaped room. Every one of those is a fact about the pack that never
|
||
* changes, and paying for it at 60 Hz would be the expensive way to learn it.
|
||
*/
|
||
function makeAddress(
|
||
level: LevelPlan,
|
||
kind: ErrandKind,
|
||
at: Point2,
|
||
facing: number | null,
|
||
): Address | null {
|
||
const roomId = standable(level.id, at);
|
||
if (roomId === null) return null;
|
||
|
||
let approach: Point2 | null = null;
|
||
if (facing !== null) {
|
||
for (const run of APPROACH_RUNS) {
|
||
// Back along the facing: the robot walks from here to `at` looking the
|
||
// way `at` says, so the run-in and the hold are the same direction.
|
||
const back: Point2 = {
|
||
x: at.x + Math.sin(facing) * run,
|
||
z: at.z + Math.cos(facing) * run,
|
||
};
|
||
if (standable(level.id, back) === null) continue;
|
||
// Measured on both reference packs: this leg has never once been the
|
||
// thing that failed, because it is short and colinear with two points
|
||
// already known to be clear. It is checked anyway — a pack is allowed to
|
||
// put a partition between a desk and the space behind it, and finding
|
||
// that out at 60 Hz with a robot walking through it is not the way.
|
||
if (plan.blocked(level.id, back, at, radius)) continue;
|
||
approach = back;
|
||
break;
|
||
}
|
||
}
|
||
// Far enough in the past that everything starts fully interesting, without
|
||
// any special case for "never visited" in the scoring.
|
||
return { kind, at, facing, approach, roomId, visitedAt: -REVISIT_COOLDOWN };
|
||
}
|
||
|
||
/**
|
||
* Every place on a level worth walking to, built once and kept.
|
||
*
|
||
* Cost is a few `roomAt` and `blocked` calls per candidate — up to three of
|
||
* each for a facing address — over every seat, every authored prop and every
|
||
* room on the level. On the reference ground floor that is 76 seats, 147
|
||
* qualifying props and 17 rooms, and it is paid on the frame the first robot
|
||
* on that level picks its first destination and never again. Doing it eagerly
|
||
* at construction would move the same work to a worse moment, since a level
|
||
* with no robots on it never needs a book at all.
|
||
*
|
||
* Seats come from `level.seats` rather than `plan.allSeats()` on purpose: a
|
||
* robot belongs to one level for its whole life, and the building's seat list
|
||
* would offer it addresses on a floor it can never reach.
|
||
*/
|
||
function bookFor(level: LevelPlan): AddressBook {
|
||
const hit = books.get(level.id);
|
||
if (hit) return hit;
|
||
const made: AddressBook = { desk: [], fixture: [], room: [] };
|
||
|
||
for (const seat of level.seats) {
|
||
const spot = makeAddress(
|
||
level,
|
||
"desk",
|
||
{
|
||
x: seat.position.x + Math.sin(seat.facing) * DESK_STANDOFF,
|
||
z: seat.position.z + Math.cos(seat.facing) * DESK_STANDOFF,
|
||
},
|
||
seat.facing,
|
||
);
|
||
if (spot) made.desk.push(spot);
|
||
}
|
||
|
||
for (const prop of level.props) {
|
||
// A prop bound to a seat, or generated by a desk bank, is the furniture of
|
||
// a workstation — the seat itself is already a better address for it, and
|
||
// adding the desk and the chair as well would put three addresses on one
|
||
// spot and weight the whole floor toward whichever room has the most desks.
|
||
if (prop.seat !== undefined || prop.source !== undefined) continue;
|
||
// `position.y` is the base of the prop with the level's elevation already
|
||
// in it, so the level's own floor has to come back out before it can be
|
||
// compared with a height on the robot.
|
||
if (prop.position.y - level.floorY > FIXTURE_MAX_BASE) continue;
|
||
const spot = makeAddress(
|
||
level,
|
||
"fixture",
|
||
{
|
||
x: prop.position.x + Math.sin(prop.rotation) * FIXTURE_STANDOFF,
|
||
z: prop.position.z + Math.cos(prop.rotation) * FIXTURE_STANDOFF,
|
||
},
|
||
prop.rotation,
|
||
);
|
||
if (spot) made.fixture.push(spot);
|
||
}
|
||
|
||
for (const room of level.rooms) {
|
||
// No facing: there is nothing at a room's centre to look at, and inventing
|
||
// one — face the longest wall, face the door — would be a guess dressed up
|
||
// as intent. A robot arriving at a centroid stops looking the way it came
|
||
// in, which is into the room, which is enough.
|
||
//
|
||
// `centroid` is the area centroid and a room need not be convex, so this
|
||
// can land outside its own outline; `makeAddress` drops those rather than
|
||
// falling back to the bounding box centre, which is not more likely to be
|
||
// inside. Both reference packs have none.
|
||
const spot = makeAddress(level, "room", { x: room.centroid.x, z: room.centroid.z }, null);
|
||
if (spot) made.room.push(spot);
|
||
}
|
||
|
||
books.set(level.id, made);
|
||
return made;
|
||
}
|
||
|
||
// Scratch for the shortlist, reused by every robot on every pick. Same
|
||
// discipline as `from`, `to` and `chosen`: nothing here outlives the call that
|
||
// fills it, and `pickErrand` is the only thing allowed to read or write it.
|
||
const shortlist: (Address | null)[] = new Array(SHORTLIST).fill(null);
|
||
const shortlistScore: number[] = new Array(SHORTLIST).fill(0);
|
||
|
||
/**
|
||
* Which kind of errand this one is, weighted by `ERRAND_MIX` over the kinds
|
||
* this level actually has any of.
|
||
*
|
||
* The empty-kind skip is not defensive coding for its own sake. Nothing in the
|
||
* office contract obliges a level to have seats, or props, or more than one
|
||
* room — the second reference pack's mezzanine resolves three desks and a
|
||
* single room, and a floor of meeting rooms with no authored furniture is an
|
||
* ordinary thing to write. A weight table that did not renormalise would spend
|
||
* a fifth of its draws on an empty list and fail whole picks for no reason,
|
||
* which presents as a robot that thinks for a second and a half.
|
||
*/
|
||
function drawKind(rand: () => number, book: AddressBook): ErrandKind | null {
|
||
let total = 0;
|
||
let last: ErrandKind | null = null;
|
||
for (const [kind, weight] of ERRAND_MIX) {
|
||
if (book[kind].length === 0) continue;
|
||
total += weight;
|
||
last = kind;
|
||
}
|
||
if (last === null) return null;
|
||
|
||
let roll = rand() * total;
|
||
for (const [kind, weight] of ERRAND_MIX) {
|
||
if (book[kind].length === 0) continue;
|
||
roll -= weight;
|
||
if (roll <= 0) return kind;
|
||
}
|
||
// Floating-point slop only: the loop above subtracts exactly `total`.
|
||
return last;
|
||
}
|
||
|
||
/**
|
||
* How much this robot wants this address, as a number in (0, 1].
|
||
*
|
||
* Three factors, multiplied, and each one exists to stop a specific way four
|
||
* robots stop looking like four people:
|
||
*
|
||
* - **Cooldown**, so the crowd does not converge on the same few best
|
||
* addresses and pace a rut between them for the rest of the session.
|
||
* - **Elbow room**, so a robot does not walk across the building to stand
|
||
* where another one already is. Other robots' *destinations* count as much
|
||
* as their positions, which is the half that stops two robots setting off
|
||
* for the same desk and discovering it on arrival.
|
||
* - **Somewhere else**, so a robot in the kitchen tends to leave the
|
||
* kitchen. This is the term that spreads the crowd through the building
|
||
* rather than round one room, and it is the cheapest of the three.
|
||
*
|
||
* Multiplied rather than summed, because these are qualities a destination can
|
||
* lack independently and a sum lets one good factor carry two bad ones — the
|
||
* desk you were just at, with another robot already standing at it, would
|
||
* still score well for being in the next room. The floors under the first two
|
||
* keep the product away from zero, so a level with only bad options still
|
||
* produces an ordering rather than a tie.
|
||
*/
|
||
function scoreAddress(robot: Robot, address: Address, hereRoom: string | null): number {
|
||
const cool = Math.max(COOL_FLOOR, Math.min(1, (clock - address.visitedAt) / REVISIT_COOLDOWN));
|
||
|
||
let nearest = Infinity;
|
||
for (const other of robots) {
|
||
if (other === robot || other.level.id !== robot.level.id) continue;
|
||
const here = other.view.position;
|
||
nearest = Math.min(nearest, Math.hypot(here.x - address.at.x, here.z - address.at.z));
|
||
const bound = other.target;
|
||
if (bound) {
|
||
nearest = Math.min(nearest, Math.hypot(bound.x - address.at.x, bound.z - address.at.z));
|
||
}
|
||
}
|
||
const elbow =
|
||
nearest === Infinity ? 1 : Math.max(SPREAD_FLOOR, Math.min(1, nearest / SPREAD_FULL));
|
||
|
||
return cool * elbow * (address.roomId === hereRoom ? SAME_ROOM : 1);
|
||
}
|
||
|
||
/**
|
||
* Pick somewhere with a reason to be there, or fail and let the caller fall
|
||
* back to the sampler.
|
||
*
|
||
* Shortlist, then commit — and the split is what keeps this cheap. Scoring is
|
||
* arithmetic over four robots and costs nothing, so `SHORTLIST` candidates are
|
||
* drawn and ranked without touching the collider at all; only then is line of
|
||
* sight tested, best first, and the first one that can be seen wins. The
|
||
* measured cost is one `plan.blocked` call for most picks, because the best
|
||
* candidate is usually visible, and at most `SHORTLIST` of them.
|
||
*
|
||
* That is strictly cheaper than the sampler it replaced, which spent up to two
|
||
* `blocked` calls on each of `PICK_ATTEMPTS` candidates and still ended up
|
||
* somewhere with no name — and it is most of why the whole tick got faster
|
||
* rather than slower.
|
||
*/
|
||
function pickErrand(robot: Robot): boolean {
|
||
const book = bookFor(robot.level);
|
||
const here = robot.view.position;
|
||
const hereRoom = plan.roomAt(robot.level.id, here)?.id ?? null;
|
||
|
||
let filled = 0;
|
||
for (let i = 0; i < SHORTLIST; i++) {
|
||
const kind = drawKind(robot.rand, book);
|
||
if (kind === null) return false;
|
||
const pool = book[kind];
|
||
const candidate = pool[Math.floor(robot.rand() * pool.length)];
|
||
if (!candidate) continue;
|
||
// Same rule as the sampler's: too close and the robot shuffles rather than
|
||
// walks, and the walk is the part anybody sees.
|
||
if (Math.hypot(candidate.at.x - here.x, candidate.at.z - here.z) < MIN_TRIP) continue;
|
||
|
||
// Insertion sort, best first. Six entries at most, so this is a handful of
|
||
// compares and — unlike sorting an array of pairs — no allocation.
|
||
const value = scoreAddress(robot, candidate, hereRoom);
|
||
let slot = filled;
|
||
while (slot > 0 && (shortlistScore[slot - 1] ?? 0) < value) {
|
||
shortlist[slot] = shortlist[slot - 1] ?? null;
|
||
shortlistScore[slot] = shortlistScore[slot - 1] ?? 0;
|
||
slot--;
|
||
}
|
||
shortlist[slot] = candidate;
|
||
shortlistScore[slot] = value;
|
||
filled++;
|
||
}
|
||
|
||
for (let i = 0; i < filled; i++) {
|
||
const address = shortlist[i];
|
||
if (!address) continue;
|
||
|
||
// The run-in is taken whenever there is one, from wherever the robot is
|
||
// standing — including from the far side, where taking it means walking
|
||
// past the destination and coming back at it. That looked like the wrong
|
||
// trade and the measurement said otherwise. Taking it only from the near
|
||
// side, on the sign of a dot product, halved how often it was used at all
|
||
// — 128 of 399 picks over half an hour rather than 267 — and left the
|
||
// median arrival 99° off the angle it was supposed to hold. Taking it
|
||
// always costs 1.3% more walking and brings that median to 9°.
|
||
const goal = address.approach ?? address.at;
|
||
from.x = here.x;
|
||
from.z = here.z;
|
||
if (plan.blocked(robot.level.id, from, goal, radius)) continue;
|
||
|
||
// Copied rather than aliased. The address is shared by every robot and
|
||
// lives for the whole session; `waypoint` and `target` are one robot's and
|
||
// are cleared and replaced constantly, and one line that reached for
|
||
// `robot.target.x = …` would quietly move the desk for everybody.
|
||
robot.waypoint =
|
||
address.approach === null ? null : { x: address.approach.x, z: address.approach.z };
|
||
robot.target = { x: address.at.x, z: address.at.z };
|
||
robot.arriveFacing = address.facing;
|
||
const [low, high] = DWELL[address.kind];
|
||
robot.arriveDwell = low + robot.rand() * (high - low);
|
||
// Heading somewhere with a name, so the last door stops defining this
|
||
// robot — the same reasoning as the sampler's, and the reason a long
|
||
// circuit can come back through the door it left by.
|
||
robot.lastDoor = null;
|
||
address.visitedAt = clock;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* A doorway to head for when nowhere in the room is worth walking to.
|
||
*
|
||
* This is the fix for the one thing pure line-of-sight sampling cannot do, and
|
||
* it is not a small thing: a robot inside a small room can see almost nothing
|
||
* outside it, because every candidate has to be visible through a 0.9 m gap
|
||
* with 0.28 m of clearance either side. Measured on the reference office, three
|
||
* of four robots wandered into a kitchen, a stair core and a 2 × 2 m phone
|
||
* booth within the first minute and then stood there for the remaining
|
||
* nineteen. Not vibrating, not erroring — parked, forever, which is a worse
|
||
* failure than a visible one because it looks deliberate.
|
||
*
|
||
* So a robot that cannot see anywhere to go walks to a door instead: a
|
||
* **waypoint** at the middle of the opening and a target `THROUGH_DOOR` metres
|
||
* past it, so it ends up in the next space with sight lines into it rather
|
||
* than stopped in the gap still looking at the room it wanted to leave.
|
||
* `Plan` has already decided which openings a walker fits through — the
|
||
* `passable` flag is the wall split's own answer, computed from the same sill
|
||
* and head heights that punched the hole — so this invents no geometry and
|
||
* cannot disagree with the collider.
|
||
*
|
||
* The waypoint is the difference between working and not. A 0.9 m door with a
|
||
* 0.28 m robot leaves 0.22 m of usable width once both jambs are cleared, so a
|
||
* single straight line from an off-axis corner of a room to a point beyond the
|
||
* door misses by centimetres and the whole door is rejected — which is what
|
||
* left the last robot in a phone booth after every other fix. Split into two
|
||
* legs, each checked on its own, both are easy: any point in the room can see
|
||
* the middle of its own door, and the middle of a door can always see straight
|
||
* out of it. Which is the general shape of the thing: **one** waypoint, chosen
|
||
* from data `Plan` already publishes. Two would be a path, and a path needs a
|
||
* graph, and a graph is the navmesh this file exists to not build.
|
||
*
|
||
* Two passes over the doors, and the second one is why a booth works. The
|
||
* first skips the door this robot last came through, so a robot that has just
|
||
* walked into the open floor does not turn straight round. The second allows
|
||
* it, because a room with exactly one door — a booth, a store, a server room —
|
||
* has no other way out, and refusing to reuse it is refusing to leave.
|
||
*/
|
||
function pickDoor(robot: Robot): boolean {
|
||
const doors = samplerFor(robot.level).doors;
|
||
if (doors.length === 0) return false;
|
||
const here = robot.view.position;
|
||
const beyond: Point2 = { x: 0, z: 0 };
|
||
|
||
// Started at a random index rather than at zero, so a robot with two doors
|
||
// in sight does not always take the same one and pace a rut between two
|
||
// rooms for the rest of the session.
|
||
const start = Math.floor(robot.rand() * doors.length);
|
||
for (const allowLast of [false, true]) {
|
||
for (let i = 0; i < doors.length; i++) {
|
||
const door = doors[(start + i) % doors.length];
|
||
if (!door) continue;
|
||
if (!allowLast && door.id === robot.lastDoor) continue;
|
||
const dx = door.center.x - here.x;
|
||
const dz = door.center.z - here.z;
|
||
if (Math.hypot(dx, dz) < MIN_DOOR) continue;
|
||
|
||
from.x = here.x;
|
||
from.z = here.z;
|
||
if (plan.blocked(robot.level.id, from, door.center, radius)) continue;
|
||
|
||
// A wall at yaw φ runs along (cos φ, −sin φ), so its normal is
|
||
// (sin φ, cos φ). Step out along whichever end of that normal is
|
||
// further from the robot — that is the far side, which is the side
|
||
// worth going to.
|
||
const nx = Math.sin(door.yaw);
|
||
const nz = Math.cos(door.yaw);
|
||
const sign = dx * nx + dz * nz >= 0 ? 1 : -1;
|
||
beyond.x = door.center.x + sign * THROUGH_DOOR * nx;
|
||
beyond.z = door.center.z + sign * THROUGH_DOOR * nz;
|
||
if (!plan.roomAt(robot.level.id, beyond)) continue;
|
||
if (plan.blocked(robot.level.id, door.center, beyond, radius)) continue;
|
||
|
||
robot.waypoint = { x: door.center.x, z: door.center.z };
|
||
robot.target = { x: beyond.x, z: beyond.z };
|
||
robot.arriveFacing = null;
|
||
robot.arriveDwell = DWELL_DOOR[0] + robot.rand() * (DWELL_DOOR[1] - DWELL_DOOR[0]);
|
||
robot.lastDoor = door.id;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* A random reachable point on the floor. The fallback, and no longer the plan.
|
||
*
|
||
* This used to be the whole of destination selection, and everything that read
|
||
* as patrolling was here: a point drawn from a room's bounding box is not a
|
||
* place, it is a coordinate — so a robot walked to the middle of nowhere,
|
||
* stopped at whatever angle it happened to arrive at, waited a fixed-ish beat
|
||
* and set off again. `pickErrand` runs first now, and this catches the two
|
||
* things it cannot do: a robot in a room with no address it can see, and a
|
||
* pack that authors no seats, no props and no usable centroids at all.
|
||
*
|
||
* It is worth keeping precisely because it asks so little of the pack. An
|
||
* office is a `Plan`, and a `Plan` is allowed to be four walls and a door.
|
||
*
|
||
* The line-of-sight test is against the segment from here to there, and a
|
||
* segment includes its endpoints — so this is also the check that the
|
||
* destination itself has room to stand in, and there is no separate one.
|
||
*/
|
||
function pickWander(robot: Robot): boolean {
|
||
const candidate: Point2 = { x: 0, z: 0 };
|
||
const level = robot.level;
|
||
const sampler = samplerFor(level);
|
||
const here = robot.view.position;
|
||
for (let i = 0; i < PICK_ATTEMPTS; i++) {
|
||
if (!trySample(sampler, level.id, robot.rand, candidate)) continue;
|
||
if (Math.hypot(candidate.x - here.x, candidate.z - here.z) < MIN_TRIP) continue;
|
||
from.x = here.x;
|
||
from.z = here.z;
|
||
if (plan.blocked(level.id, from, candidate, radius)) continue;
|
||
robot.target = { x: candidate.x, z: candidate.z };
|
||
robot.waypoint = null;
|
||
// Nothing there to look at and no reason to linger, so a shrug of a pause
|
||
// and off again.
|
||
robot.arriveFacing = null;
|
||
robot.arriveDwell = PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN);
|
||
// Somewhere in the open: this robot is no longer defined by the last door
|
||
// it used, and forgetting it is what lets a long circuit of the building
|
||
// come back through the same doorway without a special case.
|
||
robot.lastDoor = null;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Choose somewhere to walk to, or fail.
|
||
*
|
||
* Three tiers, in descending order of how much the destination means:
|
||
* somewhere with a name and a facing, then anywhere at all on this floor, then
|
||
* out through the nearest door. A robot reaches the second only because it can
|
||
* see no address from where it is standing, and the third only because it can
|
||
* see nothing at all — which is why the order is this way round, and it is a
|
||
* happy accident of the shortlist that the tier that means the most is also
|
||
* the one that costs the least.
|
||
*
|
||
* Failure is still a normal outcome, not an error — a robot boxed into a
|
||
* corner with no door in sight will wait and try again from wherever it is —
|
||
* and nothing is logged, because a robot with nowhere to go looks exactly like
|
||
* a robot taking a moment.
|
||
*
|
||
* Every tier sets `target`, `waypoint`, `arriveFacing` and `arriveDwell`
|
||
* rather than returning a destination, because two of the three have to set a
|
||
* waypoint as well and a function that returned one field and mutated three
|
||
* would be the worst of both.
|
||
*/
|
||
function pickTarget(robot: Robot): boolean {
|
||
if (pickErrand(robot)) return true;
|
||
if (pickWander(robot)) return true;
|
||
return pickDoor(robot);
|
||
}
|
||
|
||
/** Whether a robot could walk `PROBE_AHEAD` metres on this heading with `clearance` to spare. */
|
||
function clearAhead(robot: Robot, heading: number, clearance: number): boolean {
|
||
const here = robot.view.position;
|
||
from.x = here.x;
|
||
from.z = here.z;
|
||
to.x = here.x - Math.sin(heading) * PROBE_AHEAD;
|
||
to.z = here.z - Math.cos(heading) * PROBE_AHEAD;
|
||
return !plan.blocked(robot.level.id, from, to, clearance);
|
||
}
|
||
|
||
/** Whether another robot on the same level is close enough and far enough ahead to yield to. */
|
||
function shouldYield(robot: Robot): boolean {
|
||
const here = robot.view.position;
|
||
const fx = -Math.sin(robot.yaw);
|
||
const fz = -Math.cos(robot.yaw);
|
||
for (const other of robots) {
|
||
if (other === robot || other.level.id !== robot.level.id) continue;
|
||
const dx = other.view.position.x - here.x;
|
||
const dz = other.view.position.z - here.z;
|
||
const distance = Math.hypot(dx, dz);
|
||
if (distance > YIELD_RANGE || distance < 1e-4) continue;
|
||
if ((dx * fx + dz * fz) / distance > YIELD_CONE) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Stop, and stand there for `seconds`.
|
||
*
|
||
* `settle` is the yaw to turn to while standing, and it is a parameter rather
|
||
* than something read off the robot because the two callers want opposite
|
||
* things from it. Arriving somewhere passes the errand's facing — that is the
|
||
* point of the errand. Giving up — wedged, deadlocked, watchdogged — passes
|
||
* `null`, because a robot that failed to get somewhere has no business
|
||
* adopting the pose of having got there.
|
||
*/
|
||
function beginPause(robot: Robot, seconds: number, settle: number | null): void {
|
||
robot.target = null;
|
||
robot.waypoint = null;
|
||
robot.wait = seconds;
|
||
robot.settle = settle;
|
||
robot.sinceCheck = 0;
|
||
robot.checkAge = 0;
|
||
}
|
||
|
||
// ---- Population ---------------------------------------------------------
|
||
|
||
const warned = new Set<string>();
|
||
options.robots.forEach((spec, index) => {
|
||
const level = plan.level(spec.levelId);
|
||
if (!level) {
|
||
if (!warned.has(spec.levelId)) {
|
||
warned.add(spec.levelId);
|
||
console.warn(`[tera/interiors] no level "${spec.levelId}" for a robot; skipping it`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Seeded per robot rather than from one shared stream, so adding a fifth
|
||
// robot does not move the other four. Same reasoning as `furnish.ts` keying
|
||
// its randomness on the batch rather than on a counter.
|
||
const rand = seededRandom(seed + index * 0x9e37);
|
||
const start: Point2 = { x: 0, z: 0 };
|
||
if (!samplePoint(level, rand, start)) {
|
||
// Nowhere on this level a robot fits. That is a fact about the pack — a
|
||
// level of corridors narrower than 0.56 m, or one with no rooms — and it
|
||
// is worth one line, because the symptom otherwise is a robot that is
|
||
// simply absent with no explanation anywhere.
|
||
console.warn(
|
||
`[tera/interiors] found nowhere to stand on level "${level.id}" after ` +
|
||
`${PICK_ATTEMPTS} tries; that robot is not in the scene`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const rig = cloneOptimus(prototype);
|
||
rig.root.name = spec.id ?? `robot-${index + 1}`;
|
||
rig.root.position.set(start.x, level.floorY, start.z);
|
||
rig.root.rotation.y = rand() * Math.PI * 2;
|
||
group.add(rig.root);
|
||
|
||
const view: RobotView = {
|
||
id: rig.root.name,
|
||
levelId: level.id,
|
||
position: new THREE.Vector3(start.x, level.floorY, start.z),
|
||
};
|
||
views.push(view);
|
||
robots.push({
|
||
view,
|
||
level,
|
||
rig,
|
||
yaw: rig.root.rotation.y,
|
||
target: null,
|
||
// Staggered, so four robots do not all set off on the same frame.
|
||
wait: rand() * PAUSE_MAX,
|
||
// Nothing to settle to and nowhere to have arrived from: a robot's first
|
||
// errand overwrites both of these before either is read.
|
||
settle: null,
|
||
arriveFacing: null,
|
||
arriveDwell: PAUSE_MIN,
|
||
gait: 0,
|
||
distance: rand() * STRIDE,
|
||
waypoint: null,
|
||
lastDoor: null,
|
||
sinceCheck: 0,
|
||
checkAge: 0,
|
||
rand,
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The heading nearest `want` that a robot can actually walk, and the clearance
|
||
* it was found at.
|
||
*
|
||
* `PROBE_RELIEF` is the escape hatch, and it is the reason a wedged robot
|
||
* cannot stay wedged. `plan.blocked` measures from the *start* of the segment
|
||
* as well as along it, so a robot standing closer to a wall than its own
|
||
* radius has every direction blocked — including straight away from the wall.
|
||
* The step check below is supposed to make that unreachable, and it is an
|
||
* invariant rather than a proof: an earlier version of it broke, and three of
|
||
* four robots spent nineteen simulated minutes welded to the spot. So if
|
||
* nothing is clear at full radius the robot is allowed to be thinner until
|
||
* something is, and creeps back out. No relief can push it through a wall,
|
||
* because `segmentDistance` returns zero for segments that actually cross and
|
||
* zero is under every clearance there is.
|
||
*
|
||
* Falls back to `want` itself when everything is blocked, so the caller still
|
||
* turns toward where it wanted to go and simply does not move.
|
||
*
|
||
* **The returned object is `chosen`, every time.** This used to be a fresh
|
||
* `{ heading, clearance }` per call, which is once a frame for every robot
|
||
* that is moving — the same allocation-per-frame this file goes out of its
|
||
* way to avoid in `from`, `to` and `RobotLayer.robots`, and it is odd that
|
||
* one survived where those did not. It is scratch now, and it is safe
|
||
* *because* of how it is used: `step` reads both fields on the line after the
|
||
* call and never keeps the reference. Anything that wants to hold on to a
|
||
* choice — comparing this frame's against last frame's, say — has to copy the
|
||
* two numbers out, or it will find that both of them changed underneath it on
|
||
* the next robot's turn.
|
||
*/
|
||
function chooseHeading(robot: Robot, want: number): Heading {
|
||
for (const relief of PROBE_RELIEF) {
|
||
const clearance = radius * relief;
|
||
for (const offset of PROBE_TURNS) {
|
||
if (clearAhead(robot, want + offset, clearance)) {
|
||
chosen.heading = want + offset;
|
||
chosen.clearance = clearance;
|
||
return chosen;
|
||
}
|
||
}
|
||
}
|
||
chosen.heading = want;
|
||
chosen.clearance = radius;
|
||
return chosen;
|
||
}
|
||
|
||
// ---- Step ---------------------------------------------------------------
|
||
|
||
function step(robot: Robot, dt: number): void {
|
||
let moved = 0;
|
||
let effort = 0;
|
||
|
||
if (robot.target === null) {
|
||
robot.wait -= dt;
|
||
if (robot.wait <= 0) {
|
||
if (pickTarget(robot)) {
|
||
robot.sinceCheck = 0;
|
||
robot.checkAge = 0;
|
||
} else {
|
||
robot.wait = RETRY_PAUSE;
|
||
}
|
||
}
|
||
|
||
// Still nothing to walk to, so this is a robot standing somewhere on
|
||
// purpose: turn it to the angle its errand asked for. Guarded on `target`
|
||
// rather than sequenced before the pick because `arriveFacing` has already
|
||
// been copied into `settle` by then and a pick that succeeded has replaced
|
||
// it with the *next* destination's — turning toward that one from here
|
||
// would have the robot aim itself across the building before setting off.
|
||
//
|
||
// This is the one place the yaw moves without a destination, and the
|
||
// reason `SETTLE_RATE` is slower than `TURN_RATE`.
|
||
if (robot.target === null && robot.settle !== null) {
|
||
let swing = robot.settle - robot.yaw;
|
||
swing = Math.atan2(Math.sin(swing), Math.cos(swing));
|
||
const limit = SETTLE_RATE * dt;
|
||
if (Math.abs(swing) <= limit) {
|
||
// Arrived at the angle. `+= swing` rather than `= settle` keeps the
|
||
// yaw continuous — the facing came out of the pack and may be any
|
||
// multiple of a turn away from where this robot has wound up to.
|
||
robot.yaw += swing;
|
||
robot.settle = null;
|
||
} else {
|
||
robot.yaw += limit * Math.sign(swing);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Steer at the waypoint while there is one, and at the destination after
|
||
// that. There is at most one waypoint and it is always a doorway.
|
||
const goal = robot.waypoint ?? robot.target;
|
||
if (goal && robot.target) {
|
||
const here = robot.view.position;
|
||
const dx = goal.x - here.x;
|
||
const dz = goal.z - here.z;
|
||
const remaining = Math.hypot(dx, dz);
|
||
|
||
if (remaining < (robot.waypoint ? REACHED : ARRIVE)) {
|
||
if (robot.waypoint) robot.waypoint = null;
|
||
// Arrived. Both halves of what the errand asked for are spent here and
|
||
// nowhere else: how long to stand, and which way to look while doing it.
|
||
else beginPause(robot, robot.arriveDwell, robot.arriveFacing);
|
||
} else {
|
||
// A figure faces −Z at yaw 0, so the heading that points along (dx, dz)
|
||
// is the one whose (−sin, −cos) matches it. This is the same convention
|
||
// `Yaw` carries everywhere else and it is why there is no conversion.
|
||
const want = Math.atan2(-dx, -dz);
|
||
let error = want - robot.yaw;
|
||
error = Math.atan2(Math.sin(error), Math.cos(error));
|
||
|
||
// Slow while turning and slow into the destination. The first is what
|
||
// makes a robot pivot toward a doorway instead of arcing into its
|
||
// frame; the second is what stops it overshooting and orbiting the
|
||
// point it was aiming at. Both fall out of the walk cycle for free,
|
||
// because the cycle is driven by distance — a slow robot takes short
|
||
// steps rather than the same steps more slowly.
|
||
const facing = Math.max(0, Math.cos(error));
|
||
const approach = Math.min(1, remaining / (ARRIVE * 2.5));
|
||
let pace = speed * facing * approach;
|
||
if (shouldYield(robot)) pace = 0;
|
||
|
||
// Fan out from the direction the destination wants until something is
|
||
// clear. The straight line was clear when the destination was chosen,
|
||
// but a robot turns on an arc rather than pivoting, so it can end up
|
||
// aimed at a corner the original line missed.
|
||
//
|
||
// Probing around `want` and not around the robot's own heading is not a
|
||
// detail. Probing around the heading, and then steering toward whatever
|
||
// came back, gives *two* rate-limited turns in one frame — one toward
|
||
// the destination and one toward the probe — and at equal rates they
|
||
// cancel exactly. The observed symptom was a robot locked 0.95 rad off
|
||
// course, pacing on the spot in a phone booth for the whole session,
|
||
// with every individual line of it looking correct. One desired heading
|
||
// and one turn.
|
||
const choice = chooseHeading(robot, want);
|
||
|
||
// The turn happens whether or not anything was clear, so a robot that
|
||
// has walked into a dead end keeps rotating and finds its way out by
|
||
// looking around rather than by waiting for the watchdog.
|
||
let swing = choice.heading - robot.yaw;
|
||
swing = Math.atan2(Math.sin(swing), Math.cos(swing));
|
||
robot.yaw += Math.min(Math.abs(swing), TURN_RATE * dt) * Math.sign(swing);
|
||
|
||
// And it only walks if the direction it actually ended up facing is
|
||
// clear. The turn rate caps how far the yaw got, so mid-turn the robot
|
||
// faces somewhere nothing has tested; walking that is exactly how one
|
||
// ends up inside the clearance band, which presents as a robot standing
|
||
// in a kitchen for the rest of the session rather than as an error.
|
||
if (pace > 0 && clearAhead(robot, robot.yaw, choice.clearance)) {
|
||
const advance = pace * dt;
|
||
robot.view.position.x -= Math.sin(robot.yaw) * advance;
|
||
robot.view.position.z -= Math.cos(robot.yaw) * advance;
|
||
moved = advance;
|
||
effort = pace / speed;
|
||
}
|
||
|
||
robot.distance += moved;
|
||
robot.sinceCheck += moved;
|
||
robot.checkAge += dt;
|
||
if (robot.checkAge >= STUCK_WINDOW) {
|
||
if (robot.sinceCheck < STUCK_DISTANCE) {
|
||
// Wedged, deadlocked with another robot, or aiming at somewhere it
|
||
// can no longer reach. Throwing the destination away and standing
|
||
// still for a moment resolves all three, and is the reason this
|
||
// cannot vibrate against a wall forever.
|
||
beginPause(robot, RETRY_PAUSE, null);
|
||
} else {
|
||
robot.sinceCheck = 0;
|
||
robot.checkAge = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ease rather than snap, so a robot that stops settles its limbs over about
|
||
// a quarter of a second instead of jumping to attention mid-stride.
|
||
robot.gait += (effort - robot.gait) * Math.min(1, dt / GAIT_EASE);
|
||
if (robot.gait < 1e-3) {
|
||
robot.gait = 0;
|
||
restOptimus(robot.rig.joints);
|
||
} else {
|
||
pose(robot.rig.joints, robot.distance, robot.gait);
|
||
}
|
||
|
||
robot.rig.root.position.x = robot.view.position.x;
|
||
robot.rig.root.position.z = robot.view.position.z;
|
||
robot.rig.root.rotation.y = robot.yaw;
|
||
}
|
||
|
||
return {
|
||
group,
|
||
tick(dt) {
|
||
if (!(dt > 0)) return;
|
||
const clamped = Math.min(dt, MAX_STEP);
|
||
// The clamped step, deliberately: the clock exists to age destinations
|
||
// against how much walking has happened, and in a backgrounded tab none
|
||
// has. See `clock`.
|
||
clock += clamped;
|
||
for (const robot of robots) step(robot, clamped);
|
||
},
|
||
robots() {
|
||
return views;
|
||
},
|
||
dispose() {
|
||
for (const robot of robots) group.remove(robot.rig.root);
|
||
robots.length = 0;
|
||
views.length = 0;
|
||
// Every clone shares the prototype's buffers, so this frees all of them
|
||
// exactly once. Materials belong to the caller's registry and are left
|
||
// alone, the same way every asset in this library leaves them alone.
|
||
disposeOptimus(prototype);
|
||
},
|
||
};
|
||
}
|