Brighter stands, a serve hint that shows the gesture, and a boy who walks

- The crowd sat in the dark, worst of all in splatter mode where you spend the
  game. Both crowd frames get an identical gamma lift (means 30/14/39 ->
  42/22/56) and the rot tint drops 0.5 -> 0.26. The curve MUST stay identical
  across the two frames or the clap swap flashes brightness; _sheet_crowd1.png
  keeps the pre-lift original.
- The splatter hint led with the tap when pulling is the whole gesture. It now
  reads PULL BACK TO AIM - RELEASE TO FIRE, and after 1.5s of sitting in the
  serve stance untouched a ghost thumb slides back while chevrons grow up-court
  on a loop. Pull back, shoot forward is an inverse relationship that a line of
  text cannot carry on its own.
- The mop boy has three sheets instead of two: the push cycle now plays ONLY
  while he is stopped and working, a new carry sheet walks him upright with the
  mop shouldered, and the run sheet stays for the tennis-mode ball fetch. He was
  mopping thin air the entire way to the stain.

Sheet note: frames 2 and 3 of the carry sheet merged (a mop handle bridging the
gap), so it is cut at the true column gaps plus the thinnest column inside the
merged pair, then common-scaled — same technique as the brute stride sheet.

26-game soak clean. Serve returns 133/150 = 88.7% +/-2.6%, 30 human-tapped
points = 50 hits / 0 whiffs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-28 10:34:43 +03:00
1 parent 21b9dd53fa
commit 7dde5874ab
14 files changed
+67 -5

No files matched your search

+47 -4
View File
@@ -76,7 +76,8 @@ const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2',
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads',
'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
'x_boycarry1','x_boycarry2','x_boycarry3','x_boycarry4','x_howto', // walking with the mop shouldered, and the slingshot tutorial panel
'z_gw2','z_gw3','z_gw4','z_gwb','z_gw2b','z_gw3b','z_gw4b', // the brute comes apart in stages (4/5 hp = z_gw .. 1/5 = z_gw4), each with a 2-frame lurch (…b)
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3',
@@ -143,11 +144,15 @@ function drawStatic(name, x, z, hMeters, bob = 0) {
function drawBoy(b) {
// mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run
// frames until the mop PNGs have loaded.
// Three sheets, because a boy WALKING to the next stain is not a boy mopping: x_boymop is the
// push cycle (used only while he's stopped and working), x_boycarry is him walking upright with
// the mop shouldered, x_boyrun is the plain tennis-mode ball fetch.
const mop = b.mop && SPR.x_boymop1;
const sheet = !mop ? 'x_boyrun' : b.mopT > 0 ? 'x_boymop' : SPR.x_boycarry1 ? 'x_boycarry' : 'x_boymop';
// Frames step off DISTANCE COVERED, not elapsed time: a time-driven cycle made him moonwalk
// whenever his speed and the frame rate disagreed. Now the stride owns the ground he covers.
const stride = TUNE.boyStride * (mop ? 1 : 2.1); // a sprint eats more ground per frame than a push
const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return;
const img = SPR[sheet + (Math.floor((b.dist || 0) / stride) % 4 + 1)]; if (!img) return;
const p = proj(b.x, 0, b.z);
// scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per
// sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright
@@ -379,6 +384,40 @@ function drawCard(title, sub1, sub2, color) {
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12);
}
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
// BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats.
// Pulling is the whole game in splatter mode, and a line of text was carrying that alone.
function drawPullHint() {
const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc;
const draw = Math.min(1, t / 1.15); // the pull itself, then a beat held at full
const gone = t > 1.45; // released — the thumb lifts off
const anchor = proj(P.x, 0, P.z + 0.6);
const ax = Math.round(anchor.x), ay = Math.round(Math.min(anchor.y + 6, METER_Y() - 26));
const len = 30 * draw;
ctx.save();
ctx.globalAlpha = gone ? 0 : 0.85;
// the dotted track the thumb travels, straight down-screen = "pull back"
ctx.fillStyle = UI.dim;
for (let d = 6; d < 34; d += 6) ctx.fillRect(ax - 1, ay + d, 2, 2);
// chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson
ctx.fillStyle = UI.warm;
for (let i = 0; i < 3; i++) {
const u = (i + 1) / 4;
if (draw < u * 0.9) continue;
const cy = ay - 8 - i * 7, s = 4;
ctx.beginPath();
ctx.moveTo(ax, cy - s); ctx.lineTo(ax + s, cy + s * 0.7); ctx.lineTo(ax - s, cy + s * 0.7);
ctx.fill();
}
// the ghost thumb
ctx.globalAlpha = gone ? 0 : 0.9;
ctx.fillStyle = UI.cream;
ctx.beginPath(); ctx.arc(ax, ay + len, 5, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath(); ctx.arc(ax, ay + len, 2, 0, 7); ctx.fill();
ctx.restore();
}
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
// early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away.
function drawModal(m) {
@@ -455,7 +494,9 @@ export function render() {
for (let x = 0; x < W; x += tw) ctx.drawImage(ads, x, standsBottom - adH, tw, adH);
} else { ctx.fillStyle = pal.board; ctx.fillRect(0, standsBottom - adH, W, adH); }
// the rot dims the stadium: one tint over crowd + boards, fading home during duels
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.5 * pt})`; ctx.fillRect(0, 0, W, standsBottom); }
// the rot dims the stadium — but 0.5 on top of an already-dark crowd sheet buried the stands in
// splatter mode, where you spend most of the game. 0.26 still reads as "the lights went wrong".
if (pt > 0) { ctx.fillStyle = `rgba(12,8,20,${0.26 * pt})`; ctx.fillRect(0, 0, W, standsBottom); }
// ground + court (Super Tennis lawn); everything outside the doubles court is OUT
const dw = COURT.dblW / 2;
@@ -680,10 +721,12 @@ export function render() {
? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN')
: G.aim ? 'PULL BACK: FURTHER = HARDER'
: G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}`
: touch ? 'TAP TO SERVE · PULL BACK TO AIM' : 'SPACE SERVES · ARROWS AIM')
: touch ? 'PULL BACK TO AIM · RELEASE TO FIRE' : 'DRAG BACK TO AIM · SPACE SERVES')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses
// and after a beat of inaction, show the gesture instead of only naming it
if (G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter) drawPullHint();
}
if (G.flash) {
ctx.font = F(12); ctx.fillStyle = '#ffffff';