diff --git a/HANDOFF.md b/HANDOFF.md index 6b92e6b..4fdf297 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -230,6 +230,20 @@ Oliver rejected the procedural fakes on sight. The rule this round: **animation expects; it's now ~62. Rack text is positioned proportionally (`g.h * 0.33` / `0.72`) so the tile height stays a free knob. +## Fifth feedback round (2026-07-27) +- **Crowd brightness**: both crowd sheets got an identical gamma-0.8 lift (means 30/14/39 → + 42/22/56) and the splatter tint dropped 0.5 → 0.26. **If you ever touch one crowd frame you must + apply the exact same curve to the other**, or the clap swap starts flashing brightness. + `assets/_sheet_crowd1.png` is the pre-lift original. +- **The splatter serve hint led with the tap** ("TAP TO SERVE · PULL BACK TO AIM") when pulling is + the entire gesture. Now "PULL BACK TO AIM · RELEASE TO FIRE", plus `drawPullHint()`: after + `TUNE.pullHintAfter` seconds of sitting in the serve stance untouched (`G.idleHintT`), a ghost + thumb slides back while chevrons grow up-court, on a loop. It teaches the inverse relationship + the text can't. +- **The mop boy has three sheets now**, not two: `x_boymop` is the push cycle used ONLY while + stopped and working, `x_boycarry` is him walking upright with the mop shouldered, `x_boyrun` is + the tennis-mode fetch. He was mopping the air the whole way to the stain. + ## Gotchas learned the hard way - Browser-pane verification: python http.server sends no cache headers, so edited ES modules often load STALE even after location.reload(). Reliable fix: preview_stop + diff --git a/assets/_sheet_boycarry.png b/assets/_sheet_boycarry.png new file mode 100644 index 0000000..59a2abe Binary files /dev/null and b/assets/_sheet_boycarry.png differ diff --git a/assets/_sheet_crowd1.png b/assets/_sheet_crowd1.png new file mode 100644 index 0000000..910b3e8 Binary files /dev/null and b/assets/_sheet_crowd1.png differ diff --git a/assets/x_boycarry1.png b/assets/x_boycarry1.png new file mode 100644 index 0000000..3716d8b Binary files /dev/null and b/assets/x_boycarry1.png differ diff --git a/assets/x_boycarry2.png b/assets/x_boycarry2.png new file mode 100644 index 0000000..4c9ad3c Binary files /dev/null and b/assets/x_boycarry2.png differ diff --git a/assets/x_boycarry3.png b/assets/x_boycarry3.png new file mode 100644 index 0000000..876950a Binary files /dev/null and b/assets/x_boycarry3.png differ diff --git a/assets/x_boycarry4.png b/assets/x_boycarry4.png new file mode 100644 index 0000000..0f1f4b1 Binary files /dev/null and b/assets/x_boycarry4.png differ diff --git a/assets/x_crowd.png b/assets/x_crowd.png index 910b3e8..92e386a 100644 Binary files a/assets/x_crowd.png and b/assets/x_crowd.png differ diff --git a/assets/x_crowd2.png b/assets/x_crowd2.png index 361df44..1428647 100644 Binary files a/assets/x_crowd2.png and b/assets/x_crowd2.png differ diff --git a/config.js b/config.js index 7a5a0da..a7c25a7 100644 --- a/config.js +++ b/config.js @@ -58,6 +58,7 @@ export const TUNE = { // mean something: at 0.45 the cone was ±10 m at the far baseline — the whole // court — so every downward pull picked the same body and the angle felt inert. // 0.10 gives ±2.9 m out there: you have to actually point at a lane. + pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the // cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the // lane always wins", which is what made the magnet grab the wrong shambler. diff --git a/gameplay.js b/gameplay.js index 1b540a3..1ca70aa 100644 --- a/gameplay.js +++ b/gameplay.js @@ -798,6 +798,8 @@ export function update(dt) { crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; const w = BOUNCE_IDLE.tempo; + // idle in the serve stance and not touching anything: after a beat, show the pull gesture + if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0; if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } diff --git a/index.html b/index.html index 2713f0e..daf87d5 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index a6749d9..0e315ff 100644 --- a/render.js +++ b/render.js @@ -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'; diff --git a/state.js b/state.js index e9aa103..840a4e2 100644 --- a/state.js +++ b/state.js @@ -46,6 +46,8 @@ export const G = { afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) + idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the + // pull gesture demonstrates itself (render.drawPullHint) meleeT: 0, // s of racket-swish cooldown left interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel