Drawn frames, not procedural fakes: brute lurch, boy stride, crowd applause
Every animation in this round gets real frames. The procedural squash-and-sway on the wounded brute read as fake on sight, and that gait is where the comedy lives. - Each brute wound stage now has its own 2-frame lurch (z_gw/z_gwb, z_gw2/z_gw2b, ...). The generator gave every B frame a dragged pole that only stage 1 actually carries, so it would have blinked in and out each step — stripped by keeping the largest blob, and where the pole touched the body, by cutting on column density (a torso is dense, a dragged pole is a thin diagonal). - Frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys, z/bruteStride for brutes. A time-driven cycle moonwalks the moment speed and frame rate disagree, which is exactly what "he slides faster than the animation suggests" was. Change a speed now and the legs keep up on their own. - One boy, one slot. sendBoy no longer replaces a boy already on court: that was the appearing and disappearing, since every kill teleported him back to the wing mid-stride. New mess is appended to his round instead. He now walks for 10.5s a crossing, and there is a lot of mopping to do. - The applauding crowd is a real two-frame animation (seated / arms up), swapped per column on its own phase. The old "smooth wave" still shifted each column by a FRACTIONAL dy, and nearest-neighbour resampling at a fractional y re-snaps a column's pixel rows every frame — that boiling beside the HUD is what read as the scoreboard flickering. The board art is 99.9% opaque; nothing was ever showing through it. With the crowd calm the frame delta is now 0.0%. Benchmarks: 136/150 serve returns (90.7% ±2.4%, the 11/12 bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -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 +
|
||||
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -60,6 +60,6 @@
|
||||
<p class="start">TAP TO START</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=29"></script>
|
||||
<script type="module" src="main.js?v=30"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||