diff --git a/HANDOFF.md b/HANDOFF.md index 140e84a..a368072 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,10 +5,11 @@ 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. The **late-game depth work is complete**: steps 1-5 of the plan agreed after -Oliver's 50+ game playtest are all built and verified in-browser. Step 6 (crowd turning, ball-boy -catcher) is PARKED by his decision. **Awaiting his phone playtest of the boss/ranged build.** -What is still owed is art, not mechanics — see "Art still owed" at the bottom. +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.** ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game @@ -122,6 +123,27 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu (it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when you choose). Making the machine gate progression turned "decide what to prioritise" into a chore every four games. +- **The parry is bound to the TAP, in every phase, and that is load-bearing.** `keyPressed` checks + `shots` for anything within `TUNE.parryArm` FIRST and turns the tap into a swing if so. Before that, + the parry sat behind the `phase === 'rally'` branch at the bottom of `keyPressed` — and splatter is + in `pretoss` nearly all the time, so a tap aimed at an incoming shot tossed the serve ball instead + and the return was unreachable in practice. Oliver could not land one in a whole session. + `TUNE.parryReach`/`parryWindow` are deliberately much more generous than a normal volley (2.4 m / + 0.34 s), because this is a phone, one thumb, against something aimed at you. The fail cases still + exist and are what keep it a timing skill: tap before the shot is inside `parryArm`, tap after it + has passed, or don't tap. **`parryWindow` must stay <= `swingWindup + swingWindow * VOLLEY.window`** + (0.348 s) or update()'s swing clock expires before the window closes. + `meleeSwipe` deliberately does NOT touch `shots`: it runs first in `keyPressed`, so it used to eat + the very shot the parry was about to return. +- **`zb.hitT` (`TUNE.bossHitCool`) stops a LINGERING ball multi-wounding a boss.** A flame ball has + full pierce — it is not consumed on contact, so it can cut a lane — but a boss is not removed when + hit, so the same ball sat inside its radius and re-wounded it *every frame*. One flame ball killed a + 16 hp chapter boss. Flame now also spends itself (`lastHit = 'X'`) on a boss specifically. Any new + weapon that is not consumed on contact must set `hitT` too. +- **No blood is painted onto the player.** Oliver: *"the player blood looks odd, appears as a painted + 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. - **`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 @@ -227,6 +249,12 @@ 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 + `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 + ORIGINAL lowercase name, which is why it is easy to miss. Boss frames are `z_boss*` now. Use + all-lowercase, whole-word names. Known generator quirks, all still true: - It sometimes ignores the magenta (harmless — the key uses the corner colour). @@ -264,26 +292,40 @@ Known generator quirks, all still true: --- -# ART STILL OWED (mechanics are done) +# CLOSED — Oliver's first boss/ranged playtest (2026-07-30) -The only outstanding work on the boss/ranged build is art. Everything below is playable now. +Six reports, all fixed. Two were serious. -- **The chapter boss has no B-frames for its wound stages.** `assets/_sheet_boss.png` is a 5-frame - sheet: `z_B1`/`z_B2` (a real 2-frame stride, driven off `zb.z / TUNE.bruteStride` per the - distance-not-time invariant) plus three static wound skins `z_Bw1` (arm torn off) / `z_Bw2` (ribs - out) / `z_Bw3` (headless, still throwing). The brute precedent has an A **and** B frame per wound - stage (`z_gw` + `z_gwb`, etc.), so a wounded boss currently stops striding mid-fight. Fix by - generating a second sheet of the three wound poses with the stride swapped, named `z_Bw1b`..`z_Bw3b`, - and extending drawZombie's stage pick the way the brute's does. - **Generating it: the model laid the first sheet out in TWO ROWS, which `_slice.py` cannot cut (it - segments on column gaps).** Don't fight the prompt — detect the blobs and re-flow them into one - baseline-aligned row before slicing. The reflow script is in this session's git history. -- **The ball machine is drawn procedurally** (`drawMachine` in render.js) — a hopper, a barrel and two - wheels, with the muzzle glowing hotter through the wind-up. That was a choice, not a stopgap: it is - furniture, a box is four rects, and the readable parts are the barrel and the charge. Replace with a - sheet only if it reads as a crate on a phone. -- **Chapter cards reuse `drawCard`,** which still cuts in with no entrance while modals now swoosh - (`TUNE.modalIn`). Giving the card a matching entrance was offered and not yet asked for. +### A boss sprite appeared in the shambler crowd — CASE COLLISION +`z_B1.png` / `z_B2.png` overwrote the 'b' shambler's `z_b1.png` / `z_b2.png` on a case-insensitive +filesystem. Restored from `HEAD~1`; boss frames renamed `z_boss1`, `z_boss2`, `z_bossw1..3`. The rule +is now in the asset pipeline section — this is a trap that leaves no obvious trace. + +### The parry was unreachable — the whole mechanic was dead +*"I could not return a single ball of the ballmachine.. I tried it with taps near the hit - it never +worked"*, and the same for the boss's volleys. Cause: `P.swingT` is only set in `keyPressed`'s +`phase === 'rally'` branch, and splatter is in `pretoss` almost all of the time, so the tap tossed +the serve ball instead of swinging. An incoming shot now owns the tap in every phase — see the +invariant above. Verified returning from `pretoss`, `rally` and `over`, at 0.9 m through 5.4 m, with +the three fail cases (too early / too late / no tap) intact. + +### One flame ball killed a 16 hp boss +Not tuning — a real bug. The flame ball is not consumed on contact (full pierce) and a boss is not +removed when hit, so it re-wounded every frame. `zb.hitT` + spending the ball on a boss. Chapter 1 +now costs 4 flame balls or 16 clean smashes, and `bossHp` went 10 -> 16 (`bossHpPerChapter` 8 -> 9), +so chapter 4 is 43 hp. + +### The boss had no wounded state while throwing +Correct — the wound skins were standing poses only. New sheet `_sheet_bossthrow.png` gives all four +damage states their own overarm pitch (`z_bosst0..3`), used whenever `windT > 0`. + +### The ball machine was "naively painted" +It was — four rects. Replaced with a generated sheet (`z_mach1` idle, `z_mach2` firing with a muzzle +flash): a dented hopper cart on wheels with a barrel aimed down-court. The muzzle GLOW stays +procedural on top, because the telegraph has to read at 20 px tall. + +### The player's blood read as a painted layer +Removed entirely. See the invariant above. # CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30) diff --git a/assets/_sheet_bossthrow.png b/assets/_sheet_bossthrow.png new file mode 100644 index 0000000..537a05f Binary files /dev/null and b/assets/_sheet_bossthrow.png differ diff --git a/assets/_sheet_mach.png b/assets/_sheet_mach.png new file mode 100644 index 0000000..ecaf160 Binary files /dev/null and b/assets/_sheet_mach.png differ diff --git a/assets/z_b1.png b/assets/z_b1.png index 9f34ac4..def6fdf 100644 Binary files a/assets/z_b1.png and b/assets/z_b1.png differ diff --git a/assets/z_b2.png b/assets/z_b2.png index e8b8603..8acf96c 100644 Binary files a/assets/z_b2.png and b/assets/z_b2.png differ diff --git a/assets/z_boss1.png b/assets/z_boss1.png new file mode 100644 index 0000000..9f34ac4 Binary files /dev/null and b/assets/z_boss1.png differ diff --git a/assets/z_boss2.png b/assets/z_boss2.png new file mode 100644 index 0000000..e8b8603 Binary files /dev/null and b/assets/z_boss2.png differ diff --git a/assets/z_bosst0.png b/assets/z_bosst0.png new file mode 100644 index 0000000..5b88a96 Binary files /dev/null and b/assets/z_bosst0.png differ diff --git a/assets/z_bosst1.png b/assets/z_bosst1.png new file mode 100644 index 0000000..ea2ccd4 Binary files /dev/null and b/assets/z_bosst1.png differ diff --git a/assets/z_bosst2.png b/assets/z_bosst2.png new file mode 100644 index 0000000..76500f0 Binary files /dev/null and b/assets/z_bosst2.png differ diff --git a/assets/z_bosst3.png b/assets/z_bosst3.png new file mode 100644 index 0000000..e959545 Binary files /dev/null and b/assets/z_bosst3.png differ diff --git a/assets/z_Bw1.png b/assets/z_bossw1.png similarity index 100% rename from assets/z_Bw1.png rename to assets/z_bossw1.png diff --git a/assets/z_Bw2.png b/assets/z_bossw2.png similarity index 100% rename from assets/z_Bw2.png rename to assets/z_bossw2.png diff --git a/assets/z_Bw3.png b/assets/z_bossw3.png similarity index 100% rename from assets/z_Bw3.png rename to assets/z_bossw3.png diff --git a/assets/z_mach1.png b/assets/z_mach1.png new file mode 100644 index 0000000..f5fe8e9 Binary files /dev/null and b/assets/z_mach1.png differ diff --git a/assets/z_mach2.png b/assets/z_mach2.png new file mode 100644 index 0000000..c104f2e Binary files /dev/null and b/assets/z_mach2.png differ diff --git a/config.js b/config.js index ae83a67..eba9508 100644 --- a/config.js +++ b/config.js @@ -68,11 +68,19 @@ export const TUNE = { machSpeed: 13, // m/s it fires at machDmg: 1, // player hp a shot costs if you don't return it 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 + // 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 // ---- 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 - bossHp: 10, // hp of the FIRST one; each later chapter adds bossHpPerChapter - bossHpPerChapter: 8, + bossHp: 16, // hp of the FIRST one; each later chapter adds bossHpPerChapter. 10 was far too + // soft — three banked flame balls took chapter 1 down (2 impact + 2.4 burn each) + bossHpPerChapter: 9, bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m) bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable @@ -90,6 +98,10 @@ export const TUNE = { dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before) bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body + bossHitCool: 0.2, // s a boss body is immune to further BALL contact after taking a hit. A flame ball + // has full pierce (it is not consumed on contact, so it can cut a lane), and a boss + // is not removed when hit — so the same ball sat inside its radius and re-wounded it + // EVERY FRAME. That is why one flame ball killed a 16 hp chapter boss. changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one @@ -206,6 +218,7 @@ COURT.svcNear = COURT.netZ + 6.4; // near service line // world sizes (meters) export const PLAYER_H = 1.88; +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 // waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out. diff --git a/gameplay.js b/gameplay.js index 384a3f8..1da14e8 100644 --- a/gameplay.js +++ b/gameplay.js @@ -775,6 +775,16 @@ export function keyPressed(code) { // a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the // default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool. if (G.mode === 'splatter' && G.meleeT <= 0 && meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = TUNE.meleeCool; + // AN INCOMING SHOT OWNS THE TAP. The parry used to live behind the `phase === 'rally'` branch at the + // bottom of this function — and in splatter you are in `pretoss` almost all of the time, so a tap + // aimed at a machine's shot tossed the serve ball instead and the return was, in practice, + // impossible. Oliver could not land one. A hostile ball in the air is the most visible thing on the + // screen, so "the tap is a swing at it" keeps one gesture meaning one thing at any given moment. + if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm)) { + P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; + swish(0.3); + return; + } if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel @@ -964,7 +974,9 @@ export function update(dt) { r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) }); } trySwing(P, false); // a contact resets swingT to -1 inside trySwing - const win = TUNE.swingWindow * (ball.active && ball.bounces === 0 ? VOLLEY.window : 1); // match trySwing's volley window + // volley-length window while a hostile shot is in the air too, or the swing clock expires at + // 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance + const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1); if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing } expirePose(P, dt); diff --git a/index.html b/index.html index 8807c81..67aa6e0 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@
TAP TO START
- +