diff --git a/index.html b/index.html index 68ed74a..c59b283 100644 --- a/index.html +++ b/index.html @@ -230,9 +230,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { .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 live player, sitting between the tour tiles and the colours it answers to. `position: static` + is not decoration: the bare `canvas` rule above is the COURT — fixed, full-bleed, 100dvh — and it + catches any canvas in the document, so this one flew to the top-left corner of the screen until + it was told otherwise. Every geometry property that rule sets has to be undone here. */ + #look { position: static; display: block; margin: 12px auto 0; + width: auto; height: 150px; touch-action: auto; image-rendering: pixelated; } /* 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. */ @@ -466,6 +469,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {

MATCH SETUP

01 · PLAYER

+ +

02 · KIT

diff --git a/src/config.js b/src/config.js index 8b6e0e9..5341c20 100644 --- a/src/config.js +++ b/src/config.js @@ -747,15 +747,21 @@ export const CUP_ROUNDS = [ ]; export const TOURS = [ - // `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, + // `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the + // same operation cannot serve both. + // + // 'dye' the men. Drawn in blue shorts, so the kit pick rotates that hue and keeps the shading. + // + // 'wash' the women. Drawn head to toe in WHITES — her skirt is (240,248,248)/(208,216,224), a + // cool-cast white — so there is no hue to rotate: rotating a near-neutral does nothing, + // and the only genuinely saturated blue on her sheets is HER HAIR, at the same hue AND + // lightness as his shorts. So her whites are TINTED INSTEAD: the pick's hue laid over + // them at a low saturation, lightness kept, which is how you dye a white kit in real + // life and gives rose / powder blue / mint / lilac rather than a solid block of colour. + // Her hair and skin fall out of it on lightness alone, which is why this is safe where + // a dye was not. + { id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null, kit: 'dye' }, + { id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', kit: 'wash', dx: 0.46, apex: 0.78, tossDx: -0.59, oDx: 0.48, oApex: 0.73 }, ]; @@ -814,7 +820,22 @@ export const SKINS = [ // 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 }; +// +// THE OPPONENT wears red, not blue — (184,8,8) for him, (184,48,48) for her — so the friend across +// the net in a netplay match needs his own kit arc, `oKit`, sitting around 0 deg. It is safe there +// for the one reason it would not be on the player's sheets: the opponent art carries no DRAWN +// blood (his damage is all live particles), so nothing in that arc is blood. +// +// THE WHITES, for a 'wash' tour. Bright and barely saturated, which is exactly what her skin is +// NOT — hers reaches lum 0.77 at saturation 0.79, so the pair of limits keeps them apart with room +// to spare — and what her hair is not either, at lum 0.25. washS/washL are the dye strength: the +// pick's saturation scaled down to a pastel, and a slight darkening without which a tint laid on a +// 0.96-lightness white is invisible. +export const TINT = { + kit: [150, 300], oKit: [345, 15], skin: [10, 45], + satKit: 0.15, satSkin: 0.28, lumSkin: 0.80, + whiteLum: 0.72, whiteSat: 0.60, washS: 0.30, washL: 0.93, +}; // 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 diff --git a/src/main.js b/src/main.js index cd62b1a..6f3d50e 100644 --- a/src/main.js +++ b/src/main.js @@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, s 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'; -import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js'; +import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { cv } from './dom.js'; @@ -186,20 +186,24 @@ 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); + refreshLook(); 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; +// THE LIVE PLAYER, redrawn on every tap of a tour, a kit or a skin. It draws the REAL sprite through +// the REAL tint (drawLook), so there is no second recolour to keep in step with the first — the only +// way this can show a wrong colour is if the court would show it too. +// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first +// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds. +const lookCv = document.getElementById('look'); +let lookPoll = 0; +function refreshLook() { + const t = TOURS.find(x => x.id === pickedTour) || TOURS[0]; + const ok = drawLook(lookCv, t, KITS.find(e => e.id === pickedKit) || KITS[0], + SKINS.find(e => e.id === pickedSkin) || SKINS[0]); + clearTimeout(lookPoll); + if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly } // --- KIT and SKIN swatches, built from the tables. Same rule as the tour tiles: tapping selects and @@ -219,6 +223,7 @@ const swatches = (rowId, nameId, table, keyName, get, set) => { try { localStorage.setItem(keyName, e.id); } catch {} for (const el of row.children) el.classList.toggle('on', el === b); label.textContent = e.name; + refreshLook(); }); row.appendChild(b); } @@ -228,7 +233,7 @@ 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 +refreshLook(); // --- 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 diff --git a/src/net.js b/src/net.js index 59a903e..7169bd3 100644 --- a/src/net.js +++ b/src/net.js @@ -17,8 +17,8 @@ // // ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded // plan calls reconnection "the bulk of the real work" and a prototype must not start there. -import { COURT, UI } from './config.js'; -import { G, P, O, ball, score, setupPoint, selectCourt, court } from './state.js'; +import { COURT, UI, KITS, SKINS } from './config.js'; +import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin } from './state.js'; import { pointTo, fault, showModal, tossBall } from './gameplay.js'; import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module import { zombies } from './zombies.js'; @@ -116,6 +116,9 @@ function netSync(m) { function netStart(side, death, courtId) { G.net.ready = true; G.net.side = side; + // tell them how we look, the moment there is somebody to tell. Sent once, on pairing: neither + // phone can change its pick mid-match, because the setup screen is only reachable between runs. + send({ t: 'look', k: kit.id, s: skin.id }); G.modal = null; G.modalQ.length = 0; // the waiting card has done its job // THE HOST'S COURT RIDES THE ROOM, exactly as the death-match pick does — two phones playing the // same rally on two different surfaces disagree about the bounce as well as the picture, and the @@ -176,6 +179,14 @@ export function netMsg(m) { else if (m.t === 'fault') netApplyFault(); else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); } + // WHAT THEY LOOK LIKE. Their phone's kit and skin, resolved against our own tables so a build + // that has never heard of a colour falls back to the drawn one rather than drawing nothing. + // Unknown message types are ignored above, so an older build simply never sends this and their + // friend sees the opponent as drawn — the match still plays. + else if (m.t === 'look') G.net.look = { + kit: KITS.find(k => k.id === m.k) || KITS[0], + skin: SKINS.find(s => s.id === m.s) || SKINS[0], + }; } // ticked from update() while G.net: the position stream out, the O sprite in diff --git a/src/render.js b/src/render.js index 3070cf9..1555f87 100644 --- a/src/render.js +++ b/src/render.js @@ -1538,18 +1538,28 @@ const inArc = (h, [a, b]) => a <= b ? h >= a && h < b : h >= a || h < b; // 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) { +export function tintPx(r, g, b, k, sk, mode = 'dye', arc = TINT.kit) { 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))); + // THE KIT, two ways. 'dye' rotates a coloured kit's hue and needs only a low saturation floor + // (nothing else on those sheets is blue, and a low floor catches the soft edges). 'wash' lays the + // pick over a WHITE kit — bright, barely saturated — which is a different test entirely and one + // that would swallow the men's shirt, which is why it belongs to the tour and not to the pixel. + const isKit = mode === 'wash' ? (l >= TINT.whiteLum && s <= TINT.whiteSat) + : (inArc(h, arc) && s >= TINT.satKit); + if (isKit) { + if (!k || k.h == null) return null; + return mode === 'wash' + // lightness kept (times any darkening the pick asks for), the hue laid on at a pastel strength + ? hsl2rgb(k.h, Math.min(1, TINT.washS * (k.s ?? 1)), Math.min(1, l * TINT.washL * (k.l ?? 1))) + : hsl2rgb(k.h, Math.min(1, s * (k.s ?? 1)), Math.min(1, l * (k.l ?? 1))); + } + // the skin needs a floor AND a ceiling: below the floor is grey, above the ceiling is shirt + if (!(inArc(h, TINT.skin) && s >= TINT.satSkin && l <= TINT.lumSkin)) return null; + if (!sk || sk.h == null) return null; // "as drawn" entries cost one branch and exit + return hsl2rgb(sk.h, Math.min(1, s * (sk.s ?? 1)), Math.min(1, l * (sk.l ?? 1))); } -function tint(img, k, sk) { +function tint(img, k, sk, mode, arc) { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const g = c.getContext('2d'); @@ -1557,34 +1567,56 @@ function tint(img, k, sk) { 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); + const out = tintPx(px[i], px[i + 1], px[i + 2], k, sk, mode, arc); 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. +// Recoloured sheets are 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 key carries the +// whole look, so a changed pick simply misses and rebuilds — there is no invalidation to forget. +// The cap is a runaway guard for the sprite bench, which can cycle looks all afternoon. const TINTC = new Map(); -let tintKey = ''; +export function tinted(n, img, k, sk, mode, arc) { + if ((!k || k.h == null) && (!sk || sk.h == null)) return img; // "as drawn" both ways: no pass + const key = n + '|' + (k ? k.id : '-') + '|' + (sk ? sk.id : '-') + '|' + mode; + let out = TINTC.get(key); + if (!out) { + if (TINTC.size > 96) TINTC.clear(); + TINTC.set(key, out = tint(img, k, sk, mode, arc)); + } + return out; +} + 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; + if (!img) return img; + // YOUR body, dyed or washed depending on what this tour is dressed in (see TOURS.kit) + if (n[0] === 'p' && n[1] === '_') return tinted(n, img, kit, skin, tour.kit, TINT.kit); + // ...AND THE FRIEND ACROSS THE NET, wearing what THEY picked on their own phone (netplay only — + // the roster CPU is not a person and stays exactly as drawn). His sheets are red, not blue, so + // the kit rides its own arc; his shirt is always a dye, whatever tour the art came from. + if (n[0] === 'o' && n[1] === '_' && G.net && G.net.look) + return tinted(n, img, G.net.look.kit, G.net.look.skin, 'dye', TINT.oKit); + return img; +} + +// THE SETUP SCREEN'S LIVE PLAYER. Not a mock-up of the pick — the pick, run through the same tint +// and the same sheet the court will draw, so what the menu shows cannot drift from what walks on. +// Returns false while the sprites are still loading, so the caller knows to come back. +export function drawLook(cv2, t, k, sk) { + const img = SPR['p_idle1' + (t.sfx || '')] || SPR.p_idle1; + if (!img) return false; + const out = tinted('look' + (t.sfx || ''), img, k, sk, t.kit, TINT.kit); + const g = cv2.getContext('2d'); + g.clearRect(0, 0, cv2.width, cv2.height); + g.imageSmoothingEnabled = false; // it is pixel art; let it be pixel art + const s = Math.min(cv2.width / out.width, cv2.height / out.height); + const w = Math.round(out.width * s), h = Math.round(out.height * s); + g.drawImage(out, Math.round((cv2.width - w) / 2), cv2.height - h, w, h); + return true; } // BOUNCE_IDLE.dx/apex are measured off the DRAWN PALM (see config), so a tour whose player reaches diff --git a/test.js b/test.js index 43a50b9..de46c2a 100644 --- a/test.js +++ b/test.js @@ -1579,15 +1579,70 @@ test('BLOOD STAYS BLOOD — no skin pick may turn the death frames to mud', () = 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'); +// A WHITE KIT IS WASHED, NOT DYED. Rotating a near-neutral does nothing, and the only saturated +// blue on her sheets is HER HAIR — same hue as his shorts, so a dye turned it green. Colours +// measured off p_idle1_f. +const HER_SKIRT = [208, 216, 224], HER_BRIGHT = [240, 248, 248]; // cool-cast whites +const HER_HAIR = [40, 44, 72]; // dark blue-black, hue ~220 +test('a white kit takes a wash: her whites colour, her hair and skin do not', () => { + assert.equal(cfg.TOURS.find(t => t.id === 'women').kit, 'wash'); + assert.equal(cfg.TOURS.find(t => t.id === 'men').kit, 'dye'); + const red = cfg.KITS.find(k => k.id === 'red'); + + for (const c of [HER_SKIRT, HER_BRIGHT]) { + const out = tintPx(...c, red, cfg.SKINS[0], 'wash'); + assert.ok(out, 'her whites take the colour'); + assert.equal(dom(out), 'r', 'CLARET reads as rose on her'); + // a WASH, not a dye: it must stay pale, or she is wearing a block of poster paint + assert.ok(Math.min(...out) > 150, `${c} stays a pastel, not a solid (${out})`); + } + // the trap this whole mode exists to avoid, pinned from both sides + assert.ok(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'dye'), 'her hair IS in the dye arc — the trap'); + assert.equal(tintPx(...HER_HAIR, red, cfg.SKINS[0], 'wash'), null, 'a wash cannot reach it'); + assert.equal(tintPx(...SKINPX, red, cfg.SKINS[0], 'wash'), null, 'nor her skin'); +}); + +test('the wash leaves the shading: her folds stay in order', () => { + const red = cfg.KITS.find(k => k.id === 'red'); + const lum = c => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11; + const lit = tintPx(...HER_BRIGHT, red, cfg.SKINS[0], 'wash'); + const shade = tintPx(...HER_SKIRT, red, cfg.SKINS[0], 'wash'); + assert.ok(lum(lit) > lum(shade), 'the lit fold stays lighter than the shaded one'); +}); + +// NETPLAY: the friend across the net wears their own pick. Their sprites are RED, not blue, so the +// kit rides its own arc — safe there only because the opponent art carries no drawn blood. +test("the opponent's red kit takes a dye on its own arc, and his skin still reads as skin", () => { + const HIS_SHIRT = [184, 8, 8], HER_TOP = [184, 48, 48], HIS_SKIN = [248, 176, 128]; + const green = cfg.KITS.find(k => k.id === 'green'); + for (const [nm, c] of [['his', HIS_SHIRT], ['hers', HER_TOP]]) { + const out = tintPx(...c, green, cfg.SKINS[0], 'dye', cfg.TINT.oKit); + assert.ok(out && dom(out) === 'g', `${nm} red shirt takes the pick`); + } + // his skin must not go with it, and the player's own blue arc must not claim his shirt + assert.equal(tintPx(...HIS_SKIN, green, cfg.SKINS[0], 'dye', cfg.TINT.oKit), null, + 'the opponent kit arc leaves his skin alone'); + assert.equal(tintPx(...HIS_SHIRT, green, cfg.SKINS[0], 'dye', cfg.TINT.kit), null, + "and the PLAYER's blue arc never claims the opponent's red"); +}); + +// The look crosses the wire ONCE, on pairing — neither phone can change its pick mid-match, because +// the setup screen is only reachable between runs. +test('netplay: your look goes over the wire on pairing, and theirs comes back', () => { + state.selectKit('green'); state.selectSkin('deep'); + freshNet('A'); + const look = netTest.sent.find(m => m.t === 'look'); + assert.ok(look, 'pairing announces this phone\'s look'); + assert.equal(look.k, 'green'); assert.equal(look.s, 'deep'); + + netMsg({ t: 'look', k: 'red', s: 'pale' }); + assert.equal(G.net.look.kit.id, 'red', 'their kit is what the opponent is drawn in'); + assert.equal(G.net.look.skin.id, 'pale'); + // an older build, or a colour this one has never heard of, must not blank the opponent out + netMsg({ t: 'look', k: 'chartreuse', s: undefined }); + assert.equal(G.net.look.kit.id, cfg.KITS[0].id, 'an unknown colour falls back to as-drawn'); + assert.equal(G.net.look.skin.id, cfg.SKINS[0].id); + state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found }); test('every kit and skin in the tables is a live, distinct pick', () => {