From a42dc6fc81c7dac8dc42f6f6c799fdf68702b834 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 22 Aug 2026 13:13:56 +0200 Subject: [PATCH] The lob is out: a gesture the opponent's position redefines is unpredictable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver, on the build: "all good but the lob. please remove it - this is totally unpredictable." Reverted in full — oppAtNet, farBody, TUNE.lobArm, TUNE.lobApex, SHOT_DEPTH.lob, the branch in doHit and its two tests. The forward flick is the smash it always was. Everything else from that commit stays: the pace feedback (streak, crack, camera kick) and the death match's blood alignment. The reason is worth more than the code, and PROJECT.md carries it as a rule: a gesture whose MEANING depends on where the opponent is standing is unpredictable however well the shot is tuned. It is the same objection input.js already states about the slingshot — the direction you moved is the thing you asked for, no invisible modes — and the written comparison let option (c) past it on the argument that context cannot mis-fire. It can: you do not track where the far body is standing while a ball is coming at you, so one thumb movement produced two shots for reasons you were not watching. The LOB word at contact explained the surprise; it did not prevent it. If it comes back it needs a gesture of its own. The ballistics are solved and are in the history at 4f4534e. 111 tests pass. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 68 ++++++++++++++++++++++++------------------------- index.html | 2 +- src/config.js | 16 +----------- src/gameplay.js | 39 +++------------------------- test.js | 62 +------------------------------------------- 5 files changed, 41 insertions(+), 146 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index d538d48..9273108 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,44 +491,41 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. -## Session log: THE LOB, AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day) +## Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day) -### THE LOB IS THE FLICK YOU ALREADY HAVE — option (c), picked by Oliver after a written comparison +### THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part Oliver: *"when you're close to the net in a duel with another player there should be a gesture allowing to make a ball flying high from below (lupfen)... this likely needs to be discussed."* -Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) context -arms it — and he picked (c): *"i think this is the easiest. no additional gesture to learn."* He also -corrected the premise: *"the opponent should come to the net too — in zombie tennis the zombie often -comes to the net"*, and he is right (racketAI chases up to `COURT.svcFar`, the classic CPU to z 9.5). +Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) let +CONTEXT arm it: the forward flick keeps its one meaning ("put it deep behind him") and the game +picks the trajectory that does that, which with a man at the net is over his head. He picked (c): +*"i think this is the easiest. no additional gesture to learn."* -**The rule: a forward flick means "put it deep behind him", and the game picks the trajectory that -actually does that.** With him on his baseline that is the smash it has always been; with him in, it -is over his head. `oppAtNet()` in gameplay.js is the whole switch. Four things it rests on: +It was built, measured and shipped. It worked exactly as specified — armed at `TUNE.lobArm` 7 m +(the distance at which a lob peaking at `lobApex` still passes over `CONTACT.maxY`), pace derived +from the height so the arc was the same from the baseline and from mid-court, target deeper than +`SHOT_DEPTH.deep`, and the word LOB popped at the contact to say which shot had just been asked for. -1. **It reads the BODY, not O.** In the duel and the match the opponent you play is the racket - zombie; O is the invisible tennis body the serve machinery is driven off and it only moves - between points, so asking O would have answered "on his mark" all rally, in the one mode Oliver - plays most. `farBody()` picks the 'r' zombie whenever there is one and is not netplay. -2. **`TUNE.lobArm` 7.0 m is not taste.** A lob peaking at `lobApex` passes a man 7 m from the net at - almost exactly `CONTACT.maxY` (2.3 m, the height above which nothing can be struck) — so the shot - arms EXACTLY as far out as it can still clear him. Measured, not guessed; the numbers are in the - suite. Move one of the three and re-measure the other two. -3. **The pace is DERIVED from the height, never chosen.** `vy = sqrt(2g(apex - y))` fixes the hang - time and the ground still to cover fixes the speed, so the same arc comes off the baseline (9.5 - m/s) and off mid-court (5.8 m/s). A fixed speed was tried on paper first and is a fine lob from - one place and a flat drive from the other. It also carries NO topspin multiplier — the dive that - makes a hard flat ball land in would pull a lob into the net. -4. **`SHOT_DEPTH.lob` is DEEPER than `deep` (2.0 vs 3.0 m).** At the ordinary deep target the ball - passes only a metre or two behind the man it is going over, and at the far edge of the arm band - that was 2.05 m — inside his reach. The shot is defined by where it comes DOWN. +**His verdict, within the hour: *"all good but the lob. please remove it — this is totally +unpredictable."*** It is reverted in full: `oppAtNet`, `farBody`, `TUNE.lobArm`, `TUNE.lobApex`, +`SHOT_DEPTH.lob`, the branch in `doHit` and its two tests are gone, and the forward flick is the +smash it always was. -Measured, nominal (no scatter), from the baseline: over a man at the far service line it passes at -**3.25 m**, lands 2 m inside his baseline, 2.17 s of hang. Over one at z 9.5 (a real net-rusher): -5.4 m. The AI can and does run it down — a lob is not a free point in tennis either — but it pulls -him off the net, which is what the shot is for. The word `LOB` pops at the contact: (c) buys "no -gesture to learn" by letting context change what the flick means, and something has to say which -shot you just asked for. +**THE LESSON, AND IT IS A RULE NOW: a gesture whose MEANING depends on where the opponent is +standing is unpredictable, however well the shot itself is tuned.** This is the same failure the +input layer already names in its own words — *"No timing window and no invisible mode: the direction +you moved is the thing you asked for"* (the slingshot's sidestep-vs-pull comment). The written +comparison put that objection against option (a) and let (c) past on the argument that context can +never mis-fire. It can: the player does not track where the far body is standing while a ball is +coming at him, so the same thumb movement produced two different shots for reasons he was not +watching. The word at the contact told him WHICH shot he got, but a label after the fact does not +make a gesture predictable — it only explains the surprise. + +**If the lob is ever asked for again, it needs its own gesture** (option (b), the down-then-up hook, +is the only candidate left that cannot be confused with an existing one) — and the mechanics above +are all still correct and are recorded in the git history at 4f4534e, so the shot itself is a +half-hour's work. The question was never the ballistics. ### "THE OPPONENT SEES THE BALL REALLY FAST — THE PLAYER BARELY SEES THE SPEED INCREASE" @@ -4033,9 +4030,12 @@ Nothing here is broken; these are known gaps and unverified risks. from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave. - **boss sounds** — footfall, wind-up, throw, wound, death. Three designs in `BOSS_ART`; decide whether they share one set or get one each before recording anything. -14. ~~**THE LOB ("lupfen")**~~ SHIPPED 2026-08-22 as option (c) — see the session log. The rest of - this entry is the discussion it came out of, kept because the two rejected options are the - argument for the one that shipped. +14. **THE LOB ("lupfen") — asked for, built and CUT on 2026-08-22. Do not rebuild it as option + (c).** Oliver played the context-armed version and removed it the same hour: *"totally + unpredictable."* See the session log for the rule that came out of it. What is left open is the + shot with a gesture OF ITS OWN — option (b) below is the only candidate that cannot be confused + with something already in the language. The ballistics are solved and are in the history at + 4f4534e; do not re-derive them, and do not re-propose (c). **~~asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.~~** Oliver: *"when you're close to the net in a duel with another player there should be a gesture allowing to make a ball flying high from below... I don't know how to implement this gesture wise — this diff --git a/index.html b/index.html index 096559e..01929cd 100644 --- a/index.html +++ b/index.html @@ -664,6 +664,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index d8f4409..e609e51 100644 --- a/src/config.js +++ b/src/config.js @@ -74,16 +74,6 @@ export const TUNE = { smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not // send the ball longer — solveShot still lands it on the same spot — it // gets there flatter and sooner, which is what a smash is. - // THE LOB ("lupfen", Oliver 2026-08-22). Same forward flick as the smash — the game picks which - // shot that gesture means from WHERE THE OTHER MAN IS STANDING, so there is no second gesture to - // learn. He is "in" when he is closer to the net than this, in metres: - lobArm: 7.0, // ...and 7.0 is not a taste number. A lob peaking at lobApex passes a man - // 7 m from the net at almost exactly CONTACT.maxY (2.3 m), so the shot - // arms EXACTLY where it can still go over him. Raise one, raise the other. - lobApex: 6.0, // m the lob peaks at, whatever the distance. The pace is DERIVED from this - // (see doHit): a fixed speed would be a fine lob from the baseline and a - // flat drive from mid-court, because the same speed over half the ground - // is half the hang time. Height is the shot; speed is what it costs. aimError: 2.4, // m of scatter at quality 0 aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the // ball further OUT (the chosen out-risk keeps its exact shape) but only @@ -1409,11 +1399,7 @@ export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 }; export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 }; // shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short) -// `lob` is DEEPER than deep on purpose: a lob that lands where an ordinary drive lands passes only -// a metre or two behind the man it is going over, and a ball that close to him is still inside his -// 2.3 m reach (measured — at SHOT_DEPTH.deep it cleared him by nothing at the far edge of the arm -// band). The shot is defined by where it comes DOWN, not by how high it goes. -export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6, lob: 2.0 }; +export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 }; // how long swing/serve poses hold before returning to idle (s) export const POSE_TIME = 0.38; diff --git a/src/gameplay.js b/src/gameplay.js index d4a4f07..31aa16a 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -157,15 +157,6 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax // 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); -// WHERE THE MAN ACROSS THE NET ACTUALLY IS — and it is not always O. In the duel and the match the -// opponent is the BODY: O is the invisible tennis body the serve machinery is driven off, and it -// only moves between points, so asking O where the opponent is would answer "on his mark" all -// rally. In classic and in netplay (both kinds) O is the opponent, drawn and driven. -const farBody = () => (!G.net && zombies.find(z => z.variant === 'r')) || O; -// HAS HE COME IN? The one question the lob is armed on — see TUNE.lobArm for why 7 m and not taste. -// Exported for the suite and for anything that ever wants to show the player that the shot is live. -export const oppAtNet = () => COURT.netZ - farBody().z < TUNE.lobArm; - 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) const toFar = ch.side === 'P'; @@ -180,7 +171,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, sway = 0, lob = 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; @@ -230,30 +221,14 @@ function doHit(ch, quality, aim) { const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x)); tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound); } - // THE LOB IS THE SAME FLICK, READ AGAINST WHERE HE IS STANDING (Oliver 2026-08-22, "lupfen"). - // Forward flick = "put it deep behind him", and when he has come to the net the trajectory that - // does that is over his head, not through him. No new gesture, nothing to learn, and it cannot - // be mis-fired — with him on his baseline the same flick is the smash it has always been. - lob = ch.side === 'P' && aim.az < 0 && oppAtNet(); - const tzBase = lob ? SHOT_DEPTH.lob - : aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; + 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 // depth and nothing else — the gesture had a direction with no consequence behind it. It buys // PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot // still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather // than longer. A smash you can hit out on a whim would just be the bug above wearing a hat. - if (ch.side === 'P' && aim.az < 0 && !lob) s *= 1 + TUNE.smashBoost * quality; - // PACE DERIVED FROM THE HEIGHT, not chosen: for the lob to peak at lobApex it must leave at - // vy = sqrt(2g(apex - y)), which fixes the hang time, and the hang time over the ground still - // to cover fixes the speed. Same arc from the baseline and from mid-court — a fixed speed - // would be a lob from one and a flat drive from the other. Timing still prices it: `tz` above - // already carries the same scatter every aimed ball does. - if (lob) { - const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, TUNE.lobApex - ball.y)); - const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity; - s = Math.hypot(tx - ball.x, tz - ball.z) / hang; - } + if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality; aimedLane = true; } tx = Math.max(-5, Math.min(5, tx)); @@ -287,9 +262,7 @@ function doHit(ch, quality, aim) { } // topspin: quality shots dive under extra effective gravity, which is what lets real // pace arc over the net and still land in — the core tennis trade-off - // ...and a lob carries NO topspin: the dive multiplier is what makes a hard flat ball land in, and - // on a shot whose whole point is hang time it would just pull the arc down into the net. - const g = lob ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor); + const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); // low contact → trade pace for loft until the net is cleared; good timing gets a // comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's, @@ -328,10 +301,6 @@ function doHit(ch, quality, aim) { const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact if (w) popText(ball.x, ball.y, ball.z, w); } - // ...and the lob NAMES ITSELF. (c) buys "no gesture to learn" by letting context change what the - // flick means, and the price of that is that nothing on screen says which shot you just asked - // for. One word at the contact, in the praise words' own slot, is what pays it back. - if (lob) popText(ball.x, ball.y + 0.3, ball.z, 'LOB'); ch.pose = 'swing'; ch.poseT = 0; if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit } diff --git a/test.js b/test.js index a8cde4c..586a7db 100644 --- a/test.js +++ b/test.js @@ -89,7 +89,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, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt, oppAtNet } = await import('./src/gameplay.js'); +const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = 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 @@ -736,49 +736,6 @@ test('a receiver stands BEHIND his baseline, on both sides of the net', () => { }); -test('THE LOB: the forward flick goes OVER him once he has come in', () => { - // Oliver, 2026-08-22: "when you're close to the net in a duel there should be a gesture allowing - // to make a ball flying high from below." Option (c): no new gesture — the flick that already - // means "deep" picks the trajectory that actually puts the ball deep, which with a man at the net - // is over his head. The contract: unchanged while he is back, unhittable while he is in. - const shot = (oz, swipe) => { - const rnd = Math.random; Math.random = () => 0.5; // no scatter: the NOMINAL arc - startMatch(); G.interlude.introT = 0; G.phase = 'rally'; - const zb = oppOf(); zb.x = 0; zb.z = oz; - P.x = 0; P.z = 23; P.swingT = -1; - ball.active = true; ball.lastHit = 'O'; ball.bounces = 1; - ball.x = 0; ball.z = 22.6; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1; ball.g = TUNE.gravity; - G.swipe = swipe; keyPressed('Space'); - for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT); - G.swipe = null; Math.random = rnd; - assert.equal(ball.lastHit, 'P', 'setup: he got a racket on it'); - let y = ball.y, z = ball.z, vy = ball.vy, apex = y, over = null; - const g = ball.g, dt = 1 / 240; - for (let i = 0; i < 4000 && y > 0; i++) { - const z0 = z; vy -= g * dt; y += vy * dt; z += ball.vz * dt; - apex = Math.max(apex, y); - if (over === null && z0 > zb.z && z <= zb.z) over = y; // height as it passes him - } - return { s: Math.hypot(ball.vx, ball.vz), apex, over, land: z }; - }; - const UP = { ax: 0, az: -1, fx: 0, fy: -1, mag: 90 }; - const back = shot(1.5, UP); - assert.ok(back.apex < 2.5, 'he is on his baseline: the forward flick is the DRIVE it always was, apex ' + back.apex.toFixed(1)); - assert.ok(back.s > 19, '...at drive pace: ' + back.s.toFixed(1) + ' m/s'); - // ...and once he steps in, the SAME gesture lifts it over him. Two positions: the far service - // line (as far back as the racket zombie's chase ever brings it) and the edge of the arm band. - for (const oz of [9.5, 5.5, COURT.netZ - TUNE.lobArm + 0.05]) { - const l = shot(oz, UP); - assert.ok(Math.abs(l.apex - TUNE.lobApex) < 0.2, 'it peaks at lobApex from anywhere: ' + l.apex.toFixed(1)); - assert.ok(l.over > CONTACT.maxY, 'it passes him at ' + l.over.toFixed(2) + 'm, over his ' + CONTACT.maxY + 'm reach (z ' + oz + ')'); - assert.ok(l.land > 0.3 && l.land < COURT.netZ, '...and comes down IN, at z ' + l.land.toFixed(1)); - assert.ok(l.s < back.s * 0.6, '...off a much slower ball: ' + l.s.toFixed(1) + ' m/s'); - } - // the sideways pass is untouched — only the FORWARD flick changes meaning - const pass = shot(5.5, { ax: 1, az: 0, fx: 1, fy: 0, mag: 90 }); - assert.ok(pass.apex < 2.5 && pass.s > 19, 'a sideways flick with him at the net is still a drive'); -}); - test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => { // Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the // opponent sees the ball really fast — the player barely sees the speed increase." The ball IS @@ -803,23 +760,6 @@ test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => { assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')'); }); -test('THE LOB arms off the BODY, not off the invisible tennis opponent', () => { - // In the match O is the body the serve machinery is driven off and it only moves between points; - // the opponent you actually play is the racket zombie. Asking O would answer "on his mark" all - // rally, so the lob would arm at the wrong times in the one mode Oliver plays most. - startMatch(); - const zb = oppOf(); - O.z = 10.5; // O parked at the net, the body back on its mark - zb.z = 1.5; - assert.ok(!oppAtNet(), 'the BODY is back, so nothing is armed'); - zb.z = 6.0; - assert.ok(oppAtNet(), '...and the body coming in arms it'); - // classic tennis has no body: there O IS the opponent - restartRun(); G.classic = true; - O.z = 1.5; assert.ok(!oppAtNet(), 'classic: O back, not armed'); - O.z = 9.5; assert.ok(oppAtNet(), 'classic: O in, armed'); -}); - test('the match brings its own legs: auto-run works even where autoAssist is 0', () => { // The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a // racket — the flight recorder showed a swing in every lost point and an x that never moved.