Make the parry reachable, and stop a boss dying to one flame ball
Six reports from Oliver's first playtest of the boss build. Two mattered. A boss sprite turned up in the shambler crowd because z_B1.png and z_b1.png are the same file on a case-insensitive filesystem — writing the boss frames silently overwrote the 'b' shambler's first two frames, and `ls` only ever showed the original lowercase name. Restored from HEAD~1, boss frames renamed z_boss*, and the rule is now in the asset pipeline notes. The parry was unreachable, which killed the entire new mechanic: P.swingT is only set in keyPressed's `phase === 'rally'` branch, and splatter sits in pretoss almost all the time, so every tap aimed at an incoming shot tossed the serve ball instead of swinging. An incoming shot inside parryArm now owns the tap in every phase, with its own generous reach and window — this is a phone, one thumb, against something aimed at you. The three fail cases (too early, too late, no tap) are what keep it a timing skill. The swish no longer eats shots: it runs first in keyPressed and was swallowing the very ball the parry was about to return. One flame ball killed a 16 hp boss, and that was a bug rather than tuning. A flame ball is not consumed on contact (full pierce, so it can cut a lane) but a boss is not removed when hit, so the same ball re-wounded it every frame. Boss bodies now take a contact cooldown and flame spends itself on them. Chapter 1 costs 4 flame balls or 16 clean smashes, and bossHp goes 10 to 16. The boss had no wounded state while throwing — correct, the wound skins were standing poses only. New sheet gives all four damage states their own overarm pitch. The ball machine's four painted rects are replaced with a real sheet, idle and firing, keeping only the muzzle glow procedural because the telegraph has to read at 20px tall. And the player's blood no longer sits on him as a painted layer: live drip and the screen-edge wash, nothing else. Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean, all four chapters firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=40"></script>
|
||||
<script type="module" src="main.js?v=41"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<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');
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||