The opponents come back to life: the friendly refresh, open string beds, and a steadier chase

Oliver's batch, delivered in dependency order. "The opponents look pretty
harsh, almost already pre-zombie" — so BOTH tennis opponents got a new
identity: warm skin, human eyes, an easy near-smile, rolled anchor-first so
every later sheet inherits the face. The male's bounce pair also ADOPTS the
female's palm-out posture (his old crouched receiver stance was the wrong
pose for bouncing a ball — her better one is now the shared convention), and
the pre-serve ball was re-measured onto the drawn palms: BOUNCE_IDLE's oDx
0.45 -> 0.24 / oApex 0.55 -> 0.72 for his close carry, with her wider reach
riding TOURS (oDx 0.48 / oApex 0.73) the same way the player's pair already
does.

"parts of the body as well as the inner of the racket are solid" — the beds
were the cutter's own doing: _fine.key repaints enclosed magenta pale grey
(protection against accidental magenta strings), which defeated the beds we
now draw magenta ON PURPOSE. key()/main() grow a bed=True switch (the
famroll convention, used by _runroll and the refresh cuts); the run cycles'
beds went from ~70% to ~44% opaque — strings, not paddles. Enclosed pockets
of a roll's off-spec background (one sheet came back on blue) are scrubbed
by corner-colour distance too.

Both RUN CYCLES re-rolled off the friendly anchors: his landed 8 clean
frames (racket +0.05..+0.30, one side); hers took four rolls — the gates
caught a white background, a crossing racket, near-dup neighbours — and the
keeper drew a 10-frame cycle, from which the best 8 were chosen by
maximising the minimum adjacent stride (29.7%, smoother than his 22.7).
o_run7/8 return to the SPR list.

AND THE CHASE STOPS DITHERING (Oliver: "switching between a left and right
movement increasingly faster the nearer the ball comes"): intercept()
re-solves every frame and the earliest-reachable point jumps around its
solution as the flight shortens, flipping his run direction each time. The
CPU now commits to its stored target unless a fresh solve moves it by more
than 0.35 m. SPRV 121, main.js v244, 66 tests, every opponent frame verified
200 in the live game.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 13:16:55 +02:00
1 parent dd8dbd04e9
commit 80bf6c4cee
41 files changed
+37 -14

No files matched your search

+7 -2
View File
@@ -689,7 +689,8 @@ export const CUP_ROUNDS = [
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78, tossDx: -0.59 },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78, tossDx: -0.59,
oDx: 0.48, oApex: 0.73 },
];
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
@@ -1137,7 +1138,11 @@ export const POSE_TIME = 0.38;
// opponent's ball out by his racket instead of under his palm. Both are FEEL KNOBS measured off the
// drawn palm by eye — a skin-detector guess at the hand lands on a knee, so tune these by looking.
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6, // the player, drawn from behind
oDx: 0.45, oApex: 0.55 }; // the opponent, drawn from the front
// the opponent, drawn from the front. Remeasured 2026-08-14 for the
// friendlier anchor art: HIS new palm-out bounce holds the ball close
// (palm +0.24 m, 0.83 m up); HER reach stays wide — her pair rides
// TOURS like the player's dx/apex already does.
oDx: 0.24, oApex: 0.72 };
// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it
export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline
+9 -1
View File
@@ -1732,7 +1732,15 @@ export function update(dt) {
G.aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P'
&& G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction);
const tgt = incoming ? intercept('O') : HOME.O;
// THE INTERCEPT GETS A DEADBAND (Oliver: "switching between a left and right movement
// increasingly faster the nearer the ball comes"). intercept() re-solves every frame, and as
// the flight shortens the earliest-reachable point jumps around its solution — each jump
// flips his run direction, faster and faster. He commits to the stored point unless the
// fresh solve moves it meaningfully (or the chase resets between shots).
const fresh = incoming ? intercept('O') : HOME.O;
if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35)
G.aiTgt = fresh;
const tgt = G.aiTgt;
const spd = TUNE.aiSpeed * (G.diff ? G.diff.speed : 1)
* (incoming ? 1 : (G.diff ? G.diff.recover : 0.5)); // slow recovery: wrong-footing wins points
+4 -4
View File
@@ -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 <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 = 119;
export const SPRV = 121;
// 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".
@@ -230,7 +230,7 @@ const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_run5','p_run6','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_swing1','o_swing2',
'o_run1','o_run2','o_run3','o_run4','o_run5','o_run6', // the rolled 6-frame cycle (_runroll.py; the model drew 6, and a clean 6 beats a fought-for 8)
'o_run1','o_run2','o_run3','o_run4','o_run5','o_run6','o_run7','o_run8', // the rolled 8-frame cycle (_runroll.py; the friendly refresh landed a full 8 again)
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4',
'x_crowd','x_crowd2','x_ads', // CENTRE COURT (grass) — also the fallback set
'x_crowd_c','x_crowd_c2','x_ads_c', // TERRE ROUGE (clay)
@@ -2129,8 +2129,8 @@ export function render() {
// the racket side. One `* sv.facing` fixes it for both players and both tours; it was wrong in
// the men's world too, it just took a second body to notice.
const mine = sv.side === 'P';
const bx = sv.x + sv.facing * (mine ? -bounceDx() : BOUNCE_IDLE.oDx);
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex);
const bx = sv.x + sv.facing * (mine ? -bounceDx() : (tour.oDx ?? BOUNCE_IDLE.oDx));
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : (tour.oApex ?? BOUNCE_IDLE.oApex));
drawBall(bx, by, sv.z);
}
// hot-ball ghost streak: a fast rally ball drags three fading ghosts behind it. It gated on