diff --git a/PROJECT.md b/PROJECT.md index fd06493..48bac38 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -493,6 +493,69 @@ 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. + +**...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 +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 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 +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. **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 +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..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 5d9d73e..c08011a 100644 --- a/src/config.js +++ b/src/config.js @@ -69,16 +69,36 @@ 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 // 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 + 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. 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..448358c 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; } }; @@ -179,8 +181,24 @@ 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 + // 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 +215,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..b94543e 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'; @@ -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); @@ -2271,11 +2279,49 @@ 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' && 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; + // 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) if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { 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..53b45c0 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 @@ -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)})`); @@ -814,6 +817,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