diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index e620958..6d34875 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -12,6 +12,7 @@ // Determinism: every random here comes from the seeded rng('enemies') stream. import * as THREE from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { BALANCE as B } from './balance.js'; import { emissiveMat, EMISSIVE } from '../flight/emissive.js'; @@ -20,6 +21,37 @@ const _dir = new THREE.Vector3(), _v = new THREE.Vector3(); const FORWARD = new THREE.Vector3(0, 0, 1); const BUCKET = 20; // u of s per broad-phase bucket +// The bacteriophage silhouette, built from primitives. The phage is the ONE microbe that is +// geometric, not blobby — an icosahedral capsid on a tail sheath with splayed leg fibres — so +// image->3D (Trellis/Hunyuan) webs its thin legs into a lump (measured: a 1.27x1.41x0.94 blob), +// while merged cylinders nail the iconic "lander" at ~140 tris. Authored along +Z (travel dir): +// legs reach forward, capsid trails. +const _up = new THREE.Vector3(0, 1, 0); +function phageGeo(r) { + const parts = []; + const head = new THREE.IcosahedronGeometry(r * 0.82, 0); + head.translate(0, 0, -r * 0.75); + parts.push(head); + const tail = new THREE.CylinderGeometry(r * 0.22, r * 0.22, r * 0.9, 6); // BOLD sheath + tail.rotateX(Math.PI / 2); // Y-axis -> Z-axis + tail.translate(0, 0, -r * 0.1); + parts.push(tail); + const N = 6; + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2; + const leg = new THREE.CylinderGeometry(r * 0.1, r * 0.06, r * 1.0, 4); // thick, tapered + leg.translate(0, r * 0.5, 0); // base at origin, extends +Y + // splay WIDE (small z) so a head-on phage reads as a 6-point star, not a dot + const dir = new THREE.Vector3(Math.cos(a), Math.sin(a), 0.55).normalize(); + leg.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(_up, dir)); + leg.translate(0, 0, r * 0.35); // shift to the +Z baseplate + parts.push(leg); + } + // Icosahedron is non-indexed, cylinders are indexed — mergeGeometries needs all-or-none, so + // drop every index before merging. + return mergeGeometries(parts.map((p) => (p.index ? p.toNonIndexed() : p)), false); +} + // Round-1 roster. `geo` is the procedural fallback; D's model swaps in via assets.get. const ARCHETYPES = { floater: { @@ -54,12 +86,12 @@ const ARCHETYPES = { 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). + // bursts its host). Teal, distinct from cyan flora. Budded by high BIOME STANDING. Silhouette + // is the iconic capsid+tail+legs, built procedurally (phageGeo) — image->3D can't hold the + // thin legs, and the shape is geometric anyway. phage: { cfg: B.enemies.phage, - geo: () => new THREE.IcosahedronGeometry(B.enemies.phage.radius, 0), + geo: () => phageGeo(B.enemies.phage.radius), color: EMISSIVE.ally, foe: false, },