The slice stops steering: each kill strikes the ball once, then physics

Oliver, watching a sliced ball circle a shambler it had only wounded: "an
orbiting tennis ball midair, nobody knows what is going on." He is right,
and not only about that shot. Every ball in this game flies a parabola you
can read, so the one that thinks reads as broken even on the shots where it
works. The hook was the wrong primitive, not a hook with two bugs in it.

So there is no hook. At the instant of a kill the ball is struck again —
ONE velocity out of solveShot, the same solver the serve uses, aimed at the
next body's chest — and from the next frame it is an ordinary ball under
ordinary gravity. It cannot orbit, cannot hover and cannot outlive its shot,
because after the strike nothing about the slice is still running.

That also deletes the wound bug rather than fixing it. Six sites spend the
ball (lastHit = 'X') and not one of them cleared the hook, so a ball that
merely WOUNDED its target was left steering at 30% pace toward a point that
body had already walked off — the loop he saw. With no persistent state
there is nothing to clear: a ball spent on a body it failed to kill just
drops, like every other spent ball.

The hop draws its own band, white, so a chain leaves the same chalk streak
every other shot leaves. sliceTurn and sliceHookT are gone with the steering
that needed them; sliceArc stays, no longer as a turn-radius limit but to
keep a carom from doubling back down-court, which reads as a stray
deflection rather than as a cut.

Net 24 lines lighter than the thing it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-09 14:15:24 +02:00
1 parent e11ad30a63
commit 5de42648ec
3 files changed
+50 -74

No files matched your search

+13 -18
View File
@@ -6,7 +6,7 @@
import { TUNE, UI, COURT, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst } from './gameplay.js';
import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { tally, multiKill, endRun } from './stats.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
@@ -633,23 +633,19 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
const sp0 = Math.hypot(ball.vx, ball.vz); // pace BEFORE the pierce tax, for a slice to keep
killZombie(i, !!G.horde);
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
// SLICE: hook off this body onto the next one, at FULL pace. The pierce tax is what a straight
// ball pays for plowing through meat, and a cut ball does not plow — it leaves. Only a kill gets
// here, so a wound ends the chain on its own and no brute, steward or boss ever extends one.
// SLICE: struck again off this body, onto the next one, at FULL pace — the pierce tax is what a
// straight ball pays for plowing through meat, and a cut ball does not plow, it leaves. Only a
// KILL reaches here, so a wound ends the chain by simply never extending it, and no brute,
// steward or boss ever carries one on. See sliceCarom for why this is a single strike and not a
// steer. The new arc is solved from the CORPSE's position, not the ball's, because at this point
// in the frame the ball is still a hair short of the body it just burst.
if (ball.chain > 0) {
const nx = nearestTo(zx, zz, TUNE.sliceR, ball, TUNE.sliceArc * Math.PI / 180);
if (nx) {
ball.chain--;
const k = sp0 / (Math.hypot(ball.vx, ball.vz) || 1);
ball.vx *= k; ball.vz *= k;
// a POINT, not the body: the hook must not pin a corpse alive. `t` is its licence to steer —
// see the hook in gameplay, and sliceHookT for why it cannot be left open-ended.
ball.hook = { x: nx.x, z: nx.z, t: TUNE.sliceHookT };
G.trail = null; // the fired band is the parabola this ball has just STOPPED flying — leaving
// it up draws a streak peeling away from the ball, which is the one thing
// the band exists to avoid. From here the chalk thread is the trail.
sliceCarom(nx, Math.max(sp0, 14)); // floored: a ball that arrived slowly still leaves fast
pock(0.5, 1100 + 450 * ball.kills); // the cut, a step higher up the scale per link
} else ball.chain = 0; // nothing in reach: it plows on as a normal ball
} else ball.chain = 0; // nothing in reach: it plows on as a normal ball
}
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
ball.kills++; // this shot's running streak
@@ -713,11 +709,10 @@ export function firePellet(w = WEAPONS.gun) {
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
}
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// The next body a slice hooks onto: nearest live thing to the kill it just made, and it may well be
// off to the side or short of it — that curl is the whole shot. What it may NOT be is something the
// ball cannot physically turn onto. A ball travelling at `b` can only reach a body within `arc` of
// its heading; past that there is no tangent arc at any turn rate, and steering at one anyway is
// what made the ball fly circles round a body two metres away instead of ever hitting it.
// The next body a slice is struck on to: nearest live thing to the kill it just made, and it may
// well be off to the side or short of it — that deflection is the whole shot. `arc` keeps it inside
// a believable angle off the ball's heading: a carom can physically go anywhere, but one that turns
// round and heads back down-court reads as a stray bounce, not as a cut.
// Furniture and barrels are not bodies and are never a target.
function nearestTo(x, z, maxD, b, arc) {
const head = Math.atan2(b.vx, b.vz);