[render] THE INVERSION: The Correction is the only thing that never misses a frame
Rungs 2-4 embodied as hospital-white placeholder bodies (warden / gunship / auditor), the gunship's ghost-mirrored sector + parity tether, the auditor's 0:00 stasis bleach, compliance placards, and the v9 onEvents migration. The inversion is measured, not asserted: fed a worst-case integer-stepped position that the sim moves on 6 of 120 frames, the warden renders 120 distinct positions with zero stalled frames. The auditor renders 1 position and 119 stalled frames, deliberately — it moves only between frames (codex 5). - correction.ts: tick-lerp + damped follow for wardens/gunships, instant relocation + blink for auditors, instanced ghost mirror (cap 512), tether, bleach + 00:00:00:00 timecode, and the frozen-entity set - entities.ts: compliance placard + total idle/mixer/haze freeze (idle clock delta measured at exactly 0.000000 over 30 sealed frames) - beltitems.ts: cargo pinned MID-TILE inside a lock; the freeze path is cheaper than the normal path (0.392 vs 0.465 ms at 2,000 items) - index.ts: onEvents buffered and drained at the top of render(); repaired, the firewall can, and relicFound all retired as snapshot heuristics - notice.ts: the fax-side notice halo, with a one-frame retry because main.ts runs the renderer BEFORE the UI and the panel may not be laid out yet - correction.test.ts: 9 tests, mutation-checked. Pins the cross-lane rect convention against LANE-SIM's own frozenEnt formula over 4,896 cases. Perf at a deliberate worst case (500 entities, 2,000 items, the entire base both mirrored AND frozen): whole frame 2.30 ms median against a 16.6 ms budget; this round's net cost ~0.2 ms. Prod bundle clean of all scaffold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
88bcfada88
commit
462db9947c
@ -1012,3 +1012,203 @@ pass and this change compound.
|
||||
- Wire the renderer event channel if granted; `firewall.glb` when it lands; per-mite
|
||||
orientation; heat-shimmer relief inside a cooler rectangle; bloom-loop belt tell;
|
||||
save/load camera state.
|
||||
|
||||
### Round 7 PHASE 1 — 2026-07-29 — Opus 5 — THE CORRECTION, embodied
|
||||
|
||||
**THE HEADLINE: the inversion is not a vibe, it is a measurable property, and it now has a
|
||||
number. Driven at 60fps over 60 sim ticks with a WORST-CASE stepped input — a warden whose
|
||||
sim position changes on 6 of 120 frames and only ever by whole integer tiles — the renderer
|
||||
produced 120 distinct positions and ZERO stalled frames. The hash auditor, on the same rig,
|
||||
produced 1 distinct position and 119 stalled frames, deliberately. Authority glides;
|
||||
authority also holds perfectly still; what authority never does is judder.**
|
||||
|
||||
**SHIPPED** — all 5 orders. New: `src/render/correction.ts`, `src/render/notice.ts`,
|
||||
`src/render/correction.test.ts`. Zero GLBs, as ordered.
|
||||
|
||||
1. **Placeholder bodies, rungs 2-4** (`correction.ts`). Hospital-white, matte, spotless,
|
||||
one green element each — green is the faction signature it shares with the mite's
|
||||
checkmark eye, so a player learns "green on white = the immune system" once.
|
||||
- **WARDEN**: tall filing-cabinet slab, drawer breaks, rubber-stamp arm on a green pad
|
||||
that slams on `phase === 'audit'` and rests cocked otherwise.
|
||||
- **GUNSHIP**: five platters in a row on spindles, counter-rotating about the centre
|
||||
(that is where "mirrored" went), green parity emitter on the belly, continuous hover.
|
||||
- **AUDITOR**: faceless white-marble column, green padlock halo, binary scan cone.
|
||||
2. **The gunship's mirror**: the `rect` gets a faint white sector pad, and every entity
|
||||
inside it is re-rendered as a featureless translucent white box hovering at 1.5 under
|
||||
the gunship — your own layout, sanitised (§5). One InstancedMesh, capped at 512. The
|
||||
parity **tether** is a green cylinder stretched from the emitter to the anchor entity,
|
||||
with a pulse running down it.
|
||||
3. **Stasis**: bleached ground + hard white edge + a floating **00:00:00:00** timecode, and
|
||||
a `frozen` entity set that EntityLayer and BeltItemLayer both read. Release snaps back
|
||||
the same frame with a white slap at the rect's centre and four corners.
|
||||
4. **Seal**: sealed machines get a printed compliance placard (green tick, SEALED, a hash
|
||||
line) facing the camera azimuth, and every moving part stops.
|
||||
5. **onEvents migration** — all three derived cues retired, plus four new ones.
|
||||
|
||||
**VERIFIED (numbers, on FRESH hard-navigated loads — round 6's lesson, followed)**
|
||||
|
||||
*The glide (order's DoD: "verified against juddering surroundings").* 120 render frames /
|
||||
60 sim ticks, warm-up discarded, positions sampled per frame:
|
||||
|
||||
| | sim moved it on | rendered distinct | step min | step max | mean | stalled frames |
|
||||
|---|---|---|---|---|---|---|
|
||||
| warden (integer-stepped input, worst case) | **6 / 120** | **120** | 0.00097 | 0.228 | 0.048 | **0** |
|
||||
| gunship (fractional input, SIM's real shape) | every tick | **120** | 0.00037 | 0.0157 | 0.0075 | **0** |
|
||||
| auditor (teleports, never walks) | 3 jumps | **1** | 0 | 0 | 0 | **119** (by design) |
|
||||
|
||||
The warden's max/mean ratio of ~4.7 is the damped follow easing out of each whole-tile
|
||||
jump; it is continuous, and it is the deliberately unfair case. **I checked SIM's actual
|
||||
implementation rather than guessing**: `driftUnit` moves wardens and gunships by a
|
||||
FRACTIONAL step every tick (the gunship row above), and the auditor is relocated by direct
|
||||
assignment with the comment "it moves only between frames". Both of my paths match the sim
|
||||
they will actually be fed.
|
||||
|
||||
*Stasis freeze.* 40 frames / 20 sim ticks with the lock held: **5 of 6 melt items dead
|
||||
still**, the 6th correctly still moving because its belt is the one tile outside the rect.
|
||||
Frozen coordinates are genuinely **mid-tile** — fractional parts 0.052, 0.4, 0.503, 0.733 —
|
||||
not snapped to a tile edge, which is the whole read. Over a longer 25-tick hold, 5 of 6
|
||||
shared ids were **byte-identical**; the 6th legitimately left the rect and re-entered.
|
||||
**Release**: frozen 10 → 0 and iced 7 → 0 within 4 frames, `stasisActive` false.
|
||||
|
||||
*Seal.* Placards visible 0 → 1 on the seal edge, positioned at (1.75, 0.68, 9.75) for a 2×2
|
||||
at (0,8) — dead on the footprint centre plus the camera-facing offset — at yaw **0.7854**
|
||||
(= ISO_YAW). The freeze is exact: **idle clock delta 0.000000 over 30 sealed frames**,
|
||||
0.500000 over the next 30 free frames (30/60 exactly).
|
||||
|
||||
*Perf — the standing rule, at a load far past anything the sim will produce.* I put the
|
||||
Correction into `?devscene=stress` at the honest worst case: a gunship mirroring the ENTIRE
|
||||
500-entity build and an auditor freezing all of it — **500 ghost boxes, 500 frozen
|
||||
entities, 2,000 pinned belt items**, 149 draw calls, 265k tris at 2800×1800.
|
||||
|
||||
| | ms |
|
||||
|---|---|
|
||||
| whole frame callback (sim tick + render + UI + screen + audio), median / p90 / max | **2.30 / 5.5 / 6.0** |
|
||||
| renderer-only rolling avg | 1.90 |
|
||||
| `gl.render` submit, correction layer ON vs OFF | 0.41–0.51 vs 0.325 |
|
||||
| `correction.sync` CPU (the entire worst-case pass) | **0.128** |
|
||||
| `cargo.sync` WITH the freeze vs without | **0.392 vs 0.465** |
|
||||
| `entities.sync` with vs without | 0.032 vs 0.020 |
|
||||
|
||||
Net cost of this round at that load: **~0.2 ms against a 16.6 ms budget.** The cargo freeze
|
||||
is *cheaper* than not freezing — a pinned item skips the topology and path maths entirely.
|
||||
|
||||
*Prod bundle*: `devscene` / `showroom` / `plinth` / `__fktryPerf` / `correctionUnits` /
|
||||
`buildCorrectionBay` all **0 hits**; the only `__render` matches are three.js's own
|
||||
`__renderTarget` (4), as every round since 3. The shipping strings are present
|
||||
(`PARITY NOTICE`, `00:00:00:00`, `fk-notice`). `npm run check` clean.
|
||||
|
||||
*Tests*: this lane 31 → **40** (9 new, all mutation-checked — see below). Repo 598 tests,
|
||||
596 pass; the 2 reds are `src/sim/mite.test.ts`, LANE-SIM's in-flight firewall-vs-warden
|
||||
work, which imports nothing from `src/render` (grep: 0 hits). `src/sim/reference.test.ts`
|
||||
was red mid-round and SIM fixed it while I worked.
|
||||
|
||||
**DECISIONS**
|
||||
|
||||
- **The auditor has NO idle animation at all, and that is the whole design.** Not a bob,
|
||||
not a halo drift, not an emissive pulse. Every other object in this game is alive;
|
||||
the one that isn't is the one to be afraid of. It is also the honest reading of §5's
|
||||
"moves only between frames" — stillness is not judder, it is the same claim of total
|
||||
control from the other side. Its scan cone is binary (present during `prescan`, absent
|
||||
otherwise) rather than sweeping, for the same reason.
|
||||
- **The warden keeps its RED LED ledger eyes, but as pinpoints.** Orders said "one green
|
||||
element each"; codex §5 says the warden has "red LED ledger eyes". The codex wins on
|
||||
canon and the orders win on the faction read, so both got what they were actually
|
||||
protecting: the ONE element is the large green stamp pad (which is also literally the
|
||||
seal that ends up on your chassis), and the ledger is a strip of five 5cm red pinpoints
|
||||
on the drawer chest. A large red element would have said "different faction" on a screen
|
||||
where the other three rungs are green.
|
||||
- **The gunship is matte white, not chrome, and its platters are SMALLER than half their
|
||||
spacing.** §5 says "mirrored disk-platter rotors"; five actually-mirrored surfaces on a
|
||||
black-sky world read as five black holes, and authored at true platter proportions
|
||||
(radius 0.4, spacing 0.54) they fused into a single white lozenge at game zoom — caught
|
||||
in `?showroom`, fixed to radius 0.26. The COUNT is the silhouette, so the count has to
|
||||
survive. Screenshots before/after are stark.
|
||||
- **The warden's detail face is local +Z, not -Z**, breaking this lane's own placeholder
|
||||
convention on purpose. Machines are authored facing north because they carry a `dir`; a
|
||||
Correction unit instead yaws toward its own travel, and a parked one sits at yaw 0 — with
|
||||
the drawers on -Z, a parked warden showed the iso camera a blank white back. Found in the
|
||||
showroom bay, which is exactly what the bay is for.
|
||||
- **Stasis rects come from the UNION of the event and the snapshot.** `phase === 'stasis'`
|
||||
is a string this lane does not own, and keying a freeze off it alone would be a silent
|
||||
single point of failure the day SIM renames a phase; the `stasis` event's rect (keyed by
|
||||
`lockHash`) is the other half. Either source alone lights the region. They currently
|
||||
agree — I read SIM's `refreshStasis`.
|
||||
- **The rect overlap convention is now PINNED BY A TEST against SIM's own formula.** SIM's
|
||||
`frozenEnt` uses inclusive bounds, mine are half-open. On integers those are the same
|
||||
predicate — but only by argument, and phase 0's era tie-break was exactly this shape:
|
||||
two lanes claiming to agree, never checked, latent for three rounds because the data
|
||||
never disagreed. SIM's formula is transcribed into `correction.test.ts` as an oracle and
|
||||
the two are asserted equal over 4,896 footprint/position combinations. If either lane
|
||||
retunes its bounds, it fails loudly.
|
||||
- **The bleach overlay is totally static** — no pulse, no scroll, no breathing. Every other
|
||||
overlay in this game moves; the one that doesn't is the one that stopped time.
|
||||
- **Stasis freezes machines as well as cargo**, reusing the sealed path. Both are the same
|
||||
sentence from The Correction: this is not running. (SIM agrees independently — its
|
||||
`frozenEnt` gates crafting, heat, progress and belt movement.)
|
||||
- **`shipmentFlash` was deliberately NOT migrated** to the `shipped` event. The event
|
||||
carries item and count but no position, so the uplink tiles come from the snapshot
|
||||
either way, and the existing delta is already exact. Migrating for its own sake would
|
||||
have been churn.
|
||||
- **The mite's "canned by a firewall" cue is now event-TIMED but still state-DISCRIMINATED.**
|
||||
`wildlife: 'cleared'` fires for a sated mite leaving at the rim and for one canned
|
||||
mid-repair alike, and only `size` tells them apart — so timing and position are now exact
|
||||
(from the event) and one boolean is still read from the layer's last-seen state. Honest
|
||||
improvement, not a full retirement. See CONTRACT REQUEST 2.
|
||||
|
||||
**BLOCKED/BROKEN** — nothing blocking.
|
||||
|
||||
- **The notice ping reads LANE-UI's DOM to find the fax, and that is a bridge.** The
|
||||
`notice` event has no `pos` and the fax is a DOM panel, so `NoticePing` measures
|
||||
`#fk-notices` (UI's new v9 tray) or `#fk-fax` and strikes a halo around it. The element
|
||||
is mine — created by and appended by this lane, same as the round-1 vignette — and the
|
||||
read is read-only, but it is a cross-lane coupling that wants a real hook. It also
|
||||
exposed a real ordering trap: **main.ts runs the renderer BEFORE the UI within a frame**,
|
||||
so on the first notice of a session the panel may not be laid out yet and measures 0×0.
|
||||
It now retries once on the next animation frame before falling back to a corner. I hit
|
||||
this for real — my first live notice struck the fallback position.
|
||||
- **The `#screen` pointer-events request is closed, not granted** — the round-5 review
|
||||
ruled it a legitimate interactive surface now that UI has click-to-enlarge. Dropping it
|
||||
from my asks; recording the resolution so it isn't re-filed.
|
||||
- **The verification environment fought back again, worse than round 5.** The Browser pane
|
||||
reports `document.hidden === true` and rAF never fires, so `stats.frames` sat at 0 while
|
||||
the page looked alive; and the shared tree hot-reloaded my page mid-measurement (I
|
||||
caught it only because `stats.frames` reset to 0 under me and I nearly wrote up a
|
||||
"notice ping is broken" finding from a page that had reloaded 200ms earlier). Fixed the
|
||||
way round 5 did: an isolated `rsync` copy of the repo on its own port, plus a
|
||||
setTimeout-backed rAF shim so frames advance at all. Every number above comes from that
|
||||
isolated build. **Anyone measuring in this pane should check `document.hidden` first.**
|
||||
- The mirror only ghosts ENTITIES, not belt cargo. Belts are entities so the sector's
|
||||
layout reads fully; the items riding it don't get duplicated. Cheap to add (one more
|
||||
instanced mesh) if the mirror ever needs to look busy rather than clean — though "clean
|
||||
parallel copy" arguably wants to be emptier than yours.
|
||||
- `match.html` 404 in dev (rounds 5-7, unchanged).
|
||||
|
||||
**CONTRACT REQUEST**
|
||||
|
||||
1. **`notice` could carry a `pos?: Vec2`** — the unit that filed it has a position, and
|
||||
with one the renderer could put the cue in the world at the thing being noticed instead
|
||||
of reading another lane's DOM to find the fax. Small, optional, retires a bridge.
|
||||
2. **`repaired` / `mirrored` could carry `by: number`** (the unit or mite id). Would let
|
||||
the "canned vs sated" discrimination above come from the event stream instead of the
|
||||
layer's remembered `size`, retiring the last state-derived cue in this lane. Low
|
||||
priority — the current form is correct for SIM's lifecycle.
|
||||
3. Nothing else. **v9 granted the renderer event channel** asked for in rounds 5, 6 and
|
||||
7-phase-0; it landed clean and retired three heuristics on contact.
|
||||
|
||||
**PROPOSAL / MODELBEAST WORK ORDER — rungs 2-4 are hot-swap targets the moment they exist.**
|
||||
`?showroom` now has a **CORRECTION bay** north of the machine hall: all three rungs on
|
||||
labelled plinths, plus four duplicate machines being mirrored, sealed and frozen so every
|
||||
state is one screenshot. The bodies are procedural placeholders and do NOT yet go through
|
||||
`AssetRegistry` (they are not `MachineDef`s and have no `asset` key), so a GLB drop needs a
|
||||
small registry extension rather than nothing — flagging that honestly, since every other
|
||||
asset in this game is zero-code. Codex asset lines are in §5; the three worth generating
|
||||
are `checksum-warden` (rigged: stamp arm), `redundancy-gunship` (5 platters), `hash-auditor`
|
||||
(rigged, though it must never be seen to walk — an idle-only clip).
|
||||
|
||||
**NEXT (round 8 candidates, not self-assigned)**
|
||||
- Registry support for non-machine actors so Correction GLBs hot-swap like everything else.
|
||||
- `debtBand` as a world-wide grade (the sky/fog cooling as the bands climb) — deliberately
|
||||
left alone this round because UI and SCREEN both own ledger surfaces and three lanes
|
||||
drawing the same number is how a screen gets noisy.
|
||||
- Mirror the cargo inside a gunship sector; per-mite orientation; heat-shimmer relief
|
||||
inside a cooler rectangle; `firewall.glb`; save/load camera state.
|
||||
|
||||
@ -63,6 +63,15 @@ export class BeltItemLayer {
|
||||
private written = new Map<string, number>();
|
||||
private lp = { x: 0, z: 0 };
|
||||
private wp = { x: 0, z: 0 };
|
||||
/**
|
||||
* v9 — stasis. Cargo caught inside a hash auditor's 0:00 lock is pinned to the exact
|
||||
* transform it had when the lock closed, so it reads as stopped MID-TILE rather than
|
||||
* snapped to a tile edge. Keyed by BeltItem.id; entries are dropped the frame the item
|
||||
* leaves the lock, which is what makes the release "snap back hard" — no easing, the
|
||||
* item is simply where the sim has been saying it is all along.
|
||||
*/
|
||||
private iced = new Map<number, { x: number; y: number; z: number; ry: number }>();
|
||||
private icedSeen = new Set<number>();
|
||||
|
||||
constructor(data: GameData, private topo: BeltTopology) {
|
||||
this.group.name = 'beltItems';
|
||||
@ -91,7 +100,8 @@ export class BeltItemLayer {
|
||||
return im;
|
||||
}
|
||||
|
||||
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
|
||||
/** @param frozen belt entity ids inside an auditor's stasis lock (see CorrectionLayer). */
|
||||
sync(snap: SimSnapshot, alpha: number, timeSec: number, frozen?: ReadonlySet<number>): void {
|
||||
// Roll the tick window: cur becomes prev the moment the sim advances.
|
||||
const advanced = snap.tick !== this.lastTick;
|
||||
if (advanced) {
|
||||
@ -116,6 +126,7 @@ export class BeltItemLayer {
|
||||
|
||||
// Pass 2 — write instance matrices.
|
||||
this.written.clear();
|
||||
this.icedSeen.clear();
|
||||
for (const bi of snap.beltItems) {
|
||||
const node = this.topo.get(bi.entity);
|
||||
if (!node) continue;
|
||||
@ -123,16 +134,31 @@ export class BeltItemLayer {
|
||||
const im = this.byItem.get(bi.item);
|
||||
if (!def || !im) continue;
|
||||
|
||||
const from = this.prev.get(bi.id);
|
||||
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
|
||||
const held = frozen?.has(bi.entity) ? this.iced.get(bi.id) : undefined;
|
||||
if (held) {
|
||||
this.icedSeen.add(bi.id);
|
||||
this.dummy.position.set(held.x, held.y, held.z);
|
||||
this.dummy.rotation.set(0, held.ry, 0);
|
||||
} else {
|
||||
const from = this.prev.get(bi.id);
|
||||
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
|
||||
|
||||
localPath(node.shape, t, this.lp);
|
||||
toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp);
|
||||
this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z);
|
||||
this.dummy.rotation.set(0, 0, 0);
|
||||
if (def.tier >= 2) {
|
||||
this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
|
||||
this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02;
|
||||
localPath(node.shape, t, this.lp);
|
||||
toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp);
|
||||
this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z);
|
||||
this.dummy.rotation.set(0, 0, 0);
|
||||
if (def.tier >= 2) {
|
||||
this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
|
||||
this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02;
|
||||
}
|
||||
// The lock has just closed over this item: pin it exactly where it is, mid-tile.
|
||||
if (frozen?.has(bi.entity)) {
|
||||
this.icedSeen.add(bi.id);
|
||||
this.iced.set(bi.id, {
|
||||
x: this.dummy.position.x, y: this.dummy.position.y, z: this.dummy.position.z,
|
||||
ry: this.dummy.rotation.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.dummy.updateMatrix();
|
||||
|
||||
@ -142,6 +168,11 @@ export class BeltItemLayer {
|
||||
this.written.set(bi.item, n + 1);
|
||||
}
|
||||
}
|
||||
// Release: anything no longer frozen forgets its pinned pose and resumes at full
|
||||
// speed on the very next frame. Bounded — the map only ever holds locked cargo.
|
||||
if (this.iced.size !== this.icedSeen.size) {
|
||||
for (const id of this.iced.keys()) if (!this.icedSeen.has(id)) this.iced.delete(id);
|
||||
}
|
||||
|
||||
for (const [id, im] of this.byItem) {
|
||||
im.mesh.count = this.written.get(id) ?? 0;
|
||||
|
||||
@ -5,6 +5,13 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
const ISO_DIR = new THREE.Vector3(1, 1.15, 1).normalize();
|
||||
/**
|
||||
* Yaw of the (fixed) camera azimuth, for anything that must face the viewer without a
|
||||
* per-frame billboard — compliance placards, the stasis timecode. DERIVED from ISO_DIR
|
||||
* rather than written as `Math.PI / 4`, so a rig change can't leave a second copy of the
|
||||
* angle behind (the scram-latch lesson: no duplicated constants across modules).
|
||||
*/
|
||||
export const ISO_YAW = Math.atan2(ISO_DIR.x, ISO_DIR.z);
|
||||
/**
|
||||
* Ortho: this affects near/far clipping, not apparent size. It DOES set how far every
|
||||
* object sits from the camera, so scene fog must be banded around it — see index.ts.
|
||||
|
||||
122
fktry/src/render/correction.test.ts
Normal file
122
fktry/src/render/correction.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* LANE-RENDER — source-level tests for THE CORRECTION's geometry (v9, round 7).
|
||||
*
|
||||
* Three of the four things this lane learned to test the hard way are in here:
|
||||
*
|
||||
* 1. **The rect convention is CROSS-LANE and it is not written in the contract.**
|
||||
* `CorrectionUnit.rect` and the `stasis` event's rect decide which machines the sim
|
||||
* stops crafting and which machines the renderer stops animating. The sim tests
|
||||
* overlap with INCLUSIVE bounds (`src/sim/index.ts frozenEnt`); this lane tests it
|
||||
* half-open. Those are the same predicate on integers — but only by argument, and
|
||||
* round 7 phase 0's era tie-break was exactly this shape of bug: two lanes claiming
|
||||
* to agree, never checked, latent for three rounds because the data never
|
||||
* disagreed. So the sim's formula is transcribed below and the two are asserted
|
||||
* equal over an exhaustive grid. If either lane retunes its bounds, this fails.
|
||||
*
|
||||
* 2. **THE INVERSION is arithmetic, not vibes.** A Correction unit must never render at
|
||||
* a quantised position. `angleTo` is the one piece of its motion that can be checked
|
||||
* without a GPU — and a naive lerp spinning the long way round ±π would read as a
|
||||
* unit snapping through 350 degrees, which is the exact "wrong motion" the round-7
|
||||
* ruling says belongs to corruption and never to authority.
|
||||
*
|
||||
* Node environment, like registry.test.ts: no GPU here, and the bodies stay an eyeball
|
||||
* check in `?showroom`'s Correction bay.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { angleTo, overlaps, rectHasTile, type Rect } from './correction';
|
||||
|
||||
/**
|
||||
* LANE-SIM's `frozenEnt` overlap test, transcribed verbatim from `src/sim/index.ts`
|
||||
* (inclusive bounds over the footprint's min/max tile). This is the oracle: the renderer
|
||||
* must freeze exactly the machines the sim froze, no more and no fewer.
|
||||
*/
|
||||
function simOverlaps(x: number, y: number, w: number, h: number, r: Rect): boolean {
|
||||
if (x + w - 1 < r.x || x > r.x + r.w - 1) return false;
|
||||
if (y + h - 1 < r.y || y > r.y + r.h - 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('stasis / mirror rect overlap', () => {
|
||||
const rect: Rect = { x: -3, y: 0, w: 5, h: 6 };
|
||||
|
||||
it('matches LANE-SIM tile-for-tile over an exhaustive grid of footprints', () => {
|
||||
let checked = 0;
|
||||
let agreed = 0;
|
||||
for (let x = -8; x <= 8; x++) {
|
||||
for (let y = -5; y <= 12; y++) {
|
||||
for (const w of [1, 2, 3, 5]) {
|
||||
for (const h of [1, 2, 3, 5]) {
|
||||
checked++;
|
||||
if (overlaps(x, y, w, h, rect) === simOverlaps(x, y, w, h, rect)) agreed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(checked).toBe(17 * 18 * 16);
|
||||
expect(agreed).toBe(checked); // any divergence is a machine one lane froze and the other didn't
|
||||
});
|
||||
|
||||
it('includes a footprint that only clips the rect by one tile', () => {
|
||||
// A 3x3 whose far corner lands on the rect's first tile.
|
||||
expect(overlaps(-5, -2, 3, 3, rect)).toBe(true);
|
||||
// ...and the same machine one tile further out, which must NOT freeze.
|
||||
expect(overlaps(-6, -3, 3, 3, rect)).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes a footprint that stops exactly at the far edge', () => {
|
||||
// rect covers x in [-3, 1]; a 1x1 at x=2 is the first tile outside.
|
||||
expect(overlaps(2, 3, 1, 1, rect)).toBe(false);
|
||||
expect(overlaps(1, 3, 1, 1, rect)).toBe(true);
|
||||
// and on y: rect covers y in [0, 5].
|
||||
expect(overlaps(0, 6, 1, 1, rect)).toBe(false);
|
||||
expect(overlaps(0, 5, 1, 1, rect)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a rect as half-open for single tiles', () => {
|
||||
expect(rectHasTile(rect, -3, 0)).toBe(true); // origin corner is inside
|
||||
expect(rectHasTile(rect, 1, 5)).toBe(true); // last tile is inside
|
||||
expect(rectHasTile(rect, 2, 5)).toBe(false); // one past on x
|
||||
expect(rectHasTile(rect, 1, 6)).toBe(false); // one past on y
|
||||
expect(rectHasTile(rect, -4, 0)).toBe(false); // one before on x
|
||||
});
|
||||
|
||||
it('a zero-area rect freezes nothing', () => {
|
||||
expect(overlaps(0, 0, 1, 1, { x: 0, y: 0, w: 0, h: 0 })).toBe(false);
|
||||
expect(rectHasTile({ x: 0, y: 0, w: 0, h: 0 }, 0, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the glide: shortest-path turning', () => {
|
||||
it('turns the SHORT way across the ±pi seam', () => {
|
||||
// from just under +pi to just over -pi is a 0.2rad turn, not a 6.08rad spin.
|
||||
const from = Math.PI - 0.1;
|
||||
const to = -Math.PI + 0.1;
|
||||
const stepped = angleTo(from, to, 1);
|
||||
// Full step lands on `to` modulo a full turn, and the move taken is small.
|
||||
expect(Math.abs(stepped - from)).toBeLessThan(0.25);
|
||||
expect(Math.abs(Math.atan2(Math.sin(stepped - to), Math.cos(stepped - to)))).toBeLessThan(1e-9);
|
||||
});
|
||||
|
||||
it('interpolates proportionally', () => {
|
||||
expect(angleTo(0, 1, 0.5)).toBeCloseTo(0.5, 10);
|
||||
expect(angleTo(0, 1, 0)).toBeCloseTo(0, 10);
|
||||
expect(angleTo(0, 1, 1)).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
it('never takes the long way round for any pair of angles', () => {
|
||||
for (let a = -Math.PI; a < Math.PI; a += 0.13) {
|
||||
for (let b = -Math.PI; b < Math.PI; b += 0.17) {
|
||||
const moved = Math.abs(angleTo(a, b, 1) - a);
|
||||
expect(moved).toBeLessThanOrEqual(Math.PI + 1e-9);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a partial step always moves, so a turning unit never freezes a frame', () => {
|
||||
// THE INVERSION: authority does not stall. Any non-zero k on a non-zero delta moves.
|
||||
for (const k of [0.01, 0.1, 0.5]) {
|
||||
expect(angleTo(0, 0.4, k)).toBeGreaterThan(0);
|
||||
expect(angleTo(0, -0.4, k)).toBeLessThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
754
fktry/src/render/correction.ts
Normal file
754
fktry/src/render/correction.ts
Normal file
@ -0,0 +1,754 @@
|
||||
/**
|
||||
* LANE-RENDER — THE CORRECTION, rungs 2-4 (§5). Checksum wardens, redundancy gunships,
|
||||
* hash auditors: the Bitstream's immune system, embodied.
|
||||
*
|
||||
* ── THE INVERSION ────────────────────────────────────────────────────────────────────
|
||||
* The style guide (§8) assigns wrong-motion to CORRUPTION, not to authority: "clean
|
||||
* things move at 60fps; corrupted things move wrong on purpose". Everything the player
|
||||
* owns therefore keeps its judder vocabulary — belts step at 30tps, harassed machines
|
||||
* stutter, a throttled machine labours, a scrammed one is dead-dark. The Correction is
|
||||
* the only thing in this world that never misses a frame.
|
||||
*
|
||||
* So these units are the ONE layer that is smoothly interpolated end to end: tick-to-tick
|
||||
* lerp on `alpha` (like belt cargo), then a short critically-damped follow on top so even
|
||||
* a sim that relocates them in whole-tile steps renders as continuous velocity rather than
|
||||
* a polyline with visible corners. Nothing here is quantised, ever. It is unbothered.
|
||||
*
|
||||
* The HASH AUDITOR is the deliberate exception, and it is the codex's own: §5 says it
|
||||
* "moves only between frames". It does not glide — it is perfectly, unnervingly STILL,
|
||||
* and relocates instantly. Stillness is not judder; it is the same claim of total control
|
||||
* from the other side.
|
||||
*
|
||||
* ── WHAT THIS LAYER OWNS ─────────────────────────────────────────────────────────────
|
||||
* bodies — placeholder silhouettes for all three rungs (hospital-white, matte,
|
||||
* spotless, one green element each; no GLBs this round)
|
||||
* mirror — the gunship's `rect` re-rendered as a ghostly white duplicate of the
|
||||
* sector, hovering under it, plus the parity tether to its anchor
|
||||
* stasis — the auditor's `rect`: bleached ground, a floating 00:00:00:00 timecode,
|
||||
* and the frozen-entity set that EntityLayer and BeltItemLayer read
|
||||
*
|
||||
* Sim is truth. `snapshot.correction` may be absent (SIM lands rungs 2-4 in parallel with
|
||||
* this) — everything here no-ops cleanly on undefined.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { CorrectionUnit, MachineDef, SimSnapshot, Vec2 } from '../contracts';
|
||||
import { CORRECTION, HEIGHT_BY_KIND } from './palette';
|
||||
import { ISO_YAW } from './camera';
|
||||
import { centerOf, disposeObject, rotatedFootprint } from './coords';
|
||||
import type { Juice } from './juice';
|
||||
|
||||
/** Hard cap. The Correction escalates; it does not swarm. */
|
||||
const MAX_UNITS = 24;
|
||||
/** Ghost boxes in a mirrored sector — one per entity, instanced. */
|
||||
const MAX_MIRROR = 512;
|
||||
|
||||
/**
|
||||
* Follow time-constant for the glide, in seconds. Small enough that the unit is never
|
||||
* meaningfully behind the sim (~1.5 ticks), large enough to round off the velocity
|
||||
* discontinuity at a tick boundary. CONTRACTS: "sim owns the truth, renderer owns the
|
||||
* smoothness" — stated there for belts, and this is the same licence.
|
||||
*/
|
||||
const GLIDE_TAU = 0.055;
|
||||
|
||||
const HOVER_Y = 2.65; // gunship cruise altitude
|
||||
const MIRROR_Y = 1.5; // the ghost sector hangs between the floor and the gunship
|
||||
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/** Matte, spotless, unweathered. Faintly self-lit so white stays white on a black floor. */
|
||||
function shellMat(color: number = CORRECTION.shell): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: 0xe8ecf8,
|
||||
emissiveIntensity: 0.2,
|
||||
roughness: 0.62,
|
||||
metalness: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
function greenMat(intensity = 1.5): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color: CORRECTION.green,
|
||||
emissive: CORRECTION.green,
|
||||
emissiveIntensity: intensity,
|
||||
roughness: 0.3,
|
||||
metalness: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
/** A crisp 00:00:00:00 on transparent ground — the stasis lock's frozen timecode. */
|
||||
function timecodeTexture(): THREE.CanvasTexture {
|
||||
const w = 256;
|
||||
const h = 64;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w;
|
||||
cv.height = h;
|
||||
const ctx = cv.getContext('2d')!;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.10)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(1.5, 1.5, w - 3, h - 3);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 38px "Courier New", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('00:00:00:00', w / 2, h / 2 + 2);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
interface UnitRec {
|
||||
kind: CorrectionUnit['kind'];
|
||||
obj: THREE.Object3D;
|
||||
/** Per-frame hook: phase-driven motion (stamp arm, rotors, scan cone). */
|
||||
animate: (t: number, phase: string, progress: number) => void;
|
||||
/** Tick-window positions for the glide — same shape as belt-cargo interpolation. */
|
||||
prevX: number; prevY: number; curX: number; curY: number;
|
||||
/** Smoothed render position (world units). */
|
||||
sx: number; sz: number;
|
||||
seeded: boolean;
|
||||
}
|
||||
|
||||
export class CorrectionLayer {
|
||||
readonly group = new THREE.Group();
|
||||
/**
|
||||
* Entity ids the auditor has frozen. EntityLayer stops their idle/animation and
|
||||
* BeltItemLayer pins their cargo mid-tile. Rebuilt each sync; read after sync().
|
||||
*/
|
||||
readonly frozen = new Set<number>();
|
||||
|
||||
private units = new Map<number, UnitRec>();
|
||||
private seen = new Set<number>();
|
||||
private lastTick = -1;
|
||||
|
||||
// --- gunship mirror -----------------------------------------------------------
|
||||
private mirror: THREE.InstancedMesh;
|
||||
private mirrorPads: THREE.Mesh[] = [];
|
||||
private padPool: THREE.Mesh[] = [];
|
||||
private tethers: THREE.Mesh[] = [];
|
||||
private tetherPool: THREE.Mesh[] = [];
|
||||
|
||||
// --- auditor stasis -----------------------------------------------------------
|
||||
private bleaches: THREE.Object3D[] = [];
|
||||
private bleachPool: THREE.Object3D[] = [];
|
||||
|
||||
/**
|
||||
* Stasis rects announced by the `stasis` event, keyed by lockHash. Unioned with any
|
||||
* auditor whose `phase` is 'stasis' — SIM defines the phase strings, so keying the
|
||||
* freeze off a string this lane does not own would be a silent single point of failure.
|
||||
* Either source alone lights the region; both together agree.
|
||||
*/
|
||||
private eventRects = new Map<string, Rect>();
|
||||
private activeStasis: Rect[] = [];
|
||||
|
||||
private dummy = new THREE.Object3D();
|
||||
private timecodeMat: THREE.MeshBasicMaterial;
|
||||
private v = new THREE.Vector3();
|
||||
|
||||
constructor(private defs: Map<string, MachineDef>, private juice?: Juice) {
|
||||
this.group.name = 'correction';
|
||||
|
||||
// The ghost sector: one instanced translucent box per entity in the rect. Depth-write
|
||||
// off so the ghosts stack into a haze instead of z-fighting each other.
|
||||
const mirrorMat = new THREE.MeshBasicMaterial({
|
||||
color: CORRECTION.ghost,
|
||||
transparent: true,
|
||||
opacity: 0.17,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
this.mirror = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), mirrorMat, MAX_MIRROR);
|
||||
this.mirror.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
this.mirror.frustumCulled = false;
|
||||
this.mirror.count = 0;
|
||||
this.group.add(this.mirror);
|
||||
|
||||
this.timecodeMat = new THREE.MeshBasicMaterial({
|
||||
map: timecodeTexture(),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ events
|
||||
|
||||
/** `stasis` on/off. Release "snaps back hard": the bleach is gone the same frame. */
|
||||
onStasis(rect: Rect, on: boolean, lockHash: string): void {
|
||||
if (on) {
|
||||
this.eventRects.set(lockHash, rect);
|
||||
} else if (this.eventRects.delete(lockHash)) {
|
||||
// No fade. A lock does not ease off — it is simply over, and the world resumes at
|
||||
// full speed on the next frame. The flash is the only softening it gets.
|
||||
this.snapBack(rect);
|
||||
}
|
||||
}
|
||||
|
||||
/** The hard release: a white slap at the corners and centre of the un-frozen region. */
|
||||
private snapBack(r: Rect): void {
|
||||
if (!this.juice) return;
|
||||
this.juice.stasisRelease(r.x + r.w / 2, r.y + r.h / 2);
|
||||
this.juice.stasisRelease(r.x, r.y);
|
||||
this.juice.stasisRelease(r.x + r.w, r.y);
|
||||
this.juice.stasisRelease(r.x, r.y + r.h);
|
||||
this.juice.stasisRelease(r.x + r.w, r.y + r.h);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ frame
|
||||
|
||||
/**
|
||||
* @param alpha 0..1 into the next tick — the glide's interpolant.
|
||||
* @param camera used only to face the timecode; the units themselves never billboard.
|
||||
*/
|
||||
sync(snap: SimSnapshot, alpha: number, timeSec: number, dt: number, camera: THREE.Camera): void {
|
||||
const advanced = snap.tick !== this.lastTick;
|
||||
if (advanced) this.lastTick = snap.tick;
|
||||
|
||||
const state = snap.correction;
|
||||
const list = state?.units ?? [];
|
||||
|
||||
// --- bodies ---------------------------------------------------------------
|
||||
this.seen.clear();
|
||||
let n = 0;
|
||||
for (const u of list) {
|
||||
if (n++ >= MAX_UNITS) break;
|
||||
this.seen.add(u.id);
|
||||
let rec = this.units.get(u.id);
|
||||
if (rec && rec.kind !== u.kind) {
|
||||
this.dropUnit(u.id, rec); // a unit id reused for a different rung
|
||||
rec = undefined;
|
||||
}
|
||||
if (!rec) {
|
||||
rec = this.buildUnit(u);
|
||||
this.units.set(u.id, rec);
|
||||
this.group.add(rec.obj);
|
||||
}
|
||||
this.driveUnit(rec, u, alpha, timeSec, dt, advanced);
|
||||
}
|
||||
for (const [id, rec] of this.units) {
|
||||
if (!this.seen.has(id)) this.dropUnit(id, rec);
|
||||
}
|
||||
|
||||
// --- sectors: the gunship's mirror, the auditor's stasis --------------------
|
||||
this.activeStasis.length = 0;
|
||||
for (const r of this.eventRects.values()) this.activeStasis.push(r);
|
||||
for (const u of list) {
|
||||
if (u.kind === 'hash-auditor' && u.rect && u.phase === 'stasis') {
|
||||
if (!this.activeStasis.some((r) => sameRect(r, u.rect!))) this.activeStasis.push(u.rect);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncMirrors(snap, list, timeSec);
|
||||
this.syncStasis(camera);
|
||||
|
||||
// --- the frozen set EntityLayer and BeltItemLayer read ----------------------
|
||||
this.frozen.clear();
|
||||
if (this.activeStasis.length) {
|
||||
for (const e of snap.entities) {
|
||||
const def = this.defs.get(e.def);
|
||||
if (!def) continue;
|
||||
const f = rotatedFootprint(def, e.dir);
|
||||
for (const r of this.activeStasis) {
|
||||
if (overlaps(e.pos.x, e.pos.y, f.x, f.y, r)) { this.frozen.add(e.id); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True if any region is currently locked — a cheap early-out for callers. */
|
||||
get stasisActive(): boolean {
|
||||
return this.activeStasis.length > 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the glide
|
||||
|
||||
private driveUnit(
|
||||
rec: UnitRec, u: CorrectionUnit, alpha: number, timeSec: number, dt: number, advanced: boolean,
|
||||
): void {
|
||||
const tx = u.pos.x + 0.5;
|
||||
const tz = u.pos.y + 0.5;
|
||||
if (!rec.seeded) {
|
||||
rec.prevX = rec.curX = tx;
|
||||
rec.prevY = rec.curY = tz;
|
||||
rec.sx = tx;
|
||||
rec.sz = tz;
|
||||
rec.seeded = true;
|
||||
} else if (advanced) {
|
||||
rec.prevX = rec.curX;
|
||||
rec.prevY = rec.curY;
|
||||
rec.curX = tx;
|
||||
rec.curY = tz;
|
||||
} else {
|
||||
// Same tick, position changed anyway (a sim that mutates between drains): follow it.
|
||||
rec.curX = tx;
|
||||
rec.curY = tz;
|
||||
}
|
||||
|
||||
if (rec.kind === 'hash-auditor') {
|
||||
// §5: "moves only between frames". No interpolation, no easing, no drift — it is
|
||||
// simply somewhere else now. The blink is handled by the body's own animate().
|
||||
const moved = Math.abs(rec.sx - tx) > 1e-3 || Math.abs(rec.sz - tz) > 1e-3;
|
||||
rec.sx = tx;
|
||||
rec.sz = tz;
|
||||
if (moved && rec.obj.userData.teleport) (rec.obj.userData.teleport as () => void)();
|
||||
} else {
|
||||
// Tick-to-tick lerp, then a short damped follow. The lerp is exact where the sim
|
||||
// moves smoothly; the follow rounds the corner where it steps. Frame-rate
|
||||
// independent (dt is already clamped upstream), so a hitch cannot overshoot.
|
||||
const gx = rec.prevX + (rec.curX - rec.prevX) * alpha;
|
||||
const gz = rec.prevY + (rec.curY - rec.prevY) * alpha;
|
||||
const k = 1 - Math.exp(-dt / GLIDE_TAU);
|
||||
rec.sx += (gx - rec.sx) * k;
|
||||
rec.sz += (gz - rec.sz) * k;
|
||||
// Face travel. Only when actually moving, or a parked unit spins on numeric noise.
|
||||
const dx = gx - rec.sx;
|
||||
const dz = gz - rec.sz;
|
||||
if (dx * dx + dz * dz > 1e-6) {
|
||||
const want = Math.atan2(dx, dz);
|
||||
rec.obj.rotation.y = angleTo(rec.obj.rotation.y, want, Math.min(1, dt * 6));
|
||||
}
|
||||
}
|
||||
|
||||
rec.obj.position.x = rec.sx;
|
||||
rec.obj.position.z = rec.sz;
|
||||
rec.animate(timeSec, u.phase, u.progress);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ mirrors
|
||||
|
||||
private syncMirrors(snap: SimSnapshot, list: ReadonlyArray<CorrectionUnit>, timeSec: number): void {
|
||||
let ghosts = 0;
|
||||
let pads = 0;
|
||||
let teths = 0;
|
||||
|
||||
for (const u of list) {
|
||||
if (u.kind !== 'redundancy-gunship') continue;
|
||||
const rec = this.units.get(u.id);
|
||||
|
||||
if (u.rect) {
|
||||
// The sector floor: a faint white pad marking exactly what is being mirrored.
|
||||
const pad = this.takePad(pads++);
|
||||
pad.position.set(u.rect.x + u.rect.w / 2, 0.035, u.rect.y + u.rect.h / 2);
|
||||
pad.scale.set(u.rect.w, u.rect.h, 1);
|
||||
|
||||
// The duplicate itself: every entity inside the rect, re-rendered as a
|
||||
// featureless white ghost, hanging beneath the gunship. Your own layout,
|
||||
// sanitised (§5) — a box is exactly the right amount of "sanitised".
|
||||
for (const e of snap.entities) {
|
||||
if (ghosts >= MAX_MIRROR) break;
|
||||
const def = this.defs.get(e.def);
|
||||
if (!def) continue;
|
||||
const f = rotatedFootprint(def, e.dir);
|
||||
if (!overlaps(e.pos.x, e.pos.y, f.x, f.y, u.rect)) continue;
|
||||
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
|
||||
centerOf(e.pos, def, e.dir, this.v);
|
||||
// A slow vertical breath on the whole copy, so it reads as a projection rather
|
||||
// than as scenery. Uniform across the sector: one object, not per-machine life.
|
||||
const lift = MIRROR_Y + Math.sin(timeSec * 0.9) * 0.06;
|
||||
this.dummy.position.set(this.v.x, lift + h / 2, this.v.z);
|
||||
this.dummy.rotation.set(0, 0, 0);
|
||||
this.dummy.scale.set(Math.max(0.5, f.x * 0.9), h, Math.max(0.5, f.y * 0.9));
|
||||
this.dummy.updateMatrix();
|
||||
this.mirror.setMatrixAt(ghosts++, this.dummy.matrix);
|
||||
}
|
||||
}
|
||||
|
||||
// The parity tether: gunship → its anchor. §5 says killing the link pops the copy,
|
||||
// so the link has to be a thing you can look at and want to cut.
|
||||
if (u.target !== undefined && rec) {
|
||||
const anchor = snap.entities.find((e) => e.id === u.target);
|
||||
const def = anchor ? this.defs.get(anchor.def) : undefined;
|
||||
if (anchor && def) {
|
||||
centerOf(anchor.pos, def, anchor.dir, this.v);
|
||||
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
|
||||
this.aimTether(
|
||||
this.takeTether(teths++),
|
||||
rec.obj.position.x, HOVER_Y - 0.18, rec.obj.position.z,
|
||||
this.v.x, h, this.v.z,
|
||||
timeSec,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.mirror.count = ghosts;
|
||||
this.mirror.instanceMatrix.needsUpdate = true;
|
||||
this.retire(this.mirrorPads, this.padPool, pads);
|
||||
this.retire(this.tethers, this.tetherPool, teths);
|
||||
}
|
||||
|
||||
/** Stretch + orient a unit-height cylinder between two points. */
|
||||
private aimTether(
|
||||
t: THREE.Mesh, ax: number, ay: number, az: number,
|
||||
bx: number, by: number, bz: number, timeSec: number,
|
||||
): void {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const dz = bz - az;
|
||||
const len = Math.hypot(dx, dy, dz) || 0.001;
|
||||
t.position.set((ax + bx) / 2, (ay + by) / 2, (az + bz) / 2);
|
||||
t.scale.set(1, len, 1);
|
||||
// Cylinder geometry runs along +Y; rotate that onto the segment.
|
||||
this.v.set(dx / len, dy / len, dz / len);
|
||||
t.quaternion.setFromUnitVectors(UP, this.v);
|
||||
// A data pulse running down the link, so it reads as live rather than structural.
|
||||
const m = t.material as THREE.MeshStandardMaterial;
|
||||
m.emissiveIntensity = 1.1 + Math.sin(timeSec * 5) * 0.45;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ stasis
|
||||
|
||||
private syncStasis(camera: THREE.Camera): void {
|
||||
let n = 0;
|
||||
for (const r of this.activeStasis) {
|
||||
const b = this.takeBleach(n++);
|
||||
b.position.set(r.x + r.w / 2, 0, r.y + r.h / 2);
|
||||
const fill = b.children[0];
|
||||
fill.scale.set(r.w, r.h, 1);
|
||||
const edge = b.children[1];
|
||||
edge.scale.set(r.w, r.h, 1);
|
||||
// 00:00:00:00, hanging over the locked region, facing the (fixed) camera azimuth.
|
||||
const tc = b.children[2];
|
||||
tc.position.set(0, 2.3, 0);
|
||||
tc.quaternion.copy(camera.quaternion);
|
||||
}
|
||||
this.retire(this.bleaches, this.bleachPool, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ bodies
|
||||
|
||||
private buildUnit(u: CorrectionUnit): UnitRec {
|
||||
const built =
|
||||
u.kind === 'checksum-warden' ? buildWarden()
|
||||
: u.kind === 'redundancy-gunship' ? buildGunship()
|
||||
: buildAuditor();
|
||||
return {
|
||||
kind: u.kind,
|
||||
obj: built.obj,
|
||||
animate: built.animate,
|
||||
prevX: 0, prevY: 0, curX: 0, curY: 0, sx: 0, sz: 0,
|
||||
seeded: false,
|
||||
};
|
||||
}
|
||||
|
||||
private dropUnit(id: number, rec: UnitRec): void {
|
||||
this.group.remove(rec.obj);
|
||||
disposeObject(rec.obj);
|
||||
this.units.delete(id);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pooling
|
||||
|
||||
private takePad(i: number): THREE.Mesh {
|
||||
let m = this.mirrorPads[i];
|
||||
if (!m) {
|
||||
m = this.padPool.pop() as THREE.Mesh | undefined ?? new THREE.Mesh(
|
||||
PLANE,
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: CORRECTION.ghost, transparent: true, opacity: 0.07,
|
||||
depthWrite: false, side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
m.rotation.x = -Math.PI / 2;
|
||||
m.renderOrder = 3;
|
||||
this.group.add(m);
|
||||
this.mirrorPads[i] = m;
|
||||
}
|
||||
m.visible = true;
|
||||
return m;
|
||||
}
|
||||
|
||||
private takeTether(i: number): THREE.Mesh {
|
||||
let m = this.tethers[i];
|
||||
if (!m) {
|
||||
m = this.tetherPool.pop() as THREE.Mesh | undefined ?? new THREE.Mesh(TETHER_GEO, greenMat(1.2));
|
||||
this.group.add(m);
|
||||
this.tethers[i] = m;
|
||||
}
|
||||
m.visible = true;
|
||||
return m;
|
||||
}
|
||||
|
||||
private takeBleach(i: number): THREE.Object3D {
|
||||
let g = this.bleaches[i];
|
||||
if (!g) {
|
||||
g = this.bleachPool.pop() ?? this.buildBleach();
|
||||
this.group.add(g);
|
||||
this.bleaches[i] = g;
|
||||
}
|
||||
g.visible = true;
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* A locked region. Deliberately, totally STATIC: no pulse, no scroll, no breathing.
|
||||
* Every other overlay in this game moves; the one that doesn't is the one that stopped
|
||||
* time. The absence of animation IS the effect.
|
||||
*/
|
||||
private buildBleach(): THREE.Object3D {
|
||||
const g = new THREE.Group();
|
||||
const fill = new THREE.Mesh(
|
||||
PLANE,
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: CORRECTION.ghost, transparent: true, opacity: 0.26,
|
||||
depthWrite: false, side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
fill.rotation.x = -Math.PI / 2;
|
||||
fill.position.y = 0.045;
|
||||
fill.renderOrder = 4;
|
||||
|
||||
const edge = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(PLANE),
|
||||
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 }),
|
||||
);
|
||||
edge.rotation.x = -Math.PI / 2;
|
||||
edge.position.y = 0.05;
|
||||
|
||||
const tc = new THREE.Mesh(PLANE, this.timecodeMat);
|
||||
tc.scale.set(3.2, 0.8, 1);
|
||||
tc.renderOrder = 5;
|
||||
|
||||
g.add(fill, edge, tc);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Hide + park anything beyond `used`, keeping the objects for the next frame. */
|
||||
private retire<T extends THREE.Object3D>(live: T[], pool: T[], used: number): void {
|
||||
for (let i = used; i < live.length; i++) {
|
||||
live[i].visible = false;
|
||||
pool.push(live[i]);
|
||||
this.group.remove(live[i]);
|
||||
}
|
||||
live.length = used;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared geometry
|
||||
|
||||
const UP = new THREE.Vector3(0, 1, 0);
|
||||
const PLANE = new THREE.PlaneGeometry(1, 1);
|
||||
/** Unit-height, so `scale.y = length` stretches it between two points. */
|
||||
const TETHER_GEO = new THREE.CylinderGeometry(0.035, 0.035, 1, 6);
|
||||
|
||||
interface Built {
|
||||
obj: THREE.Object3D;
|
||||
animate: (t: number, phase: string, progress: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CHECKSUM WARDEN (§5): "tall white filing-cabinet golem, drawer-chest full of hashes,
|
||||
* rubber-stamp fist, red LED ledger eyes". The green element is the STAMP PAD — what it
|
||||
* does to you is the seal, and the seal is what appears on your chassis afterwards, so
|
||||
* the two read as the same object. The ledger eyes stay red per the codex, as pinpoints.
|
||||
*/
|
||||
function buildWarden(): Built {
|
||||
const g = new THREE.Group();
|
||||
const shell = shellMat();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.86, 2.0, 0.68), shell);
|
||||
body.position.y = 1.0;
|
||||
body.castShadow = body.receiveShadow = true;
|
||||
g.add(body);
|
||||
|
||||
// Drawer breaks: a white box with no edges is a white smear at iso distance.
|
||||
//
|
||||
// The detail face is local +Z, NOT -Z. Placeholders elsewhere in this lane are authored
|
||||
// facing north (-Z) because machines carry a `dir`; a Correction unit instead yaws to
|
||||
// face its own travel, and a parked one sits at yaw 0. With the drawers on -Z, a parked
|
||||
// warden showed the iso camera a blank white back — verified in `?showroom`, which is
|
||||
// exactly the case the bay exists to catch. +Z at yaw 0 faces south, into the camera.
|
||||
const drawerMat = shellMat(CORRECTION.shellDark);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const d = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.035, 0.02), drawerMat);
|
||||
d.position.set(0, 0.45 + i * 0.36, 0.35);
|
||||
g.add(d);
|
||||
}
|
||||
|
||||
// Red LED ledger eyes — a reading strip, not a face.
|
||||
const ledMat = new THREE.MeshStandardMaterial({
|
||||
color: CORRECTION.ledger, emissive: CORRECTION.ledger, emissiveIntensity: 1.6, roughness: 0.4,
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const led = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.05, 0.02), ledMat);
|
||||
led.position.set(-0.2 + i * 0.1, 1.82, 0.35);
|
||||
g.add(led);
|
||||
}
|
||||
|
||||
// The stamp arm: shoulder, forearm, and the green pad it presses with.
|
||||
const arm = new THREE.Group();
|
||||
arm.position.set(0.48, 1.55, 0);
|
||||
const upper = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.62, 0.14), shell);
|
||||
upper.position.y = -0.31;
|
||||
arm.add(upper);
|
||||
const padMat = greenMat(1.7);
|
||||
const pad = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.1, 0.26), padMat);
|
||||
pad.position.y = -0.67;
|
||||
arm.add(pad);
|
||||
g.add(arm);
|
||||
|
||||
const animate = (t: number, phase: string): void => {
|
||||
// Auditing = stamping: a fast, mechanical, utterly repeatable slam. Otherwise the arm
|
||||
// rests, cocked. Note the stamp is SMOOTH — the Correction does not judder even when
|
||||
// it is hitting something.
|
||||
const auditing = phase === 'audit' || phase === 'stamp';
|
||||
const rate = auditing ? 3.2 : 0.9;
|
||||
const swing = auditing ? 1.15 : 0.12;
|
||||
const s = (Math.sin(t * rate) + 1) * 0.5;
|
||||
arm.rotation.x = -0.15 - Math.pow(s, 3) * swing;
|
||||
padMat.emissiveIntensity = auditing ? 1.4 + s * 1.6 : 1.2;
|
||||
};
|
||||
return { obj: g, animate };
|
||||
}
|
||||
|
||||
/**
|
||||
* REDUNDANCY GUNSHIP (§5): "hovering white chassis of five mirrored disk-platter rotors
|
||||
* in a row, projecting a ghostly duplicate landscape beneath itself". Matte white per the
|
||||
* round-7 uniform rather than chrome — five actually-mirrored surfaces on a black-sky
|
||||
* world read as five black holes. The green element is the parity emitter on its belly,
|
||||
* which is also where the tether starts.
|
||||
*/
|
||||
function buildGunship(): Built {
|
||||
const g = new THREE.Group();
|
||||
const shell = shellMat();
|
||||
const chassis = new THREE.Mesh(new THREE.BoxGeometry(2.7, 0.26, 0.78), shell);
|
||||
chassis.position.y = HOVER_Y;
|
||||
chassis.castShadow = true;
|
||||
g.add(chassis);
|
||||
|
||||
// Five platters. "Mirrored" is expressed as counter-rotating pairs about the centre —
|
||||
// a RAID array's redundancy, made kinetic.
|
||||
//
|
||||
// Radius is deliberately SMALLER than half the spacing (0.26 vs 0.27). A real drive's
|
||||
// platters nearly touch, and authored that way the five of them fused into one white
|
||||
// lozenge at game zoom — the count is the silhouette, so the count has to survive. The
|
||||
// visible gap between discs is what makes it read as an array rather than a blob.
|
||||
const platters: THREE.Mesh[] = [];
|
||||
const platterGeo = new THREE.CylinderGeometry(0.26, 0.26, 0.05, 18);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const spindle = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.26, 6), shell);
|
||||
spindle.position.set(-1.08 + i * 0.54, HOVER_Y + 0.25, 0);
|
||||
g.add(spindle);
|
||||
const p = new THREE.Mesh(platterGeo, shellMat(CORRECTION.shellDark));
|
||||
p.position.set(-1.08 + i * 0.54, HOVER_Y + 0.38, 0);
|
||||
p.castShadow = true;
|
||||
g.add(p);
|
||||
platters.push(p);
|
||||
}
|
||||
|
||||
// Parity emitter: the tether's origin, and the thing you are meant to want to break.
|
||||
const emitterMat = greenMat(1.8);
|
||||
const emitter = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.22, 10), emitterMat);
|
||||
emitter.position.set(0, HOVER_Y - 0.2, 0);
|
||||
emitter.rotation.x = Math.PI;
|
||||
g.add(emitter);
|
||||
|
||||
const animate = (t: number, phase: string, progress: number): void => {
|
||||
// Spin-up tracks the phase: idling while it approaches, screaming while it mirrors.
|
||||
const busy = phase === 'tether' || phase === 'mirror';
|
||||
const rpm = busy ? 9 + progress * 5 : 3.5;
|
||||
for (let i = 0; i < platters.length; i++) {
|
||||
// Counter-rotating about the centre platter: 0,1 one way, 3,4 the other.
|
||||
const sign = i < 2 ? 1 : i > 2 ? -1 : 0.4;
|
||||
platters[i].rotation.y = t * rpm * sign;
|
||||
}
|
||||
// Hover: continuous, never stepped. The one place a sine wave IS the correct motion.
|
||||
g.position.y = Math.sin(t * 1.35) * 0.09;
|
||||
emitterMat.emissiveIntensity = busy ? 1.6 + Math.sin(t * 7) * 0.5 : 1.1;
|
||||
};
|
||||
return { obj: g, animate };
|
||||
}
|
||||
|
||||
/**
|
||||
* HASH AUDITOR (§5): "faceless white-marble cinema usher, flashlight that projects an XML
|
||||
* scroll, padlock icon halo, moves only between frames".
|
||||
*
|
||||
* It is the only body in this game with NO idle animation at all. Not a bob, not a pulse,
|
||||
* not a drifting halo — a still photograph of a column, which relocates between frames.
|
||||
* Everything else on screen is alive; the thing that isn't is the thing to be afraid of.
|
||||
* The scan cone is binary (present during prescan, absent otherwise), never animated.
|
||||
*/
|
||||
function buildAuditor(): Built {
|
||||
const g = new THREE.Group();
|
||||
// Marble: paler and smoother than the other two shells, and truly matte.
|
||||
const marble = new THREE.MeshStandardMaterial({
|
||||
color: 0xfbfcff, emissive: 0xe6ebf7, emissiveIntensity: 0.24, roughness: 0.42, metalness: 0.0,
|
||||
});
|
||||
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.5, 0.16, 16), marble);
|
||||
base.position.y = 0.08;
|
||||
base.castShadow = base.receiveShadow = true;
|
||||
g.add(base);
|
||||
const column = new THREE.Mesh(new THREE.CylinderGeometry(0.26, 0.32, 1.65, 16), marble);
|
||||
column.position.y = 0.98;
|
||||
column.castShadow = true;
|
||||
g.add(column);
|
||||
// Faceless head: a smooth capsule, no features of any kind.
|
||||
const head = new THREE.Mesh(new THREE.CapsuleGeometry(0.24, 0.16, 4, 14), marble);
|
||||
head.position.y = 1.98;
|
||||
head.castShadow = true;
|
||||
g.add(head);
|
||||
|
||||
// The padlock halo — the one green element, and the one thing that says "refused".
|
||||
const halo = new THREE.Mesh(new THREE.TorusGeometry(0.34, 0.035, 8, 22), greenMat(1.9));
|
||||
halo.position.y = 2.34;
|
||||
halo.rotation.x = Math.PI / 2;
|
||||
g.add(halo);
|
||||
const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.1, 0.022, 6, 14, Math.PI), greenMat(1.6));
|
||||
shackle.position.y = 2.42;
|
||||
g.add(shackle);
|
||||
|
||||
// The flashlight's XML scroll, as a scan cone. Present or absent — never sweeping.
|
||||
const cone = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.95, 1.9, 14, 1, true),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: CORRECTION.green, transparent: true, opacity: 0.09,
|
||||
depthWrite: false, side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
cone.position.y = 0.95;
|
||||
cone.visible = false;
|
||||
g.add(cone);
|
||||
|
||||
// Teleport: gone, then here. Two frames of absence is enough to feel like a cut.
|
||||
let blink = 0;
|
||||
g.userData.teleport = () => { blink = 2; };
|
||||
|
||||
const animate = (_t: number, phase: string): void => {
|
||||
if (blink > 0) {
|
||||
blink--;
|
||||
g.visible = false;
|
||||
return;
|
||||
}
|
||||
g.visible = true;
|
||||
cone.visible = phase === 'prescan' || phase === 'scan';
|
||||
};
|
||||
return { obj: g, animate };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/** Footprint (tile-space AABB) against a rect. Half-open, matching the tile convention. */
|
||||
export function overlaps(x: number, y: number, w: number, h: number, r: Rect): boolean {
|
||||
return x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y;
|
||||
}
|
||||
|
||||
/** Tile-point containment — for a single tile rather than a footprint. */
|
||||
export function rectHasTile(r: Rect, x: number, y: number): boolean {
|
||||
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
|
||||
}
|
||||
|
||||
function sameRect(a: Rect, b: Rect): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
|
||||
/** Shortest-path angular lerp, so a unit turning past ±π doesn't spin the long way. */
|
||||
export function angleTo(from: number, to: number, k: number): number {
|
||||
let d = (to - from) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return from + d * k;
|
||||
}
|
||||
|
||||
/** The compliance placard's facing, re-exported so EntityLayer needn't import camera.ts. */
|
||||
export const PLACARD_YAW = ISO_YAW;
|
||||
@ -17,6 +17,7 @@
|
||||
*/
|
||||
import {
|
||||
TICKS_PER_SECOND,
|
||||
type CorrectionState, type CorrectionUnit,
|
||||
type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot,
|
||||
type WildlifeState,
|
||||
} from '../contracts';
|
||||
@ -114,6 +115,22 @@ export class DevScene {
|
||||
target: host?.id,
|
||||
});
|
||||
}
|
||||
// v9 WORST CASE, deliberately: a gunship mirroring the ENTIRE 500-entity build and
|
||||
// an auditor freezing all of it. That is 500 ghost boxes, 500 frozen entities and
|
||||
// 2,000 pinned belt items on top of the standing-rule load — far past anything the
|
||||
// sim will ever produce (SIM sizes sectors to a handful of machines), which is the
|
||||
// point: if the frame holds here it holds anywhere.
|
||||
this.correctionUnits.push(
|
||||
{
|
||||
id: 1, kind: 'redundancy-gunship', pos: { x: -18, y: -10 }, phase: 'tether',
|
||||
progress: 0.7, target: ents[0]?.id, rect: { x: -32, y: -30, w: 32, h: 60 },
|
||||
},
|
||||
{
|
||||
id: 2, kind: 'hash-auditor', pos: { x: -18, y: 6 }, phase: 'stasis', progress: 0.5,
|
||||
rect: { x: -32, y: -30, w: 32, h: 60 },
|
||||
},
|
||||
{ id: 3, kind: 'checksum-warden', pos: { x: -4, y: -10 }, phase: 'audit', progress: 0.3 },
|
||||
);
|
||||
this.ok = true;
|
||||
return;
|
||||
}
|
||||
@ -167,6 +184,28 @@ export class DevScene {
|
||||
{ id: 922, kind: 'parity-mite', pos: { x: 0, y: 5 }, size: 0.7 },
|
||||
);
|
||||
|
||||
// THE CORRECTION, rungs 2-4 (v9). SIM lands these in parallel; until then this is the
|
||||
// only way to look at them. Positions are driven in snapshot() so the GLIDE can be
|
||||
// judged against the juddering belts right next to them — which is the whole point of
|
||||
// the round-7 inversion, and cannot be checked on a static fixture.
|
||||
const gunTarget = this.entities.find((e) => e.def === 'mosh-reactor')
|
||||
?? this.entities.find((e) => e.def === 'quantizer');
|
||||
this.correctionUnits.push(
|
||||
// Rung 2: paces the aisle south of the main line, then stops to stamp.
|
||||
{ id: 1, kind: 'checksum-warden', pos: { x: -12, y: 3 }, phase: 'approach', progress: 0 },
|
||||
// Rung 3: cruises over the machine row, mirroring it, tethered to its anchor.
|
||||
{
|
||||
id: 2, kind: 'redundancy-gunship', pos: { x: -6, y: 8 }, phase: 'tether', progress: 0.6,
|
||||
target: gunTarget?.id, rect: { x: -13, y: 7, w: 12, h: 3 },
|
||||
},
|
||||
// Rung 4: teleports around the bend, and locks it. The rect covers the corner run,
|
||||
// so cargo mid-tile is what freezes — a tile-edge freeze would prove nothing.
|
||||
{
|
||||
id: 3, kind: 'hash-auditor', pos: { x: 2, y: 2 }, phase: 'prescan', progress: 0,
|
||||
rect: { x: -3, y: 0, w: 5, h: 6 },
|
||||
},
|
||||
);
|
||||
|
||||
this.ok = true;
|
||||
}
|
||||
|
||||
@ -190,11 +229,13 @@ export class DevScene {
|
||||
if (e.recipe) e.progress = (tick % 90) / 90;
|
||||
}
|
||||
|
||||
// Cycle mite 922 through hunting → repairing → sated so the eye-glow ramp and the
|
||||
// tidy 'repaired' sparkle (fired on the <1→≥1 crossing) are both visible in the scaffold.
|
||||
// Cycle mite 922 through hunting → repairing → sated so the eye-glow ramp is visible.
|
||||
// (The tidy 'repaired' sparkle now comes from the real event, not this crossing.)
|
||||
const mite = this.wildlife.find((w) => w.id === 922);
|
||||
if (mite) mite.size = (tick % 150) / 100; // 0 → 1.5, crossing 1.0 each loop
|
||||
|
||||
this.driveCorrection(tick);
|
||||
|
||||
const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
|
||||
const perTick = this.speed / TICKS_PER_SECOND;
|
||||
const span = seg.ids.length;
|
||||
@ -233,9 +274,66 @@ export class DevScene {
|
||||
// aim a swarm, or excavate the relic from the console.
|
||||
wildlife: this.wildlife,
|
||||
relics: this.relics,
|
||||
correction: this.correction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the three rungs. The important property under test is MOTION, so:
|
||||
*
|
||||
* - the WARDEN gets a WHOLE-TILE-STEPPED position (it only ever changes on tick
|
||||
* boundaries, and only by integers) — deliberately the WORST case for the renderer,
|
||||
* because if it still glides from that it will glide from anything the sim does;
|
||||
* - the GUNSHIP gets a smooth fractional path, the best case;
|
||||
* - the AUDITOR jumps between three fixed spots and never moves in between.
|
||||
*
|
||||
* It also cycles the auditor prescan → stasis, and seals the quantizer while the warden
|
||||
* is stamping, so the placard and the region freeze are both reachable in a short session.
|
||||
*/
|
||||
private driveCorrection(tick: number): void {
|
||||
const [warden, gunship, auditor] = this.correctionUnits;
|
||||
// 300-tick loop = 10s at 30tps.
|
||||
const p = tick % 300;
|
||||
|
||||
if (warden) {
|
||||
// Integer tiles only: -12 → 2 and back, one tile every 10 ticks.
|
||||
const leg = Math.floor(p / 10) % 28;
|
||||
warden.pos.x = -12 + (leg < 14 ? leg : 28 - leg);
|
||||
warden.pos.y = 3;
|
||||
const stamping = p > 210;
|
||||
warden.phase = stamping ? 'audit' : 'approach';
|
||||
warden.progress = stamping ? (p - 210) / 90 : p / 210;
|
||||
}
|
||||
|
||||
if (gunship) {
|
||||
// Fractional, smooth: a slow sweep across the machine row.
|
||||
gunship.pos.x = -7 + Math.sin(tick / 90) * 5;
|
||||
gunship.pos.y = 8 + Math.cos(tick / 130) * 0.8;
|
||||
gunship.progress = (Math.sin(tick / 60) + 1) / 2;
|
||||
}
|
||||
|
||||
if (auditor) {
|
||||
// Three stations, a jump every 100 ticks, perfect stillness in between.
|
||||
const spot = [
|
||||
{ x: 2, y: 2 }, { x: -4, y: 5 }, { x: 1, y: -3 },
|
||||
][Math.floor(tick / 100) % 3];
|
||||
auditor.pos.x = spot.x;
|
||||
auditor.pos.y = spot.y;
|
||||
// Locked for the middle stretch of each loop, so freeze AND release are both seen.
|
||||
auditor.phase = p > 120 && p < 260 ? 'stasis' : 'prescan';
|
||||
auditor.progress = p / 300;
|
||||
}
|
||||
|
||||
// The compliance seal: stamped while the warden audits, pried off when it leaves.
|
||||
const sealed = p > 210;
|
||||
for (const e of this.entities) {
|
||||
if (e.def === 'quantizer') e.sealed = sealed;
|
||||
}
|
||||
|
||||
this.correction.debt = (Math.sin(tick / 200) + 1) / 2;
|
||||
this.correction.band = (Math.floor(this.correction.debt * 5) % 5) as 0 | 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
/** Mutable so a verification session can unlock techs and watch padlocks clear. */
|
||||
readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] };
|
||||
/** v3-hardened field — present so fixtures match the contract, not just the renderer. */
|
||||
@ -244,4 +342,7 @@ export class DevScene {
|
||||
readonly wildlife: WildlifeState[] = [];
|
||||
/** v6 secret: one buried optical fossil, unfound. Flip `found` to see the monument. */
|
||||
readonly relics: RelicState[] = [{ id: 1, kind: 'optical-fossil', pos: { x: 9, y: -7 }, found: false }];
|
||||
/** v9: THE CORRECTION. Mutable from the console — park a unit, force a phase, hold a lock. */
|
||||
readonly correctionUnits: CorrectionUnit[] = [];
|
||||
readonly correction: CorrectionState = { debt: 0, band: 0, units: this.correctionUnits };
|
||||
}
|
||||
|
||||
@ -10,12 +10,15 @@
|
||||
* scram — heat >= 1: dead-dark, blinking amber until it restarts
|
||||
* jam — blinking amber klaxon
|
||||
* buffer — tank fill from the machine's share of `bandwidth.stored`
|
||||
* sealed — v9: a warden's compliance placard, and every moving part STOPPED
|
||||
* stasis — v9: inside an auditor's lock, a machine is frozen the same way
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { Dir, MachineDef, SimSnapshot } from '../contracts';
|
||||
import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from './registry';
|
||||
import { centerOf, yawFor } from './coords';
|
||||
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
|
||||
import { CORRECTION, HEIGHT_BY_KIND, JAM_AMBER } from './palette';
|
||||
import { PLACARD_YAW } from './correction';
|
||||
import { createHeatHaze, type HeatHaze } from './heat';
|
||||
import type { Juice } from './juice';
|
||||
|
||||
@ -65,9 +68,79 @@ interface Rec {
|
||||
* at s≠1 and hardcoding 1 collapsed the body to a speck (the beacon/jam sit on their own
|
||||
* local scale so they were unaffected, which is why only bodies vanished). */
|
||||
baseScale: number;
|
||||
/** v9: the warden's compliance placard. Lazily built — most machines are never sealed.
|
||||
* Lives on the LAYER group, not on `obj`, so a normalised GLB's 2-3.5x root scale can't
|
||||
* inflate it (same trap the round-6 body-scale regression fell into). */
|
||||
placard: THREE.Mesh | null;
|
||||
/** The instant this machine stopped, so its idle/haze animation holds one pose instead
|
||||
* of resuming from wherever the world clock has got to. */
|
||||
stoppedAt: number;
|
||||
/** Half-extent used to stand the placard clear of the chassis. */
|
||||
radius: number;
|
||||
/** Body height, for placard + haze placement. */
|
||||
height: number;
|
||||
}
|
||||
|
||||
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
|
||||
const PLACARD_GEO = new THREE.PlaneGeometry(0.62, 0.4);
|
||||
|
||||
/**
|
||||
* The compliance seal (§5): a printed notice the warden staples to your chassis. Green
|
||||
* tick, hospital-white card, the word SEALED, and a hash line that means nothing to you.
|
||||
* One canvas for every placard in the world — they are all literally the same form.
|
||||
*/
|
||||
let placardTex: THREE.CanvasTexture | null = null;
|
||||
function placardTexture(): THREE.CanvasTexture {
|
||||
if (placardTex) return placardTex;
|
||||
const w = 248;
|
||||
const h = 160;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w;
|
||||
cv.height = h;
|
||||
const ctx = cv.getContext('2d')!;
|
||||
ctx.fillStyle = '#f4f7fb';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.strokeStyle = '#39ff88';
|
||||
ctx.lineWidth = 7;
|
||||
ctx.strokeRect(3.5, 3.5, w - 7, h - 7);
|
||||
// The tick.
|
||||
ctx.strokeStyle = '#1fbf63';
|
||||
ctx.lineWidth = 15;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(30, 74);
|
||||
ctx.lineTo(56, 102);
|
||||
ctx.lineTo(104, 44);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#12151c';
|
||||
ctx.font = 'bold 34px "Courier New", monospace';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('SEALED', 118, 68);
|
||||
ctx.fillStyle = '#5c6474';
|
||||
ctx.font = '19px "Courier New", monospace';
|
||||
ctx.fillText('PARITY NOTICE', 30, 126);
|
||||
placardTex = new THREE.CanvasTexture(cv);
|
||||
placardTex.colorSpace = THREE.SRGBColorSpace;
|
||||
return placardTex;
|
||||
}
|
||||
|
||||
/** Shared across every placard: they are identical notices, so they are one material. */
|
||||
let placardMat: THREE.MeshStandardMaterial | null = null;
|
||||
function placardMaterial(): THREE.MeshStandardMaterial {
|
||||
placardMat ??= new THREE.MeshStandardMaterial({
|
||||
map: placardTexture(),
|
||||
color: CORRECTION.shell,
|
||||
emissive: 0xffffff,
|
||||
emissiveMap: placardTexture(),
|
||||
emissiveIntensity: 0.5,
|
||||
roughness: 0.8,
|
||||
metalness: 0,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: false,
|
||||
});
|
||||
return placardMat;
|
||||
}
|
||||
|
||||
export class EntityLayer {
|
||||
readonly group = new THREE.Group();
|
||||
@ -90,7 +163,16 @@ export class EntityLayer {
|
||||
this.group.name = 'entities';
|
||||
}
|
||||
|
||||
sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set<number>): void {
|
||||
/**
|
||||
* @param harassed entity ids a mosquito swarm is degrading (they stutter).
|
||||
* @param frozen v9: entity ids inside an auditor's stasis lock. A frozen machine is
|
||||
* treated exactly like a sealed one — every moving part stops — because
|
||||
* both are the same sentence from The Correction: this is not running.
|
||||
*/
|
||||
sync(
|
||||
snap: SimSnapshot, timeSec: number, dt: number,
|
||||
harassed?: ReadonlySet<number>, frozen?: ReadonlySet<number>,
|
||||
): void {
|
||||
this.seen.clear();
|
||||
let anyAlarm = false;
|
||||
|
||||
@ -152,8 +234,18 @@ export class EntityLayer {
|
||||
// desyncing the day they retuned it. The `?? heat >= 1` is only a bridge for a
|
||||
// sim that hasn't wired the field yet; it carries no local constant.
|
||||
const scrammed = e.scrammed ?? heat >= 1;
|
||||
// v9: STOPPED. A sealed machine is a still photograph of itself (the warden's whole
|
||||
// threat is that your factory keeps existing without running), and a machine inside
|
||||
// an auditor's 0:00 lock is stopped for the same reason. Both freeze the idle clock,
|
||||
// the animation mixer AND the heat haze's own time — a frozen machine whose shimmer
|
||||
// kept rising would be a machine that is obviously still working.
|
||||
const stopped = !!e.sealed || (frozen?.has(e.id) ?? false);
|
||||
if (stopped && rec.stoppedAt < 0) rec.stoppedAt = timeSec;
|
||||
else if (!stopped) rec.stoppedAt = -1;
|
||||
const clock = stopped ? rec.stoppedAt : timeSec;
|
||||
|
||||
// Throttle ramps in rather than snapping, so "labouring" reads before "dead".
|
||||
const speed = scrammed
|
||||
const speed = scrammed || stopped
|
||||
? 0
|
||||
: heat > THROTTLE_AT
|
||||
? THREE.MathUtils.lerp(1, THROTTLE_FLOOR, (heat - THROTTLE_AT) / (1 - THROTTLE_AT))
|
||||
@ -164,7 +256,8 @@ export class EntityLayer {
|
||||
rec.mixer?.update(dt * speed);
|
||||
if (def.kind === 'buffer') rec.fill?.(fillLevel);
|
||||
|
||||
this.applyHeat(rec, heat, scrammed, timeSec);
|
||||
this.applyHeat(rec, heat, scrammed, clock);
|
||||
this.applySeal(rec, e.sealed === true, def);
|
||||
|
||||
const alarm = !!e.jammed || scrammed;
|
||||
rec.jam.visible = alarm;
|
||||
@ -183,10 +276,19 @@ export class EntityLayer {
|
||||
}
|
||||
|
||||
// Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8).
|
||||
if (harassed?.has(e.id)) {
|
||||
// A stopped machine does not stutter — nothing about it moves at all.
|
||||
if (harassed?.has(e.id) && !stopped) {
|
||||
rec.obj.position.x += Math.sin(timeSec * 47 + e.id) * 0.012;
|
||||
rec.obj.position.z += Math.cos(timeSec * 53 + e.id) * 0.012;
|
||||
}
|
||||
if (rec.placard?.visible) {
|
||||
// Placards ride on the layer group, so they follow the body by hand.
|
||||
rec.placard.position.set(
|
||||
rec.obj.position.x + Math.sin(PLACARD_YAW) * rec.radius,
|
||||
rec.height * 0.62 + 0.18,
|
||||
rec.obj.position.z + Math.cos(PLACARD_YAW) * rec.radius,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, rec] of this.live) {
|
||||
@ -234,6 +336,28 @@ export class EntityLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The compliance seal. A printed placard, stapled to the chassis, facing the (fixed)
|
||||
* camera azimuth — it is a notice meant to be READ, so it does not respect the machine's
|
||||
* rotation. Built on first seal and then kept: a machine that gets sealed once is likely
|
||||
* to be sealed again, and the mesh is 2 triangles.
|
||||
*/
|
||||
private applySeal(rec: Rec, sealed: boolean, def: MachineDef): void {
|
||||
if (!sealed) {
|
||||
if (rec.placard) rec.placard.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!rec.placard) {
|
||||
const m = new THREE.Mesh(PLACARD_GEO, placardMaterial());
|
||||
m.rotation.y = PLACARD_YAW;
|
||||
m.renderOrder = 3;
|
||||
rec.placard = m;
|
||||
rec.radius = Math.max(def.footprint.x, def.footprint.y) * 0.5 + 0.06;
|
||||
this.group.add(m);
|
||||
}
|
||||
rec.placard.visible = true;
|
||||
}
|
||||
|
||||
private build(id: number, defId: string, def: MachineDef, version: number): Rec {
|
||||
const entry = this.registry.get(def.asset)!;
|
||||
const obj = entry.create();
|
||||
@ -307,6 +431,10 @@ export class EntityLayer {
|
||||
wasScrammed: false,
|
||||
pop: POP_TIME, // settled by default; a real placement rewinds it to 0
|
||||
baseScale: obj.scale.x, // 1 for placeholders; the GLB normalisation scale otherwise
|
||||
placard: null,
|
||||
stoppedAt: -1,
|
||||
radius: Math.max(def.footprint.x, def.footprint.y) * 0.5 + 0.06,
|
||||
height: h,
|
||||
};
|
||||
this.live.set(id, rec);
|
||||
return rec;
|
||||
@ -314,6 +442,12 @@ export class EntityLayer {
|
||||
|
||||
private drop(id: number, rec: Rec): void {
|
||||
this.group.remove(rec.obj);
|
||||
if (rec.placard) {
|
||||
// Geometry and material are module-shared across every placard — remove, never
|
||||
// dispose, or the next sealed machine in the world gets a blank card.
|
||||
this.group.remove(rec.placard);
|
||||
rec.placard = null;
|
||||
}
|
||||
// The jam light and haze use module-shared geometry — detach them before any
|
||||
// disposal walks the subtree and frees it out from under everyone else.
|
||||
rec.obj.remove(rec.jam);
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
* belt items / ghost layers -> ortho iso camera.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { Dir, GameData, MachineDef, Renderer, SimSnapshot, Vec2 } from '../contracts';
|
||||
import type {
|
||||
Dir, GameData, MachineDef, Renderer, SimEvent, SimSnapshot, Vec2,
|
||||
} from '../contracts';
|
||||
import { AssetRegistry } from './registry';
|
||||
import { CAM_DIST, CameraRig, WORLD_HALF } from './camera';
|
||||
import { createGround, createVignette } from './ground';
|
||||
@ -25,6 +27,8 @@ import { BreakRoom } from './breakroom';
|
||||
import { WORLD } from './palette';
|
||||
import { createEnvironment } from './env';
|
||||
import { centerOf } from './coords';
|
||||
import { CorrectionLayer } from './correction';
|
||||
import { NoticePing } from './notice';
|
||||
|
||||
const KEY_INTENSITY = 1.9;
|
||||
const FILL_INTENSITY = 0.7;
|
||||
@ -58,6 +62,8 @@ export function createRenderer(): Renderer {
|
||||
let ghost: GhostLayer;
|
||||
let relics: RelicLayer;
|
||||
let wildlife: WildlifeLayer;
|
||||
let correction: CorrectionLayer;
|
||||
let notice: NoticePing;
|
||||
let juice: Juice;
|
||||
let breakroom: BreakRoom;
|
||||
let seamLayer: SeamLayer | null = null;
|
||||
@ -112,9 +118,22 @@ export function createRenderer(): Renderer {
|
||||
return sel && sel.mode === 'inspect' && typeof sel.entity === 'number' ? sel.entity : null;
|
||||
}
|
||||
|
||||
/** Uplink tile centres, recomputed on demand — machines come and go. */
|
||||
function refreshShippers(view: SimSnapshot): void {
|
||||
shipperCentres.length = 0;
|
||||
for (const e of view.entities) {
|
||||
const d = defs.get(e.def);
|
||||
if (d?.kind !== 'shipper') continue;
|
||||
const c = centerOf(e.pos, d, e.dir);
|
||||
shipperCentres.push({ x: c.x, y: c.z });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A rise in `shippedTotal` is a shipment — flash the uplinks in the shipped item's
|
||||
* colour. Derived from the snapshot because render() never sees events.
|
||||
* colour. Still snapshot-derived: v9's `shipped` event carries item and count but no
|
||||
* position, so the uplink tiles have to come from the snapshot either way, and the
|
||||
* delta is already exact. Left alone deliberately rather than migrated for its own sake.
|
||||
*/
|
||||
function shipmentFlash(view: SimSnapshot): void {
|
||||
let total = 0;
|
||||
@ -129,19 +148,86 @@ export function createRenderer(): Renderer {
|
||||
}
|
||||
if (lastShipped < 0) { lastShipped = total; return; } // first frame: prime, don't flash
|
||||
if (total > lastShipped) {
|
||||
shipperCentres.length = 0;
|
||||
for (const e of view.entities) {
|
||||
const d = defs.get(e.def);
|
||||
if (d?.kind !== 'shipper') continue;
|
||||
const c = centerOf(e.pos, d, e.dir);
|
||||
shipperCentres.push({ x: c.x, y: c.z });
|
||||
}
|
||||
refreshShippers(view);
|
||||
const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f;
|
||||
for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col);
|
||||
}
|
||||
lastShipped = total;
|
||||
}
|
||||
|
||||
/**
|
||||
* v9 EVENT CHANNEL — granted after three rounds of asking (rounds 5, 6, 7-phase-0).
|
||||
*
|
||||
* main.ts calls `onEvents(events)` immediately before `render(snap, alpha)` with the
|
||||
* same array ScreenFX and AudioFX get. We BUFFER rather than act: at onEvents time the
|
||||
* frame's `view` hasn't been resolved yet (showroom/devscene may substitute a different
|
||||
* snapshot), and several cues need to look an entity or a relic up in it. Draining at
|
||||
* the top of render() keeps every cue and the snapshot it reasons about on the same
|
||||
* frame. Bounded — main.ts drains the sim every frame, so this never grows.
|
||||
*
|
||||
* What this retired: three cues that rounds 6 and 7-phase-0 derived from snapshot
|
||||
* deltas (`repaired`, the firewall "can", `relicFound`) and their heuristics with them.
|
||||
*/
|
||||
const pending: SimEvent[] = [];
|
||||
const relicsFound = new Set<number>();
|
||||
|
||||
function applyEvents(view: SimSnapshot): void {
|
||||
relicsFound.clear();
|
||||
if (!pending.length) return;
|
||||
for (const ev of pending) {
|
||||
switch (ev.kind) {
|
||||
case 'repaired':
|
||||
// EXACT now: the mite's correction lands at the event's own pos, on the frame
|
||||
// the sim says it landed. Round 6 inferred both from a mite's `size` crossing.
|
||||
juice.tidy(ev.pos.x + 0.5, ev.pos.y + 0.5);
|
||||
break;
|
||||
case 'wildlife':
|
||||
// A parity mite leaving. Sated ones reached the rim carrying their correction —
|
||||
// no cue. One canned by a firewall mid-repair reads as filed paperwork. The
|
||||
// event gives exact timing and position; `size` is the only thing that still
|
||||
// discriminates the two, so it comes from the layer's last-seen state.
|
||||
if (ev.on === 'cleared' && ev.wildlife === 'parity-mite') {
|
||||
const size = wildlife.sizeOfMite(ev.id);
|
||||
if (size !== undefined && size < 1) juice.tidy(ev.pos.x + 0.5, ev.pos.y + 0.5);
|
||||
}
|
||||
break;
|
||||
case 'relicFound':
|
||||
relicsFound.add(ev.id); // RelicLayer knows where it is; the event doesn't say
|
||||
break;
|
||||
case 'notice':
|
||||
// Fax first, never silent (v9). The paper cue is on the glass; the world gets a
|
||||
// small twitch at the uplinks, which is what The Correction is auditing.
|
||||
notice.fire(ev.unit);
|
||||
refreshShippers(view);
|
||||
for (const c of shipperCentres) juice.notice(c.x, c.y);
|
||||
break;
|
||||
case 'mirrored': {
|
||||
// The clean copy out-shipped you. Flash inside the sector, in the item's colour
|
||||
// draining to white — it left as yours and arrived as theirs.
|
||||
const col = itemColors.get(ev.item) ?? 0xffffff;
|
||||
juice.mirrorFlash(ev.pos.x + 0.5, ev.pos.y + 0.5, col);
|
||||
break;
|
||||
}
|
||||
case 'sealed': {
|
||||
if (!ev.on) break; // un-sealing is the player's win; the pry itself is UI's cue
|
||||
const e = view.entities.find((x) => x.id === ev.entity);
|
||||
const d = e ? defs.get(e.def) : undefined;
|
||||
if (e && d) {
|
||||
const c = centerOf(e.pos, d, e.dir);
|
||||
juice.stamp(c.x, c.z); // the rubber stamp landing
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stasis':
|
||||
correction.onStasis(ev.rect, ev.on, ev.lockHash);
|
||||
break;
|
||||
default:
|
||||
break; // shipped/crafted/jammed/... are other layers' business
|
||||
}
|
||||
}
|
||||
pending.length = 0;
|
||||
}
|
||||
|
||||
/** True when the pointer is over the arcade cabinet. */
|
||||
function cabinetHit(clientX: number, clientY: number): boolean {
|
||||
if (!breakroom) return false;
|
||||
@ -247,10 +333,14 @@ export function createRenderer(): Renderer {
|
||||
cargo = new BeltItemLayer(data, topo);
|
||||
ghost = new GhostLayer(registry, defs, data);
|
||||
relics = new RelicLayer(juice);
|
||||
wildlife = new WildlifeLayer(juice);
|
||||
wildlife = new WildlifeLayer();
|
||||
// THE CORRECTION, rungs 2-4. Added LAST of the world layers so its translucent
|
||||
// ghost-mirror and stasis bleach sort over the factory they are overwriting.
|
||||
correction = new CorrectionLayer(defs, juice);
|
||||
notice = new NoticePing(container);
|
||||
scene.add(
|
||||
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
|
||||
relics.group, wildlife.group, juice.group,
|
||||
relics.group, wildlife.group, correction.group, juice.group,
|
||||
);
|
||||
|
||||
// THE FLOOR: seam regions from data — era skins the crust, the ore glints.
|
||||
@ -303,6 +393,12 @@ export function createRenderer(): Renderer {
|
||||
if (import.meta.env.DEV && (devSceneEnabled() || showroomEnabled())) {
|
||||
(window as unknown as { __render: unknown }).__render = {
|
||||
scene, camera: rig.camera, rig, stats, gl, dev, showroom, topo, cargo, entities, ghost,
|
||||
// v9: the Correction layer plus a way to inject SimEvents by hand. The devscene
|
||||
// fabricates snapshots, not events, so this is the only way to exercise the
|
||||
// onEvents cues (notice / mirrored / sealed / stasis / repaired) before SIM's
|
||||
// escalation ladder happens to climb during a verification session.
|
||||
correction, juice, wildlife, relics, notice,
|
||||
fire: (...e: SimEvent[]) => { for (const x of e) pending.push(x); },
|
||||
};
|
||||
}
|
||||
|
||||
@ -341,15 +437,24 @@ export function createRenderer(): Renderer {
|
||||
view = dev.snapshot(snap.tick);
|
||||
}
|
||||
|
||||
// Events first: every one-shot cue reasons about THIS frame's view, and the stasis
|
||||
// rects a `stasis` event opens must exist before CorrectionLayer computes what is
|
||||
// frozen — otherwise the freeze would always be one frame late.
|
||||
applyEvents(view);
|
||||
|
||||
rig.update(dt);
|
||||
// Topology is shared by belts (which piece to draw) and cargo (which path the
|
||||
// items take), so it is rebuilt once here rather than twice downstream.
|
||||
topo.rebuild(view, defs);
|
||||
// The Correction runs BEFORE the layers that read its `frozen` set. It is also the
|
||||
// one layer that takes `alpha`: rungs 2-4 glide, everything the player owns judders
|
||||
// (§8 — wrong motion belongs to corruption, not to authority). See correction.ts.
|
||||
correction.sync(view, alpha, t, dt, rig.camera);
|
||||
belts.sync(view, t);
|
||||
entities.sync(view, t, dt, harassedIn(view));
|
||||
cargo.sync(view, alpha, t);
|
||||
entities.sync(view, t, dt, harassedIn(view), correction.frozen);
|
||||
cargo.sync(view, alpha, t, correction.frozen);
|
||||
ghost.update(view, t, inspectedEntity());
|
||||
relics.sync(view.relics, t);
|
||||
relics.sync(view.relics, t, relicsFound);
|
||||
wildlife.sync(view.wildlife, t);
|
||||
seamLayer?.frame(t);
|
||||
breakroom.frame(t);
|
||||
@ -397,5 +502,14 @@ export function createRenderer(): Renderer {
|
||||
setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build') {
|
||||
ghost?.set(def, pos, dir, mode);
|
||||
},
|
||||
|
||||
/**
|
||||
* v9. Buffered, not acted on — see `applyEvents`. The array belongs to main.ts and is
|
||||
* shared with ScreenFX/AudioFX, so we copy the references out and never retain it.
|
||||
*/
|
||||
onEvents(events: SimEvent[]) {
|
||||
if (!gl) return; // init hasn't run; nothing to cue
|
||||
for (const e of events) pending.push(e);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -99,6 +99,30 @@ export class Juice {
|
||||
this.emit(cx, cz, 4, 0.35, 1.6, 0.11, 0.6, 0xd6ffe0);
|
||||
}
|
||||
|
||||
/** `sealed`: the warden's rubber stamp landing. One hard, flat, downward slap — no
|
||||
* spread, no arc. Paperwork does not explode; it is simply, suddenly, done. */
|
||||
stamp(cx: number, cz: number): void {
|
||||
this.emit(cx, cz, 8, 0.9, 0.5, 0.14, 0.35, 0xeafff2);
|
||||
}
|
||||
|
||||
/** `mirrored`: a shipment stolen by the gunship's clean copy. The item's own colour,
|
||||
* drained toward white — it left as yours and arrived as theirs. */
|
||||
mirrorFlash(cx: number, cz: number, col: number): void {
|
||||
this.emit(cx, cz, 7, 0.7, 3.4, 0.16, 0.4, col);
|
||||
this.emit(cx, cz, 5, 0.3, 4.6, 0.13, 0.5, 0xffffff);
|
||||
}
|
||||
|
||||
/** `stasis` release: the region snapping back to speed. Hard and bright — the whole
|
||||
* point is that a lock does not ease off, it ends. Fired at the rect's corners. */
|
||||
stasisRelease(cx: number, cz: number): void {
|
||||
this.emit(cx, cz, 12, 3.2, 4.2, 0.2, 0.32, 0xffffff);
|
||||
}
|
||||
|
||||
/** A notice arriving: the fax's world-side twitch. Small, white, bureaucratic. */
|
||||
notice(cx: number, cz: number): void {
|
||||
this.emit(cx, cz, 6, 0.6, 2.2, 0.13, 0.5, 0xf0f4ff);
|
||||
}
|
||||
|
||||
update(dt: number, camera: THREE.Camera): void {
|
||||
// integrate + recycle
|
||||
for (let i = this.live.length - 1; i >= 0; i--) {
|
||||
|
||||
134
fktry/src/render/notice.ts
Normal file
134
fktry/src/render/notice.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* LANE-RENDER — the NOTICE ping.
|
||||
*
|
||||
* Contracts v9: "'notice' always precedes action (fax first, never silent)". The fax is
|
||||
* where a notice physically arrives, and the fax is a DOM panel (LANE-UI's `#fk-fax`), so
|
||||
* the only honest place to put this cue is on the glass — a hospital-white halo that
|
||||
* strikes once around the panel and decays. The `notice` event carries no `pos`, so there
|
||||
* is no world anchor to draw it at even if there were a fax in the world.
|
||||
*
|
||||
* OWNERSHIP: this element is created by, appended by, and owned by LANE-RENDER — the same
|
||||
* arrangement as the vignette overlay (ground.ts) that has shipped since round 1. It
|
||||
* READS `#fk-fax`'s bounding box to know where to strike and never touches LANE-UI's DOM.
|
||||
* If the fax panel isn't mounted (showroom, devscene, a UI mid-refactor) it falls back to
|
||||
* the bottom-right of the play area rather than disappearing. Filed in NOTES as a bridge:
|
||||
* a `notice` event carrying a position, or a UI-side hook, would retire the DOM read.
|
||||
*
|
||||
* Stacking: `#game` is positioned but has no z-index, so it creates no stacking context —
|
||||
* a positioned child of it therefore competes in the ROOT stacking context and can sit
|
||||
* above `#ui` (z-index 10). Hence 20. Verified on the live page, not assumed.
|
||||
*/
|
||||
|
||||
/** Which rung is filing. Only the label differs; the strike is one faction gesture. */
|
||||
const LABEL: Record<string, string> = {
|
||||
'checksum-warden': 'CHECKSUM WARDEN',
|
||||
'redundancy-gunship': 'REDUNDANCY GUNSHIP',
|
||||
'hash-auditor': 'HASH AUDITOR',
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
@keyframes fk-notice-strike {
|
||||
0% { opacity: 0; transform: scale(1.10); }
|
||||
9% { opacity: 1; transform: scale(1.00); }
|
||||
22% { opacity: 0.75; transform: scale(1.00); }
|
||||
100% { opacity: 0; transform: scale(1.00); }
|
||||
}
|
||||
.fk-notice {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
border: 2px solid #39ff88;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.85) inset, 0 0 26px 6px rgba(57,255,136,0.38);
|
||||
background: rgba(242,244,248,0.10);
|
||||
opacity: 0;
|
||||
font: 10px/1.2 "Courier New", ui-monospace, monospace;
|
||||
color: #eafff2;
|
||||
letter-spacing: 0.14em;
|
||||
text-align: right;
|
||||
}
|
||||
.fk-notice > span {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: -14px;
|
||||
text-shadow: 0 0 6px #000, 0 1px 2px #000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fk-notice.fk-on { animation: fk-notice-strike 1.5s ease-out 1 forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fk-notice.fk-on { animation-duration: 0.9s; }
|
||||
}
|
||||
`;
|
||||
|
||||
export class NoticePing {
|
||||
private el: HTMLDivElement;
|
||||
private label: HTMLSpanElement;
|
||||
private host: HTMLElement;
|
||||
|
||||
constructor(host: HTMLElement) {
|
||||
this.host = host;
|
||||
if (!document.getElementById('fk-notice-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fk-notice-style';
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'fk-notice';
|
||||
this.label = document.createElement('span');
|
||||
this.el.appendChild(this.label);
|
||||
host.appendChild(this.el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the notice lands. `#fk-notices` is LANE-UI's v9 notice tray and is the more
|
||||
* specific home when it is mounted; `#fk-fax` is the fax panel itself. A zero-size rect
|
||||
* means the panel is `display:none` right now, which is a real and ordinary state — UI
|
||||
* updates AFTER the renderer inside a frame, so on the first notice of a session the
|
||||
* panel may not have been laid out yet.
|
||||
*/
|
||||
private anchor(): DOMRect | null {
|
||||
for (const id of ['fk-notices', 'fk-fax']) {
|
||||
const r = document.getElementById(id)?.getBoundingClientRect();
|
||||
if (r && r.width > 0 && r.height > 0) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strike once. Re-triggering mid-animation restarts it — a second notice is a second
|
||||
* notice, and The Correction does not queue politely. */
|
||||
fire(unit: string): void {
|
||||
this.label.textContent = `NOTICE · ${LABEL[unit] ?? unit.toUpperCase()}`;
|
||||
const r = this.anchor();
|
||||
if (r) { this.strike(r); return; }
|
||||
// Nothing measurable yet. Give the UI exactly one frame to lay itself out before
|
||||
// giving up — a notice that struck an empty corner because we asked 4ms too early
|
||||
// would be indistinguishable from a bug.
|
||||
requestAnimationFrame(() => this.strike(this.anchor()));
|
||||
}
|
||||
|
||||
private strike(r: DOMRect | null): void {
|
||||
if (r) {
|
||||
this.el.style.left = `${r.left - 4}px`;
|
||||
this.el.style.top = `${r.top - 4}px`;
|
||||
this.el.style.width = `${r.width + 8}px`;
|
||||
this.el.style.height = `${r.height + 8}px`;
|
||||
} else {
|
||||
// No panel on this page (showroom, devscene, a UI mid-refactor): strike where one
|
||||
// would be, so the cue is never silent. Fax first, never silent — including here.
|
||||
const h = this.host.clientHeight || innerHeight;
|
||||
const w = this.host.clientWidth || innerWidth;
|
||||
this.el.style.left = `${w - 300}px`;
|
||||
this.el.style.top = `${h - 240}px`;
|
||||
this.el.style.width = '280px';
|
||||
this.el.style.height = '150px';
|
||||
}
|
||||
this.el.classList.remove('fk-on');
|
||||
void this.el.offsetWidth; // force reflow so the animation restarts
|
||||
this.el.classList.add('fk-on');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
@ -79,3 +79,24 @@ export const HEIGHT_BY_KIND: Record<MachineKind, number> = {
|
||||
};
|
||||
|
||||
export const JAM_AMBER = 0xffa726;
|
||||
|
||||
/**
|
||||
* THE CORRECTION's uniform (§5). One faction, four rungs, one palette: hospital-white
|
||||
* bodies, matte and spotless, with a single green element each. Green is the faction
|
||||
* signature — it is the parity mite's checkmark eye, the compliance seal's tick, and the
|
||||
* gunship's parity link, so a player learns "green on white = the immune system" once and
|
||||
* reads all four rungs with it.
|
||||
*
|
||||
* `ledger` is the exception, and it is the codex's: §5 gives the CHECKSUM WARDEN "red LED
|
||||
* ledger eyes". Kept, but deliberately as pinpoints on the drawer chest rather than as the
|
||||
* unit's one impossible element — a large red element would say "different faction" on a
|
||||
* screen where the other three rungs are green.
|
||||
*/
|
||||
export const CORRECTION = {
|
||||
shell: 0xf2f4f8, // hospital-white, matte
|
||||
shellDark: 0xd9dee8, // panel/drawer breaks, so a white box still has edges
|
||||
green: 0x39ff88, // faction signature (matches the mite's checkmark, wildlife.ts)
|
||||
ledger: 0xff3b30, // §5's red LED ledger eyes — warden only, pinpoints only
|
||||
/** Ghost mirror + stasis bleach: pure white, worn as transparency rather than emissive. */
|
||||
ghost: 0xffffff,
|
||||
} as const;
|
||||
|
||||
@ -58,9 +58,6 @@ export class RelicLayer {
|
||||
private seen = new Set<number>();
|
||||
private shimmerUniforms = { uTime: { value: 0 } };
|
||||
private shimmerMat: THREE.ShaderMaterial;
|
||||
/** False until the first sync: a save loaded with a relic already found must not fire
|
||||
* the excavation geyser, exactly as EntityLayer primes before firing placement juice. */
|
||||
private primed = false;
|
||||
|
||||
constructor(private juice?: Juice) {
|
||||
this.group.name = 'relics';
|
||||
@ -73,14 +70,22 @@ export class RelicLayer {
|
||||
});
|
||||
}
|
||||
|
||||
sync(relics: ReadonlyArray<RelicState> | undefined, timeSec: number): void {
|
||||
/**
|
||||
* @param justFound relic ids that fired `relicFound` this frame. v9 replaced round 6's
|
||||
* derived unfound→found transition with the real event: the transition also fires when a
|
||||
* save that already contains an excavated relic is LOADED, which is a geyser nobody dug.
|
||||
* Round 6 papered over that with a `primed` flag; the event simply doesn't have the bug.
|
||||
*/
|
||||
sync(
|
||||
relics: ReadonlyArray<RelicState> | undefined, timeSec: number,
|
||||
justFound?: ReadonlySet<number>,
|
||||
): void {
|
||||
this.shimmerUniforms.uTime.value = timeSec;
|
||||
this.seen.clear();
|
||||
|
||||
for (const r of relics ?? []) {
|
||||
this.seen.add(r.id);
|
||||
let rec = this.live.get(r.id);
|
||||
const justExcavated = !!rec && !rec.found && r.found;
|
||||
if (rec && rec.found !== r.found) {
|
||||
this.drop(r.id, rec); // excavated: swap shimmer → monument
|
||||
rec = undefined;
|
||||
@ -92,9 +97,8 @@ export class RelicLayer {
|
||||
rec = { found: r.found, obj };
|
||||
this.live.set(r.id, rec);
|
||||
}
|
||||
// The fossil surfaces: a golden geyser that "sings" up out of the crust. Gated on a
|
||||
// real unfound→found transition seen live (not a save that loads already-excavated).
|
||||
if (justExcavated && this.primed) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5);
|
||||
// The fossil surfaces: a golden geyser that "sings" up out of the crust.
|
||||
if (justFound?.has(r.id)) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5);
|
||||
if (r.found) {
|
||||
// gentle lift + rotate so the excavated fossil reads as an exhibit
|
||||
rec.obj.rotation.y = timeSec * 0.5;
|
||||
@ -103,7 +107,6 @@ export class RelicLayer {
|
||||
}
|
||||
|
||||
for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec);
|
||||
this.primed = true;
|
||||
}
|
||||
|
||||
private buildShimmer(): THREE.Object3D {
|
||||
|
||||
@ -18,7 +18,9 @@
|
||||
* Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { Dir, EntityState, GameData, SimSnapshot } from '../contracts';
|
||||
import type {
|
||||
CorrectionState, CorrectionUnit, Dir, EntityState, GameData, MachineDef, SimSnapshot,
|
||||
} from '../contracts';
|
||||
import { AssetRegistry } from './registry';
|
||||
import { itemGeometry, itemMaterial } from './beltitems';
|
||||
|
||||
@ -40,6 +42,13 @@ const I_GAP = 7; // between the two halls
|
||||
const I_Z_LIMIT = 30; // keep the item hall inside the world (camera clamps at ±32)
|
||||
const X_LIMIT = 30;
|
||||
const PLINTH_H = 0.05;
|
||||
/**
|
||||
* THE CORRECTION bay — rungs 2-4, one row NORTH of the machine hall so the asset grid is
|
||||
* untouched (the gunship's mirror and the auditor's bleach are translucent overlays, and
|
||||
* hazing three catalog machines with them would compromise exactly the read this page
|
||||
* exists for). It carries its own duplicate machines to be mirrored, sealed and frozen.
|
||||
*/
|
||||
const C_Z_GAP = 6;
|
||||
|
||||
interface Slot {
|
||||
label: string;
|
||||
@ -66,9 +75,70 @@ export class Showroom {
|
||||
this.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The Correction bay. Three placeholder rungs on plinths, plus four duplicate machines
|
||||
* that exist only to be acted upon: two inside the gunship's mirrored sector, one wearing
|
||||
* a compliance placard, one inside the auditor's stasis lock (watch its idle stop).
|
||||
* Duplicates, not hall machines, so `?showroom`'s asset QA read stays clean.
|
||||
*/
|
||||
private buildCorrectionBay(bz: number, nextId: number): number {
|
||||
let id = nextId;
|
||||
// Prefer machines with a visible idle so "frozen" and "sealed" read as a CHANGE.
|
||||
const pick = (kind: MachineDef['kind']): MachineDef | undefined =>
|
||||
this.data.machines.find((m) => m.kind === kind);
|
||||
const subject = pick('crafter') ?? this.data.machines[0];
|
||||
if (!subject) return id;
|
||||
|
||||
const place = (def: MachineDef, cx: number, sealed = false): number => {
|
||||
const eid = id++;
|
||||
this.entities.push({
|
||||
id: eid, def: def.id,
|
||||
pos: { x: Math.round(cx - def.footprint.x / 2), y: Math.round(bz - def.footprint.y / 2) },
|
||||
dir: FACING, recipe: null, progress: 0, inputBuf: {}, outputBuf: {},
|
||||
jammed: null, heat: 0, scrammed: false, sealed,
|
||||
});
|
||||
this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, bz));
|
||||
return eid;
|
||||
};
|
||||
|
||||
const label = (text: string, sub: string, cx: number): void => {
|
||||
this.slots.push({ label: text, sub, x: cx, z: bz, w: 0, h: 0 });
|
||||
};
|
||||
|
||||
// --- rung 3: the gunship, over a two-machine sector it is mirroring ---------------
|
||||
const anchor = place(subject, -9);
|
||||
place(pick('extractor') ?? subject, -3);
|
||||
label('REDUNDANCY GUNSHIP', 'rung 3 · mirrored sector + parity tether', -6);
|
||||
this.units.push({
|
||||
id: 9002, kind: 'redundancy-gunship', pos: { x: -7, y: Math.round(bz) },
|
||||
phase: 'tether', progress: 0.8, target: anchor,
|
||||
rect: { x: -12, y: Math.round(bz) - 2, w: 12, h: 4 },
|
||||
});
|
||||
|
||||
// --- rung 2: the warden, mid-stamp, beside the machine it just sealed -------------
|
||||
place(subject, 5, true);
|
||||
label('CHECKSUM WARDEN', 'rung 2 · stamp arm + compliance placard', 2);
|
||||
this.units.push({
|
||||
id: 9001, kind: 'checksum-warden', pos: { x: 2, y: Math.round(bz) },
|
||||
phase: 'audit', progress: 0.5, target: id - 1,
|
||||
});
|
||||
|
||||
// --- rung 4: the auditor, holding a lock over one frozen machine ------------------
|
||||
place(subject, 13);
|
||||
label('HASH AUDITOR', 'rung 4 · 0:00 stasis lock (never walks)', 10);
|
||||
this.units.push({
|
||||
id: 9003, kind: 'hash-auditor', pos: { x: 10, y: Math.round(bz) },
|
||||
phase: 'stasis', progress: 0.4,
|
||||
rect: { x: 8, y: Math.round(bz) - 2, w: 8, h: 4 },
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
let id = 1;
|
||||
const mCentre = ((M_COLS - 1) * M_SPACING) / 2;
|
||||
id = this.buildCorrectionBay(M_Z0 - C_Z_GAP, id);
|
||||
|
||||
// --- machines: fabricated entities, rendered by the real EntityLayer -------------
|
||||
this.data.machines.forEach((def, i) => {
|
||||
@ -143,9 +213,14 @@ export class Showroom {
|
||||
shippedTotal: {},
|
||||
activeCommission: null,
|
||||
commissionProgress: {},
|
||||
correction: this.correction,
|
||||
};
|
||||
}
|
||||
|
||||
/** v9: the Correction bay's three rungs. Mutable from the console for QA. */
|
||||
readonly units: CorrectionUnit[] = [];
|
||||
readonly correction: CorrectionState = { debt: 0.5, band: 3, units: this.units };
|
||||
|
||||
frame(timeSec: number): void {
|
||||
for (const o of this.decor.children) {
|
||||
if (o.userData.spin) o.rotation.y = timeSec * 0.9;
|
||||
|
||||
@ -7,15 +7,20 @@
|
||||
* the machine's own flicker is EntityLayer's job, driven off the same `target` ids.
|
||||
* Parity mites are hospital-white beetles with a green checkmark eye that walk with an
|
||||
* unsettling gentleness (§5): they don't rampage, they FILE. Where one corrects your
|
||||
* product it leaves an infuriatingly tidy sparkle (Juice.tidy), the visual of the
|
||||
* 'repaired' event — which render() can't see, so it's derived from the mite's presence.
|
||||
* product it leaves an infuriatingly tidy sparkle (Juice.tidy).
|
||||
*
|
||||
* v9/round 7: that sparkle is no longer derived here. `Renderer.onEvents` exists now, so
|
||||
* index.ts fires it on the real `repaired` event at the event's own `pos`. This layer
|
||||
* still records each mite's last-seen `size`, because the OTHER cue — a mite canned by a
|
||||
* firewall rather than leaving sated — is a `wildlife: 'cleared'` event either way, and
|
||||
* `size` is the only thing that tells the two apart. Timing and position are now exact;
|
||||
* only that one discrimination is still state-derived. See NOTES.
|
||||
*
|
||||
* All instanced from day one: one InstancedMesh for every pile, one for every gnat across
|
||||
* every swarm, one for every mite body, one for every mite eye.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { SimSnapshot, WildlifeState } from '../contracts';
|
||||
import type { Juice } from './juice';
|
||||
|
||||
const GNATS_PER_SWARM = 24;
|
||||
|
||||
@ -58,14 +63,14 @@ export class WildlifeLayer {
|
||||
private miteCap = 96;
|
||||
private dummy = new THREE.Object3D();
|
||||
private eyeCol = new THREE.Color();
|
||||
/** Per-mite last-seen {size,pos}, so the renderer can derive the two mite events it can't
|
||||
* see: `repaired` (size crossing <1 → ≥1 = a correction landed) and "canned by a
|
||||
* firewall" (a mite gone from the list while still unsated = filed, not fled). */
|
||||
private miteState = new Map<number, { size: number; x: number; z: number }>();
|
||||
/** Per-mite last-seen size. Read by index.ts when a `wildlife: 'cleared'` event names a
|
||||
* parity mite: unsated (<1) means a firewall canned it mid-repair; sated means it
|
||||
* simply reached the rim and left carrying its correction, which gets no cue. */
|
||||
private miteState = new Map<number, number>();
|
||||
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
|
||||
readonly harassed = new Set<number>();
|
||||
|
||||
constructor(private juice?: Juice) {
|
||||
constructor() {
|
||||
this.group.name = 'wildlife';
|
||||
// A low, faceted heap; instance scale grows it with `size`.
|
||||
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
|
||||
@ -155,15 +160,10 @@ export class WildlifeLayer {
|
||||
}
|
||||
}
|
||||
|
||||
// A mite that vanished from the list: sated ones (size≥1) reached the rim and left
|
||||
// carrying their correction — no cue. One still unsated was CANNED by a firewall
|
||||
// mid-repair (§5's counter): the product survived, so it reads as filed paperwork —
|
||||
// an infuriatingly tidy sparkle where the jar closed.
|
||||
for (const [id, st] of this.miteState) {
|
||||
if (seenMites.has(id)) continue;
|
||||
if (st.size < 1) this.juice?.tidy(st.x, st.z);
|
||||
this.miteState.delete(id);
|
||||
}
|
||||
// Forget mites that are gone. The cue itself is fired by index.ts from the real
|
||||
// `wildlife: 'cleared'` event — which arrives on the SAME frame the mite disappears
|
||||
// from this list, so the size recorded here is still the one it had when it died.
|
||||
for (const id of this.miteState.keys()) if (!seenMites.has(id)) this.miteState.delete(id);
|
||||
|
||||
this.piles.count = pileN;
|
||||
this.piles.instanceMatrix.needsUpdate = true;
|
||||
@ -181,8 +181,8 @@ export class WildlifeLayer {
|
||||
* mid-repair, size≥1 = sated and carrying the correction home. The tell tracks it —
|
||||
* a hunting mite drifts with a slow, deliberate patrol (the wrongness is the calm, not
|
||||
* the speed); on station it stops and only trembles while the checkmark eye glows up to
|
||||
* completion; sated it moves off. The eye is tilted to meet the iso camera. Crossing
|
||||
* into sated fires the tidy 'repaired' sparkle where the product used to be.
|
||||
* completion; sated it moves off. The eye is tilted to meet the iso camera. The tidy
|
||||
* 'repaired' sparkle is fired from the real event now, not from this crossing.
|
||||
*/
|
||||
private placeMite(w: WildlifeState, timeSec: number, n: number): number {
|
||||
const cx = w.pos.x + 0.5;
|
||||
@ -218,13 +218,14 @@ export class WildlifeLayer {
|
||||
const glow = size <= 0 ? 0.5 : size < 1 ? 0.5 + 0.5 * size : 1;
|
||||
this.miteEyes.setColorAt(n, this.eyeCol.setRGB(glow, glow, glow));
|
||||
|
||||
// Derive the `repaired` event (render() can't see events): the correction lands the
|
||||
// instant the mite crosses from repairing into sated.
|
||||
const prev = this.miteState.get(w.id);
|
||||
if (prev && prev.size < 1 && size >= 1) this.juice?.tidy(cx, cz);
|
||||
this.miteState.set(w.id, { size, x: cx, z: cz });
|
||||
this.miteState.set(w.id, size);
|
||||
return n + 1;
|
||||
}
|
||||
|
||||
/** Last-seen `size` for a parity mite, or undefined if this layer never saw it. */
|
||||
sizeOfMite(id: number): number | undefined {
|
||||
return this.miteState.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: which entities a snapshot's swarms are harassing (for EntityLayer). */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user