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
+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
|
||||
|
||||
Reference in new issue
Block a user