diff --git a/PROJECT.md b/PROJECT.md index 31f284f..e602674 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1805,6 +1805,33 @@ 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 — 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 +player's four frames measured 91/110/92/98 px wide, frames 1/3 and 2/4 were near-duplicates drawn +differently, and the racket was missing outright. Oliver: *"really awkward... I think we need more +frames to sell this as good running"* — the frame COUNT was never the fault, the drift was. + +`assets/_runroll.py` re-rolled all four characters (player m/f, opponent m/f) on the family-pipeline +discipline: identity from a composed reference of SETTLED frames (`_pose_ref_run_.png`, tracked, +no API — the broken run frames stay OUT of it), poses from skeleton language in the prompt, one 4K +21:9 call per character, `_fine.py` cut, stride gate, judged by eye per roll. What it added to the +pipeline's knowledge: + +- **The model negotiates the COUNT.** Asked for 8 it returned 7, then 6, then 8 — and the row it + draws at its own count is the coherent one. The cutter accepts 6..8 and `drawChar` probes + run8/run6/run4/run2, so a good 6 beats a fought-for 8. State the count you can SEE with `--n` + when the figures touch (a low racket bridges every blob gap). +- **A run cycle is the one pose set whose generator cliché is RIGHT.** No pose reference needed — + pin only the frame count, the facing, the racket ("a figure with an empty hand is WRONG", early + and loud, after roll 1 dropped the racket from six figures of seven). +- **A back view has no direction.** Roll 1's "seen from behind, sprinting right" came back running + straight away up-court. The working language: back THREE-QUARTER view, body angled toward the + right, "NOT a straight-away back view: he moves ACROSS the frame". +- **The old male opponent art faced LEFT and had been MOONWALKING right since install** (char art + faces RIGHT by convention — the gameplay.js note at the swing aim). The new rows are all drawn + facing right, which quietly fixed it. + ## THE ZOMBIE FAMILY PIPELINE — one sheet, one character, 31 sprites (2026-08-12) **This replaces the nine-batch flow above for a whole opponent family.** `_regen.py` still exists and diff --git a/assets/_pose_ref_run_o.png b/assets/_pose_ref_run_o.png new file mode 100644 index 0000000..74161a7 Binary files /dev/null and b/assets/_pose_ref_run_o.png differ diff --git a/assets/_pose_ref_run_of.png b/assets/_pose_ref_run_of.png new file mode 100644 index 0000000..7ca844f Binary files /dev/null and b/assets/_pose_ref_run_of.png differ diff --git a/assets/_pose_ref_run_p.png b/assets/_pose_ref_run_p.png new file mode 100644 index 0000000..fcc19a3 Binary files /dev/null and b/assets/_pose_ref_run_p.png differ diff --git a/assets/_pose_ref_run_pf.png b/assets/_pose_ref_run_pf.png new file mode 100644 index 0000000..704942d Binary files /dev/null and b/assets/_pose_ref_run_pf.png differ diff --git a/assets/_runroll.py b/assets/_runroll.py new file mode 100644 index 0000000..2d5f693 --- /dev/null +++ b/assets/_runroll.py @@ -0,0 +1,203 @@ +# _runroll.py — roll ONE character's 8-frame RUN CYCLE: one call, one row, one character. +# +# uv run --with pillow --with numpy --with requests python assets/_runroll.py p # compose ref + roll +# uv run --with pillow --with numpy --with requests python assets/_runroll.py p --cut # cut + gate -> review dir +# uv run --with pillow --with numpy --with requests python assets/_runroll.py p --install # cut + gate -> assets/ +# keys: p (player m), pf (player f), o (opponent m), of (opponent f) +# +# WHY. The p_run*/o_run* frames were generated ONE AT A TIME in the pre-pipeline era, and it shows, +# measurably: the male player's four frames are 91/110/92/98 px wide, frames 1/3 and 2/4 are near +# duplicates of each other drawn differently, and the racket is missing outright. That is the drift +# the family pipeline exists to make impossible, so the run cycles go through the same discipline: +# +# IDENTITY COMES FROM A COMPOSED REFERENCE — settled frames already on disk, pasted locally at one +# scale, NEAREST, no API round trip (assets/_pose_ref_run_.png, tracked). The broken run frames +# stay OUT of it: the model copies what it is shown, and they are the defect being replaced. +# THE POSES COME FROM THE PROMPT'S SKELETON LANGUAGE, not from example art — a run cycle is the +# one pose set the generator's own cliché is RIGHT about; we only pin the frame count, the order, +# the facing and the racket. +# +# FACING: every char sprite is drawn facing RIGHT and render.js mirrors on facing < 0 (see the note +# at gameplay.js's swing aim). The old o_run art faced LEFT — the male opponent has been moonwalking +# to the right since it was installed — so the facing is pinned in the prompt and gated after the cut. +import base64, os, sys + +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_MODEL', 'google/gemini-3-pro-image') +N = 8 # frames per cycle — render.js probes run4/run8 and subdivides the same stride +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 +# stop the model averaging the reference into a generic figure), view (the camera + direction). +CHARS = { + 'p': {'id': ['p_idle1', 'p_bounce', 'p_swing1', 'p_smash', 'p_toss'], 'install': ('p_run', ''), + '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'}, + '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'}, + '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, ' + 'carrying a wooden tennis racket', + 'view': 'in three-quarter FRONT view facing the camera like image 1, sprinting toward the ' + 'RIGHT edge of the frame'}, + 'of': {'id': ['o_idle1_f', 'o_bounce_f', 'o_swing1_f', 'o_smash_f', 'o_toss_f'], 'install': ('o_run', '_f'), + 'who': 'the female tennis opponent of image 1: a light-brown ponytail, a red polo shirt, a ' + 'dark grey pleated skirt, dark red shoes, carrying a wooden tennis racket', + 'view': 'in three-quarter FRONT view facing the camera like image 1, sprinting toward the ' + 'RIGHT edge of the frame'}, +} + +MAGENTA = (255, 0, 255) +REF_SIZE = (6720, 2880) # 21:9 — the aspect a one-row-of-eight roll is asked at, so the reference +MARGIN = 0.03 # is composed at the same enum value and nothing gets rearranged to fit. +GAP = 0.22 # gap between identity figures, as a fraction of their height + + +def ref_path(k): return os.path.join(HERE, f'_pose_ref_run_{k}.png') + + +def compose(k): + """The identity reference: the character's SETTLED frames, one scale, NEAREST, no API.""" + frames = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in CHARS[k]['id']] + rh = max(f.height for f in frames) + gap = round(rh * GAP) + cw = sum(f.width for f in frames) + gap * (len(frames) - 1) + s = min(REF_SIZE[0] * (1 - 2 * MARGIN) / cw, REF_SIZE[1] * (1 - 2 * MARGIN) / rh) + sheet = Image.new('RGB', REF_SIZE, MAGENTA) + x = round((REF_SIZE[0] - cw * s) / 2) + ground = round((REF_SIZE[1] + rh * s) / 2) # one ground line, bottom-aligned + for f in frames: + r = f.resize((round(f.width * s), round(f.height * s)), Image.NEAREST) + sheet.paste(r, (x, ground - r.height), r) + x += r.width + round(gap * s) + sheet.save(ref_path(k)) + print(f'{ref_path(k)} {sheet.size} {len(frames)} figures at one scale {s:.2f}') + + +def prompt(k): + 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' + 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' + f'EVERY ONE OF THE {N} FIGURES GRIPS THE WOODEN TENNIS RACKET IN THE RIGHT HAND, carried low ' + 'at the side mid-run. A figure with an empty hand is WRONG.\n\n' + f'THE {N} FIGURES ARE ONE CONTINUOUS RUN CYCLE, {c["view"]}. Evenly spaced, every figure on ' + 'one shared ground line, every figure at the same scale as its neighbours.\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; 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' + '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' + '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, ' + 'no ground lines.') + + +def api_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 u(p): + return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode() + + +def roll(k): + r = requests.post('https://openrouter.ai/api/v1/images', + headers={'Authorization': f'Bearer {api_key()}', 'Content-Type': 'application/json'}, + # 4K and 21:9 spelled out: the endpoint defaults to 1K and snaps aspect to an enum — the + # reference is composed at 21:9 for exactly this reason (see PROJECT.md, the API facts). + json={'model': MODEL, 'prompt': prompt(k), 'resolution': '4K', 'aspect_ratio': '21:9', + 'input_references': [{'type': 'image_url', 'image_url': {'url': u(ref_path(k))}}]}, + timeout=1800) + if r.status_code != 200: + sys.exit(f'{k}: HTTP {r.status_code} {r.text[:300]}') + d = r.json().get('data') or [] + if not d: + sys.exit(f'{k}: no image returned') + b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1] + os.makedirs(SCRATCH, exist_ok=True) + out = os.path.join(SCRATCH, f'run_{k}.png') + open(out, 'wb').write(base64.b64decode(b)) + print(f'{k}: {out} {Image.open(out).size}', flush=True) + + +def cut(k, install=False, n=0): + """Cut the roll with _fine's machinery, gate the stride, and (on --install) place the frames. + + THE COUNT IS WHAT THE MODEL DREW, 6..8 — asked for 8, it returns 6 or 7 as often as not, and a + clean 6-frame cycle beats a fought-for 8 (the row it drew at its own count is the coherent one). + render.js probes run8/run6/run4, so the game wires whichever count survives review. + + `--n ` states the count when the figures TOUCH (a low-carried racket bridges every gap, + so blob-counting reads one figure): the count is then a thing you judged by eye off the roll, + which is the pipeline's own rule, and _fine's valley rescue does the cutting.""" + sys.path.insert(0, HERE) + import _fine + from _famref import legs, stride_diff + from PIL import Image as I + sheet = os.path.join(SCRATCH, f'run_{k}.png') + if not os.path.exists(sheet): sys.exit(f'{sheet}: no roll — run `_runroll.py {k}` first') + got = n or len(_fine.frames(_fine.key(I.open(sheet)), [])) # count what actually arrived + if not 6 <= got <= 8: + sys.exit(f'{k}: {got} figures on the sheet — outside 6..8. Re-roll, or state the count ' + 'you can see with --n ') + pre, suf = CHARS[k]['install'] + outdir = HERE if install else os.path.join(SCRATCH, f'runcut_{k}') + os.makedirs(outdir, exist_ok=True) + outs = [os.path.join(outdir, f'{pre}{i}{suf}.png') for i in range(1, got + 1)] + _fine.main(sheet, outs, TARGET_H, [], -1) + # THE STRIDE GATE, on what was actually cut: every neighbouring pair (wrapping — it loops) must + # differ in the legs, or the cycle holds a duplicated pose. Threshold calibrated against + # _famref's (30% = a real stride pair; 12 catches only true near-duplicates). + L = [legs(Image.open(o)) for o in outs] + bad = [] + for i in range(got): + d = stride_diff(L[i], L[(i + 1) % got]) + print(f' stride {i + 1}->{(i + 1) % got + 1}: {d:5.1f}%') + if d < 12: bad.append(f'{i + 1}->{(i + 1) % got + 1} at {d:.1f}%') + if bad: + sys.exit('GATE FAILED: near-identical neighbour frames: ' + ', '.join(bad)) + print(f'{k}: {got} frames -> {outdir}' + ('' if install else ' (review, then --install)')) + + +if __name__ == '__main__': + argv = sys.argv[1:] + n = 0 + if '--n' in argv: i = argv.index('--n'); n = int(argv[i + 1]); del argv[i:i + 2] + args = [a for a in argv if not a.startswith('-')] + if len(args) != 1 or args[0] not in CHARS: + sys.exit(f'usage: _runroll.py [--cut|--install] [--n ] keys: {" ".join(CHARS)}') + k = args[0] + if '--install' in argv: cut(k, install=True, n=n) + elif '--cut' in argv: cut(k, n=n) + else: compose(k); roll(k) diff --git a/assets/o_run1.png b/assets/o_run1.png index 77e808e..79001b0 100644 Binary files a/assets/o_run1.png and b/assets/o_run1.png differ diff --git a/assets/o_run1_f.png b/assets/o_run1_f.png index 81f9295..5a550bb 100644 Binary files a/assets/o_run1_f.png and b/assets/o_run1_f.png differ diff --git a/assets/o_run2.png b/assets/o_run2.png index cf122fd..88388c6 100644 Binary files a/assets/o_run2.png and b/assets/o_run2.png differ diff --git a/assets/o_run2_f.png b/assets/o_run2_f.png index 0f28cff..e7b4105 100644 Binary files a/assets/o_run2_f.png and b/assets/o_run2_f.png differ diff --git a/assets/o_run3.png b/assets/o_run3.png index d929975..b040929 100644 Binary files a/assets/o_run3.png and b/assets/o_run3.png differ diff --git a/assets/o_run3_f.png b/assets/o_run3_f.png new file mode 100644 index 0000000..9c1f184 Binary files /dev/null and b/assets/o_run3_f.png differ diff --git a/assets/o_run4.png b/assets/o_run4.png index 845125f..87bbcee 100644 Binary files a/assets/o_run4.png and b/assets/o_run4.png differ diff --git a/assets/o_run4_f.png b/assets/o_run4_f.png new file mode 100644 index 0000000..f97260e Binary files /dev/null and b/assets/o_run4_f.png differ diff --git a/assets/o_run5.png b/assets/o_run5.png new file mode 100644 index 0000000..e45f024 Binary files /dev/null and b/assets/o_run5.png differ diff --git a/assets/o_run5_f.png b/assets/o_run5_f.png new file mode 100644 index 0000000..6f9058a Binary files /dev/null and b/assets/o_run5_f.png differ diff --git a/assets/o_run6.png b/assets/o_run6.png new file mode 100644 index 0000000..71e3e9b Binary files /dev/null and b/assets/o_run6.png differ diff --git a/assets/o_run6_f.png b/assets/o_run6_f.png new file mode 100644 index 0000000..f74fc1d Binary files /dev/null and b/assets/o_run6_f.png differ diff --git a/assets/o_run7.png b/assets/o_run7.png new file mode 100644 index 0000000..4e92674 Binary files /dev/null and b/assets/o_run7.png differ diff --git a/assets/o_run7_f.png b/assets/o_run7_f.png new file mode 100644 index 0000000..6a59e00 Binary files /dev/null and b/assets/o_run7_f.png differ diff --git a/assets/o_run8.png b/assets/o_run8.png new file mode 100644 index 0000000..0efda55 Binary files /dev/null and b/assets/o_run8.png differ diff --git a/assets/o_run8_f.png b/assets/o_run8_f.png new file mode 100644 index 0000000..1c25777 Binary files /dev/null and b/assets/o_run8_f.png differ diff --git a/assets/p_run1.png b/assets/p_run1.png index 49c7d9b..e4cda31 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 82a4096..d8c37d7 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 e326827..4522cb1 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 4b30bcb..21f55b9 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 fa4f701..627652b 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 6529c8d..1d27b5b 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 399f77b..28c0278 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 79faad0..24d8e91 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 new file mode 100644 index 0000000..5664c65 Binary files /dev/null and b/assets/p_run5.png differ diff --git a/assets/p_run5_f.png b/assets/p_run5_f.png new file mode 100644 index 0000000..0d7e575 Binary files /dev/null and b/assets/p_run5_f.png differ diff --git a/assets/p_run6.png b/assets/p_run6.png new file mode 100644 index 0000000..d072e6d Binary files /dev/null and b/assets/p_run6.png differ diff --git a/assets/p_run6_f.png b/assets/p_run6_f.png new file mode 100644 index 0000000..36ce399 Binary files /dev/null and b/assets/p_run6_f.png differ diff --git a/index.html b/index.html index 4fde2f3..fa62d0b 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/render.js b/src/render.js index 566f8ea..c84f720 100644 --- a/src/render.js +++ b/src/render.js @@ -221,15 +221,16 @@ 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 = 107; +export const SPRV = 108; // 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 }; const SPR = {}; -for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5', +for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_run5','p_run6','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_run1','o_run2','o_swing1','o_swing2', + 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_swing1','o_swing2', + 'o_run1','o_run2','o_run3','o_run4','o_run5','o_run6','o_run7','o_run8', // the rolled 8-frame cycle (_runroll.py) 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4', 'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set 'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay) @@ -277,10 +278,8 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' 'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS // THE WOMEN'S TOUR. Same poses, same 224 px anchor, a different body — see SK(). // Only the two players and the turned CPU have a twin; everything above is shared. - // No o_run3_f/o_run4_f on purpose: runN reads the MEN'S table, where o_run4 is not - // preloaded, so the opponent runs on two frames whichever tour you picked. 'p_idle1_f','p_idle2_f','p_bounce_f','p_bounce2_f','p_toss_f','p_smash_f','p_pull_f', - 'p_run1_f','p_run2_f','p_run3_f','p_run4_f','p_swing1_f','p_swing2_f', + 'p_run1_f','p_run2_f','p_run3_f','p_run4_f','p_run5_f','p_run6_f','p_swing1_f','p_swing2_f', // The death is DRAWN for frames 1-3 and RECOLOURED for 4-5. The generator refuses to // draw a dead woman in any wording worth using, and frames 4-5 survive that fine — // she is flat and face down, then a heap of torn cloth — so those two are the men's @@ -288,7 +287,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' // could actually see was blue popping in after three frames of white. 'p_die1_f','p_die2_f','p_die3_f','p_die4_f','p_die5_f', 'o_idle1_f','o_idle2_f','o_bounce_f','o_bounce2_f','o_toss_f','o_smash_f', - 'o_run1_f','o_run2_f','o_swing1_f','o_swing2_f', + 'o_run1_f','o_run2_f','o_run3_f','o_run4_f','o_run5_f','o_run6_f','o_run7_f','o_run8_f','o_swing1_f','o_swing2_f', 'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f', 'z_r1_f','z_r2_f','z_r3_f','z_r4_f', 'z_rw1_f','z_rw1b_f','z_rw1s_f','z_rw2_f','z_rw2b_f','z_rw2s_f', @@ -1444,10 +1443,11 @@ function drawChar(ch) { // on a phone, at thumb speed, the middle stages are never read — the three states are the beat. if (G.aim && G.aim.mag > 0 && ch.side === 'P' && score.server === 'P' && ch.pose === 'idle') name = 'p_pull'; - // Four-frame run where the frames exist (the player), two where they don't (the opponent). Same - // CYCLE rate either way — more frames subdivide the stride, they do not speed it up, exactly as - // ZFRAMES does for the shamblers. - const runN = SPR[pre + 'run4'] ? 4 : 2; + // The run cycle at whatever count the character's sheet carries: the _runroll.py cycles are 6 + // (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. Same CYCLE rate at every count — more + // frames subdivide the stride, they do not speed it up, exactly as ZFRAMES does for the shamblers. + const runN = SPR[pre + 'run8'] ? 8 : SPR[pre + 'run6'] ? 6 : SPR[pre + 'run4'] ? 4 : 2; if (ch.pose === 'run') name = pre + 'run' + (Math.floor(ch.runT * 4.5 * 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.)