diff --git a/HANDOFF.md b/HANDOFF.md index bae964e..732ec2f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -425,6 +425,23 @@ strategic answer, so the run keeps feeding it. **The umpire triple-tap cheat is deleted** — the test group found it. `T.forceZombies()` remains. +**Second batch, same day — THE MESSAGE BAND.** Oliver: transient messages need "a definite space +… over the crowd". `AD_TOP()`/`bandTop(h)` in render.js pin the whole family there — below the +scoreboard block, above the ad strip, never on the court: +- **drawCard** anchors in the band instead of centre court (a card at H/2 covered the very play it + announced, non-blocking or not) and its title dropped B(30) → B(22) ("the heading is a little + too big"). FLASH_Y (68) already lived in the band and is unchanged. +- **RESUMING IN n sits on its own PANEL** (the info-bar style) under the flash slot — bare shadowed + text was unreadable over the sunlit clay crowd, a complaint Oliver had been carrying "forever". + It YIELDS to a live card: both draw in the band, and the card is the announcement of the pause. +- **The gesture-teaching strings are deleted from the METER_Y slot** ('PULL DOWN TO AIM…', + 'TAP TO TOSS, TAP TO SERVE', 'PULL DOWN: FURTHER = HARDER') — the ghost hand is the teacher, and + the same lesson in two registers was clutter. The slot now only carries STATE the mime cannot: + which weapon the serve fires, the gun-unload line, SECOND SERVE. +- **Ghost hand v2**: 1.4x default scale, hint type F(8) → F(10), and a real silhouette — extended + index finger with a nail hint, three curled-knuckle bumps, thumb out to the side. The first cut + "could be recognized, but…"; the knuckle bumps are what flipped it from mitten to pointing hand. + ## Session log: the sidestep, and two type registers (2026-08-03) Oliver's post-ship playtest. One blocker and seven polish items; all eight landed. diff --git a/index.html b/index.html index 46ad05a..cb40c76 100644 --- a/index.html +++ b/index.html @@ -359,6 +359,6 @@ - + diff --git a/render.js b/render.js index f854b57..49234e3 100644 --- a/render.js +++ b/render.js @@ -1172,25 +1172,31 @@ function drawRack() { // the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel // and the SET announcement so both read as the same interruption. Assumes textAlign is already set. +// ---- THE MESSAGE BAND. Every transient centred message — the milestone card, the RESUMING +// countdown, the one-line flash (FLASH_Y already lived here) — has ONE definite home: over the +// CROWD, below the scoreboard block, above the ad strip. Nothing transient sits on the court +// anymore — a card at centre court covered the very play it was announcing, non-blocking or not. +const AD_TOP = () => Math.round(proj(0, 0, -6).y) - AD_H; // top of the courtside ad strip +const bandTop = h => Math.max(56, AD_TOP() - h - 4); // fit an h-px band above the ads + function drawCard(title, sub1, sub2, color, icon) { - // 80 tall, not 72: the second sub line ends at H/2+26 and the band used to stop at +30, so it sat - // all but touching the edge. The band now clears it by 10 px, the same air it has at the top. - // An ICON (the won-duel tin) needs its own room above the title, so the band grows for it rather - // than the art crowding the type. + // An ICON (the won-duel tin) needs its own room above the title, so the band grows for it + // rather than the art crowding the type. const img = icon && SPR[icon], ih = img ? 34 : 0; - ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 44 - ih, W, 80 + ih); + const y0 = bandTop(68 + ih); + ctx.fillStyle = 'rgba(10,6,8,0.72)'; ctx.fillRect(0, y0, W, 68 + ih); ctx.textAlign = 'center'; if (img) { // the tin itself, at its own aspect, sat on the title const w = Math.round(img.width * (ih - 6) / img.height); ctx.imageSmoothingEnabled = false; - ctx.drawImage(img, Math.round(W / 2 - w / 2), Math.round(H / 2 - 44 - ih + 3), w, ih - 6); + ctx.drawImage(img, Math.round(W / 2 - w / 2), y0 + 3, w, ih - 6); } - // the title SHOUTS (Bangers, twice the old size); the subs stay Silkscreen, because they are the - // detail you read second and a whole card in the loud face is just noise - ctx.font = B(30); shadowText(title, W / 2, H / 2 - 34, color); + // the title still SHOUTS (Bangers) but at 22, not 30 — up here it shares the screen with the + // scoreboard and the ads, and 30 read as oversized the moment it left centre court + ctx.font = B(22); shadowText(title, W / 2, y0 + ih + 8, color); ctx.font = F(8); - if (sub1) shadowText(sub1, W / 2, H / 2 + 4, UI.cream); - if (sub2) shadowText(sub2, W / 2, H / 2 + 18, UI.cream); + if (sub1) shadowText(sub1, W / 2, y0 + ih + 36, UI.cream); + if (sub2) shadowText(sub2, W / 2, y0 + ih + 50, UI.cream); } // A GHOST HAND, fingertip at (x, y) — the tutorial finger every gesture hint shares. A drawn @@ -1198,24 +1204,30 @@ function drawCard(title, sub1, sub2, color, icon) { // finger goes here". `press` (0..1) dips it toward the glass, which is what makes a mimed tap // read as a tap and not as a cursor hovering. Pure canvas paths, no sprite: it is HUD chrome in // the UI palette, same family as the arrowheads and the dotted track, not a body on the court. -function drawGhostHand(x, y, press = 0) { +function drawGhostHand(x, y, press = 0, s = 1.4) { ctx.save(); - ctx.translate(Math.round(x), Math.round(y) - 2 + press * 3); - ctx.rotate(-0.18); // the slight tilt a right hand actually lands with - ctx.fillStyle = UI.cream; ctx.strokeStyle = 'rgba(8,12,10,0.75)'; ctx.lineWidth = 1; - ctx.beginPath(); ctx.roundRect(-2.2, 0, 4.4, 11, 2.2); ctx.fill(); ctx.stroke(); // index finger, tip at origin - ctx.beginPath(); ctx.roundRect(-3.2, 8, 11.5, 10, 4); ctx.fill(); ctx.stroke(); // the curled fist behind it - ctx.beginPath(); ctx.ellipse(-2.6, 12.5, 2.4, 3.6, 0.55, 0, 7); ctx.fill(); ctx.stroke(); // folded thumb - ctx.strokeStyle = 'rgba(8,12,10,0.35)'; // two knuckle creases — what makes the blob read as fingers - ctx.beginPath(); ctx.moveTo(2.4, 9.5); ctx.lineTo(2.4, 13.5); ctx.moveTo(5.4, 9.5); ctx.lineTo(5.4, 13.5); ctx.stroke(); + ctx.translate(Math.round(x), Math.round(y) - 3 + press * 4); + ctx.scale(s, s); // 1.4 default — the first cut was "you could recognize it, but…" small + ctx.rotate(-0.15); // the slight tilt a right hand actually lands with + ctx.fillStyle = UI.cream; ctx.strokeStyle = 'rgba(8,12,10,0.8)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(-2.5, 0, 5, 15, 2.5); ctx.fill(); ctx.stroke(); // index finger, tip at origin + ctx.beginPath(); ctx.roundRect(-2.5, 12, 13, 11, 5); ctx.fill(); ctx.stroke(); // the palm behind it + // three CURLED fingers along the palm's leading edge — the knuckle bumps are what make the + // silhouette read as a pointing hand instead of a mitten + for (const [bx, by, br] of [[4.5, 13.2, 2.4], [7.4, 13.7, 2.2], [9.9, 14.4, 2.0]]) { + ctx.beginPath(); ctx.arc(bx, by, br, 0, 7); ctx.fill(); ctx.stroke(); + } + ctx.beginPath(); ctx.ellipse(-3.9, 17.5, 2.6, 4.4, 0.6, 0, 7); ctx.fill(); ctx.stroke(); // thumb, out to the side + ctx.strokeStyle = 'rgba(8,12,10,0.35)'; // fingernail hint on the tip + ctx.beginPath(); ctx.arc(0, 3, 1.6, 0, 7); ctx.stroke(); ctx.restore(); } // the contact ring a mimed tap leaves on the glass: grows and fades under the fingertip function drawTapRing(x, y, p) { ctx.save(); ctx.globalAlpha *= Math.max(0, 0.9 * (1 - p)); - ctx.strokeStyle = UI.warm; ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.arc(x, y, 3 + p * 12, 0, 7); ctx.stroke(); + ctx.strokeStyle = UI.warm; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(x, y, 4 + p * 16, 0, 7); ctx.stroke(); ctx.restore(); } @@ -1255,12 +1267,13 @@ function demoSwipe(dx, dy, label) { ctx.lineTo(hx - dx * s * 0.7 - dy * s, hy - dy * s * 0.7 - dx * s); ctx.fill(); // ...and say it in words. The mime alone was too abstract to read as an instruction. - ctx.font = F(8); + ctx.font = F(10); // hint type is 10 now, one size up — arm's-length readable was the ask ctx.textAlign = 'center'; ctx.textBaseline = 'top'; // The label sits under the TRACK, not under the arrowhead — following the arrow sideways would // carry it off the edge of the screen, and clearing the full travel below a horizontal stroke // left it floating in the middle of an empty apron with nothing to attach itself to. - shadowText(label, ax, ay + (dy ? travel + 24 : 18), UI.warm); + // (offsets clear the bigger hand: its palm hangs ~32 px below the fingertip now) + shadowText(label, ax, ay + (dy ? travel + 36 : 38), UI.warm); // the ghost hand riding the stroke, pressed into the glass for the whole draw ctx.globalAlpha = gone ? 0 : 0.9; drawGhostHand(ax + dx * len, ay + dy * len, 1); @@ -1294,9 +1307,9 @@ function drawTapHint() { ctx.save(); ctx.globalAlpha = 0.9; if (t >= 0.72 && t < 1.32) drawTapRing(ax, ay, (t - 0.72) / 0.6); drawGhostHand(ax, ay, press); - ctx.font = F(8); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - shadowText('TAP TO TOSS', ax - 20, ay + 26, UI.warm); - shadowText('TAP AGAIN AT THE TOP', ax - 20, ay + 38, UI.warm); + ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + shadowText('TAP TO TOSS', ax - 20, ay + 40, UI.warm); + shadowText('TAP AGAIN AT THE TOP', ax - 20, ay + 54, UI.warm); ctx.restore(); } // two dropped tosses in a row: the hand rides the ball itself and presses exactly at the apex, @@ -1308,8 +1321,8 @@ function drawApexHint() { ctx.save(); ctx.globalAlpha = 0.9; if (press > 0.55) drawTapRing(b.x + 4, b.y + 4, 1 - press); // tight at the apex, blooming as it passes drawGhostHand(b.x + 4, b.y + 4, press); - ctx.font = F(8); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - shadowText('TAP AT THE TOP', b.x, b.y - 24, UI.warm); + ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + shadowText('TAP AT THE TOP', b.x, b.y - 28, UI.warm); ctx.restore(); } // two returns passed untouched: the hand presses harder as the ball closes on the player @@ -1321,8 +1334,8 @@ function drawHitHint() { ctx.save(); ctx.globalAlpha = 0.9; if (press >= 1) drawTapRing(hx, hy, (G.idleT * 2) % 1); drawGhostHand(hx, hy, press); - ctx.font = F(8); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - shadowText('TAP AS IT ARRIVES', hx - 26, hy + 34, UI.warm); + ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + shadowText('TAP AS IT ARRIVES', hx - 26, hy + 44, UI.warm); ctx.restore(); } @@ -1739,15 +1752,19 @@ export function render() { : Math.max(G.changeover > 0 ? G.changeover : 0, G.phase === 'over' ? G.overT : 0, G.interlude ? G.interlude.introT : 0); - if (waitT > 0.5) { - // Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the - // busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible - // whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd. + // A live card owns the band and IS the announcement of the pause — the countdown yields to it. + if (waitT > 0.5 && !(G.card && G.card.t > 0 && !(G.card.wait > 0))) { + // In the message band with the rest of the family, and on its OWN PANEL: bare shadowed text + // was barely readable over the sunlit clay crowd (Oliver flagged it every court since). // Plain Silkscreen on purpose — a countdown is information, not an announcement. const txt = 'RESUMING IN ' + Math.ceil(waitT); - const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16); + const y = Math.min(FLASH_Y + 26, AD_TOP() - 18); ctx.font = F(10); - shadowText(txt, W / 2, y, hexA(UI.warm, 0.92)); + const tw = Math.ceil(ctx.measureText(txt).width); + ctx.lineWidth = 1; + panel(W / 2 - tw / 2 - 7, y - 4, tw + 14, 18, 3, 'rgba(8,26,18,0.85)', hexA(UI.warm, 0.4)); + ctx.textBaseline = 'top'; + shadowText(txt, W / 2, y, hexA(UI.warm, 0.95)); } if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot ctx.font = F(8); @@ -1763,15 +1780,15 @@ export function render() { // so this slot shuts up rather than printing the same instruction a second time 40 px below it const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter; const tapDemo = G.mode === 'tennis' && G.serveNum !== 2 && G.idleHintT > TUNE.pullHintAfter; + // The gesture-teaching strings ('PULL DOWN TO AIM…', 'TAP TO TOSS…') are GONE — the ghost hand + // is the teacher now, and saying the same thing twice in two registers was clutter. This slot + // only carries STATE the mime cannot: which weapon the serve fires, and a second serve. const txt = G.mode === 'splatter' && !G.interlude ? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`) - : G.aim ? 'PULL DOWN: FURTHER = HARDER' - : slotId(G.weapon) !== 'ball' ? `SERVE FIRES: ${sel} · ${switchHint}` - : demo ? '' - : touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES') + : !G.aim && slotId(G.weapon) !== 'ball' ? `SERVE FIRES: ${sel} · ${switchHint}` + : '') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') - : tapDemo ? '' // the ghost hand says it in mime and in its own words - : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); + : ''; if (txt) shadowText(txt, W / 2, METER_Y(), UI.warm); // same slot the draw meter uses if (demo) drawPullHint(); // show the gesture instead of only naming it if (tapDemo) drawTapHint();