The right axis wasn't "generate or don't" — it was WHICH HALF. Geometry stays procedural because a molecule's shape is already known exactly. **Material is exactly a generation problem**, and it's the whole difference between a textbook diagram and a game object. So MODELBEAST made the materials. 5 greyscale matcaps, flux_local, 69 seconds, $0.00, 68 KB total: mol_glass (O,N) — the big win: oxygen is a red glass marble with internal glow mol_matte (C) — graphite soot; carbon reads as the scaffold it is mol_chrome (Co) — B12's cobalt is now a chrome bearing in a cage mol_molten (P) — ATP's phosphate tail is incandescent AND pulses (uTime, free) mol_pearl (H) — satin white; hydrogens stop shouting The trick is Lane D's own law applied to spheres: author the LUMINANCE, tint in-shader. derive_maps already greyscales every matcap, so one glass ball serves oxygen AND nitrogen at their own CPK hues — 5 balls dress the whole periodic table we care about. They pack into one strip atlas indexed per-vertex by aMat, so **a molecule is still exactly 1 draw call** however many materials it's made of (7 molecules = 7 draws, unchanged). ?matcap=0 falls back to the built-in fake-lit path: the assets-optional law holds. SPACE-FILLING WINS FOR PICKUPS, and the canal decided it, not me. New `repr: 'space-filling'` draws every atom at its real van der Waals radius with no sticks. A/B'd at real pickup size against the real L2 wall, same camera: ball-and-stick goes spindly and dissolves; the solid blob holds its silhouette — and it's CHEAPER (21k vs 26k tris, no bond cylinders). Recommendation to B: space-filling in flight, ball-stick for hero/UI/collect close-ups where the chemistry is worth reading. Evidence: round2_molecule_materials.png (3-way). Two defects found by rendering it, both mine, both fixed: - Tint x luminance darkens TWICE: CPK carbon is 0.4 and a matte ball averages 0.5, so soot x soot vanished — the first pass sank glucose, caffeine and the cobalt into the background. Floored the matcap at 0.22, the same floor the fallback's key light always had. - The molten matcap was my own bad prompt: I asked for an "incandescent white hot CORE" and got exactly that — a black ball with a hot spot, so phosphorus lost its orange. Re-rolled for the whole sphere to glow (attempt 2 of the <=2 PIPELINE allows). Lesson for the kit, next to the organ/tissue one: a matcap is a LUMINANCE LOOKUP, so a dark-dominant ball makes a dark-dominant object. Also corrected the doc's own framing — v1 said "I'm authorised to burn GPU and I'm not going to", which was the wrong axis, not just the wrong tone. qa GREEN; 16/16 texture provenance verified (synced the FIXED batch json up first this time, so the box couldn't clobber it on the way back). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
570 lines
31 KiB
JavaScript
570 lines
31 KiB
JavaScript
// world/molecule.js (Lane A) — ball-and-stick molecules, built from real chemistry.
|
||
//
|
||
// PROTOTYPE / cross-lane proposal (round 2). Not wired into boot. Pickups are Lane B's
|
||
// systems, their economy is C's, their art is D's — this is the *renderer* and a design
|
||
// argument, offered to all three. Harness: web/dev/laneA_molecules.html
|
||
//
|
||
// WHY PROCEDURAL AND NOT A GENERATED MESH. Everything else in GUTS that is an object gets
|
||
// concepted and put through MODELBEAST (PIPELINE.md). Molecules should not, and it isn't a
|
||
// cost argument: a molecule's shape is *known exactly*. Glucose is a hexagonal ring because
|
||
// it is a hexagonal ring. Feeding "glucose molecule" to FLUX+TRELLIS would produce a plausible
|
||
// blob that a chemist would clock as wrong in a second, at 3-8 minutes a go, and it could never
|
||
// be re-derived. Ball-and-stick from an atom list is exact, is ~40 lines of geometry, rebuilds
|
||
// instantly, and — the actual point — **reads as science because it IS the notation science
|
||
// uses**. The game gets its sciency-ness for free by not faking it.
|
||
//
|
||
// The look: CPK colours (the standard element palette — O red, N blue, P orange, S yellow,
|
||
// metals pink). Every chemistry textbook, every protein viewer, every science documentary uses
|
||
// it. Nobody needs to know what it means to feel it. And it does a gameplay job for free:
|
||
// against six biomes of monochrome tinted tissue, a CPK molecule is the only thing on screen
|
||
// with saturated foreign colour — it reads as *artificial, valuable, targetable* at a glance,
|
||
// with no HUD marker. That is the ART_BIBLE's synthetic-scanner fiction paying for itself:
|
||
// the ship's scanner identifies a compound and colours it in.
|
||
//
|
||
// The geometry is IDEALISED, not crystallographic: correct connectivity (which atom bonds to
|
||
// which), correct ring sizes, correct bond orders, believable angles — mostly authored flat
|
||
// because a flat ring reads instantly and spins beautifully. This is a game, not PyMOL, and
|
||
// this comment is here so nobody mistakes it for a structure database later.
|
||
//
|
||
// No real-time lights anywhere in GUTS (ART_BIBLE), so the shading is faked in-shader from a
|
||
// baked key direction. Colorspace: `#include <colorspace_fragment>` is LAW (TECH §Shader law).
|
||
|
||
import * as THREE from 'three';
|
||
|
||
const TAU = Math.PI * 2;
|
||
|
||
// ── elements ────────────────────────────────────────────────────────────────────────────
|
||
// `col` is CPK, nudged for a black void background: real CPK carbon is black, which is
|
||
// invisible here, so carbon is lifted to a grey that still reads as "not an element with a
|
||
// colour". `r` is a display radius in bond-length units — ball-and-stick, so balls are much
|
||
// smaller than the van der Waals radii; these are tuned to read, not to measure.
|
||
// `vdw` is the van der Waals radius (Å) — the atom's real "size", used by space-filling mode.
|
||
// `mat` names the material family this element is made of (see MATCAP_FAMILIES): CPK gives the
|
||
// hue, the matcap gives the substance. Carbon is soot, oxygen and nitrogen are gems, phosphorus
|
||
// is molten, metals are chrome. That pairing is what turns a diagram into an object.
|
||
export const ELEMENTS = {
|
||
H: { col: 0xf2f2f2, r: 0.26, vdw: 1.20, mat: 'pearl' },
|
||
C: { col: 0x63666e, r: 0.40, vdw: 1.70, mat: 'matte' },
|
||
N: { col: 0x3b60ff, r: 0.40, vdw: 1.55, mat: 'glass' },
|
||
O: { col: 0xff3222, r: 0.39, vdw: 1.52, mat: 'glass' },
|
||
P: { col: 0xff9020, r: 0.50, vdw: 1.80, mat: 'molten' },
|
||
S: { col: 0xf5f52a, r: 0.49, vdw: 1.80, mat: 'glass' },
|
||
Cl: { col: 0x35e035, r: 0.44, vdw: 1.75, mat: 'glass' },
|
||
Na: { col: 0xab5cf2, r: 0.52, vdw: 2.27, mat: 'chrome' },
|
||
Fe: { col: 0xe06633, r: 0.55, vdw: 2.00, mat: 'chrome' },
|
||
Co: { col: 0xf090a0, r: 0.56, vdw: 2.00, mat: 'chrome' },
|
||
};
|
||
const el = (e) => ELEMENTS[e] || { col: 0xff00ff, r: 0.4, vdw: 1.6, mat: 'matte' }; // magenta = typo'd element
|
||
|
||
/** Atlas cell order. Index into this is what `aMat` carries per-vertex. */
|
||
export const MATCAP_FAMILIES = ['matte', 'pearl', 'glass', 'chrome', 'molten'];
|
||
const famIndex = (e) => Math.max(0, MATCAP_FAMILIES.indexOf(el(e).mat));
|
||
|
||
/**
|
||
* Pack the family matcaps into ONE horizontal strip texture, so a molecule stays a single draw
|
||
* however many different materials its atoms are made of. The alternative — one material per
|
||
* family — would split every molecule into up to five draws and lose the batching that makes
|
||
* these cheap enough to scatter around a level.
|
||
*
|
||
* Returns null if anything is missing, and that is not an error: the shader falls back to its
|
||
* built-in fake-lit path (TECH.md's assets-optional law — the game must boot with an empty
|
||
* assets/gen/). Await it before building molecules; pass the result as `matcap`.
|
||
*
|
||
* @param {string[]} urls one URL per MATCAP_FAMILIES entry, same order.
|
||
*/
|
||
export async function buildMatcapAtlas(urls) {
|
||
try {
|
||
if (!Array.isArray(urls) || urls.length !== MATCAP_FAMILIES.length) return null;
|
||
const imgs = await Promise.all(urls.map((u) => new Promise((res, rej) => {
|
||
const im = new Image();
|
||
im.crossOrigin = 'anonymous';
|
||
im.onload = () => res(im); im.onerror = () => rej(new Error(`matcap load failed: ${u}`));
|
||
im.src = u;
|
||
})));
|
||
const S = Math.max(...imgs.map((i) => i.height)) || 512;
|
||
const cv = document.createElement('canvas');
|
||
cv.width = S * imgs.length; cv.height = S;
|
||
const ctx = cv.getContext('2d');
|
||
imgs.forEach((im, i) => ctx.drawImage(im, i * S, 0, S, S));
|
||
const tex = new THREE.CanvasTexture(cv);
|
||
tex.colorSpace = THREE.SRGBColorSpace; // these are authored images, like D's walls
|
||
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
|
||
tex.generateMipmaps = false; // mips would bleed neighbouring cells together
|
||
tex.minFilter = tex.magFilter = THREE.LinearFilter;
|
||
tex.needsUpdate = true;
|
||
return tex;
|
||
} catch (err) {
|
||
console.info('[molecule] no matcap atlas, using the built-in shading —', err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** The URLs `buildMatcapAtlas` wants, read through the documented asset contract. */
|
||
export function matcapUrls(assets, base = '/assets/') {
|
||
if (!assets || typeof assets.get !== 'function') return null;
|
||
const urls = MATCAP_FAMILIES.map((f) => assets.get('matcaps', `mol_${f}`)?.url);
|
||
if (urls.some((u) => !u)) return null; // a partial set is a miss, not a half-look
|
||
return urls.map((u) => new URL(u, new URL(base, location.href)).href);
|
||
}
|
||
|
||
// ── authoring helpers ───────────────────────────────────────────────────────────────────
|
||
/** A regular n-gon in the XY plane. Rings are rings; this is most of chemistry's shapes. */
|
||
function polygon(n, r, cx = 0, cy = 0, phase = -Math.PI / 2) {
|
||
return Array.from({ length: n }, (_, i) => {
|
||
const a = phase + (i * TAU) / n;
|
||
return [cx + r * Math.cos(a), cy + r * Math.sin(a), 0];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The other n-2 vertices of a regular n-gon that shares the edge A-B with an existing ring —
|
||
* i.e. a FUSED ring (caffeine's purine, ATP's adenine). Fused bicyclics are the visual
|
||
* signature of "this is a serious biomolecule", so it's worth the trig.
|
||
*
|
||
* **Returned in ring order starting from the vertex adjacent to B and ending adjacent to A**,
|
||
* so the caller bonds `B–out[0] … out[last]–A` and the shared A–B edge closes the ring. Get
|
||
* that backwards and you bond A to the far vertex: still a valid 5-cycle, so nothing errors —
|
||
* it just draws a chord straight across the ring. It looked like a squashed pentagon and it
|
||
* was found by rendering it, which is the whole reason the bench exists.
|
||
*
|
||
* @param {number[]} away a point the new ring must bulge away from (the host ring's centre)
|
||
*/
|
||
function fuseRing(A, B, n, away = [0, 0, 0]) {
|
||
const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2;
|
||
let ex = B[0] - A[0], ey = B[1] - A[1];
|
||
const s = Math.hypot(ex, ey); ex /= s; ey /= s;
|
||
let px = -ey, py = ex; // edge normal, sign undecided
|
||
if (Math.hypot(mx + px - away[0], my + py - away[1]) < Math.hypot(mx - px - away[0], my - py - away[1])) {
|
||
px = -px; py = -py; // ...pick the one pointing outward
|
||
}
|
||
const apothem = (s / 2) / Math.tan(Math.PI / n);
|
||
const R = (s / 2) / Math.sin(Math.PI / n);
|
||
const cx = mx + px * apothem, cy = my + py * apothem;
|
||
const a0 = Math.atan2(A[1] - cy, A[0] - cx);
|
||
const aB = Math.atan2(B[1] - cy, B[0] - cx);
|
||
const step = TAU / n;
|
||
const wrap = (x) => ((x + Math.PI) % TAU + TAU) % TAU - Math.PI; // to (-pi, pi]
|
||
const dir = Math.abs(wrap(a0 + step - aB)) < Math.abs(wrap(a0 - step - aB)) ? 1 : -1;
|
||
const out = [];
|
||
for (let k = 2; k < n; k++) {
|
||
const a = a0 + dir * step * k;
|
||
out.push([cx + R * Math.cos(a), cy + R * Math.sin(a), 0]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Push an atom bonded outward from `from`, away from `origin`, at distance d. */
|
||
function outward(from, d, origin = [0, 0, 0]) {
|
||
const dx = from[0] - origin[0], dy = from[1] - origin[1];
|
||
const L = Math.hypot(dx, dy) || 1;
|
||
return [from[0] + (dx / L) * d, from[1] + (dy / L) * d, from[2]];
|
||
}
|
||
|
||
const add = (p, dx, dy, dz = 0) => [p[0] + dx, p[1] + dy, p[2] + dz];
|
||
|
||
// ── the library ─────────────────────────────────────────────────────────────────────────
|
||
// Every molecule here is really in the human gut. That constraint is doing design work: it
|
||
// means the pickup table IS the biochemistry of digestion, so the fiction writes itself and
|
||
// nothing has to be invented. `role` is a PROPOSAL to Lanes B/C — see docs/MOLECULES.md.
|
||
|
||
function buildLibrary() {
|
||
const M = {};
|
||
const def = (id, o) => { M[id] = { id, ...o }; };
|
||
|
||
// ── water ── the common little pickup. Bent at ~104.5°, which is the one fact everyone
|
||
// remembers from school, so it must not be drawn straight.
|
||
def('water', {
|
||
name: 'Water', formula: 'H₂O', role: 'trickle',
|
||
blurb: 'The chaff pickup. Everywhere, worth almost nothing, tops off a sliver of coat.',
|
||
atoms: [{ e: 'O', p: [0, 0, 0] }, { e: 'H', p: [0.76, 0.59, 0] }, { e: 'H', p: [-0.76, 0.59, 0] }],
|
||
bonds: [[0, 1, 1], [0, 2, 1]],
|
||
});
|
||
|
||
// ── bicarbonate ── the antacid ammo that L2/L3 already reference. Trigonal planar, and it
|
||
// is *literally* what neutralises stomach acid in a real body: HCO₃⁻ + HCl → salt + water
|
||
// + CO₂. Firing this into the acid sea is real chemistry and it is also just a good weapon.
|
||
def('bicarbonate', {
|
||
name: 'Bicarbonate', formula: 'HCO₃⁻', role: 'antacid ammo',
|
||
blurb: 'Antacid ordnance. Neutralises acid on contact — the real reaction, and it fizzes CO₂.',
|
||
atoms: [
|
||
{ e: 'C', p: [0, 0, 0] },
|
||
{ e: 'O', p: [1.30, 0, 0] },
|
||
{ e: 'O', p: [-0.65, 1.126, 0] },
|
||
{ e: 'O', p: [-0.65, -1.126, 0] },
|
||
{ e: 'H', p: [-1.35, 1.82, 0] },
|
||
],
|
||
bonds: [[0, 1, 2], [0, 2, 1], [0, 3, 1], [2, 4, 1]],
|
||
});
|
||
|
||
// ── glucose ── the score/nutrient pickup. Pyranose: a six-ring of 5 carbons and ONE oxygen
|
||
// (drawn red, top-right, exactly where a chemist expects it), hydroxyls hanging off. This is
|
||
// sugar. It is what the gut is FOR.
|
||
def('glucose', {
|
||
name: 'Glucose', formula: 'C₆H₁₂O₆', role: 'nutrient / score',
|
||
blurb: 'Food. The reason the canal exists. Common, stacks, feeds the score multiplier.',
|
||
...(() => {
|
||
const ring = polygon(6, 1.42);
|
||
const atoms = ring.map((p, i) => ({ e: i === 0 ? 'O' : 'C', p })); // ring oxygen at index 0
|
||
const bonds = ring.map((_, i) => [i, (i + 1) % 6, 1]);
|
||
for (let i = 1; i <= 5; i++) { // hydroxyls on every carbon
|
||
if (i === 5) { // ...except C5, which carries CH₂OH
|
||
const c = outward(ring[i], 1.45); atoms.push({ e: 'C', p: c }); bonds.push([i, atoms.length - 1, 1]);
|
||
const o = add(outward(c, 1.35), 0, 0.35); atoms.push({ e: 'O', p: o }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
|
||
atoms.push({ e: 'H', p: add(o, 0.5, 0.75) }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
|
||
continue;
|
||
}
|
||
const o = outward(ring[i], 1.38);
|
||
atoms.push({ e: 'O', p: o }); bonds.push([i, atoms.length - 1, 1]);
|
||
atoms.push({ e: 'H', p: outward(o, 0.95) }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
|
||
}
|
||
return { atoms, bonds };
|
||
})(),
|
||
});
|
||
|
||
// ── caffeine ── the overdrive powerup, and a joke that lands without explanation: it is a
|
||
// purine (fused 6+5 with four nitrogens), and it is genuinely absorbed through the gut wall.
|
||
// Everyone knows what caffeine does to a body; nobody needs a tutorial for this pickup.
|
||
def('caffeine', {
|
||
name: 'Caffeine', formula: 'C₈H₁₀N₄O₂', role: 'overdrive',
|
||
blurb: 'Overdrive. Throttle ceiling up, handling twitchier, and it wears off badly.',
|
||
...(() => {
|
||
const six = polygon(6, 1.42);
|
||
const five = fuseRing(six[2], six[3], 5); // fuse the imidazole onto one edge
|
||
const atoms = [
|
||
{ e: 'N', p: six[0] }, { e: 'C', p: six[1] }, { e: 'C', p: six[2] },
|
||
{ e: 'C', p: six[3] }, { e: 'N', p: six[4] }, { e: 'C', p: six[5] },
|
||
{ e: 'N', p: five[0] }, { e: 'C', p: five[1] }, { e: 'N', p: five[2] },
|
||
];
|
||
const bonds = [
|
||
[0, 1, 1], [1, 2, 2], [2, 3, 1], [3, 4, 1], [4, 5, 1], [5, 0, 1], // pyrimidine
|
||
[3, 6, 1], [6, 7, 2], [7, 8, 1], [8, 2, 1], // imidazole (B->…->A)
|
||
];
|
||
const carbonyl = (ci, dir) => { // the two C=O that make it a dione
|
||
const o = outward(atoms[ci].p, 1.24, [0, 0, 0]);
|
||
atoms.push({ e: 'O', p: [o[0] * dir, o[1] * dir === 0 ? o[1] : o[1], o[2]] });
|
||
bonds.push([ci, atoms.length - 1, 2]);
|
||
};
|
||
carbonyl(1, 1); carbonyl(5, 1);
|
||
for (const ni of [0, 4, 8]) { // three methyls — caffeine's tell
|
||
const c = outward(atoms[ni].p, 1.47);
|
||
atoms.push({ e: 'C', p: c }); bonds.push([ni, atoms.length - 1, 1]);
|
||
}
|
||
return { atoms, bonds };
|
||
})(),
|
||
});
|
||
|
||
// ── ATP ── the boost. Adenine + ribose + a three-phosphate tail, and that orange tail IS the
|
||
// energy: a body spends ATP by snapping the last phosphate off. A boost pickup that visibly
|
||
// carries three charges is a gameplay diagram of itself.
|
||
def('atp', {
|
||
name: 'ATP', formula: 'C₁₀H₁₆N₅O₁₃P₃', role: 'boost',
|
||
blurb: 'Boost. Literally the cell\'s energy currency — three phosphates, three charges.',
|
||
...(() => {
|
||
const atoms = [], bonds = [];
|
||
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
|
||
// adenine, off to the left
|
||
const six = polygon(6, 1.40, -5.2, 0.8);
|
||
const five = fuseRing(six[2], six[3], 5, [-5.2, 0.8, 0]);
|
||
const A = [
|
||
push('N', six[0]), push('C', six[1]), push('C', six[2]),
|
||
push('C', six[3]), push('N', six[4]), push('C', six[5]),
|
||
];
|
||
const F = [push('N', five[0]), push('C', five[1]), push('N', five[2])];
|
||
bonds.push([A[0], A[1], 2], [A[1], A[2], 1], [A[2], A[3], 2], [A[3], A[4], 1], [A[4], A[5], 2], [A[5], A[0], 1]);
|
||
bonds.push([A[3], F[0], 1], [F[0], F[1], 2], [F[1], F[2], 1], [F[2], A[2], 1]); // B->…->A
|
||
const nh2 = push('N', outward(six[5], 1.36, [-5.2, 0.8, 0])); // the amine
|
||
bonds.push([A[5], nh2, 1]);
|
||
// ribose, a five-ring with its own oxygen, hung off the adenine
|
||
const rib = polygon(5, 1.20, -1.9, -0.2, 0.6);
|
||
const R = rib.map((p, i) => push(i === 0 ? 'O' : 'C', p));
|
||
for (let i = 0; i < 5; i++) bonds.push([R[i], R[(i + 1) % 5], 1]);
|
||
bonds.push([F[2], R[1], 1]); // base -> sugar
|
||
const oh1 = push('O', outward(rib[3], 1.36, [-1.9, -0.2, 0]));
|
||
const oh2 = push('O', outward(rib[4], 1.36, [-1.9, -0.2, 0]));
|
||
bonds.push([R[3], oh1, 1], [R[4], oh2, 1]);
|
||
// the triphosphate tail: P-O-P-O-P marching right, each P with its own oxygens
|
||
let prev = R[2];
|
||
let x = -0.4;
|
||
for (let i = 0; i < 3; i++) {
|
||
const brO = push('O', [x, 0.9, 0]); bonds.push([prev, brO, 1]); // bridging oxygen
|
||
const p = push('P', [x + 1.25, 1.45, 0]); bonds.push([brO, p, 1]);
|
||
bonds.push([p, push('O', [x + 1.25, 2.85, 0]), 2]); // P=O
|
||
bonds.push([p, push('O', [x + 1.05, 0.15, 0]), 1]); // P-O⁻
|
||
prev = p; x += 1.85;
|
||
}
|
||
return { atoms, bonds };
|
||
})(),
|
||
});
|
||
|
||
// ── capsaicin ── the burn hazard. Aromatic ring at one end, long greasy tail at the other:
|
||
// the silhouette says "organic and wrong" from across a room, and the tail makes it tumble
|
||
// differently from every compact pickup, which is free readability.
|
||
def('capsaicin', {
|
||
name: 'Capsaicin', formula: 'C₁₈H₂₇NO₃', role: 'burn hazard',
|
||
blurb: 'Chilli. Contact burns the coat. Long-tailed and greasy — reads wrong on sight.',
|
||
...(() => {
|
||
const atoms = [], bonds = [];
|
||
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
|
||
const ring = polygon(6, 1.40, -4.6, 0);
|
||
const R = ring.map((p) => push('C', p));
|
||
for (let i = 0; i < 6; i++) bonds.push([R[i], R[(i + 1) % 6], i % 2 ? 2 : 1]); // aromatic
|
||
const oh = push('O', outward(ring[3], 1.36, [-4.6, 0, 0])); // phenol
|
||
bonds.push([R[3], oh, 1]);
|
||
bonds.push([oh, push('H', outward(ring[3], 2.3, [-4.6, 0, 0])), 1]);
|
||
const om = push('O', outward(ring[4], 1.36, [-4.6, 0, 0])); // methoxy
|
||
bonds.push([R[4], om, 1]);
|
||
bonds.push([om, push('C', outward(ring[4], 2.7, [-4.6, 0, 0])), 1]);
|
||
// amide linker + the alkyl tail, zig-zagging like a real chain
|
||
const c1 = push('C', [-2.7, -0.9, 0]); bonds.push([R[1], c1, 1]);
|
||
const n = push('N', [-1.5, -0.3, 0]); bonds.push([c1, n, 1]);
|
||
const co = push('C', [-0.3, -0.9, 0]); bonds.push([n, co, 1]);
|
||
bonds.push([co, push('O', [-0.3, -2.3, 0]), 2]);
|
||
let prev = co, x = 0.9, up = true;
|
||
for (let i = 0; i < 7; i++) {
|
||
const c = push('C', [x, up ? -0.25 : -1.15, 0]);
|
||
bonds.push([prev, c, i === 5 ? 2 : 1]); // one double bond kink, as in the real thing
|
||
prev = c; x += 1.05; up = !up;
|
||
}
|
||
return { atoms, bonds };
|
||
})(),
|
||
});
|
||
|
||
// ── cobalamin (B12) core ── the treasure. B12 is the most structurally complex vitamin there
|
||
// is and the only one with a METAL at its heart — a cobalt held in a corrin cage. C already
|
||
// authored B12 as a rare pickup before any of this existed. Simplified to the corrin core:
|
||
// the whole read is "a jewel in a setting", which is exactly what a rare pickup should be.
|
||
def('cobalamin', {
|
||
name: 'Cobalamin (B₁₂) core', formula: 'C₆₃H₈₈CoN₁₄O₁₄P', role: 'rare treasure',
|
||
blurb: 'The jewel. A cobalt atom held in a corrin cage — the only vitamin with a metal.',
|
||
...(() => {
|
||
const atoms = [{ e: 'Co', p: [0, 0, 0] }];
|
||
const bonds = [];
|
||
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
|
||
for (let q = 0; q < 4; q++) { // four pyrrole rings around the metal
|
||
const a = (q * TAU) / 4 + Math.PI / 4;
|
||
const nx = Math.cos(a) * 1.95, ny = Math.sin(a) * 1.95;
|
||
const n = push('N', [nx, ny, 0]);
|
||
bonds.push([0, n, 1]); // Co-N coordination bond
|
||
// the ring bulges outward from the metal
|
||
const ring = polygon(5, 1.20, Math.cos(a) * 3.05, Math.sin(a) * 3.05, a + Math.PI);
|
||
const C = ring.slice(1).map((p) => push('C', p));
|
||
bonds.push([n, C[0], 1], [C[0], C[1], 2], [C[1], C[2], 1], [C[2], C[3], 2], [C[3], n, 1]);
|
||
const sub = push('C', outward(ring[2], 1.45)); // a stub of the real side chains
|
||
bonds.push([C[1], sub, 1]);
|
||
const o = push('O', outward(ring[3], 1.40));
|
||
bonds.push([C[2], o, 1]);
|
||
}
|
||
return { atoms, bonds };
|
||
})(),
|
||
});
|
||
|
||
return M;
|
||
}
|
||
|
||
export const MOLECULES = buildLibrary();
|
||
export const listMolecules = () => Object.keys(MOLECULES);
|
||
|
||
// ── material ────────────────────────────────────────────────────────────────────────────
|
||
/**
|
||
* There are no lights in GUTS, so this fakes one: a baked key direction gives the balls their
|
||
* roundness, a tight specular gives the glossy model-kit read, and a fresnel rim in the
|
||
* scanner's cyan says "this object has been identified" — the same rim language the wall uses,
|
||
* so molecules belong to the world instead of being stickers on it.
|
||
*/
|
||
export function createMoleculeMaterial({ scan = 0x7fdfff, scanGain = 0.55, matcap = null } = {}) {
|
||
return new THREE.ShaderMaterial({
|
||
defines: matcap ? { USE_MATCAP: '' } : {},
|
||
uniforms: {
|
||
uScan: { value: new THREE.Color(scan) },
|
||
uScanGain: { value: scanGain },
|
||
uKey: { value: new THREE.Vector3(0.35, 0.72, 0.6).normalize() },
|
||
uMatcap: { value: matcap },
|
||
uMatCount: { value: MATCAP_FAMILIES.length },
|
||
uMolten: { value: MATCAP_FAMILIES.indexOf('molten') },
|
||
uTime: { value: 0 },
|
||
},
|
||
vertexShader: /* glsl */`
|
||
attribute vec3 color;
|
||
attribute float aMat;
|
||
varying vec3 vColor; varying vec3 vN; varying vec3 vView; varying float vMat;
|
||
void main() {
|
||
vColor = color;
|
||
vMat = aMat;
|
||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||
vN = normalize(normalMatrix * normal);
|
||
vView = -mv.xyz;
|
||
gl_Position = projectionMatrix * mv;
|
||
}`,
|
||
fragmentShader: /* glsl */`
|
||
uniform vec3 uScan, uKey; uniform float uScanGain, uMatCount, uMolten, uTime;
|
||
#ifdef USE_MATCAP
|
||
uniform sampler2D uMatcap;
|
||
#endif
|
||
varying vec3 vColor; varying vec3 vN; varying vec3 vView; varying float vMat;
|
||
|
||
void main() {
|
||
vec3 N = normalize(vN), V = normalize(vView);
|
||
float fres = pow(1.0 - max(0.0, dot(N, V)), 3.0);
|
||
vec3 col;
|
||
|
||
#ifdef USE_MATCAP
|
||
// Standard matcap lookup: the VIEW-space normal is the coordinate. vN already is one
|
||
// (normalMatrix * normal), which is why a tumbling molecule stays correctly lit.
|
||
vec2 muv = N.xy * 0.5 + 0.5;
|
||
muv = clamp(muv, 0.006, 0.994); // stay off the cell edge...
|
||
float u = (vMat + muv.x) / uMatCount; // ...then pick this atom's family cell
|
||
float lum = texture2D(uMatcap, vec2(u, muv.y)).r;
|
||
// Floor + gain. Tint x luminance darkens TWICE — CPK carbon is already 0.4, and a
|
||
// matte ball averages 0.5, so soot x soot = invisible (measured: the first matcap
|
||
// pass sank glucose, caffeine and the cobalt into the background). The fallback path
|
||
// has floored its key light at 0.30 since day one for the same reason; this is that
|
||
// floor, restored. Keeps the material's shape, lifts its black point off the void.
|
||
lum = 0.22 + 0.92 * lum;
|
||
|
||
// Lane D's law, applied to spheres instead of walls: the matcap is authored greyscale
|
||
// and carries the MATERIAL; the CPK colour carries the ELEMENT. One glass ball serves
|
||
// oxygen and nitrogen at their own hues, and 68 KB dresses the whole set.
|
||
col = vColor * lum * 1.7;
|
||
// Tinting alone would give chrome a red highlight on an oxygen, which instantly reads
|
||
// as plastic — a real specular is the colour of the LIGHT, not the object. So push the
|
||
// hot end back to white.
|
||
col = mix(col, vec3(1.0), smoothstep(0.72, 1.0, lum) * 0.72);
|
||
// Molten atoms are the only ones that emit: ATP's phosphate tail throbs, which is the
|
||
// charge it is carrying made visible. Costs one compare and no per-molecule work.
|
||
float molten = step(abs(vMat - uMolten), 0.5);
|
||
col += vColor * molten * (0.25 + 0.20 * sin(uTime * 3.1)) * lum;
|
||
#else
|
||
// Fallback when no matcap shipped (assets-optional law). Key light in VIEW space so a
|
||
// tumbling molecule never rotates into an unlit pose; 0.30 floor so nothing silhouettes.
|
||
float diff = 0.30 + 0.70 * max(0.0, dot(N, uKey));
|
||
float spec = pow(max(0.0, dot(reflect(-uKey, N), V)), 26.0);
|
||
col = vColor * diff + vec3(1.0) * spec * 0.55;
|
||
#endif
|
||
|
||
col += uScan * fres * uScanGain;
|
||
gl_FragColor = vec4(col, 1.0);
|
||
#include <colorspace_fragment>
|
||
}`,
|
||
});
|
||
}
|
||
|
||
// ── geometry ────────────────────────────────────────────────────────────────────────────
|
||
// Atoms and bonds are baked into ONE geometry with per-vertex colour, so a molecule is a
|
||
// single draw call however many atoms it has. That matters: these are pickups, and there may
|
||
// be dozens live. A naive mesh-per-atom glucose would be 21 draws on its own.
|
||
let SPHERE = null, CYL = null;
|
||
const templates = (detail) => {
|
||
if (!SPHERE) {
|
||
SPHERE = new THREE.IcosahedronGeometry(1, detail).toNonIndexed();
|
||
CYL = new THREE.CylinderGeometry(1, 1, 1, 9, 1, true).toNonIndexed();
|
||
}
|
||
return { SPHERE, CYL };
|
||
};
|
||
|
||
function appendGeo(dst, src, matrix, colorHex, mat = 0) {
|
||
const nm = new THREE.Matrix3().getNormalMatrix(matrix);
|
||
const pos = src.attributes.position, nor = src.attributes.normal;
|
||
const c = new THREE.Color(colorHex);
|
||
const v = new THREE.Vector3(), n = new THREE.Vector3();
|
||
for (let i = 0; i < pos.count; i++) {
|
||
v.fromBufferAttribute(pos, i).applyMatrix4(matrix);
|
||
n.fromBufferAttribute(nor, i).applyMatrix3(nm).normalize();
|
||
dst.position.push(v.x, v.y, v.z);
|
||
dst.normal.push(n.x, n.y, n.z);
|
||
dst.color.push(c.r, c.g, c.b);
|
||
dst.mat.push(mat);
|
||
}
|
||
}
|
||
|
||
const Y = new THREE.Vector3(0, 1, 0);
|
||
|
||
/**
|
||
* @param {string} id key in MOLECULES
|
||
* @param {object} opts
|
||
* @param {number} opts.fit scale so the whole molecule fits this radius (game units).
|
||
* Every molecule ends up the same size on screen — right for a
|
||
* contact sheet, and right if a pickup must occupy a fixed box.
|
||
* @param {number} opts.unit ALTERNATIVE to `fit`: absolute units per bond length, so
|
||
* molecules keep their TRUE relative sizes — water is a speck and
|
||
* B₁₂ is a chandelier. For pickups this is the better one: size
|
||
* tells the player what a thing is worth before they read a
|
||
* single colour, and it costs nothing because it's just true.
|
||
* @param {number} opts.detail icosphere detail: 2 for hero/close, 1 for a live pickup
|
||
* @param {string} opts.repr 'ball-stick' (default) reads the chemistry and is right up
|
||
* close. 'space-filling' draws each atom at its real van der
|
||
* Waals radius with no sticks — the atoms merge into one solid
|
||
* lump. That's how a molecule actually occupies space, AND it is
|
||
* the readable one: at pickup size and flight speed a
|
||
* ball-and-stick goes spindly and dissolves, while a solid blob
|
||
* holds its silhouette. Suspected LOD: space-filling far,
|
||
* ball-stick close. Not yet measured in flight.
|
||
* @param {THREE.Material} opts.material share ONE across every molecule (see harness)
|
||
* @returns {THREE.Mesh} one mesh, one draw. `.userData` carries name/formula/role/blurb.
|
||
*/
|
||
export function buildMolecule(id, { fit = 1, unit = 0, detail = 2, material = null, bondRadius = 0.13, repr = 'ball-stick' } = {}) {
|
||
const spec = MOLECULES[id];
|
||
if (!spec) throw new Error(`[molecule] unknown molecule "${id}". Have: ${listMolecules().join(', ')}`);
|
||
const { SPHERE: sph, CYL: cyl } = templates(detail);
|
||
|
||
// Recentre on the atom centroid and solve the scale that makes it `fit`, so every molecule
|
||
// arrives the same size on screen no matter how many atoms it has — a pickup is a pickup.
|
||
const filling = repr === 'space-filling';
|
||
const radiusOf = (e) => (filling ? el(e).vdw * 0.62 : el(e).r); // 0.62: vdW spheres overlap
|
||
// hard at full size and the
|
||
// shape turns to porridge
|
||
|
||
const ps = spec.atoms.map((a) => new THREE.Vector3(...a.p));
|
||
const centre = ps.reduce((acc, p) => acc.add(p), new THREE.Vector3()).multiplyScalar(1 / ps.length);
|
||
ps.forEach((p) => p.sub(centre));
|
||
const extent = Math.max(...ps.map((p, i) => p.length() + radiusOf(spec.atoms[i].e))) || 1;
|
||
const k = unit > 0 ? unit : fit / extent;
|
||
|
||
const dst = { position: [], normal: [], color: [], mat: [] };
|
||
const m = new THREE.Matrix4();
|
||
|
||
for (let i = 0; i < ps.length; i++) {
|
||
const a = spec.atoms[i], r = radiusOf(a.e) * k;
|
||
m.compose(ps[i].clone().multiplyScalar(k), new THREE.Quaternion(), new THREE.Vector3(r, r, r));
|
||
appendGeo(dst, sph, m, el(a.e).col, famIndex(a.e));
|
||
}
|
||
|
||
for (const [i, j, order = 1] of (filling ? [] : spec.bonds)) {
|
||
const A = ps[i].clone().multiplyScalar(k), B = ps[j].clone().multiplyScalar(k);
|
||
const dir = B.clone().sub(A), len = dir.length();
|
||
if (len < 1e-6) continue;
|
||
const q = new THREE.Quaternion().setFromUnitVectors(Y, dir.clone().normalize());
|
||
// A double bond is drawn as two parallel sticks, a triple as three — the notation, again.
|
||
// Offset perpendicular to both the bond and the view-ish axis so the split always reads.
|
||
const perp = new THREE.Vector3().crossVectors(dir, new THREE.Vector3(0, 0, 1));
|
||
if (perp.lengthSq() < 1e-6) perp.set(1, 0, 0);
|
||
// Bond radius lives in the same scaled units as the atom radii — ball-and-stick only reads
|
||
// as ball-and-stick if the sticks are visibly thinner than the balls.
|
||
perp.normalize().multiplyScalar(bondRadius * 1.9 * k);
|
||
const rr = bondRadius * k * (order > 1 ? 0.62 : 1);
|
||
const offs = order === 1 ? [0] : order === 2 ? [-0.5, 0.5] : [-1, 0, 1];
|
||
for (const o of offs) {
|
||
const mid = A.clone().add(B).multiplyScalar(0.5).addScaledVector(perp, o);
|
||
// Each half of the bond takes its own atom's colour — the classic two-tone stick, and it
|
||
// means you can read what's bonded to what without any of the balls being visible.
|
||
for (const half of [-1, 1]) {
|
||
const c = mid.clone().addScaledVector(dir.clone().normalize(), (len / 4) * half);
|
||
const e = spec.atoms[half < 0 ? i : j].e;
|
||
m.compose(c, q, new THREE.Vector3(rr, len / 2, rr));
|
||
appendGeo(dst, cyl, m, el(e).col, famIndex(e));
|
||
}
|
||
}
|
||
}
|
||
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.Float32BufferAttribute(dst.position, 3));
|
||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(dst.normal, 3));
|
||
geo.setAttribute('color', new THREE.Float32BufferAttribute(dst.color, 3));
|
||
geo.setAttribute('aMat', new THREE.Float32BufferAttribute(dst.mat, 1));
|
||
geo.computeBoundingSphere();
|
||
|
||
const mesh = new THREE.Mesh(geo, material || createMoleculeMaterial());
|
||
mesh.name = `molecule ${id}`;
|
||
mesh.userData = { id, name: spec.name, formula: spec.formula, role: spec.role, blurb: spec.blurb, atoms: spec.atoms.length, tris: dst.position.length / 9, repr };
|
||
return mesh;
|
||
}
|