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>
No files matched your search
@@ -17,7 +17,7 @@ KEY_D2 = 60 * 60
|
||||
MIN_GAP = 12
|
||||
MIN_BLOB = 60
|
||||
|
||||
def key(im, defringe=2):
|
||||
def key(im, defringe=2, bed=False):
|
||||
a = np.array(im.convert('RGBA'))
|
||||
bg = a[0, 0, :3].astype(int)
|
||||
d = ((a[..., :3].astype(int) - bg) ** 2).sum(2)
|
||||
@@ -39,7 +39,14 @@ def key(im, defringe=2):
|
||||
# ...and what that leaves — magenta INSIDE a body — is repainted as pale grey rather than kept,
|
||||
# because bright pink strings are as wrong as no strings. Luminance is preserved, so the drawn
|
||||
# crosshatch survives as crosshatch; only the hue goes.
|
||||
# UNLESS the sheet MEANS its magenta beds (`bed=True` — the famroll convention, adopted by the
|
||||
# opponent refresh and _runroll): then enclosed magenta is the string bed and CUTS TO
|
||||
# TRANSPARENCY, exactly as _zsheet already treats it. The grey repaint is what shipped the
|
||||
# opponents holding solid white paddles.
|
||||
inner = magenta & ~outside
|
||||
if bed:
|
||||
a[inner] = 0
|
||||
inner = np.zeros_like(inner)
|
||||
if inner.any():
|
||||
lum = a[..., :3].mean(2)
|
||||
a[inner, 0] = a[inner, 1] = a[inner, 2] = np.clip(lum[inner] * 0.5 + 115, 0, 255).astype('uint8')
|
||||
@@ -152,8 +159,8 @@ def frames(a, cuts, want=0):
|
||||
out = list(zip(edges, edges[1:]))
|
||||
return out
|
||||
|
||||
def main(sheet, outs, target_h, cuts, anchor, defringe=2):
|
||||
a = key(Image.open(sheet), defringe)
|
||||
def main(sheet, outs, target_h, cuts, anchor, defringe=2, bed=False):
|
||||
a = key(Image.open(sheet), defringe, bed)
|
||||
b = block_size(a)
|
||||
crops = []
|
||||
spans = frames(a, cuts, len(outs))
|
||||
|
||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 169 KiB |
@@ -408,7 +408,8 @@
|
||||
"anchor": 0,
|
||||
"check": "loose",
|
||||
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art, side by side. FRAME 1: a plain standing frame for scale, exactly like the reference. FRAME 2 \u2014 THE BACKSWING: he has turned his shoulders and taken the racket BACK BEHIND HIS RIGHT HIP, low and well behind his body, arm coiled, racket head pointing backwards away from the net, weight on his back foot. He has NOT hit the ball yet. FRAME 3 \u2014 THE FOLLOW-THROUGH: the swing is FINISHED. His arm has swept all the way ACROSS HIS CHEST to the OTHER SIDE of his body and the racket head is now UP AND OVER HIS LEFT SHOULDER, near his left ear, his chest turned to face the net, weight on his front foot. FRAMES 2 AND 3 MUST LOOK COMPLETELY DIFFERENT FROM EACH OTHER \u2014 in frame 2 the racket is BEHIND him on his RIGHT, in frame 3 it is HIGH on his LEFT. This contrast is the whole point of the pair. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, DARK CHARCOAL GREY shorts, RED cloth headband, blond hair, white shoes, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, never white or cream. In both swing frames he stays UPRIGHT at his full standing height \u2014 head at the same level as the standing frame, back straight, knees only slightly bent, never crouching or lunging low. The whole racket is drawn and fully inside the canvas. His shoes are WHITE with RED socks and his shirt is a BRIGHT PURE RED, exactly like the reference \u2014 not brick, not brown, not orange.",
|
||||
"palette_ref": "o_idle1"
|
||||
"palette_ref": "o_idle1",
|
||||
"pose": true
|
||||
},
|
||||
"opponent_idle": {
|
||||
"sprites": [
|
||||
|
||||
@@ -103,7 +103,9 @@ def prompt(k):
|
||||
'behind the back to the left, never changes sides between neighbouring figures. The right '
|
||||
'arm swings a LITTLE; the racket head stays visibly right of the body\'s midline throughout. '
|
||||
'A single figure with the racket on the left side makes the whole animation flicker and is '
|
||||
'WRONG.\n\n'
|
||||
'WRONG. THE INSIDE OF EVERY RACKET HEAD IS EXACTLY THE BACKGROUND MAGENTA #FF00FF — never '
|
||||
'white, never grey, never solid: it cuts to transparency, the same rule the family sheets '
|
||||
'follow (a white-filled bed shipped as a solid paddle once — that is what this line prevents).\n\n'
|
||||
f'THE {N} FIGURES ARE ONE CONTINUOUS RUN CYCLE, {c["view"]}. Evenly spaced, every figure on '
|
||||
'one shared ground line, every figure at the same scale as its neighbours. THE FIGURES '
|
||||
'NEVER TOUCH OR OVERLAP: leave a clear band of empty magenta between every pair of '
|
||||
@@ -184,7 +186,7 @@ def cut(k, install=False, n=0):
|
||||
outdir = HERE if install else os.path.join(SCRATCH, f'runcut_{k}')
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
outs = [os.path.join(outdir, f'{pre}{i}{suf}.png') for i in range(1, got + 1)]
|
||||
_fine.main(sheet, outs, TARGET_H, [], -1)
|
||||
_fine.main(sheet, outs, TARGET_H, [], -1, bed=True)
|
||||
# ANCHOR EVERY FRAME ON ITS FEET. drawChar centres a frame by its total width, and the racket's
|
||||
# reach varies wildly across a cycle (the cut female frames measured -28..+15 px of feet drift
|
||||
# from frame centre) — so the BODY teleported sideways every frame, which is exactly Oliver's
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=242"></script>
|
||||
<script type="module" src="src/main.js?v=244"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||