diff --git a/assets/_zsheet.py b/assets/_zsheet.py index d8ead2d..bccc63e 100644 --- a/assets/_zsheet.py +++ b/assets/_zsheet.py @@ -111,6 +111,18 @@ def cut(sheet, fam, outdir): k_row = crops[0].shape[0] / crops[i].shape[0] for j in range(i, i + len(row)): mult[j] = k_row + # A WALK CYCLE IS ONE DRAWING WITH THE LEGS MOVED, so its frames must be the SAME HEIGHT. + # The generator does not know that: the six new families came back with the shuffle frame + # 30-44 px shorter than the stance it walks out of, and the game draws every frame at its own + # height — so the body rose and dropped once per step. "the opponents are bouncing up and + # down on the court." The hand-made racket family spreads 9 px; that is what right looks like. + # Only the STANCE and the two shuffle frames are levelled; the forehand is a different pose + # and keeps its own height, exactly as the serve and the fall do. + elif not row[0].endswith('bounce'): + n = min(3, len(row)) # stance + stride pair (wound rows: the pair) + for j in range(i, i + n): + if crops[j].shape[0]: + mult[j] = crops[i].shape[0] / crops[j].shape[0] i += len(row) os.makedirs(outdir, exist_ok=True) diff --git a/assets/audio/ump_felled.m4a b/assets/audio/ump_felled.m4a deleted file mode 100644 index 0b96c66..0000000 Binary files a/assets/audio/ump_felled.m4a and /dev/null differ diff --git a/assets/audio/ump_wound_opp2.m4a b/assets/audio/ump_wound_opp2.m4a deleted file mode 100644 index caa92fd..0000000 Binary files a/assets/audio/ump_wound_opp2.m4a and /dev/null differ diff --git a/assets/audio/ump_wound_you.m4a b/assets/audio/ump_wound_you.m4a deleted file mode 100644 index 7de6ca0..0000000 Binary files a/assets/audio/ump_wound_you.m4a and /dev/null differ diff --git a/assets/z_bb2.png b/assets/z_bb2.png index 6791349..1e2f5f5 100644 Binary files a/assets/z_bb2.png and b/assets/z_bb2.png differ diff --git a/assets/z_bb3.png b/assets/z_bb3.png index 810b265..368090d 100644 Binary files a/assets/z_bb3.png and b/assets/z_bb3.png differ diff --git a/assets/z_bbw1b.png b/assets/z_bbw1b.png index fc4b448..f45b2d4 100644 Binary files a/assets/z_bbw1b.png and b/assets/z_bbw1b.png differ diff --git a/assets/z_bbw1s.png b/assets/z_bbw1s.png index 3c56eed..7326fef 100644 Binary files a/assets/z_bbw1s.png and b/assets/z_bbw1s.png differ diff --git a/assets/z_bbw2b.png b/assets/z_bbw2b.png index 75db99a..f4729a4 100644 Binary files a/assets/z_bbw2b.png and b/assets/z_bbw2b.png differ diff --git a/assets/z_bbw2s.png b/assets/z_bbw2s.png index 2b3fcab..12aa068 100644 Binary files a/assets/z_bbw2s.png and b/assets/z_bbw2s.png differ diff --git a/assets/z_br2.png b/assets/z_br2.png index b814c25..bb19d9d 100644 Binary files a/assets/z_br2.png and b/assets/z_br2.png differ diff --git a/assets/z_br3.png b/assets/z_br3.png index 85c4eb2..f0f5b2c 100644 Binary files a/assets/z_br3.png and b/assets/z_br3.png differ diff --git a/assets/z_brw1s.png b/assets/z_brw1s.png index df8717e..10146cf 100644 Binary files a/assets/z_brw1s.png and b/assets/z_brw1s.png differ diff --git a/assets/z_brw2b.png b/assets/z_brw2b.png index ebe3507..602e87e 100644 Binary files a/assets/z_brw2b.png and b/assets/z_brw2b.png differ diff --git a/assets/z_brw2s.png b/assets/z_brw2s.png index 6b95d7f..002baed 100644 Binary files a/assets/z_brw2s.png and b/assets/z_brw2s.png differ diff --git a/assets/z_gk2.png b/assets/z_gk2.png index f2035aa..db298cb 100644 Binary files a/assets/z_gk2.png and b/assets/z_gk2.png differ diff --git a/assets/z_gk3.png b/assets/z_gk3.png index 9ccf864..e181c46 100644 Binary files a/assets/z_gk3.png and b/assets/z_gk3.png differ diff --git a/assets/z_gkw1s.png b/assets/z_gkw1s.png index 5f88644..1cdd292 100644 Binary files a/assets/z_gkw1s.png and b/assets/z_gkw1s.png differ diff --git a/assets/z_gkw2b.png b/assets/z_gkw2b.png index db42bd0..f30bb34 100644 Binary files a/assets/z_gkw2b.png and b/assets/z_gkw2b.png differ diff --git a/assets/z_gkw2s.png b/assets/z_gkw2s.png index 261f8bc..21418f6 100644 Binary files a/assets/z_gkw2s.png and b/assets/z_gkw2s.png differ diff --git a/assets/z_lj2.png b/assets/z_lj2.png index 15c3263..bfa659e 100644 Binary files a/assets/z_lj2.png and b/assets/z_lj2.png differ diff --git a/assets/z_lj3.png b/assets/z_lj3.png index c027e39..87fdf1c 100644 Binary files a/assets/z_lj3.png and b/assets/z_lj3.png differ diff --git a/assets/z_ljw1b.png b/assets/z_ljw1b.png index 297762b..3941c32 100644 Binary files a/assets/z_ljw1b.png and b/assets/z_ljw1b.png differ diff --git a/assets/z_ljw1s.png b/assets/z_ljw1s.png index 438ba7a..6d6d1c0 100644 Binary files a/assets/z_ljw1s.png and b/assets/z_ljw1s.png differ diff --git a/assets/z_ljw2b.png b/assets/z_ljw2b.png index bb1ab79..95c05dc 100644 Binary files a/assets/z_ljw2b.png and b/assets/z_ljw2b.png differ diff --git a/assets/z_ljw2s.png b/assets/z_ljw2s.png index 8d2ac1b..b40ec0d 100644 Binary files a/assets/z_ljw2s.png and b/assets/z_ljw2s.png differ diff --git a/assets/z_ru2.png b/assets/z_ru2.png index cf65115..8d83a71 100644 Binary files a/assets/z_ru2.png and b/assets/z_ru2.png differ diff --git a/assets/z_ru3.png b/assets/z_ru3.png index 41da348..8031678 100644 Binary files a/assets/z_ru3.png and b/assets/z_ru3.png differ diff --git a/assets/z_ruw1s.png b/assets/z_ruw1s.png index 9666cbd..a519a9f 100644 Binary files a/assets/z_ruw1s.png and b/assets/z_ruw1s.png differ diff --git a/assets/z_ruw2b.png b/assets/z_ruw2b.png index 1b75323..db338bd 100644 Binary files a/assets/z_ruw2b.png and b/assets/z_ruw2b.png differ diff --git a/assets/z_ruw2s.png b/assets/z_ruw2s.png index 9d22ae7..36c4ab3 100644 Binary files a/assets/z_ruw2s.png and b/assets/z_ruw2s.png differ diff --git a/assets/z_sh2.png b/assets/z_sh2.png index 8a5c7be..6a93120 100644 Binary files a/assets/z_sh2.png and b/assets/z_sh2.png differ diff --git a/assets/z_sh3.png b/assets/z_sh3.png index f919322..221ab2e 100644 Binary files a/assets/z_sh3.png and b/assets/z_sh3.png differ diff --git a/assets/z_shw1s.png b/assets/z_shw1s.png index 7bd843e..3345dc9 100644 Binary files a/assets/z_shw1s.png and b/assets/z_shw1s.png differ diff --git a/assets/z_shw2b.png b/assets/z_shw2b.png index 7459d48..302e1b6 100644 Binary files a/assets/z_shw2b.png and b/assets/z_shw2b.png differ diff --git a/assets/z_shw2s.png b/assets/z_shw2s.png index 0424812..5d63d0b 100644 Binary files a/assets/z_shw2s.png and b/assets/z_shw2s.png differ diff --git a/assets/z_st2.png b/assets/z_st2.png index 489f9b3..58c56d6 100644 Binary files a/assets/z_st2.png and b/assets/z_st2.png differ diff --git a/assets/z_st3.png b/assets/z_st3.png index 4a344b6..10647bd 100644 Binary files a/assets/z_st3.png and b/assets/z_st3.png differ diff --git a/assets/z_stw1s.png b/assets/z_stw1s.png index 67a49c8..920b2c1 100644 Binary files a/assets/z_stw1s.png and b/assets/z_stw1s.png differ diff --git a/assets/z_stw2b.png b/assets/z_stw2b.png index f420770..b4f8f18 100644 Binary files a/assets/z_stw2b.png and b/assets/z_stw2b.png differ diff --git a/assets/z_stw2s.png b/assets/z_stw2s.png index c90d197..d7a6cf6 100644 Binary files a/assets/z_stw2s.png and b/assets/z_stw2s.png differ diff --git a/index.html b/index.html index 7d23661..de89687 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 8a0a56a..98f1bf6 100644 --- a/src/beats.js +++ b/src/beats.js @@ -366,7 +366,7 @@ function matchReady() { // stumble; the beat only lands if it is down long enough to look beaten. // "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth // of a second — and the time is spent on the floor, where it reads. -const FALL = [['fall1', 0.09], ['fall2', 0.11], ['fall3', 2.0], ['rise', 0.30]]; +const FALL = [['fall1', 0.05], ['fall2', 0.07], ['fall3', 2.0], ['rise', 0.30]]; export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0); function startFall(from) { @@ -473,7 +473,7 @@ function matchPoint(side) { // AFTER the score call, not under it. voice() drops any line that would start while the umpire // is still talking (one man, one microphone) — and matchPoint calls scoreKey() at 0.4 s with a // clip over a second long, so every one of these was being swallowed silently. - aside(G.hp <= 2 ? 'ump_low_blood' : (score.pts[1] % 2 ? 'ump_wound_you2' : 'ump_wound_you'), 2.2); + aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2); if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever } const over = advancePoint(side); // banks the game and resets the points itself @@ -484,7 +484,7 @@ function matchPoint(side) { // A LIMB IS WORTH A REMARK. Two lines, alternating on the stage taken, so the same sentence // never lands twice in one game — a repeated aside reads as a stuck HUD, the same reason the // praise words cycle rather than roll. - aside(fellFrom ? 'ump_wound_opp2' : 'ump_wound_opp', 2.2); // ...after the score call, see above + aside('ump_wound_opp', 2.2); // ...after the score call, see above return startFall(fellFrom); // it goes down, and gets up short of a limb } matchReady(); // next point, same opponent, both on their marks @@ -500,7 +500,6 @@ function matchGameWon(zb, banked = false) { 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 voice('game_player', 0.3); cheer(2.4, 0.6); - aside('ump_felled', 2.8); // ...after 'game', over the applause // 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 // once — and the plate is the better card anyway: it announces the win by replacing the loser. diff --git a/src/input.js b/src/input.js index 1643ace..2006a1b 100644 --- a/src/input.js +++ b/src/input.js @@ -5,6 +5,7 @@ import { cv } from './dom.js'; import { audio, pock } from './audio.js'; import { TUNE, WEAPONS } from './config.js'; +import { benchButtons, W, H } from './render.js'; // W/H are live bindings: fitViewport rewrites them import { G, score } from './state.js'; import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge } from './gameplay.js'; import { rackHit } from './render.js'; @@ -106,6 +107,21 @@ cv.addEventListener('touchstart', e => { // gun"). Only the TAP dies, decided at touchend: a drag or hold starting in the same spot is // a deliberate gesture (burst steering parks the thumb low, serve pulls start anywhere) and // must keep working, so the stick is still created. + // THE BENCH'S OWN BUTTONS, tested before anything else — the bench is a back-room art screen and + // its taps mean only one thing. Arrow keys are not a control scheme on a phone, which is exactly + // the device the walk cycles have to be judged on. Geometry comes from render (benchButtons), so + // a moved button cannot stop responding — the rack learned that lesson first. + if (G.bench) { + const bx = (t.clientX - r.left) / r.width * W, by = (t.clientY - r.top) / r.height * H; + let hit = false; + for (const b of benchButtons()) { + if (bx >= b.x && bx <= b.x + b.w && by >= b.y && by <= b.y + b.h) { + keyPressed(bx < b.x + b.w / 2 ? b.k : b.k2); // left half steps back, right half forward + hit = true; break; + } + } + if (hit) continue; + } let nearRack = false; if (!G.horde) { // rack tap selects a tin — a burst can be aborted out of, a horde cannot (selectWeapon) const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height); diff --git a/src/render.js b/src/render.js index 8583a64..8982e83 100644 --- a/src/render.js +++ b/src/render.js @@ -195,7 +195,7 @@ export function proj(x, y, 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 = 104; +export const SPRV = 105; // 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". @@ -742,7 +742,11 @@ function drawZombie(zb) { // the ring turns gold inside the band. The landing ring set this precedent: a marker that // contracts toward its moment IS a timing cue, no legend needed. function drawApproachRing() { - if (!G.match || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return; + // ...in CLASSIC TENNIS TOO. This was `!G.match`, so the mode whose entire pitch is "if the core + // tennis is good everything else is good" was the one mode without the timing cue that makes the + // core tennis legible. Reported three times before it was believed. The gore mode is left out on + // purpose: there is no rally there, the ball comes back off bodies. + if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return; const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back const p = proj(ball.x, ball.y, ball.z); @@ -762,7 +766,7 @@ function drawApproachRing() { // zone for the playable one. A tick rides the live ball height. The ghost hand MIMES this moment; // the band makes it a target you can see coming. function drawApexBand() { - if (!G.match || G.phase !== 'toss' || score.server !== 'P') return; + if (!(G.match || G.mode === 'tennis') || G.phase !== 'toss' || score.server !== 'P') return; const gx = ball.x + 0.55; // beside the toss, out of the ball's way const apex = serveApexY(); const yTop = proj(gx, apex + 0.45, ball.z), yBot = proj(gx, 1.2, ball.z); @@ -785,8 +789,35 @@ function drawApexBand() { // THE SPRITE BENCH'S READOUT. The one thing a contact sheet cannot give you: what the game is // ACTUALLY drawing, right now, at the size it plays at — including the filename, so a frame that // looks wrong on the lawn names itself instead of being hunted for afterwards. +// THE BENCH'S OWN BUTTONS. Arrow keys are not a control scheme on the device this game is played +// on, so every family and every wound stage was unreachable from a phone — which is where "the +// walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing, +// and input.js hit-tests the same function: one source, so a moved button cannot stop responding +// (the rack learned this the hard way — see rackGeom). +export function benchButtons() { + const w = Math.floor((W - 24) / 3), h = 22, y = H - SAFE.bottom - h - 6; + return [ + { k: 'ArrowUp', k2: 'ArrowDown', label: 'BODY', x: 8, y, w, h }, + { k: 'ArrowLeft', k2: 'ArrowRight', label: 'WOUND', x: 8 + w + 4, y, w, h }, + { k: 'Space', k2: 'Space', label: 'POSE', x: 8 + (w + 4) * 2, y, w, h }, + ]; +} + +function drawBenchButtons() { + for (const b of benchButtons()) { + panel(b.x, b.y, b.w, b.h, 4, 'rgba(12,14,20,0.82)', UI.dim); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = F(8); + // '<' and '>' on the two-way ones say the tap SIDE matters, which is the whole control: + // left half steps back, right half steps forward. + shadowText(b.k === b.k2 ? b.label : '\u2039 ' + b.label + ' \u203a', + b.x + b.w / 2, b.y + b.h / 2 + 1, UI.cream); + } + ctx.textBaseline = 'alphabetic'; +} + function drawBenchHud() { const b = G.bench; if (!b) return; + drawBenchButtons(); const zb = zombies.find(z => z.variant === 'r'); const y = CARD_TOP + 4; ctx.textAlign = 'center'; diff --git a/test.js b/test.js index d5eff7f..631f194 100644 --- a/test.js +++ b/test.js @@ -6,6 +6,7 @@ // (pretoss = no steering, horde = no serving, spawns keep coming → unrecoverable). import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; // ---- DETERMINISM. This suite is a Dockerfile build stage: a red run blocks the deploy, so a test // that fails one run in five is worse than no gate at all — it trains you to re-run it. Two of the @@ -636,6 +637,20 @@ test('the match recovers to CENTRE between shots — a wide return must not stra } finally { TUNE.autoAssist = assist; } }); +test('the tennis timing cues belong to CLASSIC tennis too, not just the match', () => { + // Reported three times before it was believed, and it was a one-word gate each time: drawApproachRing + // and drawApexBand both opened with `if (!G.match ...)`, so the mode whose whole pitch is "if the + // core tennis is good everything else is good" was the one mode without the cues that make the + // core tennis readable. Asserted on the SOURCE because these are draw calls with no state to + // observe — what matters is that neither is gated on the match alone ever again. + const src = readFileSync(new URL('./src/render.js', import.meta.url), 'utf8'); + for (const fn of ['drawApproachRing', 'drawApexBand']) { + const body = src.slice(src.indexOf('function ' + fn), src.indexOf("function " + fn) + 900); + assert.ok(!/if \(!G\.match \|\|/.test(body), `${fn} must not be gated on the match alone`); + assert.ok(/G\.match \|\| G\.mode === 'tennis'/.test(body), `${fn} must also run in classic tennis`); + } +}); + test('the opponent card is a RAMP: it starts easy and only gets harder', () => { // "the players should start easy and get harder over time." That is a property of the table, not // of any code, so the table is what gets asserted — a row edited into the wrong slot is otherwise