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:
1 parent
655b86d27f
commit
99d84b2c9d
4 files changed
+91
-6
No files matched your search
@@ -237,6 +237,18 @@ export const TUNE = {
|
|||||||
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
|
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)
|
// 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
|
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;
|
||||||
|
// much narrower and a spread-out crowd never chains at all
|
||||||
|
sliceTurn: 11, // rad/s the ball swings onto the next one, and THE knob for how it looks: past
|
||||||
|
// ~30 it snaps like a ricochet, under ~6 it sails wide before finishing the turn
|
||||||
// The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
|
// 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.
|
// 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
|
duelFirst: 2, // game after which the first scheduled sudden-death duel is called
|
||||||
|
|||||||
+35
-4
@@ -161,6 +161,7 @@ function doHit(ch, quality, aim) {
|
|||||||
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
|
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
|
||||||
ball.kills = 0; // fresh shot: multi-kill streak restarts
|
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.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
|
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
|
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
|
// 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);
|
const st = splatServeTarget(P, aim, s, c.x, c.z);
|
||||||
if (!st) return null;
|
if (!st) return null;
|
||||||
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty);
|
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
|
// 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
|
// 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.
|
// 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
|
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
|
// `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
|
// 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
|
// 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.
|
// 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.g = g;
|
||||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||||
ball.lastHit = sv.side; ball.bounces = 0;
|
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 wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0;
|
||||||
const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 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
|
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
|
// 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.
|
// about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
|
||||||
if (wid === 'laser' && armed) {
|
if (wid === 'laser' && armed) {
|
||||||
@@ -1561,6 +1573,25 @@ export function update(dt) {
|
|||||||
t: 0.5 + Math.random() * 0.4, smoke: true });
|
t: 0.5 + Math.random() * 0.4, smoke: true });
|
||||||
}
|
}
|
||||||
ball.vy -= (ball.g || TUNE.gravity) * dt;
|
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;
|
const oldZ = ball.z;
|
||||||
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
||||||
// net: plane at netZ, only during rally flight
|
// net: plane at netZ, only during rally flight
|
||||||
|
|||||||
@@ -744,7 +744,12 @@ function drawShotBand(a, u0, flying) {
|
|||||||
// of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the
|
// of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the
|
||||||
// band itself to communicate the shape of the shot.
|
// band itself to communicate the shape of the shot.
|
||||||
const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0;
|
const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0;
|
||||||
|
// CHALK WHITE = this one is SLICED. The band has always had one threshold (kills / only staggers)
|
||||||
|
// and the perfect window sat inside the killing half, invisible: quality saturates at pullFull, so
|
||||||
|
// an overpull drew exactly the band a perfect pull did. White is the third state, and the only
|
||||||
|
// place white appears on the court — nothing else in the palette is near it.
|
||||||
const hex = beamW ? WEAPONS[slotId(G.weapon)].col
|
const hex = beamW ? WEAPONS[slotId(G.weapon)].col
|
||||||
|
: a.pf ? '#f2f2e6'
|
||||||
: a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
: a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||||||
// the slice of arc to draw: everything while aiming, a short streak behind the ball in flight
|
// the slice of arc to draw: everything while aiming, a short streak behind the ball in flight
|
||||||
const lo = flying ? Math.max(0, u0 - TAIL) : 0;
|
const lo = flying ? Math.max(0, u0 - TAIL) : 0;
|
||||||
@@ -965,7 +970,7 @@ function drawBeamCuts(dirX, dirZ, ox, oz, L) {
|
|||||||
function drawAimMark(a, u0, flying) {
|
function drawAimMark(a, u0, flying) {
|
||||||
const left = 1 - u0;
|
const left = 1 - u0;
|
||||||
if (left <= 0.01) return;
|
if (left <= 0.01) return;
|
||||||
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
const col = a.pf ? '#f2f2e6' : a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||||||
const onBody = !!a.zt;
|
const onBody = !!a.zt;
|
||||||
const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point
|
const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point
|
||||||
// NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the
|
// NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the
|
||||||
@@ -982,7 +987,15 @@ function drawAimMark(a, u0, flying) {
|
|||||||
if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso
|
if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso
|
||||||
else ctx.ellipse(m.x, m.y, m.s * 0.30, m.s * 0.12, 0, 0, 7); // flat, lying on the grass
|
else ctx.ellipse(m.x, m.y, m.s * 0.30, m.s * 0.12, 0, 0, 7); // flat, lying on the grass
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.globalAlpha = 1;
|
// ...and a SECOND ring outside it when the shot is sliced: the fill says where the ball lands,
|
||||||
|
// the ring says it will not stop there. Drawn on the mark rather than as a third HUD element
|
||||||
|
// because your eye is already on this body — that is what you are aiming at.
|
||||||
|
if (a.pf && onBody) {
|
||||||
|
ctx.globalAlpha = 0.75 * (flying ? left * left : 1);
|
||||||
|
ctx.strokeStyle = col; ctx.lineWidth = Math.max(1, m.s * 0.02);
|
||||||
|
ctx.beginPath(); ctx.ellipse(m.x, m.y, m.s * 0.36, m.s * 0.50, 0, 0, 7); ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1; ctx.lineWidth = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown
|
function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown
|
||||||
|
|||||||
+29
@@ -630,8 +630,25 @@ export function meleeSwipe(x, z, range) {
|
|||||||
|
|
||||||
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
||||||
const zx = zombies[i].x, zy = zombieH(zombies[i]) * 0.9, zz = zombies[i].z; // for the pops — killZombie splices
|
const zx = zombies[i].x, zy = zombieH(zombies[i]) * 0.9, zz = zombies[i].z; // for the pops — killZombie splices
|
||||||
|
const sp0 = Math.hypot(ball.vx, ball.vz); // pace BEFORE the pierce tax, for a slice to keep
|
||||||
killZombie(i, !!G.horde);
|
killZombie(i, !!G.horde);
|
||||||
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
|
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.
|
||||||
|
if (ball.chain > 0) {
|
||||||
|
const nx = nearestTo(zx, zz, TUNE.sliceR);
|
||||||
|
if (nx) {
|
||||||
|
ball.chain--;
|
||||||
|
const k = sp0 / (Math.hypot(ball.vx, ball.vz) || 1);
|
||||||
|
ball.vx *= k; ball.vz *= k;
|
||||||
|
ball.hook = { x: nx.x, z: nx.z }; // a POINT, not the body: the hook must not pin a corpse alive
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
|
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
|
||||||
ball.kills++; // this shot's running streak
|
ball.kills++; // this shot's running streak
|
||||||
// splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the
|
// splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the
|
||||||
@@ -694,6 +711,18 @@ export function firePellet(w = WEAPONS.gun) {
|
|||||||
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
|
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
|
||||||
}
|
}
|
||||||
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
|
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
|
||||||
|
// the next body a slice hooks onto: nearest live thing to the kill it just made, in any direction —
|
||||||
|
// a cut ball can come BACK down-court, and the shot that curls round into something behind the body
|
||||||
|
// you aimed at is the one worth earning. Furniture and barrels are not bodies and never a target.
|
||||||
|
function nearestTo(x, z, maxD) {
|
||||||
|
let best = null, bd = maxD;
|
||||||
|
for (const zb of zombies) {
|
||||||
|
if ('mo'.includes(zb.variant)) continue;
|
||||||
|
const d = Math.hypot(zb.x - x, zb.z - z);
|
||||||
|
if (d < bd) { bd = d; best = zb; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
|
// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
|
||||||
function nearestAhead() {
|
function nearestAhead() {
|
||||||
let best = null, bd = 1e9;
|
let best = null, bd = 1e9;
|
||||||
|
|||||||
Reference in new issue
Block a user