Both players run from a video, and the middle chip finally plays you
Six sessions of asking an image model for a row of running figures, and it
never once drew a gait: three phases repeated with the same leg leading,
"ropeskipping, not running". A frame-by-frame LEFT/RIGHT skeleton spec, a
closing checklist, a loop clause, six figures instead of eight — all lost.
One 5-second image-to-video clip has no such problem, because it moves a
body instead of drawing poses. ~$0.42 a character through the key already in
.env, pose pinned at BOTH ends (pinned only at the front, she rotated into
profile halfway), stride period MEASURED off the frames rather than sampled
by eye — hers repeats in 6 clip frames, his in 23 — and the phases cut and
palette-snapped through _stride.cut. assets/_vidroll.py films and cuts it;
her first frame came from one still roll that took his approved pose as a
picture instead of a paragraph.
Wired: runN probes the sheet through SK() so the women's tour counts HER
frames, the player is no longer clamped to a two-frame pair, runHz 2.4 ->
1.9 ("this looks like roadrunner"), and the bench grew two PLAYER stops —
a cycle only ever seen mid-rally is a cycle nobody has watched.
Also in: the difficulty retune (medium is a real step above the shipped CPU
and is where `smart` first appears; hard stepped back off superhuman; easy
closed its gap) with MEDIUM's dial-for-dial pin to TUNE removed from the
test, and Oliver's own transparency fixes on the male frames.
SPRV 138 — the frames were right on disk and wrong on screen for an hour
because assets are cached for a year unless the ?v= moves. main.js v269.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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, '
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 <key> [--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')
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
@@ -645,6 +645,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=268"></script>
|
||||
<script type="module" src="src/main.js?v=269"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<suffix> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <img> tags on the same files, and two copies of this number is one copy too
|
||||
// many: the one that does not get bumped is the one that serves a cached 404.
|
||||
export const SPRV = 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||