diff --git a/PROJECT.md b/PROJECT.md index c3668d5..d538d48 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,98 @@ 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) + +### THE LOB IS THE FLICK YOU ALREADY HAVE — option (c), picked by Oliver after a written comparison + +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). + +**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: + +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. + +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 OPPONENT SEES THE BALL REALLY FAST — THE PLAYER BARELY SEES THE SPEED INCREASE" + +Measured first, because the report has two possible causes and only one of them is a bug. +**The ball is the same ball on both phones** — `netSendShot` copies vx/vy/vz/g verbatim and the +receiver mirrors them, no re-simulation. What differs is the CAMERA: it sits behind your baseline, +so your own ball's screen speed decays into the distance while the same shot expands toward the man +receiving it. That asymmetry cannot be fixed by changing pace. + +The pace ladder, measured: **serve 49.2 plain / 50.8-52.4 with the hard flick** (the +9 m/s +`serveHard` asks for 56-58, and `serveArc`'s net-clearance loop takes 10% of it back in one cut); +**rally 19.2 + 14.4×timing, ×1.42 for a smash at flush timing** — so the smash is a real 42% when +you time it and nothing when you do not, and the hard SERVE is only ever ~3-6%. + +Nothing about the pace was changed. Three feedback channels were added instead, all on the striker's +side, none of them touching a ball's physics: +- **The ghost streak now reads PACE.** It was three ghosts at every speed, so it said fast/not-fast + and nothing else; it is 3 to 8 now, sampled every frame, so a quick ball is both longer and more + spread out. A lob stays clean. +- **The crack is pitched by pace** (`strikeRate`, 0.93 to 1.09 — past a tenth the sample stops + sounding like the same racket). +- **A fast ball kicks the camera** (`strikeKick`, up to ~2.2 px for 0.12 s, YOUR racket only, and + never over a shake already running). + +**Still open, and it is Oliver's call.** If he wants the hard serve to actually BE harder, the knob +is `serveArc`'s loft margin: it charges `0.06 + descent×0.04` (about 0.46 m of extra net clearance +on a fast serve) against a sampling error the sim does not really make any more — the net test in +`update()` already interpolates the crossing height, and semi-implicit Euler's real lag at the tape +measures ~2-17 cm. Cutting that margin gives the hard serve its full +9 back (58 vs 49, a difference +you could see). Not done unasked: a serve into the tape is worse than a slow one, and that margin +exists because balls were clipping it. + +### THE DEATH MATCH: THE BURST IS THE DEATH, AND ONLY BLOOD IS + +Oliver: *"each player has 8 blood points but the player explodes for the winner already at 0:40 — +the winner sees the zombie explode but the player on the other side is not dead yet. this needs to +be aligned."* Two clocks were running side by side: **a game is four points, a life is eight**, and +`matchGameWon` burst the friend's body on the game. Both were true on their own phone and they +disagreed with each other. + +- **A won game is only a game now** (netplay branch of `matchGameWon`): the flash, the serve change + and the heal it pays, then the next point. No burst, no walk-on, no mop beat — there is no roster + body to walk back on in a death match anyway. +- **The burst moved to the death**, in `netMatchWon`, which fires on the `dead` message the dying + phone sends when its own blood runs out. Both sides are then agreeing about the same event. +- **The summary waits `TUNE.netWinBurst` (1.4 s) for the gore to fly.** `update()` freezes the whole + world on `G.done` — no particles — so setting it on the same frame as the burst hangs a still + frame of an explosion behind the trophy screen. Ticked in `tickMatchFall`, like every other beat + in beats.js that has to outlive the frame that started it. +- **The friend's bleeding tracks THEIR blood.** `bleedO` read points-won-this-game, which resets + every game and ignores the heal — a friend two points from death looked unhurt. Their hp now rides + the 10 Hz `pos` stream (one number; a dropped one corrects itself 100 ms later) and the flow reads + it. The old count survives as the fallback for a build that sends none. + ## Session log: THE SERVICE RULES ARRIVE IN THE OTHER MODES (2026-08-22) Three notes from Oliver, two of them fixed here; the third (the lob) is under discussion in OPEN. @@ -3941,7 +4033,10 @@ 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") — asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.** Oliver: +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. + **~~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 likely needs to be discussed."* The shot itself is nearly free: `trySwing` already solves every diff --git a/index.html b/index.html index 9244102..096559e 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/beats.js b/src/beats.js index df9acdd..0ca2104 100644 --- a/src/beats.js +++ b/src/beats.js @@ -499,6 +499,7 @@ export function tickMatchFall(dt) { // 'over' beat (see the section note above). When the boy's time is up the next opponent walks // on with its own intro card, exactly as it used to on the kill frame. const d = G.match; + if (d && d.winT > 0 && (d.winT -= dt) <= 0) { d.winT = 0; return netMatchDone(); } if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) { // Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the // mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and @@ -753,7 +754,22 @@ function matchGameWon(zb, banked = false) { // NETPLAY: the human body has no fall art to play, so the game-winning point bursts him where // he stands (procedural gore is the one sanctioned fake) and the mop beat runs over the gap; // matchOpponent's net branch un-hides him when the beat hands the court back. - if (G.net) { explode(O.x, O.z); G.hideO = true; } + // NETPLAY DEATH MATCH: A GAME IS NOT A DEATH, AND ONLY BLOOD IS (Oliver, 2026-08-22: "each player + // has 8 blood points but the player explodes for the winner already at 0:40 — the winner sees the + // zombie explode but the player on the other side is not dead yet. this needs to be aligned"). + // Two clocks were running: a GAME is four points, a life is eight, so the body burst on this + // phone while the friend was still standing on theirs with half a pool left. The burst moves to + // the only event both phones agree is death — their blood running out, said out loud over the + // wire (netSendDead -> netMatchWon). A won game keeps everything it is worth: the flash, the + // serve, and the heal it pays. Solo is untouched — there the body IS the scoreboard. + if (G.net) { + matchHealBeat(); + d.ending = false; // nothing is walking on: play resumes here + matchReady(); + G.interlude = { introT: 1.2 }; // a breath before the next serve, as ever + G.phase = 'over'; G.overT = 2.2; + return; + } // Everything past the burst waits for it — the heal card, the boy, the walk-on clock. d.afterFall = () => { matchHealBeat(); @@ -839,13 +855,29 @@ function cupWon() { // NETPLAY'S WIN ENDING: the other phone said 'dead' — their player bled out, so this side is the // last one standing. The classic win's whole shape (held summary, gated tap), banked NOT-dead // through the same endRun as every match run; felled = the games that actually burst the body. +// THE FRIEND'S BLOOD RAN OUT — the one event that ends a death match, and now the only one that +// bursts him. It arrives as a message from their phone (they bookkeep their own hp), which is why +// this is the honest place for the burst: both sides are agreeing about the same death. export function netMatchWon() { - if (!G.match) return; + if (!G.match || G.match.winT > 0) return; // already ending const d = G.match; d.ending = false; d.mopT = 0; - G.interlude = false; G.matchFall = null; + G.matchFall = null; ball.active = false; G.flash = null; G.modal = null; G.card = null; + explode(O.x, O.z); G.hideO = true; cheer(4, 1); voice('unbelievable', 0.4); + // ...AND THE SUMMARY WAITS FOR THE GORE. update() freezes the whole world on G.done — no + // particles, no parts — so setting it on the same frame as the burst would hang a still frame of + // an explosion behind the trophy screen. The beat is ticked in tickMatchFall, like every other + // beat in this file that has to outlive the frame that started it. + d.winT = TUNE.netWinBurst; + G.interlude = { introT: TUNE.netWinBurst + 1 }; // nothing gets served over a body coming apart + G.phase = 'over'; G.overT = TUNE.netWinBurst + 1; +} + +function netMatchDone() { + const d = G.match; + G.interlude = false; const games = score.games[0] + score.games[1]; const b = endRun(score.games[0], games, G.splatT, false, MODE_MATCH); G.best = b; G.newBest = b.newBest; diff --git a/src/config.js b/src/config.js index 8944a28..d8f4409 100644 --- a/src/config.js +++ b/src/config.js @@ -74,6 +74,16 @@ 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 @@ -587,6 +597,8 @@ export const TUNE = { // ("still a LOT of unreachable balls"); now line-painting is the late ladder matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer) matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate) + netWinBurst: 1.4, // s the friend's burst plays before the win screen covers it. update() freezes + // the world on G.done, so this beat is the only thing that lets the gore fly. matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN killGush: 3, // prone bleed-out during the kill fall: particles ATTEMPTED per frame (gushProne) // — the body visibly bleeds out on the ground before it bursts @@ -1397,7 +1409,11 @@ 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) -export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 }; +// `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 }; // 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 0210928..d4a4f07 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -16,6 +16,21 @@ import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFau // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. // `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a // shambler's chest, and solving to the ground under one is what made the ball strike the ankles. +// The recorded racket sample, pitched by the pace of the ball that just left it — 20 m/s of rally +// dink at 0.93, a 52 m/s serve at 1.09. Deliberately narrow: past about a tenth either way the +// sample stops sounding like the same racket. Read off the LIVE ball, so it is one call site's +// worth of code for every strike in the game. +const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200)); +// ...and a fast ball KICKS THE CAMERA as it leaves. The third answer to the same report: the trail +// says how fast the ball is, the crack says how hard it was hit, and this says it in the one channel +// the striker cannot miss, because it moves the whole picture rather than something inside it. +// YOUR racket only (the far side's strike is something you watch — the same rule the haptic keeps), +// nothing under 26 m/s, and never over a shake that is already running: a bomb outranks a backhand. +const strikeKick = () => { + const p = Math.min(2.2, (Math.hypot(ball.vx, ball.vz) - 26) / 9); + if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at +}; + export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) { const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T }; @@ -142,6 +157,15 @@ 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'; @@ -156,7 +180,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; + let tx, tz, aimedLane = false, sway = 0, lob = false; 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; @@ -206,14 +230,30 @@ 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); } - const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; + // 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; 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) s *= 1 + TUNE.smashBoost * quality; + 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; + } aimedLane = true; } tx = Math.max(-5, Math.min(5, tx)); @@ -247,7 +287,9 @@ 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 - const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); + // ...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); 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, @@ -273,7 +315,11 @@ function doHit(ch, quality, aim) { // (the landing ring that used to be stamped here confused testers — the umpire calls the ball, // the court stays clean) if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet - sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side + // ...and the CRACK carries the pace (same report as the ball streak): one recorded sample, pitched + // by how fast the ball actually left. It is the striker's cue at the one moment he is looking at + // his own racket rather than at the far court. + sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded racket strike, per side + if (ch.side === 'P') strikeKick(); // CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not // something your hand did, and buzzing for both halves of every rally is the numbing failure mode. if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one @@ -282,6 +328,10 @@ 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 } @@ -515,7 +565,8 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { } } // (spendSlot already hands you back plain balls when the last one goes) - G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side + G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded serve strike, per side + if (sv.side === 'P') strikeKick(); if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit sv.pose = 'serve'; sv.poseT = 0; if (perfect && sv.side === 'P') { diff --git a/src/net.js b/src/net.js index 0452d0f..d91e1b1 100644 --- a/src/net.js +++ b/src/net.js @@ -221,7 +221,13 @@ export function netTick(dt) { posT = 0; // the HOST's stream carries the scoreboard as well (netSync) — one authority, no new message // type, and it only rides the frames where the guest can actually act on it - send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0, + // ...and it carries MY BLOOD. Each phone bookkeeps its own hp (matchPoint), so without this the + // other side has to GUESS how close I am to death — it was reading its own points-this-game + // instead, which resets every game and ignores the heal, so the friend's body showed a wound + // state that had nothing to do with the blood left in it (Oliver, 2026-08-22: "the winner sees + // the zombie explode but the player on the other side is not dead yet"). One number on a stream + // that already exists, ten times a second: a dropped one corrects itself 100 ms later. + send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0, hp: G.hp, ...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) }); } const o = n.opp; diff --git a/src/render.js b/src/render.js index d8df765..80efe3a 100644 --- a/src/render.js +++ b/src/render.js @@ -2567,23 +2567,32 @@ export function render() { const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : (oTour().oApex ?? BOUNCE_IDLE.oApex)); drawBall(bx, by, sv.z); } - // hot-ball ghost streak: a fast rally ball drags three fading ghosts behind it. It gated on - // pow > 0.8 alone, which in practice only the gore slingshot ever reached — tennis tap swings - // live below it, so both tennis modes played without the streak (Oliver's sidequest). PACE now - // qualifies a ball too: a flat serve or a clean winner streaks in every mode, a lob stays clean. + // hot-ball ghost streak: a fast ball drags fading ghosts behind it. It gated on pow > 0.8 alone, + // which in practice only the gore slingshot ever reached — tennis tap swings live below it, so + // both tennis modes played without the streak (Oliver's sidequest). PACE now qualifies a ball + // too: a flat serve or a clean winner streaks in every mode, a lob stays clean. + // ...AND THE STREAK IS HOW LONG THE BALL IS FAST (Oliver, 2026-08-22, after the multiplayer test: + // "when smashing the opponent sees the ball really fast — the player barely sees the speed + // increase"). It was three ghosts at every pace, so it said "fast/not fast" and nothing else — + // while the CAMERA works against the striker, compressing his own ball into the distance as the + // same shot expands toward the man receiving it. Length reads as pace on both phones now, and it + // is the striker's only cue that the ball he just hit is quick. Sampled every frame, so a fast + // ball's ghosts are ALSO further apart: pace shows as reach and as spacing at once. + const bspd = Math.hypot(ball.vx, ball.vz); const hot = ball.active && G.phase === 'rally' && !ball.collected - && ((ball.pow || 0) > 0.8 || Math.hypot(ball.vx, ball.vz) > TUNE.trailSpeed); - if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); } + && ((ball.pow || 0) > 0.8 || bspd > TUNE.trailSpeed); + const ghosts = Math.max(3, Math.min(8, Math.round((bspd - TUNE.trailSpeed) / 6))); + if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 10) ballHist.shift(); } else if (ballHist.length) ballHist.length = 0; if (hot) { - for (let i = 0; i < 3; i++) { - const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break; + for (let i = 0; i < ghosts; i++) { + const h = ballHist[ballHist.length - 2 - i]; if (!h) break; const p = proj(h.x, h.y, h.z); // 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it // vanished entirely — "i see no ball trails" (Oliver, in the tournament) - ctx.globalAlpha = 0.38 - i * 0.11; + ctx.globalAlpha = 0.4 * (1 - i / ghosts); ctx.fillStyle = UI.ball; - ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.9 - i * 0.18)), 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.95 - 0.6 * i / ghosts)), 0, 7); ctx.fill(); } ctx.globalAlpha = 1; } diff --git a/src/zombies.js b/src/zombies.js index 0ed2c59..41de787 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -933,7 +933,15 @@ function gushProne(zb) { // with the body. Same thin-drop discipline as bleed() — more EMISSIONS, never heavier drops. const O_BLEED = [[0.12, 1.55, 0.9], [-0.22, 1.2, 0.8], [0.16, 0.9, 0.7]]; function bleedO() { - const k = Math.min(3, score.pts[0]); // points off them THIS game + // THE BLOOD IS THEIR BLOOD, not our scoreboard (Oliver, 2026-08-22). This read points-this-game, + // which is a different clock from the one that actually kills them: it resets to zero every game, + // it ignores the heal a won game pays them, and it said "unhurt" to a friend two points from + // death. Their phone puts its real hp on the position stream (net.js), so the flow now tracks the + // pool that ends the match. The old count survives as the fallback for a build that sends none. + const o = G.net.opp; + const k = o && o.hp !== undefined + ? Math.min(3, Math.ceil(Math.max(0, G.hpMax - o.hp) * 3 / Math.max(1, G.hpMax))) + : Math.min(3, score.pts[0]); if (!k || G.hideO || parts.length > 120) return; for (const w of O_BLEED.slice(0, k)) { if (Math.random() > 0.14 * k) continue; // ramps: one light nick → gushing at game point diff --git a/test.js b/test.js index 7aba9ef..a8cde4c 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 } = await import('./src/gameplay.js'); +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 { 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 @@ -99,7 +99,7 @@ const { zombies, spawnZombie, pellets, parts, explode } = await import('./src/zo const { killPlayer } = await import('./src/zombies.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const cfg = await import('./src/config.js'); -const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg; +const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg; const statsMod = await import('./src/stats.js'); const audio = await import('./src/audio.js'); const { hapticTest } = await import('./src/haptics.js'); @@ -735,6 +735,91 @@ test('a receiver stands BEHIND his baseline, on both sides of the net', () => { assert.ok(P.z > COURT.L, '...with the server behind his own line'); }); + +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 + // the same ball on both phones (net.js copies the velocities verbatim); what differs is the + // camera, which compresses your own outgoing ball into the distance and expands theirs as it + // comes at you. So the striker gets the pace in three channels he cannot miss: the ghost streak's + // LENGTH (render), the crack's pitch, and this. + restartRun(); G.classic = true; + score.server = 'P'; setupPoint(); + ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0; + G.shake = null; + hitServe(P, 1, null, true); // a flush serve: ~49 m/s + assert.ok(G.shake && G.shake.mag > 0, 'a 49 m/s serve kicks: mag ' + (G.shake && G.shake.mag.toFixed(1))); + const hard = G.shake.mag; + // ...and a slow ball does not. Same call, no timing on it. + score.server = 'P'; setupPoint(); + ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0; + G.shake = null; + hitServe(P, 0.05, null, false); // a badly mistimed one: ~24 m/s + assert.ok(Math.hypot(ball.vx, ball.vz) < 30, 'setup: that one left slowly'); + assert.equal(G.shake, null, 'a slow serve does not kick the camera'); + 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. @@ -1725,25 +1810,35 @@ test('netplay death match: the host\'s pick rebuilds the court on pairing — fr assert.equal(G.modal, null, 'no popup may eat the first remote serve'); }); -test('netplay death match: their lost points read as BLOOD, and the game bursts the body', () => { +test('netplay death match: THE BURST IS THE DEATH — a won game is only a game', () => { + // Oliver, 2026-08-22: "each player has 8 blood points but the player explodes for the winner + // already at 0:40 — the winner sees the zombie explode but the player on the other side is not + // dead yet. this needs to be aligned." Two clocks: a game is four points, a life is eight. freshNetDeath('A'); netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them - assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp'); + assert.equal(score.pts[0], 1, 'the point banks'); assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back'); const blood = G.hp; netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me assert.equal(G.hp, blood - 1, 'their point draws my blood'); - score.pts = [3, 0]; // game point against them: the flow at full ramp - const before = parts.length; - for (let i = 0; i < 30; i++) update(DT); - assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)'); + // THE FLOW READS THEIR BLOOD, off the position stream — not our points-this-game + const onHim = () => parts.filter(q => Math.abs(q.x - O.x) < 1 && Math.abs(q.z - O.z) < 1).length; + netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: G.hpMax }); // (their z 2 mirrors to O.z 21.77... ) + O.x = 0; O.z = 2; // ...so park him by hand: this is bleedO, not the lerp + for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; } + assert.equal(onHim(), 0, 'a friend at full blood does not bleed, whatever our score says'); + netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: 1 }); + for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; } + assert.ok(onHim() > 0, '...and one a point from death bleeds hard'); + score.pts = [3, 0]; netMsg({ t: 'pt', win: 'you' }); // the game-winning point assert.equal(score.games[0], 1, 'game banked'); - assert.deepEqual(score.pts, [0, 0], 'the blood ramp resets with the body'); - assert.equal(G.hideO, true, 'the burst took the body'); - for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT); - assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again'); + assert.equal(G.hideO, false, 'A GAME IS NOT A DEATH: the friend is still standing'); + assert.ok(!G.done, '...and the match is not over'); + for (let i = 0; i < 60 * 6; i++) update(DT); + assert.equal(G.hideO, false, 'still standing a beat later — no burst, no walk-on'); assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game'); + assert.ok(G.phase === 'pretoss' || G.phase === 'over', 'and the court is playable again'); }); test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => { @@ -1765,9 +1860,14 @@ test('netplay death match: bleeding out is said on the wire, and the other phone assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud'); freshNetDeath('A'); // ...and the winner's phone, receiving it score.games[0] = 3; + const before = parts.length; netMsg({ t: 'dead' }); - assert.ok(G.done && G.done.won, 'the last one standing gets the win ending'); - assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body'); + assert.equal(G.hideO, true, 'THE BURST IS THE DEATH: their blood ran out, so the body comes apart'); + assert.ok(parts.length > before, '...and the gore is real'); + assert.ok(!G.done, 'the summary waits — a frozen explosion behind a trophy screen is the bug'); + for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT); + assert.ok(G.done && G.done.won, 'the last one standing gets the win ending, after the burst'); + assert.equal(G.match.felled, 3, 'felled = the games won on the way'); }); test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {