The whites take a wash, the friend brings their own colours, and the menu shows the man

Three things off the back of the picker.

THE LIVE PLAYER on the setup screen is the real sprite through the real tint, not
a mock-up of the pick — so the menu cannot promise a colour the court will not
keep. It redraws on every tap of a tour, a kit or a skin.

THE WOMEN'S TOUR IS NO LONGER SAT OUT. Her set is drawn head to toe in whites, so
there was no hue to rotate — the answer is not a dye but a WASH: the pick laid
over her whites at a low saturation with the lightness kept, which is how you dye
a white kit in life and gives rose, powder blue, mint, lilac. Her hair and skin
fall out of it on lightness alone, which is exactly what a dye could not manage.

THE FRIEND ACROSS THE NET now wears what they picked on their own phone. Their
look crosses on pairing, and since the opponent art is red rather than blue the
kit rides its own hue arc — safe there for the one reason it would not be on the
player's own sheets: the opponent sprites carry no drawn blood. An older build
simply never sends it and the opponent draws as before; the match still plays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 15:23:34 +02:00
1 parent 1daa3e9d6a
commit b5838b2b24
6 files changed
+194 -64

No files matched your search

+31 -10
View File
@@ -747,15 +747,21 @@ export const CUP_ROUNDS = [
];
export const TOURS = [
// `kit` — whether this tour's art HAS a kit to dye. The men's set is drawn in blue shorts, so it
// does. The women's set is drawn in WHITES: her skirt is (240,248,248)/(208,216,224), a cool-cast
// white, and the only genuinely saturated blue anywhere on her sheets is HER HAIR — same hue as
// his shorts (210-240) and overlapping in lightness, so no hue or lightness rule can tell the two
// apart. A kit tint on her set therefore dyes her hair and nothing else, which is why she is
// flagged out of it rather than fenced around. THE FIX IS ART, NOT CODE: give her set a coloured
// kit through the sprite pipeline and this becomes `kit: true` with no other change.
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null, kit: true },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', kit: false, dx: 0.46, apex: 0.78, tossDx: -0.59,
// `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the
// same operation cannot serve both.
//
// 'dye' the men. Drawn in blue shorts, so the kit pick rotates that hue and keeps the shading.
//
// 'wash' the women. Drawn head to toe in WHITES — her skirt is (240,248,248)/(208,216,224), a
// cool-cast white — so there is no hue to rotate: rotating a near-neutral does nothing,
// and the only genuinely saturated blue on her sheets is HER HAIR, at the same hue AND
// lightness as his shorts. So her whites are TINTED INSTEAD: the pick's hue laid over
// them at a low saturation, lightness kept, which is how you dye a white kit in real
// life and gives rose / powder blue / mint / lilac rather than a solid block of colour.
// Her hair and skin fall out of it on lightness alone, which is why this is safe where
// a dye was not.
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null, kit: 'dye' },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', kit: 'wash', dx: 0.46, apex: 0.78, tossDx: -0.59,
oDx: 0.48, oApex: 0.73 },
];
@@ -814,7 +820,22 @@ export const SKINS = [
// them cleanly and only just — blood is a spike at 0 deg, skin peaks at 16-24 — so the arc starts
// 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.
export const TINT = { kit: [150, 300], skin: [10, 45], satKit: 0.15, satSkin: 0.28, lumSkin: 0.80 };
//
// 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 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
// to spare — and what her hair is not either, at lum 0.25. washS/washL are the dye strength: the
// 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],
satKit: 0.15, satSkin: 0.28, lumSkin: 0.80,
whiteLum: 0.72, whiteSat: 0.60, washS: 0.30, washL: 0.93,
};
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts
+17 -12
View File
@@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, s
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
import { netConnect, netHooks } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
@@ -186,20 +186,24 @@ for (const t of TOURS) {
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
kitRowFor(t);
refreshLook();
summary();
});
tourList.appendChild(b);
}
// A TOUR PLAYED IN WHITES HAS NO KIT TO DYE (see TOURS.kit), and a row of swatches that silently
// does nothing is worse than no row — you tap all six and conclude the feature is broken. The row
// goes dead and the header says why, in the same slot that names the colour when it is live.
function kitRowFor(t) {
const off = t.kit === false;
document.getElementById('kitRow').classList.toggle('dead', off);
if (off) document.getElementById('kitName').textContent = 'PLAYED IN WHITES';
else document.getElementById('kitName').textContent =
(KITS.find(e => e.id === pickedKit) || KITS[0]).name;
// THE LIVE PLAYER, redrawn on every tap of a tour, a kit or a skin. It draws the REAL sprite through
// the REAL tint (drawLook), so there is no second recolour to keep in step with the first — the only
// way this can show a wrong colour is if the court would show it too.
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
const lookCv = document.getElementById('look');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const ok = drawLook(lookCv, t, KITS.find(e => e.id === pickedKit) || KITS[0],
SKINS.find(e => e.id === pickedSkin) || SKINS[0]);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
// --- KIT and SKIN swatches, built from the tables. Same rule as the tour tiles: tapping selects and
@@ -219,6 +223,7 @@ const swatches = (rowId, nameId, table, keyName, get, set) => {
try { localStorage.setItem(keyName, e.id); } catch {}
for (const el of row.children) el.classList.toggle('on', el === b);
label.textContent = e.name;
refreshLook();
});
row.appendChild(b);
}
@@ -228,7 +233,7 @@ let pickedKit = remembered('kit') || KITS[0].id;
let pickedSkin = remembered('skin') || SKINS[0].id;
swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
kitRowFor(TOURS.find(t => t.id === pickedTour) || TOURS[0]); // a remembered women's tour comes up dead too
refreshLook();
// --- court rows, built from the table.
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
+13 -2
View File
@@ -17,8 +17,8 @@
//
// ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded
// plan calls reconnection "the bulk of the real work" and a prototype must not start there.
import { COURT, UI } from './config.js';
import { G, P, O, ball, score, setupPoint, selectCourt, court } from './state.js';
import { COURT, UI, KITS, SKINS } from './config.js';
import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin } from './state.js';
import { pointTo, fault, showModal, tossBall } from './gameplay.js';
import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module
import { zombies } from './zombies.js';
@@ -116,6 +116,9 @@ function netSync(m) {
function netStart(side, death, courtId) {
G.net.ready = true; G.net.side = side;
// tell them how we look, the moment there is somebody to tell. Sent once, on pairing: neither
// phone can change its pick mid-match, because the setup screen is only reachable between runs.
send({ t: 'look', k: kit.id, s: skin.id });
G.modal = null; G.modalQ.length = 0; // the waiting card has done its job
// THE HOST'S COURT RIDES THE ROOM, exactly as the death-match pick does — two phones playing the
// same rally on two different surfaces disagree about the bounce as well as the picture, and the
@@ -176,6 +179,14 @@ export function netMsg(m) {
else if (m.t === 'fault') netApplyFault();
else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending
else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); }
// WHAT THEY LOOK LIKE. Their phone's kit and skin, resolved against our own tables so a build
// that has never heard of a colour falls back to the drawn one rather than drawing nothing.
// Unknown message types are ignored above, so an older build simply never sends this and their
// friend sees the opponent as drawn — the match still plays.
else if (m.t === 'look') G.net.look = {
kit: KITS.find(k => k.id === m.k) || KITS[0],
skin: SKINS.find(s => s.id === m.s) || SKINS[0],
};
}
// ticked from update() while G.net: the position stream out, the O sprite in
+60 -28
View File
@@ -1538,18 +1538,28 @@ const inArc = (h, [a, b]) => a <= b ? h >= a && h < b : h >= a || h < b;
// interesting claim in this feature is "the kit can move without the skin moving", and that claim
// lives entirely here; test.js checks it against the colours actually measured off p_idle1.png,
// with no DOM in the way. Returns null for "leave this pixel exactly as it is".
export function tintPx(r, g, b, k, sk) {
export function tintPx(r, g, b, k, sk, mode = 'dye', arc = TINT.kit) {
const [h, s, l] = rgb2hsl(r, g, b);
// each bucket carries its own fence — see TINT. The kit only needs a low saturation floor (nothing
// else on the sheet is blue, and a low floor catches its soft edges); the skin needs a floor AND a
// lightness ceiling, because the shirt's warm highlights sit above every real skin tone.
const spec = inArc(h, TINT.kit) && s >= TINT.satKit ? k
: inArc(h, TINT.skin) && s >= TINT.satSkin && l <= TINT.lumSkin ? sk : null;
if (!spec || spec.h == null) return null; // "as drawn" entries cost one branch and exit
return hsl2rgb(spec.h, Math.min(1, s * (spec.s ?? 1)), Math.min(1, l * (spec.l ?? 1)));
// THE KIT, two ways. 'dye' rotates a coloured kit's hue and needs only a low saturation floor
// (nothing else on those sheets is blue, and a low floor catches the soft edges). 'wash' lays the
// pick over a WHITE kit — bright, barely saturated — which is a different test entirely and one
// that would swallow the men's shirt, which is why it belongs to the tour and not to the pixel.
const isKit = mode === 'wash' ? (l >= TINT.whiteLum && s <= TINT.whiteSat)
: (inArc(h, arc) && s >= TINT.satKit);
if (isKit) {
if (!k || k.h == null) return null;
return mode === 'wash'
// lightness kept (times any darkening the pick asks for), the hue laid on at a pastel strength
? hsl2rgb(k.h, Math.min(1, TINT.washS * (k.s ?? 1)), Math.min(1, l * TINT.washL * (k.l ?? 1)))
: hsl2rgb(k.h, Math.min(1, s * (k.s ?? 1)), Math.min(1, l * (k.l ?? 1)));
}
// the skin needs a floor AND a ceiling: below the floor is grey, above the ceiling is shirt
if (!(inArc(h, TINT.skin) && s >= TINT.satSkin && l <= TINT.lumSkin)) return null;
if (!sk || sk.h == null) return null; // "as drawn" entries cost one branch and exit
return hsl2rgb(sk.h, Math.min(1, s * (sk.s ?? 1)), Math.min(1, l * (sk.l ?? 1)));
}
function tint(img, k, sk) {
function tint(img, k, sk, mode, arc) {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const g = c.getContext('2d');
@@ -1557,34 +1567,56 @@ function tint(img, k, sk) {
const d = g.getImageData(0, 0, c.width, c.height), px = d.data;
for (let i = 0; i < px.length; i += 4) {
if (px[i + 3] < 8) continue; // fully transparent: nothing to recolour
const out = tintPx(px[i], px[i + 1], px[i + 2], k, sk);
const out = tintPx(px[i], px[i + 1], px[i + 2], k, sk, mode, arc);
if (out) { px[i] = out[0]; px[i + 1] = out[1]; px[i + 2] = out[2]; }
}
g.putImageData(d, 0, 0);
return c;
}
// Recoloured sheets are cached PER FRAME NAME and built on first draw, not on the pick: a swatch tap
// costs nothing, and each frame pays its ~1 ms once, the first time it is actually on screen. The
// cache is keyed on the pick, so changing it mid-run (the setup screen is reachable between runs,
// and the bench can drive it live) drops the old bodies instead of showing stale ones.
// Recoloured sheets are built on FIRST DRAW, not on the pick: a swatch tap costs nothing, and each
// frame pays its ~1 ms once, the first time it is actually on screen. The cache key carries the
// whole look, so a changed pick simply misses and rebuilds — there is no invalidation to forget.
// The cap is a runaway guard for the sprite bench, which can cycle looks all afternoon.
const TINTC = new Map();
let tintKey = '';
export function tinted(n, img, k, sk, mode, arc) {
if ((!k || k.h == null) && (!sk || sk.h == null)) return img; // "as drawn" both ways: no pass
const key = n + '|' + (k ? k.id : '-') + '|' + (sk ? sk.id : '-') + '|' + mode;
let out = TINTC.get(key);
if (!out) {
if (TINTC.size > 96) TINTC.clear();
TINTC.set(key, out = tint(img, k, sk, mode, arc));
}
return out;
}
function PS(name) {
const n = SK(name), img = SPR[n];
// YOUR body only. drawChar draws both sides through this one lookup, and the man across the net
// wears the same blue out of the same sheets — recolouring him too would mean picking the
// opponent's kit as a side effect of picking your own.
if (n[0] !== 'p' || n[1] !== '_') return img;
// a tour whose art has no kit to dye (see TOURS) sits the kit pick out entirely — on her sheets
// the only thing in the kit's hue arc is her hair
const k = tour.kit === false ? null : kit;
const key = (k ? k.id : '-') + '/' + skin.id;
if (key !== tintKey) { TINTC.clear(); tintKey = key; }
if (!img || ((k == null || k.h == null) && skin.h == null)) return img; // nothing to do: no pass
let out = TINTC.get(n);
if (!out) TINTC.set(n, out = tint(img, k, skin));
return out;
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);
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.
// 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;
if (!img) return false;
const out = tinted('look' + (t.sfx || ''), img, k, sk, t.kit, TINT.kit);
const g = cv2.getContext('2d');
g.clearRect(0, 0, cv2.width, cv2.height);
g.imageSmoothingEnabled = false; // it is pixel art; let it be pixel art
const s = Math.min(cv2.width / out.width, cv2.height / out.height);
const w = Math.round(out.width * s), h = Math.round(out.height * s);
g.drawImage(out, Math.round((cv2.width - w) / 2), cv2.height - h, w, h);
return true;
}
// BOUNCE_IDLE.dx/apex are measured off the DRAWN PALM (see config), so a tour whose player reaches