[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:
parent
68bddbec0a
commit
0d491f8a2f
@ -648,3 +648,115 @@ per lane fixes both.
|
|||||||
inside a cooler's rectangle once SIM lands spatial cooling; relic excavation VFX on the
|
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
|
`relicFound` event; bloom-loop belt tell; save/load camera state; LOD/culling if a
|
||||||
megabase ever drags the frame.
|
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.
|
||||||
|
|||||||
@ -159,6 +159,12 @@ export class DevScene {
|
|||||||
{ id: 901, kind: 'dust-pile', pos: { x: 2, y: 12 }, size: 0.8 },
|
{ 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: 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 },
|
{ 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;
|
this.ok = true;
|
||||||
@ -184,6 +190,11 @@ export class DevScene {
|
|||||||
if (e.recipe) e.progress = (tick % 90) / 90;
|
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 beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
|
||||||
const perTick = this.speed / TICKS_PER_SECOND;
|
const perTick = this.speed / TICKS_PER_SECOND;
|
||||||
const span = seg.ids.length;
|
const span = seg.ids.length;
|
||||||
|
|||||||
@ -204,8 +204,8 @@ export function createRenderer(): Renderer {
|
|||||||
entities = new EntityLayer(registry, defs, juice);
|
entities = new EntityLayer(registry, defs, juice);
|
||||||
cargo = new BeltItemLayer(data, topo);
|
cargo = new BeltItemLayer(data, topo);
|
||||||
ghost = new GhostLayer(registry, defs, data);
|
ghost = new GhostLayer(registry, defs, data);
|
||||||
relics = new RelicLayer();
|
relics = new RelicLayer(juice);
|
||||||
wildlife = new WildlifeLayer();
|
wildlife = new WildlifeLayer(juice);
|
||||||
scene.add(
|
scene.add(
|
||||||
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
|
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
|
||||||
relics.group, wildlife.group, juice.group,
|
relics.group, wildlife.group, juice.group,
|
||||||
|
|||||||
@ -86,6 +86,19 @@ export class Juice {
|
|||||||
this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col);
|
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 {
|
update(dt: number, camera: THREE.Camera): void {
|
||||||
// integrate + recycle
|
// integrate + recycle
|
||||||
for (let i = this.live.length - 1; i >= 0; i--) {
|
for (let i = this.live.length - 1; i >= 0; i--) {
|
||||||
|
|||||||
@ -222,10 +222,89 @@ export function beltPlateGeometry(): THREE.BufferGeometry {
|
|||||||
return geo;
|
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
|
// ---------------------------------------------------------------- GLB handling
|
||||||
|
|
||||||
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
|
/** 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
|
// SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound
|
||||||
// per instance or every copy animates off the first one's bones.
|
// per instance or every copy animates off the first one's bones.
|
||||||
const obj = cloneSkinned(src);
|
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
|
// 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.
|
// get wrapped: the mixer drives the inner node, the wrapper does the normalising.
|
||||||
|
let root: THREE.Object3D;
|
||||||
if (clips.length) {
|
if (clips.length) {
|
||||||
const wrap = new THREE.Group();
|
const wrap = new THREE.Group();
|
||||||
wrap.add(obj);
|
wrap.add(obj);
|
||||||
wrap.userData.fktryClips = clips;
|
wrap.userData.fktryClips = clips;
|
||||||
return wrap;
|
root = wrap;
|
||||||
|
} else {
|
||||||
|
obj.userData.fktryClips = clips;
|
||||||
|
root = obj;
|
||||||
}
|
}
|
||||||
obj.userData.fktryClips = clips;
|
// Machine create path passes an accent -> perch the beacon. The instanceable path
|
||||||
return obj;
|
// (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 entries = new Map<string, AssetEntry>();
|
||||||
private defs = new Map<string, MachineDef>();
|
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 eras = new Map<string, Era>();
|
||||||
private loader = new GLTFLoader();
|
private loader = new GLTFLoader();
|
||||||
private timer: number | null = null;
|
private timer: number | null = null;
|
||||||
@ -324,7 +412,9 @@ export class AssetRegistry {
|
|||||||
this.eras = eraIndex(data);
|
this.eras = eraIndex(data);
|
||||||
for (const def of data.machines) {
|
for (const def of data.machines) {
|
||||||
this.defs.set(def.asset, def);
|
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);
|
this.entries.set(def.asset, entry);
|
||||||
}
|
}
|
||||||
await this.probeAll();
|
await this.probeAll();
|
||||||
@ -371,12 +461,17 @@ export class AssetRegistry {
|
|||||||
const prev = this.entries.get(def.asset);
|
const prev = this.entries.get(def.asset);
|
||||||
const version = (prev?.version ?? 0) + 1;
|
const version = (prev?.version ?? 0) + 1;
|
||||||
const template = gltf.scene;
|
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 clips = gltf.animations ?? [];
|
||||||
|
const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
|
||||||
this.entries.set(def.asset, {
|
this.entries.set(def.asset, {
|
||||||
key: def.asset,
|
key: def.asset,
|
||||||
version,
|
version,
|
||||||
isGLB: true,
|
isGLB: true,
|
||||||
create: () => normaliseGLB(template, def, clips),
|
create: () => normaliseGLB(template, def, clips, accent),
|
||||||
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
|
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
|
||||||
});
|
});
|
||||||
this.version++;
|
this.version++;
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { RelicState } from '../contracts';
|
import type { RelicState } from '../contracts';
|
||||||
import { disposeObject } from './coords';
|
import { disposeObject } from './coords';
|
||||||
|
import type { Juice } from './juice';
|
||||||
|
|
||||||
const SHIMMER_VERT = /* glsl */ `
|
const SHIMMER_VERT = /* glsl */ `
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
@ -57,8 +58,11 @@ export class RelicLayer {
|
|||||||
private seen = new Set<number>();
|
private seen = new Set<number>();
|
||||||
private shimmerUniforms = { uTime: { value: 0 } };
|
private shimmerUniforms = { uTime: { value: 0 } };
|
||||||
private shimmerMat: THREE.ShaderMaterial;
|
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.group.name = 'relics';
|
||||||
this.shimmerMat = new THREE.ShaderMaterial({
|
this.shimmerMat = new THREE.ShaderMaterial({
|
||||||
uniforms: this.shimmerUniforms,
|
uniforms: this.shimmerUniforms,
|
||||||
@ -76,6 +80,7 @@ export class RelicLayer {
|
|||||||
for (const r of relics ?? []) {
|
for (const r of relics ?? []) {
|
||||||
this.seen.add(r.id);
|
this.seen.add(r.id);
|
||||||
let rec = this.live.get(r.id);
|
let rec = this.live.get(r.id);
|
||||||
|
const justExcavated = !!rec && !rec.found && r.found;
|
||||||
if (rec && rec.found !== r.found) {
|
if (rec && rec.found !== r.found) {
|
||||||
this.drop(r.id, rec); // excavated: swap shimmer → monument
|
this.drop(r.id, rec); // excavated: swap shimmer → monument
|
||||||
rec = undefined;
|
rec = undefined;
|
||||||
@ -87,6 +92,9 @@ export class RelicLayer {
|
|||||||
rec = { found: r.found, obj };
|
rec = { found: r.found, obj };
|
||||||
this.live.set(r.id, rec);
|
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) {
|
if (r.found) {
|
||||||
// gentle lift + rotate so the excavated fossil reads as an exhibit
|
// gentle lift + rotate so the excavated fossil reads as an exhibit
|
||||||
rec.obj.rotation.y = timeSec * 0.5;
|
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);
|
for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec);
|
||||||
|
this.primed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildShimmer(): THREE.Object3D {
|
private buildShimmer(): THREE.Object3D {
|
||||||
|
|||||||
@ -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`.
|
* 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`);
|
* 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.
|
* 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
|
* All instanced from day one: one InstancedMesh for every pile, one for every gnat across
|
||||||
* itself a cloud of gnats. One InstancedMesh for every pile, one for every gnat across
|
* every swarm, one for every mite body, one for every mite eye.
|
||||||
* every swarm — two draw calls for the whole ecosystem.
|
|
||||||
*/
|
*/
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SimSnapshot, WildlifeState } from '../contracts';
|
import type { SimSnapshot, WildlifeState } from '../contracts';
|
||||||
|
import type { Juice } from './juice';
|
||||||
|
|
||||||
const GNATS_PER_SWARM = 24;
|
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 {
|
export class WildlifeLayer {
|
||||||
readonly group = new THREE.Group();
|
readonly group = new THREE.Group();
|
||||||
private piles: THREE.InstancedMesh;
|
private piles: THREE.InstancedMesh;
|
||||||
private gnats: THREE.InstancedMesh;
|
private gnats: THREE.InstancedMesh;
|
||||||
|
private mites: THREE.InstancedMesh;
|
||||||
|
private miteEyes: THREE.InstancedMesh;
|
||||||
private pileCap = 64;
|
private pileCap = 64;
|
||||||
private gnatCap = 64 * GNATS_PER_SWARM;
|
private gnatCap = 64 * GNATS_PER_SWARM;
|
||||||
|
private miteCap = 96;
|
||||||
private dummy = new THREE.Object3D();
|
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. */
|
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
|
||||||
readonly harassed = new Set<number>();
|
readonly harassed = new Set<number>();
|
||||||
|
|
||||||
constructor() {
|
constructor(private juice?: Juice) {
|
||||||
this.group.name = 'wildlife';
|
this.group.name = 'wildlife';
|
||||||
// A low, faceted heap; instance scale grows it with `size`.
|
// A low, faceted heap; instance scale grows it with `size`.
|
||||||
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
|
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
|
||||||
@ -52,7 +85,30 @@ export class WildlifeLayer {
|
|||||||
this.gnats.frustumCulled = false;
|
this.gnats.frustumCulled = false;
|
||||||
this.gnats.count = 0;
|
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 {
|
sync(wildlife: ReadonlyArray<WildlifeState> | undefined, timeSec: number): void {
|
||||||
@ -60,6 +116,8 @@ export class WildlifeLayer {
|
|||||||
const list = wildlife ?? [];
|
const list = wildlife ?? [];
|
||||||
let pileN = 0;
|
let pileN = 0;
|
||||||
let gnatN = 0;
|
let gnatN = 0;
|
||||||
|
let miteN = 0;
|
||||||
|
const seenMites = new Set<number>();
|
||||||
|
|
||||||
for (const w of list) {
|
for (const w of list) {
|
||||||
if (w.kind === 'dust-pile') {
|
if (w.kind === 'dust-pile') {
|
||||||
@ -70,6 +128,10 @@ export class WildlifeLayer {
|
|||||||
this.dummy.scale.set(s, s, s);
|
this.dummy.scale.set(s, s, s);
|
||||||
this.dummy.updateMatrix();
|
this.dummy.updateMatrix();
|
||||||
this.piles.setMatrixAt(pileN++, this.dummy.matrix);
|
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 {
|
} else {
|
||||||
if (w.target !== undefined) this.harassed.add(w.target);
|
if (w.target !== undefined) this.harassed.add(w.target);
|
||||||
const cx = w.pos.x + 0.5;
|
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.count = pileN;
|
||||||
this.piles.instanceMatrix.needsUpdate = true;
|
this.piles.instanceMatrix.needsUpdate = true;
|
||||||
this.gnats.count = gnatN;
|
this.gnats.count = gnatN;
|
||||||
this.gnats.instanceMatrix.needsUpdate = true;
|
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, 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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user