The hook turns as hard as the geometry needs, or not at all
A slice hooking onto a body two metres to the side flew a closed loop around it forever: 285 frames of the same circuit, never arriving, never falling. A constant turn rate has a constant turn radius (v/w = 2 m), and anything inside that circle cannot be reached however long the ball steers — while the hook pins vy at 0, so nothing else ends it either. A crowd is nothing but close bodies, which made the one place this weapon should shine the one place it hung. The rate is no longer a constant. An arc leaving along the current heading and passing through a body at distance d, bearing a, has radius d/(2 sin a), so the ball needs 2 v sin(a) / d — solved every frame, because the body is walking while the ball travels. sliceTurn stays as the FLOOR, which is what it was always really for: how wide the curl looks when there is room. Turning faster than needed is safe, the aim being re-solved each frame. Two things the maths cannot cover, so they are ruled out instead: a body behind the ball, which no tangent arc reaches at any rate (sliceArc), and a target that walks out of reach mid-hook (sliceHookT, a hard 0.7 s licence to steer). An endless orbit is now impossible by construction rather than by the geometry happening to work out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99d84b2c9d
commit
e11ad30a63
3 files changed
+42
-11
No files matched your search
@@ -247,8 +247,19 @@ export const TUNE = {
|
|||||||
sliceHops: 2, // extra bodies one perfect serve hooks to. 2 => TRIPLE KILL is the ceiling
|
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;
|
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
|
// 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
|
sliceTurn: 11, // rad/s the ball swings onto the next one: a FLOOR, not the rate. It is the knob
|
||||||
// ~30 it snaps like a ricochet, under ~6 it sails wide before finishing the turn
|
// 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
|
||||||
// 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
|
||||||
|
|||||||
+13
-2
@@ -1581,12 +1581,23 @@ export function update(dt) {
|
|||||||
// 1 px, so the ball stays the bright thing and the line reads as the cut, not as a second ball.
|
// 1 px, so the ball stays the bright thing and the line reads as the cut, not as a second ball.
|
||||||
if (ball.hook) {
|
if (ball.hook) {
|
||||||
const dx = ball.hook.x - ball.x, dz = ball.hook.z - ball.z;
|
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
|
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 {
|
else {
|
||||||
const sp = Math.hypot(ball.vx, ball.vz) || 1;
|
const sp = Math.hypot(ball.vx, ball.vz) || 1;
|
||||||
const cur = Math.atan2(ball.vx, ball.vz), want = Math.atan2(dx, dz);
|
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 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);
|
// 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;
|
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,
|
parts.push({ x: ball.x, y: ball.y, z: ball.z, vx: 0, vy: 0, vz: 0,
|
||||||
t: 0.28, fine: true, spark: '#f2f2e6' });
|
t: 0.28, fine: true, spark: '#f2f2e6' });
|
||||||
|
|||||||
+16
-7
@@ -637,12 +637,14 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
|
|||||||
// ball pays for plowing through meat, and a cut ball does not plow — it leaves. Only a kill gets
|
// 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.
|
// here, so a wound ends the chain on its own and no brute, steward or boss ever extends one.
|
||||||
if (ball.chain > 0) {
|
if (ball.chain > 0) {
|
||||||
const nx = nearestTo(zx, zz, TUNE.sliceR);
|
const nx = nearestTo(zx, zz, TUNE.sliceR, ball, TUNE.sliceArc * Math.PI / 180);
|
||||||
if (nx) {
|
if (nx) {
|
||||||
ball.chain--;
|
ball.chain--;
|
||||||
const k = sp0 / (Math.hypot(ball.vx, ball.vz) || 1);
|
const k = sp0 / (Math.hypot(ball.vx, ball.vz) || 1);
|
||||||
ball.vx *= k; ball.vz *= k;
|
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
|
// 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
|
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
|
// 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.
|
// the band exists to avoid. From here the chalk thread is the trail.
|
||||||
@@ -711,15 +713,22 @@ 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 —
|
// The next body a slice hooks onto: nearest live thing to the kill it just made, and it may well be
|
||||||
// a cut ball can come BACK down-court, and the shot that curls round into something behind the body
|
// off to the side or short of it — that curl is the whole shot. What it may NOT be is something the
|
||||||
// you aimed at is the one worth earning. Furniture and barrels are not bodies and never a target.
|
// ball cannot physically turn onto. A ball travelling at `b` can only reach a body within `arc` of
|
||||||
function nearestTo(x, z, maxD) {
|
// 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.
|
||||||
|
// 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);
|
||||||
let best = null, bd = maxD;
|
let best = null, bd = maxD;
|
||||||
for (const zb of zombies) {
|
for (const zb of zombies) {
|
||||||
if ('mo'.includes(zb.variant)) continue;
|
if ('mo'.includes(zb.variant)) continue;
|
||||||
const d = Math.hypot(zb.x - x, zb.z - z);
|
const d = Math.hypot(zb.x - x, zb.z - z);
|
||||||
if (d < bd) { bd = d; best = zb; }
|
if (d >= bd) continue;
|
||||||
|
const da = ((Math.atan2(zb.x - b.x, zb.z - b.z) - head + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
|
||||||
|
if (Math.abs(da) > arc) continue; // behind the ball: no arc gets there, so the chain ends
|
||||||
|
bd = d; best = zb;
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user