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:
1 parent
7edd623372
commit
01498eddad
7 files changed
+82
-12
No files matched your search
+4
-2
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -97,6 +97,6 @@
|
||||
<p id="build"></p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=70"></script>
|
||||
<script type="module" src="main.js?v=71"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
+14
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user