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
|
||||
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
|
||||
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
|
||||
* 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:
|
||||
@ -92,6 +96,24 @@ 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;
|
||||
}
|
||||
@ -125,6 +147,19 @@ 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;
|
||||
}
|
||||
@ -181,9 +216,21 @@ 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 }];
|
||||
}
|
||||
|
||||
@ -17,6 +17,10 @@ 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;
|
||||
@ -54,6 +58,8 @@ 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);
|
||||
@ -62,6 +68,8 @@ 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,
|
||||
@ -69,11 +77,15 @@ export class EntityLayer {
|
||||
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';
|
||||
}
|
||||
|
||||
sync(snap: SimSnapshot, timeSec: number, dt: number): void {
|
||||
sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set<number>): void {
|
||||
this.seen.clear();
|
||||
let anyAlarm = false;
|
||||
|
||||
@ -102,7 +114,16 @@ 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);
|
||||
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) {
|
||||
centerOf(e.pos, def, e.dir, rec.obj.position);
|
||||
@ -136,11 +157,31 @@ 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.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;
|
||||
}
|
||||
@ -250,6 +291,7 @@ 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;
|
||||
|
||||
@ -90,6 +90,10 @@ 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,
|
||||
@ -102,6 +106,19 @@ 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) {
|
||||
@ -196,9 +213,13 @@ export class GhostLayer {
|
||||
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) {
|
||||
this.aura.hide();
|
||||
if (!this.inspectAura) this.aura.hide();
|
||||
this.remove.hide();
|
||||
this.group.visible = false;
|
||||
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
|
||||
* 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;
|
||||
|
||||
@ -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,
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { WORLD } from './palette';
|
||||
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 */ `
|
||||
varying vec2 vWorld;
|
||||
@ -28,21 +43,52 @@ 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() {
|
||||
vec3 col = uBase;
|
||||
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) ---
|
||||
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>
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@ -17,7 +17,13 @@ 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;
|
||||
@ -34,10 +40,20 @@ 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();
|
||||
@ -52,6 +68,90 @@ 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]));
|
||||
@ -99,14 +199,42 @@ 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);
|
||||
entities = new EntityLayer(registry, defs, juice);
|
||||
cargo = new BeltItemLayer(data, topo);
|
||||
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();
|
||||
|
||||
// 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);
|
||||
@ -147,7 +275,12 @@ export function createRenderer(): Renderer {
|
||||
render(snap: SimSnapshot, alpha: number) {
|
||||
if (!gl) return;
|
||||
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;
|
||||
|
||||
// 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.
|
||||
topo.rebuild(view, defs);
|
||||
belts.sync(view, t);
|
||||
entities.sync(view, t, dt);
|
||||
entities.sync(view, t, dt, harassedIn(view));
|
||||
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
|
||||
// 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