A perfect serve is a cut ball: it hooks from kill to kill

Slice is not a tin and not a rack slot. It is what a perfect serve DOES —
the toss ritual's 45 ms window, or a pull that stops inside perfectBand.
Every other weapon is handed to you; this one is a technique, 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. Banking your specials stops
meaning playing worse in the meantime.

Only a kill hooks, so a wound ends the chain and no brute, steward or boss
ever extends one. It is a crowd skill shot, which is the half of the arsenal
the tins do not cover.

The pull had to learn a third state to say this. Quality SATURATES at
pullFull, so an overpull and a perfect pull drew an identical band: the game
was asking you to hit a window it never showed you. The band now goes chalk
white inside it and the mark on the target grows a second ring — the fill
says where the ball lands, the ring says it will not stop there.

The chain path is deliberately NOT previewed. It is a scallop laid over the
exact patch of court you need to read, it solves the shot before you take
it, and the bodies walk — any chain drawn at pull time is a lie by the time
the ball arrives, which is the one thing the band exists not to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-09 13:44:17 +02:00
1 parent 655b86d27f
commit 99d84b2c9d
4 files changed
+91 -6

No files matched your search

+35 -4
View File
@@ -161,6 +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.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
@@ -262,15 +263,20 @@ export function previewServe(aim) {
const st = splatServeTarget(P, aim, s, c.x, c.z);
if (!st) return null;
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty);
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q);
// ...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).
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);
}
// The drawable form of a shot: everything render needs and nothing that would pin a zombie object
// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `ty` is
// the arrival height, so the mark render paints sits exactly where the ball is going.
function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q) {
function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q, pf = false) {
const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target
return { x0, y0, z0, v, g, tx, tz, ty, zt, q, f: qualityFrac(q), T, t: 0 };
return { x0, y0, z0, v, g, tx, tz, ty, zt, q, pf, f: qualityFrac(q), T, t: 0 };
}
// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
@@ -295,7 +301,8 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it
// flies, so the aim resolves into the shot rather than blinking out of existence. This is the
// ACTUAL launch, error included, so the ball tracks the band it is consuming exactly.
if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality);
if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality,
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0;
@@ -311,6 +318,11 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0;
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
// 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) {
@@ -1561,6 +1573,25 @@ 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;
if (dx * dx + dz * dz < 0.09) ball.hook = null; // arrived (or it moved) — 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
const a = cur + Math.sign(da) * Math.min(Math.abs(da), TUNE.sliceTurn * 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