[lane A] Molecules as pickups: real gut chemistry, procedural, 1 draw each
PROTOTYPE + proposal, not wired into boot. Pickups are B's systems, their economy is C's, object art is D's — this is a renderer and an argument, offered to all three. Bench: web/dev/laneA_molecules.html · docs/MOLECULES.md THE PITCH: the pickups already in L2 ARE molecules. C authored `nutrient`, `mucin`, `B12` and `antacid ammo` before any of this existed — those are the real biochemistry of a digestive tract, and glucose/cobalamin/bicarbonate are real compounds with known shapes. We don't need to invent a pickup language; we need to stop hiding the one the fiction handed us. GUTS is already a science game (an eosinophil is a real white blood cell, candida a real yeast, the hiatus a real choke point) — it just doesn't LOOK like one, because everything on screen is tinted tissue. THE RULE THAT MAKES IT WORK: CPK is the scanner. Everything identified is drawn in the standard element palette (O red, N blue, P orange, metals pink); everything else stays monochrome tissue. Against six biomes of tinted wall a CPK molecule is the ONLY saturated foreign colour on screen — it reads as artificial, valuable and targetable with NO HUD marker. Proof: round2_molecules_in_canal.png. That's the ART_BIBLE's synthetic-scanner fiction paying rent, and it hands Lane E a free HUD palette. WHY PROCEDURAL AND NOT MODELBEAST — and I'm authorised to burn the GPU: a molecule's shape is already known exactly. Glucose is a hexagonal ring because it IS one. FLUX+TRELLIS gives a plausible blob in 3-8 min that a chemist clocks as wrong and that can never be re-derived. Ball-and-stick from an atom list is exact, rebuilds in ms, and is ONE DRAW CALL per molecule (measured: 7 molecules = 7 draws / 26.5k tris). The split is a principle: small molecules = procedural (exact geometry known); proteins/enzymes = MODELBEAST blobs (real ones are 3000-atom blobs nobody reads — and PIPELINE says blobby organics are TRELLIS's sweet spot); cells/creatures = MODELBEAST, D's lane, unaffected. BUILT: water (chaff) · bicarbonate (antacid ammo — HCO3- + HCl is the real neutralisation, it genuinely fizzes CO2) · glucose (nutrient/score) · caffeine (overdrive — really gut-absorbed, needs no tutorial) · ATP (boost — the orange triphosphate tail IS the charge, a pickup that's a diagram of its own mechanic) · capsaicin (burn hazard — long greasy tail reads wrong on sight) · B12 (rare treasure — a cobalt in a corrin cage, the only metal vitamin; C authored it as a pickup already). Size is a free rarity signal: `unit` mode keeps TRUE relative sizes, so water is a speck and B12 a chandelier. Found by rendering it, which is why the bench exists: fuseRing returns vertices starting adjacent to B, and bonding them from A instead draws a chord across the ring — still a valid 5-cycle so nothing errors, it just looked like a squashed pentagon. Fixed in caffeine + ATP's adenine, adjacency documented. Stated plainly: geometry is idealised (correct connectivity/rings/bond orders, believable angles, authored flat) NOT crystallographic — real glucose is a chair, not a hexagon. The B12 is a simplified corrin core. Untested above ~7 on screen; fifty would want instancing. qa GREEN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
111ade7d12
commit
9f42a2e9ab
136
docs/MOLECULES.md
Normal file
136
docs/MOLECULES.md
Normal file
@ -0,0 +1,136 @@
|
||||
# MOLECULES — a proposal (Lane A, round 2)
|
||||
|
||||
**Status: PROTOTYPE + argument.** Nothing here is wired into boot. Pickups are Lane B's
|
||||
systems, their economy is Lane C's, object art is Lane D's — this document is a pitch to all
|
||||
three, plus a working renderer they can take or leave.
|
||||
|
||||
Fly it: `web/dev/laneA_molecules.html` · code: `web/js/world/molecule.js` ·
|
||||
evidence: `docs/shots/laneA/round2_molecule_pickups.png`, `round2_molecules_in_canal.png`,
|
||||
`round2_molecules_truescale.png`
|
||||
|
||||
---
|
||||
|
||||
## The pitch in one line
|
||||
|
||||
**The pickups already in L2 are molecules — so draw them as molecules.**
|
||||
|
||||
C authored `nutrient`, `mucin`, `B12`, `antacid ammo` before any of this existed. Those aren't
|
||||
game-flavoured names; they're the actual biochemistry of a digestive tract. Glucose, cobalamin
|
||||
and bicarbonate are *real compounds with known shapes*. We don't have to invent a pickup
|
||||
language — we have to stop hiding the one the fiction already handed us.
|
||||
|
||||
## Why this is free sciency-ness, and why it isn't educational
|
||||
|
||||
The game does not explain anything and should not start. Nobody needs to know what ATP is.
|
||||
But a purine reads as *chemistry* to anyone alive, the way a circuit diagram reads as
|
||||
*electronics* — because it is the real notation, not an impression of one. **The game gets to
|
||||
feel sciency by not faking it**, which is cheaper than faking it and better.
|
||||
|
||||
Consider what's already true: an **eosinophil** is a real white blood cell. **Candida** is a
|
||||
real yeast. A **bolus** is a real mass of chewed food. The **hiatus** is a real anatomical
|
||||
choke point. **GUTS is already a science game** — C built it that way. It just doesn't *look*
|
||||
like one yet, because everything on screen is tinted tissue.
|
||||
|
||||
## The one rule that makes it all work: CPK is the scanner
|
||||
|
||||
**Everything the ship's scanner has identified is drawn in CPK colours. Everything it hasn't
|
||||
is monochrome tinted tissue.**
|
||||
|
||||
CPK is the standard element palette — oxygen red, nitrogen blue, phosphorus orange, sulfur
|
||||
yellow, metals pink. Every chemistry textbook and protein viewer on earth uses it. Against six
|
||||
biomes of tinted tissue, **a CPK molecule is the only saturated foreign colour on screen**, so
|
||||
it reads as artificial, valuable and targetable at a glance — *with no HUD marker*
|
||||
(`round2_molecules_in_canal.png` is the proof: a glucose in the esophagus, unmissable).
|
||||
|
||||
That's ART_BIBLE's synthetic-scanner fiction paying rent: the ship identifies a compound and
|
||||
colours it in. → **Lane E**: this is a free HUD language. Accent a readout in an element's CPK
|
||||
colour and it belongs to that compound with no legend.
|
||||
|
||||
## Why procedural, not MODELBEAST — and where MODELBEAST *is* right
|
||||
|
||||
I'm authorised to burn GPU on this and I'm not going to, for one reason: **a molecule's shape
|
||||
is already known exactly.** Glucose is a hexagonal ring because it is a hexagonal ring. FLUX +
|
||||
TRELLIS would give a plausible blob, in 3–8 minutes, that a chemist clocks as wrong instantly
|
||||
and that can never be re-derived. Ball-and-stick from an atom list is exact, rebuilds in
|
||||
milliseconds, and is **one draw call per molecule** (measured: 7 molecules = 7 draws, 26.5k
|
||||
tris total; glucose alone is 3.7k tris / 1 draw).
|
||||
|
||||
**The split, and it's a principle, not a preference:**
|
||||
|
||||
| kind | how | why |
|
||||
|---|---|---|
|
||||
| **Small molecules** — glucose, ATP, caffeine, bicarbonate, capsaicin, B12, ions | **procedural ball-and-stick** | exact geometry is known; generation can only degrade it |
|
||||
| **Proteins / enzymes** — pepsin, trypsin, lipase, mucin | **MODELBEAST** (flux → sf3d → trellis) | real ones are 3000-atom blobs; nobody reads their structure. A blob is the honest render, and PIPELINE says blobby organics are TRELLIS's sweet spot |
|
||||
| **Cells / creatures** — eosinophil, candida, bolus, ENDO-1 | **MODELBEAST** (Lane D's round-2 headline, gated on concept sign-off) | organic, no canonical geometry, exactly what it's for |
|
||||
|
||||
So MODELBEAST still does the heavy lifting — on the things where invention is the right answer.
|
||||
|
||||
## The library (built, rendering, in the sheet)
|
||||
|
||||
Every one of these is really in the human gut. That constraint does the design work: the pickup
|
||||
table *is* the biochemistry of digestion, so nothing has to be invented and the fiction is
|
||||
automatic.
|
||||
|
||||
| molecule | proposed role | why it's the right object |
|
||||
|---|---|---|
|
||||
| **Glucose** `C₆H₁₂O₆` | nutrient / score | Food. The reason the canal exists. Common, stacks. |
|
||||
| **ATP** `C₁₀H₁₆N₅O₁₃P₃` | **boost** | Literally the cell's energy currency. The orange triphosphate tail *is* the charge — a body spends ATP by snapping one off. A boost pickup that visibly carries three charges is a diagram of its own mechanic. |
|
||||
| **Bicarbonate** `HCO₃⁻` | **antacid ammo** | Already in L2. `HCO₃⁻ + HCl → salt + H₂O + CO₂` is the real reaction that neutralises stomach acid — fire it into the acid sea and it genuinely fizzes. |
|
||||
| **Caffeine** `C₈H₁₀N₄O₂` | **overdrive** | Really absorbed through the gut wall. Everyone knows what it does to a body: throttle up, twitchier, wears off badly. Needs no tutorial. |
|
||||
| **Capsaicin** `C₁₈H₂₇NO₃` | **burn hazard** | Chilli. Long greasy tail + aromatic ring = a silhouette that reads *wrong* across a room, and it tumbles unlike any compact pickup. Free readability. |
|
||||
| **Vitamin B₁₂** `C₆₃H₈₈CoN₁₄O₁₄P` | **rare treasure** | C already authored B12 as a pickup. It's the most complex vitamin and the *only one with a metal at its heart* — a cobalt in a corrin cage. It renders as a jewel in a setting. |
|
||||
| **Water** `H₂O` | trickle / chaff | Everywhere, worth a sliver. The size floor. |
|
||||
|
||||
**Size is a free rarity signal.** `buildMolecule(id, {unit})` keeps molecules at their TRUE
|
||||
relative sizes: water is a speck, B12 is a chandelier (`round2_molecules_truescale.png`). The
|
||||
player learns what's worth chasing before reading a single colour, and it costs nothing because
|
||||
it's just true. (`{fit}` normalises them all to one size instead, if a pickup must occupy a
|
||||
fixed box — B's call.)
|
||||
|
||||
## Cheap next molecules, if this lands
|
||||
|
||||
- **Ions as basic ammo/energy**: Na⁺, K⁺, Cl⁻, Ca²⁺ — single coloured spheres, ~30 tris, and
|
||||
they're the real electrolytes. Violet, green, purple: instant colour-coded ammo types.
|
||||
- **Ethanol** `C₂H₅OH` — a *powerdown*. Controls drift, aim lags. Funny, real, gut-absorbed.
|
||||
- **Vitamin C** `C₆H₈O₆` — coat repair (it's the antioxidant). Pairs with B12 as a vitamin tier.
|
||||
- **Mucin** — the coat itself, and it's already a pickup. It's a glycoprotein → MODELBEAST blob,
|
||||
not ball-and-stick. Good first test of the split above.
|
||||
- **HCl** — the acid sea's own molecule. One green Cl, one white H. The sea could *shed* them.
|
||||
|
||||
## Other ways to be sciency that cost ~nothing
|
||||
|
||||
1. **Show the real pH.** L3's identity is "ambient pH drains the coat". Stomach pH really is
|
||||
~1.5–3.5. A HUD readout ticking `pH 1.8` as you descend is instantly sciency, is true, and
|
||||
is one number. → Lane E / C.
|
||||
2. **Name the real reaction when it fires.** Antacid hit → `HCO₃⁻ + HCl → NaCl + H₂O + CO₂↑`
|
||||
flashed for half a second in the feed. Never explained. Reads as an instrument log.
|
||||
3. **Let CO₂ be the fizz.** The bicarbonate reaction really produces gas. Bubbles from a
|
||||
neutralised acid patch are the reaction being visible, not a particle effect.
|
||||
4. **The enemies are already real** — lean in. `eosinophil_swarm` is a real immune response to
|
||||
an invader. The player IS the invader. That's the whole game's joke and it's science.
|
||||
|
||||
## What I'd need from each lane
|
||||
|
||||
- **→ C**: are these the roles you want? The economy is yours. If `nutrient` should be glucose
|
||||
and `antacid` bicarbonate, they're built. Tell me which molecules a level needs.
|
||||
- **→ B**: `buildMolecule(id, {fit|unit, detail, material})` returns one `THREE.Mesh`, one draw,
|
||||
`.userData` carries name/formula/role. Share ONE material across all of them (colour is
|
||||
per-vertex, so they still batch). Use `detail: 1` for live pickups, `2` for hero/close.
|
||||
- **→ D**: this doesn't touch your pack and doesn't want your GPU time. It *asks* for the split
|
||||
above — proteins and cells are yours, small molecules are arithmetic. Your round-2 hero list
|
||||
is unaffected.
|
||||
- **→ E**: CPK as the scanner's identification language is a free HUD palette. `ELEMENTS` in
|
||||
`molecule.js` exports the hexes.
|
||||
- **→ F**: `world/molecule.js` sits in my dir because it's my dir, not because molecules are
|
||||
"world". If this is adopted it probably wants a shared `web/js/fx/` — your call.
|
||||
|
||||
## Honest limitations
|
||||
|
||||
- **Geometry is idealised, not crystallographic**: correct connectivity, ring sizes and bond
|
||||
orders, believable angles, authored mostly flat because a flat ring reads instantly and spins
|
||||
well. Real molecules pucker (glucose is a chair, not a hexagon). This is a game, not PyMOL —
|
||||
but nobody should mistake this table for a structure database later.
|
||||
- **The B12 is a simplified corrin core** — the real thing has a nucleotide tail and far more
|
||||
side chains. It reads as "jewel in a cage", which is the job.
|
||||
- **Not performance-tested at pickup density.** 7 on screen is 7 draws / 26k tris. Fifty would
|
||||
want instancing or a merged batch, and neither exists yet.
|
||||
BIN
docs/shots/laneA/round2_molecule_pickups.png
Normal file
BIN
docs/shots/laneA/round2_molecule_pickups.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
BIN
docs/shots/laneA/round2_molecules_in_canal.png
Normal file
BIN
docs/shots/laneA/round2_molecules_in_canal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/shots/laneA/round2_molecules_truescale.png
Normal file
BIN
docs/shots/laneA/round2_molecules_truescale.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
165
web/dev/laneA_molecules.html
Normal file
165
web/dev/laneA_molecules.html
Normal file
@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GUTS — Lane A molecule harness</title>
|
||||
<!--
|
||||
Lane A's molecule bench (web/dev/, per ruling #5 — dev-only, never shipped).
|
||||
PROTOTYPE for a cross-lane proposal: see docs/MOLECULES.md and web/js/world/molecule.js.
|
||||
|
||||
http://localhost:8145/dev/laneA_molecules.html spin every molecule in a grid
|
||||
...?mol=atp one molecule, big
|
||||
...?shots=1 hide the overlay
|
||||
|
||||
DBG.one(id) show a single molecule, framed
|
||||
DBG.grid() back to the grid
|
||||
DBG.pose(t) deterministic frame at rotation t (rAF is dead in a hidden pane)
|
||||
DBG.post(name) POST the canvas to pipeline/shot_sink.py (house evidence tool)
|
||||
-->
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #05070a; }
|
||||
canvas { display: block; }
|
||||
#dbg { position: fixed; left: 10px; bottom: 10px; color: #7fdfff;
|
||||
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
|
||||
pointer-events: none; white-space: pre; }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "../vendor/three.module.js",
|
||||
"three/addons/": "../vendor/addons/" } }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="dbg"></div>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { buildMolecule, listMolecules, createMoleculeMaterial, MOLECULES } from '../js/world/molecule.js';
|
||||
|
||||
const p = new URLSearchParams(location.search);
|
||||
const on = (k) => p.has(k) && p.get(k) !== '0';
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(innerWidth || 1280, innerHeight || 720);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
renderer.info.autoReset = false;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x05070a);
|
||||
const camera = new THREE.PerspectiveCamera(45, (innerWidth || 1280) / (innerHeight || 720), 0.1, 200);
|
||||
|
||||
// ONE material for every molecule on screen — colour is per-vertex, so they still batch.
|
||||
const material = createMoleculeMaterial();
|
||||
const root = new THREE.Group();
|
||||
scene.add(root);
|
||||
|
||||
const ids = listMolecules();
|
||||
let spinners = [];
|
||||
|
||||
function clear() {
|
||||
for (const c of [...root.children]) { root.remove(c); c.geometry.dispose(); }
|
||||
spinners = [];
|
||||
}
|
||||
|
||||
function grid() {
|
||||
clear();
|
||||
const cols = Math.ceil(Math.sqrt(ids.length));
|
||||
const rows = Math.ceil(ids.length / cols);
|
||||
const pitch = 2.6;
|
||||
ids.forEach((id, i) => {
|
||||
const m = buildMolecule(id, { fit: 1, detail: 2, material });
|
||||
m.position.set((i % cols - (cols - 1) / 2) * pitch, -((i / cols | 0) - (rows - 1) / 2) * pitch, 0);
|
||||
root.add(m); spinners.push(m);
|
||||
});
|
||||
camera.position.set(0, 0, cols * pitch * 1.25);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
/** Every molecule at its TRUE relative size, in a row — the "what is this worth" read. */
|
||||
function trueScale(unit = 0.42, gap = 0.5) {
|
||||
clear();
|
||||
const order = [...ids].sort((a, b) => MOLECULES[a].atoms.length - MOLECULES[b].atoms.length);
|
||||
const built = order.map((id) => {
|
||||
const m = buildMolecule(id, { unit, detail: 2, material });
|
||||
m.geometry.computeBoundingSphere();
|
||||
return { m, r: m.geometry.boundingSphere.radius };
|
||||
});
|
||||
const total = built.reduce((a, b) => a + b.r * 2 + gap, 0) - gap;
|
||||
let x = -total / 2;
|
||||
for (const { m, r } of built) {
|
||||
x += r; m.position.set(x, 0, 0); x += r + gap;
|
||||
root.add(m); spinners.push(m);
|
||||
}
|
||||
camera.position.set(0, 0, total * 0.62); // frame the row, whatever it adds up to
|
||||
camera.lookAt(0, 0, 0);
|
||||
return total;
|
||||
}
|
||||
|
||||
function one(id) {
|
||||
clear();
|
||||
const m = buildMolecule(id, { fit: 1, detail: 3, material });
|
||||
root.add(m); spinners.push(m);
|
||||
camera.position.set(0, 0, 3.1);
|
||||
camera.lookAt(0, 0, 0);
|
||||
return m.userData;
|
||||
}
|
||||
|
||||
if (p.has('mol')) one(p.get('mol')); else if (on('true')) trueScale(); else grid();
|
||||
|
||||
const dbgEl = document.getElementById('dbg');
|
||||
if (on('shots')) dbgEl.style.display = 'none';
|
||||
|
||||
window.DBG = {
|
||||
scene, camera, renderer, root, MOLECULES,
|
||||
one, grid, trueScale, ids,
|
||||
|
||||
size(w = 1280, h = 720) {
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||
return [renderer.domElement.width, renderer.domElement.height];
|
||||
},
|
||||
|
||||
/** Deterministic frame: every molecule at rotation t. The pane runs tabs hidden, so rAF
|
||||
* never fires and nothing renders unless we ask — same trap as the world harness. */
|
||||
pose(t = 0.6, w = 1280, h = 720) {
|
||||
this.size(w, h);
|
||||
for (const m of spinners) { m.rotation.set(t * 0.55, t, t * 0.2); }
|
||||
renderer.render(scene, camera);
|
||||
const r = { draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
|
||||
renderer.info.reset();
|
||||
return r;
|
||||
},
|
||||
|
||||
post(name, port = 8144) {
|
||||
return new Promise((res, rej) => renderer.domElement.toBlob((b) => {
|
||||
if (!b || b.size < 8) return rej(new Error('empty canvas — pose() first?'));
|
||||
fetch(`http://localhost:${port}/shot/${name}.png`, { method: 'POST', body: b })
|
||||
.then((r) => (r.ok ? res(`${name}.png ${(b.size / 1024) | 0} KB`) : rej(new Error(`sink ${r.status}`))))
|
||||
.catch(rej);
|
||||
}, 'image/png'));
|
||||
},
|
||||
};
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
let t = 0, last = performance.now();
|
||||
function frame(now) {
|
||||
const dt = Math.min((now - last) / 1000, 0.05); last = now; t += dt * 0.6;
|
||||
for (const m of spinners) m.rotation.set(t * 0.55, t, t * 0.2);
|
||||
renderer.render(scene, camera);
|
||||
if (!on('shots')) {
|
||||
const u = spinners.length === 1 ? spinners[0].userData : null;
|
||||
dbgEl.textContent = u
|
||||
? `${u.name} ${u.formula}\nrole: ${u.role}\n${u.atoms} atoms · ${u.tris} tris · ${renderer.info.render.calls} draw`
|
||||
: `${spinners.length} molecules · ${renderer.info.render.calls} draws · ${renderer.info.render.triangles} tris`;
|
||||
}
|
||||
renderer.info.reset();
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
455
web/js/world/molecule.js
Normal file
455
web/js/world/molecule.js
Normal file
@ -0,0 +1,455 @@
|
||||
// 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.
|
||||
export const ELEMENTS = {
|
||||
H: { col: 0xf2f2f2, r: 0.26 },
|
||||
C: { col: 0x63666e, r: 0.40 },
|
||||
N: { col: 0x3b60ff, r: 0.40 },
|
||||
O: { col: 0xff3222, r: 0.39 },
|
||||
P: { col: 0xff9020, r: 0.50 },
|
||||
S: { col: 0xf5f52a, r: 0.49 },
|
||||
Cl: { col: 0x35e035, r: 0.44 },
|
||||
Na: { col: 0xab5cf2, r: 0.52 },
|
||||
Fe: { col: 0xe06633, r: 0.55 },
|
||||
Co: { col: 0xf090a0, r: 0.56 },
|
||||
};
|
||||
const el = (e) => ELEMENTS[e] || { col: 0xff00ff, r: 0.4 }; // magenta = you typo'd an element
|
||||
|
||||
// ── 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 } = {}) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uScan: { value: new THREE.Color(scan) },
|
||||
uScanGain: { value: scanGain },
|
||||
uKey: { value: new THREE.Vector3(0.35, 0.72, 0.6).normalize() },
|
||||
},
|
||||
vertexShader: /* glsl */`
|
||||
attribute vec3 color;
|
||||
varying vec3 vColor; varying vec3 vN; varying vec3 vView;
|
||||
void main() {
|
||||
vColor = color;
|
||||
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;
|
||||
varying vec3 vColor; varying vec3 vN; varying vec3 vView;
|
||||
void main() {
|
||||
vec3 N = normalize(vN), V = normalize(vView);
|
||||
// Key light in VIEW space: it follows the camera, so a molecule tumbling in flight
|
||||
// never rotates into an unlit pose. Wrapped (0.30 floor) so nothing goes to silhouette.
|
||||
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);
|
||||
float fres = pow(1.0 - max(0.0, dot(N, V)), 3.0);
|
||||
vec3 col = vColor * diff + vec3(1.0) * spec * 0.55 + 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {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 } = {}) {
|
||||
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 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() + el(spec.atoms[i].e).r)) || 1;
|
||||
const k = unit > 0 ? unit : fit / extent;
|
||||
|
||||
const dst = { position: [], normal: [], color: [] };
|
||||
const m = new THREE.Matrix4();
|
||||
|
||||
for (let i = 0; i < ps.length; i++) {
|
||||
const a = spec.atoms[i], r = el(a.e).r * 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);
|
||||
}
|
||||
|
||||
for (const [i, j, order = 1] of 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);
|
||||
m.compose(c, q, new THREE.Vector3(rr, len / 2, rr));
|
||||
appendGeo(dst, cyl, m, el(spec.atoms[half < 0 ? i : j].e).col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.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 };
|
||||
return mesh;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user