From 77615b23bd915038d0f71e69b11a0041e1db3218 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sat, 25 Jul 2026 23:13:08 +1000 Subject: [PATCH] Saturation is the firebreak: make bonding matter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arena ran ten seconds at 116 atoms and formed exactly ZERO bonds. Valence, the twelve elements, the open sockets and the bond wrestle — the best idea in the codebase — were all decorative, because building something had no effect on anything. A radical now abstracts from an OPEN VALENCE SLOT, so an atom with every slot spoken for is a wall the wound cannot cross. Bonded H2 is the cheapest firebreak in the game; a lone carbon with four open sockets is the best kindling. Three things had to follow: - The wall must be breakable or one good build wins forever. A blast snaps a bond and both halves come away radical; Q breaks every bond in radius with 40% going homolytic, so it is finally the igniter the design doc describes rather than a free crowbar. - Walling a radical in kills it in 3.8s and credits you. That is the payoff for building, and the only way to kill the LAST radical, since clearing needs zero live and terminating needs a pair. - Two starve timers, far apart. Starving anything with no prey deleted the meltdown outright: at 34 atoms over 1280x720 the mean spacing is ~130px against a 70px reach, so nearly every radical is momentarily alone. Boxed-in is 3.8s, merely adrift is 11s. Legibility. Radicals wore element colour — the one enemy arrived in twelve of them and at 30 on screen you could not tell threat from fuel. They now draw as a dark hole with a white rim. Molly drew BEFORE the radicals and was buried under thirty additive white glows; she draws last, over the fire, with a hard dark ring that more white cannot wash out. Bonded groups render as one silhouette, teal when sealed. The field grid. The flat lattice is now displaced by the Coulomb field the sim already computes. 0.41ms/frame at worst case. The score is the player's. CHAIN counted radical hops, so the one big number on screen went up only while you were losing. It counts terminations now, with a streak that stacks and lapses. Neon was defined but never spawned. Now that saturation stops fire, a zero-slot atom is a free firebreak — drawn from the spice table the densest arena rolled 3 to 9 of them, and at 9 it stopped melting down at all. Fixed per-arena counts; all 8 seeds now melt in 17-32s. Also: prod-safe module loading (the absolute /v/ prefix fell through nginx to the lander's index.html and was served as text/html, a silent dead title screen), an opt-in F frame-time readout, a deploy script, and removal of a full O(n^2) scan that ran every frame to angle some 3px rings. 29 new tests in test/firebreak.test.mjs; 68 passing. Co-Authored-By: Claude Opus 5 --- README.md | 25 ++- deploy/deploy.sh | 51 +++++ index.html | 16 +- src/arena.js | 34 +++- src/config.js | 18 ++ src/input.js | 8 +- src/main.js | 43 ++++- src/overload.js | 12 +- src/radicals.js | 139 ++++++++++++-- src/render.js | 415 ++++++++++++++++++++++++++++++---------- src/world.js | 25 ++- test/firebreak.test.mjs | 226 ++++++++++++++++++++++ 12 files changed, 869 insertions(+), 143 deletions(-) create mode 100755 deploy/deploy.sh create mode 100644 test/firebreak.test.mjs diff --git a/README.md b/README.md index aeb340b..cb95f94 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ No dependencies. No build step. Vanilla JS + Canvas 2D. | **left click** | proton bolt — pushes charge in (**+**) | | **right click** | vacuum bolt — rips charge out (**−**) | | **space** (hold) | grab two things and squeeze them together | -| **Q** | heat — area shove, breaks bonds | +| **Q** | heat — area shove, breaks every bond in radius (40% go homolytic) | | **R** | retry | | **shift+A** | jump straight to CASCADE | @@ -34,6 +34,16 @@ slip through a membrane. and you're dry — the left hand always works, for free. - **Bonds can't form on their own.** Every pair has an activation barrier that parks them at arm's length. Your hands are the only way over it. +- **Saturation is the firebreak.** A radical takes an atom by grabbing an + *open valence slot*. Close every slot on something and the fire can't cross + it. A bonded pair of hydrogens is the cheapest wall in the game; a lone + carbon has four open sockets and is the best kindling. Wall a radical in + with nothing left to take and it goes out on its own in a few seconds — + which is the only way to kill the last one, since terminating needs a pair. +- **Nothing you build is permanent.** A detonation snaps a bond and *both* + halves come away radical. **Q breaks every bond it touches** and 40% of + those splits go homolytic — two new radicals. The button that unsticks a + jam is the button that opens a hole in your own wall. - **The membrane blocks anything charged and ignores anything neutral.** It has no guard and no keyhole. You get through by becoming the right kind of thing. - **Sour air keeps shoving protons onto everything.** Including you. @@ -55,6 +65,9 @@ the fire out and it clears. Let it burn and the field melts down. Density is the difficulty dial. Everything else follows from it. +The one number on screen is **terminations**, and back-to-back kills stack a +streak that lapses after three quiet seconds. No label, no praise, no record. + ## Layout ``` @@ -76,6 +89,7 @@ test/*.test.mjs headless sim tests — `node test/sim.test.mjs` 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 ``` The sim is seedable (`seedRandom`), so runs are reproducible. Tests drive the @@ -90,10 +104,13 @@ The three knobs that matter, in order: two numbers. 3. **`SNAP_TRAUMA` / `HITSTOP`** — the payoff. -Two constants have non-obvious constraints, documented where they live: +Some constants have non-obvious constraints, documented where they live: `BARRIER_FORCE` must stay above `MAX_CHARGE_FORCE` (or atoms bond themselves -and the wrestle is skipped), and `RAD_SPLIT_R` must stay above the field's -natural packing distance (or outbreaks never bloom). +and the wrestle is skipped); `RAD_SPLIT_R` must stay above the field's natural +packing distance (or outbreaks never bloom); and `RAD_STARVE_TIME` / +`RAD_DRIFT_TIME` must stay far apart — a radical walled in by matter you built +should die fast, but one merely adrift in a sparse early arena must not, or +the outbreak evaporates unattended and the meltdown lose state disappears. ## Dev note diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..47f0247 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Deploy Molly Cool to monsterrobot.games/mollycool +# +# The lander lives on the botchat/games VPS and forum-nginx serves it from a +# bind mount of /home/humanjing/monsterrobot.games. That mount is READ-ONLY +# inside the container, so `docker cp` fails silently — always write the HOST +# path. (Same trap as beyondmorp V2.) +# +# monsterrobot.games -> /home/humanjing/monsterrobot.games (nginx root) +# monsterrobot.games/mollycool -> games/mollycool via a root symlink +# +# nginx needs no config change: the server block's `location /` try_files +# resolves the symlink, which is the same convention not-tonight uses. +set -euo pipefail + +HOST="humanjing@100.71.119.27" +LANDER="/home/humanjing/monsterrobot.games" +DEST="$LANDER/games/mollycool" +URL="https://monsterrobot.games/mollycool/" +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "==> shipping $SRC -> $HOST:$DEST" +# Static game only. serve.py is the dev server, test/ is the headless suite, +# and neither belongs on a public web root. +rsync -az --delete \ + --include='index.html' \ + --include='src/' --include='src/**' \ + --exclude='*' \ + "$SRC/" "$HOST:$DEST/" + +echo "==> ensuring the /mollycool route" +ssh "$HOST" "ln -sfn games/mollycool '$LANDER/mollycool' && ls -ld '$LANDER/mollycool'" + +echo "==> verifying live" +STAMP="$(date +%s)" +code=$(curl -s -o /dev/null -w '%{http_code}' "$URL?cb=$STAMP") +title=$(curl -s "$URL?cb=$STAMP" | grep -o '[^<]*' || true) +# The real test is the module: a broken subpath serves the LANDER's html here +# with content-type text/html, which the browser silently refuses to execute. +mime=$(curl -s -o /dev/null -w '%{content_type}' "${URL}src/main.js?cb=$STAMP") +mcode=$(curl -s -o /dev/null -w '%{http_code}' "${URL}src/main.js?cb=$STAMP") + +echo " page $code $title" +echo " module $mcode $mime" + +if [ "$code" != "200" ] || [ "$mcode" != "200" ]; then + echo "!! NOT DEPLOYED (non-200)"; exit 1 +fi +case "$title" in *"Molly Cool"*) ;; *) echo "!! wrong page served — check the symlink"; exit 1;; esac +case "$mime" in *javascript*) ;; *) echo "!! module served as '$mime', not javascript"; exit 1;; esac +echo "==> OK $URL" diff --git a/index.html b/index.html index 17c7129..82210e9 100644 --- a/index.html +++ b/index.html @@ -59,11 +59,21 @@ diff --git a/src/arena.js b/src/arena.js index 92dabc3..8cdcb04 100644 --- a/src/arena.js +++ b/src/arena.js @@ -18,17 +18,25 @@ import { Radicals } from './radicals.js'; const FUEL = ['H', 'H', 'C', 'O', 'N', 'C', 'O', 'H']; const SPICE = ['Na', 'Cl', 'S', 'Mg', 'Cu', 'Fe', 'K']; +// Ne is placed by COUNT, never rolled from SPICE. Zero slots means a radical +// can never take it and it can never be bonded, so every neon is a permanent +// firebreak the player didn't have to build — somewhere to herd a wound. +// That makes it far too strong to leave to chance: drawn randomly, the densest +// arena rolled anywhere from 3 to 9 of them, and at 9 it stopped melting down +// at all. The lose state is not allowed to depend on the shuffle. + // waves = how many ignition events. A wave has a beginning and an end, so // the arena is a fight you can finish rather than a faucet you stand under. +// ne = fixed neon count: permanent unburnable holes in the fuel map. export const ARENAS = [ - { n: 34, spice: 0.00, born: 1, every: 8.0, waves: 3, name: 'ignition' }, - { n: 44, spice: 0.08, born: 1, every: 7.0, waves: 4 }, - { n: 54, spice: 0.14, born: 2, every: 6.5, waves: 4 }, - { n: 64, spice: 0.20, born: 2, every: 6.0, waves: 5 }, - { n: 76, spice: 0.26, born: 2, every: 5.5, waves: 5 }, - { n: 88, spice: 0.32, born: 3, every: 5.0, waves: 6 }, - { n: 100, spice: 0.38, born: 3, every: 4.5, waves: 6 }, - { n: 116, spice: 0.44, born: 4, every: 4.0, waves: 7, name: 'critical' }, + { n: 34, spice: 0.00, born: 1, every: 8.0, waves: 3, ne: 0, name: 'ignition' }, + { n: 44, spice: 0.08, born: 1, every: 7.0, waves: 4, ne: 0 }, + { n: 54, spice: 0.14, born: 2, every: 6.5, waves: 4, ne: 1 }, + { n: 64, spice: 0.20, born: 2, every: 6.0, waves: 5, ne: 1 }, + { n: 76, spice: 0.26, born: 2, every: 5.5, waves: 5, ne: 2 }, + { n: 88, spice: 0.32, born: 3, every: 5.0, waves: 6, ne: 2 }, + { n: 100, spice: 0.38, born: 3, every: 4.5, waves: 6, ne: 3 }, + { n: 116, spice: 0.44, born: 4, every: 4.0, waves: 7, ne: 4, name: 'critical' }, ]; // You lose by NEGLECT, not by bad luck. The outbreak has to stay above this @@ -75,6 +83,12 @@ export class Arena { particles.push({ x, y, el }); } + // the neon, spread out on purpose — clustered firebreaks are one big wall, + // scattered ones are several places to drive a wound into + for (let k = 0; k < (s.ne || 0) && k < particles.length; k++) { + particles[Math.floor((k + 0.5) * particles.length / s.ne)].el = 'Ne'; + } + return { id: `arena-${i}`, ambient: 'neutral', @@ -127,7 +141,9 @@ export class Arena { this.spawnT += dt; const live = world.radicals.count; - this.best = Math.max(this.best, world.radicals.chain); + // best STREAK, not the outbreak's hop count — the run's number is the + // player's, everywhere it's tracked. + this.best = Math.max(this.best, world.radicals.best); // ---- the curve ---- // ignite quickly the first time (nobody wants to stare at a quiet room), diff --git a/src/config.js b/src/config.js index ac1535c..f1564a7 100644 --- a/src/config.js +++ b/src/config.js @@ -130,8 +130,23 @@ export const CFG = { RAD_KILL_HITSTOP: 130, RAD_KILL_TRAUMA: 0.7, RAD_QUENCH_R: 150, // annihilation puts out everything in this radius + // seconds with NO open valence slot in reach before a radical goes out on + // its own. This is the payoff for walling one in — and the reason a single + // cornered radical can't hold an arena open forever (clearing needs zero + // live radicals, terminating needs a pair). + RAD_STARVE_TIME: 3.8, + // ...and how long one drifting in genuinely empty water lasts. Much longer: + // this one isn't a play the player made, it's just the sparse early arenas, + // and making it as fast as a real cage deletes the meltdown lose state. + RAD_DRIFT_TIME: 11.0, RAD_BARRIER_MUL: 0.15, // radical-radical is barrierless -> fast wrestle + // ---- the score is YOURS ---- + // CHAIN used to count how far the OUTBREAK had spread, which meant the one + // big number on screen only went up while you were losing. It counts + // terminations now, and back-to-back kills build a streak. + STREAK_WINDOW: 3.0, // seconds to land the next kill and keep the run + // ---- molly's life ---- MOLLY_ELECTRONS: 3, MOLLY_IFRAME: 1.1, @@ -147,6 +162,9 @@ export const CFG = { HEAT_JITTER_SPIKE: 2.4, HEAT_TRAUMA: 0.34, HEAT_COOLDOWN: 0.5, + // Q breaks every bond it catches. This fraction come apart HOMOLYTICALLY — + // two radicals instead of a clean split. Q is the igniter, not a crowbar. + HEAT_HOMOLYTIC: 0.4, LIGHT_COOLDOWN: 0.35, // ---- camera / juice ---- diff --git a/src/input.js b/src/input.js index f2c9a0d..b5120b6 100644 --- a/src/input.js +++ b/src/input.js @@ -10,6 +10,7 @@ export const Input = { grab: false, // held heat: false, light: false, // edge-triggered retry: false, + perf: false, // F — dev frame-time readout usingGamepad: false, _mouse: { x: CFG.W / 2, y: CFG.H / 2 }, _keys: new Set(), @@ -24,7 +25,7 @@ const KEYMAP = { KeyD: 'right', ArrowRight: 'right', }; -let edge = { give: false, take: false, heat: false, light: false, retry: false }; +let edge = { give: false, take: false, heat: false, light: false, retry: false, perf: false }; export function initInput(canvas, camera) { addEventListener('keydown', (e) => { @@ -33,6 +34,7 @@ export function initInput(canvas, camera) { if (e.code === 'KeyQ') edge.heat = true; if (e.code === 'KeyE') edge.light = true; if (e.code === 'KeyR') edge.retry = true; + if (e.code === 'KeyF') edge.perf = true; if (e.code === 'Space') e.preventDefault(); }); addEventListener('keyup', (e) => Input._keys.delete(e.code)); @@ -93,8 +95,8 @@ export function pollInput(camera) { Input.give = edge.give; Input.take = edge.take; Input.heat = edge.heat; Input.light = edge.light; - Input.retry = edge.retry; - edge = { give: false, take: false, heat: false, light: false, retry: false }; + Input.retry = edge.retry; Input.perf = edge.perf; + edge = { give: false, take: false, heat: false, light: false, retry: false, perf: false }; // aim resolved in molly.js (needs her position for gamepad-relative aim) Input.mouseWorld = camera.screenToWorld(Input._mouse.x, Input._mouse.y); diff --git a/src/main.js b/src/main.js index ee18c98..1aac1f4 100644 --- a/src/main.js +++ b/src/main.js @@ -85,12 +85,47 @@ function simulate(dt) { world.update(dt); } +// ---- perf readout: OFF by default, F toggles ---------------------------- +// The zero-text rule is about the game, not about the developer. This never +// draws unless you ask for it, and it reports the frame interval the browser +// actually delivered — measuring it from a headless/backgrounded tab is +// useless because requestAnimationFrame throttles the moment the page isn't +// visible, so the only honest number comes from a real window. +const perf = { on: false, times: [], fps: 0, p95: 0, t: 0 }; + +function perfSample(dt) { + perf.times.push(dt * 1000); + if (perf.times.length > 120) perf.times.shift(); + perf.t += dt; + if (perf.t < 0.25 || perf.times.length < 10) return; + perf.t = 0; + const s = [...perf.times].sort((a, b) => a - b); + perf.fps = 1000 / (s.reduce((a, b) => a + b, 0) / s.length); + perf.p95 = s[Math.floor(s.length * 0.95)]; +} + +function drawPerf() { + ctx.save(); + ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0); + ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'; + ctx.textAlign = 'left'; + const bad = perf.fps < 55 || perf.p95 > 20; + ctx.fillStyle = bad ? 'rgba(255,90,110,0.9)' : 'rgba(127,227,196,0.75)'; + const n = world.particles.length; + const r = world.radicals.count; + ctx.fillText( + `${perf.fps.toFixed(0)} fps p95 ${perf.p95.toFixed(1)}ms ${n} atoms ${r} rad`, + 12, CFG.H - 12); + ctx.restore(); +} + function frame(now) { requestAnimationFrame(frame); let dt = (now - last) / 1000; last = now; dt = Math.min(dt, CFG.MAX_FRAME); + if (perf.on) perfSample(dt); if (state === 'title') { ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0); @@ -101,6 +136,8 @@ function frame(now) { pollInput(camera); + if (Input.perf) { perf.on = !perf.on; perf.times.length = 0; perf.t = 0; } + if (Input.retry) { if (state === 'arena' || state === 'arenaClear') loadArena(arenaIndex); else if (state === 'play') loadRoom(roomIndex); @@ -179,15 +216,11 @@ function frame(now) { } } - // hitstop/sim runs for arena states too - if (state === 'arena' || state === 'arenaClear') { - // (already stepped above via the shared sim block) - } - ctx.setTransform(ctx._dpr, 0, 0, ctx._dpr, 0, 0); render(ctx, world, camera); if (state === 'end' && world._t > 6.4) drawEndCard(); + if (perf.on) drawPerf(); } function drawEndCard() { diff --git a/src/overload.js b/src/overload.js index b6863c8..0f90482 100644 --- a/src/overload.js +++ b/src/overload.js @@ -4,6 +4,7 @@ import { FX } from './fx.js'; import { Audio } from './audio.js'; import { Juice } from './juice.js'; import { Radicals } from './radicals.js'; +import { unbond } from './particle.js'; // ============================================================ // OVERLOAD. @@ -85,9 +86,18 @@ export class Overload { q.fuse = CFG.FUSE_TIME * CFG.CHAIN_FUSE_MUL; q.fuseMax = q.fuse; } - // a blast rips bonds apart homolytically — free radicals, your problem now + // ---- HOMOLYSIS: the blast rips a bond in half ---- + // This is the pressure valve on "saturation is the firebreak". A closed + // molecule is a wall the wound cannot cross — so the wall has to be + // breakable, or one good build wins the arena forever. A blast snaps the + // bond and BOTH fragments come away radical, each with a slot open again. + // Your firebreak is exactly as permanent as your fire discipline. if (q.bonds.length && f > 0.55) { + const partner = q.bonds[q.bonds.length - 1]; + unbond(q, partner); + FX.burst((q.x + partner.x) / 2, (q.y + partner.y) / 2, 10, '#FFFFFF', 320, 0.4); Radicals.make(q); + Radicals.make(partner); } } diff --git a/src/radicals.js b/src/radicals.js index 8ec1bd9..fc1dcab 100644 --- a/src/radicals.js +++ b/src/radicals.js @@ -8,10 +8,17 @@ import { Juice } from './juice.js'; // One enemy. One boolean. No bestiary, no AI, no pathing, no plan. // // An unpaired electron. Every RAD_TICK seconds it grabs the nearest -// atom, completes itself, and hands the wound to its victim. +// atom WITH AN OPEN SLOT, completes itself, and hands the wound on. // Population is conserved per hop — but the wound MOVES, and in a // dense pocket it takes two at once and the branch factor crosses 1. // +// SATURATION IS THE FIREBREAK. A radical abstracts from an open +// valence slot; an atom whose slots are all spoken for has nothing +// to give and the wound cannot jump into it. That one rule is what +// makes the bond wrestle load-bearing in the arena instead of +// decorative: every molecule you close is a wall the fire can't +// cross, and a blast that snaps that molecule open lets it back in. +// // You cannot shoot it dead. You TERMINATE it: two radicals wrestled // together annihilate, because radical-radical recombination is // barrierless. The mechanic you already built is the kill move. @@ -19,21 +26,37 @@ import { Juice } from './juice.js'; // #FFFFFF is reserved for radicals and nothing else, ever. // ============================================================ +// can a radical take this body? the one predicate, used by the hunt, +// by the telegraph thread, and by the renderer's "is this tinder" tell. +// keeping it in one place is what stops the read and the rule drifting apart. +export function isTinder(q) { + return !q.radical && !q.noble && !q.fixed && q.freeSlots > 0; +} + export class Radicals { constructor() { this.tick = 0; this.count = 0; this.chain = 0; // how many hops this outbreak has made this.peak = 0; + // ---- the player's number ---- + this.kills = 0; // radicals terminated this run + this.streak = 0; // terminations back to back + this.streakT = 0; // time left to keep it alive + this.best = 0; // best streak this run } - reset() { this.tick = 0; this.count = 0; this.chain = 0; this.peak = 0; } + reset() { + this.tick = 0; this.count = 0; this.chain = 0; this.peak = 0; + this.kills = 0; this.streak = 0; this.streakT = 0; this.best = 0; + } static make(p) { if (p.radical || p.noble) return false; p.radical = true; p.radTele = 0; - p.radTarget = null; + p.radHunt = null; + p.radStarved = false; FX.burst(p.x, p.y, 10, '#FFFFFF', 300, 0.4); FX.ring(p.x, p.y, 2, 40, '#FFFFFF', 0.3, 2); return true; @@ -42,11 +65,27 @@ export class Radicals { static clear(p) { p.radical = false; p.radTele = 0; - p.radTarget = null; + p.radHunt = null; + p.radStarved = false; } all(world) { return world.particles.filter(p => p.radical); } + // a single radical going out — starved, or caught in someone else's quench. + // it scores, because boxing one in is a play you have to set up. + quench(world, p) { + Radicals.clear(p); + p.radStarveT = 0; + FX.burst(p.x, p.y, 14, '#7FE3C4', 260, 0.45); + FX.ring(p.x, p.y, 2, 54, '#7FE3C4', 0.4, 2); + Audio.pop(false); + this.streak = (this.streakT > 0 ? this.streak : 0) + 1; + this.streakT = CFG.STREAK_WINDOW; + this.best = Math.max(this.best, this.streak); + this.kills++; + world.kills = (world.kills || 0) + 1; + } + // radical + radical = annihilation. barrierless, so the wrestle is FAST. terminate(world, a, b) { const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; @@ -60,20 +99,34 @@ export class Radicals { FX.ring(cx, cy, 4, CFG.RAD_QUENCH_R, '#FFFFFF', 0.5, 5); FX.ring(cx, cy, 2, CFG.RAD_QUENCH_R * 0.6, '#7FE3C4', 0.4, 3); + let got = 2; // the quench ring puts out everything it touches for (const p of world.particles) { if (!p.radical) continue; if (Math.hypot(p.x - cx, p.y - cy) < CFG.RAD_QUENCH_R) { Radicals.clear(p); + got++; FX.burst(p.x, p.y, 8, '#7FE3C4', 220, 0.35); } } - world.kills = (world.kills || 0) + 2; + + // ---- the score is YOURS. it counts what you put out, not what spread. ---- + // a kill inside the streak window extends it; let the window lapse and it + // resets. quenching a big knot in one go is worth more than mopping up. + this.streak = (this.streakT > 0 ? this.streak : 0) + got; + this.streakT = CFG.STREAK_WINDOW; + this.best = Math.max(this.best, this.streak); + this.kills += got; + world.kills = (world.kills || 0) + got; } update(world, dt) { const ps = world.particles; this.tick += dt; + if (this.streakT > 0) { + this.streakT -= dt; + if (this.streakT <= 0) this.streak = 0; + } let n = 0; for (const p of ps) if (p.radical) n++; @@ -121,14 +174,48 @@ export class Radicals { if (live >= CFG.RAD_CAP) break; // live count, not the stale one // ---- find prey ---- + // SATURATION IS THE FIREBREAK: isTinder() rejects anything with no + // open slot, so a closed molecule is a wall the wound cannot cross. + // `walled` counts SATURATED matter in reach — closed molecules and + // nobles, the things the player builds. Other radicals deliberately + // don't count: a knot of radicals packed together is a fire, not a + // cage, and letting it read as one had the outbreak smothering itself + // and quietly deleting the meltdown. const prey = []; + let walled = 0; for (const q of ps) { - if (q === r || q.radical || q.noble || q.fixed) continue; + if (q === r) continue; const d = Math.hypot(q.x - r.x, q.y - r.y); - if (d < CFG.RAD_REACH) prey.push({ q, d }); + if (d >= CFG.RAD_REACH) continue; + if (isTinder(q)) prey.push({ q, d }); + else if (!q.radical) walled++; } - if (!prey.length) continue; + + if (!prey.length) { + // BOXED IN — matter in reach and every slot of it spoken for. This is + // the payoff for building: wall a radical in and it goes out fast, so + // the bond wrestle is a way to WIN and not just a way to survive. + // + // ISOLATED — nothing in reach at all. That's the sparse early arenas, + // not a play, so it burns down far more slowly. Making both cases fast + // deleted the lose state outright: at 34 atoms over 1280x720 the mean + // spacing is ~130px against a 70px reach, so almost every radical is + // momentarily alone and the whole outbreak evaporated unattended. + // + // Both eventually end, and they have to: clearing needs zero live + // radicals and terminating needs a PAIR, so without a single-radical + // sink one last one holds the room open forever. + r.radStarved = walled > 0; + r.radHunt = null; + r.radStarveT = (r.radStarveT || 0) + CFG.RAD_TICK; + const limit = walled > 0 ? CFG.RAD_STARVE_TIME : CFG.RAD_DRIFT_TIME; + if (r.radStarveT >= limit) this.quench(world, r); + continue; + } + r.radStarved = false; + r.radStarveT = 0; prey.sort((a, b) => a.d - b.d); + r.radHunt = prey[0].q; // In a genuinely CROWDED pocket it takes TWO and the branch factor // crosses 1.0. Requiring three close neighbours (not two) keeps this @@ -175,14 +262,11 @@ export class Radicals { for (const p of world.particles) { if (!p.radical) continue; - // the hunt-thread: you can SEE what it's about to take - let best = null, bestD = CFG.RAD_REACH; - for (const q of world.particles) { - if (q === p || q.radical || q.noble || q.fixed) continue; - const d = Math.hypot(q.x - p.x, q.y - p.y); - if (d < bestD) { bestD = d; best = q; } - } - if (best) { + // the hunt-thread: you can SEE what it's about to take. + // the target was chosen on the last tick and cached — re-scanning every + // radical against every atom every frame is an O(n*m) tax for a line. + const best = p.radHunt; + if (best && isTinder(best)) { const urgency = 1 - (this.tick / CFG.RAD_TICK); ctx.strokeStyle = `rgba(255,60,60,${0.12 + urgency * 0.4})`; ctx.lineWidth = 1 + urgency * 1.2; @@ -193,8 +277,27 @@ export class Radicals { ctx.setLineDash([]); } - // jagged white corona - const flick = p.radTele > 0 ? 1 : 0.72 + Math.sin(t * 26 + p.id) * 0.28; + // STARVED: nothing left with an open slot in reach. It still kills on + // contact, but it has nowhere to go — and it must LOOK boxed in, or + // walling one off feels like nothing happened. + if (p.radStarved) { + // a closing teal collar. It TIGHTENS as the starve timer runs out, so + // "I boxed that one in and it is dying" is legible from across the + // room without a bar or a number. + const k = Math.min(1, (p.radStarveT || 0) / CFG.RAD_STARVE_TIME); + const pulse = 0.5 + Math.sin(t * 5 + p.id) * 0.5; + ctx.strokeStyle = `rgba(127,227,196,${0.35 + k * 0.45 + pulse * 0.12})`; + ctx.lineWidth = 1.4 + k * 1.6; + ctx.setLineDash([3, 5]); + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r + 22 - k * 12, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + // jagged white corona — dimmer once it's cornered + const starve = p.radStarved ? 0.55 : 1; + const flick = (p.radTele > 0 ? 1 : 0.72 + Math.sin(t * 26 + p.id) * 0.28) * starve; const R = p.r + 8; ctx.beginPath(); const spikes = 11; diff --git a/src/render.js b/src/render.js index 1b13e48..0d70b8f 100644 --- a/src/render.js +++ b/src/render.js @@ -91,23 +91,203 @@ function drawBody(ctx, x, y, r, dc, spin, col, fill) { } } +// ---- THE FIELD GRID --------------------------------------------------- +// The lattice was a flat sheet of graph paper — decoration, and the thing +// that made the room read as a void with objects in it rather than a charged +// medium. Now every node is displaced by the summed Coulomb field, which the +// sim already computes for free: positives bulge it, negatives dent it, a +// detonation sends a visible ripple across the whole room. Nodes sitting in +// real potential also get a tinted dot, so you can see charge pooling in a +// corner before anything in it has moved. +const FIELD_STEP = 40; +const FIELD_REACH = 230; +const FIELD_K = 9000; +const FIELD_MAX = 15; // px — past this the warp reads as noise +let _fCols = -1, _fRows = -1, _fx = null, _fy = null, _fp = null; + +function drawField(ctx, world) { + const B = world.bounds; + const cols = Math.ceil(B.w / FIELD_STEP), rows = Math.ceil(B.h / FIELD_STEP); + const n = (cols + 1) * (rows + 1); + if (cols !== _fCols || rows !== _fRows) { + _fCols = cols; _fRows = rows; + _fx = new Float32Array(n); _fy = new Float32Array(n); _fp = new Float32Array(n); + } + + const src = []; + for (const p of world.particles) if (p.charge) src.push(p); + if (world.molly.charge) src.push(world.molly); + + const R2 = FIELD_REACH * FIELD_REACH; + for (let j = 0; j <= rows; j++) { + for (let i = 0; i <= cols; i++) { + const gx = B.x + i * FIELD_STEP, gy = B.y + j * FIELD_STEP; + let ex = 0, ey = 0, pot = 0; + for (const p of src) { + const dx = gx - p.x, dy = gy - p.y; + const d2 = dx * dx + dy * dy; + if (d2 > R2) continue; + const d = Math.sqrt(d2) || 1; + const dd = d < CFG.D_MIN ? CFG.D_MIN : d; + const f = (FIELD_K * p.charge) / (dd * dd); + ex += (dx / d) * f; ey += (dy / d) * f; + pot += p.charge * (1 - d / FIELD_REACH); + } + const mag = Math.hypot(ex, ey); + if (mag > FIELD_MAX) { const k = FIELD_MAX / mag; ex *= k; ey *= k; } + const k = j * (cols + 1) + i; + _fx[k] = gx + ex; _fy[k] = gy + ey; _fp[k] = pot; + } + } + + // one stroke call for the whole warped lattice + ctx.strokeStyle = CFG.COL_GRID; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let j = 0; j <= rows; j++) + for (let i = 0; i <= cols; i++) { + const k = j * (cols + 1) + i; + i === 0 ? ctx.moveTo(_fx[k], _fy[k]) : ctx.lineTo(_fx[k], _fy[k]); + } + for (let i = 0; i <= cols; i++) + for (let j = 0; j <= rows; j++) { + const k = j * (cols + 1) + i; + j === 0 ? ctx.moveTo(_fx[k], _fy[k]) : ctx.lineTo(_fx[k], _fy[k]); + } + ctx.stroke(); + + // charge pooling, batched into one path per sign + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const sign of [1, -1]) { + ctx.beginPath(); + let any = false; + for (let k = 0; k < n; k++) { + const v = _fp[k] * sign; + if (v < 0.25) continue; + const r = Math.min(2.6, 0.7 + v * 0.9); + ctx.moveTo(_fx[k] + r, _fy[k]); + ctx.arc(_fx[k], _fy[k], r, 0, Math.PI * 2); + any = true; + } + if (!any) continue; + ctx.fillStyle = rgba(sign > 0 ? CFG.COL_POS : CFG.COL_NEG, 0.22); + ctx.fill(); + } + ctx.restore(); +} + +// A radical's body: element hue stripped out, so radicals.draw()'s white +// corona and core sit on a dark hole instead of on top of a coloured atom. +const RAD_FILL = '#241F2E'; + +function drawFlash(ctx, p) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = rgba('#ffffff', p.flash * 0.5); + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + p.flash), 0, Math.PI * 2); ctx.fill(); + ctx.restore(); +} + +// ---- MOLECULES RENDER AS ONE OBJECT ---------------------------------- +// Water is a thing, not three circles near each other. Flood-fill the bond +// adjacency, then lay a shared aura under the whole group: fat round-capped +// strokes along every bond plus a disc at every atom, drawn twice at +// decreasing width. Cheap (one pass over bonds), no offscreen buffer, and it +// gives the field the one thing it was missing — silhouettes that differ. +// +// It also has a JOB: a closed molecule is a firebreak, so "which of these +// blobs is solid" has to be answerable at a glance, mid-cascade. A group with +// every slot spoken for gets the calm teal seal; one still holding an open +// socket stays dim and warm, and reads as unfinished work. +function drawMolecules(ctx, world) { + const seen = new Set(); + for (const root of world.particles) { + if (seen.has(root) || !root.bonds.length) continue; + + // flood-fill this connected group + const group = []; + const stack = [root]; + seen.add(root); + while (stack.length) { + const p = stack.pop(); + group.push(p); + for (const q of p.bonds) if (!seen.has(q)) { seen.add(q); stack.push(q); } + } + if (group.length < 2) continue; + + let open = 0, rad = false; + for (const p of group) { open += p.freeSlots; if (p.radical) rad = true; } + const sealed = open === 0 && !rad; + + // sealed molecules are FIREBREAKS and must look like it + const aura = rad ? '#FFFFFF' : sealed ? '#7FE3C4' : '#8A7FB5'; + const base = sealed ? 0.16 : 0.09; + + // One path per layer, filled ONCE with nonzero winding, so the discs and + // the bond capsules merge into a single silhouette instead of stacking + // alpha at every joint. That means the quads have to wind the same way + // the arcs do (clockwise in screen space) or overlaps punch holes. + ctx.save(); + for (const [pad, a] of [[9, base], [4, base * 1.6]]) { + ctx.beginPath(); + for (const p of group) { + ctx.moveTo(p.x + p.r + pad, p.y); + ctx.arc(p.x, p.y, p.r + pad, 0, Math.PI * 2); + } + for (const p of group) { + for (const q of p.bonds) { + if (p.id > q.id) continue; + const dx = q.x - p.x, dy = q.y - p.y; + const L = Math.hypot(dx, dy) || 1; + const w = Math.min(p.r, q.r) + pad; + const nx = (-dy / L) * w, ny = (dx / L) * w; + ctx.moveTo(p.x + nx, p.y + ny); + ctx.lineTo(p.x - nx, p.y - ny); + ctx.lineTo(q.x - nx, q.y - ny); + ctx.lineTo(q.x + nx, q.y + ny); + ctx.closePath(); + } + } + ctx.fillStyle = rgba(aura, a); + ctx.fill('nonzero'); + } + ctx.restore(); + } +} + // OPEN SOCKETS: free valence as rings with dark centres, pulsing at 2Hz and // leaning toward the nearest compatible partner. Carbon with four open sockets // reads as visibly HUNGRY; completed methane reads as SATISFIED. // This is valence made legible in peripheral vision, at speed, with no text. -function drawSlots(ctx, p, world, t) { +// Which partner each open-slotted atom leans toward. Computed ONCE per frame +// into a map, not rescanned per atom inside the draw loop — that was a full +// O(n^2) sweep every frame purely to angle some 3px rings, 13k distance checks +// at arena 7 before a single pixel was drawn. +const _lean = new Map(); +function buildLeanMap(world) { + _lean.clear(); + const ps = world.particles; + const best = new Float64Array(ps.length).fill(120 * 120); + for (let i = 0; i < ps.length; i++) { + const a = ps[i]; + if (a.noble || a.freeSlots <= 0) continue; + for (let j = i + 1; j < ps.length; j++) { + const b = ps[j]; + if (b.noble || b.freeSlots <= 0 || a.isBondedTo(b)) continue; + const dx = b.x - a.x, dy = b.y - a.y; + const d2 = dx * dx + dy * dy; + if (d2 < best[i]) { best[i] = d2; _lean.set(a, b); } + if (d2 < best[j]) { best[j] = d2; _lean.set(b, a); } + } + } +} + +function drawSlots(ctx, p, t) { const free = p.freeSlots; if (free <= 0 || p.noble) return; - // find the nearest thing worth leaning toward - let lean = null, bestD = 120; - if (world) { - for (const q of world.particles) { - if (q === p || q.freeSlots <= 0 || q.noble || p.isBondedTo(q)) continue; - const d = Math.hypot(q.x - p.x, q.y - p.y); - if (d < bestD) { bestD = d; lean = q; } - } - } + const lean = _lean.get(p); const leanA = lean ? Math.atan2(lean.y - p.y, lean.x - p.x) : null; const pulse = 0.5 + Math.sin(t * 12.6) * 0.5; // 2Hz const R = p.r + 7; @@ -129,6 +309,90 @@ function drawSlots(ctx, p, world, t) { } } +// ---- MOLLY ------------------------------------------------------------ +// In a 116-atom field with the room on fire you must find yourself INSTANTLY. +// She gets three things nothing else in the game has, so the eye locks on +// without her having to out-shine thirty radicals: +// 1. a wide warm pool +// 2. a counter-rotating sweep — MOTION reads through clutter that +// brightness does not, and everything else in the field drifts +// 3. a hard dark ring right at her edge. Every glow in this game is +// additive, so the one thing that cannot be washed out by more white +// is a hole punched in it. +function drawMolly(ctx, world, t) { + const m = world.molly; + const col = m.charge === 0 ? CFG.COL_MOLLY : chargeColor(m.displayCharge); + + // the hole: drawn straight, NOT additive, so brightness around her can't eat it + ctx.save(); + const dg = ctx.createRadialGradient(m.x, m.y, m.r + 4, m.x, m.y, m.r + 30); + dg.addColorStop(0, 'rgba(6,4,14,0.85)'); + dg.addColorStop(1, 'rgba(6,4,14,0)'); + ctx.fillStyle = dg; + ctx.beginPath(); ctx.arc(m.x, m.y, m.r + 30, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + + const R = m.r * 5.2; + const g = ctx.createRadialGradient(m.x, m.y, 1, m.x, m.y, R); + g.addColorStop(0, rgba(CFG.COL_MOLLY, 0.30)); + g.addColorStop(0.35, rgba(CFG.COL_MOLLY, 0.10)); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(m.x, m.y, R, 0, Math.PI * 2); ctx.fill(); + + // two arcs sweeping one way, two the other — motion the eye catches in chaos + ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.55); + ctx.lineWidth = 1.8; + for (const [rr, sp, w] of [[m.r + 20, 1.1, 1.1], [m.r + 27, -0.7, 0.6]]) { + const sweep = t * sp; + ctx.beginPath(); ctx.arc(m.x, m.y, rr, sweep, sweep + w); ctx.stroke(); + ctx.beginPath(); ctx.arc(m.x, m.y, rr, sweep + Math.PI, sweep + Math.PI + w); ctx.stroke(); + } + ctx.restore(); + + drawBody(ctx, m.x, m.y, m.r, m.displayCharge, m.spin * 1.6, col, CFG.COL_MOLLY); + + // her core mark + ctx.beginPath(); + ctx.arc(m.x, m.y, 4.2, 0, Math.PI * 2); + ctx.fillStyle = m.charge === 0 ? CFG.COL_MOLLY : '#ffffff'; + ctx.fill(); + + // ---- her electrons: the only HUD in the game, and it orbits her ---- + for (let i = 0; i < CFG.MOLLY_ELECTRONS; i++) { + const a = -t * 1.6 + (i / CFG.MOLLY_ELECTRONS) * Math.PI * 2; + const R2 = m.r + 13; + const x = m.x + Math.cos(a) * R2, y = m.y + Math.sin(a) * R2; + const alive = i < m.electrons; + if (alive) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const g2 = ctx.createRadialGradient(x, y, 0, x, y, 7); + g2.addColorStop(0, rgba('#7FE3C4', 0.95)); + g2.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g2; + ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + ctx.beginPath(); + ctx.arc(x, y, 2.4, 0, Math.PI * 2); + ctx.fillStyle = alive ? '#BFFFE9' : 'rgba(120,140,155,0.22)'; + ctx.fill(); + } + + // i-frames: she strobes after a hit + if (m.iframe > 0 && Math.floor(t * 22) % 2 === 0) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = rgba('#FF3355', 0.3); + ctx.beginPath(); ctx.arc(m.x, m.y, m.r * 1.7, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } +} + function drawMembrane(ctx, m, t) { const dx = m.b.x - m.a.x, dy = m.b.y - m.a.y; const L = Math.hypot(dx, dy) || 1; @@ -174,16 +438,8 @@ export function render(ctx, world, camera) { ctx.save(); camera.apply(ctx); - // ---- the ground is a MEDIUM, not a void: graph lattice ---- - { - const B = world.bounds, S = CFG.GRID_STEP; - ctx.strokeStyle = CFG.COL_GRID; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let x = B.x; x <= B.x + B.w; x += S) { ctx.moveTo(x, B.y); ctx.lineTo(x, B.y + B.h); } - for (let y = B.y; y <= B.y + B.h; y += S) { ctx.moveTo(B.x, y); ctx.lineTo(B.x + B.w, y); } - ctx.stroke(); - } + // ---- the ground is a MEDIUM, not a void: the field grid ---- + drawField(ctx, world); // ---- zone washes ---- for (const z of world.zones) { @@ -271,11 +527,25 @@ export function render(ctx, world, camera) { ctx.restore(); } + // ---- molecule hulls: a bonded group is ONE object ---- + drawMolecules(ctx, world); + buildLeanMap(world); + // ---- particles ---- for (const p of world.particles) { + // A RADICAL IS NOT AN ELEMENT ANY MORE. It keeps its size and its place + // in the field, but it loses its hue and its charge rim — otherwise the + // one enemy in the game arrives in twelve colours and at 30 of them on + // screen you cannot tell threat from fuel. Element owns the fill, except + // here: #FFFFFF is reserved for radicals, and that cuts both ways. + if (p.radical) { + drawBody(ctx, p.x, p.y, p.r, 0, p.spin, 'rgba(255,255,255,0.85)', RAD_FILL); + if (p.flash > 0.01) drawFlash(ctx, p); + continue; + } const col = chargeColor(p.displayCharge); drawBody(ctx, p.x, p.y, p.r, p.displayCharge, p.spin, col, p.col); - drawSlots(ctx, p, world, t); + drawSlots(ctx, p, t); if (p.flash > 0.01) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; @@ -285,75 +555,6 @@ export function render(ctx, world, camera) { } } - // ---- molly ---- - // In a 116-atom field you must find yourself INSTANTLY. She gets a wide - // warm pool nothing else has, plus a slow sweep ring, so the eye locks on - // her without her having to be the brightest thing on screen. - { - const col = m.charge === 0 ? CFG.COL_MOLLY : chargeColor(m.displayCharge); - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; - - const R = m.r * 5.2; - const g = ctx.createRadialGradient(m.x, m.y, 1, m.x, m.y, R); - g.addColorStop(0, rgba(CFG.COL_MOLLY, 0.30)); - g.addColorStop(0.35, rgba(CFG.COL_MOLLY, 0.10)); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(m.x, m.y, R, 0, Math.PI * 2); ctx.fill(); - - // a slow sweeping arc — motion the eye catches even in chaos - const sweep = t * 1.1; - ctx.strokeStyle = rgba(CFG.COL_MOLLY, 0.5); - ctx.lineWidth = 1.6; - ctx.beginPath(); - ctx.arc(m.x, m.y, m.r + 20, sweep, sweep + 1.1); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(m.x, m.y, m.r + 20, sweep + Math.PI, sweep + Math.PI + 1.1); - ctx.stroke(); - ctx.restore(); - - drawBody(ctx, m.x, m.y, m.r, m.displayCharge, m.spin * 1.6, col, CFG.COL_MOLLY); - - // her core mark - ctx.beginPath(); - ctx.arc(m.x, m.y, 4.2, 0, Math.PI * 2); - ctx.fillStyle = m.charge === 0 ? CFG.COL_MOLLY : '#ffffff'; - ctx.fill(); - - // ---- her electrons: the only HUD in the game, and it orbits her ---- - for (let i = 0; i < CFG.MOLLY_ELECTRONS; i++) { - const a = -t * 1.6 + (i / CFG.MOLLY_ELECTRONS) * Math.PI * 2; - const R = m.r + 13; - const x = m.x + Math.cos(a) * R, y = m.y + Math.sin(a) * R; - const alive = i < m.electrons; - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; - if (alive) { - const g = ctx.createRadialGradient(x, y, 0, x, y, 7); - g.addColorStop(0, rgba('#7FE3C4', 0.95)); - g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = g; - ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); - } - ctx.restore(); - ctx.beginPath(); - ctx.arc(x, y, 2.4, 0, Math.PI * 2); - ctx.fillStyle = alive ? '#BFFFE9' : 'rgba(120,140,155,0.22)'; - ctx.fill(); - } - - // i-frames: she strobes after a hit - if (m.iframe > 0 && Math.floor(t * 22) % 2 === 0) { - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; - ctx.fillStyle = rgba('#FF3355', 0.3); - ctx.beginPath(); ctx.arc(m.x, m.y, m.r * 1.7, 0, Math.PI * 2); ctx.fill(); - ctx.restore(); - } - } - // ---- reticle ---- { const a = m.aim; @@ -376,6 +577,13 @@ export function render(ctx, world, camera) { world.overload.draw(ctx, world); world.radicals.draw(ctx, world); + + // MOLLY DRAWS LAST, ON TOP OF THE FIRE. She used to be painted before the + // radicals, so at 30 of them her warm pool was buried under thirty additive + // white glows and you genuinely could not find yourself. Nothing outranks + // knowing where you are. + drawMolly(ctx, world, t); + world.bolts.draw(ctx, world); FX.draw(ctx); @@ -401,17 +609,28 @@ export function render(ctx, world, camera) { ctx.restore(); } - // chain length, big and quiet, top centre. the only number in the game. - const chain = world.radicals.chain; - if (chain > 0) { + // ---- the only number in the game, and it is YOURS ---- + // It used to show the outbreak's hop count, so the one big readout on + // screen went UP while you were losing and sat still while you were + // winning. It counts terminations now. Back-to-back kills stack a streak + // that leans on the number and fades when the window lapses — the arcade + // hook, with no label and no praise. + const R = world.radicals; + if (R.kills > 0) { + const hot = R.streakT > 0 ? R.streakT / CFG.STREAK_WINDOW : 0; ctx.save(); ctx.textAlign = 'center'; ctx.font = '700 34px ui-monospace, SFMono-Regular, Menlo, monospace'; - ctx.fillStyle = rgba('#FFFFFF', 0.10 + heat * 0.25); - ctx.fillText(String(chain), CFG.W / 2, 56); - ctx.font = '10px ui-monospace, Menlo, monospace'; - ctx.fillStyle = rgba('#FFFFFF', 0.16); - ctx.fillText('CHAIN', CFG.W / 2, 72); + ctx.fillStyle = rgba('#FFFFFF', 0.14 + hot * 0.5 + heat * 0.15); + ctx.fillText(String(R.kills), CFG.W / 2, 56); + if (R.streak > 1 && hot > 0) { + ctx.font = '700 13px ui-monospace, Menlo, monospace'; + ctx.fillStyle = rgba('#7FE3C4', 0.35 + hot * 0.55); + ctx.fillText(`×${R.streak}`, CFG.W / 2 + 42, 52); + // the window draining, as a bar under the number. no digits. + ctx.fillStyle = rgba('#7FE3C4', 0.20 + hot * 0.4); + ctx.fillRect(CFG.W / 2 - 26 * hot, 64, 52 * hot, 2); + } ctx.restore(); } diff --git a/src/world.js b/src/world.js index 9b4f36e..60390aa 100644 --- a/src/world.js +++ b/src/world.js @@ -1,12 +1,12 @@ import { CFG } from './config.js'; -import { pointInRect } from './util.js'; +import { pointInRect, random } from './util.js'; import { Molly } from './molly.js'; import { BondsSystem } from './bonds.js'; import { Bolts } from './bolts.js'; import { Juice } from './juice.js'; import { Radicals } from './radicals.js'; import { Overload } from './overload.js'; -import { Particle } from './particle.js'; +import { Particle, unbond } from './particle.js'; import { FX } from './fx.js'; import { Audio } from './audio.js'; import { Input } from './input.js'; @@ -145,6 +145,27 @@ export class World { p.jitterMul = CFG.HEAT_JITTER_SPIKE; p._jitterDecay = 0.9; } + + // ---- Q IS THE IGNITER, NOT A CROWBAR ---- + // Heat snaps bonds in the blast, and HEAT_HOMOLYTIC of them come apart + // into two radicals instead of separating cleanly. There is no free + // action in this game: the button that unsticks a jam is also the button + // that opens a hole in the wall you spent ten seconds building. + const near = this.particles.filter(p => + Math.hypot(p.x - m.aim.x, p.y - m.aim.y) < CFG.HEAT_RADIUS); + for (const p of near) { + for (const q of [...p.bonds]) { + if (p.id > q.id) continue; // handle each bond once + unbond(p, q); + const cx = (p.x + q.x) / 2, cy = (p.y + q.y) / 2; + if (random() < CFG.HEAT_HOMOLYTIC) { + Radicals.make(p); Radicals.make(q); + FX.burst(cx, cy, 12, '#FFFFFF', 340, 0.45); + } else { + FX.burst(cx, cy, 6, CFG.COL_NEU, 200, 0.3); + } + } + } } // LIGHT — the scalpel. one target, flips its mood. diff --git a/test/firebreak.test.mjs b/test/firebreak.test.mjs new file mode 100644 index 0000000..f13df29 --- /dev/null +++ b/test/firebreak.test.mjs @@ -0,0 +1,226 @@ +import { + makeWorld, sim, tick, check, section, report, + CFG, Radicals, Input, +} from './harness.mjs'; + +const { bond } = await import('../src/particle.js'); +const { isTinder } = await import('../src/radicals.js'); +const { Arena } = await import('../src/arena.js'); + +// ============================================================ +// SATURATION IS THE FIREBREAK. +// +// This is the rule that makes the bond wrestle load-bearing in the arena +// instead of decorative. Before it, a 116-atom arena ran for ten seconds +// and formed exactly ZERO bonds, because building something had no effect +// on anything. Every check in this file is guarding that. +// ============================================================ + +section('FIREBREAK — a closed molecule stops the wound'); +{ + const w = makeWorld(5); + w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] }); + + // two hydrogens bonded to each other: 1 slot apiece, both spent. + const a = w.spawn({ x: 600, y: 360, el: 'H' }); + const b = w.spawn({ x: 613, y: 360, el: 'H' }); + bond(a, b); + check('bonded H2 has no open slot', a.freeSlots === 0 && b.freeSlots === 0); + check('a closed molecule is not tinder', !isTinder(a) && !isTinder(b)); + + // a lone carbon has four + const c = w.spawn({ x: 900, y: 360, el: 'C' }); + check('an unbonded carbon is tinder', isTinder(c), `${c.freeSlots} open slots`); + check('a noble is never tinder', !isTinder(w.spawn({ x: 300, y: 360, el: 'Ne' }))); +} + +section('FIREBREAK — the wound cannot cross it'); +{ + // A radical parked next to a sealed molecule and NOTHING else. If saturation + // did not block abstraction, the H2 would be taken within one 0.32s tick. + const w = makeWorld(6); + w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] }); + const a = w.spawn({ x: 600, y: 300, el: 'H' }); + const b = w.spawn({ x: 613, y: 300, el: 'H' }); + bond(a, b); + const seed = w.spawn({ x: 645, y: 300, el: 'O' }); + Radicals.make(seed); + + sim(w, 2.5); + check('a sealed molecule is never taken', !a.radical && !b.radical); + check('the outbreak made no hops at all', w.radicals.chain === 0, + `hops=${w.radicals.chain}`); + + // ...and the same radical next to OPEN matter takes it immediately + const w2 = makeWorld(6); + w2.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] }); + const open = w2.spawn({ x: 600, y: 300, el: 'H' }); + const seed2 = w2.spawn({ x: 645, y: 300, el: 'O' }); + Radicals.make(seed2); + sim(w2, 1.0); + check('open matter IS taken, so the wall is the cause', open.radical || w2.radicals.chain > 0, + `hops=${w2.radicals.chain}`); +} + +section('FIREBREAK — walling one in kills it, and it scores'); +{ + // ringed by sealed H2 pairs: matter in reach, none of it available. + const w = makeWorld(8); + w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] }); + const seed = w.spawn({ x: 640, y: 360, el: 'O' }); + for (let i = 0; i < 6; i++) { + const ang = (i / 6) * Math.PI * 2; + const x = 640 + Math.cos(ang) * 52, y = 360 + Math.sin(ang) * 52; + const p = w.spawn({ x, y, el: 'H' }); + const q = w.spawn({ x: x + 12, y, el: 'H' }); + bond(p, q); + } + Radicals.make(seed); + tick(w); + check('it starts alive', w.radicals.count === 1); + + let died = null; + for (let i = 0; i < 120 * 10 && died === null; i++) { + tick(w); + if (w.radicals.count === 0) died = i / 120; + } + check('a walled-in radical starves out', died !== null, + died ? `at ${died.toFixed(1)}s` : 'never'); + check('starving is FASTER than drifting in open water', + died !== null && died < CFG.RAD_DRIFT_TIME, + `${died?.toFixed(1)}s vs ${CFG.RAD_DRIFT_TIME}s adrift`); + check('the player is credited for the cage', w.radicals.kills === 1, + `kills=${w.radicals.kills}`); +} + +section('FIREBREAK — the wall is breakable'); +{ + // ...or one good build would win the arena forever. A blast has to be able + // to snap a bond and let the fire back through. + const w = makeWorld(9); + w.load({ id: 'lab', molly: { x: 60, y: 60 }, particles: [] }); + const a = w.spawn({ x: 640, y: 360, el: 'H' }); + const b = w.spawn({ x: 653, y: 360, el: 'H' }); + bond(a, b); + const bomb = w.spawn({ x: 660, y: 360, el: 'C' }); + + w.overload.detonate(w, bomb); + check('the blast snapped the bond', a.bonds.length === 0 && b.bonds.length === 0); + check('both fragments came away radical (homolysis)', a.radical && b.radical); + check('and they are tinder again', a.freeSlots > 0 && b.freeSlots > 0); +} + +section('FIREBREAK — Q is the igniter, not a crowbar'); +{ + const w = makeWorld(2); + w.load({ id: 'lab', molly: { x: 640, y: 360 }, particles: [] }); + let broken = 0, madeRadicals = 0; + // 40% homolytic is a coin-flip per bond, so this samples it + for (let trial = 0; trial < 40; trial++) { + w.particles.length = 0; + w.radicals.reset(); + const a = w.spawn({ x: 640, y: 340, el: 'H' }); + const b = w.spawn({ x: 653, y: 340, el: 'H' }); + bond(a, b); + w.molly.heatCool = 0; + w.molly.aim.x = 640; w.molly.aim.y = 340; + Input.heat = true; + w.handleVerbs(); + Input.heat = false; + if (a.bonds.length === 0) broken++; + if (a.radical || b.radical) madeRadicals++; + } + check('heat breaks every bond it catches', broken === 40, `${broken}/40`); + check('some of those splits go homolytic', madeRadicals > 0, `${madeRadicals}/40 lit a fire`); + check('but not all of them — it stays a gamble', madeRadicals < 40, `${madeRadicals}/40`); +} + +section('SCORE — the big number belongs to the player'); +{ + const w = makeWorld(4); + w.load({ id: 'lab', molly: { x: 60, y: 660 }, particles: [] }); + const a = w.spawn({ x: 600, y: 300, el: 'O' }); + const b = w.spawn({ x: 640, y: 300, el: 'O' }); + Radicals.make(a); Radicals.make(b); + check('score starts at zero', w.radicals.kills === 0); + + w.radicals.terminate(w, a, b); + check('a termination scores', w.radicals.kills === 2, `kills=${w.radicals.kills}`); + check('and opens a streak window', w.radicals.streakT > 0 && w.radicals.streak === 2, + `streak=${w.radicals.streak}`); + + // land another inside the window and it stacks + const c = w.spawn({ x: 200, y: 600, el: 'O' }); + const d = w.spawn({ x: 240, y: 600, el: 'O' }); + Radicals.make(c); Radicals.make(d); + w.radicals.terminate(w, c, d); + check('back-to-back kills stack the streak', w.radicals.streak === 4, + `streak=${w.radicals.streak}`); + + // let the window lapse + sim(w, CFG.STREAK_WINDOW + 0.5); + check('the streak lapses on its own', w.radicals.streak === 0); + check('but the score does not', w.radicals.kills === 4, `kills=${w.radicals.kills}`); +} + +section('NEON — a free firebreak, so it cannot be left to the shuffle'); +{ + const { ARENAS } = await import('../src/arena.js'); + // rolled from the spice table, the densest arena drew anywhere from 3 to 9 + // neon, and on a 9 roll it stopped melting down at all. Fixed counts now. + const counts = [1, 2, 3, 4, 5].map(seed => { + makeWorld(seed); // reseeds the shared RNG + const a = new Arena(); + return a.room(7).particles.filter(p => p.el === 'Ne').length; + }); + check('neon count does not depend on the seed', + new Set(counts).size === 1, `saw ${counts.join(',')}`); + check('and it matches the arena dial', counts[0] === ARENAS[7].ne, + `${counts[0]} vs spec ${ARENAS[7].ne}`); + + makeWorld(1); + const early = new Arena().room(0).particles.filter(p => p.el === 'Ne').length; + check('the first arena hands out no free walls', early === 0, `ne=${early}`); + + // the lose state must survive every shuffle, not most of them + const melts = [1, 2, 3, 5, 8, 11].map(seed => { + const w = makeWorld(seed); const a = new Arena(); + w.load(a.room(7)); a.enter(w, 7); + for (let i = 0; i < 120 * 75; i++) { tick(w); if (a.meltdown) return i / 120; } + return null; + }); + check('the densest arena melts down on EVERY seed', melts.every(m => m !== null), + melts.map(m => (m ? m.toFixed(0) + 's' : 'NEVER')).join(' ')); +} + +section('REGRESSION — the arena still has both ends'); +{ + // building must not make the arena unlosable, and the new sinks must not + // make it unwinnable. Both directions, on the real arena definitions. + const w = makeWorld(3); + const a = new Arena(); + w.load(a.room(7)); a.enter(w, 7); + let melted = null; + for (let i = 0; i < 120 * 60 && melted === null; i++) { tick(w); if (a.meltdown) melted = i / 120; } + check('neglecting the densest arena still melts it down', melted !== null, + melted ? `at ${melted.toFixed(1)}s` : 'never'); + + // a competent player terminating pairs clears the first arena + const w2 = makeWorld(11); + const a2 = new Arena(); + w2.load(a2.room(0)); a2.enter(w2, 0); + let cleared = null; + for (let i = 0; i < 120 * 120 && cleared === null; i++) { + tick(w2); + if (i % 90 === 0) { + const rs = w2.particles.filter(p => p.radical); + if (rs.length >= 2) w2.radicals.terminate(w2, rs[0], rs[1]); + else if (rs.length === 1) w2.radicals.quench(w2, rs[0]); + } + if (a2.cleared) cleared = i / 120; + } + check('and a player who keeps up still clears arena 0', cleared !== null, + cleared ? `at ${cleared.toFixed(1)}s` : 'never'); +} + +process.exit(report() ? 0 : 1);