diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index fc5ab40..5977a8f 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -207,8 +207,13 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = { mesh.frustumCulled = false; mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.count = 0; + // One float per instance: how hard THIS body was just hit. emissive.js declares `aFlash` + // unconditionally, so a pool that never attaches this simply renders as it always did. + const aFlash = new THREE.InstancedBufferAttribute(new Float32Array(def.cfg.pool), 1); + aFlash.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute('aFlash', aFlash); scene.add(mesh); - pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false }; + pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false, aFlash }; } // --- darts: the turret's homing projectile (hostile => white-hot) ------------------ @@ -276,15 +281,32 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = { // pings so the player learns "wait for the window" instead of "my gun is broken". // armor — LANE_C_NOTES P2: two regrown nodes are armoured and ONLY the antacid torpedo // strips them. The torpedo spends its armour-stripping hit rather than damaging. - if (e.invuln) { e.flash = 1; bus.emit('audio:cue', { name: 'gate_hit' }); return; } + // `enemy:hit` exists so the UI can answer the question the player is actually asking — + // did that connect, and did it do anything? Nothing announced a landed shot before: the + // reticle knew only whether the trigger was down. `blocked` is the important one: hitting + // a shut iris or an armoured plate is NOT a miss, and a player who cannot tell the two + // apart concludes the gun is broken. + if (e.invuln) { + e.flash = 1; + bus.emit('enemy:hit', { id: e.id, type: e.type, dmg: 0, blocked: true, dead: false }); + bus.emit('audio:cue', { name: 'gate_hit' }); + return; + } if (e.armor) { - if (kind !== 'torpedo') { e.flash = 1; bus.emit('audio:cue', { name: 'gate_hit' }); return; } + if (kind !== 'torpedo') { + e.flash = 1; + bus.emit('enemy:hit', { id: e.id, type: e.type, dmg: 0, blocked: true, dead: false }); + bus.emit('audio:cue', { name: 'gate_hit' }); + return; + } e.armor = false; + bus.emit('enemy:hit', { id: e.id, type: e.type, dmg: 0, blocked: false, dead: false }); bus.emit('audio:cue', { name: 'enemy_hit' }); return; } e.hp -= dmg; e.flash = 1; + bus.emit('enemy:hit', { id: e.id, type: e.type, dmg, blocked: false, dead: e.hp <= 0, foe: e.foe !== false }); if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; } kill(e, kind); } @@ -637,18 +659,25 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = { } else { _q.setFromAxisAngle(FORWARD, e.phase); } + // Same index for both, or the flash lands on somebody else's body: `n` is the + // COMPACTED draw index, which shifts every time anything in the pool dies. + pool.aFlash.array[n] = e.flash; pool.mesh.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1))); } pool.mesh.count = n; pool.mesh.instanceMatrix.needsUpdate = true; - // one flash uniform per type: a hit reads as the whole cohort blinking, which is - // wrong. Round 2: per-instance colour attribute. Noted in NOTES. - const hot = pool.slots.some((e) => e.alive && e.flash > 0.5); + pool.aFlash.needsUpdate = true; + // THE HIT LANDS ON THE THING YOU HIT. This was one uFlash uniform for the whole type, and + // the comment here said so: shoot one bolus chunk in a room of eight and all eight blinked + // white. That is not weak feedback, it is WRONG feedback — the one signal telling you + // which body absorbed the shot was being broadcast to every body that did not. Per-instance + // now (aFlash above); uFlash is left to the only thing that is genuinely per-cohort: // LANE_C_NOTES telegraph: "nodes amber -> white-hot when vulnerable". The boss's open // window is already expressed as `invuln`, so the tell costs one uniform write and can - // never disagree with the hitbox — the thing that glows IS the thing you can hurt. + // never disagree with the hitbox — the thing that glows IS the thing you can hurt. This + // one IS per-cohort — every open node is open at once — so a uniform is right for it. const openNode = type === 'mucosal_node' && pool.slots.some((e) => e.alive && !e.invuln); - pool.mat.uniforms.uFlash.value = hot ? 0.6 : (openNode ? 0.45 : 0); + pool.mat.uniforms.uFlash.value = openNode ? 0.45 : 0; } } diff --git a/web/js/flight/emissive.js b/web/js/flight/emissive.js index fe5e079..6ef00af 100644 --- a/web/js/flight/emissive.js +++ b/web/js/flight/emissive.js @@ -49,7 +49,13 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1, matc }, 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.) @@ -66,7 +72,7 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1, matc gl_Position = projectionMatrix * mv; }`, fragmentShader: /* glsl */` - varying vec3 vN; varying vec3 vV; + 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; @@ -84,7 +90,10 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1, matc 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); + // 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, diff --git a/web/js/flight/player.js b/web/js/flight/player.js index b3dbdb3..7dbe87c 100644 --- a/web/js/flight/player.js +++ b/web/js/flight/player.js @@ -26,6 +26,17 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null const reticle = buildReticle(); scene.add(ship.group, reticle.mesh); + // HITMARKER. combat/enemies.js announces every landed shot; the reticle pops for it. A + // blocked hit (shut iris, armoured plate) gets a deliberately smaller pop than a landed one + // and a kill gets the biggest — three readings from one instrument, which is what stops + // "hit but shielded" from feeling like "missed". + const HIT_DECAY = 7; // ~140 ms — long enough to see, short enough to stay per-shot + let hitMark = 0; + const offHit = bus?.on?.('enemy:hit', (e) => { + const v = e?.dead ? 1 : e?.blocked ? 0.35 : 0.7; + if (v > hitMark) hitMark = v; // a kill in the same frame as a graze reads as the kill + }) ?? null; + const st = { s: 2, x: 0, y: 0, // spline-space position vx: 0, vy: 0, // disc velocity (u/s) @@ -206,6 +217,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null reticle.mesh.position.copy(_aim); if (camera) reticle.mesh.lookAt(camera.position); reticle.setHot(intent.fire ? 1 : 0); + if (hitMark > 0) { hitMark = Math.max(0, hitMark - dt * HIT_DECAY); reticle.setHit(hitMark); } // the ship's nose chases the reticle rather than snapping to it (Star Fox lag) if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; } @@ -347,6 +359,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null }, dispose() { scene.remove(ship.group, reticle.mesh); + offHit?.(); ship.dispose(); reticle.dispose(); input.dispose(); }, }; diff --git a/web/js/flight/ship.js b/web/js/flight/ship.js index 2516960..b10758f 100644 --- a/web/js/flight/ship.js +++ b/web/js/flight/ship.js @@ -78,9 +78,22 @@ export function buildReticle() { dot.renderOrder = 10; mesh.add(dot); + // A HITMARKER. The reticle only ever knew whether the trigger was DOWN — information the + // player already had, from their own finger — and said nothing about whether the shot + // connected. In a tube where a foe can be hit and unharmed (armour), hit and shielded + // (the Guardian's shut iris), or simply missed in the fog, that is the single most useful + // thing this instrument could be telling you. + // The tell is a SIZE POP, not just brightness: firing already brightens it, so brightness + // alone would be one signal doing two jobs. Firing is dimmed to make room. + let hot = 0, hit = 0; + const apply = () => { + mat.uniforms.uFlash.value = Math.max(hot * 0.55, hit); + mesh.scale.setScalar(1 + hit * 0.5); + }; return { mesh, - setHot(v) { mat.uniforms.uFlash.value = v; }, // hot while the cannon is firing + setHot(v) { hot = v; apply(); }, // hot while the cannon is firing + setHit(v) { hit = v; apply(); }, // 0..1, decayed by player.js dispose() { geo.dispose(); dotGeo.dispose(); mat.dispose(); }, }; }