Every message knows its place now: over the crowd

- one definite band for cards, flash and the countdown — never on the court
- the card heading down a size; RESUMING IN finally on a readable panel
- the gesture-teaching strings are gone, the ghost hand is the teacher
- ghost hand v2: bigger, bigger type, and knuckles that make it a hand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-08 20:10:31 +02:00
1 parent a89e8ff419
commit aaae452338
3 files changed
+78 -44

No files matched your search

+17
View File
@@ -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.
+1 -1
View File
@@ -359,6 +359,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=128"></script>
<script type="module" src="main.js?v=129"></script>
</body>
</html>
+60 -43
View File
@@ -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();