The body mark was behind the body; the yellow ring was never mine

Two bugs, one report.

THE MARK WAS DRAWN UNDER THE SPRITE. The whole band, target mark included, is
painted before the sprite pass — which is right for the arc, since a nearer
shambler should occlude the part of the flight behind it. But the mark is a
mark ON a shambler, so it went down first and the body was then drawn straight
over the top of it. It has never once been visible. Split out as drawAimMark
and called after the near-side actors, which is the only place a highlight on
a body can go.

THE YELLOW CIRCLE WAS G.outMarker, not the aim at all. It is the tennis in/out
call — "did that land in?" — and splatter never asks: `out` could not be true
there, so it was an unconditional yellow ring on the grass carrying no
information, sitting under the shambler for 1.3 s. That is longer than the gap
before the next pull, which is why it was there "most of the time" and why it
looked like the aim marking the feet. Now set only in tennis, from both the
serve and the rally swing.

Checked: tennis keeps its ring on serves AND rally shots, splatter has none
anywhere, and the mark now draws over the torso at every pull strength.

Note this leaves a splatter VOLLEY with no landing feedback at all. It had a
ring that said nothing before, so nothing was lost, but say if the volley now
feels blind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-02 18:38:08 +02:00
1 parent 0ce857fedc
commit ff9e09e3eb
2 files changed
+38 -17

No files matched your search

+11 -3
View File
@@ -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; }
+27 -14
View File
@@ -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') {