/** * 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 = { 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 } `; 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; }