You pick your own kit and your own skin, and the blood stays red

Testers asked for some control over how their player looks. From behind — the
only way you ever see him — there are two things worth controlling, and neither
needs a single new asset: the kit and the skin are one hue-bucket pixel pass over
the sheets already loaded, cached per frame and built on first draw.

The buckets are measured, not guessed, and three neighbours had to be fenced off
one at a time: the shirt's warm HIGHLIGHTS (which spattered the man with mud on
every dark pick), the drawn BLOOD on the death frames (which turned the pool
brown — the worst bug this file could ship), and the women's HAIR, which is the
same hue as his shorts and cannot be told apart from it by any rule. Her set is
played in whites and has no kit to dye, so it is flagged out and the row says so
rather than silently doing nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 15:00:57 +02:00
1 parent 2f0c04fad4
commit 3b639e743b
6 files changed
+321 -11

No files matched your search

+89 -4
View File
@@ -1,8 +1,8 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS, TINT } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
@@ -1502,6 +1502,91 @@ const SK = n => {
return (sfx && SPR[n + sfx]) ? n + sfx : n;
};
// ---- THE PLAYER'S COLOURS. One pixel pass, no new art, no new assets (see KITS/SKINS in config).
//
// Why a HUE BUCKET and not a colour->colour table: the sheets are shaded, not flat — p_idle1 alone
// carries 1510 opaque colours. A lookup would have to name every ramp step and would still miss the
// anti-aliased ones. Hue is the thing that actually separates the regions, and it separates them
// cleanly: kit blue sits around 208 deg, skin around 22, and the two populations never overlap in
// any frame. Rotating hue while KEEPING each pixel's own lightness means every ramp, every fold and
// every shadow survives the swap — the man changes colour, not shading.
//
// Skin gets `l` rather than a big hue move for the reason in config: skin tone is lightness, and a
// rotated one is a green man.
const rgb2hsl = (r, g, b) => {
r /= 255; g /= 255; b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, l = (mx + mn) / 2;
if (!d) return [0, 0, l];
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
const h = mx === r ? ((g - b) / d + (g < b ? 6 : 0)) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [h * 60, s, l];
};
const hue2c = (p, q, t) => {
t = (t + 1) % 1;
return t < 1 / 6 ? p + (q - p) * 6 * t : t < 0.5 ? q
: t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p;
};
const hsl2rgb = (h, s, l) => {
if (!s) { const v = Math.round(l * 255); return [v, v, v]; }
const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q, t = h / 360;
return [hue2c(p, q, t + 1 / 3), hue2c(p, q, t), hue2c(p, q, t - 1 / 3)].map(v => Math.round(v * 255));
};
// windows wrap (the skin one runs 330 -> 45 through zero), so the test has to wrap with them
const inArc = (h, [a, b]) => a <= b ? h >= a && h < b : h >= a || h < b;
// ONE PIXEL, no canvas — which is the whole reason it is a function of its own and exported. The
// 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) {
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)));
}
function tint(img, k, sk) {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const g = c.getContext('2d');
g.drawImage(img, 0, 0);
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);
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.
const TINTC = new Map();
let tintKey = '';
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;
}
// BOUNCE_IDLE.dx/apex are measured off the DRAWN PALM (see config), so a tour whose player reaches
// differently needs its own pair. Only the women's row carries them; ?? falls back to the men's.
const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
@@ -1553,7 +1638,7 @@ function drawChar(ch) {
// the run bob rides the STRIDE, not a wall clock: one dip per step, in phase with the frame
// flip — the old fixed 18 Hz toggle read as independent hopping over the calm 2-frame cycle
if (ch.pose === 'run') p.y -= Math.floor(ch.runT * (runN >= 6 ? TUNE.runHz : TUNE.runHz2) * runN) % 2;
const img = SPR[SK(name)];
const img = PS(name);
drawShadow(p.x, p.y, hpx * 0.22, hpx * 0.06);
if (img) {
const k = hpx / SPRITE_H; // sprites share one scale; the idle pose defines it
@@ -1584,7 +1669,7 @@ function drawDying() {
if (t >= EATEN_BURST) return; // he is the pile now
const k = t / EATEN_BURST;
let i = 0; while (i + 1 < DIE_AT.length && k >= DIE_AT[i + 1]) i++;
const img = SPR[SK('p_die' + (i + 1))];
const img = PS('p_die' + (i + 1));
const p = proj(at.x, 0, at.z);
const j = i < 2 ? 1.6 : i < 4 ? 0.7 : 0; // he stops fighting on the way down
ctx.save();