Fix the serve deadlock, give the mop boy a job, align net and umpire
The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so anything that moved the phase mid-burst — a scheduled duel calling setupPoint — stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so no tap could serve again, and the yellow shape over the player's head was drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase, only the pellets are gated on rally) and semantically: starting a duel cancels any burst, because the duel is tennis. The mop boy now does an actual round: walk to the biggest nearby stain, stand on it and push the mop while the cycle animates in place, scrub it out, move to the next, then leave. Gore fades ~3.5x faster while he is stopped and working than when walking past, so the stain dies under him instead of fading as he drifts by. boyMaxJobs caps the round so a kill streak can't employ him forever. Applause: the clip is 4.16s and always plays in full, but every cheer() passed a shorter beat, so the stand sat down while it was still audible. Floored at the clip length. New, much bolder clap frame — the re-roll came back brighter and bluer, which would have flashed the whole stand, so it is colour-matched to frame one per channel on mean and sd before quantising. Net and umpire were both wrong since forever: netW was the singles-stick width (10.06) against a court drawn to the doubles lines, so the net stopped 0.46m short each side; the chair's near legs stood 0.45m inside the sideline. Both now sit exactly on the doubles line. 26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the wider net (the 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:
1 parent
32401366fe
commit
55ddf5932e
8 files changed
+108
-23
No files matched your search
+29
@@ -182,6 +182,35 @@ Oliver rejected the procedural fakes on sight. The rule this round: **animation
|
|||||||
- Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected:
|
- 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.
|
mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps.
|
||||||
|
|
||||||
|
## Third feedback round (2026-07-27)
|
||||||
|
- **THE FREEZE (serving dead, gun racket stuck over your head).** `G.mgT` only counted down inside
|
||||||
|
`if (G.mgT > 0 && G.phase === 'rally')`. Anything that moved the phase mid-burst — a scheduled
|
||||||
|
duel calling `setupPoint()` — stranded `mgT` above zero *forever*, and `mgT > 0` is one of
|
||||||
|
`keyPressed`'s early returns, so no tap could ever serve again. The yellow blob Oliver saw was
|
||||||
|
`drawMgRacket`'s muzzle flash still being drawn. Fixed at the root (the clock ticks in every
|
||||||
|
phase; only the pellets are gated on 'rally') **and** semantically (`startDuel` cancels any
|
||||||
|
burst — the duel is tennis). If another "can't serve" report ever comes in, check these flags
|
||||||
|
first: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT` — each one blocks `keyPressed`.
|
||||||
|
- **The mop boy has a job now** (`mopRound` in gameplay.js): walk to the biggest nearby stain,
|
||||||
|
stand on it for `TUNE.boyMopDwell` while the push cycle animates in place, scrub it out, pick the
|
||||||
|
next, then walk off. `TUNE.boyMaxJobs` caps the round so a kill streak can't employ him forever.
|
||||||
|
Gore fades ~3.5× faster while he is actually stopped and mopping than when merely walking past.
|
||||||
|
A straight-line constant-speed crossing was what read as moonwalking past the mess.
|
||||||
|
- **Applause is floored at the audio length.** `assets/audio/tennis-applaus.wav` is 4.16s and always
|
||||||
|
plays out in full, but every `cheer()` call passed a shorter beat, so the stand sat down while the
|
||||||
|
clap was still audible. `cheer()` now does `Math.max(crowd.cheer, seconds, APPLAUSE_T)`.
|
||||||
|
- **Regenerating a crowd frame: colour-match it or don't ship it.** The bolder re-roll came back
|
||||||
|
much brighter and bluer; swapping to it would have flashed the whole stand. Fixed by a per-channel
|
||||||
|
linear map onto frame one's mean and sd (means now agree to within half a level), then quantised
|
||||||
|
to 64 colours. Verify with a mean-difference number before trusting any regenerated frame.
|
||||||
|
- **Net and umpire were both wrong since forever.** `COURT.netW` was the singles-stick width 10.06
|
||||||
|
while the court is drawn to the doubles lines, so the net stopped 0.46 m short each side — now
|
||||||
|
10.97, flush. `UMP_X` 5.6 put the chair's near legs 0.45 m *inside* the doubles line (x_ump is
|
||||||
|
42×112 px at UMP_H 3.0 = 1.12 m wide as drawn); 6.05 stands them exactly on the line. Do not push
|
||||||
|
past ~6.3 or the chair clips the right edge of the portrait viewport.
|
||||||
|
The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape);
|
||||||
|
serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma.
|
||||||
|
|
||||||
## 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: 931 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 766 KiB After Width: | Height: | Size: 596 KiB |
@@ -32,6 +32,8 @@ export const TUNE = {
|
|||||||
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: 2.2, // 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)
|
||||||
|
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
|
||||||
|
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
|
||||||
boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
|
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.
|
// 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
|
||||||
@@ -109,6 +111,11 @@ export const TUNE = {
|
|||||||
// the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore
|
// the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore
|
||||||
// · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap
|
// · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap
|
||||||
// during the burst can't skip it.
|
// during the burst can't skip it.
|
||||||
|
// assets/audio/tennis-applaus.wav runs 4.16s and always plays in full, so the crowd must keep
|
||||||
|
// clapping at least that long — every cheer() call was passing a shorter beat, which left the
|
||||||
|
// stand sitting back down while the applause was still audible.
|
||||||
|
export const APPLAUSE_T = 4.2;
|
||||||
|
|
||||||
export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2;
|
export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2;
|
||||||
|
|
||||||
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
|
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
|
||||||
@@ -117,7 +124,10 @@ export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2;
|
|||||||
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
|
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
|
||||||
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
|
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
|
||||||
|
|
||||||
export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 };
|
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
|
||||||
|
// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts
|
||||||
|
// now stand exactly on the doubles sidelines.
|
||||||
|
export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.97 };
|
||||||
COURT.svcFar = COURT.netZ - 6.4; // far service line
|
COURT.svcFar = COURT.netZ - 6.4; // far service line
|
||||||
COURT.svcNear = COURT.netZ + 6.4; // near service line
|
COURT.svcNear = COURT.netZ + 6.4; // near service line
|
||||||
|
|
||||||
@@ -128,7 +138,10 @@ export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck
|
|||||||
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
|
// 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 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
|
// The chair is 1.12 m wide as drawn (x_ump is 42x112 at UMP_H 3.0), so at 5.6 its near legs stood
|
||||||
|
// 0.45 m INSIDE the doubles line, on the court. 6.05 puts them exactly on the line; anything much
|
||||||
|
// beyond ~6.3 clips the right edge of the portrait viewport (7.1 was fully off-screen).
|
||||||
|
export const UMP_X = 6.05;
|
||||||
|
|
||||||
// contact rules: outside this height band the ball can't be hit (below = it's past you)
|
// contact rules: outside this height band the ball can't be hit (below = it's past you)
|
||||||
export const CONTACT = { minY: 0.24, maxY: 2.3 };
|
export const CONTACT = { minY: 0.24, maxY: 2.3 };
|
||||||
|
|||||||
+52
-15
@@ -1,11 +1,11 @@
|
|||||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||||
|
|
||||||
import { TUNE, UI, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
|
import { TUNE, UI, APPLAUSE_T, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
|
||||||
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
|
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
|
||||||
import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
|
import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
|
||||||
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
|
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
|
||||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||||
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts, playerGore } from './zombies.js';
|
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } from './zombies.js';
|
||||||
|
|
||||||
// ------------------------------------------------------------- ballistics ----
|
// ------------------------------------------------------------- ballistics ----
|
||||||
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
||||||
@@ -222,7 +222,9 @@ export function dismissModal() {
|
|||||||
|
|
||||||
export function cheer(seconds, vol) {
|
export function cheer(seconds, vol) {
|
||||||
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
|
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
|
||||||
crowd.cheer = Math.max(crowd.cheer, seconds);
|
// floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its
|
||||||
|
// feet until it stops. `seconds` still lets a big moment run longer than the audio.
|
||||||
|
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
|
||||||
}
|
}
|
||||||
|
|
||||||
function advancePoint(side) { // shared scoring math; returns whether a game was won
|
function advancePoint(side) { // shared scoring math; returns whether a game was won
|
||||||
@@ -373,6 +375,7 @@ export function startDuel() {
|
|||||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
||||||
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
||||||
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
||||||
|
G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
|
||||||
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
|
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
|
||||||
ball.active = false; setupPoint();
|
ball.active = false; setupPoint();
|
||||||
zombieDuck(true); // fade the zombie music out for the 1:1 duel
|
zombieDuck(true); // fade the zombie music out for the 1:1 duel
|
||||||
@@ -718,6 +721,51 @@ function expirePose(ch, dt) {
|
|||||||
if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; }
|
if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- ball boys ----
|
||||||
|
// The fetch boy is unchanged: sprint the width of the court, scoop the dead ball in passing.
|
||||||
|
function fetchRound(b, dt) {
|
||||||
|
const step = b.dir * 7.5 * dt;
|
||||||
|
b.x += step; b.runT += dt; b.dist = (b.dist || 0) + Math.abs(step);
|
||||||
|
if (!ball.collected && !ball.active) {
|
||||||
|
const dz = ball.z - b.z;
|
||||||
|
b.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
|
||||||
|
if (Math.abs(b.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
|
||||||
|
}
|
||||||
|
if (Math.abs(b.x) > 13.5) G.boy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mop boy actually does a JOB: walk to a stain, stand on it and push the mop until it's
|
||||||
|
// gone, then move to the next one, then leave. He used to slide the width of the court in a
|
||||||
|
// straight line at constant speed, which read as moonwalking past the mess rather than cleaning it.
|
||||||
|
function mopRound(b, dt) {
|
||||||
|
b.runT += dt;
|
||||||
|
if (b.mopT > 0) { // ON a stain: no travel, but the push cycle keeps animating (dist drives frames)
|
||||||
|
b.mopT -= dt;
|
||||||
|
b.dist = (b.dist || 0) + TUNE.boyMopSpeed * 0.6 * dt; // the mop travels even when he doesn't
|
||||||
|
if (b.mopT <= 0) b.jobs--; // that one's done — look for the next
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// pick the next target: the biggest stain he can still reach, ignoring ones already scrubbed out
|
||||||
|
if (b.tx === undefined && b.jobs > 0) {
|
||||||
|
let best = null, bs = -1;
|
||||||
|
for (const d of gore) {
|
||||||
|
if (d.a < 0.15 || d.z < COURT.netZ - 2) continue; // faded, or up the far end where he can't work
|
||||||
|
const score = d.a * d.r - Math.hypot(d.x - b.x, d.z - b.z) * 0.04; // big and close first
|
||||||
|
if (score > bs) { bs = score; best = d; }
|
||||||
|
}
|
||||||
|
if (best) { b.tx = best.x; b.tz = best.z; }
|
||||||
|
else b.jobs = 0; // nothing left to clean — go home
|
||||||
|
}
|
||||||
|
const working = b.tx !== undefined && b.jobs > 0;
|
||||||
|
const gx = working ? b.tx : b.dir * 14, gz = working ? b.tz : b.z; // no target left: walk off the wing
|
||||||
|
const dx = gx - b.x, dz = gz - b.z, d = Math.hypot(dx, dz);
|
||||||
|
const step = Math.min(TUNE.boyMopSpeed * dt, d);
|
||||||
|
if (d > 0.001) { b.x += dx / d * step; b.z += dz / d * step; b.dist = (b.dist || 0) + step; }
|
||||||
|
if (Math.abs(dx) > 0.05) b.dir = dx > 0 ? 1 : -1; // face the way he's walking
|
||||||
|
if (working && d <= 0.12) { b.mopT = TUNE.boyMopDwell; b.tx = undefined; b.tz = undefined; } // arrived: get to work
|
||||||
|
if (Math.abs(b.x) > 13.5) G.boy = null; // off the wing — round over
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- update ----
|
// ---------------------------------------------------------------- update ----
|
||||||
export function update(dt) {
|
export function update(dt) {
|
||||||
if (G.hitstop > 0) { G.hitstop -= dt; return; }
|
if (G.hitstop > 0) { G.hitstop -= dt; return; }
|
||||||
@@ -930,18 +978,7 @@ 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) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt);
|
||||||
// mopping is not sprinting. `dist` is what drives his animation frames (render.drawBoy), so
|
|
||||||
// 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
|
|
||||||
const dz = ball.z - boy.z;
|
|
||||||
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) > 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;
|
||||||
|
|
||||||
if (G.phase === 'over') {
|
if (G.phase === 'over') {
|
||||||
|
|||||||
+1
-1
@@ -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=30"></script>
|
<script type="module" src="main.js?v=31"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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, BOY_ENTER } from './config.js';
|
import { HOME, BOY_ENTER, TUNE } 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 };
|
||||||
@@ -105,7 +105,7 @@ export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
|
|||||||
// ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride,
|
// 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.
|
// 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.
|
// 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; }
|
if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = Math.min(TUNE.boyMaxJobs, (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
|
||||||
const e = mop ? BOY_ENTER.mop : BOY_ENTER.run;
|
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 };
|
G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 };
|
||||||
|
|||||||
+9
-3
@@ -228,9 +228,12 @@ export function updateZombies(dt) {
|
|||||||
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
|
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
|
||||||
G.splatT += dt;
|
G.splatT += dt;
|
||||||
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap
|
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap
|
||||||
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
|
else if (G.mgT > 0) { // machine-gun tin: horde firepower, regular rules
|
||||||
|
// The burst clock ticks in EVERY phase. It used to tick only during 'rally', so anything that
|
||||||
|
// moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and
|
||||||
|
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
|
||||||
G.mgT -= dt;
|
G.mgT -= dt;
|
||||||
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
if (G.phase === 'rally' && (G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
|
||||||
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
|
||||||
}
|
}
|
||||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns)
|
if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns)
|
||||||
@@ -400,7 +403,10 @@ export function updateZombies(dt) {
|
|||||||
const boy = G.boy; // the boy mops any gore he passes over
|
const boy = G.boy; // the boy mops any gore he passes over
|
||||||
for (let i = gore.length - 1; i >= 0; i--) {
|
for (let i = gore.length - 1; i >= 0; i--) {
|
||||||
const d = gore[i];
|
const d = gore[i];
|
||||||
if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * (d.scorch ? 0.16 : 1.6); // craters scar the lawn 10× longer
|
// He scrubs HARD only while stopped and working the mop; walking past barely touches it, so
|
||||||
|
// the stain visibly dies under him rather than fading as he drifts by.
|
||||||
|
if (boy && Math.abs(boy.x - d.x) < 0.8 && Math.abs(boy.z - d.z) < 1.3)
|
||||||
|
d.a -= dt * (d.scorch ? 0.16 : boy.mopT > 0 ? 0.7 : 0.2); // craters scar the lawn far longer
|
||||||
if (d.a <= 0) gore.splice(i, 1);
|
if (d.a <= 0) gore.splice(i, 1);
|
||||||
}
|
}
|
||||||
for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins
|
for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins
|
||||||
|
|||||||
Reference in new issue
Block a user