diff --git a/config.js b/config.js index 3f3c362..ad015d5 100644 --- a/config.js +++ b/config.js @@ -237,6 +237,18 @@ 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; + // 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 // 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 diff --git a/gameplay.js b/gameplay.js index 90f2e2a..f25c0d5 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 diff --git a/render.js b/render.js index 847cb93..c0409d1 100644 --- a/render.js +++ b/render.js @@ -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 // band itself to communicate the shape of the shot. 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 + : a.pf ? '#f2f2e6' : 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 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) { const left = 1 - u0; 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 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 @@ -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 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.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 diff --git a/zombies.js b/zombies.js index 405a478..a9df8db 100644 --- a/zombies.js +++ b/zombies.js @@ -630,8 +630,25 @@ export function meleeSwipe(x, z, range) { 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 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. + 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 ball.kills++; // this shot's running streak // 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); } 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 function nearestAhead() { let best = null, bd = 1e9;