diff --git a/HANDOFF.md b/HANDOFF.md index 3026c7b..50fe4a9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,7 +5,7 @@ everything they specified is shipped, and what still matters from them is record long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. -Last updated 2026-07-30, after Oliver's SECOND playtest of the boss/ranged build. Both playtest +Last updated 2026-07-30, after Oliver's THIRD playtest round (the readability pass). All playtest rounds are closed at the bottom. Step 6 (crowd turning, ball-boy catcher) stays PARKED by his decision. **Awaiting his next phone playtest.** @@ -150,6 +150,22 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing, which is convenient and also means **a wiring bug looks exactly like working code** (see pipeline note 7). +- **Sprites are 2x (`SPRITE_H` 224, `_slice.py` `IDLE_TARGET_H` 224) and EVERY scale-critical sprite + must share that scale.** `RES` is 3, so the player at the baseline rasterises into ~170 DEVICE + pixels — a 112px source was being UPSCALED 1.5x, which is why near bodies looked soft. Sprites that + go through `drawChar`, `drawBoy`, `drawMachine` or the wound-stage `h *= img.height / SPRITE_H` rule + are all measured against `SPRITE_H`, so a sprite left at the old scale renders at HALF height. + That is why the remaster 2x-upscaled the ~29 frames it could not re-slice rather than leaving them. + Sprites drawn through plain `drawStatic(name, x, z, hMeters)` only care about ASPECT, so their + resolution is free. + **Heights above 224 are deliberate, not bugs**: a smash or toss pose SHOULD draw taller than idle + (`p_smash` is 322), and that ratio is exactly what `SPRITE_H` exists to express. +- **The umpire's asides are SITUATIONAL, never a timer pool.** `aside(name)` in gameplay.js is the only + entry point and enforces one shared cooldown (`TUNE.asideEvery`), so several triggers in the same + moment still yield one remark. The first version drew from a bag on a timer and Oliver's verdict was + "too much and not situation aware.. they seem random". Each line is now tied to the thing that + justifies it — a new variant appearing, the court filling up, one crossing your baseline, a game lost, + a tin delivered, a horde, a changeover, a machine, a boss, the trainer, taking a hit. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to @@ -313,6 +329,60 @@ Known generator quirks, all still true: --- +# CLOSED — Oliver's third playtest: audio, hit timing, readability (2026-07-30) + +### The umpire talked too much and at random +Rebuilt as situational triggers behind one shared cooldown — see the invariant above. `asideEvery` +26 s -> 55 s. Removed `ump_out_of_order`, `ump_let`, and `ump_no_appeal` (he asked for +"ump_no_deal"; that line does not exist and `ump_no_appeal` is the one he meant — confirm if a +different line is still unwanted). 11 `ump_*` lines remain. + +### "Visually the ball is not yet at the player but somehow the player already is hit" +Real, and measurable. The hostile-ball hit test reused the PARRY's `hypot(dx, dz * 0.8)` — weighting +depth by 0.8 SHRINKS it, so contact fired while the ball was still most of a metre up-court. Measured +at the moment of the hit: `dz = -0.68 m`, and the ball drawn at screen y 314 against a head at 305 — +up around his head, not on his body. Fixed with a separate unweighted test (`TUNE.shotHitR` 0.42, +`shotHitY` 1.75). Re-measured: contact at `dz ≈ -0.25 m`, ball at screen y 325 against head 305 / +feet 362 — squarely on the chest. **The parry keeps its 0.8 weighting on purpose**: a racket sweep +really is wider than it is deep. + +### "Everything is a tad too small" (his wife, and the best note of the project) +Measured before changing anything, because "regenerate the sprites at higher resolution" is only half +right. With `RES` 3, drawn DEVICE pixels vs the 112px source were: +``` +player at baseline 170 px UPSCALED 1.52x <- higher-res art helps +shambler your half 120 px upscaled 1.08x <- helps +shambler mid-court 98 px downscaled <- cannot help; needs to be BIGGER +shambler far 78 px downscaled 0.70x <- cannot help +``` +So near bodies needed resolution and far bodies needed the camera. Both were done. + +**Camera** (portrait only; landscape untouched): `camZ` 42->48, `camF` 580->760, `camY` 9->11, +`horizon` 90->45. Pushing the camera back while lengthening the lens FLATTENS the perspective, so +distant bodies grow relative to near ones instead of everything merely zooming; `camY` rises with it +for a steeper look-down so bodies overlap less at depth, and `horizon` re-centres the court after +both. Result: far shambler 26 -> 30 logical px, mid 33 -> 36, chapter-1 boss 39 -> 45, **player +unchanged at 57**, court band 200..390 (was 199..380). If you retune these four, check all four +outputs: far size, near size, court top, court bottom. + +**Sprite remaster, at zero generation cost.** The original `assets/_sheet_*.png` files are retained at +full generated resolution (~1400px wide), so nothing needed regenerating — `IDLE_TARGET_H` went to 224 +and every sheet was RE-SLICED. The sheet-to-output mapping was never written down, so it was recovered +automatically: slice each sheet to a temp dir, then greedily assign frames to existing assets 1:1 by +LANCZOS-normalised RGBA signature distance. 58 sprites came back from sheets; the 29 with no confident +sheet match were 2x-upscaled instead, which adds no detail but keeps them on the shared scale (see the +`SPRITE_H` invariant — a mismatched sprite renders at half height). `x_crowd`/`x_crowd2` were reverted +to originals: they never touch `SPRITE_H` and doubling them is pure memory. +**Known gap:** `p_idle1`/`p_idle2` and most `o_*` frames were among the 2x-upscaled set, so the two +most-seen poses gained scale but not detail. Re-slicing `_sheet_player2/3` and `_sheet_opp2/3` by hand +(rather than via the matcher) would fix that. + +### Regression net +30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters fired, +`mgT` drains, gore 40 / piles 30 / bodies capped at 34. + +--- + # CLOSED — Oliver's second boss/ranged playtest (2026-07-30) ### The parry was too generous diff --git a/assets/_slice.py b/assets/_slice.py index 0b3457a..278da18 100644 --- a/assets/_slice.py +++ b/assets/_slice.py @@ -4,7 +4,7 @@ import sys from PIL import Image -IDLE_TARGET_H = 112 +IDLE_TARGET_H = 224 # 2x remaster: the player renders at ~170 DEVICE px (RES 3), so 112 was being upscaled KEY_D2 = 60 * 60 # squared RGB distance to the bg color that still counts as background MIN_GAP = 12 # empty columns that separate frames MIN_BLOB = 60 # despeckle: drop opaque islands smaller than this many pixels diff --git a/assets/audio/ump_let.m4a b/assets/audio/ump_let.m4a deleted file mode 100644 index 156bd17..0000000 Binary files a/assets/audio/ump_let.m4a and /dev/null differ diff --git a/assets/audio/ump_no_appeal.m4a b/assets/audio/ump_no_appeal.m4a deleted file mode 100644 index fe7a6f4..0000000 Binary files a/assets/audio/ump_no_appeal.m4a and /dev/null differ diff --git a/assets/audio/ump_out_of_order.m4a b/assets/audio/ump_out_of_order.m4a deleted file mode 100644 index 630a7fa..0000000 Binary files a/assets/audio/ump_out_of_order.m4a and /dev/null differ diff --git a/assets/o_bounce.png b/assets/o_bounce.png index a3dd665..ad21eb5 100644 Binary files a/assets/o_bounce.png and b/assets/o_bounce.png differ diff --git a/assets/o_bounce2.png b/assets/o_bounce2.png index fcaa61a..b6d5ac2 100644 Binary files a/assets/o_bounce2.png and b/assets/o_bounce2.png differ diff --git a/assets/o_idle1.png b/assets/o_idle1.png index 83e73f7..c18ab26 100644 Binary files a/assets/o_idle1.png and b/assets/o_idle1.png differ diff --git a/assets/o_idle2.png b/assets/o_idle2.png index 474175d..01aa589 100644 Binary files a/assets/o_idle2.png and b/assets/o_idle2.png differ diff --git a/assets/o_run1.png b/assets/o_run1.png index 7cb99b5..13e40b3 100644 Binary files a/assets/o_run1.png and b/assets/o_run1.png differ diff --git a/assets/o_run2.png b/assets/o_run2.png index 5399f33..7149196 100644 Binary files a/assets/o_run2.png and b/assets/o_run2.png differ diff --git a/assets/o_smash.png b/assets/o_smash.png index 6074b1c..a4f49c2 100644 Binary files a/assets/o_smash.png and b/assets/o_smash.png differ diff --git a/assets/o_swing1.png b/assets/o_swing1.png index d93a6a9..2fc750c 100644 Binary files a/assets/o_swing1.png and b/assets/o_swing1.png differ diff --git a/assets/o_swing2.png b/assets/o_swing2.png index adea653..1933518 100644 Binary files a/assets/o_swing2.png and b/assets/o_swing2.png differ diff --git a/assets/o_toss.png b/assets/o_toss.png index 35001a8..ff1c9c8 100644 Binary files a/assets/o_toss.png and b/assets/o_toss.png differ diff --git a/assets/p_bounce.png b/assets/p_bounce.png index 805a661..fbaf783 100644 Binary files a/assets/p_bounce.png and b/assets/p_bounce.png differ diff --git a/assets/p_bounce2.png b/assets/p_bounce2.png index b786a58..6a1389e 100644 Binary files a/assets/p_bounce2.png and b/assets/p_bounce2.png differ diff --git a/assets/p_idle1.png b/assets/p_idle1.png index 1dd8428..2e752b0 100644 Binary files a/assets/p_idle1.png and b/assets/p_idle1.png differ diff --git a/assets/p_idle2.png b/assets/p_idle2.png index f968412..24579a7 100644 Binary files a/assets/p_idle2.png and b/assets/p_idle2.png differ diff --git a/assets/p_run1.png b/assets/p_run1.png index dca236f..2014c7e 100644 Binary files a/assets/p_run1.png and b/assets/p_run1.png differ diff --git a/assets/p_run2.png b/assets/p_run2.png index 6d912ef..07ff5dd 100644 Binary files a/assets/p_run2.png and b/assets/p_run2.png differ diff --git a/assets/p_smash.png b/assets/p_smash.png index 6ae4a58..da7b4cc 100644 Binary files a/assets/p_smash.png and b/assets/p_smash.png differ diff --git a/assets/p_swing1.png b/assets/p_swing1.png index 61e818c..b28b172 100644 Binary files a/assets/p_swing1.png and b/assets/p_swing1.png differ diff --git a/assets/p_swing2.png b/assets/p_swing2.png index 0361941..5d15c70 100644 Binary files a/assets/p_swing2.png and b/assets/p_swing2.png differ diff --git a/assets/p_toss.png b/assets/p_toss.png index 94b8573..c70896a 100644 Binary files a/assets/p_toss.png and b/assets/p_toss.png differ diff --git a/assets/x_boycarry1.png b/assets/x_boycarry1.png index 3716d8b..9085724 100644 Binary files a/assets/x_boycarry1.png and b/assets/x_boycarry1.png differ diff --git a/assets/x_boycarry2.png b/assets/x_boycarry2.png index 4c9ad3c..44488f5 100644 Binary files a/assets/x_boycarry2.png and b/assets/x_boycarry2.png differ diff --git a/assets/x_boycarry3.png b/assets/x_boycarry3.png index 876950a..3cafc37 100644 Binary files a/assets/x_boycarry3.png and b/assets/x_boycarry3.png differ diff --git a/assets/x_boycarry4.png b/assets/x_boycarry4.png index 0f1f4b1..9ea4cc0 100644 Binary files a/assets/x_boycarry4.png and b/assets/x_boycarry4.png differ diff --git a/assets/x_boymop1.png b/assets/x_boymop1.png index f7c619d..a46bba2 100644 Binary files a/assets/x_boymop1.png and b/assets/x_boymop1.png differ diff --git a/assets/x_boymop2.png b/assets/x_boymop2.png index cc5db5c..5339ce7 100644 Binary files a/assets/x_boymop2.png and b/assets/x_boymop2.png differ diff --git a/assets/x_boymop3.png b/assets/x_boymop3.png index 0466554..5adbbdf 100644 Binary files a/assets/x_boymop3.png and b/assets/x_boymop3.png differ diff --git a/assets/x_boymop4.png b/assets/x_boymop4.png index 22a0468..a9b2c15 100644 Binary files a/assets/x_boymop4.png and b/assets/x_boymop4.png differ diff --git a/assets/x_boyrun1.png b/assets/x_boyrun1.png index 7fd1637..d2fb68b 100644 Binary files a/assets/x_boyrun1.png and b/assets/x_boyrun1.png differ diff --git a/assets/x_boyrun2.png b/assets/x_boyrun2.png index 6e69454..4572b9f 100644 Binary files a/assets/x_boyrun2.png and b/assets/x_boyrun2.png differ diff --git a/assets/x_boyrun3.png b/assets/x_boyrun3.png index 4e5be4b..c8a85b3 100644 Binary files a/assets/x_boyrun3.png and b/assets/x_boyrun3.png differ diff --git a/assets/x_boyrun4.png b/assets/x_boyrun4.png index 27b5d47..7be9df5 100644 Binary files a/assets/x_boyrun4.png and b/assets/x_boyrun4.png differ diff --git a/assets/x_ump.png b/assets/x_ump.png index 9fad0c5..d46c2ca 100644 Binary files a/assets/x_ump.png and b/assets/x_ump.png differ diff --git a/assets/z_a1.png b/assets/z_a1.png index 89f45e4..d4d59f2 100644 Binary files a/assets/z_a1.png and b/assets/z_a1.png differ diff --git a/assets/z_a2.png b/assets/z_a2.png index 6f6bc59..a09930a 100644 Binary files a/assets/z_a2.png and b/assets/z_a2.png differ diff --git a/assets/z_a3.png b/assets/z_a3.png index 6f9d225..9896ca5 100644 Binary files a/assets/z_a3.png and b/assets/z_a3.png differ diff --git a/assets/z_a4.png b/assets/z_a4.png index 71bdea4..8877041 100644 Binary files a/assets/z_a4.png and b/assets/z_a4.png differ diff --git a/assets/z_aw.png b/assets/z_aw.png index 091ffb6..0ac074a 100644 Binary files a/assets/z_aw.png and b/assets/z_aw.png differ diff --git a/assets/z_b1.png b/assets/z_b1.png index def6fdf..953317c 100644 Binary files a/assets/z_b1.png and b/assets/z_b1.png differ diff --git a/assets/z_b2.png b/assets/z_b2.png index 8acf96c..3646823 100644 Binary files a/assets/z_b2.png and b/assets/z_b2.png differ diff --git a/assets/z_b3.png b/assets/z_b3.png index 4ef90f7..801fdfb 100644 Binary files a/assets/z_b3.png and b/assets/z_b3.png differ diff --git a/assets/z_b4.png b/assets/z_b4.png index e6df9b5..ce937fe 100644 Binary files a/assets/z_b4.png and b/assets/z_b4.png differ diff --git a/assets/z_boss1.png b/assets/z_boss1.png index 9f34ac4..0d103d8 100644 Binary files a/assets/z_boss1.png and b/assets/z_boss1.png differ diff --git a/assets/z_boss2.png b/assets/z_boss2.png index e8b8603..3e9d3d9 100644 Binary files a/assets/z_boss2.png and b/assets/z_boss2.png differ diff --git a/assets/z_bosst0.png b/assets/z_bosst0.png index 5b88a96..cc2dd60 100644 Binary files a/assets/z_bosst0.png and b/assets/z_bosst0.png differ diff --git a/assets/z_bosst1.png b/assets/z_bosst1.png index ea2ccd4..4895e75 100644 Binary files a/assets/z_bosst1.png and b/assets/z_bosst1.png differ diff --git a/assets/z_bosst2.png b/assets/z_bosst2.png index 76500f0..5584a1c 100644 Binary files a/assets/z_bosst2.png and b/assets/z_bosst2.png differ diff --git a/assets/z_bosst3.png b/assets/z_bosst3.png index e959545..915d091 100644 Binary files a/assets/z_bosst3.png and b/assets/z_bosst3.png differ diff --git a/assets/z_bossw1.png b/assets/z_bossw1.png index 7871ad3..b3e0b19 100644 Binary files a/assets/z_bossw1.png and b/assets/z_bossw1.png differ diff --git a/assets/z_bossw2.png b/assets/z_bossw2.png index 8efb835..c4e6c24 100644 Binary files a/assets/z_bossw2.png and b/assets/z_bossw2.png differ diff --git a/assets/z_bossw3.png b/assets/z_bossw3.png index 280b539..b8af233 100644 Binary files a/assets/z_bossw3.png and b/assets/z_bossw3.png differ diff --git a/assets/z_bw.png b/assets/z_bw.png index b60cc56..fca4bac 100644 Binary files a/assets/z_bw.png and b/assets/z_bw.png differ diff --git a/assets/z_c1.png b/assets/z_c1.png index 5bcf710..6b7b853 100644 Binary files a/assets/z_c1.png and b/assets/z_c1.png differ diff --git a/assets/z_c2.png b/assets/z_c2.png index 2b33c02..e8ef971 100644 Binary files a/assets/z_c2.png and b/assets/z_c2.png differ diff --git a/assets/z_chair1.png b/assets/z_chair1.png index 5d1ddfc..834b6d6 100644 Binary files a/assets/z_chair1.png and b/assets/z_chair1.png differ diff --git a/assets/z_chair2.png b/assets/z_chair2.png index a101c98..0793885 100644 Binary files a/assets/z_chair2.png and b/assets/z_chair2.png differ diff --git a/assets/z_chairt.png b/assets/z_chairt.png index 049be71..8755e98 100644 Binary files a/assets/z_chairt.png and b/assets/z_chairt.png differ diff --git a/assets/z_chairw.png b/assets/z_chairw.png index 569646e..6e0de32 100644 Binary files a/assets/z_chairw.png and b/assets/z_chairw.png differ diff --git a/assets/z_chairwt.png b/assets/z_chairwt.png index 3a1f008..08327a0 100644 Binary files a/assets/z_chairwt.png and b/assets/z_chairwt.png differ diff --git a/assets/z_cw1.png b/assets/z_cw1.png index 74b152a..7b17a33 100644 Binary files a/assets/z_cw1.png and b/assets/z_cw1.png differ diff --git a/assets/z_cw2.png b/assets/z_cw2.png index 2f73b56..925e48c 100644 Binary files a/assets/z_cw2.png and b/assets/z_cw2.png differ diff --git a/assets/z_f1.png b/assets/z_f1.png index 6a2dacb..cecf680 100644 Binary files a/assets/z_f1.png and b/assets/z_f1.png differ diff --git a/assets/z_f2.png b/assets/z_f2.png index 660b2dc..b05d404 100644 Binary files a/assets/z_f2.png and b/assets/z_f2.png differ diff --git a/assets/z_f3.png b/assets/z_f3.png index 6c68d21..1c57acc 100644 Binary files a/assets/z_f3.png and b/assets/z_f3.png differ diff --git a/assets/z_f4.png b/assets/z_f4.png index e5ad955..91858d0 100644 Binary files a/assets/z_f4.png and b/assets/z_f4.png differ diff --git a/assets/z_g1.png b/assets/z_g1.png index ceff35f..69f8799 100644 Binary files a/assets/z_g1.png and b/assets/z_g1.png differ diff --git a/assets/z_g2.png b/assets/z_g2.png index 1d81e7b..7336ad9 100644 Binary files a/assets/z_g2.png and b/assets/z_g2.png differ diff --git a/assets/z_gw.png b/assets/z_gw.png index fc9dd34..53a1036 100644 Binary files a/assets/z_gw.png and b/assets/z_gw.png differ diff --git a/assets/z_gw2.png b/assets/z_gw2.png index 5d51036..e17b755 100644 Binary files a/assets/z_gw2.png and b/assets/z_gw2.png differ diff --git a/assets/z_gw2b.png b/assets/z_gw2b.png index c28f3ce..7bde6c9 100644 Binary files a/assets/z_gw2b.png and b/assets/z_gw2b.png differ diff --git a/assets/z_gw3.png b/assets/z_gw3.png index 44bbd6c..6fa08ec 100644 Binary files a/assets/z_gw3.png and b/assets/z_gw3.png differ diff --git a/assets/z_gw3b.png b/assets/z_gw3b.png index 4f9e76f..993c710 100644 Binary files a/assets/z_gw3b.png and b/assets/z_gw3b.png differ diff --git a/assets/z_gw4.png b/assets/z_gw4.png index 6ddd66a..bf9703d 100644 Binary files a/assets/z_gw4.png and b/assets/z_gw4.png differ diff --git a/assets/z_gw4b.png b/assets/z_gw4b.png index 0733592..b1898e5 100644 Binary files a/assets/z_gw4b.png and b/assets/z_gw4b.png differ diff --git a/assets/z_gwb.png b/assets/z_gwb.png index dda1a38..df12dbc 100644 Binary files a/assets/z_gwb.png and b/assets/z_gwb.png differ diff --git a/assets/z_judge1.png b/assets/z_judge1.png index 93cd3a7..b3c41f9 100644 Binary files a/assets/z_judge1.png and b/assets/z_judge1.png differ diff --git a/assets/z_judge2.png b/assets/z_judge2.png index f048ffe..111c530 100644 Binary files a/assets/z_judge2.png and b/assets/z_judge2.png differ diff --git a/assets/z_judget.png b/assets/z_judget.png index 55885df..19e08d9 100644 Binary files a/assets/z_judget.png and b/assets/z_judget.png differ diff --git a/assets/z_judgew.png b/assets/z_judgew.png index bcf6487..8ce5ce6 100644 Binary files a/assets/z_judgew.png and b/assets/z_judgew.png differ diff --git a/assets/z_judgewt.png b/assets/z_judgewt.png index 5355478..24cb272 100644 Binary files a/assets/z_judgewt.png and b/assets/z_judgewt.png differ diff --git a/assets/z_mach1.png b/assets/z_mach1.png index f5fe8e9..839553a 100644 Binary files a/assets/z_mach1.png and b/assets/z_mach1.png differ diff --git a/assets/z_mach2.png b/assets/z_mach2.png index c104f2e..c056b41 100644 Binary files a/assets/z_mach2.png and b/assets/z_mach2.png differ diff --git a/assets/z_s1.png b/assets/z_s1.png index 2bf3d97..0241a6d 100644 Binary files a/assets/z_s1.png and b/assets/z_s1.png differ diff --git a/assets/z_s2.png b/assets/z_s2.png index 414022e..d46b694 100644 Binary files a/assets/z_s2.png and b/assets/z_s2.png differ diff --git a/assets/z_s3.png b/assets/z_s3.png index ea080b1..4863327 100644 Binary files a/assets/z_s3.png and b/assets/z_s3.png differ diff --git a/config.js b/config.js index 950b027..05f3074 100644 --- a/config.js +++ b/config.js @@ -67,6 +67,11 @@ export const TUNE = { machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek() machSpeed: 13, // m/s it fires at machDmg: 1, // player hp a shot costs if you don't return it + shotHitR: 0.42, // m — how close a hostile ball must ACTUALLY be to hit you. Measured: the old test + // (0.55 m with the parry's 0.8 z-weighting) fired while the ball was still 0.68 m + // up-court and drawn up around his head — "visually the ball is not yet at the + // player but somehow the player already is hit". Depth is NOT weighted here. + shotHitY: 1.75, // m — and it has to be at body height, so a lob passing overhead never counts parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon // The parry gets its OWN generous reach and window, not the normal swing's. Returning a shot is the // intended answer, on a phone, with one thumb, against something aimed at you — it has to be @@ -98,7 +103,7 @@ export const TUNE = { bossShotSpeed: 15.5, // m/s a boss throws at (the machine's is machSpeed 13) racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which // is why he read as "the boss" and why a real boss would not have stood out - asideEvery: 26, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never + asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never // acknowledged that the match had changed; now he comments on it, disapprovingly. racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp) // ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big @@ -289,4 +294,8 @@ export const POSE_TIME = 0.38; export const BOUNCE_IDLE = { dx: 0.5, apex: 0.55, tempo: 3.6 }; // generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it -export const SPRITE_H = 112; +export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline + // rasterises into ~170 DEVICE pixels — a 112px source was being UPSCALED + // 1.5x, which is why near sprites looked soft and mushy. Every sprite that + // goes through drawChar/drawBoy/the wound-stage height rule must share this + // scale, so unmatched sprites were 2x-upscaled rather than left at 112. diff --git a/gameplay.js b/gameplay.js index 9777e33..f6ac929 100644 --- a/gameplay.js +++ b/gameplay.js @@ -241,16 +241,15 @@ export function dismissModal() { } // The umpire's asides. He has never once acknowledged that the match turned into a massacre, and -// that is the joke — so he keeps officiating it. Fired on a jittered timer, and only when nothing -// else is talking (a modal, a changeover, the duel announcement all own the audio). -const ASIDES = ['ump_dress_code', 'ump_no_appeal', 'ump_hindrance', 'ump_new_balls_dark', - 'ump_still_a_match', 'ump_spectators', 'ump_let', 'ump_scoreline', 'ump_unsportsmanlike', - 'ump_out_of_order']; -function umpireAside(dt) { +// that is the joke — so he keeps officiating it. But each line is tied to a SITUATION that actually +// justifies it, not drawn from a bag on a timer: Oliver's verdict on the first version was "too much +// and not situation aware.. they seem random". `aside()` is the single entry point and it enforces +// one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark. +export function aside(name, delay = 0.2) { if (G.mode !== 'splatter' || G.modal || G.changeover || G.interlude || G.card) return; - if ((G.asideT -= dt) > 0) return; - G.asideT = TUNE.asideEvery * (0.6 + Math.random() * 0.8); - voice(ASIDES[Math.floor(Math.random() * ASIDES.length)], 0.2, 0.95); + if (G.asideT > 0) return; + G.asideT = TUNE.asideEvery; + voice(name, delay, 0.95); } export function cheer(seconds, vol) { @@ -321,6 +320,7 @@ export function splatPoint(side, quiet = false) { // long stretch with no clear skips a chapter rather than queueing one, which is intended voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak + if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan const i = side === 'P' ? 0 : 1; if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card @@ -388,6 +388,7 @@ function startChangeover() { // now long enough that an empty court reads as the game having stalled. sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work G.flash = { text: 'CHANGEOVER', t: 1.4 }; + aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match // no tins here anymore — weapons are won in the duel, never handed out on a schedule } @@ -418,6 +419,8 @@ export function deliverTin() { else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — a burst you pop when you choose, not on arrival if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking) sendBoy(); voice('new_balls', 0.6); + aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." + const name = SLOTS[w + 1]; showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], iconH: 40, color: UI.warm }); @@ -536,6 +539,7 @@ function startHorde() { P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) voice('remain_seated', 0.3); + aside('ump_spectators', 2.2); // a horde IS the crowd on the court G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; } @@ -912,7 +916,7 @@ export function update(dt) { } crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; - umpireAside(dt); + if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside()) // "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any // other beat that overwrote overT while the breather ran (a melee kill taking a game, say) // had the umpire calling time on a pause that was never a changeover. diff --git a/index.html b/index.html index 78b2ee7..0011e2b 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 49bcd38..4cf93df 100644 --- a/render.js +++ b/render.js @@ -50,8 +50,17 @@ export function fitViewport() { // landscape 480x270, portrait 270x480 with a lo cv.width = w * RES; cv.height = h * RES; // flatter lens: camZ pushed back (config 42, was 36) with camF scaled to keep the PLAYER // the same size — far-court zombies render ~25% bigger and actually read as zombies - TUNE.camF = portrait ? 580 : 406; - TUNE.horizon = portrait ? 90 : 40; + // READABILITY PASS. Oliver's wife: "everything is a tad too small" — a far shambler on an iPhone + // was 26 logical px tall with almost no legible detail. Pushing the camera BACK (camZ) while + // lengthening the lens (camF) flattens the perspective, so distant bodies grow relative to near + // ones instead of everything just zooming. camY is raised with it for a steeper look-down (bodies + // overlap each other less at depth) and `horizon` re-centres the court after both. + // Net: far shambler 26 -> 30 logical px, the player unchanged, the court occupying the same band. + // If you retune these, check all four: far size, near size, court top, court bottom. + TUNE.camZ = portrait ? 48 : 42; + TUNE.camY = portrait ? 11 : 9; + TUNE.camF = portrait ? 760 : 406; + TUNE.horizon = portrait ? 45 : 40; buildCrowd(); } ctx.imageSmoothingEnabled = false; // resets on canvas resize @@ -72,7 +81,7 @@ export function proj(x, y, z) { // ---------------------------------------------------------------- sprites ---- // assets/.png; game falls back to code-drawn figures until sprites load. -const SPRV = 42; // asset cache-buster — bump with main.js?v=N (see the img.src note below) +const SPRV = 43; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', diff --git a/zombies.js b/zombies.js index 441f606..a2a60f8 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,7 @@ import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; -import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer @@ -34,6 +34,7 @@ export function spawnZombie(x, z, variant) { if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left + if ('fgs'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); } if (variant === 'm') { // the rogue ball machine: furniture, not a body. It does not walk at all. zb.v = 0; zb.hp = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0; } @@ -431,6 +432,7 @@ export function updateZombies(dt) { // the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its // own instead of running into a floor; zSpawnMin is only a safety stop. const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame)); + if (zombies.length >= cap - 1) aside('ump_hindrance', 0.6); // the court is genuinely full if (spawnT <= 0 && zombies.length < cap) { spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant()); spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel)); @@ -566,7 +568,7 @@ export function updateZombies(dt) { ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball } } - if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) + if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) } for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills @@ -597,7 +599,11 @@ export function updateZombies(dt) { s.t -= dt; s.vy -= s.g * dt; s.x += s.vx * dt; s.y += s.vy * dt; s.z += s.vz * dt; let done = s.t <= 0 || s.y < -0.25 || Math.abs(s.x) > 8 || s.z > COURT.L + 2; + // The PARRY keeps the 0.8 depth weighting — a racket sweep really is wider than it is deep. + // The HIT does not: weighting depth shrinks it, so contact registered while the ball was still + // most of a metre up-court. That mismatch between what you saw and what hit you was the bug. const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8); + const dHit = Math.hypot(s.x - P.x, s.z - P.z); if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow && dP < TUNE.parryReach && s.y < CONTACT.maxY) { // the parry: it becomes an `extras` clone — that array is already ballistic and already kills @@ -607,7 +613,7 @@ export function updateZombies(dt) { pock(0.85, 1300); G.flash = { text: 'RETURNED', t: 0.6 }; cheer(0.8, 0.3); done = true; } - if (!done && dP < 0.55 && s.y < 2.0) { hurtPlayer(TUNE.machDmg); done = true; } + if (!done && dHit < TUNE.shotHitR && s.y < TUNE.shotHitY) { hurtPlayer(TUNE.machDmg); done = true; } if (done) shots.splice(i, 1); }