diff --git a/README.md b/README.md index 667f9b0..b093ed5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ slip through a membrane. wrestling two together — radical pairs recombine barrierlessly, so it's fast. - **Two radicals that drift into each other quench on their own.** Herding is a legitimate tactic. +- **Molecules have real shapes.** Bonds splay as far apart as they can, so + water sits visibly bent at 104.5°, CO₂ is a straight bar, methane is a + caltrop and a closed carbon ring is a hexagon. The geometry is a tendency, + not a rigid body — a blast deforms it and it relaxes back. +- **The room is a medium, not a vacuum.** 600 solvent motes drift on the same + Coulomb field the atoms feel. They can't be touched and nothing interacts + with them; they're there so you can *see* the field move before anything + charged does. ## Structure @@ -73,7 +81,8 @@ streak that lapses after three quiet seconds. No label, no praise, no record. ``` src/config.js ALL feel constants. Tune the game here. -src/physics.js storm, charge, the activation barrier, zones, membrane +src/physics.js storm, charge, the activation barrier, VSEPR, zones, membrane +src/solvent.js 600 non-interactive motes advected by the field src/bonds.js the wrestle — grab, squeeze, SNAP src/bolts.js laserhands src/radicals.js the enemy @@ -91,6 +100,7 @@ node test/sim.test.mjs core physics, radicals, molly, regression node test/arena.test.mjs arena waves, win/lose, difficulty ramp node test/overload.test.mjs fuses, cascade, juice budget, perf node test/firebreak.test.mjs saturation, homolysis, the score, arena both-ends +node test/geometry.test.mjs VSEPR shapes, angular damping, the solvent ``` The sim is seedable (`seedRandom`), so runs are reproducible. Tests drive the diff --git a/src/config.js b/src/config.js index f1564a7..c783241 100644 --- a/src/config.js +++ b/src/config.js @@ -66,6 +66,16 @@ export const CFG = { BOND_SPRING: 560, BOND_DAMP: 16, BOND_BREAK_DIST: 74, + // VSEPR angular springs — bond ANGLE, where bond length is BOND_SPRING. + // Deliberately weak (~15% of BOND_SPRING): geometry must lose to the storm, + // to a blast and to Molly's hands. A rigid molecule that ignores being hit + // reads as fake instantly; a molecule that relaxes back into shape reads + // as chemistry. + ANGLE_SPRING: 85, + // Damps the TANGENTIAL relative velocity. Without it the spring is undamped + // and the storm pumps it — the mean angle comes out right while the molecule + // visibly flaps, which is worse than no geometry at all. + ANGLE_DAMP: 26, // ---- snap payoff ---- SNAP_TRAUMA: 0.62, @@ -173,6 +183,14 @@ export const CFG = { MAX_ROT: 2 * Math.PI / 180, TRAUMA_DECAY: 1.5, + // ---- the solvent ---- + // Non-interactive motes advected by the field. Not particles: no charge, + // no valence, no collisions, nothing can touch them. That is what makes + // 600 affordable when 116 real atoms already cost ~5ms. + SOLVENT_N: 600, + SOLVENT_REACH: 260, + SOLVENT_PUSH: 5200, // 1/r, not 1/r^2 — see solvent.js + // ---- palette ---- COL_POS: '#FF2D9B', COL_NEG: '#2DB8FF', diff --git a/src/elements.js b/src/elements.js index 5427860..3cb2261 100644 --- a/src/elements.js +++ b/src/elements.js @@ -8,14 +8,23 @@ // THE LAW: element owns the FILL. charge owns the EDGE and the LIGHT. // ============================================================ +// `bend` = the ideal angle between two bonds on this atom, in DEGREES. +// Only consulted when the atom has exactly two bonds; with three or more the +// geometry is just even angular spacing (3 -> 120 trigonal, 4 -> 90 caltrop), +// which lands benzene and methane on the right silhouettes for free. +// +// Two bonds is where the lone pairs matter and where the shapes are famous: +// water is visibly BENT at 104.5 and CO2 is a straight bar, and those two +// reading differently at a glance is the whole point. Carbon has no lone +// pairs, so it defaults to linear. export const ELEMENTS = { H: { col: '#EAF4FF', slots: 1, r: 7, mass: 0.4, name: 'hydrogen' }, C: { col: '#9B8CFF', slots: 4, r: 13, mass: 1.2, name: 'carbon' }, - N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen' }, - O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen' }, + N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen', bend: 107 }, + O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen', bend: 104.5 }, Na: { col: '#FF9500', slots: 1, r: 14, mass: 1.8, name: 'sodium' }, Cl: { col: '#D8FF3B', slots: 1, r: 13, mass: 2.0, name: 'chlorine' }, - S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur' }, + S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur', bend: 92 }, Mg: { col: '#D9DEE6', slots: 2, r: 13, mass: 1.6, name: 'magnesium' }, Fe: { col: '#9FB4C4', slots: 3, r: 15, mass: 3.0, name: 'iron' }, Cu: { col: '#23D9C0', slots: 2, r: 14, mass: 2.8, name: 'copper' }, diff --git a/src/particle.js b/src/particle.js index 312ae93..e08929c 100644 --- a/src/particle.js +++ b/src/particle.js @@ -30,6 +30,8 @@ export class Particle { this.mass = o.mass ?? (E ? E.mass : 1); this.spin = random() * Math.PI * 2; this.flash = 0; + // ideal angle between two bonds on this atom, radians. see elements.js + this.bend = E && E.bend ? (E.bend * Math.PI) / 180 : 0; } get freeSlots() { return Math.max(0, this.slots - this.bonds.length); } diff --git a/src/physics.js b/src/physics.js index 59ffdfa..ec8f23e 100644 --- a/src/physics.js +++ b/src/physics.js @@ -194,6 +194,82 @@ function bondSprings(world, dt) { } } +// ---- 5b. VSEPR: molecules have REAL SHAPES --------------------------- +// Bond springs alone only fix bond LENGTH, so a molecule was a set of atoms +// at whatever angles the storm happened to leave them — three circles near +// each other, never a thing. Valence-shell repulsion says the bonds around +// an atom splay as far apart as they can, and that is what gives the famous +// silhouettes: water bent at 104.5, CO2 a straight bar, methane a caltrop, +// benzene a hexagon. +// +// Weak on purpose (ANGLE_SPRING is a fraction of BOND_SPRING). These springs +// have to lose to the storm, to a blast and to Molly's hands — geometry is a +// tendency the molecule relaxes into, not a rigid body. Crank it up and +// molecules stop reacting to being hit, which reads as fake immediately. +function idealAngle(c, n) { + // two bonds is where lone pairs matter and the shapes are recognisable + if (n === 2 && c.bend) return c.bend; + // otherwise splay evenly: 3 -> 120 (trigonal/benzene), 4 -> 90 (caltrop) + return (Math.PI * 2) / n; +} + +function angleSprings(world, dt) { + const k = CFG.ANGLE_SPRING; + for (const c of world.particles) { + const n = c.bonds.length; + if (n < 2) continue; + const ideal = idealAngle(c, n); + + // order the neighbours by bearing so "adjacent" means adjacent. Applying + // the ideal to EVERY pair is wrong past three bonds: with four arms the + // adjacent pairs want 90 but the opposite pairs sit at 180, and forcing + // 90 on those folds the caltrop in on itself. + const nb = c.bonds + .map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) })) + .sort((p, q) => p.a - q.a); + + // with two bonds the "adjacent pairs" are the same pair twice + const pairs = n === 2 ? 1 : n; + for (let i = 0; i < pairs; i++) { + const A = nb[i].b, B = nb[(i + 1) % n].b; + const ax = A.x - c.x, ay = A.y - c.y; + const bx = B.x - c.x, by = B.y - c.y; + const la = Math.hypot(ax, ay) || 1e-4, lb = Math.hypot(bx, by) || 1e-4; + + const cross = ax * by - ay * bx; + const dot = ax * bx + ay * by; + const theta = Math.abs(Math.atan2(cross, dot)); // 0..PI + const err = ideal - theta; // >0 => splay further + if (Math.abs(err) < 0.01) continue; + + // push each neighbour along its own tangent, in the direction that + // opens the angle; the centre takes the reaction so momentum holds. + const s = cross >= 0 ? 1 : -1; + const f = k * err; + + // unit tangents — the direction each neighbour travels to open the angle + const tax = (ay / la) * s, tay = (-ax / la) * s; + const tbx = (-by / lb) * s, tby = (bx / lb) * s; + + // ANGULAR DAMPING. Without this the spring is undamped and the Brownian + // storm pumps it: the mean angle is correct but water FLAPS between 63 + // and 132 degrees, which reads as a broken hinge rather than a bent + // molecule. bondSprings damps its radial velocity for exactly this + // reason; the angular spring needs the tangential equivalent. + const dva = (A.vx - c.vx) * tax + (A.vy - c.vy) * tay; + const dvb = (B.vx - c.vx) * tbx + (B.vy - c.vy) * tby; + const damp = CFG.ANGLE_DAMP; + + const fax = tax * f - tax * dva * damp, fay = tay * f - tay * dva * damp; + const fbx = tbx * f - tbx * dvb * damp, fby = tby * f - tby * dvb * damp; + + A.addForce(fax, fay, dt); + B.addForce(fbx, fby, dt); + c.addForce(-(fax + fbx), -(fay + fby), dt); + } + } +} + // ---- 6. collisions --------------------------------------------------- function collide(world) { const ps = world.particles; @@ -263,6 +339,7 @@ export function step(world, dt) { world.bondsSystem.update(world, dt); // hands + barrier + snap membranes(world, dt); bondSprings(world, dt); + angleSprings(world, dt); collide(world); integrate(world, dt); } diff --git a/src/render.js b/src/render.js index 9453a76..1a66422 100644 --- a/src/render.js +++ b/src/render.js @@ -435,6 +435,9 @@ export function render(ctx, world, camera) { // ---- the ground is a MEDIUM, not a void: the field grid ---- drawField(ctx, world); + // ---- and so is the air. Under everything, so it reads as depth. ---- + world.solvent.draw(ctx); + // ---- zone washes ---- for (const z of world.zones) { ctx.fillStyle = z.type === 'sour' ? CFG.TINT_SOUR diff --git a/src/solvent.js b/src/solvent.js new file mode 100644 index 0000000..944b015 --- /dev/null +++ b/src/solvent.js @@ -0,0 +1,107 @@ +import { CFG } from './config.js'; +import { rand, random, rgba } from './util.js'; + +// ============================================================ +// THE SOLVENT. +// +// The room used to be a vacuum with objects in it. Real matter at this +// scale is not sparse — it is crowded, and everything that happens +// happens THROUGH a medium. The field grid made the ground a medium; +// this makes the air one. +// +// These motes are deliberately NOT particles. They have no charge, no +// valence, no collisions, no bonds, and nothing can interact with them — +// they are advected by the Coulomb field the sim already computes, and +// that is all. That is what makes 600 of them affordable when 116 real +// atoms already cost ~5ms: no O(n^2) anything, one pass, one path, one +// fill per layer. +// +// They earn their place by carrying information the atoms cannot: you can +// SEE the field move before anything charged does, a detonation shockwave +// becomes visible as it crosses the room, and a dense pocket of matter is +// legible as a region where the medium is being dragged around. +// ============================================================ + +const MAX_SPEED = 130; + +export class Solvent { + constructor() { this.motes = []; } + + reset(bounds, n = CFG.SOLVENT_N) { + this.motes.length = 0; + for (let i = 0; i < n; i++) { + this.motes.push({ + x: bounds.x + random() * bounds.w, + y: bounds.y + random() * bounds.h, + vx: 0, vy: 0, + // three depth bands: far motes are smaller, dimmer and lag the + // field, which reads as parallax without a second camera + z: rand(0.35, 1), + tw: random() * Math.PI * 2, + }); + } + } + + update(world, dt) { + const B = world.bounds; + // gather charged bodies ONCE — this is the only thing that costs, and + // it is linear in atoms, not in motes + const src = []; + for (const p of world.particles) if (p.charge) src.push(p); + if (world.molly.charge) src.push(world.molly); + + const R2 = CFG.SOLVENT_REACH * CFG.SOLVENT_REACH; + const damp = Math.pow(0.04, dt); + + for (const m of this.motes) { + let ax = 0, ay = 0; + for (const p of src) { + const dx = m.x - p.x, dy = m.y - p.y; + const d2 = dx * dx + dy * dy; + if (d2 > R2 || d2 < 4) continue; + const d = Math.sqrt(d2); + // 1/r falloff, not 1/r^2 — the motes are here to SHOW the field's + // shape at a distance, and an inverse-square drops off so fast that + // only the motes already touching an atom would ever move. + const f = (CFG.SOLVENT_PUSH * p.charge) / d; + ax += (dx / d) * f; + ay += (dy / d) * f; + } + m.vx = (m.vx + ax * dt) * damp; + m.vy = (m.vy + ay * dt) * damp; + + const sp = Math.hypot(m.vx, m.vy); + if (sp > MAX_SPEED) { const k = MAX_SPEED / sp; m.vx *= k; m.vy *= k; } + + m.x += m.vx * dt * m.z; + m.y += m.vy * dt * m.z; + m.tw += dt * 1.7; + + // wrap, so the medium is continuous and never depletes at the edges + if (m.x < B.x) m.x += B.w; else if (m.x > B.x + B.w) m.x -= B.w; + if (m.y < B.y) m.y += B.h; else if (m.y > B.y + B.h) m.y -= B.h; + } + } + + // Drawn UNDER everything, dim, in two batched passes. Never additive: + // 600 additive motes stack into a grey wash that eats the element hues + // and the radicals' white, which is the one thing the colour law will + // not tolerate. + draw(ctx) { + for (const [zmin, alpha, size] of [[0, 0.16, 0.9], [0.7, 0.30, 1.4]]) { + ctx.beginPath(); + let any = false; + for (const m of this.motes) { + if (m.z < zmin || (zmin === 0 && m.z >= 0.7)) continue; + // a slow twinkle so the medium never looks like dead static + const r = size * (0.75 + Math.sin(m.tw) * 0.25); + ctx.moveTo(m.x + r, m.y); + ctx.arc(m.x, m.y, r, 0, Math.PI * 2); + any = true; + } + if (!any) continue; + ctx.fillStyle = rgba('#8FA8C8', alpha); + ctx.fill(); + } + } +} diff --git a/src/world.js b/src/world.js index 60390aa..39bd116 100644 --- a/src/world.js +++ b/src/world.js @@ -6,6 +6,7 @@ import { Bolts } from './bolts.js'; import { Juice } from './juice.js'; import { Radicals } from './radicals.js'; import { Overload } from './overload.js'; +import { Solvent } from './solvent.js'; import { Particle, unbond } from './particle.js'; import { FX } from './fx.js'; import { Audio } from './audio.js'; @@ -23,6 +24,7 @@ export class World { this.bolts = new Bolts(); this.radicals = new Radicals(); this.overload = new Overload(); + this.solvent = new Solvent(); this.exit = null; this.exitOpen = false; this.onSnap = null; @@ -55,6 +57,7 @@ export class World { this.bolts.reset(); this.radicals.reset(); this.overload.reset(); + this.solvent.reset(this.bounds); for (const p of this.particles) { p.load = 0; p.fuse = 0; } this.molly.revive(); this.kills = 0; @@ -189,6 +192,7 @@ export class World { this.bolts.update(this, dt); this.radicals.update(this, dt); this.overload.update(this, dt); + this.solvent.update(this, dt); // ---- radicals eat Molly ---- const m = this.molly; diff --git a/test/geometry.test.mjs b/test/geometry.test.mjs new file mode 100644 index 0000000..68cc0a5 --- /dev/null +++ b/test/geometry.test.mjs @@ -0,0 +1,138 @@ +import { makeWorld, sim, tick, check, section, report, CFG } from './harness.mjs'; +const { bond } = await import('../src/particle.js'); + +// ============================================================ +// VSEPR — molecules have real shapes. +// +// Bond springs only fix bond LENGTH, so before this a molecule was atoms at +// whatever angles the storm left them: three circles near each other, never +// a thing. These tests guard the silhouettes that make it read as chemistry +// — water bent, CO2 a bar, methane a caltrop, benzene a hexagon. +// ============================================================ + +const deg = (c, a, b) => { + const ax = a.x - c.x, ay = a.y - c.y, bx = b.x - c.x, by = b.y - c.y; + return Math.abs(Math.atan2(ax * by - ay * bx, ax * bx + ay * by)) * 180 / Math.PI; +}; + +// Build a centre with n arms, started OFF the ideal so we prove the springs +// do the work — but never at exactly 180 for a 2-arm case, which is a +// degenerate equilibrium (the cross product vanishes and it cannot pick a +// side to fold toward). +function molecule(centreEl, armEls, seed = 42) { + const w = makeWorld(seed); + w.load({ id: 'lab', molly: { x: -9999, y: -9999 }, particles: [] }); + const c = w.spawn({ x: 640, y: 360, el: centreEl }); + const arms = armEls.map((el, i) => { + const a = 0.6 + (i / armEls.length) * Math.PI * 1.4; // deliberately wrong + return w.spawn({ x: 640 + Math.cos(a) * 30, y: 360 + Math.sin(a) * 30, el }); + }); + arms.forEach((a) => bond(c, a)); + return { w, c, arms }; +} + +// mean + spread of the ADJACENT bond angles over a stretch of live sim +function measure(w, c, arms, seconds = 3) { + sim(w, 4); + const xs = []; + const n = Math.round(seconds / CFG.DT); + for (let i = 0; i < n; i++) { + tick(w); + const bs = arms.map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) })) + .sort((p, q) => p.a - q.a); + const pairs = arms.length === 2 ? 1 : arms.length; + for (let j = 0; j < pairs; j++) xs.push(deg(c, bs[j].b, bs[(j + 1) % arms.length].b)); + } + const mean = xs.reduce((a, b) => a + b, 0) / xs.length; + const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length); + return { mean, sd }; +} + +section('VSEPR — the famous shapes'); +{ + const cases = [ + { name: 'water is BENT', c: 'O', arms: ['H', 'H'], want: 104.5, tol: 8 }, + { name: 'CO2 is a straight bar', c: 'C', arms: ['O', 'O'], want: 180, tol: 12 }, + { name: 'ammonia', c: 'N', arms: ['H', 'H'], want: 107, tol: 8 }, + { name: 'H2S is tighter than water', c: 'S', arms: ['H', 'H'], want: 92, tol: 10 }, + { name: 'methane is a caltrop', c: 'C', arms: ['H', 'H', 'H', 'H'], want: 90, tol: 6 }, + { name: 'trigonal splays to 120', c: 'C', arms: ['H', 'H', 'H'], want: 120, tol: 6 }, + ]; + for (const t of cases) { + const { w, c, arms } = molecule(t.c, t.arms); + const { mean } = measure(w, c, arms); + check(t.name, Math.abs(mean - t.want) < t.tol, + `${mean.toFixed(1)}deg (want ${t.want} +-${t.tol})`); + } +} + +section('VSEPR — water and CO2 must not look the same'); +{ + // the whole point: two bonds on carbon reads as a BAR, two on oxygen as a + // BEND. If these ever converge the geometry is decorative. + const a = molecule('O', ['H', 'H']); + const b = molecule('C', ['O', 'O']); + const wa = measure(a.w, a.c, a.arms).mean; + const wb = measure(b.w, b.c, b.arms).mean; + check('bent and linear are far apart', wb - wa > 50, + `water ${wa.toFixed(0)}deg vs CO2 ${wb.toFixed(0)}deg`); +} + +section('VSEPR — geometry HOLDS, it does not flap'); +{ + // Undamped, the mean angle came out right while water swung through a 61 + // degree range — a broken hinge, not a molecule. ANGLE_DAMP fixed that and + // this is the regression guard. + const { w, c, arms } = molecule('O', ['H', 'H']); + const { sd } = measure(w, c, arms, 5); + check('water holds a steady bend under the storm', sd < 8, `sd=${sd.toFixed(1)}deg`); +} + +section('VSEPR — geometry stays SOFT'); +{ + // It must lose to a blast, or molecules read as rigid props. A detonation + // beside a molecule should visibly deform it. + const { w, c, arms } = molecule('C', ['H', 'H', 'H', 'H']); + sim(w, 5); + const before = deg(c, arms[0], arms[1]); + const bomb = w.spawn({ x: c.x + 34, y: c.y, el: 'C' }); + w.overload.detonate(w, bomb); + tick(w); + const after = deg(c, arms[0], arms[1]); + check('a blast deforms the molecule', Math.abs(after - before) > 1, + `${before.toFixed(0)}deg -> ${after.toFixed(0)}deg`); +} + +section('SOLVENT — a medium, not more particles'); +{ + const w = makeWorld(7); + w.load({ id: 'lab', molly: { x: 640, y: 360 }, particles: [] }); + check('motes are spawned', w.solvent.motes.length === CFG.SOLVENT_N, + `${w.solvent.motes.length}`); + check('motes are NOT particles (nothing can interact with them)', + w.particles.length === 0, `${w.particles.length} particles`); + + // a charged atom must visibly move the medium around it + const p = w.spawn({ x: 640, y: 360, el: 'O' }); + p.charge = 1; + const near = w.solvent.motes + .map((m) => ({ m, d: Math.hypot(m.x - 640, m.y - 360) })) + .filter((o) => o.d < 120 && o.d > 20) + .sort((a, b) => a.d - b.d)[0]; + if (near) { + const d0 = near.d; + sim(w, 1.5); + const d1 = Math.hypot(near.m.x - p.x, near.m.y - p.y); + check('a charge pushes the medium away from it', d1 > d0, + `${d0.toFixed(0)}px -> ${d1.toFixed(0)}px`); + } else check('a charge pushes the medium away from it', false, 'no mote in range'); + + // and the medium must never leak out of the room + sim(w, 4); + const B = w.bounds; + const escaped = w.solvent.motes.filter( + (m) => m.x < B.x - 1 || m.x > B.x + B.w + 1 || m.y < B.y - 1 || m.y > B.y + B.h + 1).length; + check('motes wrap and never leak out of bounds', escaped === 0, `${escaped} escaped`); +} + +process.exit(report() ? 0 : 1);