Stop the opponent shrinking, skating and sprinting on the spot

The "smaller when swinging or tossing" was an anchor bug, not art. _fine.py
scales a whole sheet so the --anchor frame is 224 px, and drawChar draws every
sprite at img.height/SPRITE_H — so anchoring opponent_play on the TOSS, with
arm and ball above the head, made 224 px cover the raised arm and shrank every
body on that sheet. o_swing1 was 162 px against a 224 px standing pose. Each
opponent batch now leads with a standing `ref` frame, which _fine.py anchors on
and _regen.py discards. Correctly anchored the toss is 278 px and the man is
full size; the swing is 221.

The receiver no longer slides 0.12 m side to side while waiting on a serve —
sliding a sprite sideways reads as skating, and the split-step swap to the run
frames (removed earlier) had already been the other half of that.

The idle bounce ball had one hardcoded offset for both men. They reach to
opposite sides of the screen — the player is drawn from behind, the opponent
from the front — so the opponent's ball sat out by his racket. dx and apex are
per-man now, and o_bounce/o_bounce2 were redrawn with a patting hand to put it
under.

Also: the opponent wears a RED headband, and a prompt of mine that said "white"
had quietly repainted it on six frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 11:37:16 +02:00
1 parent cd1c7a0cd8
commit 494bab1b69
17 files changed
+84 -14

No files matched your search

+17
View File
@@ -467,6 +467,23 @@ the title screen. `menuMusic` passes `48`. Re-encode the file and re-measure:
frames at 48 kHz — an un-round master, and the decoder returns 20.4787, so 7.9 ms of padding is frames at 48 kHz — an un-round master, and the decoder returns 20.4787, so 7.9 ms of padding is
trimmed). Both beds are AAC now; nothing in `assets/audio/` is still a multi-megabyte WAV. trimmed). Both beds are AAC now; nothing in `assets/audio/` is still a multi-megabyte WAV.
**A SPRITE THAT LOOKS "SHRUNK" IS AN ANCHOR BUG, NOT AN ART BUG.** `_fine.py` scales the whole
generated sheet so that `--anchor`'s frame is exactly 224 px, and `drawChar` then draws every sprite
at `img.height / SPRITE_H`. So if the anchor frame is one where a PROP goes above the head, 224 px
has to cover the prop too and every body on that sheet comes out a head short. `opponent_play` was
`[o_toss, o_smash, o_swing1, o_swing2]` with `anchor: 0` — anchored on the TOSS, arm and ball
stretched overhead — which is why the opponent visibly shrank whenever he served or swung
(o_swing1 came out 162 px against a 224 px standing pose). **Every batch must lead with a plain
standing figure via `ref`, which `_fine.py` anchors on and `_regen.py` then discards** — the shape
the player's own batches already used. Correctly anchored, a toss frame is TALLER than 224 (278 px
here) and the body renders at full size. Quick audit: a standing frame should be ~224, a raised-arm
frame >224, and nothing should be well under it.
**The opponent wears a RED headband.** A prompt that said "white headband" silently repainted it on
six frames, and it only showed up against the untouched `o_toss`/`o_idle1`. When regenerating part
of a character, name his colours in the prompt AND check the regenerated frames against an untouched
one — `check: "same"`/`"loose"` skips `colour_match`, so nothing else will catch a palette drift.
**The opponent was running with empty hands.** `o_run1`/`o_run2` had no racket in either fist, and **The opponent was running with empty hands.** `o_run1`/`o_run2` had no racket in either fist, and
the cause was the batch prompt: `opponent_run` never said the word "racket" and its reference sheet the cause was the batch prompt: `opponent_run` never said the word "racket" and its reference sheet
was built from the racket-less frames it was replacing, so the generator had nothing to copy one was built from the racket-less frames it was replacing, so the generator had nothing to copy one
+46 -2
View File
@@ -143,14 +143,16 @@
"opponent_run": { "opponent_run": {
"sprites": [ "sprites": [
"o_run1", "o_run1",
"o_run2" "o_run2",
"o_run3",
"o_run4"
], ],
"ref_only": [ "ref_only": [
"o_idle1" "o_idle1"
], ],
"anchor": 0, "anchor": 0,
"check": "same", "check": "same",
"subject": "The reference is ONE tennis opponent in a RED polo shirt, dark grey shorts and a white headband, seen from the FRONT, gripping a tennis racket in his RIGHT hand. Draw TWO frames of that SAME man, same size, same clothes, same face, same front-facing view, RUNNING across the court towards the camera as two stages of one stride: the first with his left leg forward and his right leg trailing, the second with the legs reversed. In BOTH frames he is STILL HOLDING THE TENNIS RACKET in his right hand, the same racket as the reference \u2014 head, strings, throat and grip all drawn, carried out to his side at about waist height as a runner carries it. His other arm swings free. The racket must be clearly visible and fully inside the canvas in both frames; neither hand is ever empty." "subject": "The reference is ONE tennis opponent in a RED polo shirt, dark grey shorts and a RED HEADBAND, seen from the front, gripping a tennis racket in his right hand. Draw FOUR frames of that same man RUNNING towards the camera. THE FOUR FRAMES MUST BE FOUR DIFFERENT BODY POSITIONS \u2014 no two frames alike, do not repeat a pose: (1) his RIGHT knee lifted high in front of him, left leg stretched out straight behind, left arm forward; (2) his right foot planted flat on the ground under him taking his weight, left knee just beginning to swing forward, both arms low; (3) his LEFT knee lifted high in front of him, right leg stretched out straight behind, right arm forward; (4) his left foot planted flat on the ground under him taking his weight, right knee just beginning to swing forward, both arms low. Every figure is the SAME man as the reference and the SAME size: red polo shirt, dark grey shorts, RED HEADBAND, blond hair, seen from the FRONT. Same face, same colours, same proportions, standing the same height. IN EVERY ONE OF THE FOUR FRAMES he is HOLDING THE TENNIS RACKET in his right hand \u2014 the full racket, with its oval head, its strings, its throat and its grip all drawn, exactly like the reference's racket, carried close in front of his chest. NO FRAME MAY HAVE AN EMPTY RIGHT HAND and no frame may show a racket with the head missing. The whole figure and the whole racket sit fully inside the canvas with clear space around them. The canvas must contain EXACTLY FOUR figures and no others. Do NOT redraw the standing reference pose as an extra figure."
}, },
"ump_boy": { "ump_boy": {
"sprites": [ "sprites": [
@@ -391,5 +393,47 @@
"anchor": 0, "anchor": 0,
"check": "same", "check": "same",
"subject": "The reference is ONE tennis player in a white shirt, blue shorts and a white headband, seen from behind, serving with the racket high in his right hand. Draw FOUR frames of that same man. Every figure is seen strictly FROM BEHIND \u2014 the back of his head and the back of his shirt towards you, NO face, NO eyes, NO nose, NO mouth. Every figure is drawn from exactly the same viewpoint: same camera height, same distance, straight on, standing on the same level ground line, with no perspective distortion, no foreshortening, no worm's-eye or bird's-eye angle and no three-quarter turn. Same size, same body, same style, same BRIGHT WHITE shirt and BRIGHT BLUE shorts as the reference \u2014 do not wash the colours out. He is RIGHT-HANDED and the racket NEVER changes hands: in every single frame the racket is in his RIGHT hand, which \u2014 because he is drawn from behind \u2014 is on the RIGHT-HAND SIDE OF THE IMAGE, exactly as in the reference. His empty left arm is on the LEFT-HAND SIDE OF THE IMAGE. Draw NO ball anywhere in any frame \u2014 the game draws the ball. Frame 1: he STANDS upright and relaxed at the baseline, arms down, the racket hanging at his right side. Frame 2: he leans forward a little and his empty LEFT hand reaches down and out to the LEFT-HAND SIDE OF THE IMAGE, palm down, as if patting a ball on the ground beside his left foot; the racket stays in his right hand at his right side. Frame 3 IS THE SAME DRAWING AS FRAME 2 \u2014 identical stance, identical legs, identical head, identical racket, identical everything \u2014 and the ONLY difference is that his left elbow is bent a little more so that left hand has risen about one hand's width higher, as if the ball had bounced back up into it. Frame 4: the ball toss \u2014 he leans back and throws that same empty LEFT arm up high above him on the LEFT-HAND SIDE OF THE IMAGE, having just released the ball, while the racket stays in his right hand down at his right side. Output exactly four figures in one row, evenly spaced and far apart." "subject": "The reference is ONE tennis player in a white shirt, blue shorts and a white headband, seen from behind, serving with the racket high in his right hand. Draw FOUR frames of that same man. Every figure is seen strictly FROM BEHIND \u2014 the back of his head and the back of his shirt towards you, NO face, NO eyes, NO nose, NO mouth. Every figure is drawn from exactly the same viewpoint: same camera height, same distance, straight on, standing on the same level ground line, with no perspective distortion, no foreshortening, no worm's-eye or bird's-eye angle and no three-quarter turn. Same size, same body, same style, same BRIGHT WHITE shirt and BRIGHT BLUE shorts as the reference \u2014 do not wash the colours out. He is RIGHT-HANDED and the racket NEVER changes hands: in every single frame the racket is in his RIGHT hand, which \u2014 because he is drawn from behind \u2014 is on the RIGHT-HAND SIDE OF THE IMAGE, exactly as in the reference. His empty left arm is on the LEFT-HAND SIDE OF THE IMAGE. Draw NO ball anywhere in any frame \u2014 the game draws the ball. Frame 1: he STANDS upright and relaxed at the baseline, arms down, the racket hanging at his right side. Frame 2: he leans forward a little and his empty LEFT hand reaches down and out to the LEFT-HAND SIDE OF THE IMAGE, palm down, as if patting a ball on the ground beside his left foot; the racket stays in his right hand at his right side. Frame 3 IS THE SAME DRAWING AS FRAME 2 \u2014 identical stance, identical legs, identical head, identical racket, identical everything \u2014 and the ONLY difference is that his left elbow is bent a little more so that left hand has risen about one hand's width higher, as if the ball had bounced back up into it. Frame 4: the ball toss \u2014 he leans back and throws that same empty LEFT arm up high above him on the LEFT-HAND SIDE OF THE IMAGE, having just released the ball, while the racket stays in his right hand down at his right side. Output exactly four figures in one row, evenly spaced and far apart."
},
"opponent_swing": {
"sprites": [
"o_swing1",
"o_swing2"
],
"ref": "o_idle1",
"anchor": 0,
"check": "loose",
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN two frames of a FOREHAND: the racket drawn back across his body, then the follow-through carried through to the other side. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both swing frames he stays UPRIGHT at his full standing height \u2014 head at the same level as the standing frame, back straight, knees only slightly bent. He does not crouch, squat or lunge low. The whole racket is drawn and fully inside the canvas."
},
"opponent_idle": {
"sprites": [
"o_idle1",
"o_idle2"
],
"ref_only": [
"o_idle1"
],
"anchor": 0,
"check": "same",
"subject": "The reference is ONE tennis opponent standing and waiting, gripping a tennis racket in his right hand. Draw TWO frames of that same man waiting to receive, standing at the SAME FULL HEIGHT as the reference: the two frames are one relaxed weight shift, first with his weight on his left foot, then on his right, racket held ready in front of him in both. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED headband, blond hair, seen from the FRONT across the net. Same face, same colours, same proportions. CRITICAL: the ENTIRE racket \u2014 head, strings, throat and grip \u2014 is fully inside the canvas in both frames, with clear empty space between the racket and every edge. Nothing is cut off at an edge. He stands upright and does not crouch. His headband is RED, the same red as his shirt \u2014 it is NEVER white, cream or grey."
},
"opponent_toss": {
"sprites": [
"o_toss",
"o_smash"
],
"ref": "o_idle1",
"anchor": 0,
"check": "loose",
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN the ball TOSS \u2014 standing tall, his free hand stretched straight up above his head having just released the ball, racket back and low. THEN the SMASH \u2014 at full stretch, the racket swung up overhead to hit. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In the toss and smash frames his FEET STAY ON THE GROUND LINE and his body stays the same size as the standing frame; only his arms reach above his head. The whole racket is drawn and fully inside the canvas. His shorts are DARK CHARCOAL GREY, almost black \u2014 the same dark grey as the reference figure standing beside them, definitely NOT pale grey, light grey or blue. His shoes are white. Match the reference palette exactly in every frame."
},
"opponent_bounce": {
"sprites": [
"o_bounce",
"o_bounce2"
],
"ref": "o_idle1",
"anchor": 0,
"check": "loose",
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN TWO frames of him standing at the baseline BOUNCING A BALL on the ground before serving, with NO ball drawn. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both bouncing frames his racket is held out to the side in his RIGHT hand \u2014 which is on the LEFT-HAND SIDE OF THE IMAGE \u2014 and his other arm is stretched DOWN AND OUT to the RIGHT-HAND SIDE OF THE IMAGE, well clear of his body, with the palm open and facing the ground as if patting a ball. CRITICAL: the two bouncing frames are otherwise the IDENTICAL drawing \u2014 same stance, same legs, same head, same racket, same size \u2014 and the ONLY difference between them is the height of that patting hand: in the first it is low, near knee height; in the second it is higher, near his waist. Draw NO ball in any frame."
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+5 -1
View File
@@ -372,7 +372,11 @@ export const POSE_TIME = 0.38;
// Measured off the sprite, not guessed: the player is RIGHT-handed, so he pats the ball with his // Measured off the sprite, not guessed: the player is RIGHT-handed, so he pats the ball with his
// LEFT hand — the left-hand side of the image — whose palm sits ~0.69 m above his feet and ~0.36 m // LEFT hand — the left-hand side of the image — whose palm sits ~0.69 m above his feet and ~0.36 m
// off his centre line. The ball tops out just under that hand instead of beside it. // off his centre line. The ball tops out just under that hand instead of beside it.
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6 }; // dx/apex are per-man because the two sprites reach differently, and a single pair put the
// opponent's ball out by his racket instead of under his palm. Both are FEEL KNOBS measured off the
// drawn palm by eye — a skin-detector guess at the hand lands on a knee, so tune these by looking.
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6, // the player, drawn from behind
oDx: 0.45, oApex: 0.55 }; // the opponent, drawn from the front
// 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
@@ -117,6 +117,6 @@
<div id="courts"><h2>CHOOSE YOUR COURT</h2></div> <div id="courts"><h2>CHOOSE YOUR COURT</h2></div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=87"></script> <script type="module" src="main.js?v=88"></script>
</body> </body>
</html> </html>
+15 -10
View File
@@ -151,7 +151,7 @@ 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 = 75; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPRV = 76; // 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_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn 'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -470,9 +470,10 @@ function drawPiles(near) { // split at the net like zombies so height occludes c
} }
function drawChar(ch) { function drawChar(ch) {
const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle'; // No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0; // of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
const p = proj(ch.x + sway * 0.12, 0, ch.z); // as the man skating — and the idle pair's own weight shift is the whole of what he should be doing.
const p = proj(ch.x, 0, ch.z);
const hpx = PLAYER_H * p.s; const hpx = PLAYER_H * p.s;
// pick sprite // pick sprite
const pre = ch.side === 'P' ? 'p_' : 'o_'; const pre = ch.side === 'P' ? 'p_' : 'o_';
@@ -495,7 +496,8 @@ function drawChar(ch) {
// ZFRAMES does for the shamblers. // ZFRAMES does for the shamblers.
const runN = SPR[pre + 'run4'] ? 4 : 2; const runN = SPR[pre + 'run4'] ? 4 : 2;
if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * runN) % runN + 1); if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * runN) % runN + 1);
if (receiverWaiting) name = pre + (sway > 0 ? 'run1' : 'run2'); // split-step shuffle // (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'); if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash'); if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash');
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob
@@ -905,11 +907,14 @@ export function render() {
// server idly bouncing the ball while waiting // server idly bouncing the ball while waiting
if (G.phase === 'pretoss') { if (G.phase === 'pretoss') {
const sv = score.server === 'P' ? P : O; const sv = score.server === 'P' ? P : O;
// The ball goes where the sprite reaches for it. The player is right-handed and drawn from // The ball goes where the sprite reaches for it, and the two men reach to OPPOSITE SIDES of the
// behind, so the racket is on the viewer's right and the hand that pats the ball is his LEFT — // screen. Both are right-handed, so both pat the ball with the left hand — but the player is
// the viewer's left. The opponent is drawn from the front and already reached that way. // drawn from BEHIND (his left hand is on the viewer's left) and the opponent from the FRONT
const bx = sv.x - BOUNCE_IDLE.dx; // (his left hand is on the viewer's right). One sign for both put the opponent's ball out by
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; // his racket instead of under his palm.
const mine = sv.side === 'P';
const bx = sv.x + (mine ? -BOUNCE_IDLE.dx : BOUNCE_IDLE.oDx);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? BOUNCE_IDLE.apex : BOUNCE_IDLE.oApex);
drawBall(bx, by, sv.z); drawBall(bx, by, sv.z);
} }
// the match ball (and any triple-serve side balls in flight) // the match ball (and any triple-serve side balls in flight)