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
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