diff --git a/HANDOFF.md b/HANDOFF.md index 757a864..33b6207 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -331,9 +331,19 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `stick.aimable` is false there, so the drag is still the absolute steer it always was. - **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render) - for `SET`, `CHAPTER`, `EPIC!`, `PERFECT` and every free-floating flash — things shouted at you. - Free-floating text carries a 1px shadow (`shadowText`) and one shared y (`FLASH_Y`), because it - has no panel and has to survive the crowd, sunlit clay and blood behind it. + for `SET`, `CHAPTER`, `EPIC!`, the praise words and every free-floating flash — things shouted at + you. EVERY floating string goes through `shadowText`, both faces, because none of them has a panel + and all of them have to survive the crowd, sunlit clay and blood behind them. The scoreboard and + the rack are the two exceptions: they sit on their own dark panels, and a 1 px shadow under 6-8 px + type on a flat dark fill only smears it. +- **Praise is a narrow window, not a ceiling.** `pullQuality` SATURATES: `pullFrac` clamps at + `pullFull`, so every pull past 130 px is exactly quality 1.0 and the old `quality > 0.999` test + praised nearly every serve — *"it displays perfect a lot even when nothing special happened"*. + `hitServe` takes `perfect` as its own argument now: the toss ritual still earns it on the 45 ms + `perfectMs` window, the slingshot earns it by STOPPING in `pullFull..pullFull*perfectBand`. The + draw clicks brighter when you enter that band and goes quiet when you overshoot — without that + audible target it would be invisible skill. The word itself rotates through `PRAISE` (cycled, not + random: the same word twice running reads as a stuck HUD element, not as praise). ## Session log: the sidestep, and two type registers (2026-08-03) diff --git a/config.js b/config.js index c9d92a5..e808c4d 100644 --- a/config.js +++ b/config.js @@ -186,8 +186,15 @@ export const TUNE = { // meant anything. A full draw should cost a deliberate stretch of the thumb. // Capped short of ~150 because a pull that STARTS low on the screen has to // still be completable downward before the finger runs off the glass. - pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 (= PERFECT) at pullFull. Below - // zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob. + pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 at pullFull. Below zSplatQ it only + // staggers, so a lazy flick can't kill: THE anti-spam knob. + perfectBand: 1.28,// ...and the PRAISE is a separate, narrower thing. Quality SATURATES at pullFull + // — every pull past 130 px is exactly 1.0 — so "quality == 1" meant "you pulled + // far enough", and the game shouted PERFECT at almost every serve. Oliver: + // "it displays perfect a lot even when nothing special happened". Praise now + // needs the pull to STOP in the band pullFull..pullFull*this (130-166 px), which + // is a target you can miss. The pull clicks when you enter it (see input.js) and + // goes quiet when you overshoot, so the window is learnable by feel. aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE // 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. diff --git a/gameplay.js b/gameplay.js index ed6e909..dd74e9c 100644 --- a/gameplay.js +++ b/gameplay.js @@ -34,6 +34,16 @@ export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pull // pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is // drawn at the same weight the live preview had. export const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin))); +// A pull that STOPS in the band at the top of the draw, rather than merely reaching it. See +// TUNE.perfectBand for why the old "quality == 1" test praised almost every serve. +export const pullPerfect = mag => mag >= TUNE.pullFull && mag <= TUNE.pullFull * TUNE.perfectBand; + +// The umpire has never once acknowledged the massacre, and he is not about to start now: a clean +// shot gets the same line it would get at Wimbledon. Cycled rather than random — the same word twice +// running is what made one word read as a stuck HUD element rather than as praise. +const PRAISE = ['PERFECT', 'TEXTBOOK', 'IMMACULATE', 'OH, LOVELY', 'UNPLAYABLE', 'SHOT OF THE DAY']; +let praiseN = 0; +const praise = () => { G.flash = { text: PRAISE[praiseN++ % PRAISE.length], t: 0.7 }; }; export function flickTarget(ch, aim) { // exported: render draws the live reticle with it const q = pullQuality(aim.mag), f = pullFrac(aim.mag); @@ -139,7 +149,7 @@ function doHit(ch, quality, aim) { G.aiReactT = 0; // CPU hasn't read the shot yet } sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side - if (quality > 0.999) { G.hitstop = 0.05; G.flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); } + if (quality > 0.999) { G.hitstop = 0.05; praise(); pock(0.5, 1600); } // the rally hit: a real 45 ms window (perfectMs) ch.pose = 'swing'; ch.poseT = 0; } @@ -234,7 +244,10 @@ function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q) { return { x0, y0, z0, v, g, tx, tz, ty, zt, q, f: qualityFrac(q), T, t: 0 }; } -function hitServe(sv, quality, aim = null) { +// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss +// ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and +// so can never fail the old `quality > 0.999` test once you have pulled far enough. +function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { const toFar = sv.side === 'P'; const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; // splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb @@ -294,7 +307,7 @@ function hitServe(sv, quality, aim = null) { if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 }; G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side sv.pose = 'serve'; sv.poseT = 0; - if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; } + if (perfect) { praise(); G.hitstop = 0.05; } } // ---------------------------------------------------------------- scoring ---- @@ -1038,7 +1051,8 @@ export function dragServe(aim) { extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g }); ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall - hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all + // a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised + hitServe(P, pullQuality(aim.mag), aim, pullPerfect(aim.mag)); } // The swish cooldown, scaled by how crowded you are. Oliver died on every game-20 horde: the wave diff --git a/index.html b/index.html index 8b1502c..ac0fbd8 100644 --- a/index.html +++ b/index.html @@ -184,6 +184,6 @@ - + diff --git a/input.js b/input.js index a125739..92544f8 100644 --- a/input.js +++ b/input.js @@ -6,7 +6,7 @@ import { cv } from './dom.js'; import { audio, pock } from './audio.js'; import { TUNE, WEAPONS } from './config.js'; import { G, score } from './state.js'; -import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId, dodge } from './gameplay.js'; +import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge } from './gameplay.js'; import { rackHit } from './render.js'; import { togglePanel } from './panel.js'; @@ -110,6 +110,11 @@ cv.addEventListener('touchmove', e => { // where the threshold is without watching the reticle const hot = pullQuality(dist) >= TUNE.zSplatQ; if (hot !== !!stick.hot) { stick.hot = hot; if (hot) pock(0.45, 1250); } + // ...and a second, brighter click at the top of the draw, where the shot is praised. It + // goes SILENT again when you pull past the band, so overshooting is something you hear. + // Without this the perfect window would be invisible skill — a target nothing marks. + const sweet = pullPerfect(dist); + if (sweet !== !!stick.sweet) { stick.sweet = sweet; if (sweet) pock(0.5, 1950); } } else G.aim = { fx: 0, fy: 0, mag: 0 }; continue; } diff --git a/render.js b/render.js index 293e5fd..60586f1 100644 --- a/render.js +++ b/render.js @@ -40,7 +40,9 @@ const B = sz => `400 ${sz}px Bangers, Silkscreen, monospace`; const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`; // Free-floating text has no panel behind it, so it has to carry its own contrast: over the crowd, // over sunlit clay, over blood. A 1px drop shadow does it and a box would cover the artwork. -// (RESUMING IN hand-rolled exactly this; now everything that floats shares it.) +// (RESUMING IN hand-rolled exactly this; now everything that floats shares it — the loud face AND +// the quiet one. The two exceptions are the SCOREBOARD and the RACK: both already sit on their own +// dark panels, and a 1px shadow under 6-8px type on a flat dark fill only smears it.) function shadowText(txt, x, y, fill) { ctx.fillStyle = 'rgba(0,0,0,0.62)'; ctx.fillText(txt, x + 1, y + 1); @@ -1016,14 +1018,16 @@ 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. function drawCard(title, sub1, sub2, color) { - ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 42, W, 72); + // 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. + ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 44, W, 80); ctx.textAlign = 'center'; // 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); - ctx.fillStyle = UI.cream; ctx.font = F(8); - if (sub1) ctx.fillText(sub1, W / 2, H / 2 + 4); - if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 18); + 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); } // Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides @@ -1062,12 +1066,12 @@ 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.fillStyle = UI.warm; ctx.font = F(8); + ctx.font = F(8); 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. - ctx.fillText(label, ax, ay + (dy ? travel + 24 : 18)); + shadowText(label, ax, ay + (dy ? travel + 24 : 18), UI.warm); // the ghost thumb ctx.globalAlpha = gone ? 0 : 0.9; ctx.fillStyle = UI.cream; @@ -1133,13 +1137,13 @@ function drawModal(m) { ctx.restore(); } ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26; - ctx.font = F(8, 0); ctx.fillStyle = UI.cream; - body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); y += 13; }); + ctx.font = F(8, 0); + body.forEach((l, i) => { shadowText(l, W / 2 + dx(i + 1, -1), y + 6, UI.cream); y += 13; }); // the prompt only exists once everything has landed — offering the tap mid-flight would be a lie, // dismissModal swallows it until then if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy - ctx.font = F(8); ctx.fillStyle = UI.warm; - ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22)); + ctx.font = F(8); + shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22), UI.warm); } ctx.textBaseline = 'top'; } @@ -1513,18 +1517,18 @@ export function render() { // and at 30 it ran edge to edge on a 270px-wide portrait canvas. ctx.font = B(26); shadowText('GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad); - ctx.fillStyle = UI.cream; ctx.font = F(9); - ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14); + ctx.font = F(9); + shadowText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14, UI.cream); if (G.newBest) { // a fresh record crows; otherwise show the mark to beat const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4)); ctx.font = B(20); shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse)); } else if (G.best && G.best.games > 0) { - ctx.fillStyle = UI.dim; ctx.font = F(8); - ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4); + ctx.font = F(8); + shadowText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim); } // gated well past the animation so a panic tap can't skip it (main.js gates the restart the same) - if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32); } + if (G.eatenT > EATEN_RESTART) { ctx.font = F(8); shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm); } ctx.restore(); } // Timed freezes swallow every input until they run out, which reads as the game having hung @@ -1547,7 +1551,7 @@ export function render() { shadowText(txt, W / 2, y, hexA(UI.warm, 0.92)); } 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.fillStyle = UI.warm; ctx.font = F(8); + ctx.font = F(8); const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH'; const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN'; // "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`, @@ -1567,7 +1571,7 @@ export function render() { : touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN 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'); - if (txt) ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses + 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 } // The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,