guts/web/js/combat/index.js
type-two 5a891107e2 [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>
2026-07-26 18:31:14 +10:00

164 lines
8.9 KiB
JavaScript

// combat/index.js (Lane B) — the createCombat factory. Composes enemies + weapons + hazards
// + pickups and feeds them Lane C's level events. All the wiring, none of the behaviour.
//
// Update order inside a frame is load-bearing:
// boot: player.update(dt) -> combat.update(dt)
// here: enemies.update(dt) -> weapons.update(dt) -> hazards.update(dt) -> pickups.update(dt)
// Enemies move first so weapons hit-test against this frame's positions, not last frame's.
// Hazards after weapons so a torpedo fired THIS frame can neutralize a surge THIS frame
// (weapons emits level:neutralize on detonation; hazards listens).
import { createEnemies } from './enemies.js';
import { createWeapons } from './weapons.js';
import { createHazards } from './hazards.js';
import { createPickups } from './pickups.js';
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, 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 });
// Bosses are a SCHEDULE over the enemy pool, not a parallel entity system — see boss.js.
const boss = createBoss({ bus, rng, player, enemies, flags, getKarma: () => karma });
enemies.setBlastFx((pos, size, dur) => weapons.spawnBlast(pos, size, dur));
enemies.setOnDeath((e) => weapons.spawnBlast(e.pos, e.radius * 1.6, 0.3));
const score = { value: 0, kills: 0, samples: 0 };
// Fan a group of `count` out along s. C's `spread` is the authored span of the group
// ("count 4 spread 120" = a field, not a wall); without one, a tight default fan keeps
// a count from stacking at a single arclength.
const fan = (ev, i) => {
const n = ev.count ?? 1;
if (n <= 1) return ev.s ?? 0;
const span = ev.spread ?? (n - 1) * 3.5;
return (ev.s ?? 0) + (i / (n - 1)) * span;
};
// C authors `theta` as a scalar (whole group) or an array (per-member — "two blooms
// covering opposite arcs"). Placement is composition, so C's word is law here.
const thetaFor = (ev, i) =>
(Array.isArray(ev.theta) ? ev.theta[i % ev.theta.length] : (ev.theta ?? null));
// Lane C's events arrive on the bus as the player crosses their `s`. Spawns and pickups are
// consumed here; hazards run their own timeline off world.level.events (they need lookahead
// for the warn telegraph — see hazards.js header); checkpoints/gates are boot's (the pump
// owns respawn/complete semantics); unknown types are ignored on purpose so C can add event
// kinds without breaking combat.
const offEvent = bus.on('level:event', (ev) => {
if (!ev) return;
if (ev.type === 'spawn') {
// Levels name enemies in fiction ("bolus_chunk"); pools are keyed by archetype
// ("floater"). levels/enemies.js is the join table (C's catalogue, round-2 task #1).
const spec = getEnemy(ev.enemy);
if (!spec) { console.warn(`[combat] spawn "${ev.enemy}" is not in levels/enemies.js — skipped`); return; }
for (let i = 0; i < (ev.count ?? 1); i++)
enemies.spawn(spec.archetype, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} else if (ev.type === 'pickup') {
for (let i = 0; i < (ev.count ?? 1); i++)
pickups.spawn(ev.kind, { s: fan(ev, i), theta: thetaFor(ev, i), rho: ev.rho ?? null });
} else if (ev.type === 'biofilm') {
biofilmGate(ev);
}
});
const offDie = bus.on('enemy:die', (e) => {
score.value += e.score ?? 0;
score.kills++;
});
// pickups.js applies the effect (refill/ammo) and announces; the score lives here so
// combat:state stays the one scoreboard E reads.
const offPickup = bus.on('pickup', (e) => {
score.value += e.score ?? 0;
score.samples += e.sample ?? 0;
});
// BIOME STANDING — the flora reputation loop (V2_IDEAS: "don't shoot the biome, it helps you
// in level 5"). Tend flora (fly through its aura -> flora:tended) to earn it; shoot flora
// (flora:harmed) to lose a chunk. At the symbiotic threshold the biome BUDS phage allies near
// you that hunt your foes — the payoff. A `standing` event feeds a future HUD meter (Lane E).
// ponytail: one run-scoped float, not a system. Persistence/tiers/L5-gate come when C needs them.
// Scaled 0..100 so E can meter it honestly: tending fills the bar, a full bar buds an ally
// and drops it to 50 (the recharge floor), harming flora empties a big chunk. Numbers are a
// first pass — C/playtest tunes the feel.
// TWO numbers, because they do two different jobs and conflating them is a bug I shipped once:
// standing — a SPENDABLE meter. Fills as you tend, and a full bar is CONSUMED to bud an ally
// (dropping to a 50 floor). It is a resource.
// karma — a LEDGER of conduct. Same inputs, but nothing ever spends it. This is what the
// Appendix Sovereign and the secret door read, because "how did you treat the
// biome across the campaign" must not be answerable by "how recently did you cash
// in". With one number, budding an ally at 100 dropped you to 50 and quietly made
// the Sovereign's best outcome unreachable.
let standing = 0, karma = 0, phageBudCd = 0;
const setStanding = (v) => { standing = Math.max(0, Math.min(100, v)); bus.emit('standing', { value: standing }); };
const setKarma = (v) => { karma = Math.max(0, Math.min(100, v)); bus.emit('karma', { value: karma }); };
const offTended = bus.on('flora:tended', (e) => {
const g = e?.gain ?? 5;
setStanding(standing + g); setKarma(karma + g); budPhage();
});
const offHarmed = bus.on('flora:harmed', () => { setStanding(standing - 35); setKarma(karma - 35); });
function budPhage() {
if (standing < 100 || phageBudCd > 0) return; // a FULL bar buds an ally
if (enemies.live.filter((e) => e.type === 'phage' && e.alive).length >= 3) return; // ally cap
if (enemies.spawn('phage', { s: player.state.s + 8 })) {
setStanding(50); // recharge floor — the next ally is halfway earned
phageBudCd = 3; // seconds between buds (decremented below)
bus.emit('audio:cue', { name: 'pickup' }); // a friendly chirp when an ally is born
}
}
// biofilm gate — the reputation CASH-OUT (V2_IDEAS: "it helps you in level 5"). Cross one with
// high BIOME STANDING and the native flora part the biofilm: a wave of phage allies buds and
// your coat is refilled, then the goodwill is spent. Low standing => it stays sealed and you run
// the gauntlet raw. Placed as a `biofilm` level event (`need`/`allies`/`coat` optional). Emits
// `biofilm {opened}` so E can voice it. The L5 payoff, built as a mechanism usable anywhere.
function biofilmGate(ev) {
const opened = standing >= (ev.need ?? 60);
if (opened) {
for (let i = 0; i < (ev.allies ?? 3); i++) enemies.spawn('phage', { s: player.state.s + 6 + i * 4 });
player.refill({ coat: ev.coat ?? 40 });
setStanding(20); // spent the goodwill
}
bus.emit('biofilm', { opened, s: ev.s });
bus.emit('audio:cue', { name: opened ? 'gate_pass' : 'gate_hit' });
}
function update(dt) {
if (phageBudCd > 0) phageBudCd -= dt;
// Before enemies.update: the boss decides this frame's invulnerability, so the window a
// player shoots into is the one they were just told about rather than one frame stale.
boss.update(dt);
enemies.update(dt, player);
weapons.update(dt);
hazards.update(dt);
pickups.update(dt);
// combat's slice of the HUD state (E reads player:state for the flight half)
bus.emit('combat:state', {
heat: weapons.heat, heatMax: weapons.heatMax, overheated: weapons.overheated,
ammo: weapons.ammo, ammoMax: weapons.ammoMax,
score: score.value, kills: score.kills, samples: score.samples, enemies: enemies.count,
});
}
return {
update,
enemies, weapons, hazards, pickups, boss, score,
/** BIOME STANDING 0..100 — the spendable meter (E's HUD bar). */
get standing() { return standing; },
/** KARMA 0..100 — the unspendable conduct ledger. boot reads it for `secretTo` branches. */
get karma() { return karma; },
// Respawn support (boot calls this from its death->checkpoint glue): re-arm hazard
// timelines at/ahead of the respawn s. Enemies and uncollected pickups persist — the
// pump never re-fires past events, so nothing double-spawns.
reset(fromS = 0) { hazards.reset(fromS); boss.reset(); },
dispose() {
offEvent(); offDie(); offPickup(); offTended(); offHarmed();
boss.dispose();
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
},
};
}