The length of the flick is the strength of the aim, and you can see it leave

The rally flick was read as three lanes: sideways past 0.38 of its own length
took the whole corner, a hair under it took dead centre, and nothing lived in
between — "sometimes applied very strong, sometimes it barely does anything",
with no way to tell which one you had asked for. swayOf reads the swipe's LENGTH
as a continuous strength, exactly as the serve already did, and doHit spends
that instead of the lane vote. Keys keep the full lane; the opponent's aim has
no length and is untouched.

That is also what hands the OUT back: going out was never a choice, because
every aimed shot took the full width and then the scatter on top. Below about
0.7 of a full swipe — the line minus aimError, over the width timing can earn —
the worst mistiming in the game cannot sail it now, and above it the old risk
curve is exactly as tested.

And the aim is drawn while the thumb is still moving: the placement ring is a
receipt that arrives with the ball already gone, so the same ring is shown live
with a ray from the player's feet, cream while the ask is safe and hot once the
sway crosses that derived threshold. The receipt goes red when the shot really
is out, so the two rings together are the lesson.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-18 06:56:44 +02:00
1 parent 99b2dec3bf
commit f3d6f71144
8 files changed
+144 -14

No files matched your search

+34 -4
View File
@@ -1,9 +1,9 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_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, HEAD_CROP, CUP_ROUNDS, TINT } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_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, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel } from './gameplay.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
@@ -2271,9 +2271,39 @@ export function render() {
// COLOUR say how much of your flick was actually applied, because reach scales with timing
// (see doHit) and that would otherwise be an invisible rule. Tight and hot = you earned the
// full width; wide and dim = you got a racket on it and little else.
const q = G.placeMark.q ?? 1;
// ...and it goes RED when the shot is actually sailing. "The number one reason I die is that I
// myself keep firing balls into the out" — and the ring said so only by sitting a few pixels
// past a painted line while the ball was still in the air. The colour is the line call, early.
const q = G.placeMark.q ?? 1, out = Math.abs(G.placeMark.x) > COURT.W / 2;
ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5) * (0.55 + 0.45 * q);
drawRing(G.placeMark.x, G.placeMark.z, q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q);
drawRing(G.placeMark.x, G.placeMark.z, out ? UI.bad : q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q);
ctx.globalAlpha = 1;
}
// THE FLICK BEFORE IT IS SPENT: where the swipe under your thumb is currently asking the ball to
// go, drawn from the moment it is long enough to count until the moment it stops being a flick.
// The placement ring above is a receipt — it arrives with the ball already gone, which teaches
// nothing about a gesture you cannot feel the length of. This is the same aim, live: the ring
// walks out toward the corner as you swipe further, and turns red once you are asking for a ball
// the line cannot hold. Timing still decides how much of it you actually get — that is what the
// receipt says, and the difference between the two rings is exactly the lesson.
if (G.swipe && G.phase === 'rally') {
const sway = swayOf(G.swipe);
const x = sway * TUNE.flickWidth;
const z = G.swipe.fy < -0.38 ? SHOT_DEPTH.deep : G.swipe.fy > 0.38 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
// ...and it goes HOT exactly where the shot stops being safe. Below this much sway the worst
// mistiming in the game cannot push the scatter past the sideline; above it, it can — so the
// colour is the honest answer to "was I too hard?", and it is DERIVED from the same three
// numbers doHit spends, never a hand-picked threshold that would rot the moment one moved.
const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach);
const col = Math.abs(sway) > safe ? UI.hot : UI.cream;
ctx.globalAlpha = 0.35 + 0.45 * Math.abs(sway); // the size of the ask is the brightness
// the ray is what makes the ring findable: the target sits deep in perspective and is small,
// and 300 ms is not long enough to go looking for it. It leaves from your own feet, so the
// gesture, the man and the corner are one line — and its length IS the strength you swiped.
const a = proj(P.x, 0, P.z), b = proj(x, 0, z);
ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(Math.round(a.x), Math.round(a.y)); ctx.lineTo(Math.round(b.x), Math.round(b.y)); ctx.stroke();
drawRing(x, z, col, 0.9);
ctx.globalAlpha = 1;
}
// landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down —