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

+19 -2
View File
@@ -222,6 +222,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
#tours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
#tours img { height: 88px; width: auto; display: block; image-rendering: pixelated; }
#tours b { font: 400 11px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
/* KIT and SKIN: a row of colour squares. That is the whole UI — the pick has no meters and no art
to show, and a labelled tile per colour would be five times the screen for a choice you make by
looking at the colour. The selected one's NAME rides in the h3 on the right, the same slot the
draw screen puts its state in, so nothing on the row is unlabelled. */
.swrow { display: flex; gap: 7px; }
.swrow button { flex: 1; aspect-ratio: 1; min-width: 0; padding: 0; border: 2px solid var(--line);
cursor: pointer; -webkit-tap-highlight-color: transparent; }
.swrow button.on { border-color: var(--acid); box-shadow: inset 0 0 0 2px var(--ink); }
/* a tour played in whites has nothing to dye — the row stays visible so the screen does not jump,
but it reads as unavailable and the header says why */
.swrow.dead { opacity: 0.32; pointer-events: none; filter: saturate(0.25); }
/* THE BOSS PICKER (back room). Two columns of portraits, because you pick a boss by looking at
him — the tile is mostly art. Same plate as the tour tiles above; the grid is what makes ten
of them fit on a phone. */
@@ -455,7 +466,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
<div class="hdr"><button class="chev" id="sBack">&lsaquo;</button><h2>MATCH SETUP</h2></div>
<h3>01 · PLAYER</h3>
<div id="tours"></div>
<h3>02 · COURT</h3>
<!-- KIT and SKIN. Both rows are built in main.js from config.KITS/SKINS, so a colour added
there needs no markup — the same rule the tour tiles and the court rows follow. -->
<h3>02 · KIT<span id="kitName"></span></h3>
<div id="kitRow" class="swrow"></div>
<h3>03 · SKIN<span id="skinName"></span></h3>
<div id="skinRow" class="swrow"></div>
<h3>04 · COURT</h3>
<div id="courtList"></div>
<div class="foot">
<p id="sSum"></p>
@@ -547,6 +564,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=262"></script>
<script type="module" src="src/main.js?v=263"></script>
</body>
</html>
+66 -2
View File
@@ -747,11 +747,75 @@ export const CUP_ROUNDS = [
];
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78, tossDx: -0.59,
// `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,
oDx: 0.48, oApex: 0.73 },
];
// THE PLAYER'S OWN COLOURS — testers asked for "some control over how my player looks", and from
// behind (which is the only way you ever see him) there are exactly two things to control: the kit
// and the skin. No new art: both are a PIXEL PASS over the sheets already loaded, keyed by hue —
// see tint() in render.js. Measured on p_idle1.png (84x224, 1510 opaque colours), the sheets fall
// into three clean hue populations: the kit blue around 208 deg, the skin around 22 deg, and the
// near-neutral shirt whites at sat < 0.18 that neither bucket touches.
//
// `h` is the ABSOLUTE hue the bucket is rotated onto; `s`/`l` scale saturation and lightness from
// whatever the pixel already had, so every bit of the drawn shading survives the swap. An entry
// with no `h` is "as drawn" and costs nothing — the tint pass is skipped entirely.
//
// The kit is the whole kit, not the shorts: the run frames carry blue down at the shoes and the
// women's set is blue well above the waist, so a hue swap moves every blue on the body together.
// That is the feature — you pick a KIT — and the alternative is a per-region mask baked by the
// sprite pipeline, which is a lot of work for a distinction nobody asked for.
export const KITS = [
{ id: 'blue', name: 'CLUB BLUE', sw: '#1678ba' }, // as drawn
{ id: 'red', name: 'CLARET', sw: '#b83030', h: 356, s: 0.92 },
{ id: 'green', name: 'ACID', sw: '#6ea52a', h: 88, s: 0.86 },
{ id: 'purple', name: 'VIOLET', sw: '#7a46c0', h: 268 },
{ id: 'orange', name: 'CLAY', sw: '#d4761e', h: 28, s: 0.94 },
{ id: 'black', name: 'BLACKOUT', sw: '#2e3138', h: 220, s: 0.16, l: 0.42 },
];
// Skin is NOT a hue rotation — rotate a skin tone and you get a green man. Real tones sit in a
// narrow hue band and differ in lightness and saturation, so these mostly move `l`.
export const SKINS = [
{ id: 'tan', name: 'TAN', sw: '#d6956f' }, // as drawn
{ id: 'pale', name: 'PALE', sw: '#eec4a4', h: 26, s: 0.72, l: 1.14 },
{ id: 'olive', name: 'OLIVE', sw: '#b8825a', h: 27, s: 0.94, l: 0.9 },
{ id: 'brown', name: 'BROWN', sw: '#8e5a38', h: 22, s: 1.02, l: 0.68 },
{ id: 'deep', name: 'DEEP', sw: '#5d3722', h: 20, s: 1.05, l: 0.46 },
];
// The hue windows the two buckets claim, in degrees, and the saturation each needs before it will
// claim a pixel at all. The kit window runs generously wide (150-300) because the ~165 anti-aliased
// pixels where blue meets the white shirt land out at 270-300, and a tight window leaves them
// behind as a dull fringe when the kit moves far. Nothing else on the sheet is blue, so its floor
// can stay low and catch every soft edge.
//
// THE SKIN BUCKET IS FENCED ON THREE SIDES, AND EVERY NUMBER IS MEASURED. It has two neighbours it
// must not touch, and each needed a different fence:
//
// THE SHIRT, above it. The whites are not neutral — they are WARM, hue ~37, running up to
// (243,236,224) on the run and smash frames — so they sit inside the skin's hue arc. Tinted, they
// came out as mud spattered across the man's back on every dark pick. Saturation does not
// separate them (they reach 0.35, inside real skin's range); LIGHTNESS does. Real leg skin never
// exceeds lum 0.77 across p_idle1/p_run1/p_smash/p_idle1_f, and the shirt whites start at 0.82,
// so `lumSkin` sits in that gap.
//
// THE BLOOD, beside it — and this is the one that matters, because this is Splatter Tennis. The
// death frames carry DRAWN blood (spray on the shirt, a pool under the body), and at a 330 deg
// lower edge a DEEP pick turned every drop of it to mud. Saturation cannot separate blood either:
// blood runs 0.8-0.9 and so does real skin ((240,160,128) is a highlight at 0.79). HUE separates
// 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 };
// 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
// now stand exactly on the doubles sidelines.
+42 -2
View File
@@ -3,8 +3,8 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
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';
@@ -186,10 +186,49 @@ 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);
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;
}
// --- KIT and SKIN swatches, built from the tables. Same rule as the tour tiles: tapping selects and
// nothing more, SERVE UP is what commits. Both rows are one builder — they differ only in which
// table they read and which localStorage key they remember, and a second copy of this loop to say
// "skin" instead of "kit" would be the same eleven lines twice.
const swatches = (rowId, nameId, table, keyName, get, set) => {
const row = document.getElementById(rowId), label = document.getElementById(nameId);
for (const e of table) {
const b = document.createElement('button');
b.className = e.id === get() ? 'on' : '';
b.style.background = e.sw;
b.title = e.name; // desktop hover; the h3 label is what a phone reads
b.setAttribute('aria-label', e.name);
b.addEventListener('click', () => {
set(e.id);
try { localStorage.setItem(keyName, e.id); } catch {}
for (const el of row.children) el.classList.toggle('on', el === b);
label.textContent = e.name;
});
row.appendChild(b);
}
label.textContent = (table.find(e => e.id === get()) || table[0]).name;
};
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
// --- 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
@@ -323,6 +362,7 @@ function refreshSplash() {
function start(modeId = pickedMode) {
if (!menuUp()) return;
selectTour(pickedTour);
selectKit(pickedKit); selectSkin(pickedSkin);
selectCourt(picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
+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();
+16 -1
View File
@@ -1,6 +1,6 @@
// All mutable game state in one place, plus the point-reset that re-arms it.
import { HOME, BOY_ENTER, TUNE, COURT, COURTS, TOURS } from './config.js';
import { HOME, BOY_ENTER, TUNE, COURT, COURTS, TOURS, KITS, SKINS } from './config.js';
import { gore } from './zombies.js'; // runtime-only cycle (zombies imports state): sendBoy reads gore
// to decide whether a mopping round is worth walking out for
@@ -41,6 +41,21 @@ export function selectTour(id) {
return tour;
}
// The kit and the skin, picked on the same screen and living by the same rule as the tour: nothing
// but the DRAWN PIXELS depends on either. No physics, no AI, no scoring, no sprite NAMES — the
// recolour happens after SK() has already chosen the frame (see tint/PS in render.js).
export let kit = KITS[0], skin = SKINS[0];
export function selectKit(id) {
kit = KITS.find(k => k.id === id) || KITS[0];
try { localStorage.setItem('kit', kit.id); } catch {}
return kit;
}
export function selectSkin(id) {
skin = SKINS.find(s => s.id === id) || SKINS[0];
try { localStorage.setItem('skin', skin.id); } catch {}
return skin;
}
// everything that used to be loose top-level lets
export const G = {
mode: 'tennis', // tennis | transition | splatter
+89
View File
@@ -1515,5 +1515,94 @@ test('death match (solo): the game-winning body bleeds out on the ground before
// normalisation that has to agree with server.py. It was exported and never called by anything.
test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); });
// ---- THE PLAYER'S COLOURS. The feature's one real claim is that the kit can move without dragging
// the skin with it, and the whole claim lives in tintPx. These are the colours MEASURED off
// p_idle1.png (the four biggest opaque populations), not invented ones — if the art is ever redrawn
// into a palette these buckets do not separate, this is the test that says so.
const { tintPx } = await import('./src/render.js');
const SHORTS = [22, 120, 186], SHORTS_DK = [0, 91, 159]; // kit blue, lit and in shadow
const SKINPX = [214, 149, 111], SHIRT = [214, 213, 200]; // forearm, and the shirt white
const dom = ([r, g, b]) => r > g && r > b ? 'r' : g > b ? 'g' : 'b';
test('kit and skin are separate buckets: recolouring one leaves the other alone', () => {
const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0];
assert.equal(asDrawn.h, undefined, 'SKINS[0] is the "as drawn" entry');
// the kit moves, in the light AND in the shadow — a bucket that only catches the lit ramp step
// leaves the folds behind in the old colour, which is the failure that would actually be visible
assert.equal(dom(tintPx(...SHORTS, red, asDrawn)), 'r', 'lit shorts turn red');
assert.equal(dom(tintPx(...SHORTS_DK, red, asDrawn)), 'r', 'shadowed shorts turn red too');
// ...and the skin does not, which is the question the whole design turns on
assert.equal(tintPx(...SKINPX, red, asDrawn), null, 'skin is untouched by a kit change');
assert.equal(tintPx(...SHIRT, red, asDrawn), null, 'the shirt white is in neither bucket');
});
test('skin recolours by lightness, and stays a skin tone rather than rotating to green', () => {
const asDrawn = cfg.KITS[0], deep = cfg.SKINS.find(s => s.id === 'deep');
const out = tintPx(...SKINPX, asDrawn, deep);
assert.equal(dom(out), 'r', 'a darker tone is still red-dominant, not hue-rotated off the band');
assert.ok(out[0] < SKINPX[0], 'DEEP is genuinely darker than the drawn tan');
assert.equal(tintPx(...SHORTS, asDrawn, deep), null, 'the kit is untouched by a skin change');
});
test('the shading survives the swap: the ramp keeps its order and its spread', () => {
const red = cfg.KITS.find(k => k.id === 'red'), asDrawn = cfg.SKINS[0];
const lit = tintPx(...SHORTS, red, asDrawn), dk = tintPx(...SHORTS_DK, red, asDrawn);
const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
assert.ok(lum(lit) > lum(dk), 'the lit step stays lighter than the shadowed one');
// hue rotation keeps lightness, so the gap between the two ramp steps must survive within rounding
assert.ok(Math.abs((lum(lit) - lum(dk)) - (lum(SHORTS) - lum(SHORTS_DK))) < 12,
'the ramp keeps its spread — a flattened one would read as a painted-on patch');
});
// THE TWO NEIGHBOURS THE SKIN BUCKET MUST NOT EAT. Both of these shipped broken once — the shirt
// came out mud-spattered, and then the death frames' blood came out brown, which on this game is
// the worst bug in the file. Colours measured off p_run1/p_smash (the shirt) and p_die4 (the pool).
const SHIRT_WARM = [243, 236, 224], SHIRT_W2 = [240, 232, 224]; // warm HIGHLIGHTS, not neutral
const BLOOD = [150, 20, 25], POOL = [170, 15, 20], BLOOD_DK = [92, 10, 14];
test('the shirt stays white — its warm highlights are not skin', () => {
for (const sk of cfg.SKINS.slice(1)) {
assert.equal(tintPx(...SHIRT_WARM, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`);
assert.equal(tintPx(...SHIRT_W2, cfg.KITS[0], sk), null, `${sk.id} leaves the shirt alone`);
}
});
test('BLOOD STAYS BLOOD — no skin pick may turn the death frames to mud', () => {
for (const sk of cfg.SKINS.slice(1)) {
for (const [nm, c] of [['spray', BLOOD], ['pool', POOL], ['dried', BLOOD_DK]])
assert.equal(tintPx(...c, cfg.KITS[0], sk), null, `${sk.id} leaves the ${nm} red`);
}
// ...and it survives a kit change too, which is the other half of the same claim
for (const k of cfg.KITS.slice(1))
assert.equal(tintPx(...POOL, k, cfg.SKINS[0]), null, `${k.id} leaves the pool red`);
});
// A tour whose art has no kit sits the kit pick out — otherwise the only thing on her sheets inside
// the kit's hue arc is HER HAIR, and picking ACID dyed it green.
test("a tour played in whites carries no kit, so nothing of hers is dyed", () => {
const women = cfg.TOURS.find(t => t.id === 'women');
assert.equal(women.kit, false, 'the women\'s set is flagged as having no kit');
assert.equal(cfg.TOURS.find(t => t.id === 'men').kit, true, 'the men\'s set has one');
const HER_HAIR = [40, 44, 72]; // dark blue-black, hue ~220 — inside the kit arc
assert.ok(tintPx(...HER_HAIR, cfg.KITS[1], cfg.SKINS[0]), 'her hair IS in the kit arc (the trap)');
assert.equal(tintPx(...HER_HAIR, null, cfg.SKINS[0]), null, 'and a null kit is what spares it');
});
test('every kit and skin in the tables is a live, distinct pick', () => {
for (const table of [cfg.KITS, cfg.SKINS]) {
assert.equal(new Set(table.map(e => e.id)).size, table.length, 'ids are unique');
for (const e of table) {
assert.ok(/^#[0-9a-f]{6}$/i.test(e.sw), `${e.id} has a swatch colour for the menu`);
assert.ok(e.name, `${e.id} is labelled`);
if (e.h != null) assert.ok(e.h >= 0 && e.h < 360, `${e.id}'s hue is a degree`);
}
}
// and the "as drawn" head of each table must actually be a no-op, or the default player is tinted
assert.equal(tintPx(...SHORTS, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
assert.equal(tintPx(...SKINPX, cfg.KITS[0], cfg.SKINS[0]), null, 'defaults change nothing');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report