diff --git a/index.html b/index.html index 4bbd5fa..1187c84 100644 --- a/index.html +++ b/index.html @@ -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') {

MATCH SETUP

01 · PLAYER

-

02 · COURT

+ +

02 · KIT

+
+

03 · SKIN

+
+

04 · COURT

@@ -547,6 +564,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
- + diff --git a/src/config.js b/src/config.js index be5688e..8b6e0e9 100644 --- a/src/config.js +++ b/src/config.js @@ -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. diff --git a/src/main.js b/src/main.js index 6b75408..cd62b1a 100644 --- a/src/main.js +++ b/src/main.js @@ -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 diff --git a/src/render.js b/src/render.js index a483f6f..540185d 100644 --- a/src/render.js +++ b/src/render.js @@ -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(); diff --git a/src/state.js b/src/state.js index 0902da6..7376d75 100644 --- a/src/state.js +++ b/src/state.js @@ -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 diff --git a/test.js b/test.js index ecbc642..43a50b9 100644 --- a/test.js +++ b/test.js @@ -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