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