diff --git a/assets/x_howto.png b/assets/x_howto.png index f935521..3679dab 100644 Binary files a/assets/x_howto.png and b/assets/x_howto.png differ diff --git a/config.js b/config.js index 664ee85..52249cd 100644 --- a/config.js +++ b/config.js @@ -331,7 +331,8 @@ export const BOSS_ART = [ 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 // 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. diff --git a/gameplay.js b/gameplay.js index ad345d0..748c2b2 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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. 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); +// 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 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); if (!st) return null; 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, - q, f: pullFrac(aim.mag) }; + return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, !!st.ft.zt, q); +} + +// 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) { @@ -218,6 +227,11 @@ function hitServe(sv, quality, aim = null) { tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; } 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.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = sv.side; ball.bounces = 0; @@ -993,6 +1007,9 @@ export function update(dt) { crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; 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 // 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. diff --git a/render.js b/render.js index e6ce27d..b191ebe 100644 --- a/render.js +++ b/render.js @@ -181,7 +181,7 @@ function drawRunOff() { // ---------------------------------------------------------------- sprites ---- // assets/.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 = {}; 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 @@ -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))))); } } +// 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) { const p = proj(x, 0, z); // 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 // shambler draw the SAME curve in different colours — which is the "distance is free" rule made // visible instead of merely documented. - if (G.aim && G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { - const pv = previewServe(G.aim); - if (pv) { - const kills = pv.q >= TUNE.zSplatQ; - // cool blue-white = this pull 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 = !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); - } + if (G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')) { + // While pulling: the live preview, whole. After release: the same arc with its tail already + // eaten, drawn from where the ball has got to. G.aim wins — a new pull replaces the last shot's + // leftovers rather than drawing two bands at once. + const band = G.aim ? previewServe(G.aim) : G.trail; + if (band) drawShotBand(band, band === G.trail ? band.t / band.T : 0); } // far-side drawables in z order (far → near), then the net, then the near side