Chasing "more detailed looking enemies" turned up the reason detail never landed, and it was
not the art.
boot.js built combat as `createCombat({ scene, world, bus, rng, flags, player })` — no `assets`.
createCombat defaults it to null, so createEnemies got null, so `assets?.get?.('models', type)`
returned null for EVERY archetype and every enemy in the game has been quietly drawing its
procedural primitive. Lane D's GLBs loaded correctly, verified correctly, and were never once
rendered. spore_pod has been an icosahedron since the day it shipped; the Giardia and H. pylori
meshes from an hour ago were cones.
This is the nastiest shape a bug can have: the fallback path is INDISTINGUISHABLE from success.
`assets.get('models','spore_pod').geometry` was there and correct every time I checked it — I
was checking the loader, never the pool that consumes it. Measured after the one-word fix: pool
geometry goes 42 verts -> 6056 (spore_pod), and the new pathogens render at 2234 (Giardia) and
2500 (H. pylori).
Also in: the entity matcap the same investigation was written to prototype. emissiveMat takes an
optional matcap and applies it as LUMINANCE ONLY — sampled, reduced to luma, multiplied into the
core — so an amber foe stays exactly amber and a cyan commensal stays exactly cyan. The
ART_BIBLE readability law that the whole friend/foe system rests on is untouched by construction,
while the silhouettes stop being flat glows and start reading as lit, wet, three-dimensional
organisms. Switchable with ?matcap=0 for A/B, and absent-safe if D's matcap never shipped.
Weapons and pickups deliberately keep the flat look — a tracer is not a creature.
Net effect for playtest: this is the first build where the pathogens are actually visible as
pathogens. The GLB pipeline was correct for weeks; the wiring was one word short.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
5.2 KiB
JavaScript
100 lines
5.2 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;
|
|
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;
|
|
#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;
|
|
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>
|
|
}`,
|
|
});
|
|
}
|