diff --git a/HANDOFF.md b/HANDOFF.md index 30af9e2..ceed4a9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -155,6 +155,33 @@ New foundations you must know before touching anything: exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5. - `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still. +## Second feedback round — "do it properly" (2026-07-27) +Oliver rejected the procedural fakes on sight. The rule this round: **animation gets drawn frames.** +- **Every brute wound stage now has a 2-frame lurch** (`z_gw`/`z_gwb`, `z_gw2`/`z_gw2b`, …). The + procedural squash-and-sway that replaced them read as fake immediately — "that's where the comedy + comes from". Generated by handing the generator a composite of the four A-frames + (`assets/_sheet_gwound_a.png`, built with PIL) and asking for the stride swapped. + **Gotcha:** the generator gave every B frame a dragged pole, but only stage 1's A frame has one — + it would have blinked in and out each step. Stripped by keeping the largest connected blob + (stages 2–3) and, where the pole touched the body (stage 4), by cutting on column density: the + torso is dense, a dragged pole is a thin diagonal of ~7 px per column. +- **Frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys, + `zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk whenever speed and frame rate + disagree, which is exactly what "he slides faster than the animation suggests" was. + Height for a wound stage ALWAYS comes from the A frame or the sprite pops size every step. +- **One boy, one slot.** `sendBoy` no longer replaces a boy who is already out — that was the + "appears and disappears": every kill teleported him back to the wing, so a kill streak restarted + him several times a second. New work is appended to his round (`jobs`). One crossing is now 10.5s. +- **The applauding crowd is a real two-frame animation** (`x_crowd` seated / `x_crowd2` arms up), + swapped per column on its own phase. The previous "smooth wave" still shifted each column by a + FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a fractional y re-snaps its + pixel rows every frame — that boiling beside the HUD was the "scoreboard flicker". The board art + is 99.9% opaque, so nothing was ever showing *through* it. **Test:** render twice without + stepping — identical pixels means nothing in the draw path rolls dice; and with the crowd calm + the frame-to-frame pixel delta must be 0.0%. +- Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected: + mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps. + ## Gotchas learned the hard way - Browser-pane verification: python http.server sends no cache headers, so edited ES modules often load STALE even after location.reload(). Reliable fix: preview_stop + diff --git a/assets/_sheet_gwound_a.png b/assets/_sheet_gwound_a.png new file mode 100644 index 0000000..3ce82c6 Binary files /dev/null and b/assets/_sheet_gwound_a.png differ diff --git a/assets/_sheet_gwound_b.png b/assets/_sheet_gwound_b.png new file mode 100644 index 0000000..d776899 Binary files /dev/null and b/assets/_sheet_gwound_b.png differ diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png new file mode 100644 index 0000000..2ad76d3 Binary files /dev/null and b/assets/x_crowd2.png differ diff --git a/assets/z_gw2b.png b/assets/z_gw2b.png new file mode 100644 index 0000000..c28f3ce Binary files /dev/null and b/assets/z_gw2b.png differ diff --git a/assets/z_gw3b.png b/assets/z_gw3b.png new file mode 100644 index 0000000..4f9e76f Binary files /dev/null and b/assets/z_gw3b.png differ diff --git a/assets/z_gw4b.png b/assets/z_gw4b.png new file mode 100644 index 0000000..0733592 Binary files /dev/null and b/assets/z_gw4b.png differ diff --git a/assets/z_gwb.png b/assets/z_gwb.png new file mode 100644 index 0000000..dda1a38 Binary files /dev/null and b/assets/z_gwb.png differ diff --git a/config.js b/config.js index cf54b50..4415b5b 100644 --- a/config.js +++ b/config.js @@ -31,8 +31,9 @@ export const TUNE = { zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests) zMaxCap: 14, // absolute shambler cap changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) - boyMopSpeed: 5.0, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5). - // Sized so one crossing ~fills the changeover instead of stranding him mid-court. + boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) + boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so + // the feet cannot slide: change a boy's speed and his legs keep up on their own. gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness) @@ -92,6 +93,8 @@ export const TUNE = { // full pierce simply killed the whole lane outright and nothing was left to catch fire. burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting) setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) + bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so + // its cycle is driven by ground covered rather than by the clock bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you @@ -121,6 +124,9 @@ COURT.svcNear = COURT.netZ + 6.4; // near service line // world sizes (meters) export const PLAYER_H = 1.88; 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. +export const BOY_ENTER = { mop: 9.5, run: 13 }; export const UMP_H = 3.0; export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait diff --git a/gameplay.js b/gameplay.js index 8851bfd..b16ff13 100644 --- a/gameplay.js +++ b/gameplay.js @@ -931,14 +931,16 @@ export function update(dt) { // ball boy: sprint across, scoop the ball in passing, despawn off the far side const boy = G.boy; if (boy) { - // mopping is not sprinting: the mop crossing takes ~6.6s, which is what fills the changeover - boy.x += boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt; boy.runT += dt; + // mopping is not sprinting. `dist` is what drives his animation frames (render.drawBoy), so + // the legs are tied to the ground he covers and can never out-run or lag behind the walk. + const step = boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt; + boy.x += step; boy.runT += dt; boy.dist = (boy.dist || 0) + Math.abs(step); if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it const dz = ball.z - boy.z; boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; } - if (Math.abs(boy.x) > 13.5) G.boy = null; + if (Math.abs(boy.x) > 13.5) G.boy = null; // off the far wing — his round is done } if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null; diff --git a/index.html b/index.html index d80b8ee..bd3c06f 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 023243d..3451619 100644 --- a/render.js +++ b/render.js @@ -75,9 +75,9 @@ export function proj(x, y, z) { 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_ads', + 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads', 'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel - 'z_gw2','z_gw3','z_gw4', // the brute comes apart in stages (4/5 hp = z_gw, down to 1/5 = z_gw4) + '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_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) @@ -144,7 +144,10 @@ function drawBoy(b) { // mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run // frames until the mop PNGs have loaded. const mop = b.mop && SPR.x_boymop1; - const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor(b.runT * (mop ? 8 : 14)) % 4 + 1)]; if (!img) return; + // Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk + // whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers. + const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push + const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return; const p = proj(b.x, 0, b.z); // scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per // sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright @@ -171,28 +174,20 @@ function drawZombie(zb) { let name = 'z_' + v + frame; if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))]; - if (SPR[stage]) name = stage; else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded - if (SPR[name]) h *= SPR[name].height / SPRITE_H; // the headless stage really is shorter — don't stretch it back + if (SPR[stage]) { + // Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by + // DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle + // was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what + // it was: fake. The comedy is in the gait, and the gait has to be drawn. + const b = stage + 'b'; + name = SPR[b] && Math.floor(zb.z / TUNE.bruteStride) % 2 ? b : stage; + h *= SPR[stage].height / SPRITE_H; // height ALWAYS off the A frame, or the size pops each step + } else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded } else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1'; - // Every wound stage is a SINGLE frame, and the brute now spends most of its life wounded — so - // without this it slid toward you like a decal. Rather than eight more sprites, the wrecked - // stages get a procedural lumber: it heaves sideways, sags onto each step and drags the trailing - // foot. Pure translate + squash (no rotation), so the pixels stay on the grid. - const wrecked = zb.variant === 'g' && zb.hp < TUNE.bruteHp && name !== 'z_g' + frame; - if (wrecked) { - const p = proj(zb.x, 0, zb.z), w = zb.walkT * 2.4; // slow and heavy — it is barely holding together - ctx.save(); - // amplitudes are in PROJECTED px, so they have to scale with p.s or they vanish at range — - // at 0.035 the whole lurch was sub-pixel out at the far baseline and read as no animation - ctx.translate(p.x + Math.sin(w) * p.s * 0.13, p.y); - ctx.scale(1 + 0.07 * Math.sin(w), 1 - 0.09 * Math.abs(Math.sin(w * 2))); - ctx.translate(-p.x, -p.y); - } - drawStatic(name, zb.x, zb.z, h, wrecked ? Math.max(0, Math.sin(zb.walkT * 4.8)) * 3 : Math.floor(zb.walkT * 5.2) % 2); - if (wrecked) ctx.restore(); + drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); if (zb.burnT > 0) drawFlames(zb, h); if (rk) { // HP bar floating above the mini-boss const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2; @@ -425,20 +420,26 @@ export function render() { const adH = 14, crowdH = standsBottom - adH + 2; const crowdImg = SPR['x_crowd']; if (crowdImg) { + // Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same + // stand with every arm up. Columns swap between them on their own phase, so the clap ripples + // across the stands. Crucially there is NO vertical offset any more — the old bounce shifted + // each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a + // fractional y makes its pixel rows re-snap every frame. That boiling, right beside the + // scoreboard, is what read as the HUD flickering. Integer-aligned draws don't shimmer. + const clapImg = SPR['x_crowd2']; const cols = 16, sw = crowdImg.width / cols, dw = W / cols; for (let c = 0; c < cols; c++) { - // A smooth per-column wave, NOT per-frame noise: random jitter here read as TV static - // through the scoreboard art's transparent ornate edges, which looked like the HUD flickering. - const dy = crowd.cheer > 0 ? -1.6 * (0.5 + 0.5 * Math.sin(G.idleT * 12 + c * 1.7)) : 0; - ctx.drawImage(crowdImg, c * sw, 0, sw, crowdImg.height, c * dw, dy, dw + 0.5, crowdH + 2); + const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2; + const img = clapping ? clapImg : crowdImg; + ctx.drawImage(img, c * sw, 0, sw, img.height, c * dw, 0, dw + 0.5, crowdH + 2); } } else { // fallback until the image loads: the old tier stripes + dots ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom); for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); } for (const d of crowd.dots) { - const w = G.idleT * 12 + d.x * 0.4; // same smooth wave for the pre-sprite dot fallback - const jx = crowd.cheer > 0 ? Math.sin(w * 0.7) : 0; - const jy = crowd.cheer > 0 ? -1.2 * (0.5 + 0.5 * Math.sin(w)) : 0; + // dot fallback (pre-sprite): whole-pixel hops only, same no-shimmer rule as above + const jx = 0; + const jy = crowd.cheer > 0 && Math.floor(G.idleT * 9 + d.x * 0.2) % 2 ? -2 : 0; ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2); } } diff --git a/state.js b/state.js index 293ce9b..8f2cb8d 100644 --- a/state.js +++ b/state.js @@ -1,6 +1,6 @@ // All mutable game state in one place, plus the point-reset that re-arms it. -import { HOME } from './config.js'; +import { HOME, BOY_ENTER } from './config.js'; export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; @@ -102,6 +102,11 @@ export function setupPoint() { // running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball. export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') { if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go + // ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride, + // which is why he flickered in and out: a kill streak restarted him several times a second. + // A boy already out on the court keeps working — he just adds the new mess to his round. + if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = (G.boy.jobs || 1) + 1; } return; } const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run - G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0, mop }; + const e = mop ? BOY_ENTER.mop : BOY_ENTER.run; + G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 }; }