diff --git a/HANDOFF.md b/HANDOFF.md index a6b4475..c02542a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -409,41 +409,66 @@ Known generator quirks, all still true: - **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. -## Session log: the serve is the gesture (2026-07-31, after the art pass) +## Session log: the serve is the gesture (2026-08-01) 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. +player has three problems. The serve is now a drawn wind-up driven by the pull. -- **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 racket must stay in ONE hand across a whole sheet, and the generator will not do it unasked.** - Oliver, straight off the file browser: *"the racket changes the hand in 3 and 4"*. It did — frames - 1-2 came back right-handed and 3-4 left-handed. Both mirrored into line (a back-view figure mirrors - for free) and the batch prompt now names the side explicitly. The convention the rest of the art - uses: **racket on the LEFT-HAND SIDE OF THE IMAGE, free arm on the RIGHT** — which is also the side - the game drops the bounced ball (`BOUNCE_IDLE.dx` > 0) and tosses the served one (`P.x + 0.35`). - `p_smash` is the deliberate exception: the racket comes over to the right to meet the ball it is - actually hitting. -- **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. +- **The pull IS the wind-up.** Four new frames (`p_pull1..4`, batch `player_windup`), chosen in + `drawChar` off `pullFrac(G.aim.mag)`. What separates the four is HOW FAR THE RACKET HAS FALLEN, and + that only came out once the prompt pinned the racket head to a different BODY LANDMARK per frame — + above his head, beside his ear, between his shoulder blades, down at the back of his thighs. + Described as "further into the wind-up each time" it drew four near-identical poses twice running + (Oliver: *"pull3 and pull4 are now basically the same image"*), which is the generator quirk the + pipeline notes already warned about, in a place I did not expect it. The stages are NOT spread evenly: **frame 1 owns the whole + first half of the pull and the three coiled frames are packed into the top half**, Oliver's call, + so the part of the gesture that costs power is the part that visibly costs effort. Release fires + `p_smash`. Verified: 30/74/95/125 px of drag -> pull1/2/3/4. +- **The whole player is RIGHT-HANDED now, and that was the real bug.** `p_idle`, `p_bounce` and + `p_toss` had been drawn holding the racket in his LEFT hand while `p_smash` and `p_swing` used his + right, so any new frame copied whichever reference it was given and the racket changed hands + mid-serve. Everything from the idle to the wind-up was regenerated with the hand named explicitly: + **drawn from behind, his right hand is the RIGHT-HAND SIDE OF THE IMAGE**. Only the racket HEAD + crosses behind his back in the deep frames — the arm never does. +- **The bounce pair is one drawing from one sheet**, differing only in the free hand, so the swap + cannot flicker; it follows the BALL (`|sin| > 0.5`) rather than its rising phase. He pats the ball + with his LEFT hand now, so `BOUNCE_IDLE` moved to the other side and was re-measured off the sprite + (`dx` 0.36, `apex` 0.68). The served toss moved to `+0.12` — nearly over his head, because the + tossing hand is on the left and the racket meets it on the right, and a big offset either way is + wrong for one of those two frames. +- **The runner** still carries a hand-mirrored fix from the previous commit (see OPEN) — its frames + need regenerating, not mangling. -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. +**THE RULE, from Oliver, that this session existed to learn:** *"fixing generated art by mangling +with it afterwards is always worse than regenerating. it's just a fact."* Mirroring a frame, +shearing a limb, cutting a prop out — a wrong frame is wrong in more ways than the one you spotted +(perspective, foreshortening, where the weight sits) and none of those survive a flip. There was an +`assets/_derive.py` doing exactly that; it is deleted. Fix the PROMPT and re-run the batch. + +**Three pipeline guards were added to `_regen.py`, each after a bad sheet installed silently:** +1. **The reference's ASPECT is the answer's aspect.** One tall reference figure gave a portrait + reference canvas and the generator answered with a 2x2 GRID, which `_fine.py` cannot cut (it + segments columns). `ref_sheet` now widens to a landscape strip with one slot per frame asked for. +2. **A non-magenta background is a rejected roll.** "It sometimes ignores the magenta" was written + off as harmless because the key uses the corner colour — until it returned a GREEN sheet and keyed + away four grey-green racket heads with it, installing four men holding a grip and nothing else. +3. **`arm_left` rejects a MIRRORED frame.** The generator flips the later frames of a serve about + half the time, which puts the racket in the other hand halfway through the animation. Oliver + caught it twice by eye before the check existed. **It is a net, not a proof**: it asks whether + anything reaches the top of the LEFT half, and a mirrored frame whose racket head hangs over there + passes. A tighter discriminator was tried (ink mass in the top third, left vs right) and does NOT + separate the two — measured 0.61/0.57/0.41 on a mirrored sheet against 0.54/0.56/0.55 on a good + one. Every wind-up sheet still has to be looked at. + +Still imperfect, and worth a look on the phone: the empty tossing arm WANDERS between the four +wind-up frames (up, out, bent, up) instead of staying thrown up at the ball. The roll that held the +arm still put the racket back in the wrong hand, and the racket is the load-bearing half of the +gesture, so this is the set that shipped. One more roll may fix both. + +Also worth knowing: a batch of raised-arm poses has no honest scale to anchor on, so frame 0 is +always a throwaway STANDING frame (`p_pull0`, deleted after installing). And a photo reference of a +real serve is what finally got the poses right — Oliver supplied a 10-frame sequence, and the four +stages are its frames 3, 4, 5 and 6, the last being maximum tension before release. ## Session log: the art-resolution pass (2026-07-31) @@ -582,12 +607,19 @@ Nothing here is broken; these are known gaps and unverified risks. 8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever clearing the court means you face the current chapter, not a backlog. Deliberate; noted in case it ever reads as a bug. -9. **Step 6 stays PARKED** by Oliver's decision: the crowd turning into zombies (spawning from the +9. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are currently + hip-registered (padded to one width with the hip on the centre line) and frames 3 and 4 are + mirrored copies, because all four were drawn leaning the same way and lifting the same leg. That + is exactly the post-processing Oliver ruled out — it shipped before he stated the rule. The batch + to write asks for a four-frame front-on sprint whose stride and lean ALTERNATE, and the numbers to + check afterwards are the per-frame hip centroid (it was 13 px of lateral jump at 12 frames a + second) and which leg leads. +10. **Step 6 stays PARKED** by Oliver's decision: the crowd turning into zombies (spawning from the stands — attacks from behind, high spectacle, `x_crowd` sheets already exist) and the ball-boy zombie that catches your shots. Recorded so the ideas are not lost, not as a to-do. -10. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the +11. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the closest match. Worth one confirmation that no other line is still unwanted. -11. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing — +12. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing — every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`): - the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue. diff --git a/assets/_derive.py b/assets/_derive.py deleted file mode 100644 index 8db2f68..0000000 --- a/assets/_derive.py +++ /dev/null @@ -1,71 +0,0 @@ -# _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') diff --git a/assets/_regen.py b/assets/_regen.py index 583e876..54cc935 100644 --- a/assets/_regen.py +++ b/assets/_regen.py @@ -32,7 +32,9 @@ STYLE = ( "colours. Hard black outline around each figure. The background must be ONE FLAT solid magenta " "#FF00FF across the entire canvas — no scenery, no gradient, no vignette. Keep at least 200 " "pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge " - "of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text." + "of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text. " + "The canvas is a WIDE LANDSCAPE STRIP and every figure stands side by side in ONE SINGLE HORIZONTAL " + "ROW on the same ground line — never a grid, never two rows, never stacked." ) def batches(): @@ -40,15 +42,21 @@ def batches(): return json.load(f) # ---------------------------------------------------------------- helpers ---- -def ref_sheet(names, out, scale=2.0, gap=220, pad=150): +def ref_sheet(names, out, scale=2.0, gap=220, pad=150, frames=1): + """THE REFERENCE'S ASPECT IS THE ANSWER'S ASPECT. One tall reference figure makes a portrait + reference canvas, and the generator then answers in portrait — it laid four figures out as a 2x2 + GRID, which _fine.py cannot cut (it segments columns). So the canvas is widened to a landscape + strip with room for every frame that was asked for, and the figures are spread across it.""" ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in names] ims = [i.resize((max(1, int(i.width * scale)), max(1, int(i.height * scale))), Image.NEAREST) for i in ims] w = sum(i.width for i in ims) + gap * (len(ims) - 1) + pad * 2 h = max(i.height for i in ims) + pad * 2 + w = max(w, int(h * 0.62 * max(frames, len(ims)))) # one landscape slot per frame asked for s = Image.new('RGBA', (w, h), (255, 0, 255, 255)) + step = (w - pad * 2 - sum(i.width for i in ims)) // max(1, len(ims) - 1) if len(ims) > 1 else 0 x = pad for i in ims: - s.paste(i, (x, h - pad - i.height), i); x += i.width + gap + s.paste(i, (x, h - pad - i.height), i); x += i.width + max(gap, step) s.save(out) return out @@ -60,6 +68,15 @@ def density(path): d = np.abs(np.diff(a[..., :3].astype(int), axis=1)).sum(2) return round(float(((d > 40) & solid[:, :-1]).sum() / max(1, solid.sum())), 3) +def not_magenta(sheet): + """The generator sometimes ignores the magenta and paints its own background. That was written + off as harmless — the key uses the corner colour — right up until it returned a GREEN sheet and + keyed away four grey-green racket heads with it, installing four men holding a grip and nothing + else. A wrong background is now a rejected roll, because the art that dies to it is exactly the + thin art the sprites are made of.""" + a = np.array(Image.open(sheet).convert('RGB'))[0, 0].astype(int) + return not (a[0] > 140 and a[2] > 140 and a[1] < a[0] - 60 and a[1] < a[2] - 60) + def touches_edge(sheet): """A CLIPPED figure, not a speck of noise: a run of 8+ ink pixels along a border. The strict version rejected sheets over single stray pixels and cost several regenerations.""" @@ -74,6 +91,19 @@ def touches_edge(sheet): return best return max(run(fg[0]), run(fg[-1]), run(fg[:, 0]), run(fg[:, -1])) >= 8 +def arm_not_left(outs): + """The generator MIRRORS the later frames of a serve about half the time — the coil is a strong + silhouette and it draws it whichever way it likes, which puts the racket in the other hand + halfway through the animation. Oliver spotted it twice. Every frame after the first must reach + its raised arm up the LEFT-hand side, so a frame whose left half has nothing near the top (the + arm went right instead) is a rejected roll, not something to notice later in the file browser.""" + for o in outs[1:]: + ink = np.array(Image.open(o).convert('RGBA'))[..., 3] > 128 + h, w = ink.shape + left = np.nonzero(ink[:, :w // 2].any(1))[0] + if not len(left) or left.min() > 0.18 * h: return o + return None + def colour_match(src, dst_name): """Fit the new frame onto the palette statistics of the sprite it replaces, so a redrawn body does not change colour halfway through a walk cycle.""" @@ -93,7 +123,8 @@ def run(name, spec, dry=False): lead = spec.get('ref') # generated for scale, thrown away after shown = spec.get('ref_only') or (([lead] if lead else []) + sprites) if spec.get('ref_only'): lead = None # ref_only sheets return exactly the frames asked for - ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0)) + ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0), + frames=len(sprites) + (1 if lead else 0)) sheet = f'{SCRATCH}/{name}_hi.png' prompt = f"{spec['subject']} {STYLE}" if dry: @@ -103,6 +134,8 @@ def run(name, spec, dry=False): capture_output=True, text=True, cwd=os.path.dirname(HERE)) if not os.path.exists(sheet): print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False + if not_magenta(sheet): + print(f'[{name}] REJECTED: background is not magenta — thin art keys away with it'); 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) + (1 if lead else 0))] @@ -113,6 +146,10 @@ 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 spec.get('arm_left'): + bad = arm_not_left(outs) + if bad: + print(f'[{name}] REJECTED: {bad} was drawn MIRRORED — racket in the wrong hand'); 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] diff --git a/assets/_regen_batches.json b/assets/_regen_batches.json index 3b3ae67..8627046 100644 --- a/assets/_regen_batches.json +++ b/assets/_regen_batches.json @@ -97,18 +97,6 @@ "subject": "Four frames of the same tennis player seen from BEHIND in a white shirt, blue shorts and a white headband, holding a racket, drawn as coarse low-resolution pixel art: two waiting stances and two frames of him bouncing a ball before serving.", "anchor": 0 }, - "player_serve": { - "sprites": [ - "p_toss", - "p_smash" - ], - "ref_only": [ - "p_run1" - ], - "anchor": 0, - "check": "same", - "subject": "The reference is one tennis player in a white shirt, blue shorts and a white headband, seen FROM BEHIND. Draw TWO frames of that same player serving, same size, same style, same BRIGHT WHITE shirt and BRIGHT BLUE shorts as the reference \u2014 do not wash the colours out. Both frames are seen FROM BEHIND, the back of his head towards you, no face, no eyes: (1) the ball toss \u2014 he leans back, his left hand thrown up releasing the ball above him, the racket dropped low behind his back; (2) the smash \u2014 stretched fully upright on his toes, the racket swung high overhead, both arms up. Output exactly two figures in one row, far apart." - }, "player_run": { "sprites": [ "p_run1", @@ -345,30 +333,6 @@ "check": "loose", "subject": "Draw TWO frames of an enormous fat zombie brute that has NO HEAD AT ALL \u2014 the neck ends in a ragged red stump at shoulder height, there is no skull, no face, no jaw, nothing above the shoulders \u2014 and whose chest is torn open with the ribs showing. Same body, same clothes and same size as the reference, as two stages of one stride: first left leg forward, then legs swapped with the weight lower. The missing head and the chest wound must be identical in both frames; only the legs change. Output exactly two figures in one row." }, - "player_stand": { - "sprites": [ - "p_idle1", - "p_idle2" - ], - "ref_only": [ - "p_run1" - ], - "anchor": 0, - "check": "same", - "subject": "The reference is one tennis player in a white shirt, blue shorts and a white headband, seen FROM BEHIND. Draw TWO frames of that same player, same size, same style. Drawn strictly FROM BEHIND like the reference: the back of his head and the back of his shirt face you, NO face, NO eyes, NO nose, NO mouth anywhere in the image. Frame 1: standing waiting, feet apart, a racket held down at his right side. Frame 2: the same stance with his weight shifted onto the other foot and the racket swung slightly across. Output exactly two figures in one row, far apart." - }, - "player_bounce": { - "sprites": [ - "p_bounce", - "p_bounce2" - ], - "ref_only": [ - "p_run1" - ], - "anchor": 0, - "check": "same", - "subject": "The reference is one tennis player in a white shirt, blue shorts and a white headband seen FROM BEHIND. Draw TWO frames of that same player getting ready to serve, same size, same style, seen strictly FROM BEHIND \u2014 back of his head towards you, NO face. He holds the racket in his LEFT hand, down at his left side, which is the LEFT-HAND SIDE OF THE IMAGE. His RIGHT hand is empty and reaches down and out to the RIGHT-HAND SIDE OF THE IMAGE, palm down, as if he has just bounced a ball on the ground there. Frame 1: he leans forward slightly, right hand low near the ground. Frame 2: he straightens a little, right hand risen to about knee height. Draw NO ball in either frame \u2014 the ball is added by the game. Output exactly two figures in one row, far apart." - }, "brute_stages": { "sprites": [ "z_gw", @@ -410,7 +374,21 @@ "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." }, - "player_pull": { + "player_stance_r": { + "sprites": [ + "p_idle1", + "p_bounce", + "p_bounce2", + "p_toss" + ], + "ref_only": [ + "p_smash" + ], + "anchor": 0, + "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." + }, + "player_windup": { "sprites": [ "p_pull0", "p_pull1", @@ -420,11 +398,39 @@ ], "ref_only": [ "p_idle1", - "p_toss", - "p_smash" + "p_toss" ], "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 the racket stays in ONE hand \u2014 the hand on the LEFT-HAND SIDE OF THE IMAGE, the same hand that holds it in the reference \u2014 and his other arm, on the RIGHT-HAND SIDE OF THE IMAGE, stays thrown up high above him where he has just released the ball. The racket must never change hands between frames. 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." + "scale": 1.6, + "arm_left": true, + "subject": "The reference shows the same tennis player twice \u2014 white shirt, blue shorts, white headband, seen from behind: standing with the racket in his right hand, and throwing the ball up to serve with his empty left arm high on the left-hand side of the image. Draw FIVE frames of that same man. Every figure is seen strictly FROM BEHIND \u2014 the back of his head towards you, NO face, NO eyes, NO nose \u2014 all from the same camera height and distance, on the same ground line, no perspective distortion and no three-quarter turn. Same size, same body, same style, same BRIGHT WHITE shirt and BRIGHT BLUE shorts, no coloured collar or trim. Draw NO ball anywhere \u2014 the game draws the ball. He is RIGHT-HANDED and the racket NEVER changes hands: his whole right arm stays on the RIGHT-HAND SIDE OF THE IMAGE and his empty LEFT arm stays thrown up high on the LEFT-HAND SIDE OF THE IMAGE. Never mirror him.\nTHE FOUR SERVE FRAMES MUST BE STRONGLY DIFFERENT FROM ONE ANOTHER, and what changes between them is HOW FAR THE RACKET HAS FALLEN: in each frame the racket head is at a clearly lower point on his body than in the frame before, and his body leans further back. Do not draw four similar poses. In ALL FOUR his empty left arm is held up high above him and never comes down \u2014 only the racket falls.\nFrame 1: he simply STANDS upright and relaxed, arms down \u2014 a plain standing frame.\nFrame 2: the racket is held STRAIGHT UP, its head HIGH ABOVE HIS HEAD with clear sky between the racket and his hair, his right arm almost straight, his body upright, and his empty LEFT arm still thrown up high above him on the left-hand side of the image.\nFrame 3: the racket has tipped over backwards so its head now points DOWN and sits BESIDE HIS EAR, level with his face, his right elbow raised to shoulder height, his body just beginning to lean back, and his empty LEFT arm still thrown up high above him on the left-hand side of the image.\nFrame 4: the racket head has fallen to the MIDDLE OF HIS BACK, between his shoulder blades, his right elbow high above his shoulder, his back arched and leaning clearly backwards, knees beginning to bend, and his empty LEFT arm still thrown up high above him on the left-hand side of the image.\nFrame 5: the racket head has fallen all the way down to the BACK OF HIS THIGHS, below his shorts, the racket hanging almost straight down behind him; his right elbow points at the sky beside his head, his body is leaned back as far as it goes with his hips pushed forward, his heels are lifted off the ground, and his empty LEFT arm is STILL thrown up high above him on the left-hand side of the image. This is maximum tension, the instant before he explodes upwards.\nOutput exactly five figures in one row, evenly spaced and far apart." + }, + "player_idle": { + "sprites": [ + "p_idle1", + "p_idle2" + ], + "ref_only": [ + "p_idle1" + ], + "anchor": 0, + "check": "same", + "scale": 1.6, + "subject": "The reference is ONE tennis player in a white shirt, blue shorts and a white headband, seen from behind, standing at the baseline with the racket in his right hand. Draw TWO frames of that same man waiting. 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 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 hangs on the LEFT-HAND SIDE OF THE IMAGE. Draw NO ball anywhere. THE TWO FRAMES ARE THE SAME DRAWING: identical body, identical arms, identical racket, identical head \u2014 the ONLY difference is a small idle weight shift. Frame 1: his weight is on his left foot, that hip a little higher. Frame 2: the same man with his weight settled onto his right foot. Output exactly two figures in one row, far apart." + }, + "player_drive": { + "sprites": [ + "p_pull0", + "p_pull4" + ], + "ref_only": [ + "p_idle1", + "p_pull3" + ], + "anchor": 0, + "check": "same", + "scale": 1.6, + "subject": "The reference shows the same tennis player twice \u2014 white shirt, blue shorts, white headband, seen from behind: standing with the racket in his right hand, and part-way through a serve with the racket dropping behind his back. Draw TWO frames of that same man. Both are seen strictly FROM BEHIND \u2014 the back of his head towards you, NO face, NO eyes, NO nose \u2014 from the same camera height and distance, on the same ground line, no perspective distortion and no three-quarter turn. Same size, same body, same style, same BRIGHT WHITE shirt and BRIGHT BLUE shorts, no coloured collar or trim, a plain grey-strung wooden racket. Draw NO ball anywhere. He is RIGHT-HANDED and the racket stays in his RIGHT hand. Frame 1: he simply STANDS upright and relaxed, arms down \u2014 a plain standing frame. Frame 2 is the instant of MAXIMUM TENSION just before he uncoils. He stands tall and nearly straight, not crouched and not folded over: his legs are long, his heels lifted, and his upper body leans BACKWARDS so his shoulders are behind his hips and his hips are pushed forward. His RIGHT elbow is raised beside his head and the racket hangs STRAIGHT DOWN BEHIND HIS BACK from that hand, the racket head level with his hips. His LEFT arm is folded down in front of his chest. His head is UP and clearly visible from behind with the white headband on it \u2014 his arms must not cover his head, and he must not be bent over sideways or forwards. Output exactly two figures in one row, far apart." } } \ No newline at end of file diff --git a/assets/p_bounce.png b/assets/p_bounce.png index 3985091..77601e1 100644 Binary files a/assets/p_bounce.png and b/assets/p_bounce.png differ diff --git a/assets/p_bounce2.png b/assets/p_bounce2.png index d40144f..de7bf60 100644 Binary files a/assets/p_bounce2.png and b/assets/p_bounce2.png differ diff --git a/assets/p_idle1.png b/assets/p_idle1.png index 76100a6..91e49f9 100644 Binary files a/assets/p_idle1.png and b/assets/p_idle1.png differ diff --git a/assets/p_idle2.png b/assets/p_idle2.png index 7784798..179e57b 100644 Binary files a/assets/p_idle2.png and b/assets/p_idle2.png differ diff --git a/assets/p_pull1.png b/assets/p_pull1.png index ea6ca13..ee8f34f 100644 Binary files a/assets/p_pull1.png and b/assets/p_pull1.png differ diff --git a/assets/p_pull2.png b/assets/p_pull2.png index 5c9cad6..2986d32 100644 Binary files a/assets/p_pull2.png and b/assets/p_pull2.png differ diff --git a/assets/p_pull3.png b/assets/p_pull3.png index 8fb2818..1dc5df4 100644 Binary files a/assets/p_pull3.png and b/assets/p_pull3.png differ diff --git a/assets/p_pull4.png b/assets/p_pull4.png index 759371f..1f3259e 100644 Binary files a/assets/p_pull4.png and b/assets/p_pull4.png differ diff --git a/assets/p_toss.png b/assets/p_toss.png index c93d884..a5a45d9 100644 Binary files a/assets/p_toss.png and b/assets/p_toss.png differ diff --git a/config.js b/config.js index 2fb5f7d..40d8e8f 100644 --- a/config.js +++ b/config.js @@ -333,9 +333,10 @@ export const POSE_TIME = 0.38; // 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. -// 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 }; +// 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 +// 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 }; // 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 diff --git a/gameplay.js b/gameplay.js index b2f99f7..ca0cddc 100644 --- a/gameplay.js +++ b/gameplay.js @@ -132,7 +132,11 @@ function doHit(ch, quality, aim) { // ----------------------------------------------------------------- serve ---- function tossBall(sv) { ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false; - ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); ball.z = sv.z; ball.y = TOSS.y0; + // The player is right-handed: he tosses with his LEFT hand (viewer's left) and hits with the + // racket on the viewer's right, so the ball goes up almost over his head — a big offset either way + // lands it on the wrong side of one of those two frames. The opponent is drawn from the front and + // keeps the reach he was drawn with. + ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); ball.z = sv.z; ball.y = TOSS.y0; ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; G.phase = 'toss'; } @@ -793,7 +797,7 @@ export function dragServe(aim) { if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g }); ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; - ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; + ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all } diff --git a/index.html b/index.html index fccf35a..232a7b9 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,6 @@

- + diff --git a/render.js b/render.js index a382296..c72404b 100644 --- a/render.js +++ b/render.js @@ -155,7 +155,7 @@ export function proj(x, y, z) { // ---------------------------------------------------------------- sprites ---- // assets/.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); }