Files
splatter-tennis/input.js
T
wuwiandClaude Opus 5 25f915f236 Fill the rack yourself, and roll barrels out that fill it
The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.

A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)

Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.

G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.

The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.

The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.

tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:09:28 +02:00

146 lines
7.4 KiB
JavaScript

// Keyboard + one-handed touch. Exposes movement axes and the flick-aim for gameplay.
// Note: input <-> gameplay is an intentional import cycle; both only call each other's
// functions at runtime, never during module evaluation.
import { cv } from './dom.js';
import { audio, pock } from './audio.js';
import { TUNE, WEAPONS } from './config.js';
import { G, score } from './state.js';
import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId } from './gameplay.js';
import { rackHit } from './render.js';
import { togglePanel } from './panel.js';
const keys = {};
addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault();
if (dismissModal()) return; // a modal eats the whole keystroke — dismissing must never also serve
if (e.key === 't' || e.key === 'T') togglePanel();
if (e.key >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot
if (!keys[e.code]) keyPressed(e.code);
keys[e.code] = true;
});
addEventListener('keyup', e => keys[e.code] = false);
const touchAxis = { x: 0, z: 0 };
export const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0);
export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0);
// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction,
// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play).
let stick = null; // {id, x0, y0, t0, moved}
let flickAim = null; // {ax, az, t} aim override for the swing just triggered
const DEAD = 14;
// gameplay consumes the flick at contact; returns null if none or expired
export function takeFlickAim(maxAgeMs = 450) {
if (flickAim && performance.now() - flickAim.t < maxAgeMs) {
const aim = flickAim; // carries ax/az (lanes, tennis) AND fx/fy/mag (splatter free-aim)
flickAim = null;
return aim;
}
return null;
}
// the two-stage serve: a drag that STARTS at a serveable pretoss aims a visible reticle
// (G.aim, drawn by render); release fires the serve there, dragging back home cancels.
// EVERY phase of splatter play, so the drag never silently turns into something else. The old
// "drag walks the player" mode between shots was invisible state and read as a bug.
// a weapon you UNLOAD (burst or shot) rather than serve — it takes the tap directly, so the
// slingshot reticle must stay out of its way. Was `G.weapon !== 4` back when slot 4 was always GUN.
const isGunLike = () => { const w = WEAPONS[slotId(G.weapon)]; return !!w && (w.kind === 'burst' || w.kind === 'shot'); };
const canAimServe = () =>
!G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow
&& G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here
&& score.server === 'P' && !isGunLike() // a gun-like weapon pops on tap, no reticle
&& !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle
let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it
export const touchPX = () => steerPX;
// Restarting a run no longer reloads the page (see state.resetState), so this module's own state has
// to be put back by hand: a key still held, or a half-finished drag, would otherwise be inherited by
// the next run's very first frame.
export function resetInput() {
for (const k in keys) delete keys[k];
touchAxis.x = touchAxis.z = 0;
stick = flickAim = steerPX = null;
}
cv.addEventListener('touchstart', e => {
e.preventDefault(); audio();
const r = cv.getBoundingClientRect();
for (const t of e.changedTouches) {
if (dismissModal()) return; // the popup owns this gesture and nothing else sees it
if (!G.horde && !(G.mgT > 0)) { // rack tap selects a tin — but never during finger-steer modes
const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
if (slot >= 0) { selectWeapon(slot); continue; }
}
if (!stick) {
stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(),
moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve
// the reticle appears the moment the thumb lands, but mag 0 aims at nothing — you have to pull
if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 };
else steerPX = (t.clientX - r.left) / r.width * 2 - 1;
}
else keyPressed('Space');
}
}, { passive: false });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (!stick) return;
for (const t of e.changedTouches) {
if (t.identifier !== stick.id) continue;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
if (dist > DEAD) stick.moved = true;
if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag
if (dist > DEAD) {
if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation
G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist };
// the moment the pull is strong enough to actually splatter, it clicks — you can feel
// where the threshold is without watching the reticle
const hot = pullQuality(dist) >= TUNE.zSplatQ;
if (hot !== !!stick.hot) { stick.hot = hot; if (hot) pock(0.45, 1250); }
} else G.aim = { fx: 0, fy: 0, mag: 0 };
continue;
}
const r = cv.getBoundingClientRect();
steerPX = (t.clientX - r.left) / r.width * 2 - 1;
touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0;
touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0;
}
}, { passive: false });
const endTouch = e => {
e.preventDefault();
for (const t of e.changedTouches) {
if (!stick || t.identifier !== stick.id) continue;
const dt = performance.now() - stick.t0;
const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy);
const aim = G.aim; G.aim = null; steerPX = null;
if (stick.aimable && stick.moved) { // slingshot release
// only a real pull fires — walked back under pullMin, it cancels and nothing is spent.
// (a bare hold used to fire at a free auto-target; that was the whole exploit)
if (aim && dist >= TUNE.pullMin) dragServe(aim);
}
else if (!stick.moved && dt < 250) keyPressed('Space'); // tap
else if (dt < 300 && dist > 28) { // flick = aimed swing
flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
t: performance.now() };
keyPressed('Space');
}
stick = null; touchAxis.x = 0; touchAxis.z = 0;
}
};
cv.addEventListener('touchend', endTouch, { passive: false });
cv.addEventListener('touchcancel', endTouch, { passive: false });
// Mouse/desktop dismissal. Registered ONLY on fine pointers: on a touch screen pointerdown
// fires before touchstart, so a shared handler would clear the modal and let the touchstart
// below it fall through into a serve — exactly the swallowed-gesture bug.
if (!matchMedia('(pointer: coarse)').matches)
cv.addEventListener('pointerdown', () => dismissModal());