diff --git a/assets/_regen.py b/assets/_regen.py index e5a39e9..c974201 100644 --- a/assets/_regen.py +++ b/assets/_regen.py @@ -82,7 +82,8 @@ def colour_match(src, dst_name): def run(name, spec, dry=False): os.makedirs(SCRATCH, exist_ok=True) 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' prompt = f"{spec['subject']} {STYLE}" 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 if touches_edge(sheet): 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 [] 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], @@ -102,6 +103,7 @@ def run(name, spec, dry=False): print(r.stdout.strip() or r.stderr.strip()) 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 + if lead: outs = outs[1:] # the scale reference is not installed before = [density(os.path.join(HERE, s + '.png')) for s in sprites] 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 diff --git a/assets/_regen_batches.json b/assets/_regen_batches.json index cdbc423..fbf6788 100644 --- a/assets/_regen_batches.json +++ b/assets/_regen_batches.json @@ -104,8 +104,9 @@ "p_swing1", "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.", - "anchor": 0 + "ref": "p_idle1", + "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": { "sprites": [ @@ -114,8 +115,9 @@ "p_run3", "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.", - "anchor": 0 + "ref": "p_idle1", + "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": { "sprites": [ @@ -231,5 +233,46 @@ ], "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." + }, + "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." } } \ No newline at end of file diff --git a/config.js b/config.js index 0856beb..2cfbd85 100644 --- a/config.js +++ b/config.js @@ -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 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 + 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 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 diff --git a/gameplay.js b/gameplay.js index 1ddfba8..b2f99f7 100644 --- a/gameplay.js +++ b/gameplay.js @@ -746,6 +746,7 @@ function racketAI(rz, dt) { const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); if (d > 0.15) { 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; if (rz.pose !== 'swing') rz.pose = 'run'; if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1; diff --git a/index.html b/index.html index 225d8bc..14ed24b 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,6 @@

- + diff --git a/render.js b/render.js index 2a905d5..da51871 100644 --- a/render.js +++ b/render.js @@ -232,12 +232,15 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) { } // ----------------------------------------------------------------- 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 p = proj(x, 0, z); const h = hMeters * p.s, w = h * img.width / img.height; 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) { // 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; 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 + // 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 + : 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 // WALKED, like its wound lurch below, // 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. 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; - 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.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 diff --git a/zombies.js b/zombies.js index 82cab18..9427721 100644 --- a/zombies.js +++ b/zombies.js @@ -3,7 +3,7 @@ // your baseline — one crossing it is a point for THEM. // (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 { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js'; @@ -542,6 +542,7 @@ export function updateZombies(dt) { const zb = zombies[i]; if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us 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 (!held) zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt; 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 } } - 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 // 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. @@ -591,6 +592,16 @@ export function updateZombies(dt) { } 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 (!held && G.mode === 'splatter' && !G.interlude) { 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 } } + 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) }