Tighten the returns, give each chapter its own boss, and let the umpire talk

Five reports from Oliver's second playtest.

The parry was too generous — at 6 m of arm with a 0.34 s window the shot flew
into reach on its own, so any tap once it was vaguely close worked. 3.4 m /
0.2 s / 1.7 m reach now: a tap at 4.5 m is rejected and costs blood, 3.2 m and
in returns. The boss telegraphs in 0.5 s against the machine's 0.9 and throws
harder, because a boss should be worse than furniture.

His three balls were decoration: fanned ±1.9 m across space, so only the middle
one could ever reach you. They are a timed salvo now — three shots 0.42 s apart,
each aimed at where you are at that moment. hurtGrace still caps a whole volley
at one point of damage, which is what it is for.

Every chapter was the same boss. Three designs cycling now, each with its own
sheet, stride, throw pose, wrecked skin and wrecked-throw skin: the
GROUNDSKEEPER, the LINE JUDGE, the CHAIR UMPIRE with his chair strapped on. The
chapter card names them. Designs are data in BOSS_ART, so a fourth is a sheet
plus a row.

Healing announces itself with a first-aid tin and a red cross instead of a 1.4 s
flash. And 14 new umpire lines: ten sarcastic asides on a jittered timer, four
hooked to specific beats. He still has not acknowledged that the match became a
massacre, so he keeps officiating it.

Two traps found and written into the pipeline notes. Sprite loads now carry
?v=SPRV, because python http.server sends no cache headers and the browser
caches 404s — a name added before its PNG exists stays permanently missing for
that browser. And a scripted replace that silently didn't match left drawZombie
on the old hardcoded boss names while everything else moved to BOSS_ART; the
built-in fallback hid it perfectly, all four designs rendering as design 1 with
no error. Assert your rewiring edits matched.

Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 14:56:00 +02:00
1 parent 295470b73c
commit 8bc57e9358
34 files changed
+180 -40

No files matched your search

+73 -6
View File
@@ -5,11 +5,9 @@ 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 first playtest of the boss/ranged build. All six things he
reported are fixed (see CLOSED at the bottom) — including the one that made the whole new mechanic
unusable: **the parry was unreachable**, because a tap only ever became a swing in `phase === 'rally'`
and splatter sits in `pretoss` almost all the time. Step 6 (crowd turning, ball-boy catcher) stays
PARKED by his decision. **Awaiting his next phone playtest.**
Last updated 2026-07-30, after Oliver's SECOND playtest of the boss/ranged build. Both playtest
rounds are closed at the bottom. Step 6 (crowd turning, ball-boy catcher) stays PARKED by his
decision. **Awaiting his next phone playtest.**
## What this is
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
@@ -144,6 +142,14 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
layer on top"*. His health reads as `bleedPlayer()`'s live drip plus the screen-edge wash on impact,
and nothing else. This is the same settled rule that governs the zombies — blood is never painted
into a sprite — and a `drawPlayerWounds` overlay broke it.
- **Boss designs are DATA: `BOSS_ART` in config.js, one row per chapter, cycling.** Each row is
`{ name, walk: [2], wounds: [...], throws: [...] }`; `spawnChapterBoss` stamps the row onto the body
as `zb.art` and `drawZombie` reads only that. Damage picks a wound stage as a fraction of `hpMax`
spread over however many stages the design HAS, and `throws` is index-clamped, so a design with
fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row —
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).
- **`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
@@ -249,7 +255,22 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the
content bbox + optional downscale, no frame splitting, no forced height.
5. Register new names in `render.js`'s `SPR` preload array.
6. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through
`assets/<n>.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends
no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a
name to the `SPR` list before its PNG exists, load the page once, and that browser treats the
sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV
alongside `main.js?v=N`.
7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent
no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to
the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss
designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage`
instrumentation is what finally settled it:
```js
const o = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); };
```
8. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
`assets/z_B1.png` for the chapter boss silently OVERWROTE the 'b' shambler's `assets/z_b1.png`
(and `z_B2` ate `z_b2`), so a 3-metre boss started showing up in the shambler crowd and `git`
reported the shambler frames as "modified" rather than reporting a new file. `ls` shows only the
@@ -292,6 +313,52 @@ Known generator quirks, all still true:
---
# CLOSED — Oliver's second boss/ranged playtest (2026-07-30)
### The parry was too generous
*"I can tap pretty early and it returns the ball."* At `parryArm` 6.0 / window 0.34 / reach 2.4 the
shot flew INTO reach during the window, so any tap once it was vaguely close worked. Now 3.4 / 0.2 /
1.7 — verified: a tap at 6.0 m or 4.5 m is rejected and costs blood, 3.2 m and closer returns.
### The boss's three balls were decoration
*"he throws 3 of them from which only ever one can reach me. that does not make sense."* Correct — they
were fanned +-1.9 m across space, so only the middle one was ever aimed at the player. They are a
**timed salvo** now: three shots `TUNE.bossSalvoGap` apart, each aimed at where you are AT THAT MOMENT
with only a hand's width of scatter. Verified firing at 0.02 / 0.45 / 0.88 s, all within 0.3 m of the
player. Note `hurtGrace` (1.1 s) still caps a whole volley at one point of damage by design.
Reaction time also tightened: bosses get `bossWind` 0.5 s of telegraph against the machine's 0.9, and
throw at `bossShotSpeed` 15.5 m/s against its 13.
### Every chapter was the same boss
Right — chapters only differed in hp and scale. There are three designs now, cycling, each with its
own sheet, a 2-frame stride, a throw pose, a wrecked skin and a wrecked-throw skin:
**THE GROUNDSKEEPER** (ch 1/4/7…, blazer + ball hopper, and the only one with three wound stages),
**THE LINE JUDGE** (ch 2/5/…, fat, striped officials' polo, cap, ball apron),
**THE CHAIR UMPIRE** (ch 3/6/…, tall and gaunt, panama hat, the umpire's chair strapped to his back).
The chapter card names the design. Chapter 4 repeats design 1 but at 43 hp and 3.67 m.
**The chair-umpire sheet came back on a WHITE background**, and it wears a cream blazer and a white
hat — a colour key would have eaten both. Fixed by flood-filling the background inward from the image
BORDER instead of keying by colour; that script is in this session's git history and is the right tool
whenever a generated sheet's background colour also appears on the character.
### Healing needed to announce itself
Winning a duel while wounded now opens a real modal — `x_cross` first-aid tin, "WOUNDS TREATED / THE
TRAINER PATCHED YOU UP / N OF M BACK" — plus an umpire line. It was a 1.4 s flash before.
### The umpire needed to comment on the new reality
14 new recorded lines (`say -v Daniel -r 168` -> `afconvert`), all `ump_*`. Ten are a rotating pool of
sarcastic asides fired on a jittered `TUNE.asideEvery` timer whenever nothing else owns the audio
(`umpireAside` in gameplay.js); four are hooked to specific beats — a machine arriving, a boss
arriving, the trainer, and taking a hit. The joke is that he has still never acknowledged that the
match became a massacre, so he keeps officiating it: *"Code violation. Unsportsmanlike conduct.
Eating."*
### Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, `mgT` drains, gore 40 /
piles 30 / bodies capped at 34.
---
# CLOSED — Oliver's first boss/ranged playtest (2026-07-30)
Six reports, all fixed. Two were serious.
Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+34 -4
View File
@@ -72,9 +72,13 @@ export const TUNE = {
// intended answer, on a phone, with one thumb, against something aimed at you — it has to be
// forgiving. parryWindow must stay <= swingWindup + swingWindow*VOLLEY.window (0.348 s) or the
// swing clock in update() expires before the window closes.
parryArm: 6.0, // m — an incoming shot this close makes the next tap a SWING instead of a serve
parryReach: 2.4, // m — how far off the racket a return still connects (normal volley reach is 1.82)
parryWindow: 0.34, // s the return window stays open after the tap
// TIGHTENED after Oliver's second playtest: "I can tap pretty early and it returns the ball".
// At parryArm 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap
// once it was vaguely close worked and the timing stopped mattering. At 13 m/s a 3.4 m arm is about
// 0.26 s of "the tap counts", which is a real reaction test and still landable one-thumbed.
parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve
parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82)
parryWindow: 0.2, // s the return window stays open after the tap
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
bossEvery: 10, // games between chapter bosses
@@ -85,9 +89,17 @@ export const TUNE = {
bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers
bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable
bossCycle: 2.6, // s between its ranged attacks (it uses the machine's projectile)
bossVolley: 3, // shots per attack, fanned
bossVolley: 3, // shots per attack — STAGGERED IN TIME, not fanned across space. Fanning them
// +-1.9 m meant only the middle ball could ever reach you and the other two were
// decoration; Oliver called that out. Three real threats in a rhythm instead.
bossSalvoGap: 0.42, // s between the shots of one volley
bossWind: 0.5, // s of telegraph before a boss throws — shorter than the machine's 0.9 on purpose:
// he asked for a tighter reaction time, and a boss should be harder than furniture
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
// 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
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
@@ -218,6 +230,24 @@ COURT.svcNear = COURT.netZ + 6.4; // near service line
// world sizes (meters)
export const PLAYER_H = 1.88;
// The chapter-boss ROSTER. One design per chapter, cycling — chapter 5 gets design 1 again, but at
// far more hp and much bigger, so it still escalates. Each entry is pure DATA so adding a design is a
// sheet plus a row here, never a change to drawZombie.
// walk: two frames, stepped off DISTANCE COVERED (never time — see the animation invariant)
// wounds: damage skins, healthiest first. However many there are, they are spread evenly over hp.
// throws: the same states mid-throw, used while windT > 0. Index is clamped, so a design with
// fewer throw frames than wound stages simply reuses its last one.
// Every name must be ALL LOWERCASE and a whole word — see the case-collision note in the pipeline.
export const BOSS_ART = [
{ name: 'THE GROUNDSKEEPER', walk: ['z_boss1', 'z_boss2'],
wounds: ['z_bossw1', 'z_bossw2', 'z_bossw3'],
throws: ['z_bosst0', 'z_bosst1', 'z_bosst2', 'z_bosst3'] },
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'] },
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'] },
];
export const MACH_H = 1.15; // a ball machine stands about waist high — it must read as furniture, not as a body
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
+25 -4
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, APPLAUSE_T, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
@@ -240,6 +240,19 @@ export function dismissModal() {
return true; // the caller must swallow the gesture: dismissing must never also serve
}
// 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) {
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);
}
export function cheer(seconds, vol) {
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
// floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its
@@ -444,8 +457,9 @@ export function startBossFight(chapter) {
ball.active = false; setupPoint();
spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it
voice('quiet_please', 0, 0.8); voice('sudden_death', 1.6, 0.85);
G.card = { title: 'CHAPTER ' + chapter, sub1: 'SOMETHING BIG IS COMING', sub2: 'IT THROWS. RETURN IT.',
voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
const art = BOSS_ART[(chapter - 1) % BOSS_ART.length];
G.card = { title: 'CHAPTER ' + chapter, sub1: art.name, sub2: 'IT THROWS. RETURN IT.',
color: UI.bad, t: TUNE.setCardT };
G.flash = null;
if (!G.seen.boss) { // the rules change: a wall, and a thing that shoots back
@@ -480,8 +494,14 @@ export function resolveInterlude(side) {
// winning the duel patches you up. THE reason to want a duel rather than merely tolerate it —
// it is the only healing in the game, and blood is the only thing you cannot buy back otherwise.
if (G.hp < G.hpMax) {
const before = G.hp;
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
G.flash = { text: 'PATCHED UP', t: 1.4 };
// a real popup, not a flash: getting patched up is the only way blood comes back, so it deserves
// to stop the world for a beat and say so plainly. The trainer came on. It's still tennis.
voice('ump_trainer', 0.2, 0.95);
showModal({ title: 'WOUNDS TREATED', color: UI.pick, icon: 'x_cross', iconH: 34,
body: ['THE TRAINER PATCHED YOU UP',
(G.hp - before) + ' OF ' + G.hpMax + ' BACK · NOW ' + G.hp + '/' + G.hpMax] });
}
deliverTin(); // win the point, win the next tin — on the spot
} else {
@@ -892,6 +912,7 @@ export function update(dt) {
}
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
umpireAside(dt);
// "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=41"></script>
<script type="module" src="main.js?v=42"></script>
</body>
</html>
+28 -15
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -72,16 +72,19 @@ 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 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',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads',
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
'z_mach1','z_mach2', // the ball machine: idle, and firing with a muzzle flash
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
@@ -89,7 +92,12 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'scoreboard']) {
const img = new Image();
img.onload = () => SPR[n] = img;
img.src = 'assets/' + n + '.png';
// ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache
// headers, so the browser caches sprite responses heuristically — INCLUDING 404s. A brand-new sheet
// whose name was added to this list before the PNG existed stays permanently "missing" for that
// browser, and drawZombie silently falls back to another design. That cost a debugging round.
// BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html.
img.src = 'assets/' + n + '.png?v=' + SPRV;
}
// ------------------------------------------------------------------ crowd ----
@@ -179,7 +187,7 @@ function drawMachine(zb) {
const h = MACH_H * (img.height / SPRITE_H);
drawStatic(name, zb.x, zb.z, h);
if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural
const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z);
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
ctx.globalAlpha = 0.3 + k * 0.7;
ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm;
const r = Math.max(1.5, p.s * 0.05 * (0.5 + k));
@@ -228,23 +236,28 @@ function drawZombie(zb) {
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
// out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
if (zb.variant === 'B') {
const f = zb.hp / (zb.hpMax || 1);
// The chapter boss picks its frames from its DESIGN (`zb.art`, one row of config.BOSS_ART per
// chapter, cycling), so a new boss is a sheet plus a data row and never a change here. Damage is a
// FRACTION of hpMax spread over however many wound stages that design has, and the wounded state
// stays visible MID-THROW — the one place Oliver found damage disappearing.
// NEVER name a sprite so it differs from another only by case: macOS is case-insensitive, so
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss showed up in the
// shambler crowd. All lowercase from here.
const wounded = f <= 0.25 ? 3 : f <= 0.5 ? 2 : f <= 0.75 ? 1 : 0;
// mid-throw it rears up — and the wounded state has to stay visible while it does, which is what
// the z_bosst<n> set is for. Oliver flagged the throwing animation as the one place damage vanished.
const stage = zb.windT > 0 && SPR['z_bosst' + wounded] ? 'z_bosst' + wounded
: wounded ? 'z_bossw' + wounded
: (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_boss2' : 'z_boss1');
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd.
if (zb.variant === 'B') {
const A = zb.art || BOSS_ART[0];
const f = zb.hp / (zb.hpMax || 1), nStage = A.wounds.length;
const idx = f >= 1 ? 0 : Math.min(nStage, Math.max(1, Math.ceil((1 - f) * nStage)));
const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)]
: idx ? A.wounds[idx - 1]
: A.walk[Math.floor(zb.z / TUNE.bruteStride) % 2];
// a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather
// than vanishing, since drawStatic silently draws nothing for a missing sprite
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[Math.floor(zb.z / TUNE.bruteStride) % 2];
if (SPR[stage]) { name = stage; h *= SPR[stage].height / SPRITE_H; } // height ALWAYS off the frame, or the size pops
}
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z);
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
ctx.globalAlpha = 0.25 + k * 0.55; ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.bad;
const r = Math.max(2, p.s * 0.1 * (0.5 + k));
ctx.beginPath(); ctx.arc(Math.round(p.x), Math.round(p.y), r, 0, 7); ctx.fill();
+1
View File
@@ -56,6 +56,7 @@ export const G = {
hpMax: TUNE.playerHp,
hurtT: 0, // s of post-hit grace left; also drives the red hit flash
hurtFlash: 0, // s of the impact wash left
asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery)
bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court
bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns.
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
+18 -10
View File
@@ -3,7 +3,7 @@
// your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, VOLLEY, CONTACT } from './config.js';
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';
@@ -44,6 +44,7 @@ export function spawnZombie(x, z, variant) {
// shoots at you until you deal with it.
export function spawnMachine() {
spawnZombie((Math.random() * 2 - 1) * 3.6, 2.2 + Math.random() * 3, 'm');
voice('ump_machine', 0.4, 0.95);
if (!G.seen.mach) {
G.seen.mach = true;
showModal({ title: 'A BALL MACHINE', color: UI.warm,
@@ -59,7 +60,8 @@ export function spawnChapterBoss(chapter) {
const hp = TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter;
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed, walkT: 0, homeOff: 0, variant: 'B',
chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer,
fireT: TUNE.bossCycle, windT: 0 });
fireT: TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
art: BOSS_ART[(chapter - 1) % BOSS_ART.length] }); // which of the boss designs this chapter is
}
// ---- the player's second health channel. Only these two functions touch it.
@@ -74,7 +76,7 @@ export function hurtPlayer(n) {
vx: (Math.random() * 2 - 1) * 2.2, vy: 0.8 + Math.random() * 2, vz: (Math.random() * 2 - 1) * 2.2,
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.5 });
if (G.hp <= 0) killPlayer();
else voice('order', 0.3); // the umpire objects on your behalf. He is still watching.
else voice('ump_quiet_bleeding', 0.3, 0.95); // the umpire objects on your behalf. He is still watching.
}
// Death, from EITHER channel: a shambler reaching you, or your hp running out. Extracted so the two
@@ -104,10 +106,10 @@ export function killPlayer() {
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
// It is a real ARC (solved like a serve, not fired flat) so it is readable coming in and so the
// swing window has something to meet — a flat bolt at head height is not a tennis shot.
function fireAtPlayer(x, z, spread = 0) {
function fireAtPlayer(x, z, spread = 0, speed = TUNE.machSpeed) {
const y0 = 1.1;
const tx = P.x + spread, tz = P.z;
const d = Math.hypot(tx - x, tz - z), Tf = Math.max(0.35, d / TUNE.machSpeed);
const d = Math.hypot(tx - x, tz - z), Tf = Math.max(0.35, d / speed);
const g = TUNE.gravity;
shots.push({ x, y: y0, z,
vx: (tx - x) / Tf, vz: (tz - z) / Tf,
@@ -471,15 +473,21 @@ export function updateZombies(dt) {
// ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter') {
if (zb.windT > 0) {
const boss = zb.variant === 'B';
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
if ((zb.salvoT -= dt) <= 0) {
zb.salvo--; zb.salvoT = TUNE.bossSalvoGap;
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.35, TUNE.bossShotSpeed); // aimed at where
} // you are NOW, with only a hand's width of scatter
} else if (zb.windT > 0) {
zb.windT -= dt;
if (zb.windT <= 0) {
const n = zb.variant === 'B' ? TUNE.bossVolley : 1;
for (let k = 0; k < n; k++) fireAtPlayer(zb.x, zb.z, n === 1 ? 0 : (k - (n - 1) / 2) * 1.9);
if (boss) { zb.salvo = TUNE.bossVolley; zb.salvoT = 0; } // the volley starts next frame
else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed);
}
} else if ((zb.fireT -= dt) <= 0) {
zb.fireT = zb.variant === 'B' ? TUNE.bossCycle : TUNE.machCycle;
zb.windT = TUNE.machWind;
zb.fireT = boss ? TUNE.bossCycle : TUNE.machCycle;
zb.windT = boss ? TUNE.bossWind : TUNE.machWind;
pock(0.3, 1500); // the tell: a mechanical click as it loads
}
}