Seven verdicts from the test group

- opening CPU beatable in a game or two (softer cap, more whiffs, rubber band)
- modals are for calm courts only; live-court news is a card now
- the transition howto is gone: a ghost hand teaches the pull, the toss,
  the apex and the swing, each on the exact struggle it answers
- a shot barrel keeps your weapon and glows its slot instead
- switching weapons clicks, the live tile is unmistakable
- barrels come faster the deeper the run goes
- the umpire triple-tap cheat is deleted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-08 19:49:27 +02:00
1 parent f13cbef28a
commit a89e8ff419
10 files changed
+242 -79

No files matched your search

+97 -9
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW } from './config.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -1130,10 +1130,22 @@ function drawRack() {
const sel = G.weapon === i, dim = stockI <= 0;
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
// rounded slots: five separate tiles read as five buttons, which is what they are
// rounded slots: five separate tiles read as five buttons, which is what they are.
// The LIVE one has to read at a glance ("the active weapon should be more pronounced"):
// a visibly brighter well and a 2px gold frame, not the same hairline every other tile gets.
if (sel) ctx.lineWidth = 2;
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4,
sel ? '#123a24' : '#0a2418', // sunk slot
sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit
sel ? UI.pick : '#05160e'); // gold frame when live
if (sel) ctx.lineWidth = 1;
// a slot that just came out of a barrel announces itself: a pink pulse for a moment, WITHOUT
// stealing the selection (grabbing the gun mid-plan was the complaint — see popBarrel)
if (G.rackGlow && G.rackGlow.slot === i) {
const p = 0.35 + 0.55 * Math.abs(Math.sin(G.rackGlow.t * 7));
ctx.save(); ctx.lineWidth = 2;
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4, null, hexA(UI.pink, p));
ctx.restore();
}
// icon: left, vertically centred, padded off the edge
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2;
if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet
@@ -1181,7 +1193,33 @@ function drawCard(title, sub1, sub2, color, icon) {
if (sub2) shadowText(sub2, W / 2, H / 2 + 18, UI.cream);
}
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
// A GHOST HAND, fingertip at (x, y) — the tutorial finger every gesture hint shares. A drawn
// pointing hand rather than the old abstract dot: testers did not read a sliding circle as "your
// 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) {
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.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.restore();
}
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost hand slides
// BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats.
// THE APRON IS THIS DEMO'S ROOM, and now it is the only thing in it. The draw meter used to share
// the space, which was backwards on both counts: a live readout down here is under your thumb the
@@ -1223,12 +1261,9 @@ function demoSwipe(dx, dy, label) {
// 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);
// the ghost thumb
// the ghost hand riding the stroke, pressed into the glass for the whole draw
ctx.globalAlpha = gone ? 0 : 0.9;
ctx.fillStyle = UI.cream;
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 6, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 2.5, 0, 7); ctx.fill();
drawGhostHand(ax + dx * len, ay + dy * len, 1);
ctx.restore();
}
@@ -1246,6 +1281,51 @@ function drawPullHint() {
demoSwipe(dir, 0, 'SWIPE TO DODGE');
}
// ---- the TENNIS lessons, same ghost hand. Tennis has no slingshot to mime, but the testteam
// struggled to grasp the tap ritual too — so the hand TAPS: on the apron when the player sits idle
// in the serve stance, riding the toss when they keep dropping it, and beside the player when
// returns keep sailing past untouched. Each appears on the exact struggle it answers and goes
// away the moment the player does the thing (the counters live in state.G, reset in gameplay).
function drawTapHint() {
const p0 = proj(P.x, 0, P.z);
const ax = Math.round(p0.x) + 20, ay = Math.round(p0.y) + 14; // beside his feet, on the apron
const t = (G.idleHintT - TUNE.pullHintAfter) % 1.6; // hover · press · hold · lift
const press = t < 0.6 ? 0 : t < 0.75 ? (t - 0.6) / 0.15 : t < 0.95 ? 1 : Math.max(0, 1 - (t - 0.95) / 0.2);
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.restore();
}
// two dropped tosses in a row: the hand rides the ball itself and presses exactly at the apex,
// so the TIMING is the thing being shown, not just the verb
function drawApexHint() {
const b = proj(ball.x, ball.y, ball.z);
const apex = TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity);
const press = Math.max(0, 1 - Math.abs(ball.y - apex) / 0.45);
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.restore();
}
// two returns passed untouched: the hand presses harder as the ball closes on the player
function drawHitHint() {
const p0 = proj(P.x, 0, P.z);
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the same distance trySwing tests
const press = Math.max(0, Math.min(1, 1 - (d - 1.6) / 2.2));
const hx = Math.round(p0.x) + 26, hy = Math.round(p0.y) - 52; // over his shoulder, off the sprite
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.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.
// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet
@@ -1682,6 +1762,7 @@ export function render() {
// after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words —
// 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;
const txt = G.mode === 'splatter' && !G.interlude
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
: G.aim ? 'PULL DOWN: FURTHER = HARDER'
@@ -1689,10 +1770,17 @@ export function render() {
: demo ? ''
: touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES')
: 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();
}
// the tennis struggle hints: each rides the exact failure it answers (see drawTapHint's block)
if (G.mode === 'tennis' && score.server === 'P' && G.phase === 'toss' && G.tossDrops >= 2 && ball.active)
drawApexHint();
if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O')
drawHitHint();
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,
// RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.