Drawn frames, not procedural fakes: brute lurch, boy stride, crowd applause

Every animation in this round gets real frames. The procedural squash-and-sway
on the wounded brute read as fake on sight, and that gait is where the comedy
lives.

- Each brute wound stage now has its own 2-frame lurch (z_gw/z_gwb, z_gw2/z_gw2b,
  ...). The generator gave every B frame a dragged pole that only stage 1
  actually carries, so it would have blinked in and out each step — stripped by
  keeping the largest blob, and where the pole touched the body, by cutting on
  column density (a torso is dense, a dragged pole is a thin diagonal).
- Frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys,
  z/bruteStride for brutes. A time-driven cycle moonwalks the moment speed and
  frame rate disagree, which is exactly what "he slides faster than the animation
  suggests" was. Change a speed now and the legs keep up on their own.
- One boy, one slot. sendBoy no longer replaces a boy already on court: that was
  the appearing and disappearing, since every kill teleported him back to the
  wing mid-stride. New mess is appended to his round instead. He now walks for
  10.5s a crossing, and there is a lot of mopping to do.
- The applauding crowd is a real two-frame animation (seated / arms up), swapped
  per column on its own phase. The old "smooth wave" still shifted each column by
  a FRACTIONAL dy, and nearest-neighbour resampling at a fractional y re-snaps a
  column's pixel rows every frame — that boiling beside the HUD is what read as
  the scoreboard flickering. The board art is 99.9% opaque; nothing was ever
  showing through it. With the crowd calm the frame delta is now 0.0%.

Benchmarks: 136/150 serve returns (90.7% ±2.4%, the 11/12 bar is inside one
sigma), 30 human-tapped points = 50 hits / 0 whiffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-27 10:20:43 +03:00
1 parent ff77922e85
commit 32401366fe
13 files changed
+77 -36

No files matched your search

+27
View File
@@ -155,6 +155,33 @@ New foundations you must know before touching anything:
exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5. exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5.
- `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still. - `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still.
## Second feedback round — "do it properly" (2026-07-27)
Oliver rejected the procedural fakes on sight. The rule this round: **animation gets drawn frames.**
- **Every brute wound stage now has a 2-frame lurch** (`z_gw`/`z_gwb`, `z_gw2`/`z_gw2b`, …). The
procedural squash-and-sway that replaced them read as fake immediately — "that's where the comedy
comes from". Generated by handing the generator a composite of the four A-frames
(`assets/_sheet_gwound_a.png`, built with PIL) and asking for the stride swapped.
**Gotcha:** the generator gave every B frame a dragged pole, but only stage 1's A frame has one —
it would have blinked in and out each step. Stripped by keeping the largest connected blob
(stages 2–3) and, where the pole touched the body (stage 4), by cutting on column density: the
torso is dense, a dragged pole is a thin diagonal of ~7 px per column.
- **Frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys,
`zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk whenever speed and frame rate
disagree, which is exactly what "he slides faster than the animation suggests" was.
Height for a wound stage ALWAYS comes from the A frame or the sprite pops size every step.
- **One boy, one slot.** `sendBoy` no longer replaces a boy who is already out — that was the
"appears and disappears": every kill teleported him back to the wing, so a kill streak restarted
him several times a second. New work is appended to his round (`jobs`). One crossing is now 10.5s.
- **The applauding crowd is a real two-frame animation** (`x_crowd` seated / `x_crowd2` arms up),
swapped per column on its own phase. The previous "smooth wave" still shifted each column by a
FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a fractional y re-snaps its
pixel rows every frame — that boiling beside the HUD was the "scoreboard flicker". The board art
is 99.9% opaque, so nothing was ever showing *through* it. **Test:** render twice without
stepping — identical pixels means nothing in the draw path rolls dice; and with the crowd calm
the frame-to-frame pixel delta must be 0.0%.
- Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected:
mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps.
## Gotchas learned the hard way ## Gotchas learned the hard way
- Browser-pane verification: python http.server sends no cache headers, so edited ES - Browser-pane verification: python http.server sends no cache headers, so edited ES
modules often load STALE even after location.reload(). Reliable fix: preview_stop + modules often load STALE even after location.reload(). Reliable fix: preview_stop +
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+8 -2
View File
@@ -31,8 +31,9 @@ export const TUNE = {
zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests) zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests)
zMaxCap: 14, // absolute shambler cap zMaxCap: 14, // absolute shambler cap
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
boyMopSpeed: 5.0, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5). boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
// Sized so one crossing ~fills the changeover instead of stranding him mid-court. boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
// the feet cannot slide: change a boy's speed and his legs keep up on their own.
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness) zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
@@ -92,6 +93,8 @@ export const TUNE = {
// full pierce simply killed the whole lane outright and nothing was left to catch fire. // full pierce simply killed the whole lane outright and nothing was left to catch fire.
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting) burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so
// its cycle is driven by ground covered rather than by the clock
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
@@ -121,6 +124,9 @@ COURT.svcNear = COURT.netZ + 6.4; // near service line
// world sizes (meters) // world sizes (meters)
export const PLAYER_H = 1.88; export const PLAYER_H = 1.88;
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
export const BOY_ENTER = { mop: 9.5, run: 13 };
export const UMP_H = 3.0; export const UMP_H = 3.0;
export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait export const UMP_X = 5.6; // chair right beside the doubles line — x 7.1 projected off-screen in portrait
+5 -3
View File
@@ -931,14 +931,16 @@ export function update(dt) {
// ball boy: sprint across, scoop the ball in passing, despawn off the far side // ball boy: sprint across, scoop the ball in passing, despawn off the far side
const boy = G.boy; const boy = G.boy;
if (boy) { if (boy) {
// mopping is not sprinting: the mop crossing takes ~6.6s, which is what fills the changeover // mopping is not sprinting. `dist` is what drives his animation frames (render.drawBoy), so
boy.x += boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt; boy.runT += dt; // the legs are tied to the ground he covers and can never out-run or lag behind the walk.
const step = boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt;
boy.x += step; boy.runT += dt; boy.dist = (boy.dist || 0) + Math.abs(step);
if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it
const dz = ball.z - boy.z; const dz = ball.z - boy.z;
boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
} }
if (Math.abs(boy.x) > 13.5) G.boy = null; if (Math.abs(boy.x) > 13.5) G.boy = null; // off the far wing — his round is done
} }
if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null; if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null;
+1 -1
View File
@@ -60,6 +60,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=29"></script> <script type="module" src="main.js?v=30"></script>
</body> </body>
</html> </html>
+29 -28
View File
@@ -75,9 +75,9 @@ export function proj(x, y, z) {
const SPR = {}; const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_ads', 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads',
'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel 'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel
'z_gw2','z_gw3','z_gw4', // the brute comes apart in stages (4/5 hp = z_gw, down to 1/5 = z_gw4) 'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2', 'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3', 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers) 'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers)
@@ -144,7 +144,10 @@ function drawBoy(b) {
// mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run // mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run
// frames until the mop PNGs have loaded. // frames until the mop PNGs have loaded.
const mop = b.mop && SPR.x_boymop1; const mop = b.mop && SPR.x_boymop1;
const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor(b.runT * (mop ? 8 : 14)) % 4 + 1)]; if (!img) return; // Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk
// whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers.
const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push
const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return;
const p = proj(b.x, 0, b.z); const p = proj(b.x, 0, b.z);
// scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per // scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per
// sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright // sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright
@@ -171,28 +174,20 @@ function drawZombie(zb) {
let name = 'z_' + v + frame; let name = 'z_' + v + frame;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out
const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))]; const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))];
if (SPR[stage]) name = stage; else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded if (SPR[stage]) {
if (SPR[name]) h *= SPR[name].height / SPRITE_H; // the headless stage really is shorter — don't stretch it back // Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by
// DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle
// was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what
// it was: fake. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b';
name = SPR[b] && Math.floor(zb.z / TUNE.bruteStride) % 2 ? b : stage;
h *= SPR[stage].height / SPRITE_H; // height ALWAYS off the A frame, or the size pops each step
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
} }
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler
else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1'; else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1';
// Every wound stage is a SINGLE frame, and the brute now spends most of its life wounded — so drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2);
// without this it slid toward you like a decal. Rather than eight more sprites, the wrecked
// stages get a procedural lumber: it heaves sideways, sags onto each step and drags the trailing
// foot. Pure translate + squash (no rotation), so the pixels stay on the grid.
const wrecked = zb.variant === 'g' && zb.hp < TUNE.bruteHp && name !== 'z_g' + frame;
if (wrecked) {
const p = proj(zb.x, 0, zb.z), w = zb.walkT * 2.4; // slow and heavy — it is barely holding together
ctx.save();
// amplitudes are in PROJECTED px, so they have to scale with p.s or they vanish at range —
// at 0.035 the whole lurch was sub-pixel out at the far baseline and read as no animation
ctx.translate(p.x + Math.sin(w) * p.s * 0.13, p.y);
ctx.scale(1 + 0.07 * Math.sin(w), 1 - 0.09 * Math.abs(Math.sin(w * 2)));
ctx.translate(-p.x, -p.y);
}
drawStatic(name, zb.x, zb.z, h, wrecked ? Math.max(0, Math.sin(zb.walkT * 4.8)) * 3 : Math.floor(zb.walkT * 5.2) % 2);
if (wrecked) ctx.restore();
if (zb.burnT > 0) drawFlames(zb, h); if (zb.burnT > 0) drawFlames(zb, h);
if (rk) { // HP bar floating above the mini-boss if (rk) { // HP bar floating above the mini-boss
const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2; const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2;
@@ -425,20 +420,26 @@ export function render() {
const adH = 14, crowdH = standsBottom - adH + 2; const adH = 14, crowdH = standsBottom - adH + 2;
const crowdImg = SPR['x_crowd']; const crowdImg = SPR['x_crowd'];
if (crowdImg) { if (crowdImg) {
// Applause is a real TWO-FRAME animation now: x_crowd is the seated sheet, x_crowd2 the same
// stand with every arm up. Columns swap between them on their own phase, so the clap ripples
// across the stands. Crucially there is NO vertical offset any more — the old bounce shifted
// each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a
// fractional y makes its pixel rows re-snap every frame. That boiling, right beside the
// scoreboard, is what read as the HUD flickering. Integer-aligned draws don't shimmer.
const clapImg = SPR['x_crowd2'];
const cols = 16, sw = crowdImg.width / cols, dw = W / cols; const cols = 16, sw = crowdImg.width / cols, dw = W / cols;
for (let c = 0; c < cols; c++) { for (let c = 0; c < cols; c++) {
// A smooth per-column wave, NOT per-frame noise: random jitter here read as TV static const clapping = crowd.cheer > 0 && clapImg && Math.floor(G.idleT * 9 + c * 0.7) % 2;
// through the scoreboard art's transparent ornate edges, which looked like the HUD flickering. const img = clapping ? clapImg : crowdImg;
const dy = crowd.cheer > 0 ? -1.6 * (0.5 + 0.5 * Math.sin(G.idleT * 12 + c * 1.7)) : 0; ctx.drawImage(img, c * sw, 0, sw, img.height, c * dw, 0, dw + 0.5, crowdH + 2);
ctx.drawImage(crowdImg, c * sw, 0, sw, crowdImg.height, c * dw, dy, dw + 0.5, crowdH + 2);
} }
} else { // fallback until the image loads: the old tier stripes + dots } else { // fallback until the image loads: the old tier stripes + dots
ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom); ctx.fillStyle = pal.stands; ctx.fillRect(0, 0, W, standsBottom);
for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); } for (let r = 0; r < 16; r++) { ctx.fillStyle = r % 2 ? pal.tierA : pal.tierB; ctx.fillRect(0, 6 + r * 5.5, W, 5.5); }
for (const d of crowd.dots) { for (const d of crowd.dots) {
const w = G.idleT * 12 + d.x * 0.4; // same smooth wave for the pre-sprite dot fallback // dot fallback (pre-sprite): whole-pixel hops only, same no-shimmer rule as above
const jx = crowd.cheer > 0 ? Math.sin(w * 0.7) : 0; const jx = 0;
const jy = crowd.cheer > 0 ? -1.2 * (0.5 + 0.5 * Math.sin(w)) : 0; const jy = crowd.cheer > 0 && Math.floor(G.idleT * 9 + d.x * 0.2) % 2 ? -2 : 0;
ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2); ctx.fillStyle = d.c; ctx.fillRect(d.x + jx | 0, d.y + jy | 0, 2, 2);
} }
} }
+7 -2
View File
@@ -1,6 +1,6 @@
// All mutable game state in one place, plus the point-reset that re-arms it. // All mutable game state in one place, plus the point-reset that re-arms it.
import { HOME } from './config.js'; import { HOME, BOY_ENTER } from './config.js';
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
@@ -102,6 +102,11 @@ export function setupPoint() {
// running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball. // running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball.
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') { export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go
// ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride,
// which is why he flickered in and out: a kill streak restarted him several times a second.
// A boy already out on the court keeps working — he just adds the new mess to his round.
if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = (G.boy.jobs || 1) + 1; } return; }
const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0, mop }; const e = mop ? BOY_ENTER.mop : BOY_ENTER.run;
G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 };
} }