Make it readable: 2x sprites, a flatter camera, honest hit timing

The umpire is situational again. Each line is tied to the thing that justifies
it — a new variant appearing, the court filling, one crossing your baseline, a
game lost, a horde, a changeover — behind one shared cooldown, so several
triggers at once still yield one remark. The random timer pool is gone, the
interval went 26s to 55s, and ump_out_of_order, ump_let and ump_no_appeal are
deleted.

"Visually the ball is not yet at the player but somehow the player already is
hit" was real and measurable. The hostile-ball hit test reused the parry's
hypot(dx, dz*0.8), and weighting depth shrinks it: contact fired while the ball
was still 0.68 m up-court and drawn up around his head. It has its own
unweighted test now, and contact lands at 0.25 m with the ball on his chest.
The parry keeps its weighting — a racket sweep really is wider than it is deep.

On "everything is a tad too small": measured first, because higher-resolution
sprites are only half the answer. At RES 3 the player rasterises into 170
device pixels from a 112px source — upscaled 1.5x — while a far shambler is
downscaled to 78. So near bodies needed resolution and far bodies needed the
camera.

Camera: pushed back and lengthened (camZ 42->48, camF 580->760) to flatten the
perspective so distant bodies grow relative to near ones rather than everything
zooming, camY raised for a steeper look-down, horizon re-centred. Far shambler
26->30px, boss 39->45px, player unchanged, court in the same band.

Sprites: the original sheets were still in the repo at full resolution, so
nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was
re-sliced. The sheet-to-output mapping was never recorded, so it was recovered
by matching sliced frames to existing assets by image signature: 58 came back
from sheets, the 29 without a confident match were 2x-upscaled to stay on the
shared scale, since a sprite off that scale renders at half height.

Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 16:23:04 +02:00
1 parent 8bc57e9358
commit 49d391dc64
95 files changed
+119 -21

No files matched your search

+71 -1
View File
@@ -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
+1 -1
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 29 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 51 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 52 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 65 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+11 -2
View File
@@ -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.
+14 -10
View File
@@ -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.
+1 -1
View File
@@ -60,6 +60,6 @@
<p class="start">TAP TO START</p>
</div>
</div>
<script type="module" src="main.js?v=42"></script>
<script type="module" src="main.js?v=43"></script>
</body>
</html>
+12 -3
View File
@@ -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/<name>.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',
+9 -3
View File
@@ -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);
}