guts/web/js/flight/emissive.js
type-two 426bf3f33d [lane B] Hits land on the thing you hit, and the reticle finally says so
Audit item 12.

THE WHOLE COHORT WAS BLINKING. Enemy flash was ONE uFlash uniform per type, and the comment
above it said so and deferred the fix: shoot one bolus chunk in a room of eight and all eight
blinked white. That is not weak feedback, it is WRONG feedback — the single signal that tells
you which body absorbed your shot was being broadcast to every body that did not, in a fogged
tube where telling them apart is the entire skill. Per-instance now: an `aFlash`
InstancedBufferAttribute written at the same compacted draw index as the matrix (they must
match, or the flash lands on somebody else's body once anything in the pool dies). Verified
with five floaters drawn and the middle one shot: aFlash reads [0, 0, 0.9, 0, 0].

emissive.js declares the attribute unconditionally, so a plain Mesh or a pool that never
attaches one gets WebGL's default 0 and renders exactly as before. uFlash stays for the one
effect that IS per-cohort — the boss's open-node tell, where every node opens at once.

THE RETICLE ONLY KNEW ABOUT THE TRIGGER. setHot was driven by `intent.fire`, so the instrument
in the middle of the screen reported whether the player's own finger was down and said nothing
about whether the shot connected. In a game where a foe can be hit-and-unharmed (armoured
plate), hit-and-shielded (the Guardian's shut iris) or simply missed in the fog, that is the
most useful thing it could have been telling you — and the shut-iris case is exactly the one
where a player without feedback concludes the gun is broken. enemies.js now announces
`enemy:hit {dmg, blocked, dead}` and the reticle pops for it: 0.35 blocked, 0.7 landed, 1.0
kill. The tell is a SIZE pop, not brightness alone, because firing already brightens it and one
signal cannot do two jobs — firing is dimmed to 0.55 to make the room.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:25:50 +10:00

109 lines
5.8 KiB
JavaScript

// flight/emissive.js (Lane B) — the one material behind every glowing thing B draws.
//
// ART_BIBLE: gameplay-relevant things glow, and the scene has NO lights (walls fake theirs
// with fresnel). So our primitives are unlit: a core colour with a rim that hots up at
// grazing angles, which reads as a volume against the dark tint instead of a flat decal.
// One material per (colour, rim) pair + InstancedMesh = 1 draw call per enemy/projectile
// type, which is how the whole combat layer fits in its 80-draw budget.
//
// Lives in flight/ rather than combat/ to keep the dependency arrow one-way: combat imports
// flight (it needs the player), flight never imports combat.
import * as THREE from 'three';
// ART_BIBLE §Emissive code — the readability law. Never hand-pick a colour outside this.
export const EMISSIVE = {
hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core
hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot
neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive)
ally: { core: 0x33ffbe, rim: 0xd6fff0 }, // teal — an active friendly unit (phage).
// distinct from cyan "don't shoot" flora:
// this one HELPS, and reads as its own thing.
pickup: { core: 0x7dffb0, rim: 0xdaffe9 },
gate: { core: 0xb06aff, rim: 0xe6d0ff },
acid: { core: 0xc8ff3a, rim: 0xeaffb0 },
};
/**
* `matcap` (optional) — the wet-tissue sheen, and the reason it is safe to put on a system whose
* whole job is colour-coding: it modulates BRIGHTNESS ONLY. The sampled matcap is reduced to a
* luminance and multiplied into the core, so an amber foe stays exactly amber and a cyan
* commensal stays exactly cyan — the ART_BIBLE readability law is untouched by construction,
* while the silhouette stops being a flat glow and starts reading as a lit, wet, three-
* dimensional object. That is the whole ask from playtest: detail, without changing what the
* colours mean.
*/
export function emissiveMat({ core, rim }, { additive = false, opacity = 1, matcap = null } = {}) {
return new THREE.ShaderMaterial({
defines: matcap ? { USE_ENT_MATCAP: '' } : {},
transparent: opacity < 1 || additive,
depthWrite: !additive,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
uniforms: {
uCore: { value: new THREE.Color(core) },
uRim: { value: new THREE.Color(rim) },
uOpacity: { value: opacity },
uFlash: { value: 0 }, // driven on hit: 0..1 blows the whole body to rim colour
uMatcap: { value: matcap },
uMatcapGain: { value: 0.85 }, // how much of the form the matcap is allowed to carve
},
vertexShader: /* glsl */`
varying vec3 vN; varying vec3 vV;
// PER-INSTANCE HIT FLASH. Declared unconditionally on purpose: a geometry that carries no
// aFlash simply leaves the attribute unbound, and WebGL feeds the default 0 — so a plain
// Mesh and an instanced pool that has not opted in both behave exactly as before.
attribute float aFlash;
varying float vFlash;
void main() {
vFlash = aFlash;
// three declares the instanceMatrix attribute for us whenever the drawn object is
// an InstancedMesh (USE_INSTANCING); this same material still works on a plain Mesh.
// (Careful: no backticks in here — this is a JS template literal.)
#ifdef USE_INSTANCING
mat4 m = modelViewMatrix * instanceMatrix;
#else
mat4 m = modelViewMatrix;
#endif
vec4 mv = m * vec4(position, 1.0);
// mat3(m) is only a true normal matrix under uniform scale — every instance we
// emit is uniformly scaled, and normalize() eats the leftover constant.
vN = normalize(mat3(m) * normal);
vV = -mv.xyz;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
varying vec3 vN; varying vec3 vV; varying float vFlash;
uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash;
#ifdef USE_ENT_MATCAP
uniform sampler2D uMatcap; uniform float uMatcapGain;
#endif
void main() {
vec3 N = normalize(vN);
float f = pow(1.0 - abs(dot(N, normalize(vV))), 2.0);
vec3 core = uCore;
#ifdef USE_ENT_MATCAP
// View-space normal -> matcap uv, the same lookup the wall uses. Reduced to LUMINANCE
// and applied multiplicatively: form, never hue. The pedestal keeps unlit faces
// readable instead of letting them go black, because a dark half on a 1.4-unit enemy
// in a fogged tube is a lost enemy.
float m = dot(texture2D(uMatcap, N.xy * 0.5 + 0.5).rgb, vec3(0.299, 0.587, 0.114));
core *= (1.0 - uMatcapGain) + uMatcapGain * (0.45 + 1.25 * m);
#endif
vec3 col = mix(core, uRim, f) + uRim * f * 0.6;
// max(), not add: uFlash is the WHOLE-COHORT tell (the boss's open-node glow, which is
// per-type by nature) and vFlash is the per-instance hit. The louder one wins, so a hit
// still reads on a node that is already glowing.
col = mix(col, uRim * 1.6, max(uFlash, vFlash));
gl_FragColor = vec4(col, uOpacity);
// MANDATORY for any custom ShaderMaterial in this project. ColorManagement is on,
// so THREE.Color(0xff5a2a) is converted to LINEAR working space on the way in, but
// a hand-written fragment shader gets no automatic conversion on the way out — it
// would write linear straight into the sRGB target. Measured: hostile amber landed
// as rgb(255,26,6) blood-red instead of rgb(255,90,42), quietly breaking the
// ART_BIBLE emissive readability law. This chunk applies linear -> sRGB.
#include <colorspace_fragment>
}`,
});
}