diff --git a/config.js b/config.js index b4e565c..7e55071 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index 0f951fd..2412d2f 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 diff --git a/zombies.js b/zombies.js index fc5ba64..8486ded 100644 --- a/zombies.js +++ b/zombies.js @@ -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);