The friend across the net wears your kit, drawn from the front

His colour landed on the shirt while yours lands on the shorts, because he was
drawn on the roster opponent's red sheets and dyed on an arc of his own. Red is
the one hue that art cannot hang a kit on: the men's shorts are neutral grey at
saturation 0.05, so no rotation ever reached them, and the women's red top sits
at her skin's and her hair's hue — a dark pick was painting her arms and her
ponytail, live, on the shipped build.

So he gets his own sheets. `assets/_frontroll.py` is `_famroll`'s trick pointed
at the tennis poses — image 1 is the ten `o_*` frames composed from disk, image 2
is the player from behind — and two calls drew all twenty. He rides the PLAYER's
arcs and the tour's own mode now: his shorts dye, her whites wash. `TINT.oKit` is
gone. The roster CPU keeps the red art; it is not a person.

The setup screen shows that front too — picking a face off the back of a head was
only ever what the art allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-20 16:04:10 +02:00
1 parent 4052ab1404
commit 5f9170b7d7
27 files changed
+324 -31

No files matched your search

+50 -1
View File
@@ -20,7 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
Last updated 2026-08-18 (the store model in LATER revised: free download + €2.99 unlock-everything
Last updated 2026-08-20 (the friend across the net gets his OWN sheets — the player's kit, drawn
from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
finetuning pass — gore opens in the gore, the menu theme
comes back, one world board per mode, and the barrel economy cut — over the batch below: six new
@@ -2291,6 +2293,7 @@ it is the WRONG tool, superseded by the reference-sheet flow. Route by job:
| Rebuild the pose reference | `_famref.py` — composed from disk, no API call, gated. |
| 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`. |
| THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | `_frontroll.py` — famroll's trick pointed at the tennis poses: image 1 is the ten `o_*` frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten `_regen` batches would have been ten chances for the man to drift. |
| 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`. |
@@ -2533,6 +2536,52 @@ 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 FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20)
Oliver, on a netplay match: *"the opponent color is displayed correctly, but the shirt is colored not
the trousers (men) and for women it is not the whole dress"* — and his own diagnosis was the right
one: **we did not have the player character from the front.** The friend was drawn on the ROSTER
opponent's sheets (`o_*`: red polo, dark grey shorts) and dyed on a third hue arc, `TINT.oKit`.
**Red is the one hue a kit cannot own on this art, and no arc could have fixed it.** Both halves
were measured before anything was generated:
- On the men's sheet the only saturated red is the SHIRT — the shorts are neutral grey at
saturation 0.05, so a hue rotation can never reach them. Your own pick dyes your SHORTS. Two
people in one match, wearing their colour on different garments.
- On the women's sheet the red top sits at the same hue as her SKIN and her HAIR (top h≈0 and 336-355,
shaded skin h 13-24, and the arc's own floor is satKit 0.15). Rendering the shipped tint with the
ACID pick put green blotches down her arms, her legs and her ponytail. That was live, and nobody
had looked at it.
**The repaint was tried first and it fails, which is worth knowing.** Mapping the red kit to white
and the grey shorts to blue offline — no API, no new art — gives a genuinely good MAN (white polo,
blue shorts, right shoes) and a ruined WOMAN: the same hue overlap eats her hair, her face and her
leg shading. `_famref`'s `bed_magenta` is also a trap on this art — it repaints the largest PALE
blob, which is the racket bed on the z_r* master but a SHOE here, so `_frontroll.open_bed()` asks
whether the bed is already keyed out before touching a frame.
So the friend got his own sheets: `n_*`, the player's kit drawn from the FRONT, tinted through the
PLAYER's arcs and the tour's own mode — his shorts dye, her whites wash, exactly as yours do.
`TINT.oKit` is deleted. The roster CPU keeps `o_*` and stays as drawn; it is not a person.
**Two rolls made all twenty sprites, and the method is the reason.** `_frontroll.py` is `_famroll`'s
trick applied to tennis: IMAGE 1 is the POSES (the ten installed `o_*` frames composed from disk at
one scale, 4/4/2, gated for band and figure count before a call is made), IMAGE 2 is the CHARACTER
(`p_idle1`, from behind — the model is told outright that it is inventing the face). `_zsheet.cut()`
takes a `want`/`level` pair now so a non-zombie family can reuse the cutter; `level` names the frame
groups that must come back the same height (the idle pair, the bounce pair, the stride pair — the
serve toss and the smash are meant to be taller and are left alone). Heights came back within 1 px
of the `o_*` originals they were composed from.
The one thing the first roll got wrong: it drew a TENNIS BALL into both bouncing frames. The game
draws the ball; a drawn one is a blob stuck to his hand. The prompt says so now.
**And the SETUP SCREEN uses the front too** (Oliver, same session: *"das heisst aber auch, dass wir
in der farb- und charakterauswahl den frontfacing nehmen können"*). Picking a face off the back of a
head was the best the art allowed; `drawLook` draws `n_idle1` now — same kit, same palette, same
tint pass, so it still cannot drift from what walks on.
## 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
+202
View File
@@ -0,0 +1,202 @@
# _frontroll.py — the NETPLAY OPPONENT: the player's own kit, drawn from the FRONT.
#
# uv run --with pillow --with numpy --with requests python assets/_frontroll.py m --ref
# uv run --with pillow --with numpy --with requests python assets/_frontroll.py m
# uv run --with pillow --with numpy python assets/_frontroll.py m --cut # review
# uv run --with pillow --with numpy python assets/_frontroll.py m --cut --install
#
# WHY THIS EXISTS. In a netplay match the body across the net is your FRIEND, and it was drawn on
# the roster opponent's sheets: red polo, dark grey shorts, red headband. His pick then dyed the
# only thing on that art that carries a hue — the SHIRT — while your own pick dyes your SHORTS,
# because the player's kit is white-over-blue. Two people in one match, wearing their colour on
# different garments. Worse on the women's sheets: the opponent's red sits at the same hue as her
# skin and her hair, so `TINT.oKit` bled green over her arms, her legs and her ponytail.
#
# Neither is fixable in the tint pass — the roster opponent's art simply has no hue-isolated kit —
# so the friend gets his own sheets, `n_*`, in the PLAYER'S kit: white shirt, blue shorts, and all
# whites for the women's tour, which is exactly what `TINT.kit`/`wash` are measured against.
#
# THE METHOD IS `_famroll.py`'s, and it is the reason this is two calls and not ten. IMAGE 1 is the
# POSES (the installed o_* frames, composed from disk at one scale), IMAGE 2 is the CHARACTER (the
# player, from behind) — "redraw image 1 with one change: the character". Ten separate _regen
# batches would be ten chances for the man to drift; the whole family in one roll cannot drift.
import base64, os, sys
import numpy as np
import requests
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _famref import bed_magenta # the racket bed has to key out — see _zsheet
from _zsheet import cut
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')
MAGENTA = (255, 0, 255)
SIZE = (4304, 5380) # 4:5, the enum value nearest this sheet's own aspect
MARGIN, GAP = 0.025, 60
# 4 / 4 / 2, which lands within a hair of 4:5. The pairs that MUST come back the same height sit
# next to each other so the levelling below reads them off one row.
ROWS = [['idle1', 'idle2', 'bounce', 'bounce2'],
['swing1', 'swing2', 'toss', 'smash'],
['run1', 'run2']]
# ...and these are the ones the game flips between at speed: a 2 px height difference in a pair is
# a body that hops once per bounce or once per stride (the lesson _zsheet's walk levelling records).
# The serve toss and the smash are NOT levelled — they are meant to be taller.
LEVEL = [['idle1', 'idle2'], ['bounce', 'bounce2'], ['run1', 'run2']]
WHO = {
'': ('a male tennis player in a PURE WHITE polo shirt, ROYAL BLUE shorts, a WHITE cloth headband, '
'white socks and white tennis shoes, short blond hair, lean and athletic. His shirt is WHITE, '
'never red. His shorts are BRIGHT ROYAL BLUE, never grey, never black, never navy. His '
'headband and his socks are WHITE, never red'),
'_f': ('a female tennis player dressed head to hem in TENNIS WHITES: a PURE WHITE sleeveless-cut '
'tennis top and a PURE WHITE pleated tennis skirt, white socks and white tennis shoes, dark '
'hair tied back in a ponytail. Her top is WHITE, never red. Her skirt is WHITE, never dark '
'grey and never black. Every garment she wears is white'),
}
def open_bed(im):
"""Transparent pixels the border cannot reach — i.e. the racket's string bed, already keyed out.
`bed_magenta` repaints the largest PALE blob, which is right for the z_r* master (its beds are
painted) and wrong for most of these: with the bed already open the largest pale blob is a SHOE,
and the reference went out with a magenta boot on it. Ask first.
"""
a = np.array(im.convert('RGBA'))[..., 3] > 40
m = Image.new('L', (im.width + 2, im.height + 2), 0)
m.paste(Image.fromarray((a * 255).astype('uint8')), (1, 1))
ImageDraw.floodfill(m, (0, 0), 128)
return int((np.array(m)[1:-1, 1:-1] == 0).sum())
def compose(sfx):
"""IMAGE 1: the ten tennis poses, composed from the installed sprites at ONE scale. No API call."""
frames = {}
for r in ROWS:
for n in r:
im = Image.open(os.path.join(HERE, f'o_{n}{sfx}.png')).convert('RGBA')
frames[n] = im if open_bed(im) > 100 else bed_magenta(im)
row_w = [sum(frames[n].width for n in r) + GAP * (len(r) - 1) for r in ROWS]
row_h = [max(frames[n].height for n in r) for r in ROWS]
cw, ch = max(row_w), sum(row_h) + GAP * (len(ROWS) - 1)
k = min(SIZE[0] * (1 - 2 * MARGIN) / cw, SIZE[1] * (1 - 2 * MARGIN) / ch)
sheet = Image.new('RGB', SIZE, MAGENTA)
y = round((SIZE[1] - ch * k) / 2)
for row, rh in zip(ROWS, row_h):
scaled = [(n, round(frames[n].width * k), round(frames[n].height * k)) for n in row]
x = round((SIZE[0] - (sum(w for _, w, _ in scaled) + round(GAP * k) * (len(row) - 1))) / 2)
ground = y + round(rh * k) # one ground line per band, bottom-aligned
for n, w, h in scaled:
im = frames[n].resize((w, h), Image.NEAREST)
sheet.paste(im, (x, ground - h), im)
x += w + round(GAP * k)
y = ground + round(GAP * k)
p = os.path.join(SCRATCH, f'front_ref{sfx or "_m"}.png')
os.makedirs(SCRATCH, exist_ok=True)
sheet.save(p)
print(f'{p} {sheet.size} scale {k:.3f} 10 figures, {len(ROWS)} bands')
return p
def char_ref(sfx):
"""IMAGE 2: the player, at 4x NEAREST on magenta. Raw sprite pixels — the famroll rule."""
im = Image.open(os.path.join(HERE, f'p_idle1{sfx}.png')).convert('RGBA')
big = im.resize((im.width * 4, im.height * 4), Image.NEAREST)
pad = round(big.height * 0.08)
out = Image.new('RGB', (big.width + 2 * pad, big.height + 2 * pad), MAGENTA)
out.paste(big, (pad, pad), big)
p = os.path.join(SCRATCH, f'front_char{sfx or "_m"}.png')
out.save(p)
return p
def prompt(sfx):
her = sfx == '_f'
return (
'Take IMAGE 1 and redraw it with ONE change: the person wearing the poses.\n\n'
'IMAGE 1 is a sprite sheet of a tennis player on a flat magenta background: TEN figures in '
'THREE rows — a waiting pair, a ball-bouncing pair, a forehand backswing and follow-through, a '
'serve toss, a smash, and a running pair.\n\n'
f'IMAGE 2 is THE PERSON to draw instead: {WHO[sfx]}. Copy that build and every one of those kit '
'colours exactly. IMAGE 2 shows this person FROM BEHIND; every figure you draw is seen FROM '
'THE FRONT, facing the viewer, exactly as image 1\'s figures do. You are inventing the face.\n\n'
'Keep IMAGE 1 EXACTLY as it is in every other respect: the same 3 rows, the same number of '
'figures per row, the same order, the same poses, the same facing, the same sizes, the same '
'ground lines. Only the person changes. Do NOT carry over image 1\'s RED shirt, RED headband, '
'RED socks or DARK GREY shorts — no red and no dark grey garment appears anywhere in your '
'output.\n\n'
# the bounce pair is one drawing with the hand moved; the game flips between them at 3 Hz and
# any second difference reads as a flicker. Image 1 already has this property — say so.
'The two BALL-BOUNCING figures at the end of row 1 are the SAME drawing twice and differ ONLY '
'in the height of the free hand. Keep them identical in every other pixel — same stance, same '
'legs, same head, same racket, same size — exactly as image 1 has them.\n\n'
# the game draws the ball itself, and a drawn one lands in the sprite as a blob stuck to his
# hand. Image 1 has none; the first roll added one to both bouncing frames anyway.
'THERE IS NO TENNIS BALL ANYWHERE IN THE PICTURE. Do not draw a ball in any figure, not in the '
'bouncing frames, not in the serve, not in the air, not on the ground. No motion lines.\n\n'
'EVERY FIGURE STILL HOLDS THE TENNIS RACKET image 1 gives it, in the same hand, in the same '
'frames — the full racket with its head, its strings, its throat and its grip.\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 airbrushing, '
'no blur, no gradients, no anti-aliasing.\n\n'
# rule 15: the keyer deletes every pixel where red AND blue both beat green
'NOTHING in any figure is pink, magenta, violet or purple.\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 every racket head is that same EXACT #FF00FF. No text, no shadows, '
'no ground lines.'
+ (' Her skin is warm and human — it is never white and never grey; only her CLOTHES are white.'
if her else ''))
def key_env():
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(sfx):
ref, ch = compose(sfx), char_ref(sfx)
r = requests.post('https://openrouter.ai/api/v1/images',
headers={'Authorization': f'Bearer {key_env()}', 'Content-Type': 'application/json'},
json={'model': MODEL, 'prompt': prompt(sfx), 'resolution': '4K', 'aspect_ratio': '4:5',
'input_references': [{'type': 'image_url', 'image_url': {'url': u(ref)}},
{'type': 'image_url', 'image_url': {'url': u(ch)}}]},
timeout=1800)
if r.status_code != 200:
sys.exit(f'HTTP {r.status_code} {r.text[:300]}')
d = r.json().get('data') or []
if not d:
sys.exit('no image returned')
b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
out = sheet_path(sfx)
open(out, 'wb').write(base64.b64decode(b))
print(f'{out} {Image.open(out).size}', flush=True)
def sheet_path(sfx):
return os.path.join(SCRATCH, f'front{sfx or "_m"}.png')
if __name__ == '__main__':
sfx = {'m': '', 'w': '_f'}.get((sys.argv[1:2] or [''])[0])
if sfx is None: sys.exit('usage: _frontroll.py m|w [--ref] [--cut [--install]]')
if '--cut' in sys.argv:
want = [[f'n_{n}{sfx}' for n in r] for r in ROWS]
level = [[f'n_{n}{sfx}' for n in g] for g in LEVEL]
out = HERE if '--install' in sys.argv else os.path.join(SCRATCH, f'front_cut{sfx or "_m"}')
cut(sheet_path(sfx), None, out, want=want, level=level)
elif '--ref' in sys.argv:
compose(sfx); char_ref(sfx)
else:
roll(sfx)
+12 -3
View File
@@ -56,8 +56,11 @@ def bands(a):
return out
def cut(sheet, fam, outdir):
want = layout(fam)
def cut(sheet, fam, outdir, want=None, level=None):
# `want`/`level` let a NON-zombie family reuse this cutter: the netplay opponent's ten tennis
# poses are not nine rows of walk/wound (see _frontroll.py). `level` names the frame groups that
# must come back the same height, which is what the zombie branch below works out by name.
want = want or layout(fam)
src = Image.open(sheet).convert('RGB')
a = key(src)
# THE ENCLOSED MAGENTA IS THE RACKET, AND HERE IT IS MEANT TO GO. _fine.py repaints magenta that
@@ -105,8 +108,14 @@ def cut(sheet, fam, outdir):
# racket family's, at 224/224) gets a factor of 1.0 and is untouched — which is the check that
# this rule is the right one rather than a fudge for one bad sheet.
mult = [1.0] * len(crops)
if level is not None:
at = {n: j for j, n in enumerate(flat)}
for grp in level:
h0 = crops[at[grp[0]]].shape[0]
for n in grp[1:]:
if crops[at[n]].shape[0]: mult[at[n]] = h0 / crops[at[n]].shape[0]
i = 0
for row in want:
for row in want if level is None else []:
if row[0].endswith('fall1') and crops[i].shape[0]:
k_row = crops[0].shape[0] / crops[i].shape[0]
for j in range(i, i + len(row)):
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=293"></script>
<script type="module" src="src/main.js?v=294"></script>
</body>
</html>
+7 -5
View File
@@ -872,10 +872,12 @@ export const SKINS = [
// at 10. What that leaves untinted on the death frames is the reddest skin, which is the
// blood-soaked skin, and it reads as gore rather than as a miss.
//
// THE OPPONENT wears red, not blue — (184,8,8) for him, (184,48,48) for her — so the friend across
// the net in a netplay match needs his own kit arc, `oKit`, sitting around 0 deg. It is safe there
// for the one reason it would not be on the player's sheets: the opponent art carries no DRAWN
// blood (his damage is all live particles), so nothing in that arc is blood.
// (THE FRIEND ACROSS THE NET used to need a THIRD arc here, `oKit`, sitting around 0 deg, because
// he was drawn on the roster opponent's red sheets. Red was the wrong hue to hang a kit on and no
// arc could fix it: the only saturated red on the men's art is the SHIRT, so a pick never reached
// the grey shorts your own pick dyes, and on the women's art it is the same hue as her skin and her
// hair, so a dark pick painted her arms and her ponytail. He is drawn on his OWN sheets now — the
// player's kit from the front, `n_*`, see assets/_frontroll.py — and rides the two arcs below.)
//
// THE WHITES, for a 'wash' tour. Bright and barely saturated, which is exactly what her skin is
// NOT — hers reaches lum 0.77 at saturation 0.79, so the pair of limits keeps them apart with room
@@ -883,7 +885,7 @@ export const SKINS = [
// pick's saturation scaled down to a pastel, and a slight darkening without which a tint laid on a
// 0.96-lightness white is invisible.
export const TINT = {
kit: [150, 300], oKit: [345, 15], skin: [10, 45],
kit: [150, 300], skin: [10, 45],
satKit: 0.15, satSkin: 0.28, lumSkin: 0.80,
// washS/washL retuned after Oliver played it: 0.30/0.93 was a hint of colour rather than a kit
// ("a little too subtle on the white — could be more"). 0.55/0.86 reads as dyed from across the
+31 -9
View File
@@ -234,7 +234,7 @@ 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 = 140;
export const SPRV = 141;
// 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".
@@ -249,6 +249,12 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'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',
// THE FRIEND ACROSS THE NET, in YOUR kit seen from the front (assets/_frontroll.py).
// Netplay only: the roster CPU keeps the red o_ sheets, because it is a club
// opponent and not a person. The poses are the o_ family's, redrawn on the player's
// white-over-blue body, so the friend's pick dyes his SHORTS exactly as yours does.
'n_idle1','n_idle2','n_bounce','n_bounce2','n_toss','n_smash','n_swing1','n_swing2',
'n_run1','n_run2',
'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')
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
@@ -311,6 +317,8 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'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', // her pair likewise (frames 7+8 of the face-transplant cycle)
'n_idle1_f','n_idle2_f','n_bounce_f','n_bounce2_f','n_toss_f','n_smash_f',
'n_run1_f','n_run2_f','n_swing1_f','n_swing2_f', // ...and the friend in tennis whites
'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',
@@ -1618,19 +1626,30 @@ function PS(name) {
if (!img) return img;
// YOUR body, dyed or washed depending on what this tour is dressed in (see TOURS.kit)
if (n[0] === 'p' && n[1] === '_') return tinted(n, img, kit, skin, tour.kit, TINT.kit);
// ...AND THE FRIEND ACROSS THE NET, wearing what THEY picked on their own phone (netplay only —
// the roster CPU is not a person and stays exactly as drawn). His sheets are red, not blue, so
// the kit rides its own arc; his shirt is always a dye, whatever tour the art came from.
if (n[0] === 'o' && n[1] === '_' && G.net && G.net.look)
return tinted(n, img, G.net.look.kit, G.net.look.skin, 'dye', TINT.oKit);
// ...AND THE FRIEND ACROSS THE NET, wearing what THEY picked on their own phone. He is drawn on
// his OWN sheets now (n_*, the player's kit from the front), so he goes through the player's arc
// and the player's tour mode: his shorts dye and her whites wash, exactly as yours do.
//
// He used to be the roster opponent's red art on TINT.oKit, and that arc is why this changed. Red
// is the one hue the kit CANNOT own on these sheets: the only saturated red on the men's art is
// the SHIRT (his shorts are neutral grey, so a hue rotation never touched them) and on the
// women's it sits at the same hue as her skin and her hair, so a dark pick painted her arms, her
// legs and her ponytail. Blue-over-white is hue-isolated by construction — see TINT in config.
if (n[0] === 'n' && n[1] === '_' && G.net && G.net.look)
return tinted(n, img, G.net.look.kit, G.net.look.skin, tour.kit, TINT.kit);
return img;
}
// THE SETUP SCREEN'S LIVE PLAYER. Not a mock-up of the pick — the pick, run through the same tint
// and the same sheet the court will draw, so what the menu shows cannot drift from what walks on.
// the court will draw, so what the menu shows cannot drift from what walks on.
//
// It shows the FRONT of him (`n_idle1`, the netplay body), not the back he plays in. Picking a face
// off the back of a head was the best the art allowed while every player frame was a back view; the
// moment the front existed, Oliver asked for it here. It is the same kit, the same palette and the
// same tint pass, so the promise above still holds — only the side you are standing on changes.
// Returns false while the sprites are still loading, so the caller knows to come back.
export function drawLook(cv2, t, k, sk) {
const img = SPR['p_idle1' + (t.sfx || '')] || SPR.p_idle1;
const img = SPR['n_idle1' + (t.sfx || '')] || SPR['p_idle1' + (t.sfx || '')] || SPR.p_idle1;
if (!img) return false;
const out = tinted('look' + (t.sfx || ''), img, k, sk, t.kit, TINT.kit);
const g = cv2.getContext('2d');
@@ -1657,7 +1676,10 @@ function drawChar(ch) {
// clamps at his deepest stance), so P draws within a hair of his old size everywhere he stands.
const hpx = PLAYER_H * p.s * farB(ch.z);
// pick sprite
const pre = ch.side === 'P' ? 'p_' : 'o_';
// THE BODY ACROSS THE NET is a PERSON in netplay and a club opponent everywhere else, and they
// are two different sets of art: n_* is the player's own kit drawn from the front (so the friend's
// colour lands where yours does), o_* is the roster's red. See PS() for why that split exists.
const pre = ch.side === 'P' ? 'p_' : (G.net ? 'n_' : 'o_');
let name = pre + (Math.floor(G.idleT * 1.6) % 2 ? 'idle1' : 'idle2'); // idle weight shift
if (G.phase === 'pretoss' && ch.side === score.server && ch.pose === 'idle') {
// The two bounce frames are the SAME drawing from one sheet — identical stance, identical legs,
+21 -12
View File
@@ -1806,20 +1806,29 @@ test('the wash leaves the shading: her folds stay in order', () => {
assert.ok(lum(lit) > lum(shade), 'the lit fold stays lighter than the shaded one');
});
// NETPLAY: the friend across the net wears their own pick. Their sprites are RED, not blue, so the
// kit rides its own arc — safe there only because the opponent art carries no drawn blood.
test("the opponent's red kit takes a dye on its own arc, and his skin still reads as skin", () => {
const HIS_SHIRT = [184, 8, 8], HER_TOP = [184, 48, 48], HIS_SKIN = [248, 176, 128];
// NETPLAY: the friend across the net wears their own pick, on the friend's OWN sheets — n_*, the
// player's kit drawn from the front (assets/_frontroll.py). The claim this pins is that he needs no
// arc of his own any more: every colour below is measured off n_idle1.png / n_idle1_f.png, and they
// fall into the SAME two buckets the player's back-view sheets do.
//
// The old netplay art was the roster opponent's red, on a third arc (`TINT.oKit`, 345->15). Both
// halves of why that had to go are pinned here: on his sheet the only thing in that arc was the
// SHIRT, so his pick moved a different garment than yours; on hers the arc also held her skin.
test('the friend across the net rides the PLAYER arcs: his shorts dye, her whites wash, skin holds', () => {
const HIS_SHORTS = [24, 112, 176], HIS_SHIRT = [224, 216, 208], HIS_SKIN = [232, 136, 104];
const HER_KIT = [240, 240, 248], HER_SKIN = [240, 168, 136], HIS_SHOE = [176, 176, 184];
const green = cfg.KITS.find(k => k.id === 'green');
for (const [nm, c] of [['his', HIS_SHIRT], ['hers', HER_TOP]]) {
const out = tintPx(...c, green, cfg.SKINS[0], 'dye', cfg.TINT.oKit);
assert.ok(out && dom(out) === 'g', `${nm} red shirt takes the pick`);
}
// his skin must not go with it, and the player's own blue arc must not claim his shirt
assert.equal(tintPx(...HIS_SKIN, green, cfg.SKINS[0], 'dye', cfg.TINT.oKit), null,
'the opponent kit arc leaves his skin alone');
const out = tintPx(...HIS_SHORTS, green, cfg.SKINS[0], 'dye', cfg.TINT.kit);
assert.ok(out && dom(out) === 'g', 'his blue shorts take the pick — the same garment yours moves');
assert.equal(tintPx(...HIS_SHIRT, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null,
"and the PLAYER's blue arc never claims the opponent's red");
'his white shirt stays white on a dye tour');
assert.equal(tintPx(...HIS_SHOE, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null,
'and his shoe grey is under the kit floor, blue-ish hue or not');
assert.ok(dom(tintPx(...HER_KIT, green, cfg.SKINS[0], 'wash')) === 'g',
'her whole white kit takes the wash — top and skirt together, which is the fix');
for (const [nm, c] of [['his', HIS_SKIN], ['hers', HER_SKIN]])
assert.equal(tintPx(...c, green, cfg.SKINS[0], nm === 'his' ? 'dye' : 'wash', cfg.TINT.kit), null,
`${nm} skin is left alone — the old red arc is what used to claim it`);
});
// The look crosses the wire ONCE, on pairing — neither phone can change its pick mid-match, because