diff --git a/HANDOFF.md b/HANDOFF.md index a368072..3026c7b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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/.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. diff --git a/assets/_sheet_chair.png b/assets/_sheet_chair.png new file mode 100644 index 0000000..ee1ce88 Binary files /dev/null and b/assets/_sheet_chair.png differ diff --git a/assets/_sheet_judge.png b/assets/_sheet_judge.png new file mode 100644 index 0000000..14971bc Binary files /dev/null and b/assets/_sheet_judge.png differ diff --git a/assets/audio/ump_boss_arrives.m4a b/assets/audio/ump_boss_arrives.m4a new file mode 100644 index 0000000..9541684 Binary files /dev/null and b/assets/audio/ump_boss_arrives.m4a differ diff --git a/assets/audio/ump_dress_code.m4a b/assets/audio/ump_dress_code.m4a new file mode 100644 index 0000000..3161953 Binary files /dev/null and b/assets/audio/ump_dress_code.m4a differ diff --git a/assets/audio/ump_hindrance.m4a b/assets/audio/ump_hindrance.m4a new file mode 100644 index 0000000..0b7c7a6 Binary files /dev/null and b/assets/audio/ump_hindrance.m4a differ diff --git a/assets/audio/ump_let.m4a b/assets/audio/ump_let.m4a new file mode 100644 index 0000000..156bd17 Binary files /dev/null and b/assets/audio/ump_let.m4a differ diff --git a/assets/audio/ump_machine.m4a b/assets/audio/ump_machine.m4a new file mode 100644 index 0000000..7d921e5 Binary files /dev/null and b/assets/audio/ump_machine.m4a differ diff --git a/assets/audio/ump_new_balls_dark.m4a b/assets/audio/ump_new_balls_dark.m4a new file mode 100644 index 0000000..d8127f8 Binary files /dev/null and b/assets/audio/ump_new_balls_dark.m4a differ diff --git a/assets/audio/ump_no_appeal.m4a b/assets/audio/ump_no_appeal.m4a new file mode 100644 index 0000000..fe7a6f4 Binary files /dev/null and b/assets/audio/ump_no_appeal.m4a differ diff --git a/assets/audio/ump_out_of_order.m4a b/assets/audio/ump_out_of_order.m4a new file mode 100644 index 0000000..630a7fa Binary files /dev/null and b/assets/audio/ump_out_of_order.m4a differ diff --git a/assets/audio/ump_quiet_bleeding.m4a b/assets/audio/ump_quiet_bleeding.m4a new file mode 100644 index 0000000..276c7d6 Binary files /dev/null and b/assets/audio/ump_quiet_bleeding.m4a differ diff --git a/assets/audio/ump_scoreline.m4a b/assets/audio/ump_scoreline.m4a new file mode 100644 index 0000000..f9a4dc7 Binary files /dev/null and b/assets/audio/ump_scoreline.m4a differ diff --git a/assets/audio/ump_spectators.m4a b/assets/audio/ump_spectators.m4a new file mode 100644 index 0000000..f4b28ed Binary files /dev/null and b/assets/audio/ump_spectators.m4a differ diff --git a/assets/audio/ump_still_a_match.m4a b/assets/audio/ump_still_a_match.m4a new file mode 100644 index 0000000..20e7f0f Binary files /dev/null and b/assets/audio/ump_still_a_match.m4a differ diff --git a/assets/audio/ump_trainer.m4a b/assets/audio/ump_trainer.m4a new file mode 100644 index 0000000..9cae857 Binary files /dev/null and b/assets/audio/ump_trainer.m4a differ diff --git a/assets/audio/ump_unsportsmanlike.m4a b/assets/audio/ump_unsportsmanlike.m4a new file mode 100644 index 0000000..e1bbbbc Binary files /dev/null and b/assets/audio/ump_unsportsmanlike.m4a differ diff --git a/assets/x_cross.png b/assets/x_cross.png new file mode 100644 index 0000000..4cc38ee Binary files /dev/null and b/assets/x_cross.png differ diff --git a/assets/z_chair1.png b/assets/z_chair1.png new file mode 100644 index 0000000..5d1ddfc Binary files /dev/null and b/assets/z_chair1.png differ diff --git a/assets/z_chair2.png b/assets/z_chair2.png new file mode 100644 index 0000000..a101c98 Binary files /dev/null and b/assets/z_chair2.png differ diff --git a/assets/z_chairt.png b/assets/z_chairt.png new file mode 100644 index 0000000..049be71 Binary files /dev/null and b/assets/z_chairt.png differ diff --git a/assets/z_chairw.png b/assets/z_chairw.png new file mode 100644 index 0000000..569646e Binary files /dev/null and b/assets/z_chairw.png differ diff --git a/assets/z_chairwt.png b/assets/z_chairwt.png new file mode 100644 index 0000000..3a1f008 Binary files /dev/null and b/assets/z_chairwt.png differ diff --git a/assets/z_judge1.png b/assets/z_judge1.png new file mode 100644 index 0000000..93cd3a7 Binary files /dev/null and b/assets/z_judge1.png differ diff --git a/assets/z_judge2.png b/assets/z_judge2.png new file mode 100644 index 0000000..f048ffe Binary files /dev/null and b/assets/z_judge2.png differ diff --git a/assets/z_judget.png b/assets/z_judget.png new file mode 100644 index 0000000..55885df Binary files /dev/null and b/assets/z_judget.png differ diff --git a/assets/z_judgew.png b/assets/z_judgew.png new file mode 100644 index 0000000..bcf6487 Binary files /dev/null and b/assets/z_judgew.png differ diff --git a/assets/z_judgewt.png b/assets/z_judgewt.png new file mode 100644 index 0000000..5355478 Binary files /dev/null and b/assets/z_judgewt.png differ diff --git a/config.js b/config.js index eba9508..950b027 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 1da14e8..9777e33 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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. diff --git a/index.html b/index.html index 67aa6e0..78b2ee7 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index ea114ad..49bcd38 100644 --- a/render.js +++ b/render.js @@ -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/.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. + // 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 turned up in the crowd. if (zb.variant === 'B') { - const f = zb.hp / (zb.hpMax || 1); - // 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 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'); + 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(); diff --git a/state.js b/state.js index 6e66be4..aac2aab 100644 --- a/state.js +++ b/state.js @@ -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 diff --git a/zombies.js b/zombies.js index 96b1482..441f606 100644 --- a/zombies.js +++ b/zombies.js @@ -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 } }