From 21b9dd53fa25a34c9c48fe0fe3da05bcfcea7c29 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 28 Jul 2026 09:51:24 +0300 Subject: [PATCH] Thumb-scale draw meter, looser aim magnet, a bomb worth the tin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The draw meter is a fixed bar across the bottom, ~90% of the width and 10px tall, with the kill notch on it. It was a 26x4 stub floating on the apron by the player's feet, which is nothing under a thumb at arm's length. The serve hint is suppressed while pulling since they share the slot. pullFull 95 -> 130: at 95 every pull maxed out by accident, so the meter never meant anything. - The aim magnet took the nearest body ALONG the lane, so a close shambler a little off-axis stole shots clearly aimed past it. It now picks the best ALIGNED body, with TUNE.aimTie of slack in which a nearer one still wins — a straight-ahead lane still eats the nearest body first, which is the settled feel. aimTie: 1 restores the old behaviour exactly, for A/B. - Bomb radius 2.5 -> 4.2 (2 kills -> 5 in a scattered ten-wave), a 120-particle eruption, a ring of thrown stains and a full-size pile. The blinding white frame is now a brief warm bloom — it was hiding the very gore it announced. - Rack tiles 22 -> 30 tall, and the touch band is padded above and runs to the screen edge below. At 22 logical px they were ~30 CSS px on a phone, well under the ~44 a thumb expects; now ~62. 26-game soak clean with heavy bomb use, arrays stay bounded. Serve returns 136/150 = 90.7% +/-2.4%, 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 --- HANDOFF.md | 19 +++++++++++++++++++ config.js | 15 ++++++++++++--- gameplay.js | 16 +++++++++++++--- index.html | 2 +- main.js | 4 ++-- render.js | 43 +++++++++++++++++++++++++------------------ zombies.js | 26 ++++++++++++++++++-------- 7 files changed, 90 insertions(+), 35 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 520e4ae..6b92e6b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -211,6 +211,25 @@ Oliver rejected the procedural fakes on sight. The rule this round: **animation The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape); serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma. +## Fourth feedback round — feel polish (2026-07-27) +- **The draw meter is a fixed bar across the bottom** (~90% of W, 10px tall, `METER_Y()` in + render.js), not a 26×4 stub floating on the apron by the player's feet. The serve hint is + suppressed while `G.aim` is live because they share that slot. `TUNE.pullFull` 95 → 130: at 95 + every pull maxed out by accident and the meter meant nothing. Don't push much past ~150 — a pull + that STARTS low on the screen still has to be completable before the finger runs off the glass. +- **Aim magnet.** `flickTarget` used to take the nearest body *along* the lane, so a close shambler + a little off-axis stole shots clearly aimed past it. It now picks the best-ALIGNED body + (normalised offset within the cone), with `TUNE.aimTie` (0..1) of slack in which a nearer body + still wins — so a straight-ahead lane still eats the nearest one first, which is the settled feel. + `aimTie: 1` restores the old "nearest always wins" exactly, for A/B. +- **Bomb**: radius 2.5 → 4.2 (2 kills → 5 in a scattered 10-wave), 120-particle eruption, a ring of + thrown stains, size-4 pile. The white-out (`rgba(255,255,255,0.85)`) is now a brief warm bloom + (`rgba(255,150,70,0.3)`) — the flash was hiding the very gore it announced. +- **Rack touch target**: `rackH` 22 → 30 and `rackHit` pads the touch band by `RACK_GRAB` above and + runs to the screen edge below. 22 logical px is ~30 CSS px on a phone, well under the ~44 a thumb + expects; it's now ~62. Rack text is positioned proportionally (`g.h * 0.33` / `0.72`) so the tile + height stays a free knob. + ## Gotchas learned the hard way - Browser-pane verification: python http.server sends no cache headers, so edited ES modules often load STALE even after location.reload(). Reliable fix: preview_stop + diff --git a/config.js b/config.js index 4afabfb..7a5a0da 100644 --- a/config.js +++ b/config.js @@ -47,13 +47,20 @@ export const TUNE = { // reaching a far shambler must not cost a longer drag than the screen has room for. The old // mapping (pull length = reach) is why you had to drag off the bottom of the phone. pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent - pullFull: 95, // px of pull for a full-power shot. Keep this well inside one thumb's travel. + pullFull: 130, // px of pull for a full-power shot. Keep this well inside one thumb's travel — but + // 95 was so short that EVERY pull maxed out by accident and the meter never + // meant anything. A full draw should cost a deliberate stretch of the thumb. + // Capped short of ~150 because a pull that STARTS low on the screen has to + // still be completable downward before the finger runs off the glass. pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below // zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob. aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE // mean something: at 0.45 the cone was ±10 m at the far baseline — the whole // court — so every downward pull picked the same body and the angle felt inert. // 0.10 gives ±2.9 m out there: you have to actually point at a lane. + aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the + // cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the + // lane always wins", which is what made the magnet grab the wrong shambler. aimReach: 22, // m the aim ray runs when no shambler stands in the lane // How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and // pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more @@ -76,8 +83,10 @@ export const TUNE = { tinFlames: 3, // flame balls in a fresh tin tinGuns: 1, // machine-gun charges in a fresh tin wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many - rackH: 22, // px height of the bottom weapon rack (feel knob: how much court it covers) - bombRadius: 2.5, // m blast radius of a bomb ball + rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers). + // 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which + // is why switching tins felt fiddly. rackHit also pads the touch band (render.js). + bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater hordeLen: 12, // s a horde wave lasts hordeRate: 2.5, // zombies spawned per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde diff --git a/gameplay.js b/gameplay.js index 4be3995..1b540a3 100644 --- a/gameplay.js +++ b/gameplay.js @@ -34,13 +34,23 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl const q = pullQuality(aim.mag), f = pullFrac(aim.mag); if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z - let zt = null, near = 1e9; + let zt = null, near = 1e9, bestErr = 1e9; for (const zb of zombies) { const dx = zb.x - ch.x, dz = zb.z - ch.z; const along = dx * ax + dz * az; // how far down the lane it stands if (along <= 0) continue; // behind the shot - if (Math.abs(dx * az - dz * ax) > TUNE.zSnap + TUNE.aimCone * along) continue; // outside the cone - if (along < near) { near = along; zt = zb; } // the FIRST one in the lane eats it + const cone = TUNE.zSnap + TUNE.aimCone * along; + const err = Math.abs(dx * az - dz * ax) / cone; // 0 = dead on the ray, 1 = the cone's edge + if (err > 1) continue; // outside the cone entirely + // Pick the body you are actually POINTING AT, not merely the nearest one that happens to fall + // inside the cone. "First in the lane wins" let a close shambler off to one side steal a shot + // clearly aimed past it — the magnet Oliver felt. Range only breaks ties between equally + // well-aimed targets (TUNE.aimTie of slack), so a deliberate far shot still beats a sloppy + // near one while a genuine straight-ahead lane still eats the nearest body first. + const tie = TUNE.aimTie; + if (err < bestErr - tie || (Math.abs(err - bestErr) <= tie && along < near)) { + bestErr = Math.min(bestErr, err); near = along; zt = zb; + } } const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R)); diff --git a/index.html b/index.html index 499a57d..2713f0e 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/main.js b/main.js index 205b4b8..ea9fbdd 100644 --- a/main.js +++ b/main.js @@ -5,7 +5,7 @@ import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { P, O, ball, score, G, setupPoint } from './state.js'; -import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude } from './gameplay.js'; +import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget } from './gameplay.js'; import { fitViewport, render, proj, RES } from './render.js'; import { voice, ambient } from './audio.js'; import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js'; @@ -58,6 +58,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 3451619..a6749d9 100644 --- a/render.js +++ b/render.js @@ -320,15 +320,20 @@ function drawMgRacket() { // horde: the player braces the racket forward and up- // the courtside weapon rack along the bottom: PLAIN + the four tins. Tap a tile (or 1–5) to // pick which one your next serve throws; empty tins dim out. Geometry is shared with input // via rackHit so a tap lands on the tile you see. -const RACK_BOT = 10; // px of breathing room below the rack (floats it up off the screen edge) +const RACK_BOT = 8; // px of breathing room below the rack (floats it up off the screen edge) +const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge) +export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17; // the draw meter's fixed bottom slot function rackGeom() { - const h = TUNE.rackH, n = 5, tw = Math.min(52, (W - 8) / n), rw = tw * n; + const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n; return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT, tw, h, n, rw }; } export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1 if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel const g = rackGeom(), cx = nx * W, cy = ny * H; - if (cy < g.y0 || cy > g.y0 + g.h || cx < g.x0 || cx >= g.x0 + g.rw) return -1; + // The touch band is deliberately BIGGER than the art: up by RACK_GRAB and all the way down to + // the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a + // phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly. + if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1; return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0)); } function drawRack() { @@ -354,9 +359,9 @@ function drawRack() { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.globalAlpha = dim ? 0.5 : 1; ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc'; - ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + pad + 5); // ball = ∞, else ×count + ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a'; - ctx.fillText(labels[i], tx, g.y0 + g.h - pad - 3); // weapon label, under its count + ctx.fillText(labels[i], tx, g.y0 + g.h * 0.72); // weapon label, under its count ctx.globalAlpha = 1; if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown } @@ -521,18 +526,18 @@ export function render() { ctx.globalAlpha = 1; drawRing(tx, tz, col); } - // the draw meter: it fills as you pull and the notch marks the kill line. Anchored on the - // apron JUST IN FRONT of your feet — over the head it sat right in the lane and the chevrons - // ran straight through it, and down here there is never anything else to collide with. - const hd = proj(P.x, 0, P.z + 1.2); - const mw = 26, mh = 4, mx = Math.round(hd.x - mw / 2); - // advanced up-court the apron runs out, so pin the meter clear of the rack and the hint line - const my = Math.min(Math.round(hd.y), H - TUNE.rackH - RACK_BOT - 28); - ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(mx - 1, my - 1, mw + 2, mh + 2); + // The draw meter: it fills as you pull and the notch marks the kill line. A fixed BAR ACROSS + // THE BOTTOM, ~90% of the width — it used to be a 26x4 px stub floating on the apron by your + // feet, which is nothing at arm's length with a thumb over it. Fixed position also means it + // never wanders behind the chevrons or off the top of the court. + const mw = Math.round(W * 0.9), mh = 10, mx = Math.round((W - mw) / 2); + const my = METER_Y(); + ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4); + ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fillRect(mx, my, mw, mh); // empty channel ctx.fillStyle = col; ctx.fillRect(mx, my, Math.round(mw * ft.f), mh); const notch = Math.round(mw * (TUNE.zSplatQ - TUNE.pullQMin) / (1 - TUNE.pullQMin)); - ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 2, 1, mh + 4); // the kill line - if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the meter flashes + ctx.fillStyle = '#ffffff'; ctx.fillRect(mx + notch, my - 3, 2, mh + 6); // the kill line + if (ft.f >= 0.999 && Math.floor(G.idleT * 12) % 2) { // fully drawn: the whole bar flashes ctx.fillStyle = '#ffffff'; ctx.fillRect(mx, my, mw, mh); } } @@ -597,7 +602,9 @@ export function render() { if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady - if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, G.boomFlash / 0.08) * 0.85})`; ctx.fillRect(0, 0, W, H); } + // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone + // and hid the very gore eruption it was announcing. + if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); } // HUD — Wimbledon manual scoreboard as a 16-bit panel: bevelled bottle-green box (light // top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game @@ -665,7 +672,7 @@ export function render() { if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); } ctx.restore(); } - if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) { + if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim) { // while pulling, the draw meter owns this slot ctx.fillStyle = UI.warm; ctx.font = F(8); const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; @@ -676,7 +683,7 @@ export function render() { : touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); - ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack + ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses } if (G.flash) { ctx.font = F(12); ctx.fillStyle = '#ffffff'; diff --git a/zombies.js b/zombies.js index 2afd186..44e0c82 100644 --- a/zombies.js +++ b/zombies.js @@ -179,18 +179,28 @@ function tickHorde(dt) { export function explode(x, z) { for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); - gore.push({ x, z, r: 1.6, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent - gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift(); + gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent + gore.push({ x, z, r: 2.0, a: 0.9 }); if (gore.length > 40) gore.shift(); + // a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle + for (let k = 0; k < 6; k++) { + const a = Math.random() * 6.2832, r = TUNE.bombRadius * (0.35 + Math.random() * 0.6); + gore.push({ x: x + Math.cos(a) * r, z: z + Math.sin(a) * r, r: 0.7 + Math.random() * 0.7, a: 0.85 }); + if (gore.length > 40) gore.shift(); + } const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6); if (pl) pl.size = Math.min(4, pl.size + 3); - else { piles.push({ x, z, size: 3, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); } - for (let k = 0; k < 40; k++) parts.push({ - x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6, - t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0, + else { piles.push({ x, z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); } + // the eruption itself: a fat, slow, high-arcing column of gore rather than a camera flash + for (let k = 0; k < 120; k++) parts.push({ + x: x + (Math.random() * 2 - 1) * 0.4, y: 0.5, z: z + (Math.random() * 2 - 1) * 0.4, + vx: (Math.random() * 2 - 1) * 11, vy: 2 + Math.random() * 9, vz: (Math.random() * 2 - 1) * 11, + t: 0.7 + Math.random() * 1.1, dark: k % 3 === 0, }); G.hitstop = 0.18; boom(); - G.shake = { t: 0.35, mag: 6 }; // camera kick - G.boomFlash = 0.08; // one white frame + G.shake = { t: 0.45, mag: 9 }; // camera kick + // a warm, brief bloom instead of a white-out — the blinding frame was doing the work the gore + // should be doing, and on a phone it just hurt (see render.js for the colour) + G.boomFlash = 0.06; if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure }