Tighten the returns, give each chapter its own boss, and let the umpire talk
Five reports from Oliver's second playtest. The parry was too generous — at 6 m of arm with a 0.34 s window the shot flew into reach on its own, so any tap once it was vaguely close worked. 3.4 m / 0.2 s / 1.7 m reach now: a tap at 4.5 m is rejected and costs blood, 3.2 m and in returns. The boss telegraphs in 0.5 s against the machine's 0.9 and throws harder, because a boss should be worse than furniture. His three balls were decoration: fanned ±1.9 m across space, so only the middle one could ever reach you. They are a timed salvo now — three shots 0.42 s apart, each aimed at where you are at that moment. hurtGrace still caps a whole volley at one point of damage, which is what it is for. Every chapter was the same boss. Three designs cycling now, each with its own sheet, stride, throw pose, wrecked skin and wrecked-throw skin: the GROUNDSKEEPER, the LINE JUDGE, the CHAIR UMPIRE with his chair strapped on. The chapter card names them. Designs are data in BOSS_ART, so a fourth is a sheet plus a row. Healing announces itself with a first-aid tin and a red cross instead of a 1.4 s flash. And 14 new umpire lines: ten sarcastic asides on a jittered timer, four hooked to specific beats. He still has not acknowledged that the match became a massacre, so he keeps officiating it. Two traps found and written into the pipeline notes. Sprite loads now carry ?v=SPRV, because python http.server sends no cache headers and the browser caches 404s — a name added before its PNG exists stays permanently missing for that browser. And a scripted replace that silently didn't match left drawZombie on the old hardcoded boss names while everything else moved to BOSS_ART; the built-in fallback hid it perfectly, all four designs rendering as design 1 with no error. Assert your rewiring edits matched. Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -5,11 +5,9 @@ everything they specified is shipped, and what still matters from them is record
|
||||
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
|
||||
detail of any individual change, read the git log.
|
||||
|
||||
Last updated 2026-07-30, after Oliver's first playtest of the boss/ranged build. All six things he
|
||||
reported are fixed (see CLOSED at the bottom) — including the one that made the whole new mechanic
|
||||
unusable: **the parry was unreachable**, because a tap only ever became a swing in `phase === 'rally'`
|
||||
and splatter sits in `pretoss` almost all the time. Step 6 (crowd turning, ball-boy catcher) stays
|
||||
PARKED by his decision. **Awaiting his next phone playtest.**
|
||||
Last updated 2026-07-30, after Oliver's SECOND playtest of the boss/ranged build. Both playtest
|
||||
rounds are closed at the bottom. Step 6 (crowd turning, ball-boy catcher) stays PARKED by his
|
||||
decision. **Awaiting his next phone playtest.**
|
||||
|
||||
## What this is
|
||||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
||||
@@ -144,6 +142,14 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
|
||||
layer on top"*. His health reads as `bleedPlayer()`'s live drip plus the screen-edge wash on impact,
|
||||
and nothing else. This is the same settled rule that governs the zombies — blood is never painted
|
||||
into a sprite — and a `drawPlayerWounds` overlay broke it.
|
||||
- **Boss designs are DATA: `BOSS_ART` in config.js, one row per chapter, cycling.** Each row is
|
||||
`{ name, walk: [2], wounds: [...], throws: [...] }`; `spawnChapterBoss` stamps the row onto the body
|
||||
as `zb.art` and `drawZombie` reads only that. Damage picks a wound stage as a fraction of `hpMax`
|
||||
spread over however many stages the design HAS, and `throws` is index-clamped, so a design with
|
||||
fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row —
|
||||
never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing,
|
||||
which is convenient and also means **a wiring bug looks exactly like working code** (see pipeline
|
||||
note 7).
|
||||
- **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
|
||||
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
|
||||
- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
|
||||
@@ -249,7 +255,22 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
|
||||
4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the
|
||||
content bbox + optional downscale, no frame splitting, no forced height.
|
||||
5. Register new names in `render.js`'s `SPR` preload array.
|
||||
6. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
|
||||
6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through
|
||||
`assets/<n>.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends
|
||||
no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a
|
||||
name to the `SPR` list before its PNG exists, load the page once, and that browser treats the
|
||||
sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV
|
||||
alongside `main.js?v=N`.
|
||||
7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent
|
||||
no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to
|
||||
the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss
|
||||
designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage`
|
||||
instrumentation is what finally settled it:
|
||||
```js
|
||||
const o = CanvasRenderingContext2D.prototype.drawImage;
|
||||
CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); };
|
||||
```
|
||||
8. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
|
||||
`assets/z_B1.png` for the chapter boss silently OVERWROTE the 'b' shambler's `assets/z_b1.png`
|
||||
(and `z_B2` ate `z_b2`), so a 3-metre boss started showing up in the shambler crowd and `git`
|
||||
reported the shambler frames as "modified" rather than reporting a new file. `ls` shows only the
|
||||
@@ -292,6 +313,52 @@ Known generator quirks, all still true:
|
||||
|
||||
---
|
||||
|
||||
# CLOSED — Oliver's second boss/ranged playtest (2026-07-30)
|
||||
|
||||
### The parry was too generous
|
||||
*"I can tap pretty early and it returns the ball."* At `parryArm` 6.0 / window 0.34 / reach 2.4 the
|
||||
shot flew INTO reach during the window, so any tap once it was vaguely close worked. Now 3.4 / 0.2 /
|
||||
1.7 — verified: a tap at 6.0 m or 4.5 m is rejected and costs blood, 3.2 m and closer returns.
|
||||
|
||||
### The boss's three balls were decoration
|
||||
*"he throws 3 of them from which only ever one can reach me. that does not make sense."* Correct — they
|
||||
were fanned +-1.9 m across space, so only the middle one was ever aimed at the player. They are a
|
||||
**timed salvo** now: three shots `TUNE.bossSalvoGap` apart, each aimed at where you are AT THAT MOMENT
|
||||
with only a hand's width of scatter. Verified firing at 0.02 / 0.45 / 0.88 s, all within 0.3 m of the
|
||||
player. Note `hurtGrace` (1.1 s) still caps a whole volley at one point of damage by design.
|
||||
Reaction time also tightened: bosses get `bossWind` 0.5 s of telegraph against the machine's 0.9, and
|
||||
throw at `bossShotSpeed` 15.5 m/s against its 13.
|
||||
|
||||
### Every chapter was the same boss
|
||||
Right — chapters only differed in hp and scale. There are three designs now, cycling, each with its
|
||||
own sheet, a 2-frame stride, a throw pose, a wrecked skin and a wrecked-throw skin:
|
||||
**THE GROUNDSKEEPER** (ch 1/4/7…, blazer + ball hopper, and the only one with three wound stages),
|
||||
**THE LINE JUDGE** (ch 2/5/…, fat, striped officials' polo, cap, ball apron),
|
||||
**THE CHAIR UMPIRE** (ch 3/6/…, tall and gaunt, panama hat, the umpire's chair strapped to his back).
|
||||
The chapter card names the design. Chapter 4 repeats design 1 but at 43 hp and 3.67 m.
|
||||
**The chair-umpire sheet came back on a WHITE background**, and it wears a cream blazer and a white
|
||||
hat — a colour key would have eaten both. Fixed by flood-filling the background inward from the image
|
||||
BORDER instead of keying by colour; that script is in this session's git history and is the right tool
|
||||
whenever a generated sheet's background colour also appears on the character.
|
||||
|
||||
### Healing needed to announce itself
|
||||
Winning a duel while wounded now opens a real modal — `x_cross` first-aid tin, "WOUNDS TREATED / THE
|
||||
TRAINER PATCHED YOU UP / N OF M BACK" — plus an umpire line. It was a 1.4 s flash before.
|
||||
|
||||
### The umpire needed to comment on the new reality
|
||||
14 new recorded lines (`say -v Daniel -r 168` -> `afconvert`), all `ump_*`. Ten are a rotating pool of
|
||||
sarcastic asides fired on a jittered `TUNE.asideEvery` timer whenever nothing else owns the audio
|
||||
(`umpireAside` in gameplay.js); four are hooked to specific beats — a machine arriving, a boss
|
||||
arriving, the trainer, and taking a hit. The joke is that he has still never acknowledged that the
|
||||
match became a massacre, so he keeps officiating it: *"Code violation. Unsportsmanlike conduct.
|
||||
Eating."*
|
||||
|
||||
### Regression net
|
||||
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, `mgT` drains, gore 40 /
|
||||
piles 30 / bodies capped at 34.
|
||||
|
||||
---
|
||||
|
||||
# CLOSED — Oliver's first boss/ranged playtest (2026-07-30)
|
||||
|
||||
Six reports, all fixed. Two were serious.
|
||||
|
||||
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 585 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=41"></script>
|
||||
<script type="module" src="main.js?v=42"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js';
|
||||
import { P, O, ball, score, crowd, G } from './state.js';
|
||||
import { predictLanding, flickTarget } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||
@@ -72,16 +72,19 @@ export function proj(x, y, z) {
|
||||
|
||||
// ---------------------------------------------------------------- sprites ----
|
||||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||||
const SPRV = 42; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||||
const SPR = {};
|
||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
|
||||
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
|
||||
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads',
|
||||
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
|
||||
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel
|
||||
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto','x_cross', // walking with the mop shouldered, and the slingshot tutorial panel
|
||||
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
|
||||
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
|
||||
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
|
||||
'z_mach1','z_mach2', // the ball machine: idle, and firing with a muzzle flash
|
||||
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...)
|
||||
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
|
||||
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
|
||||
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
|
||||
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
|
||||
@@ -89,7 +92,12 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
|
||||
'scoreboard']) {
|
||||
const img = new Image();
|
||||
img.onload = () => SPR[n] = img;
|
||||
img.src = 'assets/' + n + '.png';
|
||||
// ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache
|
||||
// headers, so the browser caches sprite responses heuristically — INCLUDING 404s. A brand-new sheet
|
||||
// whose name was added to this list before the PNG existed stays permanently "missing" for that
|
||||
// browser, and drawZombie silently falls back to another design. That cost a debugging round.
|
||||
// BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html.
|
||||
img.src = 'assets/' + n + '.png?v=' + SPRV;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ crowd ----
|
||||
@@ -179,7 +187,7 @@ function drawMachine(zb) {
|
||||
const h = MACH_H * (img.height / SPRITE_H);
|
||||
drawStatic(name, zb.x, zb.z, h);
|
||||
if (firing) { // the TELEGRAPH still needs to read at 20px tall, so the muzzle glow stays procedural
|
||||
const k = 1 - zb.windT / TUNE.machWind, p = proj(zb.x, h * 0.62, zb.z);
|
||||
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
|
||||
ctx.globalAlpha = 0.3 + k * 0.7;
|
||||
ctx.fillStyle = k > 0.6 ? '#fff2c0' : UI.warm;
|
||||
const r = Math.max(1.5, p.s * 0.05 * (0.5 + k));
|
||||
@@ -228,23 +236,28 @@ function drawZombie(zb) {
|
||||
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
|
||||
// out, then headless and still throwing. Its 2-frame stride steps off DISTANCE COVERED (the same
|
||||
// rule the brute uses) — it is the slowest thing on court and a time-driven cycle would foot-slide.
|
||||
// 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<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');
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||