- 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>
148 lines
5.2 KiB
TypeScript
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);
|
|
}
|
|
}
|