diff --git a/gameplay.js b/gameplay.js index 748c2b2..d0aad08 100644 --- a/gameplay.js +++ b/gameplay.js @@ -123,8 +123,14 @@ function doHit(ch, quality, aim) { ball.kills = 0; // fresh shot: multi-kill streak restarts ball.bomb = false; ball.flame = false; // only a consuming serve arms these; any other hit clears them if (ch.side === 'P') { // shot marker: where this will land — red means it's going out - const ok = G.mode !== 'tennis' || (Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ); - G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 }; + // TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be + // true there, so it was a permanent yellow ring on the grass carrying no information — and + // sitting under the shambler for 1.3 s, which is longer than the gap before you aim again. + // That ring, not the new mark, is the circle that seemed to live under their feet. + if (G.mode === 'tennis') { + const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ; + G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 }; + } G.aiReactT = 0; // CPU hasn't read the shot yet } sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side @@ -257,7 +263,9 @@ function hitServe(sv, quality, aim = null) { } } if (wsel > 0 && slotStock(wsel) <= 0) G.weapon = 0; // that was the last tin — hand back to plain balls - if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 }; + // tennis only, same reason as doHit's: a splatter serve lands at a shambler's feet, and the ring + // sat there in yellow for 1.3 s saying nothing — right where the aim mark is trying to be read. + if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 }; G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side sv.pose = 'serve'; sv.poseT = 0; if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; } diff --git a/render.js b/render.js index b191ebe..790a8cb 100644 --- a/render.js +++ b/render.js @@ -487,20 +487,29 @@ function drawShotBand(a, u0) { 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. + ctx.globalAlpha = 1; ctx.lineWidth = 1; +} + +// The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the +// rest of the band (which happens before the sprite pass, so a nearer body correctly occludes the +// arc) hid it completely behind the very thing it was marking. That is why it was never once seen. +// It sits on the BODY: that is where the ball actually strikes (the hit test is a radius at any +// height under 1.9 m) and where the wound lands, so a ring on the grass pointed at the one part of +// a shambler the shot has nothing to do with. Filled and washed out in the band's own colour +// rather than a hard 2px ring, so it marks without looking like a decal stuck to the lawn. +function drawAimMark(a, u0) { + const left = 1 - u0; + if (left <= 0.01) return; + const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; const onBody = !!a.zt; const m = proj(a.tx, onBody ? 0.95 : 0, a.tz); - ctx.globalAlpha = (onBody ? 0.34 : 0.24) * left; + ctx.globalAlpha = (onBody ? 0.42 : 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; + ctx.globalAlpha = 1; } function drawRing(x, z, color) { @@ -938,13 +947,16 @@ 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.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); - } + // 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. Held for the frame because the MARK on the + // far end has to wait for the bodies to be drawn (see drawAimMark below) and previewServe is a + // full ballistic solve — worth doing once. + const band = G.mode === 'splatter' && !G.interlude + && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally') + ? (G.aim ? previewServe(G.aim) : G.trail) : null; + const bandU0 = band && band === G.trail ? band.t / band.T : 0; + if (band) drawShotBand(band, bandU0); // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ @@ -969,6 +981,7 @@ export function render() { if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying }); nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); + if (band) drawAimMark(band, bandU0); // ...now that there is a body to mark // server idly bouncing the ball while waiting if (G.phase === 'pretoss') {