Thumb-scale draw meter, looser aim magnet, a bomb worth the tin
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
55ddf5932e
commit
21b9dd53fa
7 files changed
+90
-35
No files matched your search
+19
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-3
@@ -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));
|
||||
|
||||
+1
-1
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=31"></script>
|
||||
<script type="module" src="main.js?v=34"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; } };
|
||||
@@ -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';
|
||||
|
||||
+18
-8
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user