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:
1 parent
2f0c04fad4
commit
3b639e743b
6 files changed
+321
-11
No files matched your search
+89
-4
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user