Merge firebreak: saturation stops the fire, and the score is the player's
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
83ed074175
25
README.md
25
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
|
||||
|
||||
|
||||
51
deploy/deploy.sh
Executable file
51
deploy/deploy.sh
Executable file
@ -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 '<title>[^<]*</title>' || 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"
|
||||
16
index.html
16
index.html
@ -59,11 +59,21 @@
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Load the module graph under a fresh /v<timestamp>/ prefix each time.
|
||||
// DEV: load the module graph under a fresh /v<timestamp>/ prefix each time.
|
||||
// Browsers cache the *parsed* module by URL, so without this you can edit a
|
||||
// file, hard-reload, and still be running yesterday's code. serve.py strips
|
||||
// the prefix back off. Costs nothing in production (drop the prefix).
|
||||
import(`/v${Date.now()}/src/main.js`);
|
||||
// the prefix back off.
|
||||
//
|
||||
// PROD: that prefix is ABSOLUTE and nothing upstream strips it, so under
|
||||
// monsterrobot.games/mollycool/ the browser asked for /v<ts>/src/main.js,
|
||||
// fell through nginx's try_files to the lander's index.html, and got served
|
||||
// HTML with `content-type: text/html` for a module — which the browser
|
||||
// refuses to execute. Silent dead title screen, no console error worth the
|
||||
// name. Ship a RELATIVE specifier instead: it resolves against the document
|
||||
// URL, so the game works at any subpath, and nginx's ETag/Last-Modified
|
||||
// revalidation covers the cache-busting the prefix was there to do.
|
||||
const DEV = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname);
|
||||
import(DEV ? `/v${Date.now()}/src/main.js` : './src/main.js');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
34
src/arena.js
34
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),
|
||||
|
||||
@ -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 ----
|
||||
|
||||
@ -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);
|
||||
|
||||
43
src/main.js
43
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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
139
src/radicals.js
139
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;
|
||||
|
||||
415
src/render.js
415
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();
|
||||
}
|
||||
|
||||
|
||||
25
src/world.js
25
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.
|
||||
|
||||
226
test/firebreak.test.mjs
Normal file
226
test/firebreak.test.mjs
Normal file
@ -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);
|
||||
Loading…
Reference in New Issue
Block a user