From f3d6f71144f4ebb73eecd612943419539b4aa119 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 18 Aug 2026 06:56:44 +0200 Subject: [PATCH 1/4] The length of the flick is the strength of the aim, and you can see it leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT.md | 29 +++++++++++++++++++++++++++++ index.html | 2 +- src/config.js | 10 ++++++++++ src/gameplay.js | 22 ++++++++++++++++++---- src/input.js | 18 ++++++++++++++---- src/render.js | 38 ++++++++++++++++++++++++++++++++++---- src/state.js | 2 ++ test.js | 37 ++++++++++++++++++++++++++++++++++++- 8 files changed, 144 insertions(+), 14 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index fd06493..54df9c7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -493,6 +493,35 @@ it in one of the pauses") — the first cut rode the incoming ball, at the momen least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify: teaching placement to someone who cannot reach the ball is the wrong lesson. +**...and its LENGTH is its strength** (2026-08-18). Oliver: *"the flick to aim sometimes is applied +very strong, sometimes it barely does anything. this leads to a lack of agency, the player can't +really tell if there was skill or luck."* He was right and it was the gesture reader, not the feel: +the rally flick was quantised to THREE LANES (`ax`, a swipe past 0.38 of its own length sideways), +so two neighbouring gestures differed by the whole court and nothing lived in between. `gameplay. +swayOf` reads the swipe's length as a continuous 0..1 (`TUNE.flickMin`..`flickFull`, px) exactly as +the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep +the full lane, and the AI's aim (no `mag`) is untouched. + +**This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is +that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot +took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so +the risk band has a floor the player can stop short of: **below `(line - aimError) / (flickWidth * +flickReach)` ≈ 0.7 of a full swipe, the worst mistiming in the game cannot put the ball out**, and +above it the old risk curve is untouched. The tested contract from the pass before this one still +holds — a perfectly timed flick never sails, a badly timed full one can. + +**THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my +flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a +RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared +on release) draws the same target ring live, plus a RAY from the player's feet — the ring alone sits +deep in perspective and 300 ms is not long enough to go looking for it. Cream while the ask is safe, +`UI.hot` once the sway crosses that derived threshold: the colour is the answer to "was I too hard". +Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same +gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie. +Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring +turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked +for, what your timing gave you, and the difference between them. + **The umpire and the boy.** `aside()` refused during any interlude and the match runs on a permanent one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he is sitting over (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on diff --git a/index.html b/index.html index 525b60e..85d1dbc 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 5d9d73e..ad116ff 100644 --- a/src/config.js +++ b/src/config.js @@ -79,6 +79,16 @@ export const TUNE = { // top, or the flick goes out — which is exactly the bug this fixed: // 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick // gets punchy again AND starts sailing wide on sloppy timing. + // THE LENGTH OF THE SWIPE IS THE STRENGTH OF THE AIM (px of finger travel; see gameplay.swayOf). + // The rally flick used to be read as three LANES: 0.38 of its own length sideways bought the whole + // corner, a hair under it bought dead centre, and nothing lived in between — so the same gesture + // came out "very strong" or "barely anything" and which one you got was invisible (Oliver: "a lack + // of agency, the player can't really tell if there was skill or luck"). The serve already read its + // flick as a continuous strength; the rally does now too, on the same shape of numbers. + flickMin: 28, // px before a drag counts as a flick at all — input.js's own threshold + flickFull: 130, // ...and px of travel that buys the FULL width. About a third of a phone + // screen: a short swipe is a safe half-court ball, and only a committed + // one goes hunting the line, which is what makes going OUT a chosen risk. perfectMs: 45, // |offset from window center| for PERFECT // The tennis CPU only exists in the OPENING match (after the transition, tennis never comes // back — duels use racket* dials), so these three are the "how long until the first game is won" diff --git a/src/gameplay.js b/src/gameplay.js index 7c4cd90..d762824 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -123,10 +123,23 @@ function zombieTarget(aim) { return best; } +// A FLICK'S LENGTH IS ITS STRENGTH. The rally aim read the gesture as three LANES — sideways past +// 0.38 of its own length took the whole corner, a hair under it took dead centre — so one flick came +// out "applied very strong" and the next "barely does anything", with nothing in between and no way +// to tell which you had asked for. Length scales the sway continuously now, exactly as it already +// did on the serve: a short swipe is a safe half-court ball, a committed one hunts the line. That is +// also what hands the OUT back to the player — the scatter can only sail a shot that was already +// aimed near the line, so going out becomes a risk you chose rather than one that was rolled for you. +// Keys have no length; they keep the full lane. +export const swayOf = aim => aim.mag === undefined ? aim.ax + : aim.fx * Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin))); + // A return that left with a direction on it resets the flick lesson; a flat one is one more piece of // evidence that the player does not know the gesture is there. Exported for the suite — the touch // flick cannot be pressed headlessly, and this rule is the whole of the lesson's trigger. -export const flatCount = (streak, aim) => (aim && aim.ax ? 0 : streak + 1); +// It reads the same sway the shot does, so a swipe too short to bend the ball is honestly counted +// as flat rather than resetting the lesson it just failed to use. +export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1); function doHit(ch, quality, aim) { // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) @@ -142,7 +155,7 @@ function doHit(ch, quality, aim) { ball.x = ch.x + ch.facing * 0.5; ball.z = ch.z + (toFar ? -0.3 : 0.3); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); - let tx, tz, aimedLane = false; + let tx, tz, aimedLane = false, sway = 0; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; @@ -164,7 +177,8 @@ function doHit(ch, quality, aim) { // timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned; // it just stops being a coin toss you are punished for calling. const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1; - tx = aim.ax * (toFar ? TUNE.flickWidth : 2.7) * reach + err(); + sway = swayOf(aim); // -1..1, continuous on touch — see swayOf + tx = sway * (toFar ? TUNE.flickWidth : 2.7) * reach + err(); const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; // A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for @@ -201,7 +215,7 @@ function doHit(ch, quality, aim) { // It carries `q` now, so the ring also says HOW MUCH OF THE FLICK LANDED — tight and hot when your // timing earned the full reach, wide and dim when it barely applied. Without that the new // quality-scaled reach above is invisible: the shot simply goes less wide and nothing says why. - if (aimedLane && ch.side === 'P' && aim && (aim.ax || aim.az)) { + if (aimedLane && ch.side === 'P' && aim && (sway || aim.az)) { G.placeMark = { x: tx, z: tz, t: 0.8, q: quality }; } // topspin: quality shots dive under extra effective gravity, which is what lets real diff --git a/src/input.js b/src/input.js index 2006a1b..451ba79 100644 --- a/src/input.js +++ b/src/input.js @@ -7,7 +7,7 @@ import { audio, pock } from './audio.js'; import { TUNE, WEAPONS } from './config.js'; import { benchButtons, W, H } from './render.js'; // W/H are live bindings: fitViewport rewrites them import { G, score } from './state.js'; -import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge } from './gameplay.js'; +import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, pullPerfect, dismissModal, slotId, dodge, rallyFeel } from './gameplay.js'; import { rackHit } from './render.js'; import { togglePanel } from './panel.js'; @@ -31,6 +31,7 @@ export const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + let stick = null; // {id, x0, y0, t0, moved} let flickAim = null; // {ax, az, t} aim override for the swing just triggered const DEAD = 14; +const FLICK_MS = 300; // a release later than this is a rescue drag, never a swing (see endTouch) // gameplay consumes the flick at contact; returns null if none or expired export function takeFlickAim(maxAgeMs = 450) { @@ -66,6 +67,7 @@ export function resetInput() { for (const k in keys) delete keys[k]; touchAxis.x = touchAxis.z = 0; stick = flickAim = steerPX = spare = null; + G.swipe = null; } // A LOST TOUCHEND LOCKS THE GAME, part two. visibilitychange/blur (below) only catches the case @@ -77,7 +79,7 @@ export function resetInput() { // Any new touch event proves the old finger is gone if it isn't in e.touches. Drop it then. const dropStale = e => { if (stick && ![...e.touches].some(t => t.identifier === stick.id)) { - stick = null; touchAxis.x = touchAxis.z = 0; steerPX = null; G.aim = null; + stick = null; touchAxis.x = touchAxis.z = 0; steerPX = null; G.aim = null; G.swipe = null; } }; @@ -181,6 +183,14 @@ cv.addEventListener('touchmove', e => { steerPX = (t.clientX - r.left) / r.width * 2 - 1; touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; + // THE FLICK, SHOWN WHILE IT IS STILL A FLICK. The placement ring is a receipt — it arrives with + // the ball already gone. This is the same aim BEFORE the release, which is the half the player + // could not see: how much width the length of the swipe is buying, and whether that is already + // past the line. Only inside the window where a release would still BE a swing (the same + // FLICK_MS/flickMin gate endTouch uses) — past that the gesture is a run and a target hanging + // off it would be a lie. Rally modes only: gore's flick free-aims at a body, not at a lane. + G.swipe = rallyFeel() && dist > TUNE.flickMin && performance.now() - stick.t0 < FLICK_MS + ? { fx: dx / dist, fy: dy / dist, mag: dist } : null; } }, { passive: false }); @@ -197,14 +207,14 @@ const endTouch = e => { if (aim && dist >= TUNE.pullMin) dragServe(aim); } else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack - else if (!stick.adopted && dt < 300 && dist > 28) { // flick = aimed swing — a rescue drag is never a swing + else if (!stick.adopted && dt < FLICK_MS && dist > TUNE.flickMin) { // flick = aimed swing — a rescue drag is never a swing flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim t: performance.now() }; keyPressed('Space'); } - stick = null; touchAxis.x = 0; touchAxis.z = 0; + stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null; } }; cv.addEventListener('touchend', endTouch, { passive: false }); diff --git a/src/render.js b/src/render.js index f52bfc3..9e09c9d 100644 --- a/src/render.js +++ b/src/render.js @@ -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 — diff --git a/src/state.js b/src/state.js index 05a9d33..8a6757d 100644 --- a/src/state.js +++ b/src/state.js @@ -119,6 +119,8 @@ export const G = { hurtT: 0, // s of post-hit grace left; also drives the red hit flash hurtFlash: 0, // s of the impact PULSE left (the persistent damage vignette reads hp directly) placeMark: null, // {x, z, t} — where your aimed rally shot is going (match): the flick made visible + swipe: null, // {fx, fy, mag} — the rally flick WHILE the thumb is still moving, so the width + // its length is buying is visible before the ball leaves (input.js sets it) asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery) praiseT: 0, // s until the next praise word is allowed (TUNE.praiseEvery) bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court diff --git a/test.js b/test.js index 5dcab7a..4387747 100644 --- a/test.js +++ b/test.js @@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; -const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js'); +const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -814,6 +814,41 @@ test('an aimed return can be played OUT, but only by mistiming it', () => { assert.ok(aimW > line * 0.9, 'a perfectly timed flick still reaches the corner'); }); +test("a flick's LENGTH is its width: the three-lane cliff is gone", () => { + // The gesture used to be read as three LANES off a 0.38 ratio, so two neighbouring swipes differed + // by the whole court — "sometimes applied very strong, sometimes barely does anything", with no + // way to tell which one you had asked for. Length is the strength now, as it always was on serve. + const swipe = (m, fx = 1) => ({ ax: 1, fx, fy: 0, mag: m }); + assert.equal(swayOf({ ax: -1 }), -1, 'keys have no length — a held direction is the whole lane'); + assert.equal(swayOf(swipe(TUNE.flickMin)), 0, 'a swipe at the flick threshold aims nothing yet'); + assert.equal(swayOf(swipe(TUNE.flickFull)), 1, '...and a committed one takes the whole width'); + assert.equal(swayOf(swipe(999)), 1, 'over-swiping cannot aim past the corner'); + let prev = 0; + for (let m = TUNE.flickMin; m <= TUNE.flickFull; m += 2) { + const v = swayOf(swipe(m)); + assert.ok(v >= prev - 1e-9 && v - prev < 0.05, `the sway climbs smoothly at ${m}px, no cliff`); + prev = v; + } + // ...and a diagonal is a diagonal, not a lane vote: under the old ratio this aimed dead centre + assert.ok(Math.abs(swayOf(swipe(TUNE.flickFull, 0.35)) - 0.35) < 1e-9, 'fx carries the aim'); + assert.equal(flatCount(3, swipe(TUNE.flickMin + 4)), 4, 'a swipe too short to bend it counts as flat'); + assert.equal(flatCount(3, swipe(TUNE.flickFull)), 0, '...and a real one ends the lesson'); +}); + +test('a gentle flick cannot be played out — only a committed one can', () => { + // Oliver: "the number one reason i die is that i myself keep firing balls into the out." Every + // aimed shot took the FULL lane and then +-aimError on top, so whether it sailed was the dice's + // call at any width the player picked. Width follows the swipe now, so the risk band has an edge + // the player can stop short of — and going out becomes a thing you chose, not a thing rolled. + const line = COURT.W / 2; + const worst = s => s * TUNE.flickWidth * TUNE.flickReach + TUNE.aimError; // widest it can land + assert.ok(worst(0.5) < line, 'half a swipe is in at ANY timing'); + assert.ok(worst(1) > line, '...and a full one can still sail: the risk is kept, and it is chosen'); + // the threshold render colours the live aim ring with is that same arithmetic, not a magic number + const safe = (line - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach); + assert.ok(safe > 0.4 && safe < 1, 'the ring turns hot inside the swipe range, not at either end'); +}); + test('a wounding point knocks the body down and it gets up MINUS the limb', () => { // The beat exists because the wound stage used to POP between points — an arm stopped being there // and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears From d5a069a90c56b7966cc3be9a14875d650dbaa63d Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 18 Aug 2026 07:09:19 +0200 Subject: [PATCH 2/4] The auto-run owns the legs in a rally, so the drag lets go of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On touch, a drag in zombie tennis and classic still walked the player — and that was not a harmless second option. A manual axis suppresses the assist, so holding a drag traded a full-speed run to the intercept point for a 0.35x shuffle aimed wherever the thumb went instead of where the ball will be. Walking was strictly worse than standing still, which is most of "i tend to not be able to reach the balls". touchAxis is no longer written under rallyFeel. The drag keeps its one rally meaning — released quickly it is the aimed swing — keys stay a deliberate desktop override, and gore keeps its drag-walk, where the assist recovers depth only and holding your own lane is the entire point. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 11 +++++++++++ index.html | 2 +- src/input.js | 12 ++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 54df9c7..cc3bbd0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -510,6 +510,17 @@ flickReach)` ≈ 0.7 of a full swipe, the worst mistiming in the game cannot put above it the old risk curve is untouched. The tested contract from the pass before this one still holds — a perfectly timed flick never sails, a badly timed full one can. +**THE AUTO-RUN OWNS THE LEGS, so the drag does not** (2026-08-18, Oliver: *"could it be that when +playing touch on mobile zombie tennis and tennis i am still able to manually move by dragging? in +autorun this must be disabled"* — it could, and it was). It was not a harmless second option: a +manual axis SUPPRESSES the assist (the `!mx && !mz` gate in the movement block), so holding a drag +traded a full-speed run to the intercept point for a `0.35x` shuffle aimed at wherever the thumb +went rather than at where the ball will be. Walking was strictly worse than standing still, which is +most of *"i tend to not be able to reach the balls"*. `input.js` stops writing `touchAxis` under +`rallyFeel()`; the drag keeps its one rally meaning (release it quickly and it is the aimed swing), +keys stay a deliberate desktop override, and GORE keeps its drag-walk — there the assist recovers +depth only and holding your own artillery lane is the point. + **THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared diff --git a/index.html b/index.html index 85d1dbc..dd97f0f 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/input.js b/src/input.js index 451ba79..448358c 100644 --- a/src/input.js +++ b/src/input.js @@ -181,8 +181,16 @@ cv.addEventListener('touchmove', e => { } const r = stick.r; steerPX = (t.clientX - r.left) / r.width * 2 - 1; - touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; - touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; + // THE AUTO-RUN OWNS THE LEGS IN A RALLY, so the drag does not. Walking here was strictly worse + // than standing still: a manual axis SUPPRESSES the assist (the `!mx && !mz` gate in update), + // so holding a drag traded a full-speed run to the intercept point for a 0.35x shuffle that + // goes wherever your thumb went rather than where the ball will be — which is most of "i tend + // to not be able to reach the balls". The gesture keeps its one rally meaning: let go quickly + // and it is the aimed swing. Keys are untouched; a held arrow on a desktop is still an override. + // Gore keeps its drag-walk — there the assist only recovers depth and you hold your own lane. + const legs = !rallyFeel(); + touchAxis.x = legs && Math.abs(dx) > DEAD ? Math.sign(dx) : 0; + touchAxis.z = legs && Math.abs(dy) > DEAD ? Math.sign(dy) : 0; // THE FLICK, SHOWN WHILE IT IS STILL A FLICK. The placement ring is a receipt — it arrives with // the ball already gone. This is the same aim BEFORE the release, which is the half the player // could not see: how much width the length of the swipe is buying, and whether that is already From 1b16b422dfc8b49223a07065e9ef120122136f81 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 18 Aug 2026 07:53:09 +0200 Subject: [PATCH 3/4] The aim is a mark on the grass, not a line drawn over the game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live flick readout was a ring on a ray from the player's feet — a HUD element laid across the court, and read as one: "pretty bare bones... the line must go away, it is distracting". It is a soft cloud on the ground now, in the register the dust and the blood already use: it sits where the ball is being asked to land, grows with the length of the swipe, and warms from pale lime through gold to red as that ask approaches the width no timing can keep in. The threshold stopped being a flip, so too hard is something you watch coming. Three things it took to make it read, kept in the comments so the next pass does not re-learn them: it is sized in METRES off the projection, or it is an eight-pixel smudge at the far corner where it actually lives; it is a stain and not an additive glow, which turned the red end yellow over green grass; and the ramp starts pale rather than green, because green on green showed nothing at all across the whole safe half of the range. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 21 +++++++++++++++++---- index.html | 2 +- src/render.js | 46 +++++++++++++++++++++++++++++++--------------- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index cc3bbd0..3b5a965 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -524,10 +524,23 @@ depth only and holding your own artillery lane is the point. **THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared -on release) draws the same target ring live, plus a RAY from the player's feet — the ring alone sits -deep in perspective and 300 ms is not long enough to go looking for it. Cream while the ask is safe, -`UI.hot` once the sway crosses that derived threshold: the colour is the answer to "was I too hard". -Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same +on release) marks where the ball is being ASKED to land. + +**It is a cloud, not a diagram.** The first cut was a ring on a ray from the player's feet and it +was rejected on sight (*"pretty bare bones... the line from the player to the indicator must go +away, it is distracting"*): a line drawn across the court is a HUD element laid over the game. +What is there now is a soft stain on the ground in the same procedural register as the dust and the +blood — it GROWS with the length of the swipe and it WARMS as that ask approaches the width no +timing can keep in. Three things were learned making it read, all of them the hard way: +* **metres, not pixels** — sized off `p.s` so perspective shrinks it with distance. A pixel radius + looked fine at the baseline and was an 8-px smudge at the far corner, which is where it lives. +* **a stain, not a glow** — `globalCompositeOperation = 'lighter'` turned the red end YELLOW over + green grass, and the hue is the entire message. +* **the ramp is pale lime -> gold -> red, not green -> red** — green on green is invisible, so the + obvious ramp showed nothing across the whole safe half of its range. The cool end carries its + contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike. +The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is +something you watch coming. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie. Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked diff --git a/index.html b/index.html index dd97f0f..0d22d60 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/render.js b/src/render.js index 9e09c9d..092b8eb 100644 --- a/src/render.js +++ b/src/render.js @@ -16,6 +16,14 @@ const hexLerp = (a, b, t) => { const [ar, ag, ab] = c(a), [br, bg, bb] = c(b); return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`; }; +// The live aim cloud's ramp, walked in TWO steps: pale lime -> gold -> red. A straight green-to-red +// lerp was the obvious version and it failed on the only court that matters — green on green is +// invisible, so the whole safe half of the range showed nothing. The cool end carries its contrast +// in LIGHTNESS instead of hue, which is what makes it read on grass, clay and slab alike, and gold +// in the middle keeps every step of the ramp distinct from the two beside it. +const AIM_COOL = '#d8eaae', AIM_MID = '#e0cc72', AIM_HOT = '#d64a3a'; +const aimHeat = t => t < 0.5 ? hexLerp(AIM_COOL, AIM_MID, t * 2) : hexLerp(AIM_MID, AIM_HOT, (t - 0.5) * 2); + // how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home function paletteT() { return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6); @@ -2290,21 +2298,29 @@ export function render() { 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; + // A CLOUD, NOT A DIAGRAM. The first cut was a ring on a ray from the player's feet, and a line + // drawn across the court is a HUD element laid over the game — Oliver: "pretty bare bones... + // the line must go away, it is distracting". This is a soft mark on the grass instead, in the + // same procedural register as the dust and the blood: it sits where the ball is being asked to + // land, it GROWS with the length of the swipe, and it warms from green to red as that ask + // approaches the width no timing can keep in. The threshold is not a flip — the colour is + // already amber before you reach it, so "too hard" is something you watch coming. + const a = Math.abs(sway), p = proj(x, 0, z); + if (p.s > 0) { + const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach); + const col = aimHeat(Math.min(1, a / safe)); + const rx = p.s * (0.55 + 1.35 * a), ry = rx * 0.4; // metres of ground, so perspective shrinks it with distance + ctx.fillStyle = col; + // NOT additive: 'lighter' turned the red end yellow over green grass, and the hue IS the + // message here. A soft stain keeps green green and red red on every court in the table. + // stacked rather than a gradient: three soft passes build a chunky core the way the dust and + // the piles do, and a smooth radial ramp would be the one un-pixelated thing on the court + for (let i = 3; i >= 1; i--) { + ctx.globalAlpha = 0.13 + 0.10 * a; + ctx.beginPath(); ctx.ellipse(Math.round(p.x), Math.round(p.y), rx * i / 3, ry * i / 3, 0, 0, 7); ctx.fill(); + } + ctx.globalAlpha = 1; + } } // landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down — // the ring that lingered where the ball had already landed confused testers and is removed) From 7ff8671a22f9e28cbf78ef594e816a4bcd78d11b Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 18 Aug 2026 07:58:47 +0200 Subject: [PATCH 4/4] The aim cloud goes quiet, and a mistimed flick falls short of the line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hidden behind TUNE.aimCloud rather than deleted: the reading it gives — how much width the swipe is buying, before the ball leaves — is the only teacher the gesture has, and the panel can flip it back live for the next look at it. And "a little harder to hit the out" is a number: flickReach 0.62 -> 0.52, which halves the worst-case rate from about 15% to about 7%, with the test tightened so it cannot drift back. The shape is untouched — a perfect flick still never sails, a badly timed full swipe still can — but a mistimed one now lands nearer the middle than the line, which is the only half of that trade a player who cannot see his own timing can do anything about. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 12 +++++++++++- index.html | 2 +- src/config.js | 18 ++++++++++++++---- src/render.js | 2 +- test.js | 3 +++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 3b5a965..48bac38 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -502,6 +502,13 @@ swayOf` reads the swipe's length as a continuous 0..1 (`TUNE.flickMin`..`flickFu the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep the full lane, and the AI's aim (no `mag`) is untouched. +**...and the out was tightened once more** (2026-08-18, *"make it a little harder to hit the out"*): +`flickReach` 0.62 -> 0.52, halving the worst-case rate from ~15% to ~7% and pinned by the test. The +shape is unchanged — a perfect flick still never sails, a badly timed full swipe still can — but a +mistimed one now lands NEARER THE MIDDLE rather than nearer the line, which is the only half of that +trade a player who cannot see his own timing can do anything about. **`flickReach` is the out dial**; +`aimError` is shared with the opponent's returns and with every aimed shot in the gore modes. + **This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so @@ -540,7 +547,10 @@ timing can keep in. Three things were learned making it read, all of them the ha obvious ramp showed nothing across the whole safe half of its range. The cool end carries its contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike. The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is -something you watch coming. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same +something you watch coming. **It is OFF as of its first playtest** (*"hide the indicator for now"*) +— `TUNE.aimCloud` gates the whole block and the tuning panel can flip it back live. Kept rather than +deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the +lesson it teaches has no other teacher. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie. Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked diff --git a/index.html b/index.html index 0d22d60..a51f555 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index ad116ff..c08011a 100644 --- a/src/config.js +++ b/src/config.js @@ -69,12 +69,17 @@ export const TUNE = { flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115, // so this is the corner with 17 cm to spare. Raising it past the line does // not make the flick punchier, it makes a perfect one go out. - flickReach: 0.62, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it). + flickReach: 0.52, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it). // 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then // overshot it: "flicking: too easy. i must be able to play out." At 0.62 - // a mistimed flick CAN sail (~15% of the time at quality 0, on the worst - // half of the scatter) and a perfectly timed one never can — the risk is - // back and it is timing, not a coin toss, that decides it. + // a mistimed flick sailed ~15% of the time at quality 0 and a perfectly + // timed one never could — the risk was back, and timing rather than a + // coin toss decided it. 0.52 keeps that shape and halves the worst-case + // rate to ~7% ("make it a little harder to hit the out"): a badly timed + // flick now lands NEARER THE MIDDLE rather than nearer the line, which + // is the only half of the trade a player who cannot see his own timing + // can do anything about. THIS is the out dial — aimError is shared with + // the opponent's returns and with every aimed shot in the gore modes. // Reach x quality must stay under the sideline once aimError is added on // top, or the flick goes out — which is exactly the bug this fixed: // 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick @@ -86,6 +91,11 @@ export const TUNE = { // of agency, the player can't really tell if there was skill or luck"). The serve already read its // flick as a continuous strength; the rally does now too, on the same shape of numbers. flickMin: 28, // px before a drag counts as a flick at all — input.js's own threshold + aimCloud: 0, // 1 draws the live aim cloud under the moving thumb (render's G.swipe + // block); 0 hides it. OFF after its first playtest — "hide the indicator + // for now" — kept as a switch rather than deleted because the reading it + // gives (how much width the swipe is buying, before the ball leaves) is + // the only teacher the gesture has, and the panel can flip it back live. flickFull: 130, // ...and px of travel that buys the FULL width. About a third of a phone // screen: a short swipe is a safe half-court ball, and only a committed // one goes hunting the line, which is what makes going OUT a chosen risk. diff --git a/src/render.js b/src/render.js index 092b8eb..b94543e 100644 --- a/src/render.js +++ b/src/render.js @@ -2294,7 +2294,7 @@ export function render() { // 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') { + if (G.swipe && G.phase === 'rally' && TUNE.aimCloud) { 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; diff --git a/test.js b/test.js index 4387747..53b45c0 100644 --- a/test.js +++ b/test.js @@ -807,6 +807,9 @@ test('an aimed return can be played OUT, but only by mistiming it', () => { }; assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%'); assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)'); + // ...and "make it a little harder to hit the out" (2026-08-18) is a NUMBER, not a vibe: the + // worst-case rate was halved off 15%, and this is what stops flickReach drifting back up. + assert.ok(pOut(0) < 0.10, 'a mistimed flick lands nearer the middle than the line: ' + (pOut(0) * 100).toFixed(1) + '%'); assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out'); for (let q = 0.05; q <= 1.0001; q += 0.05) assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);