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

- + diff --git a/render.js b/render.js index 4629496..ea114ad 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, 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, 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'; @@ -79,7 +79,9 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' '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 '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_B1','z_B2','z_Bw1','z_Bw2','z_Bw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless) + '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_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) @@ -165,26 +167,23 @@ function drawBoy(b) { ctx.restore(); } const ZFRAMES = { a: 4, b: 4, f: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2 -// The rogue ball machine: real tennis furniture, so it is drawn as furniture — a squat hopper on -// wheels with a barrel pointed down-court. Procedural like drawFlames, and it stays that way unless -// a sheet turns up: a box is four rects, and the readable part is the BARREL and the charge glow. +// The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel +// aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural +// version "naively painted" — it was four rects, which is fine for fire but not for furniture the +// player has to recognise and prioritise. function drawMachine(zb) { - const p = proj(zb.x, 0, zb.z), u = p.s * 0.16; // u = one "pixel" at this depth - const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(Math.round(p.x + x * u), Math.round(p.y + y * u), Math.max(1, w * u), Math.max(1, h * u)); }; - drawShadow(p.x, p.y, u * 4.4, u * 1.3); - const dead = zb.hp / TUNE.machHp; - R(-4, -7, 8, 7, dead > 0.5 ? '#4a5460' : '#3a3038'); // hopper, scorched as it takes damage - R(-4, -7, 8, 1, '#6b7684'); // top light - R(-4, -1, 8, 1, '#20262e'); // bottom shadow - R(-2.2, -9.6, 4.4, 2.6, '#2b3138'); // ball basket - R(-1.6, -9.2, 3.6, 1.8, UI.ball); // ...full of tennis balls - R(1.2, -6.2, 4.6, 2.2, '#565f6b'); // the BARREL, pointed at you - R(4.4, -6.2, 1.4, 2.2, '#161a1f'); // muzzle - R(-3.4, -1.2, 2, 1.6, '#15181c'); R(1.4, -1.2, 2, 1.6, '#15181c'); // wheels - if (zb.windT > 0) { // the TELEGRAPH: the muzzle glows hotter the closer the shot is - const k = 1 - zb.windT / TUNE.machWind; - ctx.globalAlpha = 0.35 + k * 0.65; - R(4.2, -6.4, 2 + k * 1.6, 2.6, k > 0.6 ? '#fff2c0' : UI.warm); + const firing = zb.windT > 0; + const name = firing && SPR.z_mach2 ? 'z_mach2' : 'z_mach1'; + const img = SPR[name]; + if (!img) return; // sheet not loaded yet: draw nothing, not a box + 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); + 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)); + ctx.beginPath(); ctx.arc(Math.round(p.x + p.s * 0.1), Math.round(p.y), r, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } } @@ -231,8 +230,15 @@ function drawZombie(zb) { // 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); - const stage = f <= 0.25 ? 'z_Bw3' : f <= 0.5 ? 'z_Bw2' : f <= 0.75 ? 'z_Bw1' - : (Math.floor(zb.z / TUNE.bruteStride) % 2 ? 'z_B2' : 'z_B1'); + // 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'); 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); @@ -347,30 +353,11 @@ function drawChar(ch) { ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); } else drawFallbackChar(ch, p, hpx); - if (ch.side === 'P') drawPlayerWounds(p, hpx); + // No blood painted on the player. Oliver: "the player blood looks odd, appears as a painted layer + // on top" — and he is right, and it also broke the settled rule that blood is never painted into + // the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact. } -// The player's health, drawn ON HIM and nowhere else — there is no bar and there will not be one -// (same rule that removed the boss's). Blood is never painted into the sprite (settled feel -// decision), so this is a procedural overlay plus bleedPlayer()'s live trickle: fixed anchors, so it -// does not crawl frame to frame, and it grows with every hit. The white kit is the meter. -const WOUND_AT = [[-0.18, -0.62], [0.22, -0.55], [-0.05, -0.72], [0.14, -0.42], [-0.26, -0.48], [0.06, -0.33]]; -function drawPlayerWounds(p, hpx) { - if (G.hp >= G.hpMax) return; - const lost = G.hpMax - G.hp, n = Math.min(WOUND_AT.length, lost * 2); - for (let i = 0; i < n; i++) { - const [ox, oy] = WOUND_AT[i]; - const s = Math.max(1, hpx * (0.055 + (i % 2) * 0.02)); - ctx.fillStyle = i % 3 ? 'rgba(150,16,16,0.92)' : 'rgba(86,10,10,0.95)'; - ctx.fillRect(Math.round(p.x + ox * hpx * 0.5), Math.round(p.y + oy * hpx), s, s); - } - if (G.hurtFlash > 0) { // the impact itself: he goes red for a moment - ctx.save(); ctx.globalAlpha = Math.min(1, G.hurtFlash / 0.3) * 0.5; - ctx.fillStyle = UI.bad; - ctx.fillRect(Math.round(p.x - hpx * 0.2), Math.round(p.y - hpx * 0.82), hpx * 0.4, hpx * 0.6); - ctx.restore(); - } -} function drawFallbackChar(ch, p, h) { // shown only until sprites load const you = ch.side === 'P'; const u = h / 16, x = p.x, y = p.y; diff --git a/zombies.js b/zombies.js index 806ebc5..96b1482 100644 --- a/zombies.js +++ b/zombies.js @@ -279,14 +279,9 @@ export function meleeSwipe(x, z, range) { } if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; } } - // The swish also BATS AWAY incoming shots. This is what gives you a defence in `pretoss`, where you - // are rooted to the spot and cannot swing at all — without it a machine winding up while you were - // lining up a serve was an unanswerable hit. It only destroys the shot; the timed rally parry below - // is the skilled option that sends it back as a weapon. - for (let i = shots.length - 1; i >= 0; i--) { - const s = shots[i]; - if (Math.hypot(s.x - x, s.z - z) < range && s.y < 2.4) { shots.splice(i, 1); pock(0.7, 900); hit++; } - } + // The swish deliberately does NOT touch incoming shots. It used to, as a pretoss fallback — but + // meleeSwipe runs FIRST in keyPressed, so it swallowed the very shot the new tap-to-parry was about + // to return. One answer to a thrown ball: swing at it. if (hit) swish(0.35); return hit; } @@ -472,6 +467,7 @@ export function updateZombies(dt) { bleed(zb); // open wounds bleed every frame they're standing if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out + if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool) // ---- 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') { @@ -502,14 +498,16 @@ export function updateZombies(dt) { killPlayer(); if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); - if (d < hitR(zb) + ballSweep) { // brute and boss are bigger targets + if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // brute and boss are bigger targets if (ball.bomb) { // detonate on first contact, whatever the pace explode(ball.x, ball.z); ball.bomb = false; spentBall(); break; // zombies mutated — stop this frame's loop } if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax if (isBoss(zb)) { // a boss takes a heavy wound AND catches light - if (wound(zb, TUNE.dmgFlame)) splatter(i); + zb.hitT = TUNE.bossHitCool; // ...and the ball is SPENT on it: a boss is not + ball.lastHit = 'X'; ball.vx *= 0.3; ball.vz *= 0.3; // removed, so an unconsumed pierce ball + if (wound(zb, TUNE.dmgFlame)) splatter(i); // re-wounded it every single frame else zb.burnT = TUNE.burnTime; continue; } @@ -520,7 +518,8 @@ export function updateZombies(dt) { } if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI) if ((ball.pow || 0) >= TUNE.zSplatQ) { - const dead = wound(zb, TUNE.dmgBall); + zb.hitT = TUNE.bossHitCool; // belt-and-braces: this ball is spent below, but the cooldown + const dead = wound(zb, TUNE.dmgBall); // is what stops ANY lingering contact multi-wounding pock(0.6, 300); ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit if (dead) { splatter(i); continue; } // killing blow: full gore + point @@ -591,9 +590,8 @@ export function updateZombies(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; const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8); - if (!done && P.swingT >= TUNE.swingWindup - && P.swingT <= TUNE.swingWindup + TUNE.swingWindow * VOLLEY.window - && dP < TUNE.reach * VOLLEY.reach && s.y < CONTACT.maxY) { + 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 // zombies, so "returned shot is a live weapon" costs nothing to build extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,