diff --git a/PROJECT.md b/PROJECT.md index fb382ba..91df6db 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/assets/_frontroll.py b/assets/_frontroll.py new file mode 100644 index 0000000..65acc29 --- /dev/null +++ b/assets/_frontroll.py @@ -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) diff --git a/assets/_zsheet.py b/assets/_zsheet.py index bccc63e..8fb103e 100644 --- a/assets/_zsheet.py +++ b/assets/_zsheet.py @@ -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)): diff --git a/assets/n_bounce.png b/assets/n_bounce.png new file mode 100644 index 0000000..524ab15 Binary files /dev/null and b/assets/n_bounce.png differ diff --git a/assets/n_bounce2.png b/assets/n_bounce2.png new file mode 100644 index 0000000..211fd59 Binary files /dev/null and b/assets/n_bounce2.png differ diff --git a/assets/n_bounce2_f.png b/assets/n_bounce2_f.png new file mode 100644 index 0000000..b61bdcd Binary files /dev/null and b/assets/n_bounce2_f.png differ diff --git a/assets/n_bounce_f.png b/assets/n_bounce_f.png new file mode 100644 index 0000000..cc2dfda Binary files /dev/null and b/assets/n_bounce_f.png differ diff --git a/assets/n_idle1.png b/assets/n_idle1.png new file mode 100644 index 0000000..9f605a8 Binary files /dev/null and b/assets/n_idle1.png differ diff --git a/assets/n_idle1_f.png b/assets/n_idle1_f.png new file mode 100644 index 0000000..53402f2 Binary files /dev/null and b/assets/n_idle1_f.png differ diff --git a/assets/n_idle2.png b/assets/n_idle2.png new file mode 100644 index 0000000..ed3b559 Binary files /dev/null and b/assets/n_idle2.png differ diff --git a/assets/n_idle2_f.png b/assets/n_idle2_f.png new file mode 100644 index 0000000..3b715ed Binary files /dev/null and b/assets/n_idle2_f.png differ diff --git a/assets/n_run1.png b/assets/n_run1.png new file mode 100644 index 0000000..d1e1974 Binary files /dev/null and b/assets/n_run1.png differ diff --git a/assets/n_run1_f.png b/assets/n_run1_f.png new file mode 100644 index 0000000..90e171f Binary files /dev/null and b/assets/n_run1_f.png differ diff --git a/assets/n_run2.png b/assets/n_run2.png new file mode 100644 index 0000000..5f1b8c8 Binary files /dev/null and b/assets/n_run2.png differ diff --git a/assets/n_run2_f.png b/assets/n_run2_f.png new file mode 100644 index 0000000..c566d3f Binary files /dev/null and b/assets/n_run2_f.png differ diff --git a/assets/n_smash.png b/assets/n_smash.png new file mode 100644 index 0000000..8d512a8 Binary files /dev/null and b/assets/n_smash.png differ diff --git a/assets/n_smash_f.png b/assets/n_smash_f.png new file mode 100644 index 0000000..73ba6cb Binary files /dev/null and b/assets/n_smash_f.png differ diff --git a/assets/n_swing1.png b/assets/n_swing1.png new file mode 100644 index 0000000..2148fc7 Binary files /dev/null and b/assets/n_swing1.png differ diff --git a/assets/n_swing1_f.png b/assets/n_swing1_f.png new file mode 100644 index 0000000..99b3811 Binary files /dev/null and b/assets/n_swing1_f.png differ diff --git a/assets/n_swing2.png b/assets/n_swing2.png new file mode 100644 index 0000000..7d11eb4 Binary files /dev/null and b/assets/n_swing2.png differ diff --git a/assets/n_swing2_f.png b/assets/n_swing2_f.png new file mode 100644 index 0000000..f9c742f Binary files /dev/null and b/assets/n_swing2_f.png differ diff --git a/assets/n_toss.png b/assets/n_toss.png new file mode 100644 index 0000000..f7d772d Binary files /dev/null and b/assets/n_toss.png differ diff --git a/assets/n_toss_f.png b/assets/n_toss_f.png new file mode 100644 index 0000000..32740fa Binary files /dev/null and b/assets/n_toss_f.png differ diff --git a/index.html b/index.html index 7defda3..06fbb60 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 82555a4..642c018 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/render.js b/src/render.js index b94543e..fc1dc99 100644 --- a/src/render.js +++ b/src/render.js @@ -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 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, diff --git a/test.js b/test.js index fd86baa..79b10bf 100644 --- a/test.js +++ b/test.js @@ -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