Compare commits

..

No commits in common. "c441ec3a530975ed9f65c012b4420ef4a9bd636a" and "e9661dd772b7cabefe601047afd4506d642305ea" have entirely different histories.

11 changed files with 21 additions and 1156 deletions

View File

@ -524,144 +524,3 @@ doc does. Read it there rather than from round 3.
*relief* inside a cooler's rectangle once SIM lands spatial cooling; bloom-loop belt
tell; HF dust piles; wildlife; save/load camera state; LOD/culling if a megabase ever
drags the frame.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**THE HEADLINE: "the game about place has no place" was a COLOR-MANAGEMENT BUG, not a
missing feature.** I wrote the whole floor — fbm terrain, era hinting, seam glints — ran
it, and the screen was still black. The shader was fine. `THREE.Color.setHex()` converts
sRGB → the linear working space, but a raw `ShaderMaterial` gets none of the renderer's
output includes, so the linear value went to the framebuffer untouched. Measured, not
guessed: crust authored `#0c0a15` (RGB 12,10,21) was rendering as **RGB (1,1,2)**; even a
mid-grey `#2a2440` came out (6,5,13). Everything this lane has drawn with a raw shader has
been ~6× too dark **since round 1** — the grid, the crust, all of it. One
`#include <colorspace_fragment>` in `ground.ts` and the floor measures (10,8,18) against
an authored (12,10,21). The void was never a design problem; it was a missing include.
That's why the "before/after" is so stark, and it's the single most valuable thing I found
this round.
**SHIPPED** — all 8 orders.
*Part A*
1. **Inspect highlight** — selection ring + cooler aura on `{mode:'inspect'}`. Verified:
inspecting a 2×2 extractor rings it at its centre with scale 2.5; inspecting the
asic-cooler shows ring **and** its 6×6 coverage rect. This closes the
"placed-and-selected" case round 4 couldn't reach.
2. **Wildlife** (`wildlife.ts`) — dust piles as HF-dust glitter heaps scaling with `size`;
mosquito swarms as pixel-gnat clouds orbiting `target`, with the harassed machine
stuttering (§8: wrongness of motion is a faction uniform). Two draw calls for the whole
ecosystem.
3. **Fixture hygiene** — devscene now fabricates `commissionQueue`, `research`,
`wildlife` and `relics`, plus an asic-cooler and archaeology-lab so the v4/v5 kinds are
reachable in the scaffold.
*Part B*
4. **THE FLOOR** (`ground.ts`, `seams.ts`) — fbm tone mottle + neutral rim + 8 data-driven
seam regions that skin the crust with their era and glint with their ore.
5. **THE RELIC** (`relics.ts`) — unfound = a breathing static patch; found = a fossil on a
plinth. Numbers below.
6. **JUICE** (`juice.ts`) — placement scale-pop + dust puff, demolition burst, shipment
flash. All derived from snapshot deltas, since `render()` never sees events.
7. **THE BREAK ROOM** (`breakroom.ts`) — GLYTCH arcade cabinet, own raycast, `cursor:
pointer` on hover, click → `window.open('./match.html')` **verified firing**.
8. **CAMERA + CLICKS** — default framing lifts world origin to screen y=264, clear of the
build bar at y=679. Picking investigation below — it found more than expected.
**VERIFIED (numbers)**
- **Relic is subtle-but-findable, measured at two zooms.** Brightest pixel on the relic vs
plain floor 7 tiles away: at wandering zoom (12) **+39** — findable. At overview zoom
(30) **18** — i.e. indistinguishable from floor mottle. You cannot spot it from above;
you have to be down there looking. That's the Mario-hidden-pipe budget, in numbers.
- **Perf holds with everything on**: 500 entities + 2,000 belt items + 50 piles + 198
gnats + 8 seam regions + the new floor = **4.4ms/frame**, 3.8× headroom, 8 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 / R5 4.4.)
- **Juice**: puff → 14 particles decaying over ~0.67s; burst → 22; shipment flash fired 22
times tinted `#ff7a3f`, exactly MELT's authored colour — so the flash reads which item
shipped, not just that something did.
- Prod bundle: `devscene`/`showroom`/`plinth`/`window.__render` all 0 hits; the round-5
shipping code (seams, aura, relics, breakroom) present. 446 tests pass, tsc clean.
**DECISIONS**
- **Dropped my own era-rim guess when DATA contradicted it.** The orders asked for strata
whispers toward the world edges and I built exactly that — sepia north, phosphor east.
Then `data/seams.json` landed mid-round, its doc saying "RENDER: `era` skins the ground",
with reel in BOTH the north-west and north-east and disc both north-centre and
south-west. My directional guess would have contradicted the data on screen, so era is
now skinned per seam region with a wide soft falloff. It still reads as strata whispers
toward the rim — the richest seams live out there anyway.
- **Seam glint is flecks, not veins.** First pass drew continuous sinusoidal veins; on
screen they were bright green worms crawling over the floor, competing with the belts.
Mineral glitter is sparse and twinkles, so it's now a fleck grid with per-fleck sparkle
clocks. The body fade is deliberately SHORT (0.8 tiles): SIM extracts on any
footprint/rect overlap, and a glint dying two tiles inside the boundary would
under-report where mining actually works.
- **The cooler aura stays a rectangle** (round 4's reasoning) and the relic monument, the
cabinet and the seams all now carry `colorspace_fragment` for the same reason the ground
does.
- **Cabinet is rotated 45°** to face the iso camera and sized to its own marquee — at the
rig's closest zoom a 0.03-unit marquee pixel was sub-pixel and GLYTCH was a smudge.
**BLOCKED/BROKEN**
- **A frame-delta of 63.7 SECONDS was silently eating all juice.** `clock.getDelta()`
returns the real gap, and a backgrounded tab / alt-tab / GC pause / first frame after
load hands back seconds. Particles have a 0.5s TTL, so every effect was born and killed
inside one update — and a 63s integration step flings them to infinity. `render()` now
clamps dt to 0.05 (main.ts clamps the sim accumulator for the same reason; this is the
renderer's half). This also protects the scale-pop, camera pan and heat animation.
- **`match.html` only exists in `dist/`** (deploy.sh copies it there). In a dev server the
cabinet's click opens a 404. Not fixable from this lane — `public/` is mine but the file
is the deploy's. Flagged so nobody reports it as a cabinet bug.
- The devscene relic sits at (9,-7), which is NOT inside DATA's `relicReserve`
(12,12,20,20). That's fine for a fixture, but SIM's real relic should land in the
reserve — worth a check when SIM lands `relics`.
- The cabinet is decorative and reserves no tiles, so a player can place a machine
"inside" it. Cosmetic only.
**B8 PICKING INVESTIGATION — 27.6% of the viewport cannot be clicked**
I probed a 20px grid over the whole viewport (2,205 points) asking "does a click here
reach the game canvas?". 609 points — **27.6%** — do not. Breakdown:
| blocker | points | note |
|---|---|---|
| `#screen` | 153 (6.9%) | **decorative overlay, `pointer-events: auto`** |
| `#fk-build` + hint | 155 (7.0%) | build bar — legitimately interactive |
| `#fk-fax` + flavor | 90 (4.1%) | fax panel — legitimately interactive |
| `#fk-top` | 36 (1.6%) | bandwidth panel — legitimately interactive |
The one that is a **bug**: `#screen` is THE SCREEN's canvas in `index.html`, purely
decorative, 322×182 at top-centre, `z-index: 5`, and it takes pointer events. `#ui` next to
it correctly sets `pointer-events: none`. So a 6.9% rectangle of prime play area silently
swallows clicks — I hit it myself: the cabinet at (-5,-5) rendered *underneath* it and was
both invisible and unclickable, which is exactly the review's "machines spawned
unclickable". **Fix is one line in `index.html` (orchestrator-owned): add
`pointer-events: none` to `#screen`.** I moved the cabinet to (6,12) as a workaround, but
the dead zone is still there for machines.
Also seen: `#fk-screenview` (LANE-UI's theatre mode) is a full-viewport `pointer-events:
auto` host. Correctly `display:none` when closed — not a bug, noting it so the next person
who probes picking isn't confused by it the way I was.
**CONTRACT REQUEST**
1. **A renderer inspect channel.** `Renderer.setGhost`'s mode is only `build|remove` and
main.ts passes `'build'` during inspect, so nothing about the inspected entity reaches
this lane. `SelectionState` carries it, so I read `window.__fktryBus.selection()` each
frame as a bridge — same shape as the old `'__remove'` sentinel, documented on both
sides, and it wants a real hook: either widen `setGhost`'s mode to include `'inspect'`
with the entity id, or add `Renderer.setInspect(id | null)` that main.ts calls.
2. **`pointer-events: none` on `#screen`** in `index.html` (see above). Not my file.
3. Advisory: SIM doesn't gate extraction to seam rects yet, so the glint is currently a
cosmetic tell rather than a rule. The moment SIM lands the overlap check the floor
already teaches it — no renderer work needed.
**PROPOSAL — the shared tree cost me real time again.** Verification kept failing because
LANE-UI and LANE-SIM save `src/ui/*.ts` / `src/sim/*.ts` every few seconds and Vite
full-reloads my page, wiping every probe mid-measurement (the vite log shows a reload every
515s for minutes). I finished the round by snapshotting the repo to a scratch dir and
running an isolated dev server against it. That works, but it's a workaround for the same
root cause as round 3's commit collision: **one working tree, five writers.** A worktree
per lane fixes both.
**NEXT (round 6 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as *relief*
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.

View File

@ -1,109 +0,0 @@
/**
* LANE-RENDER the break room. A GLYTCH arcade cabinet near the start zone.
*
* Clicking it opens `match.html` the original match-the-glitch prototype, which deploy
* copies next to the game (see deploy.sh). The two games finally meet. The cabinet is a
* real object in the world with its own raycast (index.ts owns the pick + the cursor
* affordance), not a UI button, because it should feel like a thing you find on the
* floor, not a menu item.
*
* The marquee spells GLYTCH in glowing blocks a placeholder until a MODELBEAST cabinet
* lands under the `arcade-cabinet` asset key (the registry would hot-swap it, but the
* break room isn't a MachineDef, so this is hand-built and self-contained).
*/
import * as THREE from 'three';
// 5x5 bitmap font for G L Y T C H — one column of glowing pixels per letter row.
const GLYPHS: Record<string, number[]> = {
G: [0b01110, 0b10000, 0b10011, 0b10001, 0b01110],
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
Y: [0b10001, 0b01010, 0b00100, 0b00100, 0b00100],
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100],
C: [0b01110, 0b10001, 0b10000, 0b10001, 0b01110],
H: [0b10001, 0b10001, 0b11111, 0b10001, 0b10001],
};
export class BreakRoom {
readonly group = new THREE.Group();
/** The subtree the raycast tests against — click anywhere on the cabinet counts. */
readonly clickable: THREE.Object3D[] = [];
private marqueeMat: THREE.MeshStandardMaterial;
constructor(x: number, z: number) {
this.group.name = 'breakroom';
this.group.position.set(x, 0, z);
// The iso rig looks from (+x,+y,+z), so a face whose normal is +z points 45° away
// from the viewer and the marquee reads edge-on. Turn the whole cabinet to face the
// camera — it's a prop, not a machine, so it doesn't owe the grid an orientation.
this.group.rotation.y = Math.PI / 4;
const body = new THREE.MeshStandardMaterial({ color: 0x1a1830, roughness: 0.7, metalness: 0.2 });
// Wide enough to carry its own marquee: GLYTCH at a legible pixel size spans ~1.8
// units, and a sign wider than its cabinet reads as a floating decal.
const cabinet = new THREE.Mesh(new THREE.BoxGeometry(2.1, 2.6, 1.1), body);
cabinet.position.y = 1.3;
cabinet.castShadow = cabinet.receiveShadow = true;
this.group.add(cabinet);
this.clickable.push(cabinet);
// Screen: a dark panel with a cyan glow, angled toward the player.
const screen = new THREE.Mesh(
new THREE.PlaneGeometry(1.5, 1.0),
new THREE.MeshStandardMaterial({
color: 0x0a1420, emissive: 0x1b6cff, emissiveIntensity: 0.8, roughness: 0.4,
}),
);
screen.position.set(0, 1.45, 0.56);
screen.rotation.x = -0.18;
this.group.add(screen);
this.clickable.push(screen);
// Marquee: GLYTCH in emissive pixels across the top.
this.marqueeMat = new THREE.MeshStandardMaterial({
color: 0x3fd4ff, emissive: 0x3fd4ff, emissiveIntensity: 1.6,
});
const marquee = this.buildMarquee();
marquee.position.set(0, 2.28, 0.57);
this.group.add(marquee);
// Control deck.
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.15, 0.5), body);
deck.position.set(0, 0.78, 0.55);
deck.rotation.x = -0.3;
this.group.add(deck);
this.clickable.push(deck);
}
private buildMarquee(): THREE.Object3D {
const g = new THREE.Group();
const word = 'GLYTCH';
// Sized so the word spans most of the cabinet's width — at the rig's closest zoom
// (8) a 0.03 pixel was sub-pixel on screen and GLYTCH was an illegible smudge.
const px = 0.05;
const letterW = 5 * px + px; // 5 cols + gap
const totalW = word.length * letterW;
const pixel = new THREE.BoxGeometry(px * 0.9, px * 0.9, px * 0.6);
word.split('').forEach((ch, li) => {
const rows = GLYPHS[ch];
if (!rows) return;
rows.forEach((bits, ry) => {
for (let cx = 0; cx < 5; cx++) {
if (!(bits & (1 << (4 - cx)))) continue;
const m = new THREE.Mesh(pixel, this.marqueeMat);
m.position.set(
-totalW / 2 + li * letterW + cx * px,
(2 - ry) * px,
0,
);
g.add(m);
}
});
});
return g;
}
/** Marquee attract-mode shimmer. */
frame(timeSec: number): void {
this.marqueeMat.emissiveIntensity = 1.3 + Math.sin(timeSec * 4) * 0.4;
}
}

View File

@ -15,11 +15,7 @@
* by wrapping `t` on one belt, because that's what the real sim does — and it's what
* exercises the renderer's cross-belt interpolation and corner pathing honestly.
*/
import {
TICKS_PER_SECOND,
type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot,
type WildlifeState,
} from '../contracts';
import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts';
/**
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
@ -96,24 +92,6 @@ export class DevScene {
this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items
if (machines.length) this.machine(machines[r % machines.length], -5, y, 1); // +20 = 500
}
// The round-5 perf bar: floor + 50 piles + 10 swarms on top of 500 entities.
const ents = this.entities.filter((e) => e.def !== 'belt');
for (let i = 0; i < 50; i++) {
this.wildlife.push({
id: 1000 + i, kind: 'dust-pile',
pos: { x: -30 + (i % 10) * 3, y: -26 + Math.floor(i / 10) * 12 },
size: ((i * 37) % 100) / 100,
});
}
for (let i = 0; i < 10; i++) {
const host = ents[i % Math.max(1, ents.length)];
this.wildlife.push({
id: 2000 + i, kind: 'mosquito-swarm',
pos: host ? { x: host.pos.x, y: host.pos.y } : { x: -5, y: -20 + i * 5 },
size: 0.5 + (i % 5) * 0.1,
target: host?.id,
});
}
this.ok = true;
return;
}
@ -147,19 +125,6 @@ export class DevScene {
if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1);
if (this.has('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize');
if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh');
// v4/v5 kinds, so inspect-aura and the lab silhouette are reachable in the scaffold.
if (this.has('asic-cooler')) this.machine('asic-cooler', 9, 8, 1);
if (this.has('archaeology-lab')) this.machine('archaeology-lab', 13, 8, 1);
// A few hazards so wildlife is visible without the stress rig: a growing dust drift
// beside the quantizer (which is what sheds HF dust, §2) and a swarm on the demuxer.
const demuxer = this.entities.find((e) => e.def === 'demuxer');
this.wildlife.push(
{ id: 900, kind: 'dust-pile', pos: { x: 1, y: 11 }, size: 0.35 },
{ 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 },
);
this.ok = true;
}
@ -216,21 +181,9 @@ export class DevScene {
// the ghost's padlock is for. `unlocked` is writable from the console to check the
// other half (see NOTES).
research: this.research,
// v3 hardened: the queue is active-first, then upcoming.
commissionQueue: this.commissionQueue,
// v5/v6: hazards and secrets. Mutable so a verification session can grow a pile,
// aim a swarm, or excavate the relic from the console.
wildlife: this.wildlife,
relics: this.relics,
};
}
/** 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. */
readonly commissionQueue: string[] = [];
/** v5 hazards. `?devscene=stress` fills these to the perf bar (50 piles + 10 swarms). */
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 }];
}

View File

@ -17,10 +17,6 @@ import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from
import { centerOf, yawFor } from './coords';
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
import { createHeatHaze, type HeatHaze } from './heat';
import type { Juice } from './juice';
/** Placement thunk: how long the scale-pop takes, in seconds. */
const POP_TIME = 0.22;
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
@ -58,8 +54,6 @@ interface Rec {
all: HeatTarget[];
/** Whether dead-dark is currently applied, so restart knows to restore. */
wasScrammed: boolean;
/** Seconds into the placement scale-pop; >= POP_TIME means settled. */
pop: number;
}
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
@ -68,8 +62,6 @@ export class EntityLayer {
readonly group = new THREE.Group();
private live = new Map<number, Rec>();
private seen = new Set<number>();
/** False until the first sync completes — stops a save-load firing juice for everything. */
private primed = false;
/** One shared material: every alarm blinks together, like a real klaxon. */
private jamMat = new THREE.MeshStandardMaterial({
color: JAM_AMBER,
@ -77,15 +69,11 @@ export class EntityLayer {
emissiveIntensity: 1.5,
});
constructor(
private registry: AssetRegistry,
private defs: Map<string, MachineDef>,
private juice?: Juice,
) {
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'entities';
}
sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set<number>): void {
sync(snap: SimSnapshot, timeSec: number, dt: number): void {
this.seen.clear();
let anyAlarm = false;
@ -114,16 +102,7 @@ export class EntityLayer {
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
rec = undefined;
}
if (!rec) {
rec = this.build(e.id, e.def, def, entry.version);
// A machine that was not here last frame was just placed. First frame primes the
// set without firing, so loading a save doesn't confetti the whole factory.
if (this.primed) {
const c = centerOf(e.pos, def, e.dir);
this.juice?.puff(c.x, c.z);
rec.pop = 0; // start the scale-pop
}
}
if (!rec) rec = this.build(e.id, e.def, def, entry.version);
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
centerOf(e.pos, def, e.dir, rec.obj.position);
@ -157,31 +136,11 @@ export class EntityLayer {
const alarm = !!e.jammed || scrammed;
rec.jam.visible = alarm;
if (alarm) anyAlarm = true;
// Placement thunk: overshoot then settle. Reads as weight landing on the floor.
if (rec.pop < POP_TIME) {
rec.pop += dt;
const k = Math.min(1, rec.pop / POP_TIME);
const s = k < 1 ? 1 + Math.sin(k * Math.PI) * 0.22 : 1;
rec.obj.scale.setScalar(k < 0.12 ? k / 0.12 : s);
} else if (rec.obj.scale.x !== 1) {
rec.obj.scale.setScalar(1);
}
// Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8).
if (harassed?.has(e.id)) {
rec.obj.position.x += Math.sin(timeSec * 47 + e.id) * 0.012;
rec.obj.position.z += Math.cos(timeSec * 53 + e.id) * 0.012;
}
}
for (const [id, rec] of this.live) {
if (!this.seen.has(id)) {
this.juice?.burst(rec.obj.position.x, rec.obj.position.z);
this.drop(id, rec);
}
if (!this.seen.has(id)) this.drop(id, rec);
}
this.primed = true;
if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
}
@ -291,7 +250,6 @@ export class EntityLayer {
heat,
all,
wasScrammed: false,
pop: POP_TIME, // settled by default; a real placement rewinds it to 0
};
this.live.set(id, rec);
return rec;

View File

@ -90,10 +90,6 @@ export class GhostLayer {
private lockedBy = new Map<string, TechDef>();
/** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
private anyCoolers = false;
/** True while the inspector owns the aura, so hover logic leaves it alone. */
private inspectAura = false;
/** Selection ring drawn under the inspected machine. */
private selectRing: THREE.Mesh;
constructor(
private registry: AssetRegistry,
@ -106,19 +102,6 @@ export class GhostLayer {
this.padlock.obj.visible = false;
this.group.add(this.padlock.obj);
// Selection ring: a flat outline under the inspected machine. Lives outside `group`
// so it survives the ghost being hidden (inspect has no hovered tile).
this.selectRing = new THREE.Mesh(
new THREE.RingGeometry(0.62, 0.72, 4, 1, Math.PI / 4),
new THREE.MeshBasicMaterial({
color: VALID, transparent: true, opacity: 0.4, depthWrite: false, side: THREE.DoubleSide,
}),
);
this.selectRing.rotation.x = -Math.PI / 2;
this.selectRing.visible = false;
this.selectRing.renderOrder = 9;
this.aura.group.add(this.selectRing);
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) {
@ -213,13 +196,9 @@ export class GhostLayer {
return snap.research?.unlocked.includes(tech.id) ? null : tech;
}
update(snap: SimSnapshot, timeSec: number, inspected?: number | null): void {
// The inspected machine is highlighted whether or not the pointer is anywhere near
// it — the inspector is open until it's closed.
this.updateInspect(snap, timeSec, inspected ?? null);
update(snap: SimSnapshot, timeSec: number): void {
if (!this.pos) {
if (!this.inspectAura) this.aura.hide();
this.aura.hide();
this.remove.hide();
this.group.visible = false;
return;
@ -279,43 +258,11 @@ export class GhostLayer {
}
}
/**
* v5: the inspected machine gets a selection ring, and if it cools its coverage
* rectangle. This is the "placed-and-selected" case round 4 couldn't reach: the
* inspected entity now arrives through the typed selection.
*/
private updateInspect(snap: SimSnapshot, timeSec: number, inspected: number | null): void {
this.inspectAura = false;
if (inspected === null) {
this.selectRing.visible = false;
return;
}
const e = snap.entities.find((x) => x.id === inspected);
const def = e ? this.defs.get(e.def) : undefined;
if (!e || !def) {
this.selectRing.visible = false;
return;
}
const f = rotatedFootprint(def, e.dir);
const c = centerOf(e.pos, def, e.dir);
this.selectRing.visible = true;
this.selectRing.position.set(c.x, 0.04, c.z);
this.selectRing.scale.set(f.x + 0.5, f.y + 0.5, 1);
(this.selectRing.material as THREE.MeshBasicMaterial).opacity =
0.35 + Math.sin(timeSec * 3) * 0.12;
if (def.coolRadius) {
this.aura.show(c.x, c.z, f.x, f.y, def.coolRadius, timeSec);
this.inspectAura = true; // claim the aura so hover logic doesn't hide it
}
}
/**
* Ring the cooler being placed, or the one under the cursor. Placing wins: while you're
* holding a cooler the question is where its coverage lands, not what's already there.
*/
private updateAura(timeSec: number): void {
if (this.inspectAura) return; // the inspector owns the aura this frame
if (!this.anyCoolers || !this.pos) {
this.aura.hide();
return;

View File

@ -1,30 +1,15 @@
/**
* LANE-RENDER Stream-Crust ground plane. THE FLOOR (round 5).
* LANE-RENDER Stream-Crust ground plane.
*
* Round 1 shipped a grid over flat crust. That read as "the void with lines on it",
* which the review named: the game about place had no place. This makes the plane read
* as terrain without ever competing with belts and machines it's the stage, not the
* set. Three barely-there layers:
*
* - fbm tone variation: large, slow value-noise mottling so the crust isn't a flat
* fill. Amplitude is tiny on purpose; you feel it more than see it.
* - the grid, unchanged (it's the placement affordance and must stay legible).
* - a neutral rim: the crust darkens and dissolves into the void at the world edge.
*
* ERA HINTING lives in seams.ts, not here. The orders asked for strata whispers toward
* the world edges, and I first wrote exactly that a directional guess (sepia north,
* phosphor east...). Then DATA landed `data/seams.json`, whose doc says "RENDER: `era`
* skins the ground", and its layout has reel in BOTH the north-west and north-east and
* disc both north-centre and south-west. A directional guess would have contradicted the
* data on screen. So era is skinned per seam region, with a wide soft falloff so it still
* reads as a stratum whisper across the ground and the richest seams sit near the rim
* anyway, which is where the orders wanted the hinting to show up.
* A shader grid rather than GridHelper: resolution-independent antialiased lines,
* major lines every 8 tiles, faint scanline grain, and a dissolve at the world edge
* so the Bitstream fades into the void instead of ending on a hard rectangle.
*/
import * as THREE from 'three';
import { WORLD } from './palette';
import { WORLD_HALF } from './camera';
const PLANE = WORLD_HALF * 5; // extends past the world; detail only drawn inside ±WORLD_HALF
const PLANE = WORLD_HALF * 5; // extends past the world; grid only drawn inside ±WORLD_HALF
const VERT = /* glsl */ `
varying vec2 vWorld;
@ -43,52 +28,21 @@ const FRAG = /* glsl */ `
uniform float uHalf;
varying vec2 vWorld;
// value noise + 3-octave fbm — cheap terrain mottle, no texture fetch.
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));
return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);
}
float fbm(vec2 p){
float s = 0.0, a = 0.5;
for (int i=0;i<3;i++){ s += a*vnoise(p); p*=2.03; a*=0.5; }
return s;
}
// Antialiased grid: distance to the nearest cell border, in pixels.
float gridMask(vec2 coord, float width){
float gridMask(vec2 coord, float width) {
vec2 g = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
return 1.0 - min(min(g.x, g.y) / width, 1.0);
}
void main(){
// --- terrain tone: fbm mottle, tiny amplitude ---
float m = fbm(vWorld * 0.09);
vec3 col = uBase * (0.86 + m * 0.28); // ±~14% brightness swells
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // fine crust grain (kept from R1)
// --- the crust cools toward the rim: older, deeper, less lit ---
vec2 n = clamp(vWorld / uHalf, -1.0, 1.0);
float rim = smoothstep(0.55, 1.0, max(abs(n.x), abs(n.y)));
col *= 1.0 - rim * 0.35 * (0.6 + m * 0.8); // fbm-broken so it reads as sediment
// --- grid (unchanged — the placement affordance) ---
void main() {
vec3 col = uBase;
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
col = mix(col, uMajor, gridMask(vWorld / 8.0, 1.0) * 0.9);
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // crust grain
// --- world-edge dissolve into the void ---
vec2 d = abs(vWorld) / uHalf;
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
gl_FragColor = vec4(mix(uBase, col, inside), 1.0);
// COLOR MANAGEMENT — load-bearing, not boilerplate. THREE.Color.setHex() converts
// sRGB -> the linear working space, but a raw ShaderMaterial gets none of the output
// includes, so without this the linear value goes to the framebuffer untouched and
// the floor renders ~6x too dark: #0c0a15 measured as RGB(1,1,2). Every built-in
// material does this via the same chunk.
#include <colorspace_fragment>
}
`;

View File

@ -17,13 +17,7 @@ import { GhostLayer } from './ghost';
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
import { Showroom, showroomEnabled } from './showroom';
import { BeltTopology } from './topology';
import { SeamLayer, loadSeams } from './seams';
import { RelicLayer } from './relics';
import { WildlifeLayer, harassedIn } from './wildlife';
import { Juice } from './juice';
import { BreakRoom } from './breakroom';
import { WORLD } from './palette';
import { centerOf } from './coords';
const KEY_INTENSITY = 1.9;
const FILL_INTENSITY = 0.7;
@ -40,20 +34,10 @@ export function createRenderer(): Renderer {
let belts: BeltLayer;
let cargo: BeltItemLayer;
let ghost: GhostLayer;
let relics: RelicLayer;
let wildlife: WildlifeLayer;
let juice: Juice;
let breakroom: BreakRoom;
let seamLayer: SeamLayer | null = null;
let dev: DevScene | null = null;
let showroom: Showroom | null = null;
let host: HTMLElement;
let defs: Map<string, MachineDef>;
/** Shipment-flash detection: last frame's total shipped count, and the shipper tiles. */
let lastShipped = -1;
const shipperCentres: Vec2[] = [];
const itemColors = new Map<string, number>();
const lastPerItem = new Map<string, number>();
/** True on any dev scaffold page (devscene/showroom): enables stats + the debug hook. */
let debug = false;
const topo = new BeltTopology();
@ -68,90 +52,6 @@ export function createRenderer(): Renderer {
* RATE stays honest even when the tab is backgrounded and rAF is throttled. */
const stats = { ms: 0, avg: 0, frames: 0 };
/**
* Which entity the inspector has open, if any.
*
* BRIDGE: `Renderer` has no inspect channel `setGhost`'s mode is only build|remove,
* and main.ts calls it with 'build' during inspect, so nothing about the inspected
* entity reaches this lane. The typed selection DOES carry it (`{mode:'inspect',
* entity}`), and the bus is the designated cross-lane global, so we read it here.
* Same shape as the '__remove' sentinel bridge: it works, it's documented on both
* sides, and it wants a real contract hook. Filed in NOTES.
*/
function inspectedEntity(): number | null {
const bus = (window as unknown as { __fktryBus?: { selection?: () => unknown } }).__fktryBus;
const sel = bus?.selection?.() as { mode?: string; entity?: number } | null | undefined;
return sel && sel.mode === 'inspect' && typeof sel.entity === 'number' ? sel.entity : null;
}
/**
* 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.
*/
function shipmentFlash(view: SimSnapshot): void {
let total = 0;
let hottest: string | null = null;
let best = 0;
for (const k in view.shippedTotal) {
const n = view.shippedTotal[k];
total += n;
const prev = lastPerItem.get(k) ?? 0;
if (n - prev > best) { best = n - prev; hottest = k; }
lastPerItem.set(k, n);
}
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 });
}
const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f;
for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col);
}
lastShipped = total;
}
/** True when the pointer is over the arcade cabinet. */
function cabinetHit(clientX: number, clientY: number): boolean {
if (!breakroom) return false;
const rect = gl.domElement.getBoundingClientRect();
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, rig.camera);
return raycaster.intersectObjects(breakroom.clickable, false).length > 0;
}
/**
* The cabinet is a world object, so it gets world-object interaction: pointer cursor on
* hover, click to open. Listeners live on the CANVAS and stop propagation on a hit, so
* main.ts's #game handler never sees the click otherwise clicking the cabinet while
* holding a machine would also place one on the tile behind it.
*/
function installCabinetPointer(container: HTMLElement): void {
const canvas = gl.domElement;
canvas.addEventListener('pointermove', (e) => {
const over = cabinetHit(e.clientX, e.clientY);
// Only claim the cursor when nothing else is driving it (build/remove modes have
// their own affordance via the ghost).
canvas.style.cursor = over ? 'pointer' : '';
if (over) container.title = 'GLYTCH';
else if (container.title) container.title = '';
});
canvas.addEventListener(
'pointerdown',
(e) => {
if (e.button !== 0 || !cabinetHit(e.clientX, e.clientY)) return;
e.stopPropagation();
e.preventDefault();
window.open('./match.html', '_blank', 'noopener');
},
{ capture: true },
);
}
return {
async init(container: HTMLElement, data: GameData) {
defs = new Map(data.machines.map((m) => [m.id, m]));
@ -199,42 +99,14 @@ export function createRenderer(): Renderer {
registry = new AssetRegistry();
await registry.init(data);
juice = new Juice();
belts = new BeltLayer(registry, defs, topo);
entities = new EntityLayer(registry, defs, juice);
entities = new EntityLayer(registry, defs);
cargo = new BeltItemLayer(data, topo);
ghost = new GhostLayer(registry, defs, data);
relics = new RelicLayer();
wildlife = new WildlifeLayer();
scene.add(
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
relics.group, wildlife.group, juice.group,
);
// THE FLOOR: seam regions from data — era skins the crust, the ore glints.
seamLayer = new SeamLayer(loadSeams(), data);
scene.add(seamLayer.mesh);
// THE BREAK ROOM: the GLYTCH cabinet, a short walk south-east of the start zone.
// Position is chosen to land in CLICKABLE screen space at the default framing —
// at (-5,-5) it rendered underneath `#screen`, the decorative overlay that still
// takes pointer events, so the cabinet was both invisible and unclickable (see the
// B8 picking findings in NOTES).
breakroom = new BreakRoom(6, 12);
scene.add(breakroom.group);
installCabinetPointer(container);
// Item colours for the shipment flash, and the shipper tiles (recomputed on demand).
for (const it of data.items) itemColors.set(it.id, new THREE.Color(it.color).getHex());
scene.add(belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group);
debug = devSceneEnabled() || showroomEnabled();
// B8 review finding: the start zone must clear the build bar. The bar sits at
// screen-bottom, and in this iso rig screen-down = world +x/+z, so biasing the
// camera target south-east lifts world origin into the upper play area where a
// fresh game's first machines get placed. (Showroom/devscene set their own frame.)
if (!showroomEnabled()) rig.frame(3, 6, 15);
if (showroomEnabled()) {
showroom = new Showroom(data, registry);
scene.add(showroom.decor);
@ -275,12 +147,7 @@ export function createRenderer(): Renderer {
render(snap: SimSnapshot, alpha: number) {
if (!gl) return;
const t0 = debug ? performance.now() : 0;
// Clamp the frame delta. A backgrounded tab, an alt-tab, a GC pause or the first
// frame after load all hand back a delta of SECONDS (measured 63.7s), which
// integrates particles to infinity and kills every effect whose lifetime is
// shorter than the hitch — juice simply never appeared. main.ts clamps the sim's
// accumulator for the same reason; this is the renderer's half.
const dt = Math.min(0.05, clock.getDelta());
const dt = clock.getDelta();
const t = clock.elapsedTime;
// The showroom is a standalone page, so it owns the frame outright. The devscene
@ -298,15 +165,9 @@ export function createRenderer(): Renderer {
// items take), so it is rebuilt once here rather than twice downstream.
topo.rebuild(view, defs);
belts.sync(view, t);
entities.sync(view, t, dt, harassedIn(view));
entities.sync(view, t, dt);
cargo.sync(view, alpha, t);
ghost.update(view, t, inspectedEntity());
relics.sync(view.relics, t);
wildlife.sync(view.wildlife, t);
seamLayer?.frame(t);
breakroom.frame(t);
shipmentFlash(view);
juice.update(dt, rig.camera);
ghost.update(view, t);
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
// exposure so emissives dim too — a brownout that left the glow at full would

View File

@ -1,122 +0,0 @@
/**
* LANE-RENDER juice. The difference between placing a machine and a machine appearing.
*
* The renderer never sees events (render() gets only the snapshot), so every cue here is
* derived from frame-to-frame deltas: an entity that wasn't there last frame was placed;
* one that vanished was demolished; a rise in `shippedTotal` is a shipment. EntityLayer
* owns the meshes so it does the scale-pop itself and calls in for the puff/burst; the
* shipment flash is driven from index.ts.
*
* One InstancedMesh particle pool for the whole game cheap, instanced, everywhere.
*/
import * as THREE from 'three';
const MAX = 512;
const GRAVITY = -6;
interface P {
x: number; y: number; z: number;
vx: number; vy: number; vz: number;
life: number; ttl: number;
size: number;
r: number; g: number; b: number;
}
export class Juice {
readonly group = new THREE.Group();
private mesh: THREE.InstancedMesh;
private pool: P[] = [];
private live: P[] = [];
private dummy = new THREE.Object3D();
private color = new THREE.Color();
constructor() {
this.group.name = 'juice';
const geo = new THREE.PlaneGeometry(1, 1);
const mat = new THREE.MeshBasicMaterial({
transparent: true, depthWrite: false, side: THREE.DoubleSide,
blending: THREE.AdditiveBlending, vertexColors: true,
});
this.mesh = new THREE.InstancedMesh(geo, mat, MAX);
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mesh.frustumCulled = false;
this.mesh.count = 0;
// per-instance colour
this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX * 3), 3);
this.group.add(this.mesh);
for (let i = 0; i < MAX; i++) {
this.pool.push({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, life: 0, ttl: 1, size: 0.1, r: 1, g: 1, b: 1 });
}
}
private emit(
x: number, z: number, n: number, spread: number, up: number,
size: number, ttl: number, col: number,
): void {
this.color.setHex(col);
for (let i = 0; i < n; i++) {
const p = this.pool.pop();
if (!p) break; // pool exhausted — drop the rest rather than allocate
const a = Math.random() * Math.PI * 2;
const s = Math.random() * spread;
p.x = x; p.y = 0.2; p.z = z;
p.vx = Math.cos(a) * s;
p.vz = Math.sin(a) * s;
p.vy = up * (0.6 + Math.random() * 0.8);
p.life = 0;
p.ttl = ttl * (0.7 + Math.random() * 0.6);
p.size = size * (0.7 + Math.random() * 0.6);
p.r = this.color.r; p.g = this.color.g; p.b = this.color.b;
this.live.push(p);
}
}
/** Placement: a soft ring of dust kicked outward. Pairs with EntityLayer's scale-pop. */
puff(cx: number, cz: number): void {
this.emit(cx, cz, 14, 2.2, 1.5, 0.18, 0.5, 0xbfb3a0);
}
/** Demolition: a sharper, brighter burst. */
burst(cx: number, cz: number): void {
this.emit(cx, cz, 22, 4.0, 3.0, 0.22, 0.55, 0xff6a3a);
}
/** Shipment: a quick upward flash of the shipped item's colour at the uplink. */
shipFlash(cx: number, cz: number, col: number): void {
this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col);
}
update(dt: number, camera: THREE.Camera): void {
// integrate + recycle
for (let i = this.live.length - 1; i >= 0; i--) {
const p = this.live[i];
p.life += dt;
if (p.life >= p.ttl) {
this.live[i] = this.live[this.live.length - 1];
this.live.pop();
this.pool.push(p);
continue;
}
p.vy += GRAVITY * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (p.y < 0.05) { p.y = 0.05; p.vy *= -0.3; p.vx *= 0.6; p.vz *= 0.6; }
}
// write instances, billboarded to the camera, fading over life
const q = camera.quaternion;
for (let i = 0; i < this.live.length; i++) {
const p = this.live[i];
const k = 1 - p.life / p.ttl;
const s = p.size * (0.4 + k * 0.6);
this.dummy.position.set(p.x, p.y, p.z);
this.dummy.quaternion.copy(q);
this.dummy.scale.setScalar(s);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
this.mesh.setColorAt(i, this.color.setRGB(p.r * k, p.g * k, p.b * k));
}
this.mesh.count = this.live.length;
this.mesh.instanceMatrix.needsUpdate = true;
if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
}
}

View File

@ -1,138 +0,0 @@
/**
* LANE-RENDER the relic. `snapshot.relics` (v6): optical fossils buried in the crust.
*
* The brief is a Mario hidden pipe: findable by a player who wanders and looks, invisible
* to one who doesn't. So an UNFOUND relic is only a faint static-patch decal that breathes
* no marker, no minimap ping, no arrow. Its discoverability budget is curiosity. The
* restraint is the feature: if you can see it from across the map it isn't hidden.
*
* FOUND (excavated) the patch becomes a small monument: a fossil slab on a plinth,
* lifting out of the ground, lit. §1: the optical soundtrack fossils *sing* when
* excavated the singing is LANE-SCREEN's audio band; this is the thing it sings from.
*/
import * as THREE from 'three';
import type { RelicState } from '../contracts';
import { disposeObject } from './coords';
const SHIMMER_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const SHIMMER_FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
varying vec2 vUv;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
void main() {
vec2 p = vUv * 2.0 - 1.0;
float rr = dot(p, p);
if (rr > 1.0) discard;
// static crawl: a hidden VHS dropout, quantised so it flickers rather than smears.
vec2 g = floor(vUv * 22.0) + floor(vec2(uTime * 9.0));
float s = hash(g);
// breathe the whole patch so a still eye catches motion, but faintly.
float breathe = 0.5 + 0.5 * sin(uTime * 1.3);
float edge = 1.0 - smoothstep(0.4, 1.0, rr);
float a = s * edge * (0.05 + 0.06 * breathe); // deliberately tiny
gl_FragColor = vec4(vec3(0.7, 0.75, 0.85) * s, a);
}
`;
interface Rec {
found: boolean;
obj: THREE.Object3D;
}
const PLANE = new THREE.PlaneGeometry(1, 1);
export class RelicLayer {
readonly group = new THREE.Group();
private live = new Map<number, Rec>();
private seen = new Set<number>();
private shimmerUniforms = { uTime: { value: 0 } };
private shimmerMat: THREE.ShaderMaterial;
constructor() {
this.group.name = 'relics';
this.shimmerMat = new THREE.ShaderMaterial({
uniforms: this.shimmerUniforms,
vertexShader: SHIMMER_VERT,
fragmentShader: SHIMMER_FRAG,
transparent: true,
depthWrite: false,
});
}
sync(relics: ReadonlyArray<RelicState> | undefined, timeSec: 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);
if (rec && rec.found !== r.found) {
this.drop(r.id, rec); // excavated: swap shimmer → monument
rec = undefined;
}
if (!rec) {
const obj = r.found ? this.buildMonument() : this.buildShimmer();
obj.position.set(r.pos.x + 0.5, 0, r.pos.y + 0.5);
this.group.add(obj);
rec = { found: r.found, obj };
this.live.set(r.id, rec);
}
if (r.found) {
// gentle lift + rotate so the excavated fossil reads as an exhibit
rec.obj.rotation.y = timeSec * 0.5;
rec.obj.position.y = 0.05 + Math.sin(timeSec * 1.5) * 0.03;
}
}
for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec);
}
private buildShimmer(): THREE.Object3D {
const m = new THREE.Mesh(PLANE, this.shimmerMat);
m.rotation.x = -Math.PI / 2;
m.scale.set(2.4, 2.4, 1);
m.position.y = 0.02;
m.renderOrder = 2;
return m;
}
private buildMonument(): THREE.Object3D {
const g = new THREE.Group();
const plinth = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.6, 0.4, 12),
new THREE.MeshStandardMaterial({ color: 0x14121d, roughness: 0.9, emissive: 0x1a2740, emissiveIntensity: 0.4 }),
);
plinth.position.y = 0.2;
plinth.castShadow = plinth.receiveShadow = true;
// The fossil: a wavy photographic strip that "sings" — an emissive slab, gamut-lit.
const strip = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.8, 0.5),
new THREE.MeshStandardMaterial({
color: 0xffe0b0, emissive: 0xffcaa0, emissiveIntensity: 1.3,
roughness: 0.3, metalness: 0.4,
}),
);
strip.position.y = 0.8;
strip.castShadow = true;
g.add(plinth, strip);
g.userData.owns = true;
return g;
}
private drop(id: number, rec: Rec): void {
this.group.remove(rec.obj);
if (rec.obj.userData.owns) disposeObject(rec.obj); // monuments own their geo/mats
// shimmers share PLANE + shimmerMat — nothing to dispose
this.live.delete(id);
}
}

View File

@ -1,188 +0,0 @@
/**
* LANE-RENDER mineral seams: the glinting patches that tell you where to mine.
*
* Data is `data/seams.json` (LANE-DATA). Its doc sets the division of labour:
* SIM an extractor extracts only while its footprint overlaps a seam rect.
* RENDER `era` skins the ground; `resource` is the stratum's codex payload.
* So this layer does two jobs at once: it skins each region with its era (§1 strata,
* with a wide soft falloff so it reads as a whisper across the crust, not a decal), and
* inside the rect it lays the mineral itself obsidian darkening with the resource's
* colour flickering through fracture veins (§2: "thin glowing rainbow circuit veins").
*
* The tell is deliberately quiet. There is NO label and no icon: a seam is a patch of
* ground that glints. A player who looks discovers that extractors want to sit on them;
* a player who doesn't just sees texture. `richness` drives how strongly it glints, so
* the rich remote seams are the ones that reward wandering.
*/
import * as THREE from 'three';
import type { GameData } from '../contracts';
import seamsFile from '../../data/seams.json';
export interface SeamRect { x: number; y: number; w: number; h: number }
export interface SeamDef {
id: string;
era: string;
resource: string;
rect: SeamRect;
richness: number;
}
/** §1 strata palettes — the era whisper laid over the crust around each seam. */
const ERA_TINT: Record<string, number> = {
reel: 0x3a2a16, // celluloid shale, silver-nitrate brown
broadcast: 0x14301c, // phosphor dunes
disc: 0x241634, // rainbow-on-black gloss
stream: 0x1e2430, // flat plastic crust
fortress: 0x2c3038,
};
/** How far past the rect the era whisper bleeds, in tiles. */
const ERA_BLEED = 7;
const VERT = /* glsl */ `
attribute vec3 aEra;
attribute vec3 aOre;
attribute vec2 aHalf; // rect half-extent in tiles (for the inside/outside test)
attribute float aRich;
varying vec3 vEra;
varying vec3 vOre;
varying vec2 vHalf;
varying float vRich;
varying vec2 vLocal; // tiles from the rect centre
varying vec2 vWorld;
void main() {
vEra = aEra; vOre = aOre; vHalf = aHalf; vRich = aRich;
vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = wp.xz;
// instanceMatrix scales a unit quad to (rect + bleed); recover tiles-from-centre.
vLocal = position.xz * (aHalf + vec2(${ERA_BLEED}.0)) * 2.0;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
varying vec3 vEra;
varying vec3 vOre;
varying vec2 vHalf;
varying float vRich;
varying vec2 vLocal;
varying vec2 vWorld;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
return mix(mix(hash(i), hash(i+vec2(1,0)), f.x),
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y);
}
void main(){
vec2 d = abs(vLocal) - vHalf; // >0 outside the rect, in tiles
// --- era whisper: strongest at the rect, faded out over ERA_BLEED tiles ---
float outside = max(max(d.x, d.y), 0.0);
float era = 1.0 - smoothstep(0.0, ${ERA_BLEED}.0, outside);
// fbm-break the edge so a rect never reads as a rectangle
era *= 0.55 + 0.45 * vnoise(vWorld * 0.35);
// --- the mineral itself: only inside the rect ---
// Soft-edged so a seam never reads as a rectangle you could have drawn in CSS, but
// the fade is SHORT: SIM extracts on any footprint/rect overlap, so a glint that died
// two tiles inside the boundary would under-report where mining actually works.
float body = (1.0 - smoothstep(-0.8, 0.6, max(d.x, d.y)))
* (0.6 + 0.4 * vnoise(vWorld * 0.55));
// GLINT, not veins. The first pass drew continuous sinusoidal lines and they read as
// green worms crawling over the floor — organic, loud, competing with the belts.
// Mineral glitter is sparse and it TWINKLES: quantise to a fleck grid, keep the top
// few percent, and let each fleck sparkle on its own clock.
vec2 g = vWorld * 7.0;
vec2 cell = floor(g);
float pick = hash(cell);
// Shape a round fleck inside its cell instead of filling the cell — a full cell reads
// as confetti at this zoom, a point reads as a mineral fleck.
vec2 sub = fract(g) - 0.5 - (vec2(hash(cell + 3.7), hash(cell + 9.1)) - 0.5) * 0.5;
float dot_ = 1.0 - smoothstep(0.10, 0.30, length(sub));
float fleck = smoothstep(0.90 - vRich * 0.10, 1.0, pick) * dot_;
float twinkle = 0.35 + 0.65 * pow(max(0.0, sin(uTime * 2.2 + pick * 43.0)), 3.0);
float glint = fleck * twinkle * body;
vec3 col = vEra * era * 0.75; // stratum tint on the crust
col = mix(col, vOre, min(1.0, glint * 1.6)); // ore colour only in the flecks
float a = era * 0.22 + body * 0.09 + glint * (0.35 + vRich * 0.4);
if (a < 0.004) discard;
gl_FragColor = vec4(col, a);
// sRGB out — a raw ShaderMaterial gets none of the output includes (see ground.ts).
#include <colorspace_fragment>
}
`;
export class SeamLayer {
readonly mesh: THREE.InstancedMesh;
private uniforms = { uTime: { value: 0 } };
private dummy = new THREE.Object3D();
constructor(seams: SeamDef[], data: GameData) {
const geo = new THREE.PlaneGeometry(1, 1);
geo.rotateX(-Math.PI / 2); // lie flat on the ground
const n = Math.max(1, seams.length);
const era = new Float32Array(n * 3);
const ore = new Float32Array(n * 3);
const half = new Float32Array(n * 2);
const rich = new Float32Array(n);
const c = new THREE.Color();
const mat = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
});
this.mesh = new THREE.InstancedMesh(geo, mat, n);
this.mesh.name = 'seams';
this.mesh.renderOrder = -1; // above the ground plane, below everything else
this.mesh.frustumCulled = false;
seams.forEach((s, i) => {
const { x, y, w, h } = s.rect;
// Quad covers the rect plus the era bleed on every side.
this.dummy.position.set(x + w / 2, 0.012, y + h / 2);
this.dummy.scale.set(w + ERA_BLEED * 2, 1, h + ERA_BLEED * 2);
this.dummy.updateMatrix();
this.mesh.setMatrixAt(i, this.dummy.matrix);
c.setHex(ERA_TINT[s.era] ?? ERA_TINT.stream);
era.set([c.r, c.g, c.b], i * 3);
const item = data.items.find((it) => it.id === s.resource);
c.set(item?.color ?? '#8899aa');
ore.set([c.r, c.g, c.b], i * 3);
half.set([w / 2, h / 2], i * 2);
rich[i] = s.richness ?? 0.5;
});
this.mesh.count = seams.length;
this.mesh.instanceMatrix.needsUpdate = true;
geo.setAttribute('aEra', new THREE.InstancedBufferAttribute(era, 3));
geo.setAttribute('aOre', new THREE.InstancedBufferAttribute(ore, 3));
geo.setAttribute('aHalf', new THREE.InstancedBufferAttribute(half, 2));
geo.setAttribute('aRich', new THREE.InstancedBufferAttribute(rich, 1));
}
frame(timeSec: number): void {
this.uniforms.uTime.value = timeSec;
}
}
/** The seams DATA authored. Statically imported so it works in a production build too. */
export function loadSeams(): SeamDef[] {
return ((seamsFile as { seams?: SeamDef[] }).seams ?? []) as SeamDef[];
}
/** The SE corner DATA keeps seam-free so the relic always has somewhere to land. */
export function relicReserve(): SeamRect | null {
return (seamsFile as { relicReserve?: SeamRect }).relicReserve ?? null;
}

View File

@ -1,110 +0,0 @@
/**
* LANE-RENDER wildlife: dust piles and mosquito swarms (§2/§5, M2's living hazards).
*
* 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.
*
* 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.
*/
import * as THREE from 'three';
import type { SimSnapshot, WildlifeState } from '../contracts';
const GNATS_PER_SWARM = 24;
/** HF dust: pale, faintly iridescent glitter. Emissive so heaps sparkle at iso distance. */
function pileMaterial(): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: 0xe8e0ff, emissive: 0xb0a0ff, emissiveIntensity: 0.5,
roughness: 0.3, metalness: 0.6, flatShading: true,
});
}
export class WildlifeLayer {
readonly group = new THREE.Group();
private piles: THREE.InstancedMesh;
private gnats: THREE.InstancedMesh;
private pileCap = 64;
private gnatCap = 64 * GNATS_PER_SWARM;
private dummy = new THREE.Object3D();
/** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */
readonly harassed = new Set<number>();
constructor() {
this.group.name = 'wildlife';
// A low, faceted heap; instance scale grows it with `size`.
const pileGeo = new THREE.IcosahedronGeometry(0.5, 0);
pileGeo.scale(1, 0.5, 1);
this.piles = new THREE.InstancedMesh(pileGeo, pileMaterial(), this.pileCap);
this.piles.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.piles.castShadow = true;
this.piles.frustumCulled = false;
this.piles.count = 0;
const gnatGeo = new THREE.PlaneGeometry(0.08, 0.08);
const gnatMat = new THREE.MeshBasicMaterial({
color: 0x101014, transparent: true, opacity: 0.85, side: THREE.DoubleSide,
});
this.gnats = new THREE.InstancedMesh(gnatGeo, gnatMat, this.gnatCap);
this.gnats.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.gnats.frustumCulled = false;
this.gnats.count = 0;
this.group.add(this.piles, this.gnats);
}
sync(wildlife: ReadonlyArray<WildlifeState> | undefined, timeSec: number): void {
this.harassed.clear();
const list = wildlife ?? [];
let pileN = 0;
let gnatN = 0;
for (const w of list) {
if (w.kind === 'dust-pile') {
if (pileN >= this.pileCap) continue;
const s = 0.4 + THREE.MathUtils.clamp(w.size, 0, 1) * 1.3;
this.dummy.position.set(w.pos.x + 0.5, 0.05 * s, w.pos.y + 0.5);
this.dummy.rotation.set(0, w.id * 1.7, 0); // desync facets between heaps
this.dummy.scale.set(s, s, s);
this.dummy.updateMatrix();
this.piles.setMatrixAt(pileN++, this.dummy.matrix);
} else {
if (w.target !== undefined) this.harassed.add(w.target);
const cx = w.pos.x + 0.5;
const cz = w.pos.y + 0.5;
const density = Math.round(GNATS_PER_SWARM * (0.4 + w.size * 0.6));
for (let i = 0; i < density && gnatN < this.gnatCap; i++) {
// Each gnat orbits the swarm centre on its own little chaotic path.
const ph = w.id * 3.1 + i * 0.7;
const rad = 0.5 + 0.5 * Math.sin(timeSec * 3 + ph * 1.3);
const a = timeSec * (1.5 + (i % 3) * 0.4) + ph;
this.dummy.position.set(
cx + Math.cos(a) * rad,
0.5 + 0.4 * Math.sin(timeSec * 4 + ph) + w.size * 0.3,
cz + Math.sin(a * 1.1) * rad,
);
this.dummy.rotation.set(0, -a, 0); // face roughly along travel
this.dummy.scale.setScalar(1);
this.dummy.updateMatrix();
this.gnats.setMatrixAt(gnatN++, this.dummy.matrix);
}
}
}
this.piles.count = pileN;
this.piles.instanceMatrix.needsUpdate = true;
this.gnats.count = gnatN;
this.gnats.instanceMatrix.needsUpdate = true;
}
}
/** Convenience: which entities a snapshot's swarms are harassing (for EntityLayer). */
export function harassedIn(snap: SimSnapshot): Set<number> {
const out = new Set<number>();
for (const w of snap.wildlife ?? []) {
if (w.kind === 'mosquito-swarm' && w.target !== undefined) out.add(w.target);
}
return out;
}