Make it readable: 2x sprites, a flatter camera, honest hit timing

The umpire is situational again. Each line is tied to the thing that justifies
it — a new variant appearing, the court filling, one crossing your baseline, a
game lost, a horde, a changeover — behind one shared cooldown, so several
triggers at once still yield one remark. The random timer pool is gone, the
interval went 26s to 55s, and ump_out_of_order, ump_let and ump_no_appeal are
deleted.

"Visually the ball is not yet at the player but somehow the player already is
hit" was real and measurable. The hostile-ball hit test reused the parry's
hypot(dx, dz*0.8), and weighting depth shrinks it: contact fired while the ball
was still 0.68 m up-court and drawn up around his head. It has its own
unweighted test now, and contact lands at 0.25 m with the ball on his chest.
The parry keeps its weighting — a racket sweep really is wider than it is deep.

On "everything is a tad too small": measured first, because higher-resolution
sprites are only half the answer. At RES 3 the player rasterises into 170
device pixels from a 112px source — upscaled 1.5x — while a far shambler is
downscaled to 78. So near bodies needed resolution and far bodies needed the
camera.

Camera: pushed back and lengthened (camZ 42->48, camF 580->760) to flatten the
perspective so distant bodies grow relative to near ones rather than everything
zooming, camY raised for a steeper look-down, horizon re-centred. Far shambler
26->30px, boss 39->45px, player unchanged, court in the same band.

Sprites: the original sheets were still in the repo at full resolution, so
nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was
re-sliced. The sheet-to-output mapping was never recorded, so it was recovered
by matching sliced frames to existing assets by image signature: 58 came back
from sheets, the 29 without a confident match were 2x-upscaled to stay on the
shared scale, since a sprite off that scale renders at half height.

Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 16:23:04 +02:00
1 parent 8bc57e9358
commit 49d391dc64
95 files changed
+119 -21

No files matched your search

+14 -10
View File
@@ -241,16 +241,15 @@ export function dismissModal() {
}
// The umpire's asides. He has never once acknowledged that the match turned into a massacre, and
// that is the joke — so he keeps officiating it. Fired on a jittered timer, and only when nothing
// else is talking (a modal, a changeover, the duel announcement all own the audio).
const ASIDES = ['ump_dress_code', 'ump_no_appeal', 'ump_hindrance', 'ump_new_balls_dark',
'ump_still_a_match', 'ump_spectators', 'ump_let', 'ump_scoreline', 'ump_unsportsmanlike',
'ump_out_of_order'];
function umpireAside(dt) {
// that is the joke — so he keeps officiating it. But each line is tied to a SITUATION that actually
// justifies it, not drawn from a bag on a timer: Oliver's verdict on the first version was "too much
// and not situation aware.. they seem random". `aside()` is the single entry point and it enforces
// one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark.
export function aside(name, delay = 0.2) {
if (G.mode !== 'splatter' || G.modal || G.changeover || G.interlude || G.card) return;
if ((G.asideT -= dt) > 0) return;
G.asideT = TUNE.asideEvery * (0.6 + Math.random() * 0.8);
voice(ASIDES[Math.floor(Math.random() * ASIDES.length)], 0.2, 0.95);
if (G.asideT > 0) return;
G.asideT = TUNE.asideEvery;
voice(name, delay, 0.95);
}
export function cheer(seconds, vol) {
@@ -321,6 +320,7 @@ export function splatPoint(side, quiet = false) {
// long stretch with no clear skips a chapter rather than queueing one, which is intended
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
const i = side === 'P' ? 0 : 1;
if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
@@ -388,6 +388,7 @@ function startChangeover() {
// now long enough that an empty court reads as the game having stalled.
sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
G.flash = { text: 'CHANGEOVER', t: 1.4 };
aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
}
@@ -418,6 +419,8 @@ export function deliverTin() {
else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — a burst you pop when you choose, not on arrival
if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking)
sendBoy(); voice('new_balls', 0.6);
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
const name = SLOTS[w + 1];
showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w],
iconH: 40, color: UI.warm });
@@ -536,6 +539,7 @@ function startHorde() {
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
voice('remain_seated', 0.3);
aside('ump_spectators', 2.2); // a horde IS the crowd on the court
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
}
@@ -912,7 +916,7 @@ export function update(dt) {
}
crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt;
umpireAside(dt);
if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside())
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
// other beat that overwrote overT while the breather ran (a melee kill taking a game, say)
// had the umpire calling time on a pause that was never a changeover.