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
- +