[lane B+C] The phage ally: tend the flora and the biome fights for you
The first ALLY, and the reputation loop that earns it — "don't shoot the biome, it helps you"
(V2_IDEAS) made mechanical.
- combat/enemies.js + balance.js: the `phage` archetype. A friendly seeker that hunts the
nearest FOE (not the player) and rams it — a bacteriophage bursts its host after `bursts`
hits. Fast (26 > every foe) so it catches things, fragile (hp 8) so it is spent, not a wall.
Entities now carry a `foe` flag (foes true; flora + phage false); kill() generalised so ANY
ally scores nothing and never combos. Fallback silhouette is a bare icosahedron = the phage's
real icosahedral capsid. Teal (EMISSIVE.ally), distinct from cyan "don't shoot" flora.
- combat/index.js: BIOME STANDING — one run-scoped float. flora:tended raises it, flora:harmed
drops it 25; at the symbiotic threshold the biome BUDS a phage near you (capped 3, 3s cooldown),
spending standing. Emits `standing {value}` for a future Lane E meter.
- levels/enemies.js: ARCHETYPES += phage; catalogue `phage`.
Verified deterministically in-engine: tending flora budded a phage, which hunted a spore_pod foe
(30hp) and rammed it dead; three-colour readability holds (amber foe / teal ally / cyan friend).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5b076aef54
commit
71b0663b77
@ -180,8 +180,10 @@ Ratified round 2 (round-1 requests):
|
||||
- `hazard:proximity {kind, distance}` — rear-chaser telemetry (reflux surge); drives E's
|
||||
rear indicator, C's most important UI dependency.
|
||||
- `flora:tended {s}` / `flora:harmed {s}` — a friendly microbe's aura gave the player coat /
|
||||
a friendly microbe was killed by the player (round-2 microbe pass; seeds a future BIOME
|
||||
STANDING reputation meter — "don't shoot the biome, it helps you in level 5", V2_IDEAS).
|
||||
a friendly microbe was killed by the player (round-2 microbe pass; feeds BIOME STANDING).
|
||||
- `standing {value}` — BIOME STANDING 0..100 (combat/index.js owns it). Tending flora raises it;
|
||||
harming flora drops it; crossing the threshold buds phage allies. E can meter it. ("Don't
|
||||
shoot the biome, it helps you" — V2_IDEAS, now partly real.)
|
||||
|
||||
## Asset manifest contract (D owns `assets.js` + `manifest.json`)
|
||||
|
||||
|
||||
@ -63,6 +63,14 @@ export const BALANCE = {
|
||||
// first entity authored to be skinned by a Trellis mesh (assets/models/spore_pod.glb).
|
||||
spore_pod: { hp: 30, radius: 2.4, score: 120, drift: 1.4, bob: 0.5,
|
||||
auraRadius: 4.4, auraDps: 20, auraTick: 0.2, pool: 16 },
|
||||
|
||||
// phage — the ALLY (bacteriophage). A friendly seeker that hunts the nearest FOE and rams
|
||||
// it (`punch`), bursting after `bursts` hits (a phage lyses its host and dies). FAST (26 >
|
||||
// every foe) so it actually catches things; FRAGILE (hp 8) so a stray pellet or a foe pops
|
||||
// it. score 0 — an ally never scores. Budded by high BIOME STANDING (combat/index.js), the
|
||||
// payoff for tending flora. escort* = where it loiters when no foe is in `huntS` range.
|
||||
phage: { hp: 8, radius: 1.1, score: 0, speed: 26, turn: 3.2, punch: 20, bursts: 2,
|
||||
huntS: 70, escortLead: 10, escortR: 5, pool: 8 },
|
||||
},
|
||||
|
||||
darts: { pool: 48, radius: 0.45 },
|
||||
|
||||
@ -44,6 +44,7 @@ const ARCHETYPES = {
|
||||
cfg: B.enemies.flora,
|
||||
geo: () => new THREE.CapsuleGeometry(B.enemies.flora.radius * 0.45, B.enemies.flora.radius * 1.1, 3, 8),
|
||||
color: EMISSIVE.neutral,
|
||||
foe: false,
|
||||
},
|
||||
// spore_pod — a foe yeast cluster. Its own pool so its blobby GLB doesn't reskin the
|
||||
// food-debris floater; behaviour shares the floater branch (drift + corrosive aura).
|
||||
@ -52,6 +53,16 @@ const ARCHETYPES = {
|
||||
geo: () => new THREE.IcosahedronGeometry(B.enemies.spore_pod.radius, 1),
|
||||
color: EMISSIVE.hostile,
|
||||
},
|
||||
// phage — the ALLY. A friendly seeker that hunts the nearest FOE and rams it (a bacteriophage
|
||||
// bursts its host). Teal, distinct from cyan flora. Budded by high BIOME STANDING. Fallback
|
||||
// silhouette is a bare icosahedron = the phage's real icosahedral capsid (a Hunyuan mesh with
|
||||
// tail fibres is the eventual upgrade).
|
||||
phage: {
|
||||
cfg: B.enemies.phage,
|
||||
geo: () => new THREE.IcosahedronGeometry(B.enemies.phage.radius, 0),
|
||||
color: EMISSIVE.ally,
|
||||
foe: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
@ -77,7 +88,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.count = 0;
|
||||
scene.add(mesh);
|
||||
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [] };
|
||||
pools[type] = { mesh, geo, mat, cfg: def.cfg, slots: [], foe: def.foe !== false };
|
||||
}
|
||||
|
||||
// --- darts: the turret's homing projectile (hostile => white-hot) ------------------
|
||||
@ -122,7 +133,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
: (type === 'turret' ? free : free * (0.25 + rand() * 0.6)); // unauthored: seeded
|
||||
|
||||
const e = {
|
||||
id: nextId++, type, cfg,
|
||||
id: nextId++, type, cfg, foe: pool.foe,
|
||||
s, x: Math.sin(th) * r, y: Math.cos(th) * r, // theta = atan2(x, y), per the frame convention
|
||||
vs: 0, vx: 0, vy: 0,
|
||||
hp: cfg.hp, radius: cfg.radius, alive: true,
|
||||
@ -148,10 +159,11 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
|
||||
function kill(e, kind) {
|
||||
e.alive = false;
|
||||
if (e.type === 'flora') {
|
||||
// Shooting a friendly commensal: no score, no combo — and a signal for the (future)
|
||||
// BIOME STANDING meter. "Don't shoot the biome", made mechanical.
|
||||
bus.emit('flora:harmed', { s: e.s });
|
||||
if (!e.foe) {
|
||||
// An ally died (flora or phage): never scores, never combos. Flora killed by the player's
|
||||
// own fire (not 'spent') signals the BIOME STANDING meter — "don't shoot the biome", made
|
||||
// mechanical. A phage just bursts ('spent') or is popped, quietly.
|
||||
if (e.type === 'flora' && kind !== 'spent') bus.emit('flora:harmed', { s: e.s });
|
||||
bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, kind });
|
||||
bus.emit('audio:cue', { name: 'enemy_die' });
|
||||
onDeath?.(e);
|
||||
@ -236,6 +248,33 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
bus.emit('flora:tended', { s: e.s }); // for the (future) BIOME STANDING meter
|
||||
}
|
||||
}
|
||||
} else if (e.type === 'phage') {
|
||||
// The ALLY. A friendly seeker that hunts the nearest FOE (not the player) and rams it —
|
||||
// a bacteriophage bursts its host. No foe in range => it escorts, easing to a standoff
|
||||
// point that orbits just ahead of the player. ponytail: nearest-foe is an O(phages·live)
|
||||
// scan; with pool 8 and ~30 live it is ~free — swap to forEachNear only if it ever bites.
|
||||
let target = null, best = cfg.huntS * cfg.huntS;
|
||||
for (const o of live) {
|
||||
if (!o.alive || !o.foe) continue; // hunt foes only, never flora/other phages
|
||||
const d2 = (o.s - e.s) ** 2 + (o.x - e.x) ** 2 + (o.y - e.y) ** 2;
|
||||
if (d2 < best) { best = d2; target = o; }
|
||||
}
|
||||
e.phase += dt * 1.5; // slow orbit while escorting
|
||||
const tS = target ? target.s : ps.s + cfg.escortLead;
|
||||
const tX = target ? target.x : ps.x + Math.cos(e.phase) * cfg.escortR;
|
||||
const tY = target ? target.y : ps.y + Math.sin(e.phase) * cfg.escortR;
|
||||
const ds = tS - e.s, dx = tX - e.x, dy = tY - e.y;
|
||||
const d = Math.hypot(ds, dx, dy) || 1e-5;
|
||||
const k = Math.min(1, cfg.turn * dt);
|
||||
e.vs += ((ds / d) * cfg.speed - e.vs) * k;
|
||||
e.vx += ((dx / d) * cfg.speed - e.vx) * k;
|
||||
e.vy += ((dy / d) * cfg.speed - e.vy) * k;
|
||||
e.s += e.vs * dt; e.x += e.vx * dt; e.y += e.vy * dt;
|
||||
if (target && d < cfg.radius + target.radius) {
|
||||
hurt(target, cfg.punch, 'phage'); // ram it
|
||||
onBlast?.(e.pos, 1.2, 0.2);
|
||||
if ((e.hits = (e.hits || 0) + 1) >= cfg.bursts) { kill(e, 'spent'); continue; }
|
||||
}
|
||||
} else if (e.type === 'seeker') {
|
||||
// pursue in spline space — a chase for three float subtractions
|
||||
const ds = ps.s - e.s, dx = ps.x - e.x, dy = ps.y - e.y;
|
||||
@ -356,7 +395,7 @@ export function createEnemies({ scene, world, bus, rng, assets = null }) {
|
||||
let n = 0;
|
||||
for (const e of pool.slots) {
|
||||
if (!e.alive) continue;
|
||||
if (type === 'seeker') {
|
||||
if (type === 'seeker' || type === 'phage') { // hunters point along their heading
|
||||
_dir.set(e.vs, e.vx, e.vy);
|
||||
_q.setFromUnitVectors(FORWARD, _dir.lengthSq() > 1e-6 ? _dir.normalize() : FORWARD);
|
||||
} else {
|
||||
|
||||
@ -71,7 +71,27 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
|
||||
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.
|
||||
let standing = 0, phageBudCd = 0;
|
||||
const setStanding = (v) => { standing = Math.max(0, Math.min(100, v)); bus.emit('standing', { value: standing }); };
|
||||
const offTended = bus.on('flora:tended', () => { setStanding(standing + 2); budPhage(); });
|
||||
const offHarmed = bus.on('flora:harmed', () => setStanding(standing - 25));
|
||||
function budPhage() {
|
||||
if (standing < 40 || phageBudCd > 0) return;
|
||||
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(standing - 20); // spend standing to bud
|
||||
phageBudCd = 3; // seconds between buds (decremented below)
|
||||
bus.emit('audio:cue', { name: 'pickup' }); // a friendly chirp when an ally is born
|
||||
}
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (phageBudCd > 0) phageBudCd -= dt;
|
||||
enemies.update(dt, player);
|
||||
weapons.update(dt);
|
||||
hazards.update(dt);
|
||||
@ -92,7 +112,7 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
|
||||
// pump never re-fires past events, so nothing double-spawns.
|
||||
reset(fromS = 0) { hazards.reset(fromS); },
|
||||
dispose() {
|
||||
offEvent(); offDie(); offPickup();
|
||||
offEvent(); offDie(); offPickup(); offTended(); offHarmed();
|
||||
enemies.dispose(); weapons.dispose(); hazards.dispose(); pickups.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
@ -16,6 +16,9 @@ export const EMISSIVE = {
|
||||
hostile: { core: 0xff5a2a, rim: 0xffb488 }, // hot amber -> red core
|
||||
hostileShot: { core: 0xffe9d0, rim: 0xffffff }, // white-hot
|
||||
neutral: { core: 0x39e6ff, rim: 0xdff2ff }, // cyan (player + flora + interactive)
|
||||
ally: { core: 0x33ffbe, rim: 0xd6fff0 }, // teal — an active friendly unit (phage).
|
||||
// distinct from cyan "don't shoot" flora:
|
||||
// this one HELPS, and reads as its own thing.
|
||||
pickup: { core: 0x7dffb0, rim: 0xdaffe9 },
|
||||
gate: { core: 0xb06aff, rim: 0xe6d0ff },
|
||||
acid: { core: 0xc8ff3a, rim: 0xeaffb0 },
|
||||
|
||||
@ -20,8 +20,9 @@
|
||||
// Pure data + pure functions: no imports, no THREE. Must stay node-runnable (qa selfcheck).
|
||||
|
||||
/** The archetypes Lane B implements. Selfcheck enforces membership.
|
||||
* Round-2 microbe pass added `flora` (the first friend) and `spore_pod` (a distinct-mesh foe). */
|
||||
export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod'];
|
||||
* Round-2 microbe pass added `flora` (the first friend), `spore_pod` (a distinct-mesh foe),
|
||||
* and `phage` (the first ally — a friendly seeker that hunts foes). */
|
||||
export const ARCHETYPES = ['floater', 'seeker', 'turret', 'flora', 'spore_pod', 'phage'];
|
||||
|
||||
/**
|
||||
* Fields:
|
||||
@ -92,6 +93,13 @@ export const ENEMIES = Object.freeze({
|
||||
'Mechanically a floater, but its own archetype so it carries a distinct blobby ' +
|
||||
'silhouette: the first foe authored to be skinned by a Trellis mesh.',
|
||||
},
|
||||
phage: {
|
||||
archetype: 'phage', biome: '*', tint: 0x33ffbe, wall: false,
|
||||
note: 'Bacteriophage — the most abundant biological entity in you; hunts one bacterial ' +
|
||||
'species and bursts it. THE ALLY: a friendly seeker that chases your FOES, not you ' +
|
||||
'(teal, not the cyan "don\'t shoot" flora). Budded by high BIOME STANDING — the payoff ' +
|
||||
'for tending flora instead of spraying it. Placeable by C, but mainly biome-spawned.',
|
||||
},
|
||||
|
||||
// ---- passthrough: bare archetype names --------------------------------------------
|
||||
// Compatibility so the round-0 stub level's `enemy: 'floater'` style still resolves and
|
||||
|
||||
Loading…
Reference in New Issue
Block a user