diff --git a/web/js/boot.js b/web/js/boot.js index 31de9b5..e503eab 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -55,7 +55,12 @@ scene.background = new THREE.Color(world.biomeAt(0).palette.void); const rng = createRng(flags.seed ?? world.level?.seed ?? 0); const player = flags.fly ? null : 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 // sees player/combat, just the events they emit. After combat so every subscription exists diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index 022da2e..01deed6 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -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 pools = {}; let nextId = 1; @@ -197,7 +202,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) { const geo = glb?.geometry ? glb.geometry.clone().scale(def.cfg.radius, def.cfg.radius, def.cfg.radius) : def.geo(); - const mat = emissiveMat(def.color); + const mat = emissiveMat(def.color, { matcap: entMatcap }); const mesh = new THREE.InstancedMesh(geo, mat, def.cfg.pool); mesh.frustumCulled = false; mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); diff --git a/web/js/combat/index.js b/web/js/combat/index.js index d996284..e155f17 100644 --- a/web/js/combat/index.js +++ b/web/js/combat/index.js @@ -16,7 +16,7 @@ import { createBoss } from './boss.js'; import { getEnemy } from '../levels/enemies.js'; 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 hazards = createHazards({ scene, world, bus, rng, player }); const pickups = createPickups({ scene, world, bus, rng, player, weapons }); diff --git a/web/js/flight/emissive.js b/web/js/flight/emissive.js index ddb3a66..fe5e079 100644 --- a/web/js/flight/emissive.js +++ b/web/js/flight/emissive.js @@ -24,8 +24,18 @@ export const EMISSIVE = { 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({ + defines: matcap ? { USE_ENT_MATCAP: '' } : {}, transparent: opacity < 1 || additive, depthWrite: !additive, 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) }, 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; @@ -56,9 +68,22 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1 } = { 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() { - float f = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.0); - vec3 col = mix(uCore, uRim, f) + uRim * f * 0.6; + 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);