The lob is the flick you already have, and a game stops being a death
Three from Oliver. THE LOB. He picked option (c) from the written comparison — "no additional gesture to learn" — and corrected the premise: the zombie does come to the net (racketAI chases to the far service line, the classic CPU to z 9.5). So the forward flick keeps its one meaning, "put it deep behind him", and the game picks the trajectory that actually does that: a smash while he is back, over his head once he is in. oppAtNet() is the whole switch, and it reads the BODY, not O — in the match O is the invisible tennis body and only moves between points, so it would have answered "on his mark" all rally. lobArm 7.0 m is measured, not taste: a lob peaking at lobApex passes a man 7 m from the net at almost exactly CONTACT.maxY, so the shot arms exactly as far out as it can still clear him. The pace is derived from the height, so the same arc comes off the baseline and off mid-court, and SHOT_DEPTH.lob is deeper than deep — at the ordinary deep target the ball passed 2.05 m over him at the edge of the band, which is inside his reach. PACE, SEEN FROM THE STRIKER. "the opponent sees the ball really fast - the player barely sees the speed increase." Measured: the ball is the same ball on both phones, the camera is not — it compresses your own ball into the distance and expands theirs as it arrives. Pace untouched; three channels added on the striker's side: the ghost streak now reads pace (3-8 ghosts, was 3 at every speed), the crack is pitched by it, and a fast ball kicks the camera. The serve's own ladder is 49.2 plain / 50.8-52.4 hard, because serveArc's loft margin takes 10% of the +9 back — widening that is his call, and PROJECT.md records the knob and the risk. THE DEATH MATCH. "each player has 8 blood points but the player explodes for the winner already at 0:40." Two clocks: a game is four points, a life is eight. A won game is only a game now (flash, serve, heal); the burst moved to the death itself, on the dead message, where both phones agree. The summary waits 1.4 s for the gore, because update() freezes the world on G.done and would have hung a still frame of an explosion behind the trophy screen. And bleedO tracks THEIR blood off the pos stream instead of our points-this-game, which reset every game and ignored the heal. 4 new tests, 113 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e07dc74bb
commit
4f4534e272
9 files changed
+355
-38
No files matched your search
+96
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -664,6 +664,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=306"></script>
|
||||
<script type="module" src="src/main.js?v=307"></script>
|
||||
</body>
|
||||
</html>
|
||||
+35
-3
@@ -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;
|
||||
|
||||
+17
-1
@@ -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;
|
||||
|
||||
+57
-6
@@ -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') {
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+19
-10
@@ -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;
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user