Wind the serve up with the pull, and stop the frames flickering

Four new frames (p_pull1..4) turn the slingshot drag into the serve's wind-up:
racket above the head, behind the head, behind the back, fully coiled, picked off
pullFrac — the same 0..1 that sets the shot's power — so the gesture and the man
are one movement and the release snaps to the smash.

The two bounce frames are one drawing now. p_bounce2 is p_bounce with the free
arm sheared up 12px and nothing else touched, so the swap cannot flicker, and it
follows the ball instead of its rising phase; BOUNCE_IDLE was re-measured off the
sprite so the ball tops out under the palm. p_toss had the tossed ball drawn into
it — cut, canvas height kept, because the game scales him off img.height.

The runner: hip centres sat -2.0/+7.1/-6.0/-0.1 px off the sprite centre, a 13px
lateral jump at 12 frames a second, and Oliver's diagnosis was right that all four
frames leaned the same way and lifted the same leg. Frames 3 and 4 mirrored, all
four padded to one width with the hip on the centre line.

assets/_derive.py holds the three derivations, since "the same drawing with one
small difference" is the one thing the generator cannot draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-31 23:51:30 +02:00
1 parent f71d9293fc
commit b1f8ea3240
16 files changed
+148 -8

No files matched your search

+41 -1
View File
@@ -5,7 +5,7 @@ everything they specified is shipped, and what still matters from them is record
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log. detail of any individual change, read the git log.
Last updated 2026-07-31. **Start with "How to work on this", "OPEN — leftovers" and "LATER — Last updated 2026-07-31 (serve wind-up pass). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
@@ -384,6 +384,18 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
survives as a rim on a green court. survives as a rim on a green court.
12. **Two-row "style reference / redraw this" input does NOT work.** The model returns both rows and 12. **Two-row "style reference / redraw this" input does NOT work.** The model returns both rows and
invents a background. Describe the target detail level in words instead. invents a background. Describe the target detail level in words instead.
13. **`assets/_derive.py` is where a frame that must MATCH another frame is made.** Three of them
live there now: `p_bounce2` (a per-column shear of `p_bounce` — the free arm bends, nothing else
moves), `p_toss` (the drawn-in ball cut out, canvas height KEPT so drawChar keeps his scale) and
the runner's hip registration. "Same drawing, one small difference" is the one request the
generator cannot fill: it returns a different man and the swap reads as a flicker, which is the
defect you were fixing. The bounce/toss steps are idempotent; the runner step is `--runner` and
must run exactly once per generated set, because it mirrors and it pads.
14. **A new pose needs a STANDING frame in the same sheet, as frame 0, thrown away after.** A sprite's
drawn size comes from its total height, so a sheet of nothing but arms-overhead poses has no
honest scale to anchor on and the man comes out short. `_regen.py`'s `ref` field does this, but
only when `ref_only` is unused — with `ref_only` set, list the throwaway as an extra sprite
(`p_pull0`) and delete the PNG afterwards.
Known generator quirks, all still true: Known generator quirks, all still true:
- It sometimes ignores the magenta (harmless — the key uses the corner colour). - It sometimes ignores the magenta (harmless — the key uses the corner colour).
@@ -397,6 +409,34 @@ Known generator quirks, all still true:
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel - **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number. linear map onto the original's mean and sd, then verify with a mean-difference number.
## Session log: the serve is the gesture (2026-07-31, after the art pass)
Oliver's notes on the art pass: the zombies are good, the runner "flickers very hard", and the
player has three problems. All four fixed, all four measured first.
- **The pull IS the wind-up.** Four new frames (`p_pull1..4`, `assets/_regen_batches.json` batch
`player_pull`) — racket above the head, behind the head, behind the back, fully coiled — chosen in
`drawChar` off `pullFrac(G.aim.mag)`, the same 0..1 that sets the shot's power. Drag further, coil
further; release fires `p_smash`. Verified per stage with drawImage instrumentation:
mag 10/50/90/130 -> pull1/pull2/pull3/pull4.
- **The bounce pair is now ONE drawing.** `p_bounce2` is `p_bounce` with the free arm sheared up 12
px (0.10 m) — everything else is pixel-identical, so the swap cannot flicker. The swap follows the
BALL now (`|sin| > 0.5`) instead of its rising phase, and `BOUNCE_IDLE` was re-measured off the
sprite (`apex` 0.55 -> 0.6, `dx` 0.5 -> 0.43) so the ball tops out under the raised palm instead of
passing beside it.
- **`p_toss` had the tossed ball drawn into it.** Cut, without re-cropping the canvas: the game
scales him off `img.height`, so trimming the dead space would have shrunk him. Still 100x308.
- **The runner.** Two measured defects, both in the PNGs, no code change. Its four frames were
bbox-cropped and drawn centred on the body's x, so hip centres sat -2.0/+7.1/-6.0/-0.1 px off
centre — a 13 px lateral jump at 12 frames a second, which is the flicker. And Oliver's own
diagnosis was right: all four frames leaned the same way and lifted the SAME leg, so the cycle
never used his other side. Frames 3 and 4 are mirrored (he is front-on, so a mirror costs nothing)
and all four are padded to one width with the hip on the centre line.
Not done, and the honest reason: the runner's cadence is still time-driven (12 fps) rather than
stepped off distance covered like every other body. It did not read as wrong once the frames stopped
jumping, and it is one line if a playtest says otherwise.
## Session log: the art-resolution pass (2026-07-31) ## Session log: the art-resolution pass (2026-07-31)
**What changed.** The whole character roster was redrawn at real pixel-art resolution, plus a batch **What changed.** The whole character roster was redrawn at real pixel-art resolution, plus a batch
+71
View File
@@ -0,0 +1,71 @@
# _derive.py — the frame fixes that must NOT go through the generator.
#
# python3 assets/_derive.py
#
# Three sprites are derived from other sprites by arithmetic, because the thing that matters about
# them is that they stay pixel-identical to their neighbour. Ask the generator for "the same drawing
# with one small difference" and it returns a different man, and the swap reads as a flicker — which
# is exactly the bug each of these fixes. Re-run this after regenerating p_bounce or the runner;
# every step is idempotent, so running it twice changes nothing.
import numpy as np
from PIL import Image
import os
A = os.path.dirname(os.path.abspath(__file__)) + '/'
load = lambda n: np.array(Image.open(A + n + '.png').convert('RGBA'))
save = lambda a, n: Image.fromarray(a, 'RGBA').save(A + n + '.png')
# 1. p_toss — the generator drew the tossed ball into the sprite; the game draws the real ball.
# The CANVAS keeps its height: drawChar scales the man off img.height, so re-cropping the dead
# space would shrink him.
a = load('p_toss')
rows = (a[..., 3] > 128).any(1)
cut = int(np.argmax(~rows)) # the ball floats clear above the raised hand
if cut and not rows[cut:cut + 8].any():
a[:cut] = 0; save(a, 'p_toss')
print(f'p_toss: ball removed (rows 0..{cut})')
else:
print('p_toss: already ball-free')
# 2. p_bounce2 — p_bounce with the free arm bent so the hand rises 12 px (0.10 m) to meet the ball
# at the top of its bounce. A per-column shear, pinned at the shoulder and rigid from the wrist out,
# so the forearm bends instead of the hand detaching.
X0, K, MAX = 96, 0.8, 12
b = load('p_bounce'); out = b.copy()
for x in range(X0, b.shape[1]):
s = min(MAX, round(K * (x - X0)))
if s: out[:-s, x] = b[s:, x]; out[-s:, x] = 0
save(out, 'p_bounce2')
print('p_bounce2: derived from p_bounce')
# 3. z_f1..4 — the runner, with `--runner`. This one is NOT idempotent (it mirrors, and it pads),
# so it is opt-in: run it exactly once on a freshly generated set of runner frames.
# Two defects, both measured. The generator drew all four frames leaning the same way and lifting
# the SAME leg, so the cycle never used his other side — mirroring frames 3 and 4 gives an
# alternating stride out of the poses that exist (he is front-on, so a mirror is free). And each
# frame is cropped to its own bbox and drawn centred on the body's x, so a swinging arm slid the
# whole man sideways: hip centres sat -2.0/+7.1/-6.0/-0.1 px off centre, a 13 px lateral jump at 12
# frames a second — that was the hard flicker. Pad every frame to one width with the HIP on the
# centre line. Padding is free: drawStatic derives the drawn width from img.width, so the figure
# inside keeps its scale and only its anchor moves.
import sys
if '--runner' not in sys.argv:
print('z_f1..4: skipped (pass --runner; it mirrors and pads, so it must run only once)')
raise SystemExit
NS = ['z_f1', 'z_f2', 'z_f3', 'z_f4']
for n in NS[2:]:
save(np.array(Image.open(A + n + '.png').convert('RGBA').transpose(Image.FLIP_LEFT_RIGHT)), n)
print(f'{n}: mirrored — the other leg leads')
ims = [load(n) for n in NS]
def hip(a): # midpoint of the hip band, not the mean: an arm
al = a[..., 3] > 128 # crossing the band would drag a mean sideways
xs = np.nonzero(al[int(al.shape[0] * .50):int(al.shape[0] * .62)].any(0))[0]
return (xs.min() + xs.max()) / 2
hips = [hip(a) for a in ims]
W = int(np.ceil(max(max(h, a.shape[1] - h) for h, a in zip(hips, ims)) * 2))
for n, a, h in zip(NS, ims, hips):
left = int(round(W / 2 - h))
o = np.zeros((a.shape[0], W, 4), np.uint8)
o[:, left:left + a.shape[1]] = a
save(o, n)
print(f'z_f1..4: hip-registered onto {W}px')
+17
View File
@@ -409,5 +409,22 @@
"check": "loose", "check": "loose",
"scale": 1.4, "scale": 1.4,
"subject": "The reference is an enormous fat zombie brute \u2014 huge belly, stained shirt, brown trousers. Draw TWO frames of that SAME body at that SAME enormous size and bulk, standing, as two stages of damage: (1) both arms gone and the chest and belly torn wide open with the ribs showing through; (2) all of that AND no head at all \u2014 the neck ends in a ragged red stump at shoulder height, no skull, no face, nothing above the shoulders. Do not slim him down. Output exactly two figures in one row, far apart." "subject": "The reference is an enormous fat zombie brute \u2014 huge belly, stained shirt, brown trousers. Draw TWO frames of that SAME body at that SAME enormous size and bulk, standing, as two stages of damage: (1) both arms gone and the chest and belly torn wide open with the ribs showing through; (2) all of that AND no head at all \u2014 the neck ends in a ragged red stump at shoulder height, no skull, no face, nothing above the shoulders. Do not slim him down. Output exactly two figures in one row, far apart."
},
"player_pull": {
"sprites": [
"p_pull0",
"p_pull1",
"p_pull2",
"p_pull3",
"p_pull4"
],
"ref_only": [
"p_idle1",
"p_toss",
"p_smash"
],
"anchor": 0,
"check": "same",
"subject": "The reference shows the SAME tennis player three times \u2014 white shirt, blue shorts, white headband, seen FROM BEHIND: standing, tossing, and smashing. Draw FIVE frames of that same player, same size, same style, same BRIGHT WHITE shirt and BRIGHT BLUE shorts \u2014 do not wash the colours out. Every figure is seen strictly FROM BEHIND, the back of his head towards you, NO face, NO eyes, NO nose. Frame 1 is him simply STANDING upright and relaxed, arms down, racket in his right hand \u2014 a plain standing frame. Frames 2 to 5 are four stages of ONE continuous serve wind-up and each must be clearly further into it than the one before. Through all four his LEFT arm stays thrown up high above him where he has just released the ball, and NO BALL is drawn anywhere \u2014 the game draws the ball. Frame 2: standing tall, the racket held UP ABOVE HIS HEAD in his right hand, body straight. Frame 3: the racket has dropped behind his head, his back beginning to arch backwards, knees slightly bent. Frame 4: the racket is down behind his back, his back clearly arched backwards, chest pushed up and out, knees bent. Frame 5: fully coiled \u2014 the racket hangs low behind his back near his waist, his back arched hard backwards, both knees deeply bent, weight back on his heels, ready to explode upwards. Output exactly five figures in one row, evenly spaced and far apart."
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+3 -1
View File
@@ -333,7 +333,9 @@ export const POSE_TIME = 0.38;
// the server's idle ball bounce: sideways offset to the free hand, apex height, tempo. // the server's idle ball bounce: sideways offset to the free hand, apex height, tempo.
// tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync. // tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync.
export const BOUNCE_IDLE = { dx: 0.5, apex: 0.55, tempo: 3.6 }; // Measured off the sprite, not guessed: in p_bounce2 the raised palm sits 0.63 m above his feet and
// 0.43 m to the side of his centre line, so the ball tops out just under the hand instead of beside it.
export const BOUNCE_IDLE = { dx: 0.43, apex: 0.6, tempo: 3.6 };
// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it // generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it
export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline
+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=76"></script> <script type="module" src="main.js?v=77"></script>
</body> </body>
</html> </html>
+15 -5
View File
@@ -3,7 +3,7 @@
import { cv, ctx } from './dom.js'; import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js'; import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js'; import { predictLanding, flickTarget, pullFrac } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
// ---------------------------------------------------------------- palette ---- // ---------------------------------------------------------------- palette ----
@@ -155,9 +155,10 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ---- // ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load. // assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 69; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPRV = 70; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {}; const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5', for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull1','p_pull2','p_pull3','p_pull4', // the serve WIND-UP, one frame per quarter of the pull (see drawChar)
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_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_crowd2','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', // the push cycle, used only while he is stopped and working 'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
@@ -478,10 +479,19 @@ function drawChar(ch) {
const pre = ch.side === 'P' ? 'p_' : 'o_'; const pre = ch.side === 'P' ? 'p_' : 'o_';
let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift
if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') { if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') {
const w = G.idleT * BOUNCE_IDLE.tempo; // same phase as the bouncing ball // The two bounce frames are ONE drawing whose free hand sits 0.10 m apart (p_bounce2 is sheared
const rising = Math.sin(w) * Math.cos(w) > 0; // out of p_bounce, see assets/_bendarm.py) — generated as a pair they came back as two different
name = pre + (rising ? 'bounce2' : 'bounce'); // hand up while the ball rises, pat down exactly at the apex // men and the swap read as a flicker. So the swap can follow the BALL: hand up when the ball is
// up at it, hand down when the ball is near the ground. BOUNCE_IDLE.apex is measured off the
// raised hand, so the ball meets the palm rather than passing it.
name = pre + (Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) > 0.5 ? 'bounce2' : 'bounce');
} }
// THE PULL IS THE WIND-UP. While the slingshot is drawn, the frame is chosen by how far back the
// finger has come: racket above the head -> behind the head -> behind the back -> fully coiled.
// Four drawn stages over pullFrac (the same 0..1 that sets the shot's power), so the gesture and
// the man are the same movement, and the release snaps to p_smash.
if (G.aim && G.aim.mag > 0 && ch.side === 'P' && score.server === 'P' && ch.pose === 'idle')
name = 'p_pull' + (1 + Math.min(3, Math.floor(pullFrac(G.aim.mag) * 4)));
// Four-frame run where the frames exist (the player), two where they don't (the opponent). Same // Four-frame run where the frames exist (the player), two where they don't (the opponent). Same
// CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as // CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as
// ZFRAMES does for the shamblers. // ZFRAMES does for the shamblers.