Earn the praise, and give the quiet face a shadow too
PERFECT fired on almost every serve, and it was not a threshold that had drifted — it was the wrong test entirely. pullFrac CLAMPS at pullFull, so pullQuality is exactly 1.0 for any pull of 130 px or more, and 130 px is an ordinary comfortable thumb drag. "quality > 0.999" therefore meant "you pulled far enough", which is not an achievement and is why it read as noise. So hitServe takes `perfect` as its own argument now. The toss ritual keeps earning it the honest way, on the 45 ms perfectMs window. The slingshot earns it by STOPPING in a band at the top of the draw (pullFull..pullFull*perfectBand, 130-166 px) — a target you can miss, rather than a ceiling you cannot. The pull already clicks when it crosses the splatter threshold; it clicks brighter when it enters the band and goes silent when you pull past, so the window is learnable by feel instead of being invisible skill. And the word rotates: PERFECT, TEXTBOOK, IMMACULATE, OH LOVELY, UNPLAYABLE, SHOT OF THE DAY. Cycled rather than random — the same word twice running is what makes one read as a stuck HUD element rather than as praise. The umpire has still never acknowledged the massacre, so a clean shot gets the line it would get at Wimbledon. Also: the card band grew 72 -> 80 px, because the second sub line ended 4 px off the bottom edge and looked like it had fallen out. And the quiet face floats as much as the loud one does, so it gets the same 1 px shadow — the control hints, the gesture-demo labels, the card subs, the modal body and prompt, the death summary lines. The scoreboard and the rack keep theirs off: both already sit on dark panels, and a shadow under 6 px type on a flat fill only smears it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55c0f75c37
commit
9c7ee42bfa
6 files changed
+69
-29
No files matched your search
+13
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -184,6 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=114"></script>
|
||||
<script type="module" src="main.js?v=115"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user