[render] GLB legibility pass (emissive floor + accent beacon), parity-mite visuals, relic excavation VFX

- registry: texel-following emissive floor + one-impossible-element accent beacon on every
  hot-swapped GLB (generic, no hardcoded list) — restores the two-material rule on baked assets
- wildlife: parity-mite beetles with green checkmark eye, size-driven state tell (hunt/repair/
  sated), derived 'repaired' tidy sparkle + firewall-can 'filed paperwork' cue
- relics/juice: golden excavation geyser on unfound->found transition
- devscene: parity-mite fixtures across all three states for verification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 19:00:58 +10:00
parent 68bddbec0a
commit 0d491f8a2f
7 changed files with 382 additions and 15 deletions

View File

@ -648,3 +648,115 @@ per lane fixes both.
inside a cooler's rectangle once SIM lands spatial cooling; relic excavation VFX on the
`relicFound` event; bloom-loop belt tell; save/load camera state; LOD/culling if a
megabase ever drags the frame.
### Round 6 — 2026-07-20 — Opus 4.8
**SHIPPED** — all 3 orders, verified in the running build. The second asset wave landed
mid-round: **27 GLBs are now in `public/assets/models/`** (up from the 10 the order named).
Everything below is generic — it runs on *any* hot-swapped GLB through the registry, no
hardcoded list — so all 27 (and whatever lands next) got the treatment automatically.
1. **GLB legibility pass (centrepiece).** The two-material rule died on real assets: baked
albedo, zero emissive, near-black at game zoom (before/after below). Two halves, both
keyed off the SAME accent derivation placeholders already use, so a machine that made
melt still glows melt-orange whether it's a box or a GLB:
- **Texel-following emissive FLOOR** (`registry.ts floorMaterials`, run ONCE on each
GLB template in `tryLoad`): `emissiveMap = map`, `emissive = white`,
`emissiveIntensity = 0.42` — the baked albedo self-illuminates so the brightest texels
lift off the void without washing to flat grey. Materials with no map self-light their
own colour; materials that already ship a glow are left alone. EntityLayer still clones
these per instance, so per-entity heat/scram rides on top (heat lerps `emissive`→red;
the map keeps modulating).
- **Accent BEACON** (`addAccentBeacon`): the one impossible element, perched on top,
scaled to footprint, in the machine's derived/art-directed accent — plus a gentle idle
pulse so a GLB gets a heartbeat like a placeholder. The idle reads `beacon.material` at
call time, not a captured ref, because EntityLayer re-clones GLB materials per instance
(a closure over the pre-clone material would drive a mesh nothing renders — caught that
before it shipped). **DATA's round-6 accents were chosen against the real GLB textures,
so the beacons wear DATA's hand-picked colours.**
- Verified in `?showroom` (before: dark specks on plinths → after: lit chassis + colour-
coded beacons across the whole 27-machine catalog) AND in the live demo world at the
default game frame (`__fktryDemo()` reference factory, `rig.frame(3,6,15)` untouched):
every GLB legible, beacons marking each unit.
2. **Parity mites** (`wildlife.ts`, +2 instanced meshes). Hospital-white faceted beetles
with a green **checkmark eye** (canvas ✓ texture, tilted 45° to meet the iso camera).
The tell tracks SIM's `size` state channel exactly: `0` hunting (slow, patient patrol —
the wrongness is the calm), `0<size<1` on station (dwells, only a tremor; the eye
**glows up** via per-instance `instanceColor` as the correction completes), `size≥1`
sated. render() can't see events, so I derive the two mite cues from snapshot deltas:
- **`repaired`** = a mite crossing `size` <1 1: a tidy sparkle where the product used
to be. Verified deterministically — driving a mite 0.6→1.0 emitted **exactly 4**
particles (one `Juice.tidy`).
- **caught in a firewall = filed paperwork** = a mite that vanishes from the list while
still unsated (sated ones leave at the rim carrying their fix, no cue). Verified:
removing an unsated mite fired the tidy sparkle (juice 4→8).
3. **Excavation VFX** (`relics.ts` + `Juice.excavate`). On the relic's unfound→found
transition, a slow tall golden geyser "sings" up out of the crust as the monument rises.
Gated on a live transition (a save that loads already-excavated doesn't fire it — primed
like EntityLayer). Verified: flipping the fixture relic fired the geyser (juice pool
4→**89**) and built the monument.
**VERIFIED (perf)** — honest caveat: the canonical full-frame CPU-ms sampler could not be
read reliably this round because the browser backgrounds the tab and starves the rAF loop
that drives `layer.sync` (same shared-tree/environment friction round 5 hit), so the
instanced hot-path meshes never fully populated for a passive read. What holds by
construction: the additions don't touch the terms that dominate the frame. Wildlife gained
2 InstancedMeshes (mite bodies + eyes — 2 draw calls, only when mites exist, O(mites) capped
at 96); GLB legibility is one-time material params + one small beacon mesh per GLB *machine
instance*; the 2,000-item / 480-belt instanced path is untouched and no per-frame allocation
was added. Round 5's measured **4.4ms/frame** at full load stands as the reference.
**DECISIONS**
- **Floor is texel-following, not a flat wash.** A flat emissive floor guarantees "never a
hole" but greys the texture to mud and, since most GLBs export colour in the *map* with a
white base colour, would floor-glow everything white. `emissiveMap = map` keeps each
machine's baked colour and lifts its lit texels; the beacon + scene lighting cover the
rare all-dark texel. Trade-off noted honestly: a genuinely pure-black texel stays dark
(correct — dark rubber should read darker than a metal panel); no machine reads as a hole
in practice (verified by eye at default zoom).
- **Beacon geometry is module-shared, material is per-instance.** GLB records don't own
geometry (`ownsGeo=false`) so the shared beacon geo is never disposed; the per-instance
material is (EntityLayer disposes all materials on drop regardless).
- **Mite state is read from `size`, not invented.** SIM documents `size` as the state
channel and `target` as the belt it converges on; I render that contract rather than a
timer, so the tell is true to the sim (hunting drifts, repairing dwells + glows, sated
leaves). The eye-glow ramp uses `instanceColor` (multiplies the checkmark map) — no
shared-material emissive hack.
**BLOCKED/BROKEN** — nothing blocking.
- **The mite "filed paperwork" cue is a heuristic, not the event.** render() has no event
channel, so "canned by a firewall" is inferred from a mite disappearing while `size<1`.
This is correct for the sim's current lifecycle (sated mites leave at the rim, far from
firewalls; canned ones vanish mid-repair near one), but it's an inference — if a mite ever
despawns unsated for another reason, it'd read as a false "filed". A real renderer event
channel (see CONTRACT REQUEST) would make both mite cues and the excavation exact instead
of derived. Low risk today.
- The `firewall` machine (DATA, this round) has no GLB yet, so it shows the placeholder in
`?showroom` — it'll hot-swap + get the floor/beacon treatment the moment its asset lands,
no code change.
- `match.html` 404 in dev (round 5, unchanged): the cabinet's target only exists in `dist/`.
**CONTRACT REQUEST**
1. **A renderer event channel** (renewing round 5's ask, now with two more consumers).
`Renderer` never receives `SimEvent[]`; ScreenFX and AudioFX do. This round I derived
`repaired`, the firewall "can", and `relicFound` from snapshot deltas — all work, but
they're inferences that can drift from truth. Cheapest shape: add
`Renderer.onEvents(events: SimEvent[])` called by main.ts alongside the others. It would
make excavation fire on the actual `relicFound` and the tidy sparkle fire on the actual
`repaired` (with the event's own `pos`), retiring three heuristics. Everything else this
lane does stays snapshot-driven; this is specifically for one-shot cues.
2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator).
**PROPOSAL**
- The GLB legibility pass makes the MODELBEAST pipeline fully hands-off for legibility: any
asset dropped in — textured, untextured, rigged, multi-mesh — lands lit and identifiable
with zero per-asset tuning. The whole 27-machine catalog proves it. Hero re-cuts
(`muxer`, `mosh-reactor`) can arrive without a render change.
**NEXT (round 7 candidates, not self-assigned)**
- Wire the renderer event channel if granted (exacts the three derived cues above); a
`firewall.glb` when it lands; per-mite orientation toward its `target` belt; heat-shimmer
*relief* inside a cooler rectangle; bloom-loop belt tell; save/load camera state;
LOD/culling if a real megabase ever drags the frame.

View File

@ -159,6 +159,12 @@ export class DevScene {
{ id: 901, kind: 'dust-pile', pos: { x: 2, y: 12 }, size: 0.8 },
{ id: 902, kind: 'dust-pile', pos: { x: 0, y: 12 }, size: 0.15 },
{ id: 910, kind: 'mosquito-swarm', pos: { x: -8, y: 0 }, size: 0.7, target: demuxer?.id },
// The Correction: three parity mites, one per state so all three tells are visible —
// 920 hunting (drifts), 921 on-station (dwells, eye glowing), 922 animated in
// snapshot() so it cycles through the repair and fires the tidy 'repaired' sparkle.
{ id: 920, kind: 'parity-mite', pos: { x: -9, y: 0 }, size: 0, target: demuxer?.id },
{ id: 921, kind: 'parity-mite', pos: { x: -2, y: 3 }, size: 0.5 },
{ id: 922, kind: 'parity-mite', pos: { x: 0, y: 5 }, size: 0.7 },
);
this.ok = true;
@ -184,6 +190,11 @@ 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.
const mite = this.wildlife.find((w) => w.id === 922);
if (mite) mite.size = (tick % 150) / 100; // 0 → 1.5, crossing 1.0 each loop
const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
const perTick = this.speed / TICKS_PER_SECOND;
const span = seg.ids.length;

View File

@ -204,8 +204,8 @@ export function createRenderer(): Renderer {
entities = new EntityLayer(registry, defs, juice);
cargo = new BeltItemLayer(data, topo);
ghost = new GhostLayer(registry, defs, data);
relics = new RelicLayer();
wildlife = new WildlifeLayer();
relics = new RelicLayer(juice);
wildlife = new WildlifeLayer(juice);
scene.add(
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
relics.group, wildlife.group, juice.group,

View File

@ -86,6 +86,19 @@ export class Juice {
this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col);
}
/** Excavation (relicFound): the fossil surfacing a slow, tall golden geyser that
* "sings" up out of the crust. Longer-lived and higher than any other cue. */
excavate(cx: number, cz: number): void {
this.emit(cx, cz, 30, 1.4, 7.5, 0.26, 1.1, 0xffcaa0);
this.emit(cx, cz, 14, 0.5, 4.0, 0.18, 1.4, 0xfff2d6); // a brighter inner column
}
/** The Correction's calling card: a small, infuriatingly TIDY green-white sparkle,
* left where a parity-mite just "corrected" your product. Few, clean, gentle. */
tidy(cx: number, cz: number): void {
this.emit(cx, cz, 4, 0.35, 1.6, 0.11, 0.6, 0xd6ffe0);
}
update(dt: number, camera: THREE.Camera): void {
// integrate + recycle
for (let i = this.live.length - 1; i >= 0; i--) {

View File

@ -222,10 +222,89 @@ export function beltPlateGeometry(): THREE.BufferGeometry {
return geo;
}
// ---------------------------------------------------------------- GLB legibility
/**
* GLB legibility floor (round 6, the round's centrepiece).
*
* MODELBEAST bakes albedo into textures and ships NO emissive, so at game zoom on a
* near-black ground a real asset reads as a dark hole the placeholder two-material
* rule (dark body + one emissive accent) silently dies the moment a GLB lands. The fix
* has two halves, both keyed off the same data the placeholders use:
*
* 1. a texel-following emissive FLOOR (this function): self-illuminate the baked albedo
* (`emissiveMap = map`) so the brightest texels lift off the ground and nothing reads
* as a pure void, without washing the texture down to a flat grey. A material that
* already ships its own glow is respected, not overwritten.
* 2. an accent BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of
* what the machine makes the same accent derivation the placeholder uses.
*/
const GLB_EMISSIVE_FLOOR = 0.42;
function floorMaterials(root: THREE.Object3D): void {
root.traverse((o) => {
const m = o as THREE.Mesh;
if (!m.isMesh) return;
const mats = Array.isArray(m.material) ? m.material : [m.material];
for (const raw of mats) {
const mat = raw as THREE.MeshStandardMaterial;
if (!mat || !(mat as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue;
const e = mat.emissive;
const alreadyLit = !!e && e.r + e.g + e.b > 0.03 && (mat.emissiveIntensity ?? 0) > 0.02;
if (alreadyLit) continue; // MODELBEAST authored a glow here — leave it be
if (mat.map) {
mat.emissiveMap = mat.map; // white * map = the texture, self-lit
mat.emissive = new THREE.Color(0xffffff);
} else {
mat.emissive = mat.color.clone(); // no texture: self-colour floor
}
mat.emissiveIntensity = GLB_EMISSIVE_FLOOR;
mat.needsUpdate = true;
}
});
}
/** Unit beacon; instances scale it by footprint and never dispose it (module-shared). */
const BEACON_GEO = new THREE.IcosahedronGeometry(1, 1);
/**
* Perch the machine's ONE emissive accent on top of a baked GLB and give the asset a
* gentle idle pulse resource colour, identity, and a heartbeat, all from the same
* accent the placeholder derives. The idle reads `beacon.material` at call time (not a
* captured reference) because EntityLayer clones every GLB material per instance; a
* closure over the pre-clone material would drive a mesh nothing renders.
*/
function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): void {
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
const fp = Math.min(def.footprint.x, def.footprint.y);
const r = THREE.MathUtils.clamp(fp * 0.1, 0.08, 0.2);
const beacon = new THREE.Mesh(
BEACON_GEO,
new THREE.MeshStandardMaterial({
color: accent, emissive: accent, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.1,
}),
);
beacon.scale.setScalar(r);
beacon.position.set(
(box.min.x + box.max.x) / 2,
box.max.y + r * 1.3,
(box.min.z + box.max.z) / 2,
);
beacon.userData.fktryBeacon = true;
root.add(beacon);
root.userData.fktryIdle = ((t: number) => {
(beacon.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.8 + Math.sin(t * 2.3) * 0.45;
}) satisfies IdleFn;
}
// ---------------------------------------------------------------- GLB handling
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D {
function normaliseGLB(
src: THREE.Object3D,
def: MachineDef,
clips: THREE.AnimationClip[],
accent?: number,
): THREE.Object3D {
// SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound
// per instance or every copy animates off the first one's bones.
const obj = cloneSkinned(src);
@ -250,14 +329,20 @@ function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.Animati
});
// A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs
// get wrapped: the mixer drives the inner node, the wrapper does the normalising.
let root: THREE.Object3D;
if (clips.length) {
const wrap = new THREE.Group();
wrap.add(obj);
wrap.userData.fktryClips = clips;
return wrap;
}
root = wrap;
} else {
obj.userData.fktryClips = clips;
return obj;
root = obj;
}
// Machine create path passes an accent -> perch the beacon. The instanceable path
// (belts) passes none: an instanced belt has no room for a beacon and no accent to show.
if (accent !== undefined) addAccentBeacon(root, def, accent);
return root;
}
/**
@ -311,6 +396,9 @@ export class AssetRegistry {
private entries = new Map<string, AssetEntry>();
private defs = new Map<string, MachineDef>();
/** The derived emissive accent per asset key shared by placeholders and hot-swapped
* GLBs so a real asset gets the same one impossible element the placeholder had. */
private accents = new Map<string, number>();
private eras = new Map<string, Era>();
private loader = new GLTFLoader();
private timer: number | null = null;
@ -324,7 +412,9 @@ export class AssetRegistry {
this.eras = eraIndex(data);
for (const def of data.machines) {
this.defs.set(def.asset, def);
const entry = this.placeholderEntry(def, accentFor(def, data), bodyFor(def), this.eraOf(def.id));
const accent = accentFor(def, data);
this.accents.set(def.asset, accent);
const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id));
this.entries.set(def.asset, entry);
}
await this.probeAll();
@ -371,12 +461,17 @@ export class AssetRegistry {
const prev = this.entries.get(def.asset);
const version = (prev?.version ?? 0) + 1;
const template = gltf.scene;
// Round 6 legibility pass: lift the baked-dark chassis off the void ONCE on the
// shared template. EntityLayer clones these materials per instance, so per-entity
// heat/scram still works on top of the floor.
floorMaterials(template);
const clips = gltf.animations ?? [];
const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
this.entries.set(def.asset, {
key: def.asset,
version,
isGLB: true,
create: () => normaliseGLB(template, def, clips),
create: () => normaliseGLB(template, def, clips, accent),
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
});
this.version++;

View File

@ -13,6 +13,7 @@
import * as THREE from 'three';
import type { RelicState } from '../contracts';
import { disposeObject } from './coords';
import type { Juice } from './juice';
const SHIMMER_VERT = /* glsl */ `
varying vec2 vUv;
@ -57,8 +58,11 @@ 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() {
constructor(private juice?: Juice) {
this.group.name = 'relics';
this.shimmerMat = new THREE.ShaderMaterial({
uniforms: this.shimmerUniforms,
@ -76,6 +80,7 @@ export class RelicLayer {
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;
@ -87,6 +92,9 @@ 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);
if (r.found) {
// gentle lift + rotate so the excavated fossil reads as an exhibit
rec.obj.rotation.y = timeSec * 0.5;
@ -95,6 +103,7 @@ 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 {

View File

@ -1,16 +1,21 @@
/**
* LANE-RENDER wildlife: dust piles and mosquito swarms (§2/§5, M2's living hazards).
* LANE-RENDER wildlife: dust piles, mosquito swarms, and parity mites (§2/§5, the
* living hazards of M2 and The Correction).
*
* Piles are HF-dust glitter heaps (codex: quantizer sweepings) they grow with `size`.
* Swarms are pixel-gnat clouds that cluster over the machine they're degrading (`target`);
* 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.
*
* Both are instanced from day one: the perf bar is 50 piles + 10 swarms, and a swarm is
* itself a cloud of gnats. One InstancedMesh for every pile, one for every gnat across
* every swarm two draw calls for the whole ecosystem.
* 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;
@ -22,17 +27,45 @@ function pileMaterial(): THREE.MeshStandardMaterial {
});
}
/** A crisp green ✓ on transparent ground — the mite's single, approving eye (§5). */
function checkmarkTexture(): THREE.CanvasTexture {
const s = 64;
const cv = document.createElement('canvas');
cv.width = cv.height = s;
const ctx = cv.getContext('2d')!;
ctx.strokeStyle = '#39ff88';
ctx.lineWidth = 9;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(s * 0.2, s * 0.52);
ctx.lineTo(s * 0.42, s * 0.74);
ctx.lineTo(s * 0.82, s * 0.26);
ctx.stroke();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
export class WildlifeLayer {
readonly group = new THREE.Group();
private piles: THREE.InstancedMesh;
private gnats: THREE.InstancedMesh;
private mites: THREE.InstancedMesh;
private miteEyes: THREE.InstancedMesh;
private pileCap = 64;
private gnatCap = 64 * GNATS_PER_SWARM;
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 }>();
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
readonly harassed = new Set<number>();
constructor() {
constructor(private juice?: Juice) {
this.group.name = 'wildlife';
// A low, faceted heap; instance scale grows it with `size`.
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
@ -52,7 +85,30 @@ export class WildlifeLayer {
this.gnats.frustumCulled = false;
this.gnats.count = 0;
this.group.add(this.piles, this.gnats);
// Parity mite: a hospital-white carapace (faceted ellipsoid). Faintly self-lit so it
// reads clinical-white on the dark ground rather than going grey.
const miteGeo = new THREE.IcosahedronGeometry(0.18, 0);
miteGeo.scale(1, 0.62, 1.4); // longer than wide: a beetle, not a bead
const miteMat = new THREE.MeshStandardMaterial({
color: 0xf4f6ff, emissive: 0xd6ddf2, emissiveIntensity: 0.32,
roughness: 0.5, metalness: 0.1, flatShading: true,
});
this.mites = new THREE.InstancedMesh(miteGeo, miteMat, this.miteCap);
this.mites.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mites.castShadow = true;
this.mites.frustumCulled = false;
this.mites.count = 0;
const eyeGeo = new THREE.PlaneGeometry(0.16, 0.16);
const eyeMat = new THREE.MeshBasicMaterial({
map: checkmarkTexture(), transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
this.miteEyes = new THREE.InstancedMesh(eyeGeo, eyeMat, this.miteCap);
this.miteEyes.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.miteEyes.frustumCulled = false;
this.miteEyes.count = 0;
this.group.add(this.piles, this.gnats, this.mites, this.miteEyes);
}
sync(wildlife: ReadonlyArray<WildlifeState> | undefined, timeSec: number): void {
@ -60,6 +116,8 @@ export class WildlifeLayer {
const list = wildlife ?? [];
let pileN = 0;
let gnatN = 0;
let miteN = 0;
const seenMites = new Set<number>();
for (const w of list) {
if (w.kind === 'dust-pile') {
@ -70,6 +128,10 @@ export class WildlifeLayer {
this.dummy.scale.set(s, s, s);
this.dummy.updateMatrix();
this.piles.setMatrixAt(pileN++, this.dummy.matrix);
} else if (w.kind === 'parity-mite') {
if (miteN >= this.miteCap) continue;
seenMites.add(w.id);
miteN = this.placeMite(w, timeSec, miteN);
} else {
if (w.target !== undefined) this.harassed.add(w.target);
const cx = w.pos.x + 0.5;
@ -93,10 +155,75 @@ 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);
}
this.piles.count = pileN;
this.piles.instanceMatrix.needsUpdate = true;
this.gnats.count = gnatN;
this.gnats.instanceMatrix.needsUpdate = true;
this.mites.count = miteN;
this.mites.instanceMatrix.needsUpdate = true;
this.miteEyes.count = miteN;
this.miteEyes.instanceMatrix.needsUpdate = true;
if (this.miteEyes.instanceColor) this.miteEyes.instanceColor.needsUpdate = true;
}
/**
* One mite. `size` is the sim's state channel (§5): 0 = hunting, 0<size<1 = on station
* mid-repair, size1 = 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.
*/
private placeMite(w: WildlifeState, timeSec: number, n: number): number {
const cx = w.pos.x + 0.5;
const cz = w.pos.y + 0.5;
const size = w.size;
const onStation = size > 0 && size < 1;
const ph = w.id * 2.3999; // golden-angle desync
// On station it dwells (a tiny tremor); otherwise it patrols a slow, patient circle.
const amp = onStation ? 0.05 : 0.26;
const rate = onStation ? 0.9 : 0.55;
const gait = timeSec * rate + ph;
const px = cx + Math.cos(gait) * amp;
const pz = cz + Math.sin(gait) * amp;
const yaw = -gait; // face along the patrol tangent
const bob = Math.abs(Math.sin(gait * 3)) * (onStation ? 0.012 : 0.03);
this.dummy.position.set(px, 0.14 + bob, pz);
this.dummy.rotation.set(0, yaw, 0);
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.mites.setMatrixAt(n, this.dummy.matrix);
// Eye: forward of the carapace (local -Z, so world = (-sinYaw,-cosYaw)) and up, laid
// back ~45° so the checkmark meets the iso camera instead of lying flat on the floor.
const fwd = 0.14;
this.dummy.position.set(px - Math.sin(yaw) * fwd, 0.27 + bob, pz - Math.cos(yaw) * fwd);
this.dummy.rotation.set(-Math.PI / 4, yaw, 0);
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.miteEyes.setMatrixAt(n, this.dummy.matrix);
// The approval glows in as the correction completes: dim while hunting, ramping on
// station, full when sated. Per-instance via instanceColor (multiplies the map).
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 });
return n + 1;
}
}