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
+49 -73

No files matched your search

+14 -22
View File
@@ -237,29 +237,21 @@ export const TUNE = {
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
// so the swish speeds up as they close in, not only once they are already on you)
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
// ---- SLICE. Not a tin and not a rack slot: it is what a PERFECT serve DOES. Cut the ball and it
// hooks off each body it kills onto the next one instead of plowing straight on. Every other
// weapon is something you are handed; this one is a technique, which is why it is earned on the
// timing window (the toss ritual's 45 ms, or a pull that STOPS inside perfectBand) rather than
// won out of a barrel. Only a KILL hooks — a wound ends the chain — so it is a crowd skill shot
// that does nothing to a brute, a steward or a boss, and it hands the plain ball a real route to
// the DOUBLE/TRIPLE KILL ladder that until now only a bomb or a lucky line-up could reach.
sliceHops: 2, // extra bodies one perfect serve hooks to. 2 => TRIPLE KILL is the ceiling
sliceR: 6.5, // m the hook reaches for the next body. Wider reads as the game aiming for you;
// ---- SLICE. Not a tin and not a rack slot: it is what a PERFECT serve DOES. Cut the ball and
// each body it kills strikes it on toward the next one instead of it plowing straight through.
// Every other weapon is something you are handed; this one is a technique, which is why it is
// earned on the timing window (the toss ritual's 45 ms, or a pull that STOPS inside perfectBand)
// rather than won out of a barrel. Only a KILL carries it on — a wound ends the chain — so it is
// a crowd skill shot that does nothing to a brute, a steward or a boss, and it hands the plain
// ball a route to the DOUBLE/TRIPLE KILL ladder only a bomb or a lucky line-up could reach.
// It is ONE new velocity per kill and then ordinary ballistics: see sliceCarom for why the ball
// must never steer, whatever it costs in prettiness.
sliceHops: 2, // extra bodies one perfect serve is struck on to. 2 => TRIPLE KILL is the ceiling
sliceR: 6.5, // m it will reach for the next body. Wider reads as the game aiming for you;
// much narrower and a spread-out crowd never chains at all
sliceTurn: 11, // rad/s the ball swings onto the next one: a FLOOR, not the rate. It is the knob
// for how the shot LOOKS when there is room — past ~30 it snaps like a ricochet,
// under ~6 it sails wide. The rate actually used is whatever the geometry needs
// (see the hook), because a fixed rate has a fixed turn radius (v/w = 2 m here)
// and ANY body inside that circle is unreachable: the ball flew a closed loop
// around it forever, never arriving, never falling. A crowd is all close bodies,
// so the one place the weapon should shine was the one place it hung.
sliceArc: 120, // deg: the widest bearing off the ball's heading that can be hooked at all. No
// single tangent arc reaches a body directly BEHIND the ball at any turn rate,
// so past this the chain just ends and it plows on
sliceHookT: 0.7, // s a hook may steer before it lets go. The geometry above is solved fresh every
// frame and still cannot promise an arrival when the target WALKS, so this is the
// backstop that makes an endless orbit impossible by construction, not by maths
sliceArc: 120, // deg: widest bearing off the ball's heading it will take. A carom can physically
// go anywhere, but one that doubles back down-court reads as a stray deflection
// rather than as a cut, and the chain should be walking INTO the crowd anyway
// The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
// on its own so there is always a chance at one. Fires off the next hand-cleared wave.
duelFirst: 2, // game after which the first scheduled sudden-death duel is called
+23 -34
View File
@@ -161,7 +161,7 @@ function doHit(ch, quality, aim) {
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them
ball.chain = 0; ball.hook = null; // ...and the slice dies with the shot that earned it
ball.chain = 0; // ...and the slice dies with the shot that earned it
ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
// TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be
@@ -266,7 +266,7 @@ export function previewServe(aim) {
// ...and WHETHER THIS ONE SLICES. The perfect window was invisible until now: quality saturates
// at pullFull, so an overpull and a perfect pull drew an identical band and you were being asked
// to hit a target the game never showed you. Now the band goes chalk-white inside it. Plain balls
// only, because only a plain ball hooks (see hitServe).
// only, because only a plain ball slices (see hitServe).
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q,
pullPerfect(aim.mag) && G.weapon === 0 && !G.temp);
}
@@ -279,6 +279,26 @@ function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q, pf = false) {
return { x0, y0, z0, v, g, tx, tz, ty, zt, q, pf, f: qualityFrac(q), T, t: 0 };
}
// ---- THE SLICE CAROM. A sliced ball does not steer. At the instant it kills something it is
// STRUCK AGAIN — one new velocity out of the same solver a serve uses, aimed at the next body's
// chest — and from the next frame it is an ordinary ball under ordinary gravity.
// This replaces a hook that turned toward its target a little more every frame, and the reason is
// not that the hook was buggy twice (it was) but that it was the wrong thing to build: EVERY other
// ball in this game flies a parabola you can read, so the one that thinks reads as a glitch even on
// the shots where it works. Oliver, on watching one circle a wounded shambler: "an orbiting tennis
// ball midair, nobody knows what is going on." A carom cannot orbit, cannot hover and cannot
// outlive its shot, because after this function returns there is no slice left running anywhere —
// which is also why the wound case needed no fix of its own. A ball spent on a body it failed to
// kill simply drops, like every other spent ball.
// The hop draws its own band, white, so the chain leaves a chalk arc from the corpse to the next
// one — the same object the aim preview and the serve already use, given a target of its own.
export function sliceCarom(tgt, speed) {
const g = ball.g || TUNE.gravity, ty = zombieH(tgt) * Z_CHEST;
const v = solveShot(ball.x, ball.y, ball.z, tgt.x, tgt.z, speed, g, ty);
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
G.trail = shotBand(ball.x, ball.y, ball.z, v, g, tgt.x, tgt.z, ty, true, 1, true);
}
// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
// ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and
// so can never fail the old `quality > 0.999` test once you have pulled far enough.
@@ -319,10 +339,9 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way
// THE SLICE. A perfect serve with a PLAIN ball is cut: it hooks body to body (see splatter).
// Plain only — a bomb, a flame ball and a laser already have an identity, and stacking a hook
// Plain only — a bomb, a flame ball and a laser already have an identity, and stacking a carom
// on top of one would read as the tin misbehaving rather than as something you did.
ball.chain = perfect && ball.wid === 'ball' ? TUNE.sliceHops : 0;
ball.hook = null;
// THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else
// about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
if (wid === 'laser' && armed) {
@@ -1573,36 +1592,6 @@ export function update(dt) {
t: 0.5 + Math.random() * 0.4, smoke: true });
}
ball.vy -= (ball.g || TUNE.gravity) * dt;
// THE HOOK. splatter() points a sliced ball at its next body; this is the swing onto it, and it
// is the whole look of the weapon — a ball that BENDS through a crowd instead of ricocheting off
// it at an angle. It flies flat while hooking (a hop of a few metres would otherwise drop half a
// metre and pass under the next chest), and it sheds a one-pixel chalk thread so the curve is
// still on screen a beat after the ball has gone. The thread is the beacon's `fine` particle:
// 1 px, so the ball stays the bright thing and the line reads as the cut, not as a second ball.
if (ball.hook) {
const dx = ball.hook.x - ball.x, dz = ball.hook.z - ball.z;
ball.hook.t -= dt;
// arrived, or out of time. The timer is not a polish detail: without it a ball that cannot
// close on a walking body steers forever, and with vy pinned at 0 nothing else ever ends it.
if (dx * dx + dz * dz < 0.09 || ball.hook.t <= 0) ball.hook = null; // gravity has it back
else {
const sp = Math.hypot(ball.vx, ball.vz) || 1;
const cur = Math.atan2(ball.vx, ball.vz), want = Math.atan2(dx, dz);
const da = ((want - cur + Math.PI * 3) % (Math.PI * 2)) - Math.PI; // shortest way round
// THE RATE COMES FROM THE GEOMETRY. An arc that starts along the current heading and passes
// through a body at distance d, bearing da, has radius d / (2 sin da) — so the ball must be
// able to turn at 2 v sin(da) / d or it can never arrive, however long it steers. sliceTurn
// is only the floor (turning FASTER than needed is safe: the aim is re-solved every frame,
// so it simply finishes the turn early and flies straight in). Solved live rather than
// frozen at the kill, because the body it is hooking to is walking while the ball travels.
const d = Math.max(0.6, Math.hypot(dx, dz)); // floored: d -> 0 would demand an infinite rate
const w = Math.max(TUNE.sliceTurn, 2 * sp * Math.abs(Math.sin(da)) / d);
const a = cur + Math.sign(da) * Math.min(Math.abs(da), w * dt);
ball.vx = Math.sin(a) * sp; ball.vz = Math.cos(a) * sp; ball.vy = 0;
parts.push({ x: ball.x, y: ball.y, z: ball.z, vx: 0, vy: 0, vz: 0,
t: 0.28, fine: true, spark: '#f2f2e6' });
}
}
const oldZ = ball.z;
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
// net: plane at netZ, only during rally flight
+12 -17
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,21 +633,17 @@ 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
}
@@ -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);