glytch/fktry/src/render/relics.ts
type-two 0d491f8a2f [render] GLB legibility pass (emissive floor + accent beacon), parity-mite visuals, relic excavation VFX
- registry: texel-following emissive floor + one-impossible-element accent beacon on every
  hot-swapped GLB (generic, no hardcoded list) — restores the two-material rule on baked assets
- wildlife: parity-mite beetles with green checkmark eye, size-driven state tell (hunt/repair/
  sated), derived 'repaired' tidy sparkle + firewall-can 'filed paperwork' cue
- relics/juice: golden excavation geyser on unfound->found transition
- devscene: parity-mite fixtures across all three states for verification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:00:58 +10:00

148 lines
5.2 KiB
TypeScript

/**
* 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';
import type { Juice } from './juice';
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;
/** False until the first sync: a save loaded with a relic already found must not fire
* the excavation geyser, exactly as EntityLayer primes before firing placement juice. */
private primed = false;
constructor(private juice?: Juice) {
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);
const justExcavated = !!rec && !rec.found && r.found;
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);
}
// The fossil surfaces: a golden geyser that "sings" up out of the crust. Gated on a
// real unfound→found transition seen live (not a save that loads already-excavated).
if (justExcavated && this.primed) this.juice?.excavate(r.pos.x + 0.5, r.pos.y + 0.5);
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);
this.primed = true;
}
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);
}
}