The run cycles anchor on their feet, and stride at a real cadence

Oliver: 'the motion seems very flashy and unnatural'. Two causes, both
measured: drawChar centres a frame by its total width while the racket's
reach varies across the cycle, so the BODY teleported sideways up to 28
source px on a frame swap (the female player was worst) — every frame is now
padded until its lower-third centroid sits at the frame centre (residuals
±0.2 px, the normalisation lives in _runroll.py's cut). And the hardcoded
4.5 cycles/s was tuned on 2-4 near-duplicate frames; on eight real phases it
was a strobe — rolled sets stride at TUNE.runHz (2.4), old art keeps 4.5.

In-pane: body wobble down from ~36 device px per swap to 3.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 18:53:22 +02:00
1 parent 9a49acff51
commit e8b5c1905f
32 files changed
+27 -5

No files matched your search

+18
View File
@@ -176,6 +176,24 @@ def cut(k, install=False, n=0):
os.makedirs(outdir, exist_ok=True)
outs = [os.path.join(outdir, f'{pre}{i}{suf}.png') for i in range(1, got + 1)]
_fine.main(sheet, outs, TARGET_H, [], -1)
# ANCHOR EVERY FRAME ON ITS FEET. drawChar centres a frame by its total width, and the racket's
# reach varies wildly across a cycle (the cut female frames measured -28..+15 px of feet drift
# from frame centre) — so the BODY teleported sideways every frame, which is exactly Oliver's
# "very flashy and unnatural". Pad each frame with transparency until the lower-third centroid
# (the same surface the stride gate reads) sits at the frame's horizontal centre.
import numpy as np
for o in outs:
im = Image.open(o).convert('RGBA')
a = np.array(im)
op = a[..., 3] > 40
ys, xs = np.nonzero(op[int(op.shape[0] * 0.66):])
if not len(xs): continue
cx = xs.mean()
off = round(2 * (cx - im.width / 2)) # feet right of centre -> pad RIGHT, and vice versa:
if off == 0: continue # new centre (w+|off|)/2 then lands on the feet
pad = Image.new('RGBA', (im.width + abs(off), im.height), (0, 0, 0, 0))
pad.paste(im, (0 if off > 0 else abs(off), 0))
pad.save(o)
# THE STRIDE GATE, on what was actually cut: every neighbouring pair (wrapping — it loops) must
# differ in the legs, or the cycle holds a duplicated pose. Threshold calibrated against
# _famref's (30% = a real stride pair; 12 catches only true near-duplicates).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

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: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

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: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

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: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

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: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=215"></script>
<script type="module" src="src/main.js?v=216"></script>
</body>
</html>
+2
View File
@@ -41,6 +41,8 @@ export const TUNE = {
// the dive is what buys the pace its net clearance
serveHardErr: 1.6,// how much a hard serve multiplies the timing scatter: pace is paid for in
// faults, which is what makes tap = safe second serve a real decision
runHz: 2.4, // stride cycles/s for the rolled 6/8-frame run sets. The old hardcoded 4.5 was
// tuned on 2-4 near-duplicate frames; on eight real phases it strobed
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
+6 -4
View File
@@ -221,7 +221,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 109;
export const SPRV = 110;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
@@ -1445,10 +1445,12 @@ function drawChar(ch) {
name = 'p_pull';
// The run cycle at whatever count the character's sheet carries: the _runroll.py cycles are 6
// (one call, one row, one character — see assets/_runroll.py for why the old per-frame art
// flickered); un-rolled characters fall back to 4 or 2. Same CYCLE rate at every count — more
// frames subdivide the stride, they do not speed it up, exactly as ZFRAMES does for the shamblers.
// flickered); un-rolled characters fall back to 4 or 2.
const runN = SPR[pre + 'run8'] ? 8 : SPR[pre + 'run6'] ? 6 : SPR[pre + 'run4'] ? 4 : 2;
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * runN) % runN + 1);
// The rolled 6/8-frame cycles run at their own STRIDE RATE (TUNE.runHz, cycles/s). The old 4.5
// was tuned on 2-4 near-duplicate frames, where it read as vigour — on eight real phases it was
// a strobe ("very flashy and unnatural"), most of a full stride every display frame.
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * (runN >= 6 ? TUNE.runHz : 4.5) * runN) % runN + 1);
// (the receiver used to flip to run1/run2 here as a "split-step" — it read as a man sprinting on
// the spot, because the run frames are a full leaning stride, not a ready-hop. He idles now.)
if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');