Let the ball eat its own aim band, mark the body, grow the machine
Four things from the phone. THE BAND NO LONGER BLINKS OUT. Cutting it at the instant of release made a fast rally strobe — band, gone, band, gone. hitServe now records the arc it actually fired (error included, so the ball tracks what it is consuming to within a centimetre) and the ball eats it from behind as it flies. The aim resolves into the shot instead of vanishing. It ends early when the ball does, because a shambler swallowing the shot is a cut you want. THE TARGET MARK SITS ON THE BODY. A ring on the grass pointed at the one part of a shambler the shot has nothing to do with — the ball strikes anywhere under 1.9 m and the wound lands on the torso. So the mark rides at chest height when there is a body to hit and lies flat only when the lane is empty. Filled and washed out in the band's own colour instead of a hard 2px ring, which stops it reading as a decal stuck to the lawn. x_howto REDRAWN for the current gesture: thumb dragging back to a print, the shot leaving as a curved band onto the shambler's chest, shadow dots beneath. The old one still taught a straight arrow. MACH_H 1.15 -> 1.55. Waist high read as a prop you could ignore. Verified on a cache-busted build (the pane was serving a stale config.js, so the first pass measured nothing): preview velocity and gravity still identical to the fired ball, same flight time in the trail, unravel smooth to the hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a91d87b08
commit
0ce857fedc
4 files changed
+78
-40
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 25 KiB |
@@ -331,7 +331,8 @@ export const BOSS_ART = [
|
|||||||
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'] },
|
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MACH_H = 1.15; // a ball machine stands about waist high — it must read as furniture, not as a body
|
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
|
||||||
|
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
|
||||||
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
|
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
|
||||||
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
|
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
|
||||||
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
|
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
|
||||||
|
|||||||
+19
-2
@@ -29,6 +29,9 @@ export function predictLanding() { // first-bounce point, closed form — exact,
|
|||||||
// lane is the target; the cone widens with range so distant lanes stay pickable.
|
// lane is the target; the cone widens with range so distant lanes stay pickable.
|
||||||
export const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin)));
|
export const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin)));
|
||||||
export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin);
|
export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin);
|
||||||
|
// pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is
|
||||||
|
// drawn at the same weight the live preview had.
|
||||||
|
export const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin)));
|
||||||
|
|
||||||
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
||||||
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
||||||
@@ -199,8 +202,14 @@ 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);
|
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q);
|
||||||
return { x0: c.x, y0: c.y, z0: c.z, v, g, tx: st.tx, tz: st.tz, zt: st.ft.zt,
|
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, !!st.ft.zt, q);
|
||||||
q, f: pullFrac(aim.mag) };
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
function shotBand(x0, y0, z0, v, g, tx, tz, zt, q) {
|
||||||
|
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, zt, q, f: qualityFrac(q), T, t: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function hitServe(sv, quality, aim = null) {
|
function hitServe(sv, quality, aim = null) {
|
||||||
@@ -218,6 +227,11 @@ function hitServe(sv, quality, aim = null) {
|
|||||||
tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
||||||
}
|
}
|
||||||
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality);
|
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality);
|
||||||
|
// THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally
|
||||||
|
// 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.ft.zt, quality);
|
||||||
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;
|
||||||
@@ -993,6 +1007,9 @@ export function update(dt) {
|
|||||||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||||||
G.idleT += dt;
|
G.idleT += dt;
|
||||||
if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside())
|
if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside())
|
||||||
|
// the fired band unravels behind the ball. It also goes the moment the ball does — a shambler
|
||||||
|
// eating the shot ends the band with the splat, which is a cut you want.
|
||||||
|
if (G.trail && ((G.trail.t += dt) >= G.trail.T || !ball.active)) G.trail = null;
|
||||||
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
|
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
|
||||||
// other beat that overwrote overT while the breather ran (a melee kill taking a game, say)
|
// other beat that overwrote overT while the breather ran (a melee kill taking a game, say)
|
||||||
// had the umpire calling time on a pause that was never a changeover.
|
// had the umpire calling time on a pause that was never a changeover.
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function drawRunOff() {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- sprites ----
|
// ---------------------------------------------------------------- sprites ----
|
||||||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||||||
const SPRV = 78; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
const SPRV = 79; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||||||
const SPR = {};
|
const SPR = {};
|
||||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
||||||
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
||||||
@@ -453,6 +453,56 @@ function drawFlames(zb, hMeters) {
|
|||||||
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2)))));
|
ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2)))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The shot as a band through the air, drawn from u0 (0 = the whole flight, 0.6 = the ball has
|
||||||
|
// already eaten 60% of it) to the target. See the note at the call site for what each channel says.
|
||||||
|
function drawShotBand(a, u0) {
|
||||||
|
const left = 1 - u0;
|
||||||
|
if (left <= 0.01) return;
|
||||||
|
// cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to
|
||||||
|
// see where you're pointing, you just need to know it won't kill.
|
||||||
|
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||||||
|
const N = 20;
|
||||||
|
const at = t => ({ x: a.x0 + a.v.vx * t, y: a.y0 + a.v.vy * t - 0.5 * a.g * t * t,
|
||||||
|
z: a.z0 + a.v.vz * t });
|
||||||
|
const uAt = i => u0 + left * (i / N);
|
||||||
|
// The shadow first, under everything: an arc hanging in the air is ambiguous about where it
|
||||||
|
// comes down until something pins it to the lawn. This is the whole 3D cue.
|
||||||
|
for (let i = 0; i <= N; i++) {
|
||||||
|
const w = at(a.T * uAt(i)), p = proj(w.x, 0, w.z);
|
||||||
|
ctx.fillStyle = `rgba(0,0,0,${(0.42 - 0.24 * (i / N)) * left})`;
|
||||||
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * 0.07), Math.max(0.5, p.s * 0.028), 0, 0, 7);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
// ...then the band. Width comes from each point's OWN projection scale, so it tapers into the
|
||||||
|
// distance like everything else on the court rather than being a flat screen-space stroke. It
|
||||||
|
// also fades along its length: solid at the racket, thinning toward the target, so it reads as
|
||||||
|
// something leaving your hand and not as a label pinned on the shambler.
|
||||||
|
ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||||
|
const head = at(a.T * u0); // the cut end: the racket while aiming, the ball once it is flying
|
||||||
|
let prev = proj(head.x, head.y, head.z);
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z);
|
||||||
|
ctx.globalAlpha = (0.30 + 0.55 * a.f) * (1 - 0.55 * (i / N)) * left;
|
||||||
|
ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15));
|
||||||
|
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
||||||
|
prev = p;
|
||||||
|
}
|
||||||
|
// The mark sits on the BODY, not at his feet. That is where the ball actually strikes (the hit
|
||||||
|
// test is a radius at any height under 1.9 m) and where the wound will be, so a ring on the grass
|
||||||
|
// was pointing at the one part of the shambler the shot has nothing to do with. Filled and washed
|
||||||
|
// out in the band's own colour rather than a hard 2px ring: it marks without looking like a decal
|
||||||
|
// stuck on the lawn.
|
||||||
|
const onBody = !!a.zt;
|
||||||
|
const m = proj(a.tx, onBody ? 0.95 : 0, a.tz);
|
||||||
|
ctx.globalAlpha = (onBody ? 0.34 : 0.24) * left;
|
||||||
|
ctx.fillStyle = col;
|
||||||
|
ctx.beginPath();
|
||||||
|
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; ctx.lineWidth = 1;
|
||||||
|
}
|
||||||
|
|
||||||
function drawRing(x, z, color) {
|
function drawRing(x, z, color) {
|
||||||
const p = proj(x, 0, z);
|
const p = proj(x, 0, z);
|
||||||
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
// integer-snapped so the moving ring steps whole pixels instead of shimmering
|
||||||
@@ -888,42 +938,12 @@ export function render() {
|
|||||||
// Because the arc terminates at the auto-picked target, a weak pull and a full one at the same
|
// Because the arc terminates at the auto-picked target, a weak pull and a full one at the same
|
||||||
// shambler draw the SAME curve in different colours — which is the "distance is free" rule made
|
// shambler draw the SAME curve in different colours — which is the "distance is free" rule made
|
||||||
// visible instead of merely documented.
|
// visible instead of merely documented.
|
||||||
if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
|
if (G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) {
|
||||||
const pv = previewServe(G.aim);
|
// While pulling: the live preview, whole. After release: the same arc with its tail already
|
||||||
if (pv) {
|
// eaten, drawn from where the ball has got to. G.aim wins — a new pull replaces the last shot's
|
||||||
const kills = pv.q >= TUNE.zSplatQ;
|
// leftovers rather than drawing two bands at once.
|
||||||
// cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still
|
const band = G.aim ? previewServe(G.aim) : G.trail;
|
||||||
// need to see where you're pointing, you just need to know it won't kill.
|
if (band) drawShotBand(band, band === G.trail ? band.t / band.T : 0);
|
||||||
const col = !kills ? '#9db4d0' : pv.zt ? UI.hot : UI.warm;
|
|
||||||
const vh = Math.hypot(pv.v.vx, pv.v.vz) || 1;
|
|
||||||
const T = Math.hypot(pv.tx - pv.x0, pv.tz - pv.z0) / vh; // flight time to the target
|
|
||||||
const N = 20;
|
|
||||||
const at = t => ({ x: pv.x0 + pv.v.vx * t, y: pv.y0 + pv.v.vy * t - 0.5 * pv.g * t * t,
|
|
||||||
z: pv.z0 + pv.v.vz * t });
|
|
||||||
// The shadow first, under everything: an arc hanging in the air is ambiguous about where it
|
|
||||||
// comes down until something pins it to the lawn. This is the whole 3D cue.
|
|
||||||
for (let i = 0; i <= N; i++) {
|
|
||||||
const w = at(T * i / N), p = proj(w.x, 0, w.z);
|
|
||||||
ctx.fillStyle = `rgba(0,0,0,${0.42 - 0.24 * (i / N)})`;
|
|
||||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * 0.07), Math.max(0.5, p.s * 0.028), 0, 0, 7);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
// ...then the band. Width comes from each point's OWN projection scale, so it tapers into
|
|
||||||
// the distance like everything else on the court rather than being a flat screen-space
|
|
||||||
// stroke. It also fades along its length: solid at the racket, thinning toward the target,
|
|
||||||
// so it reads as something leaving your hand and not as a label pinned on the shambler.
|
|
||||||
ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
||||||
let prev = proj(pv.x0, pv.y0, pv.z0);
|
|
||||||
for (let i = 1; i <= N; i++) {
|
|
||||||
const u = i / N, w = at(T * u), p = proj(w.x, w.y, w.z);
|
|
||||||
ctx.globalAlpha = (0.30 + 0.55 * pv.f) * (1 - 0.55 * u);
|
|
||||||
ctx.lineWidth = Math.max(1, p.s * (0.04 + pv.f * 0.15));
|
|
||||||
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
|
||||||
prev = p;
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1; ctx.lineWidth = 1;
|
|
||||||
drawRing(pv.tx, pv.tz, col);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// far-side drawables in z order (far → near), then the net, then the near side
|
// far-side drawables in z order (far → near), then the net, then the near side
|
||||||
|
|||||||
Reference in new issue
Block a user