Draw the whole serve right-handed, and let the pull wind it up

The racket kept changing hands because the art disagreed with itself: p_idle,
p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his
right, so every new frame copied whichever reference it was handed. The idle, the
bounce pair, the toss and the four wind-up frames are all regenerated with the
side named — drawn from behind, his right hand is the right-hand side of the
image, and only the racket HEAD crosses behind his back.

What separates the four wind-up stages is how far the racket has fallen, and that
needed the racket head pinned to a body landmark per frame — above his head,
beside his ear, between his shoulder blades, at the back of his thighs. Described
as "further into it each time" it drew four near-identical poses twice running.
They are wired late in the pull, Oliver's call: frame 1 owns the first half and
the three coiled frames are packed into the top half.

assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is
always worse than regenerating; three guards in _regen.py turn the bad rolls that
used to install silently into automatic re-rolls — a portrait reference canvas
(the generator answered with a 2x2 grid), a non-magenta background (a green sheet
keyed away four racket heads), and a mirrored frame.

The bounce pair is one drawing from one sheet now, differing only in the free
hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 00:49:28 +02:00
1 parent 231cd8650a
commit bcff17812f
17 files changed
+181 -170

No files matched your search

+17 -15
View File
@@ -155,7 +155,7 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 70; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 71; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
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',
'p_pull1','p_pull2','p_pull3','p_pull4', // the serve WIND-UP, one frame per quarter of the pull (see drawChar)
@@ -479,19 +479,21 @@ function drawChar(ch) {
const pre = ch.side === 'P' ? 'p_' : 'o_';
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') {
// The two bounce frames are ONE drawing whose free hand sits 0.10 m apart (p_bounce2 is sheared
// out of p_bounce, see assets/_bendarm.py) — generated as a pair they came back as two different
// 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.
// The two bounce frames are the SAME drawing from one sheet — identical stance, identical legs,
// and only the free hand's height and reach differ — so the swap cannot read as a flicker. It
// follows the BALL: hand up when the ball is up at it, hand out and low when the ball is near
// the ground. BOUNCE_IDLE.apex is measured off that palm, so the ball meets 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)));
// finger has come: racket above the head -> behind the head -> behind the back -> fully coiled,
// and the release snaps to p_smash. The stages are NOT spread evenly over the pull: frame 1 owns
// the whole first half and the three coiled frames are packed into the top half, so the part of
// the gesture that costs power is the part that visibly costs effort.
if (G.aim && G.aim.mag > 0 && ch.side === 'P' && score.server === 'P' && ch.pose === 'idle') {
const f = pullFrac(G.aim.mag);
name = 'p_pull' + (f < 0.5 ? 1 : Math.min(4, 2 + Math.floor((f - 0.5) * 6)));
}
// 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
// ZFRAMES does for the shamblers.
@@ -899,10 +901,10 @@ export function render() {
// server idly bouncing the ball while waiting
if (G.phase === 'pretoss') {
const sv = score.server === 'P' ? P : O;
// The ball goes where the sprite reaches for it. Drawn from behind, the player bounces it beside
// his RIGHT foot, which is the viewer's right — the old sign put it on his left while the new art
// reached the other way, so the ball and the hand disagreed.
const bx = sv.x + BOUNCE_IDLE.dx * (sv.side === 'P' ? 1 : -1);
// The ball goes where the sprite reaches for it. The player is right-handed and drawn from
// behind, so the racket is on the viewer's right and the hand that pats the ball is his LEFT —
// the viewer's left. The opponent is drawn from the front and already reached that way.
const bx = sv.x - BOUNCE_IDLE.dx;
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex;
drawBall(bx, by, sv.z);
}