Turn the bodies around, calm the racket zombie, break up the pack

Three from the playtest, all code:

Every side-on sprite is DRAWN facing left, and nothing ever mirrored them, so a
zombie walking right moonwalked — obvious the moment they close on you across
your own half. They face where they last walked now. The front-facing ones (the
turned CPU, the machine, a chapter boss) have no left or right and are left alone.

The racket zombie holds a position and only moves when the ball makes him, so a
time-driven walk cycle flicked him between two frames on the spot. His stride
steps off ground covered, like the brute's — standing still is standing still.

And the pack no longer stands on the body twitching for as long as the summary is
up: once it appears they break up and shamble away up-court. Measured — feeding
at 3s, dispersed to 3m by 6.5s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-31 22:24:11 +02:00
1 parent 7edd623372
commit 01498eddad
7 files changed
+82 -12

No files matched your search

+4 -2
View File
@@ -82,7 +82,8 @@ def colour_match(src, dst_name):
def run(name, spec, dry=False): def run(name, spec, dry=False):
os.makedirs(SCRATCH, exist_ok=True) os.makedirs(SCRATCH, exist_ok=True)
sprites = spec['sprites'] sprites = spec['sprites']
ref = ref_sheet(sprites, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0)) lead = spec.get('ref') # generated for scale, thrown away after
ref = ref_sheet(([lead] if lead else []) + sprites, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0))
sheet = f'{SCRATCH}/{name}_hi.png' sheet = f'{SCRATCH}/{name}_hi.png'
prompt = f"{spec['subject']} {STYLE}" prompt = f"{spec['subject']} {STYLE}"
if dry: if dry:
@@ -94,7 +95,7 @@ def run(name, spec, dry=False):
print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False
if touches_edge(sheet): if touches_edge(sheet):
print(f'[{name}] REJECTED: a figure runs off the canvas edge — re-run'); return False print(f'[{name}] REJECTED: a figure runs off the canvas edge — re-run'); return False
outs = [f'{SCRATCH}/{name}_{i}.png' for i in range(len(sprites))] outs = [f'{SCRATCH}/{name}_{i}.png' for i in range(len(sprites) + (1 if lead else 0))]
cut = ['--cut', spec['cut']] if spec.get('cut') else [] cut = ['--cut', spec['cut']] if spec.get('cut') else []
r = subprocess.run([PY, os.path.join(HERE, '_fine.py'), sheet, *outs, r = subprocess.run([PY, os.path.join(HERE, '_fine.py'), sheet, *outs,
'--anchor', str(spec.get('anchor', 0)), '--h', str(spec.get('h', 224)), *cut], '--anchor', str(spec.get('anchor', 0)), '--h', str(spec.get('h', 224)), *cut],
@@ -102,6 +103,7 @@ def run(name, spec, dry=False):
print(r.stdout.strip() or r.stderr.strip()) print(r.stdout.strip() or r.stderr.strip())
if r.returncode or not all(os.path.exists(o) for o in outs): if r.returncode or not all(os.path.exists(o) for o in outs):
print(f'[{name}] REJECTED: slicing failed (frames merged?) — re-run or add "cut"'); return False print(f'[{name}] REJECTED: slicing failed (frames merged?) — re-run or add "cut"'); return False
if lead: outs = outs[1:] # the scale reference is not installed
before = [density(os.path.join(HERE, s + '.png')) for s in sprites] before = [density(os.path.join(HERE, s + '.png')) for s in sprites]
after = [density(o) for o in outs] after = [density(o) for o in outs]
# 1.25x, not 1.5x: a sheet that already has blood on it starts with a high edge density, so a # 1.25x, not 1.5x: a sheet that already has blood on it starts with a high edge density, so a
+47 -4
View File
@@ -104,8 +104,9 @@
"p_swing1", "p_swing1",
"p_swing2" "p_swing2"
], ],
"subject": "Four frames of the same tennis player in a white shirt, blue shorts and a white headband, drawn as coarse low-resolution pixel art: tossing the ball up for a serve; the smash at full stretch with the racket overhead; and two frames of a forehand swing.", "ref": "p_idle1",
"anchor": 0 "anchor": 0,
"subject": "Five frames of the same tennis player in a white shirt, blue shorts and a white headband, seen from BEHIND and slightly to the side, drawn as coarse low-resolution pixel art. First a plain standing frame for scale, then: tossing the ball up for a serve with the racket arm dropped back; the smash at full stretch with the racket overhead and both feet off the ground; and two frames of a forehand swing. In every frame he faces AWAY from the camera, up the court."
}, },
"player_run": { "player_run": {
"sprites": [ "sprites": [
@@ -114,8 +115,9 @@
"p_run3", "p_run3",
"p_run4" "p_run4"
], ],
"subject": "Four frames of the same tennis player seen from BEHIND in a white shirt and blue shorts, running away from the camera, a four-frame run cycle with no racket, drawn as coarse low-resolution pixel art.", "ref": "p_idle1",
"anchor": 0 "anchor": 0,
"subject": "Five frames of the same tennis player in a white shirt and blue shorts, drawn as coarse low-resolution pixel art. Every figure is seen strictly FROM BEHIND \u2014 the back of his head and the back of his shirt face the viewer, no face, no eyes, no nose, he is running or standing AWAY from the camera. First a standing frame for scale, then a four-frame run cycle, running away from the camera with no racket."
}, },
"player_death": { "player_death": {
"sprites": [ "sprites": [
@@ -231,5 +233,46 @@
], ],
"anchor": 0, "anchor": 0,
"subject": "Two frames of the same young ball boy in a white polo shirt and shorts sprinting across a tennis court, two further stages of the same run cycle, drawn as coarse low-resolution pixel art." "subject": "Two frames of the same young ball boy in a white polo shirt and shorts sprinting across a tennis court, two further stages of the same run cycle, drawn as coarse low-resolution pixel art."
},
"player_back": {
"sprites": [
"p_idle1",
"p_idle2",
"p_bounce",
"p_bounce2"
],
"ref": "p_run1",
"anchor": 0,
"subject": "Five frames of the same tennis player in a white shirt, blue shorts and a white headband, drawn as coarse low-resolution pixel art. Every figure is seen strictly FROM BEHIND \u2014 the back of his head and the back of his shirt face the viewer, no face, no eyes, no nose, he is running or standing AWAY from the camera. First a running frame for scale, then: two waiting stances holding a racket down at his side, and two frames of him bouncing a ball on the ground beside his LEFT foot before serving."
},
"runner_front": {
"sprites": [
"z_f1",
"z_f2",
"z_f3",
"z_f4"
],
"anchor": 0,
"subject": "Four frames of a lean SPRINTING zombie in a torn tracksuit, drawn as coarse low-resolution pixel art running side-on. Redraw the four frames as the same sprinting zombie CHARGING STRAIGHT AT THE VIEWER, down the court towards the camera \u2014 a front-facing run cycle: his face and chest towards you, one knee driving up towards the camera and the other leg trailing behind him, arms pumping across his body, the whole figure foreshortened as if he is bearing down on you. He must NOT be running sideways across the frame in any of them. Keep the torn tracksuit, the green skin, the same height."
},
"brute_all_wounds": {
"sprites": [
"z_gw",
"z_gwb",
"z_gw2",
"z_gw2b"
],
"anchor": 0,
"subject": "Four frames of the same enormous fat zombie brute, drawn as coarse low-resolution pixel art. The FIRST TWO are the same damage stage \u2014 his LEFT arm torn off at the shoulder leaving a ragged bloody stump, the right arm dragging a rusty pole \u2014 shown as two stages of one stride. The LAST TWO are the next damage stage, BOTH arms gone at the shoulders, again two stages of one stride. Within each pair the injuries must be IDENTICAL and on the same side of the body: only the legs change."
},
"brute_all_wounds2": {
"sprites": [
"z_gw3",
"z_gw3b",
"z_gw4",
"z_gw4b"
],
"anchor": 0,
"subject": "Four frames of the same enormous fat zombie brute, drawn as coarse low-resolution pixel art. The FIRST TWO are the same damage stage \u2014 chest and belly torn wide open with the ribs showing \u2014 as two stages of one stride. The LAST TWO are the final stage, HEADLESS with the ribs out, still standing, again two stages of one stride. Within each pair the injuries must be IDENTICAL and in the same place: only the legs change."
} }
} }
+2
View File
@@ -238,6 +238,8 @@ export const TUNE = {
shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window 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 meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies
racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND
// COVERED, so he stops animating when he stops moving
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU) racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
racketFlub: 0.2, // chance the racket zombie whiffs a return racketFlub: 0.2, // chance the racket zombie whiffs a return
+1
View File
@@ -746,6 +746,7 @@ function racketAI(rz, dt) {
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) { if (d > 0.15) {
const step = Math.min(TUNE.racketSpeed * dt, d); const step = Math.min(TUNE.racketSpeed * dt, d);
rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
if (rz.pose !== 'swing') rz.pose = 'run'; if (rz.pose !== 'swing') rz.pose = 'run';
if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1; if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1;
+1 -1
View File
@@ -97,6 +97,6 @@
<p id="build"></p> <p id="build"></p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=70"></script> <script type="module" src="main.js?v=71"></script>
</body> </body>
</html> </html>
+13 -3
View File
@@ -232,12 +232,15 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
} }
// ----------------------------------------------------------------- actors ---- // ----------------------------------------------------------------- actors ----
function drawStatic(name, x, z, hMeters, bob = 0) { function drawStatic(name, x, z, hMeters, bob = 0, flip = false) {
const img = SPR[name]; if (!img) return; const img = SPR[name]; if (!img) return;
const p = proj(x, 0, z); const p = proj(x, 0, z);
const h = hMeters * p.s, w = h * img.width / img.height; const h = hMeters * p.s, w = h * img.width / img.height;
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25); drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h); if (!flip) return ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT
ctx.drawImage(img, -w / 2, p.y - h - bob, w, h);
ctx.restore();
} }
function drawBoy(b) { function drawBoy(b) {
// mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run // mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run
@@ -301,7 +304,11 @@ function drawZombie(zb) {
let N = ZFRAMES[v] || 2; let N = ZFRAMES[v] || 2;
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up // same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
// The racket zombie holds a position and only moves when the ball makes him, so a time-driven
// cycle had him flicking between two frames on the spot. His stride steps off ground covered like
// the brute's — standing still is standing still — and his swing keeps its own frame.
let frame = rk && zb.pose === 'swing' ? 2 let frame = rk && zb.pose === 'swing' ? 2
: rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1
: zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE : zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE
// WALKED, like its wound lurch below, // WALKED, like its wound lurch below,
// or the feet slide (same cycle length) // or the feet slide (same cycle length)
@@ -376,7 +383,10 @@ function drawZombie(zb) {
// feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm. // feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm.
const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05) const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
: Math.floor(zb.walkT * 5.2) % 2; : Math.floor(zb.walkT * 5.2) % 2;
drawStatic(name, zb.x, zb.z, h, bob); // Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
if (zb.burnT > 0) drawFlames(zb, h); if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
+14 -2
View File
@@ -3,7 +3,7 @@
// your baseline — one crossing it is a point for THEM. // your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, EATEN_BURST, EATEN_FEED_R } from './config.js'; import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js'; import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js';
@@ -542,6 +542,7 @@ export function updateZombies(dt) {
const zb = zombies[i]; const zb = zombies[i];
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
zb.walkT += dt; zb.walkT += dt;
const faceX0 = zb.x; // which way he is walking, for the sprite mirror in drawZombie
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
if (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt; if (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
bleed(zb); // open wounds bleed every frame they're standing bleed(zb); // open wounds bleed every frame they're standing
@@ -570,7 +571,7 @@ export function updateZombies(dt) {
pock(0.3, 1500); // the tell: a mechanical click as it loads pock(0.3, 1500); // the tell: a mechanical click as it loads
} }
} }
if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm') { if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm' && G.eatenT < EATEN_SUMMARY) {
// You are down. Nothing else matters to them: every body on court converges on the spot and // You are down. Nothing else matters to them: every body on court converges on the spot and
// packs into a ring around it, and the ones already there feed (the bob is in drawZombie). // packs into a ring around it, and the ones already there feed (the bob is in drawZombie).
// This is the whole point of the death — you go under a CROWD, not under a puff of gore. // This is the whole point of the death — you go under a CROWD, not under a puff of gore.
@@ -591,6 +592,16 @@ export function updateZombies(dt) {
} }
continue; // no shambling, no eating you twice, no crossing the baseline for a point continue; // no shambling, no eating you twice, no crossing the baseline for a point
} }
if (G.mode === 'eaten' && G.eatenAt) {
// The summary is up. They have finished with you: the pack breaks up and shambles away up-court
// instead of standing on the body twitching in a heap for as long as the screen is on.
zb.feeding = false;
const away = Math.sign(zb.x - G.eatenAt.x) || (zb.walkT % 2 < 1 ? 1 : -1);
zb.x += away * zb.v * 0.5 * dt;
zb.z -= zb.v * 0.7 * dt; // back the way they came
zb.face = away;
continue;
}
if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
if (!held && G.mode === 'splatter' && !G.interlude) { if (!held && G.mode === 'splatter' && !G.interlude) {
if (zb.rollTo !== undefined) { if (zb.rollTo !== undefined) {
@@ -679,6 +690,7 @@ export function updateZombies(dt) {
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
} }
} }
if (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked
if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them) if (zb.z >= COURT.L + 0.7) { aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
} }