guts/web/js/flight/emissive.js
type-two 71b0663b77 [lane B+C] The phage ally: tend the flora and the biome fights for you
The first ALLY, and the reputation loop that earns it — "don't shoot the biome, it helps you"
(V2_IDEAS) made mechanical.

- combat/enemies.js + balance.js: the `phage` archetype. A friendly seeker that hunts the
  nearest FOE (not the player) and rams it — a bacteriophage bursts its host after `bursts`
  hits. Fast (26 > every foe) so it catches things, fragile (hp 8) so it is spent, not a wall.
  Entities now carry a `foe` flag (foes true; flora + phage false); kill() generalised so ANY
  ally scores nothing and never combos. Fallback silhouette is a bare icosahedron = the phage's
  real icosahedral capsid. Teal (EMISSIVE.ally), distinct from cyan "don't shoot" flora.

- combat/index.js: BIOME STANDING — one run-scoped float. flora:tended raises it, flora:harmed
  drops it 25; at the symbiotic threshold the biome BUDS a phage near you (capped 3, 3s cooldown),
  spending standing. Emits `standing {value}` for a future Lane E meter.

- levels/enemies.js: ARCHETYPES += phage; catalogue `phage`.

Verified deterministically in-engine: tending flora budded a phage, which hunted a spore_pod foe
(30hp) and rammed it dead; three-colour readability holds (amber foe / teal ally / cyan friend).

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

75 lines
3.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 },
};
export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = {}) {
return new THREE.ShaderMaterial({
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
},
vertexShader: /* glsl */`
varying vec3 vN; varying vec3 vV;
void main() {
// 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;
uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash;
void main() {
float f = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0);
vec3 col = mix(uCore, uRim, f) + uRim * f * 0.6;
col = mix(col, uRim * 1.6, uFlash);
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>
}`,
});
}