The timing cues were match-only, and the walk cycles bounced
CLASSIC TENNIS, third time asked and this time actually found. Both cues open with a mode gate and I checked the wrong two features twice: drawApproachRing (the closing circles that show when to tap) and drawApexBand (the serve bar with the gold perfect window) each began `if (!G.match ...)`. So the mode whose whole pitch is "if the core tennis is good everything else is good" was the one mode with neither. They run on `G.match || G.mode === 'tennis'` now — the gore mode stays out on purpose, there is no rally there. A test reads both function bodies and fails if either is ever gated on the match alone again. (The two I kept pointing at — the landing ring and the placement ring — really are shared. They were just not the ones being asked about.) THE BOUNCING WAS IN THE ART, and measurable: the six generated families came back with the shuffle frame 30-44 px shorter than the stance it walks out of, against 9 px on the hand-made racket family. The game draws every frame at its own PNG height, so the body rose and dropped once per step. A walk cycle is one drawing with the legs moved and must be one height; _zsheet levels the stance and the stride pair now and leaves the forehand its own, exactly as the serve and the fall keep theirs. All seven families re-cut: spreads are 1-8 px. THE BENCH GETS BUTTONS. Arrow keys are not a control scheme on a phone, which is the device the walk has to be judged on — so every family and every wound stage was unreachable from the place the problem shows. Three on-screen pads: BODY, WOUND, POSE, the two-way ones split left/right. Geometry lives beside the drawing and input hit-tests the same function, the rule the rack established. THE FALL is quicker still (0.05/0.07/2.0/0.30): drop, lie there, get up. Three umpire lines pulled — ump_wound_you, ump_felled, ump_wound_opp2. Not funny enough to keep. Files deleted, call sites collapsed to the two that stay. 60 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 7.2 KiB |
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=194"></script>
|
||||
<script type="module" src="src/main.js?v=195"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <img> 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';
|
||||
|
||||
@@ -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
|
||||
|
||||