Compare commits
2 Commits
e9661dd772
...
c441ec3a53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c441ec3a53 | ||
|
|
a89525ecbf |
@ -524,3 +524,144 @@ doc does. Read it there rather than from round 3.
|
|||||||
*relief* inside a cooler's rectangle once SIM lands spatial cooling; bloom-loop belt
|
*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
|
tell; HF dust piles; wildlife; save/load camera state; LOD/culling if a megabase ever
|
||||||
drags the frame.
|
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
|
||||||
|
5–15s 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.
|
||||||
|
|||||||
109
fktry/src/render/breakroom.ts
Normal file
109
fktry/src/render/breakroom.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,11 @@
|
|||||||
* by wrapping `t` on one belt, because that's what the real sim does — and it's what
|
* 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.
|
* exercises the renderer's cross-belt interpolation and corner pathing honestly.
|
||||||
*/
|
*/
|
||||||
import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts';
|
import {
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot,
|
||||||
|
type WildlifeState,
|
||||||
|
} from '../contracts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
|
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
|
||||||
@ -92,6 +96,24 @@ export class DevScene {
|
|||||||
this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items
|
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
|
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;
|
this.ok = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -125,6 +147,19 @@ export class DevScene {
|
|||||||
if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1);
|
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('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize');
|
||||||
if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh');
|
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;
|
this.ok = true;
|
||||||
}
|
}
|
||||||
@ -181,9 +216,21 @@ export class DevScene {
|
|||||||
// the ghost's padlock is for. `unlocked` is writable from the console to check the
|
// the ghost's padlock is for. `unlocked` is writable from the console to check the
|
||||||
// other half (see NOTES).
|
// other half (see NOTES).
|
||||||
research: this.research,
|
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. */
|
/** Mutable so a verification session can unlock techs and watch padlocks clear. */
|
||||||
readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] };
|
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 }];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@ import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from
|
|||||||
import { centerOf, yawFor } from './coords';
|
import { centerOf, yawFor } from './coords';
|
||||||
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
|
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
|
||||||
import { createHeatHaze, type HeatHaze } from './heat';
|
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"). */
|
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
|
||||||
const THROTTLE_AT = 0.7;
|
const THROTTLE_AT = 0.7;
|
||||||
@ -54,6 +58,8 @@ interface Rec {
|
|||||||
all: HeatTarget[];
|
all: HeatTarget[];
|
||||||
/** Whether dead-dark is currently applied, so restart knows to restore. */
|
/** Whether dead-dark is currently applied, so restart knows to restore. */
|
||||||
wasScrammed: boolean;
|
wasScrammed: boolean;
|
||||||
|
/** Seconds into the placement scale-pop; >= POP_TIME means settled. */
|
||||||
|
pop: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
|
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
|
||||||
@ -62,6 +68,8 @@ export class EntityLayer {
|
|||||||
readonly group = new THREE.Group();
|
readonly group = new THREE.Group();
|
||||||
private live = new Map<number, Rec>();
|
private live = new Map<number, Rec>();
|
||||||
private seen = new Set<number>();
|
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. */
|
/** One shared material: every alarm blinks together, like a real klaxon. */
|
||||||
private jamMat = new THREE.MeshStandardMaterial({
|
private jamMat = new THREE.MeshStandardMaterial({
|
||||||
color: JAM_AMBER,
|
color: JAM_AMBER,
|
||||||
@ -69,11 +77,15 @@ export class EntityLayer {
|
|||||||
emissiveIntensity: 1.5,
|
emissiveIntensity: 1.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
|
constructor(
|
||||||
|
private registry: AssetRegistry,
|
||||||
|
private defs: Map<string, MachineDef>,
|
||||||
|
private juice?: Juice,
|
||||||
|
) {
|
||||||
this.group.name = 'entities';
|
this.group.name = 'entities';
|
||||||
}
|
}
|
||||||
|
|
||||||
sync(snap: SimSnapshot, timeSec: number, dt: number): void {
|
sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set<number>): void {
|
||||||
this.seen.clear();
|
this.seen.clear();
|
||||||
let anyAlarm = false;
|
let anyAlarm = false;
|
||||||
|
|
||||||
@ -102,7 +114,16 @@ export class EntityLayer {
|
|||||||
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
|
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
|
||||||
rec = undefined;
|
rec = undefined;
|
||||||
}
|
}
|
||||||
if (!rec) rec = this.build(e.id, e.def, def, entry.version);
|
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.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
|
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
|
||||||
centerOf(e.pos, def, e.dir, rec.obj.position);
|
centerOf(e.pos, def, e.dir, rec.obj.position);
|
||||||
@ -136,11 +157,31 @@ export class EntityLayer {
|
|||||||
const alarm = !!e.jammed || scrammed;
|
const alarm = !!e.jammed || scrammed;
|
||||||
rec.jam.visible = alarm;
|
rec.jam.visible = alarm;
|
||||||
if (alarm) anyAlarm = true;
|
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) {
|
for (const [id, rec] of this.live) {
|
||||||
if (!this.seen.has(id)) this.drop(id, rec);
|
if (!this.seen.has(id)) {
|
||||||
|
this.juice?.burst(rec.obj.position.x, rec.obj.position.z);
|
||||||
|
this.drop(id, rec);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
this.primed = true;
|
||||||
|
|
||||||
if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
|
if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
|
||||||
}
|
}
|
||||||
@ -250,6 +291,7 @@ export class EntityLayer {
|
|||||||
heat,
|
heat,
|
||||||
all,
|
all,
|
||||||
wasScrammed: false,
|
wasScrammed: false,
|
||||||
|
pop: POP_TIME, // settled by default; a real placement rewinds it to 0
|
||||||
};
|
};
|
||||||
this.live.set(id, rec);
|
this.live.set(id, rec);
|
||||||
return rec;
|
return rec;
|
||||||
|
|||||||
@ -90,6 +90,10 @@ export class GhostLayer {
|
|||||||
private lockedBy = new Map<string, TechDef>();
|
private lockedBy = new Map<string, TechDef>();
|
||||||
/** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
|
/** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
|
||||||
private anyCoolers = false;
|
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(
|
constructor(
|
||||||
private registry: AssetRegistry,
|
private registry: AssetRegistry,
|
||||||
@ -102,6 +106,19 @@ export class GhostLayer {
|
|||||||
this.padlock.obj.visible = false;
|
this.padlock.obj.visible = false;
|
||||||
this.group.add(this.padlock.obj);
|
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));
|
const machines = new Set(data.machines.map((m) => m.id));
|
||||||
for (const t of data.tech) {
|
for (const t of data.tech) {
|
||||||
for (const u of t.unlocks) {
|
for (const u of t.unlocks) {
|
||||||
@ -196,9 +213,13 @@ export class GhostLayer {
|
|||||||
return snap.research?.unlocked.includes(tech.id) ? null : tech;
|
return snap.research?.unlocked.includes(tech.id) ? null : tech;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(snap: SimSnapshot, timeSec: number): void {
|
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);
|
||||||
|
|
||||||
if (!this.pos) {
|
if (!this.pos) {
|
||||||
this.aura.hide();
|
if (!this.inspectAura) this.aura.hide();
|
||||||
this.remove.hide();
|
this.remove.hide();
|
||||||
this.group.visible = false;
|
this.group.visible = false;
|
||||||
return;
|
return;
|
||||||
@ -258,11 +279,43 @@ 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
|
* 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.
|
* holding a cooler the question is where its coverage lands, not what's already there.
|
||||||
*/
|
*/
|
||||||
private updateAura(timeSec: number): void {
|
private updateAura(timeSec: number): void {
|
||||||
|
if (this.inspectAura) return; // the inspector owns the aura this frame
|
||||||
if (!this.anyCoolers || !this.pos) {
|
if (!this.anyCoolers || !this.pos) {
|
||||||
this.aura.hide();
|
this.aura.hide();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1,15 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* LANE-RENDER — Stream-Crust ground plane.
|
* LANE-RENDER — Stream-Crust ground plane. THE FLOOR (round 5).
|
||||||
*
|
*
|
||||||
* A shader grid rather than GridHelper: resolution-independent antialiased lines,
|
* Round 1 shipped a grid over flat crust. That read as "the void with lines on it",
|
||||||
* major lines every 8 tiles, faint scanline grain, and a dissolve at the world edge
|
* which the review named: the game about place had no place. This makes the plane read
|
||||||
* so the Bitstream fades into the void instead of ending on a hard rectangle.
|
* 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.
|
||||||
*/
|
*/
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { WORLD } from './palette';
|
import { WORLD } from './palette';
|
||||||
import { WORLD_HALF } from './camera';
|
import { WORLD_HALF } from './camera';
|
||||||
|
|
||||||
const PLANE = WORLD_HALF * 5; // extends past the world; grid only drawn inside ±WORLD_HALF
|
const PLANE = WORLD_HALF * 5; // extends past the world; detail only drawn inside ±WORLD_HALF
|
||||||
|
|
||||||
const VERT = /* glsl */ `
|
const VERT = /* glsl */ `
|
||||||
varying vec2 vWorld;
|
varying vec2 vWorld;
|
||||||
@ -28,21 +43,52 @@ const FRAG = /* glsl */ `
|
|||||||
uniform float uHalf;
|
uniform float uHalf;
|
||||||
varying vec2 vWorld;
|
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.
|
// 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);
|
vec2 g = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
|
||||||
return 1.0 - min(min(g.x, g.y) / width, 1.0);
|
return 1.0 - min(min(g.x, g.y) / width, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main(){
|
||||||
vec3 col = uBase;
|
// --- 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) ---
|
||||||
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
|
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
|
||||||
col = mix(col, uMajor, gridMask(vWorld / 8.0, 1.0) * 0.9);
|
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;
|
vec2 d = abs(vWorld) / uHalf;
|
||||||
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
|
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
|
||||||
gl_FragColor = vec4(mix(uBase, col, inside), 1.0);
|
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>
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,13 @@ import { GhostLayer } from './ghost';
|
|||||||
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
||||||
import { Showroom, showroomEnabled } from './showroom';
|
import { Showroom, showroomEnabled } from './showroom';
|
||||||
import { BeltTopology } from './topology';
|
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 { WORLD } from './palette';
|
||||||
|
import { centerOf } from './coords';
|
||||||
|
|
||||||
const KEY_INTENSITY = 1.9;
|
const KEY_INTENSITY = 1.9;
|
||||||
const FILL_INTENSITY = 0.7;
|
const FILL_INTENSITY = 0.7;
|
||||||
@ -34,10 +40,20 @@ export function createRenderer(): Renderer {
|
|||||||
let belts: BeltLayer;
|
let belts: BeltLayer;
|
||||||
let cargo: BeltItemLayer;
|
let cargo: BeltItemLayer;
|
||||||
let ghost: GhostLayer;
|
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 dev: DevScene | null = null;
|
||||||
let showroom: Showroom | null = null;
|
let showroom: Showroom | null = null;
|
||||||
let host: HTMLElement;
|
let host: HTMLElement;
|
||||||
let defs: Map<string, MachineDef>;
|
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. */
|
/** True on any dev scaffold page (devscene/showroom): enables stats + the debug hook. */
|
||||||
let debug = false;
|
let debug = false;
|
||||||
const topo = new BeltTopology();
|
const topo = new BeltTopology();
|
||||||
@ -52,6 +68,90 @@ export function createRenderer(): Renderer {
|
|||||||
* RATE — stays honest even when the tab is backgrounded and rAF is throttled. */
|
* RATE — stays honest even when the tab is backgrounded and rAF is throttled. */
|
||||||
const stats = { ms: 0, avg: 0, frames: 0 };
|
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 {
|
return {
|
||||||
async init(container: HTMLElement, data: GameData) {
|
async init(container: HTMLElement, data: GameData) {
|
||||||
defs = new Map(data.machines.map((m) => [m.id, m]));
|
defs = new Map(data.machines.map((m) => [m.id, m]));
|
||||||
@ -99,14 +199,42 @@ export function createRenderer(): Renderer {
|
|||||||
registry = new AssetRegistry();
|
registry = new AssetRegistry();
|
||||||
await registry.init(data);
|
await registry.init(data);
|
||||||
|
|
||||||
|
juice = new Juice();
|
||||||
belts = new BeltLayer(registry, defs, topo);
|
belts = new BeltLayer(registry, defs, topo);
|
||||||
entities = new EntityLayer(registry, defs);
|
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);
|
||||||
scene.add(belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group);
|
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());
|
||||||
|
|
||||||
debug = devSceneEnabled() || showroomEnabled();
|
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()) {
|
if (showroomEnabled()) {
|
||||||
showroom = new Showroom(data, registry);
|
showroom = new Showroom(data, registry);
|
||||||
scene.add(showroom.decor);
|
scene.add(showroom.decor);
|
||||||
@ -147,7 +275,12 @@ export function createRenderer(): Renderer {
|
|||||||
render(snap: SimSnapshot, alpha: number) {
|
render(snap: SimSnapshot, alpha: number) {
|
||||||
if (!gl) return;
|
if (!gl) return;
|
||||||
const t0 = debug ? performance.now() : 0;
|
const t0 = debug ? performance.now() : 0;
|
||||||
const dt = clock.getDelta();
|
// 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 t = clock.elapsedTime;
|
const t = clock.elapsedTime;
|
||||||
|
|
||||||
// The showroom is a standalone page, so it owns the frame outright. The devscene
|
// The showroom is a standalone page, so it owns the frame outright. The devscene
|
||||||
@ -165,9 +298,15 @@ export function createRenderer(): Renderer {
|
|||||||
// items take), so it is rebuilt once here rather than twice downstream.
|
// items take), so it is rebuilt once here rather than twice downstream.
|
||||||
topo.rebuild(view, defs);
|
topo.rebuild(view, defs);
|
||||||
belts.sync(view, t);
|
belts.sync(view, t);
|
||||||
entities.sync(view, t, dt);
|
entities.sync(view, t, dt, harassedIn(view));
|
||||||
cargo.sync(view, alpha, t);
|
cargo.sync(view, alpha, t);
|
||||||
ghost.update(view, 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);
|
||||||
|
|
||||||
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
|
// 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
|
// exposure so emissives dim too — a brownout that left the glow at full would
|
||||||
|
|||||||
122
fktry/src/render/juice.ts
Normal file
122
fktry/src/render/juice.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
fktry/src/render/relics.ts
Normal file
138
fktry/src/render/relics.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
fktry/src/render/seams.ts
Normal file
188
fktry/src/render/seams.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
110
fktry/src/render/wildlife.ts
Normal file
110
fktry/src/render/wildlife.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user