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>
This commit is contained in:
1 parent
295470b73c
commit
8bc57e9358
34 files changed
+180
-40
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user