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<ts>/ 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 <noreply@anthropic.com>
131 lines
4.4 KiB
JavaScript
131 lines
4.4 KiB
JavaScript
import { CFG } from './config.js';
|
|
import { clamp } from './util.js';
|
|
|
|
// Unified input. Game code never checks device type.
|
|
export const Input = {
|
|
moveX: 0, moveY: 0,
|
|
aimX: 0, aimY: 0, // world coords
|
|
give: false, take: false, // edge-triggered (consumed)
|
|
giveHeld: false, takeHeld: false,
|
|
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(),
|
|
_pad: null,
|
|
_rumble: 0,
|
|
};
|
|
|
|
const KEYMAP = {
|
|
KeyW: 'up', ArrowUp: 'up',
|
|
KeyS: 'down', ArrowDown: 'down',
|
|
KeyA: 'left', ArrowLeft: 'left',
|
|
KeyD: 'right', ArrowRight: 'right',
|
|
};
|
|
|
|
let edge = { give: false, take: false, heat: false, light: false, retry: false, perf: false };
|
|
|
|
export function initInput(canvas, camera) {
|
|
addEventListener('keydown', (e) => {
|
|
if (e.repeat) return;
|
|
Input._keys.add(e.code);
|
|
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));
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
const r = canvas.getBoundingClientRect();
|
|
Input._mouse.x = ((e.clientX - r.left) / r.width) * CFG.W;
|
|
Input._mouse.y = ((e.clientY - r.top) / r.height) * CFG.H;
|
|
Input.usingGamepad = false;
|
|
});
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
if (e.button === 0) edge.give = true;
|
|
if (e.button === 2) edge.take = true;
|
|
});
|
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
addEventListener('blur', () => Input._keys.clear());
|
|
|
|
Input._camera = camera;
|
|
}
|
|
|
|
export function pollInput(camera) {
|
|
// ---- gamepad ----
|
|
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
|
const pad = [...pads].find((p) => p && p.connected);
|
|
Input._pad = pad || null;
|
|
|
|
let mx = 0, my = 0;
|
|
if (Input._keys.has('KeyA') || Input._keys.has('ArrowLeft')) mx -= 1;
|
|
if (Input._keys.has('KeyD') || Input._keys.has('ArrowRight')) mx += 1;
|
|
if (Input._keys.has('KeyW') || Input._keys.has('ArrowUp')) my -= 1;
|
|
if (Input._keys.has('KeyS') || Input._keys.has('ArrowDown')) my += 1;
|
|
|
|
let grab = Input._keys.has('Space');
|
|
|
|
if (pad) {
|
|
const dz = (v) => (Math.abs(v) < 0.18 ? 0 : v);
|
|
const lx = dz(pad.axes[0] || 0), ly = dz(pad.axes[1] || 0);
|
|
if (lx || ly) { mx = lx; my = ly; Input.usingGamepad = true; }
|
|
|
|
const rx = dz(pad.axes[2] || 0), ry = dz(pad.axes[3] || 0);
|
|
if (rx || ry) {
|
|
Input.usingGamepad = true;
|
|
Input._padAim = { x: rx, y: ry };
|
|
}
|
|
const btn = (i) => pad.buttons[i] && pad.buttons[i].pressed;
|
|
if (btn(7)) { if (!Input.giveHeld) edge.give = true; Input.giveHeld = true; } else Input.giveHeld = false;
|
|
if (btn(6)) { if (!Input.takeHeld) edge.take = true; Input.takeHeld = true; } else Input.takeHeld = false;
|
|
if (btn(0)) grab = true;
|
|
if (btn(4) && !Input._lb) edge.heat = true; Input._lb = btn(4);
|
|
if (btn(5) && !Input._rb) edge.light = true; Input._rb = btn(5);
|
|
}
|
|
|
|
const m = Math.hypot(mx, my);
|
|
if (m > 1) { mx /= m; my /= m; }
|
|
Input.moveX = mx; Input.moveY = my;
|
|
Input.grab = grab;
|
|
|
|
Input.give = edge.give; Input.take = edge.take;
|
|
Input.heat = edge.heat; Input.light = edge.light;
|
|
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);
|
|
}
|
|
|
|
export function setRumble(v) {
|
|
Input._rumble = clamp(v, 0, 1);
|
|
const pad = Input._pad;
|
|
if (!pad) return;
|
|
const act = pad.vibrationActuator;
|
|
if (!act) return;
|
|
if (v < 0.02) return;
|
|
try {
|
|
act.playEffect('dual-rumble', {
|
|
duration: 90, startDelay: 0,
|
|
weakMagnitude: clamp(v, 0, 1),
|
|
strongMagnitude: clamp(v * 0.7, 0, 1),
|
|
});
|
|
} catch (_) { /* not supported */ }
|
|
}
|
|
|
|
export function pulseRumble(v, ms = 160) {
|
|
const pad = Input._pad;
|
|
if (!pad || !pad.vibrationActuator) return;
|
|
try {
|
|
pad.vibrationActuator.playEffect('dual-rumble', {
|
|
duration: ms, startDelay: 0,
|
|
weakMagnitude: clamp(v, 0, 1), strongMagnitude: clamp(v, 0, 1),
|
|
});
|
|
} catch (_) {}
|
|
}
|