diff --git a/PROJECT.md b/PROJECT.md index 5b7b1da..1d969ed 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1956,7 +1956,7 @@ it is the WRONG tool, superseded by the reference-sheet flow. Route by job: |---|---| | A whole opponent family (31 sprites, one character) | `_famroll.py` -> `_zsheet.py` — see "THE ZOMBIE FAMILY PIPELINE" below. **Never `_regen.py`**: nine separate rolls is nine chances for the character to drift, which is why the family pipeline exists. | | Rebuild the pose reference | `_famref.py` — composed from disk, no API call, gated. | -| Player/opponent run cycles | `_runroll.py`. | +| A PLAYER RUN CYCLE | `_vidroll.py` — a video model, not a still one. See "THE RUN CYCLES CAME OUT OF A VIDEO" below. `_runroll.py` still rolls the single frame you film FROM, and still serves the opponents' pairs; it cannot produce a gait. | | A stride twin (legs frozen) | `_stride.py`. | | Single-sheet remaster, boss roster, any non-family sprite | `_regen.py` + `_regen_batches.json` — the only jobs it is still for. | | Standalone art (logo, panels) | `_key.py`; coarse legacy sheets, `_slice.py`. | @@ -2200,6 +2200,46 @@ The gates, and the numbers they were calibrated on: **SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found 27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet. +## THE RUN CYCLES CAME OUT OF A VIDEO — the still models cannot draw a gait (2026-08-15) + +**A ROW OF POSES IS NOT A WALK CYCLE, and six sessions of prompting could not make it one.** Asked +for eight running figures, the image model draws three phases and repeats them with the same leg +leading. Oliver, watching the sixth such roll: *"the feet look rather like ropeskipping than +running... the legs need to alter in a balanced way"*. Everything was tried against it and lost: a +figure-by-figure LEFT/RIGHT skeleton spec, a closing "walk the row and name the leading knee" +checklist, an explicit last-against-first loop clause, dropping the count from 8 to 6. `_stride.py` +CAN swap the legs of one frame (it drew the male's twin at 81% legs / 12% upper on the first roll), +but a pair is a pair, not a cycle. + +**A VIDEO MODEL HAS NO SUCH PROBLEM, because it moves a body instead of drawing poses.** One 5 s +image-to-video clip, ~$0.42 through the OpenRouter key already in `.env` (`POST /api/v1/videos`, +async, and the "unsigned" result URL still wants the Authorization header — without it you save a +67-byte 401). `assets/_vidroll.py` films the clip and cuts it. What the clips taught: + +- **PIN THE POSE AT BOTH ENDS** — `first_frame` and `last_frame` set to the same still. Her first + clip, pinned only at the front, had rotated her into a side profile by the halfway mark. +- **MEASURE THE STRIDE.** `--cut` scores every frame against every lag with `_famref`'s + `legs`/`stride_diff` and installs N phases spaced evenly across the lowest-scoring period. Her + clip strides in 6 frames at 16 fps, his in 23 — the same "every fifth frame" sampling that + flattered hers made his read as a stagger. +- **WHOLE FRAMES, THROUGH `_stride.cut`.** Raw clip frames are soft, and the chroma key eats + lavender-white sneakers unless the test is STRONGLY magenta (`r-g > 70 and b-g > 70`). The first + attempt kept only the clip's legs under his own drawn torso; matching hips frame by frame cost an + evening of seams (Oliver: *"the upper body moves as well so the 2 parts seem drifting"* — the + torso has to be placed on each frame's own hips, and even then her skirt defeated it). +- **THE FIRST FRAME IS THE WHOLE JOB.** Generate it as a single still: her identity reference plus + HIS approved run frame as the POSE picture ("she is the person; his pose is the instruction") — + one roll, no retries, open racket bed. A soft first frame gives a clip whose racket wobbles, + which is exactly what his weaker cycle shows. + +**Settled with it:** `TUNE.runHz` 2.4 -> **1.9** (2.4 on real phases: *"this looks like +roadrunner"*), the player no longer clamped to two frames — `runN` probes the sheet **through +`SK()`**, so the women's tour counts her six against his six rather than reading his sheet for both +— and the sprite bench grew two PLAYER stops (`beats.js` `BENCH_CHARS`), because a player cycle +that is only ever seen mid-rally is a cycle nobody has watched. And **bump `SPRV`**: the frames were +right on disk and wrong on screen for an hour because `assets/*` is cached for a year unless the +`?v=` moves. + ## THE RUN CYCLES — one call, one row, one character (2026-08-13) The p_run/o_run frames were generated ONE AT A TIME in the pre-pipeline era and it showed: the male diff --git a/assets/_ex_stride.png b/assets/_ex_stride.png new file mode 100644 index 0000000..b4b2b54 Binary files /dev/null and b/assets/_ex_stride.png differ diff --git a/assets/_ex_strideb.png b/assets/_ex_strideb.png new file mode 100644 index 0000000..84bc174 Binary files /dev/null and b/assets/_ex_strideb.png differ diff --git a/assets/_runroll.py b/assets/_runroll.py index 7408767..a8047a9 100644 --- a/assets/_runroll.py +++ b/assets/_runroll.py @@ -28,7 +28,9 @@ from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) SCRATCH = os.path.join(HERE, '_refcheck') MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image') -N = 8 # frames per cycle — render.js probes run4/run8 and subdivides the same stride +N = 6 # frames per cycle — render.js probes run4/run6/run8 and subdivides the same stride. + # SIX, not eight: at eight the model pads the row with near-duplicates and the gait + # blurs into a hop. Six is three phases and their mirror, which is what a run IS. TARGET_H = 224 # SPRITE_H: the cut anchors its tallest frame here, like every char sheet # key -> identity frames (settled art, the character), install (prefix, suffix), who (words that @@ -38,17 +40,24 @@ CHARS = { 'who': 'the LIVING male tennis player of image 1: neat brown hair under a white headband, ' 'a white polo shirt, blue shorts, white socks and white sneakers, tanned skin, ' 'carrying a wooden tennis racket', - 'view': 'his BACK to the camera in three-quarter view, the body angled toward the right, ' - 'running to the RIGHT along the baseline — the right edge of his face just visible ' - 'past his shoulder. NOT a straight-away back view: he moves ACROSS the frame, ' - 'rightward, every figure leaning into that rightward run'}, + 'view': 'SEEN FROM BEHIND AND ABOVE, running AWAY from the camera and off to the right — ' + 'the camera stands behind the baseline looking down-court at his back, exactly the ' + 'angle image 1 is drawn from. You see his shoulder blades, the back of his head ' + 'and the soles of his shoes as they lift; only a sliver of cheek shows past the ' + 'right shoulder. The body is turned about 30 degrees off straight-away so he ' + 'travels diagonally away and to the RIGHT. A SIDE-ON PROFILE IS WRONG: if a figure ' + 'shows its whole face, chest or a full profile silhouette, that figure is WRONG'}, 'pf': {'id': ['p_idle1_f', 'p_bounce_f', 'p_swing1_f', 'p_smash_f', 'p_toss_f'], 'install': ('p_run', '_f'), 'who': 'the LIVING female tennis player of image 1: a dark ponytail, a white polo shirt, ' 'a white pleated tennis skirt, white socks and sneakers, carrying a wooden tennis racket', - 'view': 'her BACK to the camera in three-quarter view, the body angled toward the right, ' - 'running to the RIGHT along the baseline — the right edge of her face just visible ' - 'past her shoulder. NOT a straight-away back view: she moves ACROSS the frame, ' - 'rightward, every figure leaning into that rightward run'}, + 'view': 'SEEN FROM BEHIND AND ABOVE, running AWAY from the camera and off to the right — ' + 'the camera stands behind the baseline looking down-court at her back, exactly the ' + 'angle image 1 is drawn from. You see her shoulder blades, the ponytail swinging ' + 'off the back of her head and the soles of her shoes as they lift; only a sliver ' + 'of cheek shows past the right shoulder. The body is turned about 30 degrees off ' + 'straight-away so she travels diagonally away and to the RIGHT. A SIDE-ON PROFILE ' + 'IS WRONG: if a figure shows its whole face, chest or a full profile silhouette, ' + 'that figure is WRONG'}, 'o': {'id': ['o_idle1', 'o_bounce', 'o_swing1', 'o_smash', 'o_toss'], 'install': ('o_run', ''), 'who': 'the male tennis opponent of image 1: shaggy blond hair under a red headband, a red ' 'polo shirt with a dark collar, dark grey shorts, red socks, white sneakers, ' @@ -90,10 +99,22 @@ def compose(k): def prompt(k): + # THE BACKGROUND LINE COMES FIRST AND COMES FROM _regen.py (PROJECT.md's rule, re-learned + # 2026-08-15): the back-view re-roll's freehand magenta prose lost to the camera change and the + # sheet came back WHITE with a ghosted second row above the figures. + sys.path.insert(0, HERE) + from _regen import BG_FIRST c = CHARS[k] return ( - f'Draw a pixel-art sprite sheet: ONE ROW of EXACTLY {N} figures on a flat magenta background. ' - f'COUNT THEM: {N} figures, no fewer, no more — a row of 7 or 9 is WRONG.\n\n' + BG_FIRST + '\n\n' + f'Draw a pixel-art sprite sheet: ONE ROW of EXACTLY {N} figures on that flat magenta ' + f'background. COUNT THEM: {N} figures, no fewer, no more — a row of 7 or 9 is WRONG. ONE ' + 'SINGLE HORIZONTAL ROW on one ground line: never a grid, never two rows, never a strip of ' + 'extra heads or half-bodies above or below the row, and no faded, blurred, ghosted or ' + 'onion-skinned figures anywhere on the canvas — every figure is fully drawn and fully opaque. ' + 'THE FIGURES ARE SMALL IN THE FRAME: about two thirds of the canvas height, with a band of ' + 'empty magenta roughly a sixth of the canvas wide above, below and beyond the outermost ' + 'figure on each side.\n\n' f'IMAGE 1 is the ONLY source for who the character is: {c["who"]}. Copy the build, the ' 'garments, the hair and every one of its colours exactly. The poses in image 1 exist only to ' 'show the character — do NOT copy the poses.\n\n' @@ -103,7 +124,11 @@ def prompt(k): 'behind the back to the left, never changes sides between neighbouring figures. The right ' 'arm swings a LITTLE; the racket head stays visibly right of the body\'s midline throughout. ' 'A single figure with the racket on the left side makes the whole animation flicker and is ' - 'WRONG. THE INSIDE OF EVERY RACKET HEAD IS EXACTLY THE BACKGROUND MAGENTA #FF00FF — never ' + f'WRONG. BEFORE YOU FINISH, COUNT THE RACKETS: exactly {N}, one per figure — a row where one ' + 'figure lost its racket is WRONG, and that is the exact defect this row replaces. Seen from ' + 'behind the racket is FORESHORTENED and half-hidden by the hip — a short wooden shaft and an ' + 'oval head angled away from the camera, never a broad flat paddle held out to the side. ' + 'THE INSIDE OF EVERY RACKET HEAD IS EXACTLY THE BACKGROUND MAGENTA #FF00FF — never ' 'white, never grey, never solid: it cuts to transparency, the same rule the family sheets ' 'follow (a white-filled bed shipped as a solid paddle once — that is what this line prevents).\n\n' f'THE {N} FIGURES ARE ONE CONTINUOUS RUN CYCLE, {c["view"]}. Evenly spaced, every figure on ' @@ -111,19 +136,47 @@ def prompt(k): 'NEVER TOUCH OR OVERLAP: leave a clear band of empty magenta between every pair of ' 'neighbours — no part of any figure, the racket included, reaches into its neighbour\'s ' 'space. A racket overlapping the next figure is WRONG.\n\n' - 'The cycle, frame by frame: 1 the right foot planted under the body, left knee driving up and ' - 'forward; 2 push-off, the right leg trailing straight behind, the left leg reaching forward; ' - '3 full flight, both feet off the ground, the stride at its widest — THE RACKET STILL ' - 'GRIPPED IN THE RIGHT HAND in this airborne figure, exactly as in every other; 4 the left foot landing ' - 'ahead of the body. Frames 5 to 8 are the SAME four moments with the legs swapped — the left ' - 'foot planted, the right knee driving. Across the row the gap between the feet opens, closes, ' - 'opens and closes again. The arms counter-swing: the opposite arm to the driving knee comes ' - 'forward. If two neighbouring figures have the same leg forward, the row is WRONG.\n\n' + # THE GAIT IS THE WHOLE JOB, and from behind it is carried by ONE readable fact per figure: + # which shoe is flat on the ground and which sole is showing to the camera. Told as "the legs + # alternate" the model drew a row of small symmetrical hops — Oliver: "the feet look rather + # like ropeskipping than running". Named LEFT and RIGHT, figure by figure, it has something + # to check itself against. + 'THE CYCLE, FIGURE BY FIGURE — name the left and right leg in each one and draw exactly this:\n' + '1. The LEFT shoe is flat on the ground under the hips carrying the whole body; the RIGHT ' + 'knee is driving up and FORWARD in front of the body, that thigh climbing so the right shoe ' + 'is well clear of the ground. The body sits at its LOWEST here.\n' + '2. Push-off: the LEFT leg snaps straight and trails BEHIND the body with the whole SOLE of ' + 'the left shoe tipped up toward the camera, while the RIGHT foot reaches forward and lands ' + 'ahead. The two shoes are far apart.\n' + '3. Flight: BOTH feet are off the ground, the RIGHT foot forward and low about to take the ' + 'weight, the LEFT foot high behind with its sole full to the camera. This is the WIDEST ' + 'figure of the row and the body rides HIGHEST.\n' + '4. The mirror of 1: the RIGHT shoe flat on the ground under the hips, the LEFT knee driving ' + 'up and forward.\n' + '5. The mirror of 2: the RIGHT leg straight and trailing behind, its sole to the camera, the ' + 'LEFT foot reaching forward.\n' + '6. The mirror of 3: flight again, LEFT foot forward and low, RIGHT foot high behind.\n' + 'IN EVERY SINGLE FIGURE ONE LEG IS CLEARLY AHEAD OF THE OTHER and the two shoes are at ' + 'DIFFERENT heights. Two feet side by side under the hips, or two legs bent the same way, is ' + 'SKIPPING WITH A ROPE, not running, and is WRONG. The leading leg must CHANGE SIDES from each ' + 'figure to the next — never the same leg leading twice in a row, the last figure included, ' + 'because the row is played as a loop. The hips and shoulders counter-rotate a little with ' + 'each stride, and the free LEFT arm swings forward when the RIGHT knee drives.\n\n' 'MATCH THE DRAWING STYLE OF IMAGE 1 EXACTLY. Image 1 is hard-edged pixel art and your output ' 'must be indistinguishable from it in technique: the same square pixel grid at the same pixel ' 'size, the same hard dark outline around every figure, the same flat blocks of colour, the ' 'same stepped aliased edges, the same hand-placed dithering. No soft brushwork, no blur, no ' 'gradients, no anti-aliasing.\n\n' + 'THIS IS A HARD SPRINT, NOT A JOG. At the widest figure the shoes are at least two shoe ' + 'lengths apart and the torso leans forward over the leading knee; the trailing heel kicks up ' + 'toward the backside. A row of figures standing upright with their feet close together is a ' + 'WALK and is WRONG.\n\n' + 'EVERY FIGURE IS THE SAME SIZE as its neighbours and every figure stands on ONE shared ' + 'horizontal ground line — the airborne figure lifts a little off it, nobody floats above the ' + 'row.\n\n' + f'LAST CHECK BEFORE YOU DRAW: walk the row figure by figure, 1 to {N}, and for each one name ' + 'the racket in its right hand and the knee that leads. Every figure has a racket. No two ' + 'neighbours lead with the same knee.\n\n' 'The background is a solid, flat, uniform chroma key. Use EXACTLY hex color #FF00FF, one ' 'single flat tone across the whole canvas behind every figure, with no panels or boxes of any ' 'other colour. The inside of the racket head is that same EXACT #FF00FF. No text, no shadows, ' diff --git a/assets/_stride.py b/assets/_stride.py index 018b04b..812c11b 100644 --- a/assets/_stride.py +++ b/assets/_stride.py @@ -91,9 +91,9 @@ def one_ref(n, out, scale=4): PROMPT = ( - 'IMAGE 1 is one figure from a zombie tennis game: a walking corpse holding a racket, drawn as ' - 'hard-edged pixel art on a flat magenta background.\n\n' - 'Redraw IMAGE 1 with ONE change: THE LEGS ARE IN THE OPPOSITE PHASE OF THE SAME WALKING STEP. ' + 'IMAGE 1 is one figure from a zombie tennis game — a player or a walking corpse, holding a ' + 'racket, drawn as hard-edged pixel art on a flat magenta background.\n\n' + 'Redraw IMAGE 1 with ONE change: THE LEGS ARE IN THE OPPOSITE PHASE OF THE SAME STEP. ' 'The leg that is forward becomes the back leg and the leg that is back swings forward, so the two ' 'drawings together read as one body taking a step. The feet must clearly be in different places ' 'than in IMAGE 1.\n\n' diff --git a/assets/_vidroll.py b/assets/_vidroll.py new file mode 100644 index 0000000..47ac9b2 --- /dev/null +++ b/assets/_vidroll.py @@ -0,0 +1,172 @@ +# _vidroll.py — a run cycle out of a VIDEO model. One still frame in, one stride's worth of +# sprites out. +# +# uv run --with pillow --with numpy --with requests python assets/_vidroll.py pf --film +# uv run --with pillow --with numpy --with requests python assets/_vidroll.py pf --cut +# uv run --with pillow --with numpy --with requests python assets/_vidroll.py pf --cut --install +# keys: p (player m), pf (player f) [the opponents run two-frame pairs and do not need this] +# +# WHY THIS EXISTS, and why the still pipeline does not do it. `_runroll.py` asks one image model for +# a ROW of run poses, and across six sessions it never once returned a real gait: it draws three +# phases and repeats them with the same leg leading, which Oliver called "ropeskipping, not running" +# — amplitude wiggle, no swap. Words did not fix it (a frame-by-frame LEFT/RIGHT skeleton spec, a +# closing checklist, a loop clause: all of it lost). A VIDEO model has no such problem, because it +# is not drawing poses, it is moving a body through time — the legs alternate because that is what +# legs do. One 5-second clip is one guaranteed-coherent stride, and it costs about $0.42. +# +# THE RULES THAT MADE IT WORK (each one paid for with a bad clip): +# * PIN THE POSE AT BOTH ENDS. first_frame AND last_frame set to the same still. Her first clip, +# pinned only at the front, rotated her into a side profile by the second half. +# * The prompt says the camera never moves and the runner never turns. Say both. +# * MEASURE THE STRIDE, do not eyeball it: `--cut` scores every frame against every other with +# _famref.legs/stride_diff and takes the period with the lowest self-difference, then samples +# N phases evenly across ONE period. Her clip strides in 6 frames at 16 fps, his in 23 — a +# fixed "every fifth frame" gave a cycle that read as a stagger. +# * The frames go through `_stride.cut`, the same palette-snap-to-settled-art the leg-swap twin +# uses. Raw clip frames are soft and their racket beds fill in; the cut is what makes them +# sprites. Whole frames beat splicing: an earlier pass kept only the clip's legs under his own +# drawn torso, and matching hips frame by frame was a week of seams (her skirt made it worse). +# +# THE CLIP'S FIRST FRAME IS THE WHOLE JOB. Generate it as a single still first — for her, one +# `_pose_ref_run_*`-style identity reference plus HIS approved run frame as the pose picture ("she +# is the person; his pose is the instruction"), which landed on roll one. A crisp first frame with +# an open racket bed gives a clean clip; a soft one gives a clip whose racket wobbles. +import base64, json, os, sys, time + +import requests +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRATCH = os.path.join(HERE, '_refcheck') +MODEL = os.environ.get('SPLAT_VIDEO_MODEL', 'kwaivgi/kling-v3.0-std') +FPS = 16 # extraction rate: fine enough to find the period, coarse enough to score quickly +N = 6 # phases installed — one stride, evenly sampled + +CHARS = { + 'p': {'still': 'p_run1', 'like': 'p_idle1', 'install': ('p_run', ''), 'who': 'The man', + 'they': ('he', 'his', 'him')}, + 'pf': {'still': 'p_run1_f', 'like': 'p_idle1_f', 'install': ('p_run', '_f'), 'who': 'The woman', + 'they': ('she', 'her', 'her')}, +} + + +def key(): + if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY'] + for line in open(os.path.join(HERE, '..', '.env')): + if line.startswith('OPENROUTER_API_KEY='): + return line.split('=', 1)[1].strip().strip('"').strip("'") + sys.exit('no OPENROUTER_API_KEY') + + +def prompt(k): + c = CHARS[k]; s, poss, obj = c['they'] + return ( + f'Pixel-art video game sprite animation. {c["who"]} in the image keeps RUNNING AWAY from the ' + f'camera, seen from behind, travelling slightly to the right. Two full running strides: ' + f'{poss} legs alternate clearly, left knee driving up then right knee driving up, the trailing ' + f'foot kicking up behind {obj} each time. {s.capitalize()} grips the wooden tennis racket in ' + f'{poss} right hand the whole time and never lets go. {s.upper()} NEVER TURNS: the camera ' + f'stays behind {obj} for every single frame, {poss} face is never shown, {s} never rotates ' + f'into a side profile or toward the camera. THE CAMERA IS COMPLETELY STATIC — no pan, no zoom, ' + f'no dolly. {s.capitalize()} stays in the middle of the frame at the same size. The background ' + 'stays ONE FLAT SOLID MAGENTA #FF00FF, empty — no court, no scenery, no shadow under ' + f'{poss} feet, no ground line, no contact shadow. The drawing style stays hard-edged pixel art ' + 'the whole time.') + + +def framed(src, out, size=(1024, 1024), scale=3): + """The sprite on flat magenta, big enough for a video model to see the pixels.""" + im = Image.open(src).convert('RGBA') + im = im.resize((im.width * scale, im.height * scale), Image.NEAREST) + c = Image.new('RGB', size, (255, 0, 255)) + c.paste(im, ((size[0] - im.width) // 2, size[1] - im.height - 60), im) + c.save(out) + return out + + +def u(p): + return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode() + + +def film(k): + os.makedirs(SCRATCH, exist_ok=True) + first = framed(os.path.join(HERE, CHARS[k]['still'] + '.png'), os.path.join(SCRATCH, f'vid_{k}_first.png')) + img = {'type': 'image_url', 'image_url': {'url': u(first)}} + r = requests.post('https://openrouter.ai/api/v1/videos', + headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'}, + json={'model': MODEL, 'prompt': prompt(k), 'duration': 5, 'aspect_ratio': '1:1', + 'generate_audio': False, + # BOTH ends pinned to the same still: that is what holds the camera behind the runner + 'frame_images': [dict(img, frame_type='first_frame'), dict(img, frame_type='last_frame')]}, + timeout=600) + if r.status_code >= 400: sys.exit(f'{k}: HTTP {r.status_code} {r.text[:300]}') + job = r.json() + poll = job.get('polling_url') or f"https://openrouter.ai/api/v1/videos/{job['id']}" + for _ in range(120): + time.sleep(10) + p = requests.get(poll, headers={'Authorization': f'Bearer {key()}'}, timeout=120).json() + print(' ', p.get('status'), flush=True) + if p.get('status') in ('completed', 'succeeded', 'success'): + url = (p.get('unsigned_urls') or [None])[0] + # the "unsigned" url still wants the key — without it you save a 67-byte 401 body + v = requests.get(url, headers={'Authorization': f'Bearer {key()}'}, timeout=600).content + out = os.path.join(SCRATCH, f'vid_{k}.mp4') + open(out, 'wb').write(v) + print(f'{k}: {out} {len(v)} bytes cost {p.get("usage", {}).get("cost")}') + return + if p.get('status') in ('failed', 'error', 'cancelled'): + sys.exit(json.dumps(p)[:400]) + sys.exit(f'{k}: still pending after 20 minutes') + + +def cut(k, install=False): + import subprocess + sys.path.insert(0, HERE) + import _stride + from _famref import legs, stride_diff + mp4 = os.path.join(SCRATCH, f'vid_{k}.mp4') + if not os.path.exists(mp4): sys.exit(f'{mp4}: no clip — run `_vidroll.py {k} --film` first') + d = os.path.join(SCRATCH, f'vidf_{k}') + subprocess.run(['rm', '-rf', d], check=False); os.makedirs(d) + subprocess.run(['ffmpeg', '-v', 'error', '-i', mp4, '-vf', f'fps={FPS}', f'{d}/f%03d.png'], check=True) + fs = sorted(os.listdir(d))[6:46] # skip the held first frames + ims = [] + for f in fs: + im, err = _stride.cut(os.path.join(d, f), CHARS[k]['like']) + if im: ims.append(im) + if len(ims) < 12: sys.exit(f'{k}: only {len(ims)} frames survived the cut') + L = [legs(i) for i in ims] + # THE PERIOD IS MEASURED, NOT GUESSED: the lag whose frames look most like the frames one lag + # earlier IS the stride. (A run that lopes and one that snaps differ by 4x — see the header.) + scores = [(sum(stride_diff(L[i], L[i + p]) for i in range(len(L) - p)) / (len(L) - p), p) + for p in range(4, min(26, len(L) - 2))] + per = min(scores)[1] + print(f'{k}: {len(ims)} frames, stride = {per} frames at {FPS} fps ({per / FPS:.2f} s)') + sel = [round(i * per / N) for i in range(N)] + pre, suf = CHARS[k]['install'] + outdir = HERE if install else os.path.join(SCRATCH, f'vidcut_{k}') + os.makedirs(outdir, exist_ok=True) + import numpy as np + for n, i in enumerate(sel, 1): + im = ims[i] + # feet-centred like every other run cut: drawChar centres a frame by its total WIDTH, and + # the racket's reach varies across a cycle, so an uncentred cycle slides sideways per frame + a = np.array(im); op = a[..., 3] > 40 + ys, xs = np.nonzero(op[int(im.height * 0.66):]) + off = round(2 * (xs.mean() - im.width / 2)) if len(xs) else 0 + if off: + p = Image.new('RGBA', (im.width + abs(off), im.height), (0, 0, 0, 0)) + p.paste(im, (0 if off > 0 else abs(off), 0)); im = p + im.save(os.path.join(outdir, f'{pre}{n}{suf}.png')) + print(f'{k}: {N} frames -> {outdir}' + ('' if install else ' (review, then --install)')) + print(' BUMP SPRV in render.js, or the year-long asset cache serves the old frames.') + + +if __name__ == '__main__': + args = [a for a in sys.argv[1:] if not a.startswith('-')] + if len(args) != 1 or args[0] not in CHARS: + sys.exit(f'usage: _vidroll.py [--film|--cut] [--install] keys: {" ".join(CHARS)}') + k = args[0] + if '--film' in sys.argv: film(k) + elif '--cut' in sys.argv: cut(k, install='--install' in sys.argv) + else: sys.exit('pass --film or --cut') diff --git a/assets/p_run1.png b/assets/p_run1.png index b5a128b..8c23da8 100644 Binary files a/assets/p_run1.png and b/assets/p_run1.png differ diff --git a/assets/p_run1_f.png b/assets/p_run1_f.png index 1f58e2e..bc344f8 100644 Binary files a/assets/p_run1_f.png and b/assets/p_run1_f.png differ diff --git a/assets/p_run2.png b/assets/p_run2.png index afc1fb8..9601b63 100644 Binary files a/assets/p_run2.png and b/assets/p_run2.png differ diff --git a/assets/p_run2_f.png b/assets/p_run2_f.png index 50d566d..1267356 100644 Binary files a/assets/p_run2_f.png and b/assets/p_run2_f.png differ diff --git a/assets/p_run3.png b/assets/p_run3.png index 4a9d079..d9ef0a6 100644 Binary files a/assets/p_run3.png and b/assets/p_run3.png differ diff --git a/assets/p_run3_f.png b/assets/p_run3_f.png index 041df1d..0c48072 100644 Binary files a/assets/p_run3_f.png and b/assets/p_run3_f.png differ diff --git a/assets/p_run4.png b/assets/p_run4.png index d4ab33f..a42c76e 100644 Binary files a/assets/p_run4.png and b/assets/p_run4.png differ diff --git a/assets/p_run4_f.png b/assets/p_run4_f.png index f7fcd4d..cf3e4a4 100644 Binary files a/assets/p_run4_f.png and b/assets/p_run4_f.png differ diff --git a/assets/p_run5.png b/assets/p_run5.png index b4b2b54..907139e 100644 Binary files a/assets/p_run5.png and b/assets/p_run5.png differ diff --git a/assets/p_run5_f.png b/assets/p_run5_f.png index eb58085..a83097d 100644 Binary files a/assets/p_run5_f.png and b/assets/p_run5_f.png differ diff --git a/assets/p_run6.png b/assets/p_run6.png index 84bc174..351d1ca 100644 Binary files a/assets/p_run6.png and b/assets/p_run6.png differ diff --git a/assets/p_run6_f.png b/assets/p_run6_f.png index 5ab4c35..3cdf8a8 100644 Binary files a/assets/p_run6_f.png and b/assets/p_run6_f.png differ diff --git a/index.html b/index.html index 8bb4ed0..1a07905 100644 --- a/index.html +++ b/index.html @@ -645,6 +645,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 79fe93b..57fc81a 100644 --- a/src/beats.js +++ b/src/beats.js @@ -170,6 +170,7 @@ export function endDrill() { // ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be looked // at the day they are generated rather than the day they are wired up. const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly +const BENCH_CHARS = ['o', 'of', 'p', 'pf']; // the living slots past ZFAMS, in walk order export function startBench() { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; @@ -200,8 +201,10 @@ export function endBench() { // wound stage — the bench must ask the same question the game asks or it is testing something else. function benchApply() { const zb = zombies.find(z => z.variant === 'r'); if (!zb) return; - // the two slots past ZFAMS are the LIVING opponents; render's SK() reads b.char for the suffix - G.bench.char = G.bench.fam >= ZFAMS.length ? (G.bench.fam - ZFAMS.length ? 'of' : 'o') : null; + // the four slots past ZFAMS are the LIVING players: the two opponents, then the two PLAYERS + // (the run cycles are re-rolled from the same script and judged the same way — a player cycle + // that was only ever seen in a rally is a cycle nobody has actually watched) + G.bench.char = G.bench.fam >= ZFAMS.length ? BENCH_CHARS[G.bench.fam - ZFAMS.length] : null; zb.hpMax = BENCH_HP; zb.hp = Math.max(1, Math.round(BENCH_HP * [1, 0.5, 0.2][G.bench.stage])); // ...and the named states take the wound stage with them: z_r at full health, @@ -233,8 +236,8 @@ export function benchKey(code) { else if (code === 'ArrowRight' || code === 'KeyD') b.stage = (b.stage + 1) % 3; // up/down walks the OPPONENT FAMILIES — plus two LIVING slots past the end (the tennis // opponents m/f, Oliver's run-cycle finetune bench): the zombie steps aside and the real O runs - else if (code === 'ArrowUp' || code === 'KeyW') b.fam = (b.fam + ZFAMS.length + 1) % (ZFAMS.length + 2); - else if (code === 'ArrowDown' || code === 'KeyS') b.fam = (b.fam + 1) % (ZFAMS.length + 2); + else if (code === 'ArrowUp' || code === 'KeyW') b.fam = (b.fam + ZFAMS.length + BENCH_CHARS.length - 1) % (ZFAMS.length + BENCH_CHARS.length); + else if (code === 'ArrowDown' || code === 'KeyS') b.fam = (b.fam + 1) % (ZFAMS.length + BENCH_CHARS.length); // F plays the fall. Before the fallback below, which treats ANY other key as "next state" — a new // control that forgets to land above that line silently steps the stop instead. else if (code === 'KeyF') return benchFall(); @@ -257,14 +260,18 @@ export function updateBench(dt) { // slot exists to tune. if (b.char) { zb.x = 40; zb.vx = 0; zb.pose = 'idle'; - G.hideO = false; // splatter hid him when the match turned; the bench un-hides - O.z = 5.5; + // 'p'/'pf' pace the PLAYER on the near court instead — same back-and-forth, his own end, and + // the sprite is twice the size there, which is the whole reason his cycle needs its own stop. + const ch = b.char[0] === 'p' ? P : O; + G.hideO = b.char[0] === 'p'; // splatter hid him when the match turned; the opponent + ch.z = ch === P ? HOME.P.z : 5.5; // stops un-hide, the player stops leave him out const step = TUNE.aiSpeed * 0.7 * dt; - O.x += b.dir * step; - O.runT += dt; - if (Math.abs(O.x) > 4.0) { b.dir *= -1; O.x = Math.sign(O.x) * 4.0; } - O.facing = b.dir; - O.pose = 'run'; + ch.x += b.dir * step; + ch.runT += dt; + if (Math.abs(ch.x) > 4.0) { b.dir *= -1; ch.x = Math.sign(ch.x) * 4.0; } + ch.facing = b.dir; + ch.pose = 'run'; + if (ch === P) { O.pose = 'idle'; O.x = HOME.O.x; O.z = HOME.O.z; } return; } O.pose = 'idle'; O.x = HOME.O.x; O.z = HOME.O.z; G.hideO = true; // fam stops: the living man steps back out diff --git a/src/config.js b/src/config.js index 1b94751..8f0e64f 100644 --- a/src/config.js +++ b/src/config.js @@ -46,8 +46,9 @@ export const TUNE = { // with Oliver 2026-08-14: 4.5 on six frames was strobe, 1.7 on two read as jogging, // and 3.4 with the stride-synced bob is the one he called "way better". (6.8 was // tried and reverted on sight — the ceiling is real.) - 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 + runHz: 1.9, // 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 — and so + // did 2.4 on the back-view re-roll (Oliver: "this looks like roadrunner") faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip). // The rolled cycles hold a real racket, and an un-earned flip teleports it // across the body — the "racket flashing left and right" report @@ -710,12 +711,16 @@ export const MENU = [ // CPU pre-plans its intercept, and the dials that actually read as skill were untouched. What a // player FEELS is: how fast the CPU reads a shot (reaction — "well-aimed corners are winners"), // how long its racket is (reach), the FLOOR of its shot quality (qMin — a raised floor means every -// ball comes back with pace), how often it just misses (flub), and its serve. MEDIUM repeats the -// shipped TUNE values dial for dial (pinned by test), so the middle chip IS the old game. +// ball comes back with pace), how often it just misses (flub), and its serve. // `smart` is the probability the CPU aims at the OPEN court instead of a random lane (trySwing) — -// the dial that turns "returns everything" into "plays you". HARD got a second pass after -// Oliver's "still too easy": it now reads your shot in 0.22 s, covers court at 1.3x, floors its -// quality at 0.70, and hunts the corner you are not standing in four times out of five. +// the dial that turns "returns everything" into "plays you". +// THE 2026-08-15 RETUNE (Oliver: "medium is too easy and hard is a tad too hard, easy could also be +// a little harder"): the three chips were bunched at the bottom and then jumped. MEDIUM no longer +// repeats the shipped TUNE values — it is a real step ABOVE them, and it is where `smart` first +// appears (0.35), so the middle chip plays the court instead of just returning. HARD stepped back +// on the dials that made it feel superhuman rather than good — speed 1.5 -> 1.38, floor 0.70 -> +// 0.66, smart 0.8 -> 0.7 — and EASY closed most of its gap to the old MEDIUM. The order across all +// three still climbs on every dial (pinned by test); only MEDIUM's old pin to TUNE is gone. // `recover` scales the between-shots jog (the rally block's 0.5 "slow recovery: wrong-footing wins // points") — a HARD CPU that ambles back to centre is free points, a recovering one is a wall. // Speed calibration: the zombie RUNNER covers 2.8 x 1.8 x 1.22 = 6.15 m/s with no reaction gate, @@ -723,9 +728,9 @@ export const MENU = [ export const DIFFS = [ // EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD // taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy - { id: 'easy', name: 'EASY', flub: 0.18, quality: 0.76, qMin: 0.45, speed: 0.90, reaction: 0.54, reach: 1.52, serve: 0.9, smart: 0, recover: 0.45 }, - { id: 'medium', name: 'MEDIUM', flub: 0.10, quality: 0.86, qMin: 0.45, speed: 1.0, reaction: 0.45, reach: 1.6, serve: 1.0, smart: 0, recover: 0.5 }, - { id: 'hard', name: 'HARD', flub: 0.01, quality: 1.0, qMin: 0.70, speed: 1.50, reaction: 0.22, reach: 1.85, serve: 1.15, smart: 0.8, recover: 0.85 }, + { id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 }, + { id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 }, + { id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 }, ]; // THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's diff --git a/src/render.js b/src/render.js index c34bb81..b23e0ee 100644 --- a/src/render.js +++ b/src/render.js @@ -221,13 +221,19 @@ function farB(z) { // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang 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 = 132; +export const SPRV = 138; // 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". export const LOAD = { done: 0, total: 0 }; export 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_run5','p_run6','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_run5','p_run6', // SIX phases lifted out of a five-second + // image-to-video clip (2026-08-15): a video model animates a body through time, + // so the legs alternate on their own — every still roll drew three phases and + // repeated them ("ropeskipping, not running"). WHOLE frames, cut and + // palette-snapped to his settled art; the phases are spaced across one + // measured stride (his loped: 23 clip frames against her 6). + '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 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_swing1','o_swing2', 'o_run1','o_run2', // TWO frames, a true leg-swap pair — the whole 2026-08-14 tuning session's verdict: fewer, better-chosen frames at a calm rate beat every 6/8-frame roll ('this looks better than anything before') @@ -1002,7 +1008,7 @@ function drawBenchHud() { ctx.textAlign = 'center'; ctx.font = B(20); shadowText(b.char - ? 'PLAYER ' + (b.char === 'of' ? 'FEMALE' : 'MALE') + ' · RUN CYCLE' + ? (b.char[0] === 'p' ? 'PLAYER ' : 'OPPONENT ') + (b.char.endsWith('f') ? 'FEMALE' : 'MALE') + ' · RUN CYCLE' : (ZFAMS.length > 1 ? ZFAMS[b.fam].toUpperCase() + ' · ' : '') + BENCH[b.i].name, W / 2, y, UI.cream); if (b.char) { // the finetune readout: the dials a live tuning session actually turns @@ -1496,9 +1502,9 @@ function drawPiles(near) { // split at the net like zombies so height occludes c // deliberate: a cached 404 on one female frame degrades to a man for a frame, it does not blink the // body out of existence (the same rule drawZombie's missing-frame guard is built on). const SK = n => { - // the bench's living slots pick the suffix directly (G.bench.char 'o'/'of'), so both tennis - // opponents can be reviewed without touching the picked tour - const sfx = G.bench && G.bench.char != null ? (G.bench.char === 'of' ? '_f' : '') : tour.sfx; + // the bench's living slots pick the suffix directly (G.bench.char 'o'/'of'/'p'/'pf'), so all four + // tennis bodies can be reviewed without touching the picked tour + const sfx = G.bench && G.bench.char != null ? (G.bench.char.endsWith('f') ? '_f' : '') : tour.sfx; return (sfx && SPR[n + sfx]) ? n + sfx : n; }; @@ -1653,23 +1659,24 @@ function drawChar(ch) { // (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. // TUNE.runN: live override for the bench's tuning sessions (0 = probe the sheet as always) - const runN = TUNE.runN || (pre === 'p_' ? 2 : SPR[pre + 'run8'] ? 8 : SPR[pre + 'run6'] ? 6 : SPR[pre + 'run4'] ? 4 : 2); + // ...probed through SK, so the WOMEN'S sheet is counted when the women's tour is on. Her clip + // gave a six-frame stride and his five; a probe blind to the suffix would have played five of + // her six and cut the cycle mid-stride. + const runN = TUNE.runN || [8, 6, 5, 4].find(n => SPR[SK(pre + 'run' + n)]) || 2; // 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. - // The PLAYER runs a two-frame pair like the opponents now, cut from his rolled sheet by eye: - // the man's true leg-swap is frames 5+6, the woman's is 1+2. The offset only applies when the - // pair is running — TUNE.runN = 6 on the bench still reviews the full roll from frame 1. - const runOfs = pre === 'p_' && runN === 2 - && !(G.bench && G.bench.char != null ? G.bench.char === 'of' : tour.sfx === '_f') ? 4 : 0; - if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * (runN >= 6 ? TUNE.runHz : TUNE.runHz2) * runN) % runN + 1 + runOfs); + // The PLAYER runs a two-frame pair like the opponents. It used to be picked out of a six-frame + // roll by eye (his swap was frames 5+6, hers 1+2); the man's pair is now DRAWN as a pair — + // one back-view figure plus its _stride.py twin — so the pair is always frames 1+2. + if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * (runN > 2 ? TUNE.runHz : TUNE.runHz2) * 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'); if (ch.pose === 'serve') name = pre + (G.phase === 'toss' ? 'toss' : 'smash'); // the run bob rides the STRIDE, not a wall clock: one dip per step, in phase with the frame // flip — the old fixed 18 Hz toggle read as independent hopping over the calm 2-frame cycle - if (ch.pose === 'run') p.y -= Math.floor(ch.runT * (runN >= 6 ? TUNE.runHz : TUNE.runHz2) * runN) % 2; + if (ch.pose === 'run') p.y -= Math.floor(ch.runT * (runN > 2 ? TUNE.runHz : TUNE.runHz2) * runN) % 2; const img = PS(name); drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06); if (img) { diff --git a/test.js b/test.js index 9b9bc22..b6b30f7 100644 --- a/test.js +++ b/test.js @@ -1251,8 +1251,9 @@ test('classic wears the match feel, and the difficulty table is a ladder', () => assert.ok(rallyFeel(), 'classic is a rally mode'); assert.ok(assistK() >= TUNE.matchAssist, 'classic gets the match assist floor'); G.classic = false; - // ...and DIFFS climbs monotonically on every dial, with MEDIUM pinned dial-for-dial to the - // shipped TUNE values so the middle chip can never drift from what "no difficulty" played like. + // ...and DIFFS climbs monotonically on every dial. MEDIUM used to be pinned dial-for-dial to the + // shipped TUNE values; that pin is gone (Oliver, 2026-08-15: "medium is too easy") — the middle + // chip is now its own tuning, above what "no difficulty" played like, and HARD stepped back. // (The first cut scaled only flub/cap/speed and Oliver felt no difference — reaction, reach, // qMin and serve are the dials a player actually reads as skill.) const [e, m, h] = cfg.DIFFS; @@ -1265,15 +1266,11 @@ test('classic wears the match feel, and the difficulty table is a ladder', () => assert.ok(e.serve < m.serve && m.serve < h.serve, 'harder serves better'); assert.ok(e.smart <= m.smart && m.smart < h.smart, 'harder plays the open court'); assert.ok(e.recover <= m.recover && m.recover < h.recover, 'harder recovers to centre faster'); - assert.equal(m.smart, 0, 'MEDIUM aims at random, as the shipped classic did'); - assert.equal(m.recover, 0.5, 'MEDIUM keeps the shipped recovery jog'); - assert.equal(m.flub, TUNE.classicFlub, 'MEDIUM flub IS classicFlub'); - assert.equal(m.quality, TUNE.classicQualityMax, 'MEDIUM quality IS classicQualityMax'); - assert.equal(m.speed, 1.0, 'MEDIUM leaves aiSpeed alone'); - assert.equal(m.qMin, TUNE.aiQualityMin, 'MEDIUM floor IS aiQualityMin'); - assert.equal(m.reaction, TUNE.aiReaction, 'MEDIUM reaction IS aiReaction'); - assert.equal(m.reach, TUNE.aiReach, 'MEDIUM reach IS aiReach'); - assert.equal(m.serve, 1.0, 'MEDIUM leaves the serve alone'); + // EASY is a step below the shipped no-difficulty CPU, MEDIUM a step above it — that ordering is + // the point of the retune, so it stays pinned even though the exact numbers are free to move. + assert.ok(e.flub > TUNE.classicFlub && m.flub < TUNE.classicFlub, 'EASY misses more than shipped, MEDIUM less'); + assert.ok(e.reaction > TUNE.aiReaction && m.reaction < TUNE.aiReaction, 'MEDIUM reads faster than shipped'); + assert.ok(e.reach < TUNE.aiReach && m.reach > TUNE.aiReach, 'MEDIUM out-reaches shipped'); }); // ---- NETPLAY (src/net.js + beats.startNetVs). The shot is the sync unit and THE RECEIVER OF A