[lane B] Hits land on the thing you hit, and the reticle finally says so
Audit item 12.
THE WHOLE COHORT WAS BLINKING. Enemy flash was ONE uFlash uniform per type, and the comment
above it said so and deferred the fix: shoot one bolus chunk in a room of eight and all eight
blinked white. That is not weak feedback, it is WRONG feedback — the single signal that tells
you which body absorbed your shot was being broadcast to every body that did not, in a fogged
tube where telling them apart is the entire skill. Per-instance now: an `aFlash`
InstancedBufferAttribute written at the same compacted draw index as the matrix (they must
match, or the flash lands on somebody else's body once anything in the pool dies). Verified
with five floaters drawn and the middle one shot: aFlash reads [0, 0, 0.9, 0, 0].
emissive.js declares the attribute unconditionally, so a plain Mesh or a pool that never
attaches one gets WebGL's default 0 and renders exactly as before. uFlash stays for the one
effect that IS per-cohort — the boss's open-node tell, where every node opens at once.
THE RETICLE ONLY KNEW ABOUT THE TRIGGER. setHot was driven by `intent.fire`, so the instrument
in the middle of the screen reported whether the player's own finger was down and said nothing
about whether the shot connected. In a game where a foe can be hit-and-unharmed (armoured
plate), hit-and-shielded (the Guardian's shut iris) or simply missed in the fog, that is the
most useful thing it could have been telling you — and the shut-iris case is exactly the one
where a player without feedback concludes the gun is broken. enemies.js now announces
`enemy:hit {dmg, blocked, dead}` and the reticle pops for it: 0.35 blocked, 0.7 landed, 1.0
kill. The tell is a SIZE pop, not brightness alone, because firing already brightens it and one
signal cannot do two jobs — firing is dimmed to 0.55 to make the room.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
975e1a4ec3
commit
426bf3f33d
@ -207,8 +207,13 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = {
|
|||||||
mesh.frustumCulled = false;
|
mesh.frustumCulled = false;
|
||||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
mesh.count = 0;
|
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);
|
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) ------------------
|
// --- 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".
|
// 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
|
// 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.
|
// 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 (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;
|
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' });
|
bus.emit('audio:cue', { name: 'enemy_hit' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.hp -= dmg;
|
e.hp -= dmg;
|
||||||
e.flash = 1;
|
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; }
|
if (e.hp > 0) { bus.emit('audio:cue', { name: 'enemy_hit' }); return; }
|
||||||
kill(e, kind);
|
kill(e, kind);
|
||||||
}
|
}
|
||||||
@ -637,18 +659,25 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = {
|
|||||||
} else {
|
} else {
|
||||||
_q.setFromAxisAngle(FORWARD, e.phase);
|
_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.setMatrixAt(n++, _m.compose(e.pos, _q, _scale.setScalar(1)));
|
||||||
}
|
}
|
||||||
pool.mesh.count = n;
|
pool.mesh.count = n;
|
||||||
pool.mesh.instanceMatrix.needsUpdate = true;
|
pool.mesh.instanceMatrix.needsUpdate = true;
|
||||||
// one flash uniform per type: a hit reads as the whole cohort blinking, which is
|
pool.aFlash.needsUpdate = true;
|
||||||
// wrong. Round 2: per-instance colour attribute. Noted in NOTES.
|
// THE HIT LANDS ON THE THING YOU HIT. This was one uFlash uniform for the whole type, and
|
||||||
const hot = pool.slots.some((e) => e.alive && e.flash > 0.5);
|
// 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
|
// 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
|
// 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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,13 @@ export function emissiveMat({ core, rim }, { additive = false, opacity = 1, matc
|
|||||||
},
|
},
|
||||||
vertexShader: /* glsl */`
|
vertexShader: /* glsl */`
|
||||||
varying vec3 vN; varying vec3 vV;
|
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() {
|
void main() {
|
||||||
|
vFlash = aFlash;
|
||||||
// three declares the instanceMatrix attribute for us whenever the drawn object is
|
// 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.
|
// an InstancedMesh (USE_INSTANCING); this same material still works on a plain Mesh.
|
||||||
// (Careful: no backticks in here — this is a JS template literal.)
|
// (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;
|
gl_Position = projectionMatrix * mv;
|
||||||
}`,
|
}`,
|
||||||
fragmentShader: /* glsl */`
|
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;
|
uniform vec3 uCore; uniform vec3 uRim; uniform float uOpacity; uniform float uFlash;
|
||||||
#ifdef USE_ENT_MATCAP
|
#ifdef USE_ENT_MATCAP
|
||||||
uniform sampler2D uMatcap; uniform float uMatcapGain;
|
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);
|
core *= (1.0 - uMatcapGain) + uMatcapGain * (0.45 + 1.25 * m);
|
||||||
#endif
|
#endif
|
||||||
vec3 col = mix(core, uRim, f) + uRim * f * 0.6;
|
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);
|
gl_FragColor = vec4(col, uOpacity);
|
||||||
|
|
||||||
// MANDATORY for any custom ShaderMaterial in this project. ColorManagement is on,
|
// MANDATORY for any custom ShaderMaterial in this project. ColorManagement is on,
|
||||||
|
|||||||
@ -26,6 +26,17 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
const reticle = buildReticle();
|
const reticle = buildReticle();
|
||||||
scene.add(ship.group, reticle.mesh);
|
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 = {
|
const st = {
|
||||||
s: 2, x: 0, y: 0, // spline-space position
|
s: 2, x: 0, y: 0, // spline-space position
|
||||||
vx: 0, vy: 0, // disc velocity (u/s)
|
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);
|
reticle.mesh.position.copy(_aim);
|
||||||
if (camera) reticle.mesh.lookAt(camera.position);
|
if (camera) reticle.mesh.lookAt(camera.position);
|
||||||
reticle.setHot(intent.fire ? 1 : 0);
|
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)
|
// the ship's nose chases the reticle rather than snapping to it (Star Fox lag)
|
||||||
if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; }
|
if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; }
|
||||||
@ -347,6 +359,7 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
scene.remove(ship.group, reticle.mesh);
|
scene.remove(ship.group, reticle.mesh);
|
||||||
|
offHit?.();
|
||||||
ship.dispose(); reticle.dispose(); input.dispose();
|
ship.dispose(); reticle.dispose(); input.dispose();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -78,9 +78,22 @@ export function buildReticle() {
|
|||||||
dot.renderOrder = 10;
|
dot.renderOrder = 10;
|
||||||
mesh.add(dot);
|
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 {
|
return {
|
||||||
mesh,
|
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(); },
|
dispose() { geo.dispose(); dotGeo.dispose(); mat.dispose(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user