The review verdict was "the game about place has no place". The root cause was not a missing feature: a raw ShaderMaterial gets none of the renderer's output includes, so THREE.Color's sRGB->linear conversion was never undone and the crust rendered ~6x too dark. Measured: authored #0c0a15 (RGB 12,10,21) came out RGB(1,1,2). One #include <colorspace_fragment> and it measures (10,8,18). Every raw shader in this lane has been that dark since round 1. Round 5 orders 1-8: - ground.ts: fbm terrain mottle + neutral rim + the colorspace fix. - seams.ts: 8 seam regions from data/seams.json. Era skins the crust with a wide falloff; the ore glints as sparse twinkling flecks (first pass drew continuous veins and they read as green worms competing with the belts). Fleck fade is short on purpose: SIM extracts on any footprint/rect overlap, so a glint dying inside the boundary would under-report where mining works. - relics.ts: unfound = a breathing static patch, found = fossil on a plinth. Measured +39 contrast vs floor at wandering zoom, -18 at overview zoom — findable by looking, invisible from above. - wildlife.ts: dust-pile glitter heaps + gnat swarms, harassed machines stutter. Two draw calls for the whole ecosystem. - juice.ts: placement scale-pop + puff, demolition burst, shipment flash, all derived from snapshot deltas since render() never sees events. - breakroom.ts: the GLYTCH cabinet, own raycast, opens match.html. - ghost.ts: inspect highlight + cooler aura (closes round 4's placed-and- selected gap). Also fixes juice never appearing: clock.getDelta() returns the real gap and a backgrounded tab hands back SECONDS (measured 63.7s), so particles were born and killed inside one update and the integrator flung them to infinity. render() now clamps dt to 0.05. Default framing lifts world origin clear of the build bar. 500 entities + 2,000 items + 50 piles + 198 gnats + the floor: 4.4ms/frame, 8 draw calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.5 KiB
TypeScript
189 lines
7.5 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|