From 57e96d629ed32bfa63b3836bc6ebd033b15a61a2 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 20:43:26 +0200 Subject: [PATCH] The finishing shot is spent, and the wound skins come back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "one-shot is bad - way too easy - also we're losing all the nice gore animation sprites" and "I noticed the last kill of a game can carry to the spawn of a new zombie of the next round". drillOneShot is off. The second half of that note is the part worth remembering: a 1 hp body never passes through a wound stage, so every skin in ZWOUND — arm off, ribs out, still walking — is skipped and the roster's best art never renders at all. Kept as a switch, never a default. The carry-over was a real unfairness, not a curiosity. A ball keeps pace through a kill and travels UP-COURT, which is exactly where the next batch is born — so the shot that finished your round flew on into bodies that did not exist when you took it, and faulted you on a round you had not been shown yet. The shot that ends a round is spent now, and drillCaught does the same since it also refills the court up-court. Pierce within a batch is untouched: that is honest occlusion you could have read, this was not. Verified with real aimed serves through a whole round: round banks, ball goes inactive, the next batch walks on with 0 faults and a clean sequence, and the gang carries roster HP again (brute 5, ball boy 3). 33 tests, 15 seeds clean. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 20 +++++++++++++++----- index.html | 2 +- src/config.js | 13 +++++++------ src/drill.js | 9 ++++++++- test.js | 49 ++++++++++++++++++++++++++++++++++++++++--------- 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 86899af..23d5425 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -739,11 +739,21 @@ Watch: **a roller in the middle of a sequence is seven aimed serves.** Oliver as robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a drill-only HP scale is the knob, not a change to the roster. -**TENTH PASS — two testing switches, and neither should be left on by drift.** -- **`TUNE.drillOneShot` (ON)**: every body on a drill court drops to 1 hp. Asked for to make testing - quick, but be aware what it removes — **the roster's HP is what makes the called ORDER matter**. A - roller is normally seven serves, so being told to take it third means starting on it first; at one - hit that whole layer of planning is gone and the sequence is pure recall. Decide it on feel. +**TENTH PASS — two switches, and the round-boundary bug.** +- **`TUNE.drillOneShot` — tried ON for one playtest, now OFF.** *"one-shot is bad - way too easy - + also we're losing all the nice gore animation sprites"*. The second half is the one that is easy to + miss and worth remembering generally: **a 1 hp body never passes through a wound stage**, so every + skin in `ZWOUND` (arm off, ribs out, still walking) is skipped and the roster's best art never + renders at all. The HP is also what makes the called ORDER matter — a roller is seven serves, so + being told to take it third means starting on it first. Kept as a switch, never a default. +- **THE ROUND-BOUNDARY CARRY-OVER, and it was a real unfairness.** *"the last kill of a game can + carry to the spawn of a new zombie of the next round"*. A ball keeps pace through a kill + (`TUNE.zPierce`) and it travels UP-COURT — which is precisely where the next batch is born. So the + shot that finished your round flew on into bodies that did not exist when you took it and faulted + you on a round you had not been shown yet. **The shot that ends a round is spent** (`ball.active = + false` in drillKill's completion branch, and in `drillCaught` for the same reason: that also + refills the court up-court). Pierce WITHIN a batch is untouched — that is honest occlusion you + could have read; this was not. - **`TUNE.drillEaterMax` 3 -> 0 (OFF)**: *"remove the fake zombies for now"*. The drill is the pure puzzle with no second threat at the moment. The mechanism is untouched — `spawnEater`, the wide entry away from the gang, the never-a-fault rule — so one number brings it back. diff --git a/index.html b/index.html index b2ec5b4..1c7e8ac 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/config.js b/src/config.js index 9bebfa7..1abed20 100644 --- a/src/config.js +++ b/src/config.js @@ -374,12 +374,13 @@ export const TUNE = { // brings it back. Past 3 the puzzle stops being readable. drillEaterSpeed: 1.15,// they walk a touch faster than the batch: the batch is a list, this is a threat drillRampT: 210, // s to go from the bottom of every curve above to the top - // TESTING KNOB, currently ON. Every body in the drill dies to one splat-quality hit, whatever the - // roster says it should soak. Flip to 0 for the real thing — and note that this is not purely a - // convenience: the roster's HP is what makes the called ORDER matter (a roller is seven serves, so - // being told to take it third means starting on it first), and one-shot bodies delete that whole - // layer. Decide it on feel rather than leaving it on by drift. - drillOneShot: 1, // >= 0.5: batch bodies AND eaters all drop to 1 hp + // TESTING KNOB, and it is OFF again after one playtest: "one-shot is bad - way too easy - also + // we're losing all the nice gore animation sprites". Both halves matter and the second is the one + // that is easy to miss — a 1 hp body never passes through any of its WOUND STAGES, so every skin + // in ZWOUND (arm off, ribs out, still walking) is skipped entirely and the roster's best art never + // renders. The HP is also what makes the called ORDER matter: a roller is seven serves, so being + // told to take it third means starting on it first. Left here as a switch, not a default. + drillOneShot: 0, // >= 0.5: batch bodies AND eaters all drop to 1 hp drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver, diff --git a/src/drill.js b/src/drill.js index 977b662..957984c 100644 --- a/src/drill.js +++ b/src/drill.js @@ -19,7 +19,7 @@ // render.js reads DRILL_COLS and draws. import { TUNE } from './config.js'; -import { G } from './state.js'; +import { G, ball } from './state.js'; import { zombies, spawnZombie } from './zombies.js'; import { voice } from './audio.js'; @@ -176,6 +176,12 @@ export function drillKill(zb) { if (d.step >= d.seq.length) { // round complete — and the next one is already here d.rounds++; d.msg = { text: 'GOOD', t: 0.9 }; + // THE SHOT THAT ENDS A ROUND IS SPENT. A ball keeps pace through a kill (TUNE.zPierce) and it + // is travelling UP-COURT — which is exactly where the next batch is about to be born. So the + // ball that finished your round flew on into bodies that did not exist when you took the shot + // and faulted you on a round you had not been shown yet. Pierce WITHIN a batch stays: that is + // honest occlusion you could have read. This is not. + ball.active = false; newRound(d); } return; @@ -206,6 +212,7 @@ export function drillCaught(text = 'CAUGHT') { voice('game_opponent', 0.1); d.msg = { text, t: 1.8, bad: true }; zombies.length = 0; + ball.active = false; // same reason as the round-complete case: the court refills up-court d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0; d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little } diff --git a/test.js b/test.js index 80ad580..5bdd96e 100644 --- a/test.js +++ b/test.js @@ -490,17 +490,48 @@ test('a wrong kill never leaves an un-winnable round', () => { } }); -test('drillOneShot drops every body on the drill court to a single hit', () => { +test('the shot that ends a round cannot carry into the next batch', () => { + // Oliver: "the last kill of a game can carry to the spawn of a new zombie of the next round". A + // ball keeps pace through a kill and travels UP-COURT, which is exactly where the next batch is + // born — so it faulted you on a round you had not been shown yet. + drillPlaying(); + const seq = G.drill.seq.slice(); + for (const v of seq) drillKillType(v); + assert.ok(G.drill.rounds >= 1, 'setup: the round completed'); + assert.equal(ball.active, false, 'the finishing shot is spent, not left in flight'); + // let the next batch walk on and confirm it arrives untouched + for (let i = 0; i < 30; i++) update(DT); + const gang = zombies.filter(z => z.drill); + assert.ok(gang.length >= 2, 'the next batch is on court'); + assert.equal(G.drill.faults, 0, 'and it did not inherit a fault from the previous round'); + assert.equal(G.drill.step, 0, 'nor a half-finished sequence'); +}); + +test('being caught also spends the ball, for the same reason', () => { + drillPlaying(); + ball.active = true; ball.lastHit = 'P'; + killPlayer(); + assert.equal(ball.active, false, 'the court refills up-court; a live ball would meet it'); +}); + +test('drillOneShot is a switch, and OFF means the roster HP is intact', () => { + // Off by default: one-shot bodies were "way too easy" AND skipped every wound skin in ZWOUND, + // because a 1 hp body never passes through a damage stage. Asserts whichever way the knob is set + // rather than being deleted when it was turned off. startDrill(); - assert.ok(TUNE.drillOneShot >= 0.5, 'setup: the testing knob is on'); - for (const z of zombies) assert.equal(z.hp, 1, z.variant + ' should be one-shot, saw hp ' + z.hp); - // and it really dies to ONE splat-quality ball, roster HP notwithstanding const tank = zombies.find(z => z.drill && z.variant === 'k') - || zombies.find(z => z.drill && z.variant === 'g') || zombies.find(z => z.drill); - ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; - ball.x = tank.x; ball.z = tank.z; ball.vx = ball.vy = ball.vz = 0; - update(DT); - assert.ok(!zombies.includes(tank), 'one hit put down a ' + tank.variant); + || zombies.find(z => z.drill && z.variant === 'g'); + if (TUNE.drillOneShot >= 0.5) { + for (const z of zombies) assert.equal(z.hp, 1, z.variant + ' should be one-shot, saw hp ' + z.hp); + } else if (tank) { + assert.ok(tank.hp > 1, 'the heavy bodies keep the roster HP: ' + tank.variant + ' at ' + tank.hp); + // ...and that is what lets it show a wound stage on the way down + ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0; + ball.x = tank.x; ball.z = tank.z; ball.vx = ball.vy = ball.vz = 0; + update(DT); + assert.ok(zombies.includes(tank), 'one hit does not put down a ' + tank.variant); + assert.ok(tank.hp < tank.hpMax, 'but it is wounded: ' + tank.hp + '/' + tank.hpMax); + } }); test('a batch body that walks past you counts as caught, never as a free escape', () => {