diff --git a/assets/_heads.py b/assets/_heads.py new file mode 100644 index 0000000..df46211 --- /dev/null +++ b/assets/_heads.py @@ -0,0 +1,95 @@ +# _heads.py — cut the CARD HEAD ICONS from the 4K family sheets. +# +# uv run --with pillow --with numpy python assets/_heads.py # build all -> _refcheck/heads/ +# uv run --with pillow --with numpy python assets/_heads.py --install # -> assets/z_head.png +# +# WHY. The walk-on card first cropped heads out of the INSTALLED 224 px frames (config.HEAD_CROP): +# a ~65 px source head drawn at 144 device px is mush, and the oldest families (sh/bb/st/ru) were +# the mushiest — "the first heads in the roster are very low res". The 4K sheets in _refcheck have +# the same heads at ~4x the pixels, so the icon is cut THERE and downscaled once, cleanly. +# drawCard prefers z_head and degrades to the HEAD_CROP crop, so a family whose 4K sheet is +# gone (they are gitignored scratch) simply keeps the old icon. Re-run after re-rolling a family. +import os, sys + +import numpy as np +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _fine +from _zsheet import bands + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRATCH = os.path.join(HERE, '_refcheck') +OUT_H = 144 # the card draws the icon at CARD_H-14 = 48 logical px; RES caps at 3 -> 144 device + +FAMS = ['br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma'] +# head depth as a fraction of the standing figure, where the shoulder-widening rule fails: +# the brute's shoulders start high, the keeper's hat brim is wider than his face, the champion +# wears a headwrap, and the mascot's head IS partly the ball. Same knowledge as HEAD_CROP's note. +DEPTH = {'br': 0.27, 'gk': 0.30, 'ch': 0.28, 'ma': 0.37} + + +def head_box(a): + """Head region of one keyed figure: top of the silhouette down to the shoulder widening.""" + op = a[..., 3] > 0 + ys = np.nonzero(op.any(1))[0] + y0, y1 = ys.min(), ys.max() + h = y1 - y0 + 1 + width = op.sum(1) + med = np.median(width[y0:y0 + max(6, h // 16)][width[y0:y0 + max(6, h // 16)] > 0]) + yS = None + for y in range(y0 + h // 20, y1): + if width[y] > 1.9 * med: yS = y; break + yS = yS or y0 + int(h * 0.2) + band = op[y0:yS] + xs = np.nonzero(band.any(0))[0] + pad = max(3, h // 60) + return (max(0, xs.min() - pad), max(0, y0 - pad), xs.max() + pad, yS + pad) + + +def build(install=False): + outdir = HERE if install else os.path.join(SCRATCH, 'heads') + os.makedirs(outdir, exist_ok=True) + for k in FAMS: + sheet = os.path.join(SCRATCH, f'fam_{k}.png') + if not os.path.exists(sheet): + print(f'{k}: no 4K sheet — the card keeps its HEAD_CROP fallback'); continue + a = _fine.key(Image.open(sheet)) + bs = bands(a) + band = a[bs[0][0]:bs[0][1]] + cols = (band[..., 3] > 0).any(0) + x0 = np.nonzero(cols)[0][0] # first figure of the play row + gap = 0; x1 = x0 + for x in range(x0, len(cols)): + gap = gap + 1 if not cols[x] else 0 + if gap > 30: x1 = x - gap; break + else: x1 = len(cols) + fig = band[:, x0:x1] + ys = np.nonzero((fig[..., 3] > 0).any(1))[0] + fh = ys.max() - ys.min() + 1 + bx0, by0, bx1, by1 = head_box(fig) + if k in DEPTH: # hand depth beats the widening rule here + by1 = ys.min() + int(fh * DEPTH[k]) + band2 = (fig[..., 3] > 0)[by0:by1] + xs = np.nonzero(band2.any(0))[0] + bx0, bx1 = max(0, xs.min() - 5), xs.max() + 5 + crop = fig[by0:by1, bx0:bx1] + im = Image.fromarray(crop, 'RGBA') + s = OUT_H / im.height + im = im.resize((max(1, round(im.width * s)), OUT_H), Image.BOX) + # quantise to the crop's own palette so the icon stays flat pixel art, not a soft photo + arr = np.array(im).astype(int) + solid = arr[..., 3] > 110 + px = crop[crop[..., 3] > 200][:, :3] + pal = np.unique((px // 8) * 8, axis=0) + flat = arr[..., :3].reshape(-1, 3) + idx = ((flat[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1) + arr[..., :3] = pal[idx].reshape(arr.shape[0], arr.shape[1], 3) + arr[..., 3] = np.where(solid, 255, 0) + out = os.path.join(outdir, f'z_{k}head.png') + Image.fromarray(arr.astype('uint8'), 'RGBA').save(out) + print(f'{k}: {out} {arr.shape[1]}x{arr.shape[0]}') + + +if __name__ == '__main__': + build('--install' in sys.argv) diff --git a/assets/z_bbhead.png b/assets/z_bbhead.png new file mode 100644 index 0000000..a67e743 Binary files /dev/null and b/assets/z_bbhead.png differ diff --git a/assets/z_brhead.png b/assets/z_brhead.png new file mode 100644 index 0000000..a1a84fe Binary files /dev/null and b/assets/z_brhead.png differ diff --git a/assets/z_chhead.png b/assets/z_chhead.png new file mode 100644 index 0000000..7844c26 Binary files /dev/null and b/assets/z_chhead.png differ diff --git a/assets/z_drhead.png b/assets/z_drhead.png new file mode 100644 index 0000000..5ad731d Binary files /dev/null and b/assets/z_drhead.png differ diff --git a/assets/z_gkhead.png b/assets/z_gkhead.png new file mode 100644 index 0000000..9da677b Binary files /dev/null and b/assets/z_gkhead.png differ diff --git a/assets/z_ljhead.png b/assets/z_ljhead.png new file mode 100644 index 0000000..e6ea1b6 Binary files /dev/null and b/assets/z_ljhead.png differ diff --git a/assets/z_mahead.png b/assets/z_mahead.png new file mode 100644 index 0000000..20dcdb6 Binary files /dev/null and b/assets/z_mahead.png differ diff --git a/assets/z_phhead.png b/assets/z_phhead.png new file mode 100644 index 0000000..d8df2c6 Binary files /dev/null and b/assets/z_phhead.png differ diff --git a/assets/z_ruhead.png b/assets/z_ruhead.png new file mode 100644 index 0000000..6abda72 Binary files /dev/null and b/assets/z_ruhead.png differ diff --git a/assets/z_shhead.png b/assets/z_shhead.png new file mode 100644 index 0000000..c0992d0 Binary files /dev/null and b/assets/z_shhead.png differ diff --git a/assets/z_sthead.png b/assets/z_sthead.png new file mode 100644 index 0000000..7de817c Binary files /dev/null and b/assets/z_sthead.png differ diff --git a/index.html b/index.html index d4fd954..c7b2544 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 0df8010..f6a7b31 100644 --- a/src/beats.js +++ b/src/beats.js @@ -417,11 +417,22 @@ export function tickMatchFall(dt) { // on with its own intro card, exactly as it used to on the kill frame. const d = G.match; if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) { - d.mopT = 0; - matchOpponent(); - d.ending = false; // play re-opens only now that a body is on court - matchReady(); - G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat + // Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the + // mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and + // the walk-on holds until he is clear of the playing area; the 6 s cap is a belt on top of + // the braces — nothing should ever need it, but a stalled beat must not stall the match. + const b = G.boy; + if (b && b.mop && Math.abs(b.x) < 6.5 && (d.mopHold = (d.mopHold || 0) + dt) < 6) { + b.jobs = 0; b.tx = undefined; b.tz = undefined; b.mopT = 0; + if (G.interlude && G.interlude.introT < 1) G.interlude.introT = 1; // serving stays barred + d.mopT = 0.001; // the beat re-checks next tick + } else { + d.mopT = 0; d.mopHold = 0; + matchOpponent(); + d.ending = false; // play re-opens only now that a body is on court + matchReady(); + G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat + } } if (!G.matchFall) return tickMatchServe(dt); const f = G.matchFall; @@ -506,8 +517,10 @@ function matchOpponent() { // so killZombie can file the kill under it without asking G which opponent we were on. zb.foe = matchFoe(G.match.opp); zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS) - // the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art) - G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 2.4, head: zb.fam }; + // the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art). + // 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card + // of the player should last longer") + G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam }; zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure // scoreboard (a quarter per point), so a deeper pool would // change nothing — the ladder escalates in racketAI instead @@ -574,7 +587,14 @@ function matchGameWon(zb, banked = false) { // on a game point) — bank the game by hand or the scoreboard drifts from the ladder. if (!banked) { score.games[0]++; score.pts = [0, 0]; } G.runGames++; // the ladder metric, same as every mode - G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last + // a clean game is how you last — and when it actually restores blood, the trainer SAYS so + // (Oliver: "show wounds healed popup in zombie tennis when appropriate"). Same popup as the + // duel's, so healing has one face everywhere; a full-blood win stays silent. + const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp; + G.hp += healed; + if (healed > 0) + showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, + body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); voice('game_player', 0.3); cheer(2.4, 0.6); // GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way // round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew diff --git a/src/gameplay.js b/src/gameplay.js index 172cf2e..c624ca3 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -379,7 +379,16 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { else { // target: diagonal service box. Serving from deuce side → target box is across. const scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality); - boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + sx * TUNE.serveAimX + // THE AIM BASE NEVER LEAVES THE BOX. A full sway used to carry the target ACROSS the centre + // line — an aimed serve was a fault by construction, not by risk. The sway now runs from the + // box centre to 0.18 m INSIDE either line, and only the timing scatter (added after) can + // carry the ball out — which is the tennis being asked for: the line is where you aim, the + // miss is what you risked. (Classic enforces the box; see fault()'s caller.) + const side = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1); + const half = COURT.W / 2; + const aim = side * 2.0 + sx * TUNE.serveAimX; + const lo = side < 0 ? -half + 0.18 : 0.18, hi = side < 0 ? -0.18 : half - 0.18; + boxX = Math.max(lo, Math.min(hi, aim)) + (Math.random() * 2 - 1) * TUNE.aimError * scatter; // DEPTH RIDES QUALITY (player serves only): a flush strike aims near the service line, the // ONLY depth where flat pace survives the net-clearance loop — MEASURED with the replica @@ -1296,7 +1305,10 @@ export function keyPressed(code) { // unit components; mag carries the length). Keyboard: left/right sway, up = the hard one. // A plain tap stays the safe centre-box serve — that is the second-serve decision. const kb = { ax: axisX(), az: axisZ() }; - const k = flick ? Math.max(0, Math.min(1, (flick.mag - 24) / 130)) : 0; + // the dead zone is 44 px, well above the 28 px flick threshold: a slightly smudged strike TAP + // must stay a safe serve — accidental sway was most of classic's "double fault very often", + // because classic's line call enforces the service box (the match's only checks the court) + const k = flick ? Math.max(0, Math.min(1, (flick.mag - 44) / 130)) : 0; const sway = flick ? flick.fx * k : kb.ax * 0.75; const hard = flick ? Math.max(0, -flick.fy) * k : (kb.az < 0 ? 0.8 : 0); hitServe(P, q, sway || hard ? { sx: sway, hard } : null); @@ -1688,6 +1700,17 @@ export function update(dt) { } } if (ball.y <= 0 && ball.vy < 0) { + // EXACT TOUCHDOWN, not the frame after it. The integrated position lands up to one full + // step PAST the true contact — at serve pace that is ~0.75 m of travel in 1/60 s, so a + // serve aimed half a metre inside the service line was routinely JUDGED long. Oliver saw it + // as "classic tennis gives double fault very often" (the match's line call only checks the + // whole court, which is why zombie tennis seemed immune). Rewind the step to y = 0 before + // the bounce position is read by anything — the line call, the bounce dust, the mark. + const yOld = ball.y - ball.vy * dt; // where this step began (vy already g-updated) + if (yOld > 0) { + const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step + ball.x -= ball.vx * back; ball.z -= ball.vz * back; + } ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY; ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ; if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court diff --git a/src/render.js b/src/render.js index 4b0d530..162359d 100644 --- a/src/render.js +++ b/src/render.js @@ -221,7 +221,7 @@ function farB(z) { // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang tags on the same files, and two copies of this number is one copy too // many: the one that does not get bumped is the one that serves a cached 404. -export const SPRV = 111; +export const SPRV = 112; // The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // already requested at import — it just makes the wait visible and holds the menu back until the // last sheet has landed. See the onerror note below for the other half of "sometimes missing". @@ -273,6 +273,9 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' // filenames typed out here (each of which is a chance to typo one and have a body // vanish for a frame — the oldest bug in this file). ...ZFAMS.flatMap(famFrames), + // the card's 4K-cut head icons (assets/_heads.py). 'r' has no fam sheet — his + // card keeps the HEAD_CROP fallback, so no file and no 404 for him. + ...ZFAMS.filter(k => k !== 'r').map(k => 'z_' + k + 'head'), 'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons 'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel 'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS @@ -1711,10 +1714,16 @@ function cardBody(c, dx) { // and boss names of any length, and Bangers 22 ran a long one off both edges of a phone. Measure // once and shrink to the band; 22 stays 22 for everything that already fit. // THE OPPONENT CARD CARRIES ITS HEAD (Oliver: the punchline plus a picture of his head). The - // icon is a straight crop out of the family's own healthy frame — config.HEAD_CROP, measured, - // never separate art — drawn left of the text block, which shifts right to keep the whole - // group centred as one. A family with no crop row degrades to the plain text card. - const hd = c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1'] ? HEAD_CROP[c.head] : null; + // icon prefers the 4K-cut z_head.png (assets/_heads.py — the installed 224 px frames gave + // ~65 px heads drawn at 144 device px, which is the "very low res" he reported) and degrades + // to the HEAD_CROP crop of the healthy frame, then to the plain text card. Never separate art: + // both sources are the body already on court. + const headImg = c.head && SPR['z_' + c.head + 'head']; + const hd = headImg ? { img: headImg, w: headImg.width, h: headImg.height } + : c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1'] + ? { img: SPR['z_' + c.head + '1'], box: HEAD_CROP[c.head], + w: HEAD_CROP[c.head].w, h: HEAD_CROP[c.head].h } + : null; const ih = hd ? CARD_H - 14 : 0, iw = hd ? Math.round(hd.w * ih / hd.h) : 0; const room = W - 24 - (hd ? iw + 8 : 0); ctx.font = B(22); @@ -1731,8 +1740,9 @@ function cardBody(c, dx) { let blockW = 0; for (const [t, f] of lines) { ctx.font = f; blockW = Math.max(blockW, ctx.measureText(t).width); } ctx.imageSmoothingEnabled = false; - ctx.drawImage(SPR['z_' + c.head + '1'], hd.x, hd.y, hd.w, hd.h, - Math.round(cx - blockW / 2 - 8 - iw), Math.round(CARD_MID - ih / 2), iw, ih); + const hx = Math.round(cx - blockW / 2 - 8 - iw), hy = Math.round(CARD_MID - ih / 2); + if (hd.box) ctx.drawImage(hd.img, hd.box.x, hd.box.y, hd.box.w, hd.box.h, hx, hy, iw, ih); + else ctx.drawImage(hd.img, hx, hy, iw, ih); } for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, cx, y + rel, col); } ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred diff --git a/test.js b/test.js index a0052b2..7061e49 100644 --- a/test.js +++ b/test.js @@ -505,9 +505,13 @@ test('endDrill hands the court back', () => { const startMatch = () => { const r = _startMatch(); G.modal = null; G.modalQ.length = 0; return r; }; const oppOf = () => zombies.find(z => z.variant === 'r'); -// a won game now opens the MOP BEAT (TUNE.matchMopT) before the next opponent walks on — -// tests that play across a game boundary step through it here -const passMopBeat = () => { for (let i = 0; i < (TUNE.matchMopT + 1) * 60; i++) update(DT); }; +// a won game now opens the MOP BEAT (TUNE.matchMopT) before the next opponent walks on — plus +// the boy's walk-off hold (he must LEAVE the court) and possibly the WOUNDS TREATED popup, which +// freezes the world until tapped. Tests that play across a game boundary step through all of it, +// dismissing popups the way a player would. +const passMopBeat = () => { // ...stopping at the walk-on: past it, the next opponent PLAYS + for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); } +}; test('a match owns the court like every other mode', () => { assert.ok(startMatch(), 'a match starts on a clean court'); @@ -540,7 +544,10 @@ test('a point won tears a wound stage off the body; only the game point fells it assert.ok(!oppOf(), 'no fresh opponent on the kill frame — the mop beat owns the court'); assert.ok(G.match.mopT > 0, 'the beat is armed'); assert.ok(G.interlude && G.interlude.introT > 0, 'and serving is barred for its whole length'); - for (let i = 0; i < (TUNE.matchMopT + 1) * 10; i++) update(0.1); + // ...and the beat now also WAITS FOR THE BOY TO LEAVE the court before the walk-on + for (let i = 0; i < (TUNE.matchMopT + 2) * 10 && G.boy && Math.abs(G.boy.x) < 6.5; i++) update(0.1); + assert.ok(!oppOf(), 'still no opponent while the boy is on the court'); + passMopBeat(); const next = oppOf(); assert.ok(next && next !== zb, 'a fresh opponent shambled on after the mop beat'); assert.equal(next.hpMax, TUNE.matchOppHp, 'same flat pool — the ladder escalates in speed/flub, not hp'); @@ -766,7 +773,7 @@ test('service alternates by game, and the opponent actually serves', () => { for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish } assert.equal(score.server, 'O', 'their serve next game'); - for (let i = 0; i < (TUNE.matchMopT + 1) * 60; i++) update(DT); // the mop beat holds the court first + passMopBeat(); // the mop beat (and the boy's exit) come first // the opponent stands behind ITS baseline to serve, not on the receiving mark const rz = zombies.find(z => z.variant === 'r'); assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2));