[lane B+F] The enemies were never wearing their meshes: boot forgot to pass assets to combat

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>
This commit is contained in:
type-two 2026-07-26 18:31:14 +10:00
parent 6d3bac8d41
commit 5a891107e2
4 changed files with 42 additions and 7 deletions

View File

@ -55,7 +55,12 @@ scene.background = new THREE.Color(world.biomeAt(0).palette.void);
const rng = createRng(flags.seed ?? world.level?.seed ?? 0); const rng = createRng(flags.seed ?? world.level?.seed ?? 0);
const player = flags.fly ? null const player = flags.fly ? null
: createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement }); : createPlayer({ scene, world, bus, rng, flags, camera, dom: renderer.domElement });
const combat = player ? createCombat({ scene, world, bus, rng, flags, player }) : null; // `assets` is not optional here, and leaving it out was a silent, months-old bug: combat
// defaults it to null, so `assets.get('models', type)` in enemies.js always returned null and
// EVERY enemy quietly drew its procedural primitive. Lane D's GLBs loaded fine, were verified
// fine, and were never once rendered — the fallback path is indistinguishable from success
// unless you check the pool's geometry, which nothing did.
const combat = player ? createCombat({ scene, world, bus, rng, flags, assets, player }) : null;
// --- UI (Lane E). Constructed here because #ui needs an owner and E is bus-only: it never // --- UI (Lane E). Constructed here because #ui needs an owner and E is bus-only: it never
// sees player/combat, just the events they emit. After combat so every subscription exists // sees player/combat, just the events they emit. After combat so every subscription exists

View File

@ -177,7 +177,12 @@ const ARCHETYPES = {
}, },
}; };
export function createEnemies({ scene, world, bus, rng, assets = null }) { export function createEnemies({ scene, world, bus, rng, assets = null, flags = {} }) {
// Wet-tissue sheen on entities (playtest: "more detailed looking enemies"). Optional twice
// over: absent if D's matcap has not shipped, and switchable with ?matcap=0 so the flat look
// can be A/B'd against it without a rebuild. It only ever modulates brightness — see
// emissive.js — so the friend/foe colour law is unaffected either way.
const entMatcap = flags.matcap === false ? null : (assets?.matcap?.('tissue_wet') ?? null);
const rand = rng ? rng('enemies') : () => 0.5; const rand = rng ? rng('enemies') : () => 0.5;
const pools = {}; const pools = {};
let nextId = 1; let nextId = 1;
@ -197,7 +202,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
const geo = glb?.geometry const geo = glb?.geometry
? glb.geometry.clone().scale(def.cfg.radius, def.cfg.radius, def.cfg.radius) ? glb.geometry.clone().scale(def.cfg.radius, def.cfg.radius, def.cfg.radius)
: def.geo(); : def.geo();
const mat = emissiveMat(def.color); const mat = emissiveMat(def.color, { matcap: entMatcap });
const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool); const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool);
mesh.frustumCulled = false; mesh.frustumCulled = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);

View File

@ -16,7 +16,7 @@ import { createBoss } from './boss.js';
import { getEnemy } from '../levels/enemies.js'; import { getEnemy } from '../levels/enemies.js';
export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) { export function createCombat({ scene, world, bus, rng, flags = {}, assets = null, player }) {
const enemies = createEnemies({ scene, world, bus, rng, assets }); const enemies = createEnemies({ scene, world, bus, rng, assets, flags });
const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies }); const weapons = createWeapons({ scene, world, bus, rng, player, targets: enemies });
const hazards = createHazards({ scene, world, bus, rng, player }); const hazards = createHazards({ scene, world, bus, rng, player });
const pickups = createPickups({ scene, world, bus, rng, player, weapons }); const pickups = createPickups({ scene, world, bus, rng, player, weapons });

View File

@ -24,8 +24,18 @@ export const EMISSIVE = {
acid: { core: 0xc8ff3a, rim: 0xeaffb0 }, acid: { core: 0xc8ff3a, rim: 0xeaffb0 },
}; };
export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = {}) { /**
* `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({ return new THREE.ShaderMaterial({
defines: matcap ? { USE_ENT_MATCAP: '' } : {},
transparent: opacity < 1 || additive, transparent: opacity < 1 || additive,
depthWrite: !additive, depthWrite: !additive,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
@ -34,6 +44,8 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = {
uRim: { value: new THREE.Color(rim) }, uRim: { value: new THREE.Color(rim) },
uOpacity: { value: opacity }, uOpacity: { value: opacity },
uFlash: { value: 0 }, // driven on hit: 0..1 blows the whole body to rim colour 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 */` vertexShader: /* glsl */`
varying vec3 vN; varying vec3 vV; varying vec3 vN; varying vec3 vV;
@ -56,9 +68,22 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = {
fragmentShader: /* glsl */` fragmentShader: /* glsl */`
varying vec3 vN; varying vec3 vV; varying vec3 vN; varying vec3 vV;
uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash; 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() { void main() {
float f = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0); vec3 N = normalize(vN);
vec3 col = mix(uCore, uRim, f) + uRim * f * 0.6; 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); col = mix(col, uRim * 1.6, uFlash);
gl_FragColor = vec4(col, uOpacity); gl_FragColor = vec4(col, uOpacity);