Compare commits

Author SHA1 Message Date
wuwiandClaude Opus 5.5 0576f24614 Flatten the ladder: cap how wide an opponent paints
Oliver: from the Runner on the zombie game is very hard. Measured with
the ring bot: the win rate fell off a cliff wherever an opponent's
width crossed ~3.3 m, the auto-run's reach from the centre (at the
Steward, and again at the Seller), and from there every line could be
painted. Legs and flub decay moved nothing.

Every match opponent's spread is now capped at 3.0 m (matchWideMax),
and the put-away chance runs 0.15 to 0.8 instead of 0.2 to 0.85.
Level 10: the Runner goes from 44% to 68% of points, the Champion from
24% to 33%, with a steady slope between them. New test pins the cap.
v387.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:02:30 +02:00
wuwiandClaude Opus 5.5 c8d43fbe15 Note the story mode as live on main (v386, docs only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:42:52 +02:00
wuwiandClaude Opus 5.5 2c9c7b84fd Stop story bosses' rush at the net
Oliver: the strawberry seller as act I's first boss is way too hard,
"or it should not cross the net". His charge is the one boss move
exempt from the court band: it runs to a strike line in front of you,
and contact is death. In the story every boss's rush now stops a metre
short of the net and withdraws as before, so the threat is kept and the
reach is not. The Physio in act IV is held the same way. Only the
Champion's MATCH POINT rush in act V stays, because it is the finale.
New test. v385, staging only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:36:18 +02:00
wuwiandClaude Opus 5.5 16d0532f97 Story after Oliver's first test: metal, a straight prologue, 2x3 chapters
- The gore bed is "Dystopian Survival", made loopable: the last ten
  seconds cross-faded into the first ten, 203.5 s, as MP3 at 128k (the
  decoder strips the encoder's delay, so the seam has no gap). High-pass
  at 40 Hz instead of 800, level matched to the menu theme.
- The prologue no longer drops into gore for a moment: after the turn,
  act I's card and cup take over directly.
- RESET STORY under the build stamp (two taps; test only). It resets
  the story, league, cups and title, keeps the level, and pushes at once
  so the mirror cannot hand the old act back.
- The story screens have sound: a whoosh and a boom for the act card,
  applause and the rack's own click as each weapon lands, applause and
  a pock per arcade door on the act's end.
- Chapters are six games: mid-boss after three, the horde after four,
  the act boss after six.

v384, staging only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:12:52 +02:00
wuwiandClaude Opus 5.5 9e6d566c08 Story phase 5, prepared: the gore bed is one switch for the metal track
config.GORE_BED names the track that plays while the dead are being
killed (gore and every story chapter, from the transformation's
run-off on) and its loop length and level. When Oliver has the metal
track, it goes into assets/audio and these three values change; nothing
else does. v383, staging only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:23:22 +02:00
wuwiandClaude Opus 5.5 789bf41a9a Story phase 4: help when stuck, the arcade opens with the story
- From the third attempt at a checkpoint, the death screen offers TRY
  AGAIN · WITH HELP, at most two tiers, said openly. In the cup the help
  is fresher legs and a longer swing; in gore, one more heart and a
  slower director and horde. Every checkpoint passed resets the offer.
- The arcade shows only what the story has opened. Training and Classic
  open after act I, the Climb after II, Splatter Gore after III, and
  hosting a friend after IV (joining stays free). Locked doors are shown
  greyed with the act that opens them.
- The non-story endings point at the story instead of the cup.
- Store switches for the free tier (act I's card points at the full
  game) and a premium arcade, both off.
- The prologue's HUD no longer overlaps the kill pips.

PROJECT.md logs phases 1-4. One new test. v382, staging only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:22:25 +02:00
wuwiandClaude Opus 5.5 ce27bbc48b Story phase 3: the act card, the ceremony with its loot, the act's end
Three animated screens in the storyboard's look, built in the page over
a paused world:
- The act title card: the act's court pans in behind ACT n, its league,
  its court and its goal; it closes itself after three seconds or on a
  tap.
- The cup's ceremony: YOU CLEARED THE COURT, the rounds and the run's
  xp, then the act's weapons drop into a rack one by one ("…and gained
  these for the hordes to come"), and the chapter preview with the
  mid-boss named and game 10 still "???" until the transformation.
- The act card: what the act cost (bodies, bosses, deaths), what it
  opened in the arcade, and the next act's court; CONTINUE goes straight
  on, THE CLUBHOUSE goes home.

The checkpoint keeps its in-game banner. Headless, every screen falls
back to the plain card it replaced. v381, staging only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:18:00 +02:00
wuwiandClaude Opus 5.5 ff63c832cc Story phase 2: the transformation and the prologue
At match point of an act's cup final the beaten finalist does not fall.
The ceremony lands (the loot, still a plain card), then the applause is
cut, the camera closes in, and on the turn he bursts and the act's boss
stands where he stood, growing into the frame with his name and
"GAME 10". He runs out of the picture to the left while the stands empty
onto the court, and game 1 of the chapter begins without a cut: the
crowd that came down is its first wave. The interlude holds serving,
spawns and the boss's AI through the scene.

The prologue is one game of tennis against a living opponent with the
old transformation exactly as written; when it hands over, act I's cup
takes the court. Two new tests. v380, staging only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:13:34 +02:00
wuwiandClaude Opus 5.5 75b450f105 Story phase 1: the spine — acts, cups, chapters, checkpoints
The front door now continues the story. An act is its league cup, whose
final is the boss's own body (acts II and IV play their band out of
ladder order for that), then a ten-game gore chapter on the act's court.
The chapter has no duel at all, takes the act's weapon stock into the
rack, meets a mid-boss after game 5 (the checkpoint: restock and full
blood) and the act boss after game 10, then the act card. Death is not
permadeath: it counts the attempt, banks nothing on the boards, and
TRY AGAIN restarts from the checkpoint the profile holds. Barrels hold
what the act has earned. The chapter plays the arcade's games
base+1..base+10 so every director curve keeps its slope.

Profiles from before the story start it from the top (league, cups and
champion reset; level kept). The server mirror stores the act and
checkpoint and only ever moves them forward.

Plain cards stand in for the screens (phase 3) and the transformation
(phase 2). Six new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:10:09 +02:00
wuwiandClaude Opus 5.5 5e1c6abba4 Plan the story mode in five phases
Oliver settled the last questions: a prologue with the old
transformation, the racket zombie as an ordinary body in story chapters,
endless arcade gore keeps its duel, and the arcade unlock order. The
code survey's constraints are recorded: the cup runs in splatter under
an interlude, restartRun wipes G, runGames counts through the cup,
bosses are keyed on chapters, duels fire from two triggers, and cupWon
freezes the world. Phases: the spine, the transformation and prologue,
the screens, help/arcade/unlocks, then metal. v379 (docs only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:47:55 +02:00
wuwiandClaude Opus 5.5 f00b9d9fb2 Record the agreed story mode plan
Five acts, one per league and court: the league cup, the beaten finalist
growing into the act boss and running off, then a ten-game gore chapter
with a mid-boss checkpoint. No duel in the story, healing and restock
after the mid-boss, the act as the saved unit with three checkpoints,
help after the third failed attempt, arcade limited to unlocked modes,
metal in gore only (track pending). Nothing built yet; the storyboard
link and the open questions are in PROJECT.md. v378 (docs only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:39:10 +02:00
wuwiandClaude Opus 5.5 ba8d13a867 Make the double tap a stance that holds the net
Oliver on v376: "wenn man doppeltippt, geht er minimal vor". His own
ball's flight covered 4-9 m of the 10 to the net. His next ball was met
from mid-court, outside the net zone, and the recovery after that volley
walked him straight back to the baseline.

The double tap now sets a stance (P.post) that lasts until the next
double tap, a lob he has to run down, or the end of the point. Every
recovery heads for the net at full pace, and every incoming ball is
planned as a net man's. The 'back' stance clears once he is behind the
net zone, so short balls still pull him in.

Measured: contacts while committed are ~35% at the net and ~40% around
the service line, and he no longer falls back. The base ladder is
unchanged. New test; v377.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:56 +02:00
wuwiandClaude Opus 5.5 48d1069e40 Make the run to the net and back a double tap
Oliver: "das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp
ausgelöst werden". It replaces v374's flick-length rule: the long
up-flick is only the deep ball again, the long down-flick only the lob.

While his own ball flies there is nothing to hit, so a tap there is not
a swing. Two inside 0.35 s take him in to the net from the back, or back
from the net. "TO THE NET" or "BACK" pops under him. An incoming ball
keeps every tap a swing, so the two meanings never share a moment. After
his own serve it is serve and volley. The short-ball approach stays
automatic.

Measured: the base ladder is unchanged, and following the ball in still
pays against pushers and costs against big hitters. Three tests replace
v374's two; v376.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:30:38 +02:00
wuwiandClaude Opus 5.5 bb72a01a3e Stop the auto-run shaking while it chases a ball
Oliver: "wenn der Autoplay den Ball versucht zu bekommen, zittert der
Player schnell hin und her". Measured: the run's target flipped by
metres every other frame. intercept samples the flight every 10 ms
against a 16.7 ms frame, so any candidate on a margin (a low bounce
peaking on the 0.45 m floor, a point on the back boundary, a dying drop)
passed one frame and failed the next. Frames with no candidate sent him
home and back. 9-62 reversals inside 200 ms per five minutes.

The chase now latches its plan: the flight is fixed at the strike, so
the first point stays the answer while his legs still make it in time,
net of any time he stood (split-step, heels). Reach is judged from where
the chase began, and the target eases with a start/stop hysteresis.
After: no reversals at any rung, timing unchanged. He wastes no steps,
so he is a little stronger, mostly at the bottom of the ladder. New
test; v375.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:15:25 +02:00
wuwiandClaude Opus 5.5 599d5b0506 Let the player take the net and come back, and smash a lob he gets under
Oliver: "Steuerung ans Netz gehen", option (1), and "einen Lob vom Gegner
als Volley annehmen soll derb schmettern".

- A long up-flick (90 px or more) is the attack: the deep ball, and he
  follows it in. A long down-flick is the lob, and he goes back behind
  it. Short flicks keep the deep ball / the drop and leave him where he
  is. Length only, never context.
- The net costs him what it costs the CPU: he split-steps (netReact)
  before reading the pass, and a CPU facing a net man passes at the
  line. Without both, the attacker won 78-95% of his net points.
- The smash: a ball taken out of the air at 1.9-2.5 m is struck harder
  and to the open court, read late by the opponent, and pops "SMASH!".
  It wins ~80% of its points.
- Lobbed at the net, he decides once whether he can get under it. If he
  can, he smashes. If not, he runs it down and only blocks it back, even
  if he meets it in the air. Re-deciding every frame smashed every lob.

Measured at level 26: the base ladder is unchanged. Attacking pays
against pushers and touch players and costs against big hitters,
slicers and rushers. Three new tests; v374.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:05:59 +02:00
wuwiandClaude Opus 5.5 4406aeafdb Let an early tap be a weak ball, and let the CPU put a sitter away
Step 4 (b), Oliver's pick. v372 measured why the ladder could not climb:
nearly every lost point was the player's own early whiff, the CPU barely
missed, and the auto-run reached everything, so no opponent dial decided
a point.

- The follow-through: a groundstroke swing meets a bounced ball up to
  60 ms after its window at q 0.05. The early whiff edge goes from ~70 ms
  back to ~130 ms, where the volley window had it before step 1.
- A sitter (a player ball under q 0.5) is put away with a chance that is
  the ladder (0.2 at the bottom of the Climb, 0.85 at the Champion, by
  difficulty in classic): clean, at the line, bounded by the rung's width.
- The player is on his heels for 0.5 s after a put-away of his own sitter.
  Without it the auto-run covered the whole court and no put-away won.
- Fixed a step-2 regression this surfaced: the drop's dig-out cap fired on
  every slice drive, so every low contact against the slicer was a sitter.

Measured at level 26: the Climb now slopes from 95% of points at the
Shambler to 28-39% at the Champion. The net rushers are still easier than
their neighbours. The log names put-aways. Three new tests; v373.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:25:49 +02:00
wuwiandClaude Opus 5.5 5dd5d71c33 Keep the praise word on screen, and take the cliff out of the groundstroke
Oliver on v371: the praise word is cut off on the left on the iPhone. It
is centred under the player's feet, so out wide it ran past the edge;
every pop is now clamped to the canvas by its measured width.

Step 4, measured first. The rally sim gets a human bot (taps on the timed
ring with a timing spread), a Climb mode and a lost-point breakdown. At
level 26 the ladder falls over six rungs and then goes flat: nearly every
lost point is the player's own early whiff, the CPU barely ever misses,
and the auto-run reaches everything, so no opponent dial decides points.
A quality ramp, a follow-through and CPU put-aways were each measured
and reverted; the options go to Oliver in PROJECT.md.

Shipped: in the rally modes q no longer drops from 1 to 0.43 one ms past
PERFECT; it falls smoothly to the window's edge (TUNE.qSeamless). A tap
30 ms early is q 0.86 instead of 0.61. Same window, same whiff edge. v372.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:01:24 +02:00
wuwiandClaude Opus 5.5 d561050af8 Steady the gold ring, and move the praise word off the ball
Oliver: the target ring twitches sometimes, and "shot of the day",
"textbook" etc. cover the ball. The timed ring re-solved every frame and a
small shift in the auto-run's target moved its moment by up to 158 ms in a
frame; the drawn cue now counts down on its own clock and eases toward
each solve (p99 jump 100 -> 17 ms). The rally's praise word rose straight
into the struck ball's path (and a serve's sat on the far bounce, where the
return comes back); it now lands under the player, behind his feet, and
stays put. Same moment, out of the way. v371.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:52:34 +02:00
wuwiandClaude Opus 5.5 8e4b298d92 Keep the auto-run's target where the player is allowed to stand
Oliver's v369 log ("vieeel besser") showed two misses with the auto-run
heading to x 6.5 and 8.7, far outside the frame he is held in. Since the
dip ends at the bounce, a hard cross-court topspin ball drifts sideways
long enough that its comfort point lies off the court; intercept had no
lateral bound, and its last-resort target was never checked at all
(headless: x 14.8). Candidates now respect xLimit — a kicking ball is
taken earlier, on the rise — and the final target is clamped. v370.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:46:53 +02:00
wuwiandClaude Opus 5.5 e25047ed0c End a shot's topspin dip at the bounce, so a kicking ball kicks up
A shot's extra gravity is its dive in flight, but it was carried through
the bounce: heavy topspin skidded away low and Oliver met nearly every
ball 0-25 cm off the grass. After its bounce a ball now falls under plain
gravity (tennis modes only), and every planner that rolls a bounce
forward switches at the same moment. The higher bounces pushed the
auto-run into volleys, so it now takes a kicking ball at the top of its
bounce, up to chest height. Median contact rises from 0.42 to 1.13 m at
the bottom of the ladder. v369.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:38:01 +02:00
wuwiandClaude Opus 5.5 fe090f2fba Stop back-dating flicks, and give the half-volley rescue the right deadline
Oliver's v367 log: every 62-64 ms flick landed 76-92 ms early and a 97 ms
one whiffed — v366's touch-down timing for flicks was wrong; a flick is
timed by its swipe (flickLead now 0, the age is still logged). The
early-swing half-volley rescue checked the bounce against the long volley
window and then expired on the bounce frame; it now uses the shorter
groundstroke window. The log also records where he stood when the
opponent struck and where the auto-run was taking him on a miss. v368.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:13:08 +02:00
wuwiandClaude Opus 5.5 dfdb699f9d Stop calling "passed you" while the ball is still on its way to him
Oliver's v366 log: a LOST "passed you" with no swing. The match gave the
CPU the point the moment its ball crossed 0.3 m behind the baseline — a
line from when the player stood inside it. Since step 1 he waits up to
2.2 m behind the line for deep balls, and a ball going by ~1.5 m beside
him only enters his reach just in front of him, so the point was called
before it was ever playable. In the match "past you" now means past his
reach. The swing log's hits also say whether the timing was early or
late. v367.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:03:30 +02:00
wuwiandClaude Opus 5.5 5bbde720ad Time flicks from touch-down, log the opponent's shots, save early half-volleys
From Oliver's first swing log (cup vs the Brute): 13 of 22 hits were
perfect, 9 badly early or late — mostly forward-right flicks — and the
weak balls became seven "passed you". Every hit was a flick, and flicks
still swung only at the flickMin crossing while the timed ring says "touch
now": they are now timed from touch-down too (capped at flickLead 0.1 s).

The log gains its other half: a THEM line per opponent shot (kind, pace,
where it lands relative to you, coming in) and "never swung" judged per
incoming ball instead of once a point. And an early swing on a low ball
digs it out as a capped half-volley instead of whiffing. v366.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:48:51 +02:00
wuwiandClaude Opus 5.5 d110e12321 Bring the swing log to every rally mode, and time the gold ring
Oliver wants to hand over a log of why he missed balls. It existed behind
a scoreboard tap but was gated on G.mode === 'tennis', so the Climb, the
cup and training (which run as 'splatter') logged nothing. It now writes
in every rally mode: hits with shot, tap age and any quality cap, misses
with the new let-it-bounce reason, and a WON/LOST line naming why each
point went. Tapping the open log copies all 200 entries with a header.

Its first catch: the gold ring went gold at a distance, which is too
early for the slow balls steps 1-3 brought in. It is now timed off the
ball's real path to his reach (against where his run is taking him):
gold means touch now. ringTime 0 restores the old ring. v365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:41:05 +02:00
wuwiandClaude Opus 5.5 180288ef8f Give every opponent a style, and split the soft ball into drop and lob
Step 2 of the rally rework: eight styles as dials on the opponent rows —
pusher, retriever, slicer, big hitter, baseliner, touch, net rusher,
all-court — read where the CPU chooses its shot (moonball, drop, lob,
spin), plans its approach and serves (serve and volley sprints in). The
walk-on names the style in one line so the player can plan against it.
A player lobbed at the net now backpedals and blocks, like the CPU.

Oliver on v363: the down-flick "always felt like a lob". The length is read
at contact and a thumb has usually travelled on; on the continuum even
50 px floated the ball. Below dropMax (90 px) it is now the drop, above it
the lob. Also un-flakes the machine-squeak test (seed 31337). v364.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:25:52 +02:00
wuwiandClaude Opus 5.5 72760a9d1e Make the down-flick the soft ball: a short one drops, a long one lobs
Step 3 of the rally rework, Oliver's pick: in rally modes the down-flick
is soft, always, and its length is how far — a dying drop just over the
net at one end, an attacking lob over a net man at the other. One gesture,
one meaning; not the context-armed lob that was cut.

Ballistics from the cut lob (pace derived from the apex); backspin lives
on ball.spin and every bounce planner reads it, netplay sends it. Against
the CPU it was returned ~100% until the real-tennis reasons went in: soft
balls are read late (disguise), a net man reacts to a pass and backpedals
from a lob, and a lobbed or dug-out reply is a weak block. Four new tests;
v363.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:17:25 +02:00
wuwiandClaude Opus 5.5 f9b0dd2efb Time a tap from touch-down, and give the opponents' legs a knee
Oliver after step 1: much harder, stuck at the Steward, "trotz gutem
Timing nicht mehr bekommen". A tap only proves it is a tap when the
finger lifts, so the swing started 100-150 ms after he meant it; the old
volley window hid that, step 1's groundstrokes did not. A tap made on the
gold ring fell from q 0.82 to 0.09 (and a third were missed at 150 ms),
murky balls landed short and the opponent came in. The swing clock now
starts at the touch's age (rally modes only); flicks keep their crossing.

The opponents' legs had no ceiling: 7.3 m/s at the Runner, 15.7 at the
Champion against the player's ~6.2. Past legKnee (6 m/s) only 15% of the
growth survives, monotone, so the ladder order holds; champion ~7.5. v362.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:06:08 +02:00
wuwiandClaude Opus 5.5 4e7c816a25 Give the rally its length back: deep balls, baseliners, approaches off short balls
Every match had collapsed into serve, both at the net, reflex volleys at
~0.7 s per shot. The rally ball landed on the service line by definition,
the match CPU stood on the bounce and volleyed 90-100% of everything, and
the auto-run chased the earliest contact.

Rally modes only (classic + every match; gore and the duel untouched):
rally balls land deep and mistiming only shortens them (no new long outs);
each shot latches the receiver's plan as it leaves the racket — from the
back he lets it bounce and meets it falling, up to 2.2 m behind the line,
a short ball pulls him in and he follows it to the net, nobody approaches
a man already there, and a volley below the tape is a weak block. Measured:
contacts 10.5-13.7 m from the net, back-court volleys 0-8%, 0.8-1.0 s per
baseline shot. Three new tests pin the shape; v361.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:55:05 +02:00
wuwiandClaude Opus 5.5 f33d46366a Diagnose why every rally ends in a net duel
Oliver: every match converges on serve, both at the net, reflex volleys,
won by a wrong-foot. Measured headless: rally balls land on the service
line by definition, the match CPU stands on the bounce point and volleys
90-100% of everything from opponent 6 up, and the auto-run always picks
the earliest contact. Diagnosis, research and a four-step plan go into
PROJECT.md (awaiting his go); tools/rally-sim.mjs is the gauge. Fallback
tag rally-baseline-v360 marks the shipped feel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:32:36 +02:00
18 changed files with 3647 additions and 185 deletions

No files matched your search

+949
View File
@@ -491,6 +491,306 @@ 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 `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. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## THE STORY — the modes become one storyline (2026-10-15, AGREED in discussion; NOTHING BUILT YET)
Oliver: *"wie wäre es, wenn wir beide verbinden und eine Hauptstory daraus spinnen … eine Spielprogression,
die die Modi abwechselt"*. Storyboard (private artifact, v2):
https://claude.ai/artifact/4BVqWW89PLMcymYkuRAxv2. **This supersedes THE SEASON's decision 1 and 3 and the
LEAGUES rule "nothing playable today gets locked"** (Oliver: "jupp"). The Season's screens (front door,
record, promotion screen) are the material the story is built from.
### What the code actually had on 2026-10-15
- The tennis-to-zombie transition (`startTransition`, ~8 s) is dead code in normal play. Since
2026-08-15 gore opens straight in the gore (`startGore`); only `forceZombies` still plays it.
- Gore has **10 boss designs**: 9 in rotation, plus THE CHAMPION every 10th chapter (game 100, three
phases). After that it runs endless.
- Every boss is a ladder body made big: KEEPER → THE GROUNDSKEEPER, MASCOT → THE SPONSOR MASCOT, COACH →
THE HEAD COACH, and so on (sprites checked side by side).
- Cup rounds walk the league band bottom up (`cupRoundsFor`), so the final is the band's top rung.
### Decisions, settled with Oliver 2026-10-15
1. **THE STORY REPLACES THE MAIN PATH.** Five acts = five leagues = five courts. Each act is:
1. the league cup (zombie tennis), then
2. THE TRANSFORMATION, then
3. a gore chapter of **10 games**, then
4. the act card.
The testteam starts from zero, so that the whole story gets played.
2. **One chapter per act, with a MID-BOSS at game 5** (option A). The ten bosses:
| act | court / league | mid-boss (game 5) | boss (game 10) | cup final |
|---|---|---|---|---|
| I | Terre Rouge / Graveyard Shift | Strawberry Seller | Groundskeeper | Keeper |
| II | The Slab / Club Staff | Stringer | Sponsor Mascot | Mascot (*) |
| III | Centre Court / Officials | Line Judge | Chair Umpire | Umpire |
| IV | Night Court / Coaching Staff | Physio | Head Coach | Coach (*) |
| V | Members' Ground / Honours Board | Director | THE CHAMPION | Champion |
(*) **The cup final is always the act boss's body.** In acts II and IV the story cup reorders the band
for that (Runner and Club Pro play earlier rounds). The ladder itself is not reordered: the ramp test
and the Climb depend on it.
3. **THE TRANSFORMATION (Oliver's idea):** at match point of the final the beaten finalist grows into the
much bigger act boss (same character, boss sprite), and runs out of the frame. The crowd floods the
court and game 1 begins **without a cut**. Built on `startTransition`'s timeline (hitstop, flash,
zoom). The boss's name slams with "GAME 10". In gore it is the rematch.
4. **NO DUEL IN THE STORY.**
- Weapons: the starting stock is the cup's loot, the barrels supply more, and the rack refills after
the mid-boss.
- Healing (the duel was the ONLY heal for `G.hp`): after the mid-boss, and full at every act start.
- Open: what the racket zombie is in story chapters.
5. **SECTION = ACT, with checkpoints inside** ("Akt als Rahmen mit Speicherpunkt").
- The act is the unit that is saved between sessions.
- Death or elimination restarts from the last of three checkpoints: the cup start, the chapter start
(after the transformation), or after the mid-boss.
- No permadeath in the story.
6. **HELP WHEN STUCK:** from the 3rd failed attempt at the same checkpoint, one assist tier is offered,
at most two. It is said openly on the death screen, and the player may decline it. Tennis gets the
peer-gift shape (legs, window). Gore gets +1 heart and a slower horde until the next checkpoint.
7. **SCREENS, NOT VHS:** in-game animated interstitials.
- The act title card.
- The cup-won/loot screen: "You cleared the court … and gained these for the hordes to come".
Stats count up, the weapons drop into the rack one by one, and the chapter preview keeps game 10
as "???" until the transformation.
- The checkpoint banner: RESTOCKED, FULL HEALTH.
- The death screen with the retry point.
- The act card (the promotion screen's pattern).
- A silent build: no narrator.
8. **ARCADE** shows only modes the story has unlocked, possibly premium-only. THE CLIMB stays as an
arcade mode. The proposed unlock order is in the storyboard (not yet agreed).
9. **METAL IN GORE ONLY.** The existing beds do not fit. Oliver is researching a track; parked until he
brings one. Entry point: the transformation.
10. **A PROLOGUE**, once, before Act I: a classic match against a LIVING opponent and the old transformation
unchanged (the living man turns), which tells the outbreak. Classic tennis gets its story role.
11. **THE RACKET ZOMBIE** stays in story chapters as an ordinary opponent that returns balls, with no duel
behind it.
12. **ENDLESS GORE (arcade) keeps its duel economy unchanged.** Only the story drops the duel.
13. **ARCADE UNLOCK ORDER** (as in the storyboard):
- Act I: Training and Classic Tennis (the free tier ends here, on a win);
- Act II: The Climb;
- Act III: Splatter Gore (endless);
- Act IV: Vs a Friend, hosting;
- Act V: Members' Night, the boss marathon (option C, later).
Each act also unlocks its court for all modes, as the leagues already do. The weapon pool grows per act:
- I: bomb, triple, fire;
- II: gun, beacon;
- III: laser;
- IV: flamer, rocket;
- V: double tins.
All decided (Oliver, 2026-10-15). Next: the phase plan, written here before any gameplay changes.
### What the code forces (survey of 2026-10-15, before building)
- **The cup does not run in `'tennis'`.** It runs in `'splatter'` under a permanent `G.interlude`; O is
invisible and the opponent is a zombie `'r'` with a `fam`. `startTransition` is keyed on O and on mode
`'tennis'`, so the act transformation is a NEW variant on the `'r'` body. The prologue can use the old
one unchanged, because it IS a classic match.
- **`restartRun` wipes all of `G`.** Checkpoint state (act, checkpoint, rack snapshot, attempts) must live in
the profile, the way the promotion and the last run already survive it.
- **The cup's games count into `G.runGames`.** A chapter must start at game 0. Spawn ramps, `barrelGap`,
`BARREL_WEAPONS.from` and racket hp all read it.
- **Bosses are keyed on chapter numbers.** `G.bossDue = floor(runGames / 10)`, `bossArt(ch)`, and hp/scale
grow with the chapter. The story needs a design-indexed spawn: a `BOSS_ART` row plus an act strength,
with a game-5 hook in `splatPoint`.
- **Duels fire from TWO places:** the schedule (`duelDue`), and an unscheduled lucky-clear trigger in update
whenever exactly one un-dueled `'r'` stands in pretoss. Racket zombies also arrive from `splatPoint`,
`endHorde` and `startDuel`. "No duel in the story" must gate both triggers.
- **`cupWon` ends with `G.done`, which freezes `update()`.** The story's final must branch before that:
at the final's kill fall, or by replacing `cupWon`.
- **Tests that pin what the story changes:**
- 'THE CUP CLIMBS' (rounds ascending, the final the band's top). The story builds its own rounds and
leaves `cupRoundsFor` alone.
- The TOURNAMENT / LEAGUES promotion tests. The story's act card replaces promotion only on the story
path.
- Arcade gore's tests stay as they are, because arcade gore stays as it is.
### The phases
Each phase ships to staging and gets a phone verdict, and tests ride along with each one.
1. **THE SPINE — story state and flow, on the existing screens.**
- `prof.story = {act, cp, stock, tries}`, with a profile version bump that resets the testers.
- `beats.startStory()` is a mode owner like every other.
- The story cup is the league cup machinery with its own rounds builder (final = the act boss's body).
- The chapter is gore from game 0, capped at 10, with:
- no duels (both triggers gated);
- the racket zombie as an ordinary returning body;
- the mid-boss at game 5 and the boss at game 10 through a design-indexed boss spawn;
- the cup loot as the starting rack (`grantWeapon`);
- a restock and full heal after the mid-boss.
- Death or elimination restarts from the checkpoint.
- The splash's primary button continues the story.
- Transitions are plain cards (`showModal` / `G.card`) for now.
2. **THE TRANSFORMATION AND THE PROLOGUE.**
- The act variant: at the final's last point, zoom onto the `'r'` body, hitstop and flash, and a `'B'`
boss of its design spawns there and grows. It runs out of frame, the stands empty onto the court
(the mob beat), and `enterSplatter()` starts game 1 without a cut. The boss's name slams with
"GAME 10".
- The prologue: a classic match against a living opponent, re-using `startTransition` as written.
3. **THE SCREENS.** The act title card, the cup-won/loot screen (count-up, weapons dropping into the rack,
the chapter preview with "???"), the checkpoint banner, the death screen with its retry point, and the
act card on the promotion screen's pattern. All animated, in the storyboard's look.
4. **HELP, ARCADE, UNLOCKS.**
- The assist tiers (from the 3rd try, at most 2).
- The menu: arcade shows only what the story has unlocked (with a premium flag stub), in the agreed
order.
- Courts and the weapon pool per act, and the free-tier cut at the end of Act I (the store plan's flag).
5. **LATER.** The metal bed (when Oliver has a track: start at the transformation, gore only) and Members'
Night (option C).
## Session log: the ladder is flatter — the width cliff (2026-10-15, v387)
Oliver: *"ab Runner ist das Zombie-Game schon sehr schwer — können wir die Schwierigkeitsleiter ein wenig
abflachen?"* **Measured first**: cup mode, the ring bot, 6-8 seeds (`tools/rally-sim.mjs`, `T_<knob>` sweeps).
- **The cliff.** Roller (rung 5) 90-98% of points, then Steward 54-79%, Mascot ~50, Runner 41-55. At level
10 it fell again at the Seller (29%).
- **What it is NOT:** sweeps of the legs (`legKnee` 5.3, `matchSpeedPer` 0.07) and of the flub decay
(`matchFlubKeep` 0.84) all moved it within the noise.
- **What it IS: the width.** `matchWideBase + step·matchWidePer` × the foe's `wide` grows without bound.
Wherever it crosses ~3.3 m (the auto-run's reach from the centre) the win rate falls off. That happens
at the Steward, and with a slower slope it just moved to the Seller. From there every line can be
painted.
- **The put-away chance** sets the top: without it, rungs 9-18 sat flat at ~60%.
- **Shipped (variant G):**
- `TUNE.matchWideMax` 3.0 m caps every match opponent's spread (Climb, cup, story cups, training).
- `punishMin`/`punishMax` go from 0.2/0.85 to 0.15/0.8.
- **Bot, level 10, cup** (before → after):
| rung | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 16 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|
| before | 57 | 51 | 44 | 29 | 31 | 27 | 26 | 34 | 22 | 24 |
| after | 72 | 73 | 68 | 53 | 56 | 57 | 42 | 42 | 36 | 33 |
The rushers stay their usual bumps. Level 26: Runner 43 → 83, Champion 29 → 41. Climb, level 26:
85 → 44, smooth.
- 3.1 m was tried as a middle way. It barely helped at level 10, because the cliff sits between 3.0 and
3.3.
- Test: rungs 8, 12 and 18 never land wider than 3.35 m (it reads 4.05 at the Runner without the cap).
## Session log: no story boss crosses the net (2026-10-15, v385, STAGING ONLY)
Oliver: *"strawberry seller as first endboss is way too hard — or it should not cross the net"*. The
seller is the arcade's chapter-9 design. His move is `charge`, the one style deliberately exempt from
the z band: it rushes to a strike line in front of you, and contact is death. He stays act I's
mid-boss. Every story boss now carries `holdNet` (`beats.startStoryBoss`): a rush stops
`TUNE.bossNetStop` (1 m) short of the net and then withdraws as before. The threat is kept, the reach
is not. That covers the Physio in act IV too. Only THE CHAMPION as act V's boss keeps his MATCH POINT
rush, because it is the finale. Test: the seller rushes at least twice in 40 s and never passes
netZ - 1; the champion is not held.
## Session log: THE STORY, Oliver's first test (2026-10-15, v384, STAGING ONLY)
Oliver: *"after my first test it all made a good impression"*, plus five asks.
- **The metal track** ("Dystopian Survival", 3:36). `assets/audio/metal-loop.mp3` is 0.5-214 s with the
last 10 s cross-faded (equal power, `acrossfade qsin`) into the first 10. The seam sits inside the
fade and the loop is exactly 203.5 s.
- Built with a static ffmpeg in the scratchpad: decode to WAV first, then three `-ss/-t` inputs. Two
`atrim`s of ONE input read out of order yield an empty crossfade.
- It was AAC first; MP3 at Oliver's call. The LAME tag carries the encoder delay and padding, and the
decoder strips them: 8974350 frames measured in Chromium, signal at both ends. `GORE_BED.loopEnd`
pins it anyway.
- `lowcut` is 40 Hz (the old 800 Hz high-pass would gut metal) and `vol` is 0.5: it is mastered at
-14 LUFS against the old bed's -8.
- The open-source Chromium used for tests cannot decode AAC at all, the old beds included. Phones
can; that was never a file defect.
- **The prologue goes straight on** (*"momentan wechselt man kurz in den Gore-Mode, das ist
verwirrend"*). In the story the old transformation stops after the turn: no mob, no "remain
seated", no gore beat. At 5.2 s, `enterSplatter(quiet)` and act I's card and cup take over.
- Because the quiet entry no longer loads the gore bed, the chapter start and the transformation's
run-off call `zombieMusic()` (load if needed, then un-duck) instead of `zombieDuck(false)`.
- **RESET STORY** (test only, `STORE.testReset`) under the splash's build stamp. Two taps within 3 s.
- It resets the story, league, cups and champion flag; the level stays.
- It pushes at once, or `pull()` (forward-only) would hand the old act back.
- Gotcha: `armT` started at 0, which counts as armed in a page younger than 3 s.
- **Screen sounds:**
- Act card: a whoosh and a boom when the title lands.
- Ceremony: applause, and the rack's own `weapon-click2` as each tin lands, at 70% of its drop.
- Act card at the end: applause, a boom for the badge, a rising pock per arcade door.
- Timers are cleared when a screen is tapped away.
- **Chapters are 2×3** (*"statt 2x5 lieber 2x3"*): `STORY.midAt` 3, `length` 6, and `hordeAt` 4. A
chapter's "set" is four games: at the arcade's six the set would land on the act boss, and the horde
would vanish behind him. The test pins one horde after game four.
## Session log: THE STORY, PHASES 1-4 — built on staging (2026-10-15, branch `story`, v380-v382, STAGING ONLY)
Oliver: *"mache alle, ich vertraue. alles erst mal auf staging"*. The plan is in "THE STORY" below.
Phases 1-4 are built; phase 5 (metal, Members' Night) waits on him. `main`/prod stays at v379 until he
releases it; staging carries the diff until then (`git push origin story:staging`). Verified headless
(182 tests, several seeds) and in a real Chromium at 390×844, where every screen was looked at.
**Phase 1, the spine.**
- **Data:** `config.STORY`, with acts (rounds with the boss's body as the final, mid/boss designs,
pool, arcade) plus `midAt` 5, `length` 10 and `base(act)` = (act-1)·20, and its helpers.
- **Persistence:** `prof.story` = {act, cp: pro|cup|chap|mid|done, rack, tries, deaths, help}.
- It is saved in the profile, because `restartRun` wipes G.
- The server whitelists act and cp only.
- `pull()` moves the story forward only, and ignores a pre-story mirror's league.
- A profile without `story` loads reset: league 1, no cups, no champion, level kept (xp is the
mirror's ratchet).
- **Flow, in beats.js:**
- `startStory` dispatches on the checkpoint. The cup is `startMatch(true, storyRounds)` (the dead
`draw` slot became `rounds`).
- `startStoryChapter(fromMid, keepCrowd)` sets `runGames = base(+5)`, fills the rack from
`storyStock` or the saved rack, and gives full blood.
- `splatPoint` arms `G.storyBoss` ('mid' at chapter game 5, 'end' at 10) and never calls a duel in a
chapter. `proceedAfterClear` runs `startStoryBoss`, which is `startBossFight(chapter, design)`;
`spawnChapterBoss` takes a design.
- `endBossFight` handles both: 'mid' → `storyCheckpoint` (restock to the stock, full blood, save);
'end' → keeps `bossFight` up (no spawns) and `winT` → `storyActCleared`.
- `startDuel` refuses in the story, and so does the lucky-clear trigger in update.
- `killPlayer` → `storyDeath`: the attempt counts, nothing is banked (no `endRun`: chapter games
are on the arcade's scale).
- Barrels draw from `storyPool`.
- **Main:** the front door says `storyFixture`; `start('story')`; the summaries say TRY AGAIN /
CONTINUE (door 'story').
**Phase 2, the transformation and the prologue.**
- **The transformation** (`storyMorph` / `tickMorph`, beats.js). At the final's winning point the body
does NOT fall. The ceremony screen pauses the world, then:
- 1.2 s: applause cut, "quiet please", zoom.
- 2.8 s: hitstop and flash; the body bursts and the act boss spawns at its spot, growing from 0.45 to
full scale.
- 3.3 s: name card, "GAME 10".
- 4.6 s: four bodies come down from the stands, "remain seated", the chapter music comes up; the
boss runs off to the left.
- 6.4 s: `startStoryChapter(false, keepCrowd)`: game 1 without a cut.
The interlude holds serving, spawns and the boss AI. `cine` holds the racket body. A rebuild of the
old `startTransition` was impossible, since the cup runs in splatter under an interlude, keyed on
the `'r'` body.
- **The prologue** (`cp 'pro'`): `start('story')` stays in mode 'tennis' (not classic) and plays the
old transformation unchanged. Its 'go' beat calls `storyPrologueGo`, and 2.4 s later act I's cup
opens.
**Phase 3, the screens** (`#sty` overlay + `main.storyScreen`, through the `beats.storyUI.show` hook;
headless, the old cards stand in). The world is paused under each:
- the act title card (auto 2.8 s or a tap);
- the ceremony: stats, the weapons dropping into a rack, the chapter preview with "10 · ???",
TO THE COURT;
- the act card: bodies, bosses, deaths this act, new in arcade, the next act's court, CONTINUE /
THE CLUBHOUSE.
The checkpoint is an in-game card. Gotcha found in the browser: a second `style=""` on an element is
silently dropped, so `delay(k, more)` merges them.
**Phase 4, help, arcade, unlocks.**
- **Help** (`config.STORY_HELP`). From `tries` ≥ 2 at a checkpoint, the death screen offers
TRY AGAIN · WITH HELP (max 2 tiers, reset by every checkpoint passed).
- Cup: `legLevel` ×(1+0.08·tier) and `winLevel` ×(1+0.12·tier).
- Gore: +1 heart per tier, and the director's gap and the horde rate are slowed by 1+0.25·tier.
- **Arcade:** a door opens when the act listed in `acts[].arcade` is cleared, or once the story is told.
- Locked doors are greyed with "CLEAR ACT n TO OPEN"; a tap shakes them.
- Splatter Gore (and its court link) opens after act III.
- Hosting vs a friend opens after act IV; joining stays free.
- **Store switches:** `STORE.freeTier` and `STORE.arcadePremium` (both off) and a `premium()` stub. With
the free tier on, act I's card points at the full game.
- The non-story summaries' fixture is the story now (door 'next' → `start('story')`).
**Phase 5, prepared:** the gore bed is one switch, `config.GORE_BED` = {file, loopEnd, vol}. It plays
in gore and in every story chapter from the transformation's run-off on, and is ducked everywhere else.
Metal is: drop the track into `assets/audio` and change those three values.
**Not done / open:**
- Phase 5: Oliver's metal track, and Members' Night (the boss marathon, option C).
- The tutorial and the ghost hand for the new double tap.
- Stats: story kills are not banked into the career totals (no `endRun`).
- The old cup mode is unreachable but still in MODES.
## Session log: THE SEASON, PHASES 1-4 — the front door, the record, the rooms, the season beat (2026-08-31, STAGING ONLY) ## Session log: THE SEASON, PHASES 1-4 — the front door, the record, the rooms, the season beat (2026-08-31, STAGING ONLY)
All four phases of the plan below, plus the overlay consolidation Oliver asked for in the same All four phases of the plan below, plus the overlay consolidation Oliver asked for in the same
@@ -5127,10 +5427,659 @@ replay button"*. Both are netplay-only by construction, which is why neither had
→ both courts come up 0-0 with full blood, the friend standing, no gore, "REMATCH — YOUR SERVE" → both courts come up 0-0 with full blood, the friend standing, no gore, "REMATCH — YOUR SERVE"
against "REMATCH — THEIR SERVE". 146 tests. against "REMATCH — THEIR SERVE". 146 tests.
## THE RALLY GOES FLAT — diagnosis, plan, and step 1 (2026-10-13, STEP 1 SHIPPED v361, phone verdict pending)
Oliver: *"jedes Spiel läuft am Ende gleich ab (vor allem in höheren Levels): Aufschlag, dann schnell
vor ans Netz (Spieler und Opponent), von wo aus ein sehr schneller Ballwechsel passiert und durch eine
Täuschung des Gegners schließlich ein Punkt erzielt werden kann ... so läuft kein echtes Tennismatch
ab."* He suspected the late opponents' speed. Both modes, classic and zombie tennis. **Fallback tag
before any change: `rally-baseline-v360`** (806a739, pushed).
**Measured** with `node tools/rally-sim.mjs` (real modules, test.js's autopilot, 240 s per opponent;
the service line is 6.4 m from the net, the baseline 11.9 m):
| zombie tennis | P meets the ball (m from net) | P volleys | O meets the ball | O volleys | time per shot |
|---|---|---|---|---|---|
| opponent 1 | 4.9 | 87% | 9.3 | 56% | 0.53-0.67 s |
| opponent 6 | 7.7 | 52% | 7.6 | 90% | 0.63-0.72 s |
| opponent 12 | 7.8 | 50% | 7.7 | 99% | 0.72 s |
| opponent 18 | 8.5 | 7% | 6.6 | 100% | 0.72-0.75 s |
Every rally ball lands at a median **5.4 m from the baseline: on the service line**. Classic tennis
is milder but has the same shape (contact 8.5-10.8 m from the net, 0.75-1.0 s per shot, the same
5.4 m landings).
**Four structural causes, none of them a tuning value:**
1. **The rally ball lands on the service line by definition.** `SHOT_DEPTH.mid` is 5.4, i.e. the
service line itself. In real tennis that is a SHORT ball, an invitation to attack. `deep` (3.0) is
the only ball in the back third, and the CPU asks for it 25% of the time.
2. **The match opponent runs to the BOUNCE and takes the ball there.** `racketAI` aims at `pred.z`
(the first-bounce point, since v352's "approach as far as 1.2 m behind the net"), and is clamped at
`>= 1.5`, so it can never stand behind its own baseline. Standing on the bounce point, the ball
enters its 1.6 m reach before it lands, so from opponent 6 up it volleys 90-100% of everything
from the service line. The speed dials (`matchSpeedPer`, foe `speed`, `leaguePace`) only make it
arrive there sooner. That is what "they get very fast" feels like.
3. **The player's auto-run picks the EARLIEST reachable waist-high point.** `intercept('P')` walks
the flight forward in time and returns the first point that qualifies, which is always the one
nearest the net. At opponent 1, 72% of the player's contacts are inside the service line. The
classic CPU goes through the same `intercept`, which is why classic has the shape too.
4. **Every shot has the same shape.** `solveShot` at pace `s` to one of three depths. No height, no
spin family, no lob or drop for the CPU. The lane is the only variable left, so wrong-footing
(THE READ) is the only way a point can end. That is exactly his description.
**Prototype (scratch copy, not committed):** both sides take the ball AFTER the bounce, below 1.5 m
and falling, and may stand up to 2.5 m behind their baseline. Homes move behind the baseline (P 24.4,
O -0.6). The match CPU waits behind the bounce instead of on it. Rally depth is deep by default, and
mistiming lands the ball SHORTER, never longer, so it adds no outs. Result: contact 10-12 m from the
net on both sides, volleys 0-2%, 0.8-0.97 s per shot. Rallies at the high rungs then never end for
the bot. That is expected: once the geometry is fixed, what is missing is the tools that END a point.
**What the research says** (real tennis and other tennis games, sources in the session's brief):
- Real points are mostly SHORT. 0-4 shots make up ~70% of men's and ~66% of women's points, 5-8 ~20%, 9+ ~10%
(AO 2015, Wimbledon 2021). Variety is lots of short points plus a long-rally tail, not long rallies
everywhere.
- Pros win 66-69% at the net BECAUSE they come in selectively. Controlled for the situation,
serve-and-volley is worse than staying back: 66.8% vs 72.2% (Tennis Abstract). They approach "only when
the point has developed in an inviting way", i.e. off a short ball. The net is made expensive by the
passing shot dipped at the feet, the lob, and needing a good approach first. Our game has none of those
three costs.
- Even the most minimal arcade tennis games (Super Tennis, Virtua Tennis DC, Mario Tennis) give the lob
its own input. Wii Sports rejected reading the swing's direction because "the ball doesn't fly in the
direction you expect", which supports our one-gesture rule. Shape-coded swipes (VT Tennis mobile) fight
with direction.
- AI that is "just fast" is the genre's known failure. Matchpoint's career mode reportedly needs constant
net rushing. What works is style dials (TopSpin's baseline/serve-volley/defense/all-round spreads) and
believable over/under-hits. Free Tennis's AI "learns whether it is best to take the net or stay back".
**The plan put to him (smallest root first; every number a TUNE knob, the tag is the fallback):**
1. **THE COURT GETS ITS LENGTH BACK.** No new input, both modes. Rally balls land deep, and mistiming lands
them SHORT (never long, no new outs). Both sides take the ball after the bounce from behind the
baseline. Coming forward happens only onto a short ball: the auto-run runs you in, and you are then
visibly at the net, where you can be passed or lobbed. A volley below net height can only be blocked up
soft. The suite gets a rally-shape test (contact depth, volley share, shots per point).
2. **OPPONENTS WITH A GAME.** The CPU gains shot shapes (heavy topspin that kicks high and pushes you
back, low skidding slice, moonball, lob, drop shot) and per-row STYLE dials on `OPPONENTS`: home depth,
approach threshold, lob/drop rate, spin. The roster lines already read as styles: pushers at the bottom,
ROLLER/BRUTE flat hitters, BALL BOY/RUNNER retrievers, CLUB PRO/DIRECTOR serve-and-volley,
MASCOT/PHYSIO touch players, CHAMPION all-court, and he learns whether the net is working. The ladder
then climbs through style and consistency rather than legs, and the speed dials are re-tuned
afterwards. Context-chosen shots are fine for the CPU: the rule binds the player's gesture, and a lob's
arc announces itself.
3. **THE PLAYER'S SOFT BALL (Oliver's call).** The down-flick today means "short", which is a fast short
ball nobody needs. Proposal: down = SOFT, and its length = how far, so short = drop shot, long = lob.
One meaning in every situation, no new gesture. This is NOT the context-armed lob that was cut on
2026-08-22. Rejected alternative: coding lob/drive by flick SPEED (AO Tennis Smash, Tennis Clash). A
right thumb's left swipe is naturally ~2x slower (measured 2026-08-20), so every left flick would turn
into a lob.
4. **Retune the escalation** against the new shape, with `tools/rally-sim.mjs` and the step-1 test as the
gauge.
**Oliver's answers (2026-10-13): "a) ja b) ja c) erstmal nur kurze bälle — go".** (a) Step 1 first, in
both modes, then a phone test. (b) The down-flick becomes the soft ball (short = drop, long = lob), which
is APPROVED and is step 3. (c) The player goes to the net ONLY on a short ball for now. Following an
attacking up-flick in was offered and deferred.
### Step 1, as shipped (v361): THE COURT HAS ITS LENGTH
All dials are in TUNE's block of that name. Rally modes only (`rallyFeel()`); gore's prologue and the duel
are byte-identical.
- **Depth**: `rallyMid` 2.8 / `rallyDeep` 1.9 m inside the line. Timing only SHORTENS the ball:
`(1-q)·shortK·(0.5..1.5)`. Pinned: a rally ball never lands nearer than ~1.4 m to the line. (solveShot
aims at ty 0.08, so the real bounce sits a few dm past the mark.) The down-flick keeps the old
`SHOT_DEPTH.short` until step 3.
- **The plan, `ball.rx`, latched once per shot** (every strike clears it; `rxPlan` sets it on the
receiver's first planning frame). `net` = the receiver stood inside `netFrom` (7 m) when the shot left;
`short` = it lands more than `shortBall` (4.9 m) inside his line and is not a serve. The latch is
load-bearing: re-reading position every frame flipped a man running in for a short ball into a volleyer
the moment he crossed `netFrom`.
- **From the back you let it bounce.** `intercept` meets the ball between its bounce and the next, at the
top or falling through `rallyHit`+0.3, up to `rallyBehind` (2.2 m) behind the line. A short ball is met
the first moment it is up off the grass. The old earliest-contact candidates are only the fallback.
`racketAI` does the same in closed form (`bounceContact`), keeps THE READ's blend on x, and is now
allowed behind its own baseline (the old `>= 1.5` clamp). `letBounce()` refuses a below-the-tape
pick-up on the full from the back, for both CPUs (in their near-checks, or the cooldown eats the
bounce) and for the player (in `trySwing`, where his swing simply waits for the bounce).
- **The approach**: a short ball taken sets `ball.approach`, and the striker's recovery (`rallyHome`)
heads for `netStand` (3.2 m) while it flies. Inside `netFrom` you stay at the net; otherwise you
recover `rallyBack` (0.6 m) behind your line. **Nobody approaches a man already at the net**
(`ball.from`, the striker's distance): that ball is a pass. Without this rule, both bodies ended up at
the net at a quarter of a second per shot, which was the old bug in a new place.
- **A net man facing a lob** (the ball will clear his racket height at his depth) turns and runs it down
as a baseliner. Nothing hits a lob yet; it is groundwork for steps 2 and 3.
- **A volley below the tape** is capped at `lowVolleyQ` 0.3: soft, short, little width. This is the
net's cost.
**Measured after** (`tools/rally-sim.mjs`, same bot): both sides now meet the ball 10.5-13.7 m from the
net, back-court volleys are 0-8%, and a baseline exchange takes 0.8-1.0 s per shot (it was 0.7 s at the
T). Approaches happen off short balls only, mostly the opponent's at the high rungs, because the bot
mistimes. Classic shows the same shape. Suite 150, plus three new tests: the rally is played from the
baselines (back-court volley share, time per shot), short means approach / deep means stay back / never
approach a net man, and depth never goes long. Fuzzed over 8 seeds.
**What step 1 did NOT fix, and it is the next lever: THE LEGS.** `rzChaseSpeed` at the Climb's rungs is
about 3.4 m/s (1), 5.6 (6), 8.8 (12) and **15.7 m/s (18, faster than a sprinter)**, against the player's
~6.5. Oliver's hunch that "the opponents get very fast" was literally true. Under the new geometry a
late opponent at the net covers every pass, so the bot loses ~95% of the points where the Champion
comes in. That is step 4 (and the passing/lob game of steps 2-3); it is deliberately not mixed into
step 1, so its phone verdict reads one change.
### Step 1's phone verdict, and the two fixes it surfaced (2026-10-14, v362)
Oliver: *"schritt 1 ist schon gut ... Das Spiel ist momentan wesentlich schwerer geworden (not a bad
thing) — ich komme als geübter Spieler über the Climb vielleicht höchstens bis zum Steward. das liegt vor
allem gefühlt an der Geschwindigkeit und manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu
bekommen."* Two real defects, measured, and neither of them was a balance value:
- **A TAP SWUNG WHEN THE FINGER LIFTED.** A touch only proves it is a tap at touchend (it could still
become a flick), so the swing started 100-150 ms after the player meant it. The volley window used to
absorb that (x1.8 window, x1.4 reach). Step 1 made the rally groundstrokes, and the lag became visible.
Ring-tapping bot at opponent 6, finger down exactly on gold:
0 ms of tap = 100% returned at q 0.82, 100 ms = 95% at **q 0.09**, 150 ms = **40%** returned.
Murky returns land short, a short ball pulls the opponent in, and his legs then made him unpassable: a
cascade, felt as "speed". **Fix:** input.js hands the touch's age to `keyPressed('Space', lead)`, and
the rally swing clock starts at `lead` (rally modes only). Timing is judged from touch-down; the ball
still has to arrive inside the window. Flicks keep their crossing-fired swing (the left-flick saga).
After the fix: 100 ms taps 99-100% at q 0.96. Verified with real touch events in the pane (a 120 ms
tap starts the swing at 0.122 s). Test: a gold-ring tap with 120 ms on top returns >= 90% at q >= 0.6
(it fails at q 0.08 without the fix).
- **THE LEGS GET A KNEE** (step 4, first cut). `gameplay.knee`: up to `legKnee` (6.0 m/s) nothing
changes; past it only `legSoft` (15%) of the growth survives. Runner 7.3 -> 6.2, Umpire 8.3 -> 6.3,
Coach 10.6 -> 6.7, Champion 15.7 -> ~7.5. The player is ~6.2 at level 1 and gets more with level and
peer gifts. The mapping is monotone, so the ladder order and the like-for-like speed tests hold. Only
the rig guard (`> 7 m/s`) moved, to `> legKnee*0.8`. Rungs 1-7 are unchanged: the Steward (6) already
ran 5.6. Ball PACE is untouched; it is at full pace times the league tier from rung 5.
### Step 3: THE SOFT BALL (2026-10-14, v363)
**In rally modes the down-flick is SOFT, and its length is HOW FAR**: a flick of `flickMin` is a drop
(`dropNet` 2 m past the net, low arc), a flick of `flickFull` or more is an attacking lob (`lobDeep` 1.6 m
inside his line, `lobApex` 5.5 m), with a continuum of floaters in between. It is one gesture with one
meaning everywhere, which is why it does not repeat the 2026-08-22 cut. Lateral sway still comes from
the swipe's x. Keys take the lob. `aim.soft` (0..1) lets a caller without a swipe ask for one, which is
the hook for step 2's opponent.
- **Ballistics** are the cut lob's (4f4534e). The pace is derived from the apex, so the arc is the same
from anywhere. There is no topspin. From the baseline the net-clearance loop lofts a drop to ~2.8 m,
which is correct (a 2 m arc from there clips the tape).
- **Mistiming pulls the target toward `softSitter`** (5.5 m, the service line): a drop that sits up, a
lob that falls short. It never goes out by depth.
- **Backspin: `ball.spin`.** A drop keeps `dropSpin` 0.3 of its pace through the bounce and (`spinLow`)
half that loss in height too, so it dies. `bXZ()`/`bY()` are the ONE bounce rule. The physics,
`intercept`, `bounceContact` and `matchOutOfReachFlight` all read them; a planner rolling the court
numbers would predict a ball that is not there. Netplay sends `spin` with the shot, so both phones
bounce it alike (an old build reads 1).
- **What makes it WIN, measured against the CPU** (a well-timed bot, q >= 0.75). It was ~100% returned
until four things went in, each of them real tennis:
1. `softRead` 0.35 s: any opponent stands reading a soft ball, because it is disguised until contact.
2. `netReact` 0.22 s: a net man reads every pass before moving. Without it nothing could pass him.
3. `lobTurn` 0.6: a lobbed net man backpedals.
4. Weak replies. A lobbed net man who runs it down (`rx.lob`) and a drop dug out below `scoopY`
0.45 m (backspin balls only) are capped at `lowVolleyQ`.
A first cut capped EVERY low contact. Since contact fires on the first frame in reach, that turned
every rally ball into a sitter and parked the player at the net, so it was reverted at once.
- **Result:** a drop played from inside the court wins outright against the slower half of the ladder.
Against the rest, a drop or a lob draws a defensive ball back (CPU reply q ~0.40-0.45 against 0.65
after a drive) for the player to put away. Rally shape unchanged. A deep lob's high bounce is taken
on the RISE when its fall would land past `rallyBehind` (`bounceContact`), otherwise it was unplayable.
- Tests: drop/lob shape and the lob clearing a net man's racket by 0.5 m, mistimed toward the sitter,
the disguise, the lobbed reply cap. 155, fuzzed over 8 seeds. Verified with real touch events in the
pane: a short down-swipe is a drop with spin 0.38, a long one a spinless lob.
- **Not done:** a lesson for the gesture (the ghost hand only teaches the side flick), and a "LOB"/"DROP"
word at contact. The arc says it. Add one if the phone test shows nobody finds it.
### v363's phone verdict: the drop never came (2026-10-14, fixed in v364)
Oliver: *"in touch nach unten erzeugt gefühlt immer einen Lob.. mir ist ein Stoppball nicht gelungen..
vielleicht ist die Geste zu subtil."* Two reasons stacked. The swing fires at `flickMin` (28 px) but the
length is read at CONTACT, by when the thumb has usually travelled on. And on the continuum even a 50 px
flick already floated the ball to mid-court. **Now it is two shots:** a down-flick shorter than
`dropMax` (90 px) is the drop, longer is the lob. The continuum survives only for the opponent's
moonball (`aim.soft` 0.85). Verified with real touch events: 50 px and 75 px give drops, 150 px a lob.
Pinned: a 75 px flick lands exactly where the shortest drop does. A slow, long swipe that is still
short of 90 px at contact is still a drop, so "long and brisk" is the lob gesture.
### Step 2: OPPONENTS WITH A GAME (2026-10-14, v364)
`config.STYLES`: eight styles as dials, one `style` per OPPONENTS row, and the walk-on carries the
style's `tag` as one bold row ("PUSHER — IT ALL COMES BACK HIGH"), because the player has to know what
he faces to plan against it. Dials:
- `loft`: a moonball habit, the soft ball at 0.85.
- `drop`: only when the player stands behind his line AND the body is 1.5 m inside its own.
- `lob`: when the player is at the net.
- `spin`: rally-ball bounce, >1 topspin kick, <1 slice.
- `charge`: lowers its short-ball line.
- `sv`: serve and volley.
Context-chosen shots are fine for the CPU; the one-gesture rule binds the player's thumb.
| style | who | does |
|---|---|---|
| pusher | Shambler, Punter, Steward | 45% moonballs, lobs a net man half the time |
| retriever | Ball Boy, Runner | some moonballs, lobs |
| slicer | Keeper, Seller | low ball (spin 0.8), the odd drop |
| power | Roller, Stringer, Brute | heavy topspin kick (1.15) |
| baseliner | Line Judge | mild topspin, the odd lob |
| touch | Mascot, Physio | drops you when you hang back, lobs you at the net |
| rusher | Umpire, Club Pro, Director | serve and volley, comes in 1.6 m earlier |
| allcourt | Head Coach, Champion | a little of everything |
Wiring:
- **Serve and volley:** `hitServe` stamps `ball.approach = 'O'`. `rz.rushing` is set while its own ball
flies, and the recovery SPRINTS instead of walking. `rxPlan` plays the return as a net man wherever
the run has got to (never a serve return), and net mode heads to `netStand` depth on the ball's line,
with `netReact` as the split step.
- **The player lobbed at the net** gets the CPU's rule: `intercept` flips his plan to `lob`, his
backpedal runs at `lobTurn`, and his reply is capped.
- `rzChaseSpeed`'s lob factor reads only the CPU's OWN plan.
Tests (160, ten seeds):
- every rung styled, tags fit
- pusher moonball and lob shares match the table, and it never drops
- big-hitter balls all kick
- touch drops from inside, never from behind its line
- the rusher charges more than 2 m behind its serve
- the player's plan turns to lob
The step-1 baseline-shape test now plays THE LINE JUDGE (a baseliner, `oppOf().foe` set; the body plays
the style, not the rung number), and its net share floor is 50%. The autopilot mistimes into short balls,
and a body is meant to come in on those. The back-court volley cap is the real guard. The depth and Climb
pace samplers skip soft balls, since a moonball's pace comes from its arc. Also fixed in passing: the
machine-squeak test was ~1% seed-fragile (one 3 m roll, about 5 grind ticks at 60%). It now keeps
rolling (seed 31337 found it).
**Not built:** the champion LEARNING whether the net works for him (Free Tennis's idea), a style for
classic's CPU, and a "DROP"/"LOB" word at contact.
### The swing log comes back for every rally mode, and the gold ring is TIMED (2026-10-14, v365)
Oliver: *"bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht bekommen habe
(ich glaube das war ein Tap aufs Scoreboard).. ich würde dir das gerne mal zum Debuggen geben."*
- **The log existed but was gated on `G.mode === 'tennis'`.** The match runs as `'splatter'` under its
interlude, so the Climb, the cup and training logged nothing at all. `swingLog()` (tennis or
`rallyFeel`) now gates every site: whiff analysis, swing snapshots, closest approach, the HIT line.
- What a line now carries:
- **HIT:** `q=0.82 drop left volley · tap 120ms · capped: …`, i.e. the shot asked for, the tap's
age (keyPressed's `lead`, stored as `P.tap`), and which cap took the quality down (volley below
the tape, dug-out drop, lobbed).
- **MISS:** the old early/late/wide/high/low reasons, plus "waited for the bounce" when the
let-it-bounce gate refused it. The snapshot reach now includes `matchReach`; without it a near
miss in the match read as wide.
- **WON/LOST:** every point names why (`logPoint`, called by both `pointTo` and `resolveInterlude`).
A point lost with no swing adds how close the ball came. The old PASSED entry folded into this
line; the ghost hand's pass streak keeps its old classic-only trigger.
- The ring holds 200 entries. **Tap the scoreboard** to open the last nine. **Tap the open log** to
copy all of it (`copySwingLog`, main.js): a header with build, mode, opponent and style, then one
line per entry with its measured fields. It uses the clipboard, with the share sheet as fallback.
`render.logGeom` is the one geometry for drawing and hit test.
- **The log's first catch: the gold ring was a DISTANCE** (2.7 m ± 0.5). That is right for the
~20 m/s rally balls it was calibrated on, and too early for every slow ball steps 1-3 brought in:
moonballs, lobs, drops, deep balls braked by their bounce. A gold-ring tap on one missed "30 ms after
the window".
- `gameplay.ringCue` rolls the ball forward (the real bounce rule, reach, height band and
let-it-bounce gate) to the moment it enters the player's contact reach, measured against where
the auto-run is CARRYING him (`P.tgt`). Against where he stands, the cue jumped 50-180 ms a frame.
- Gold means a finger landing now centres that moment in the swing window (`ringBand` 0.35 of the
half-window, so the gold edge is q 0.65). The rings kiss at the sweet moment: a tap at the kiss
measured q 1.00 every time.
- Gold-tapping bot with a 100 ms lift: 92-98% returned at every style. The distance ring returned
75% against THE BRUTE's topspin.
- `TUNE.ringTime 0` restores the distance ring for A/B.
- Note: contact still fires on the first frame the ball is in reach, which right after a bounce is
often 15-25 cm off the grass. A visual half-volley, the old contact model; untouched.
### Oliver's first swing log (v365, cup vs THE BRUTE) and what it changed (2026-10-14, v366)
**His question: "am I just bad or is something off".** Read off the log:
- 22 hits: 13 at q = 1, 9 under 0.5. Six of the nine bad ones were "deep right" (the forward-right
flick), which failed 6 of its 9 tries; the left flick failed 3 of 10. The bad hits split between
EARLY (the ball met at the edge of reach, d 1.55-1.73) and LATE (the ball already on top of him,
d 0.55-0.73). It is timing, with no single offset.
- Weak balls are what the Brute (power, league-5 quality) punishes. Seven LOST "passed you", and
several of them follow a weak hit of his within a second.
- Every hit said `tap 0ms`: he flicks everything. Hypothesis to check in the next log: the gore
slingshot habit. A pull-back before the forward flick crosses `flickMin` DOWNWARD, which fires the
swing early, and the live aim turns to "deep right" by contact.
**Changes:**
- **Flicks are timed from touch-down too** (`keyPressed(code, lead, flick)`; input.js passes the
crossing's age, capped at `flickLead` 0.1 s so a slow drag keeps a window). The timed ring means
"touch now" for both gestures. The log says `flick 46ms` / `tap 120ms`. Verified with real touch
events.
- **The log's missing half:** a THEM line per opponent shot (kind, spin, q, pace, where it lands
relative to the player, OUT, "coming in"), and `whiffed it` for a CPU flub. "Never swung" is now judged
per incoming ball: `G.swung` and `G.rallyNear` reset at the opponent's strike. It used to be judged
once a point, so none of his "passed you" lines could say whether he swung or how close the ball came.
- **An early swing on a low skidding ball no longer whiffs.** The let-it-bounce gate waits only while
the bounce will land inside the swing window. Otherwise it digs the ball out as a half-volley, capped
below the tape. His 48.14 s MISS was exactly this.
### "passed you" called before the ball reached him (2026-10-14, v367) — a step-1 regression
Oliver's v366 log vs THE BRUTE, first loss:
1. A 29 m/s topspin drive landed 3.7 m to his left.
2. He reached it at the edge of his reach (q 0.09).
3. The Brute came in behind the weak reply and hit 3 m to his left.
4. LOST "passed you", with no swing at all.
The match awards the CPU the point the moment its ball crosses `COURT.L + 0.3`. That line was
written when the player stood INSIDE his baseline. Since step 1 he waits up to `rallyBehind` (2.2 m)
BEHIND it, deepest against heavy topspin. A ball passing ~1.5 m beside him only enters his reach about
0.5 m in front of him (the reach ellipse is shallow off-centre), so the point was called before the
ball was ever playable. A late-but-legal tap was cut off the same way. **In the match, "past you" now
means past your reach:** `max(L + 0.3, P.z + reach·matchReach + 0.3)`. The duel keeps the old line;
classic was never affected (it calls the second bounce). Test: a deep, slightly wide topspin ball
against a player held 1.8 m behind his line. Without the fix it reproduces his exact log line
(`LOST passed you`); with it he returns it. The "never swung, it came within X m" annotation is pinned
in the same test.
The swing log's HIT line now also says which way the timing missed: `late 40ms` (the ball was already
there when the window opened) or `early 40ms`. That was the question his first loss could not answer.
### His v367 log: the flick back-dating was wrong, and the half-volley rescue used the wrong window (v368)
- **v366's flick back-dating made slow flicks EARLY.** Every 62-64 ms flick landed 76-92 ms early (q
0.05-0.2). A 97 ms flick whiffed "14 ms after the window closed" and cost a point. The 31-45 ms
flicks were fine. A flick is timed by its SWIPE, not by its touch-down. `TUNE.flickLead` is now 0
(knob kept). The touch's age is still logged for both gestures, and taps keep their touch-down timing.
- **The early-swing half-volley rescue judged its deadline against the swing's current, VOLLEY-length
window.** After the bounce the shorter groundstroke window applies, so it decided to wait and then
expired on the bounce frame. That is his 8.98 s "waited for the bounce": the Brute had come in and
dug his drop out weakly (q 0.3, 15 m/s), so it was a sitter, lost. The deadline is now the
post-bounce window, less a frame.
- **Point 3 (a 32 m/s topspin return of his serve into the left corner):** he ended up 1.5 m FORWARD and
only 1 m left. Rebuilt headless twice (from the baseline, and after his own serve); the auto-run
reached the ball both times (5-15 cm). Not reproduced. THEM lines now carry where the player stood
(`px,pz`) and MISS lines where the auto-run was taking him (`runTo`), so the next one explains itself.
- Observed, not changed: the CPU's "heavy topspin" carries its extra gravity THROUGH the bounce, so it
skids low afterwards and nearly every contact in his logs is 0-25 cm off the grass. Real topspin
kicks UP. Resetting `ball.g` at the bounce would fix the picture but moves every timing in the
rally (all four planners), so it is a decision to put to him.
### The dip ends at the bounce: topspin kicks UP (2026-10-14, v369, Oliver: "ja, bau den topspin-fix")
A shot's extra gravity (quality topspin `spinFactor`, the serve's `serveSpin`) is its DIVE IN FLIGHT,
and it used to be carried THROUGH the bounce: a "heavy topspin" ball skidded away low and nearly every
contact in his logs was 0-25 cm off the grass. `gAfter()` hands a ball plain gravity at its bounce
(tennis modes; gore's balls keep theirs). The kick itself was already `bY`/`bXZ`'s spin factor. Every
planner that rolls a bounce forward switches at the same moment (physics, `intercept`, `bounceContact`,
`ringCue`, `matchOutOfReachFlight`); one left on the old gravity would predict a ball that is not there.
- **Side effect, caught by the probe:** higher bounces fell outside the auto-run's 0.45-1.3 m comfort
band, so it found no point behind the line and ran forward to VOLLEY (38-42% volleys). The player
now takes a kicking bounce at its top, up to `rallyTop` 1.7 m. Volleys are back to 8-18%.
- **Measured** (probe, median player contact): rung 1 0.42 -> 1.13 m, rung 6 0.53 m, and against the
big hitter still 0.35 m. The cause there is no longer the bounce. A contact fires on the FIRST frame
the ball is in reach, so against hard pace it is met as it enters reach, just off the bounce. Waist
high contact against pace would need the contact model itself changed: contact at the closest
approach, quality by distance from the sweet spot. That is a separate decision. The timed ring still
returns 92-98%. 162 tests, nine seeds.
### v369's phone verdict: "vieeel besser" — and one planning bug the topspin fix exposed (v370)
His v369 log against THE BRUTE: long clean rallies. His flicks now scatter around the centre (q = 1 for
most; the misses are ordinary ±50-90 ms timing, both directions). That confirms the v368 revert: no
offset is left. One new pattern: two misses where the auto-run was heading to `runTo` x 6.51 and 8.73,
far outside the frame the player is held in (`xLimit`, ~4.3 m on a phone). Since the dip ends at the
bounce, a hard cross-court topspin ball landing just inside the line drifts on sideways long enough that
its comfort point lies off the court. Two things were wrong. `intercept` had no lateral bound on any
candidate. And its last resort (`deepest`, "run at the deepest hittable point") was never checked for
reach at all; headless it planned to x 14.8. Every candidate now respects `xLimit`, so a kicking ball is
taken earlier, on the rise, while it is still inside. The final target is clamped too, so an unreachable
ball still gets a run to the nearest legal spot. Test: his ball (hard cross-court, lands at 3.3, kicks
out). Without the fix the target is 14.8 m; with it the run stays in bounds and he returns it.
### The ring stops twitching, and the praise word gets out of the ball's way (2026-10-14, v371)
Oliver: *"der Treffer-/Zielkreis zittert jetzt manchmal, außerdem ... die Nachrichten wie shot of the
day, textbook ... verdecken manchmal den Ball."*
- **The timed ring re-solved every frame** against the auto-run's target, and a target shifting a few
centimetres moved the predicted moment. Probe: p99 100 ms, max 158 ms per frame, nearly half the
ring's size in one frame. The drawn cue is now `G.ring`, smoothed in `update`: it counts down on its
own clock and eases toward each fresh solve (`ringSmooth` 8/s), and a frame with no solve holds it
for up to 0.12 s. Measured jump: p99 17 ms, max 21 ms. Gold toggles are unchanged (two per ball).
The bots and tests keep reading the raw `ringCue`.
- **The praise word sat ON the ball.** On a rally contact it rose straight into the path of the ball
just struck; a serve's word sat on the far bounce, where the return comes back through.
`praisePop`: in tennis modes it lands `praiseBehind` (1 m) behind the player's feet, on the dark
apron under him, and stays put (`popText`'s new `still`). The MOMENT is unchanged (contact / the
serve's landing). Gore keeps its words on the bodies they belong to. Checked in the pane: PERFECT
under him, ball clear.
### Step 4, measured first: the ladder cannot climb, because points are decided by the player's whiffs (2026-10-14, v372)
Oliver on v371: *"okay, aber der Text wird auf dem iPhone teilweise links abgeschnitten"*, and *"es kann auch
sein, dass durch die geänderte Mechanik alles etwas schwerer geworden ist"*.
- **The praise word is kept on screen** (`render`, the pops loop): v371 centres it under the player's feet,
and a player out wide pushed "SHOT OF THE DAY" past the left edge. Every pop is clamped to the canvas by
its measured width. Gore's words get the same clamp.
- **`tools/rally-sim.mjs` grew a human bot** (`BOT=human`, the default): it lands its finger on the timed
ring with `JIT` ms of gaussian spread (clamped ±2σ) plus a fixed `BIAS`, taps with a 120 ms lift, lobs or
passes a net man, drops a deep one. `CLIMB=1` plays the Climb's step boost on a pinned rung (training
with `train` off and hp topped up; driving the real Climb ended the run). `XP` sets the level, `WHY=1`
files every lost point under how it came about, `PROBE=1` times ring vs contact, `JSON=1` for
aggregation. Run 8 seeds and pool them: one seed's 40-50 points per rung is ±7%.
- **Measured** (Climb, level 26, 8 seeds × 500 s, point win %; σ40 ≈ a practised thumb, σ60 an average one):
| rung | 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| σ40 | 78 | 65 | 77 | 57 | 50 | 44 | 40 | 62 | 48 | 40 | 65 | 41 | 49 | 64 | 40 | 61 | 45 |
| σ60 | 61 | 39 | 52 | 38 | 32 | 30 | 29 | 44 | 27 | 29 | 48 | 23 | 31 | 51 | 29 | 49 | 34 |
The curve falls over the first six rungs and is then FLAT, zigzagging by style: the three net rushers and
the second slicer are 15-20 points easier than their neighbours, and the Champion is the Runner.
- **Why it cannot climb.** Nearly every lost point (`WHY=1`) is the player's own "MISS tap too early"; the
CPU's whiff rate is `matchFlub`·0.76^step, ~1% at the Runner and ~0.07% at the Champion. The auto-run
makes the player defensively near-perfect, so the CPU's pace, width, quality and style barely decide a
point; the player's whiff rate decides, and no opponent dial touches it. Three experiments, all measured:
1. **A quality ramp** (every rung's q band on a line from the shambler's to the Brute's, smart aim from rung
6): no change outside the noise. Reverted.
2. **A follow-through** (a groundstroke swing meeting a ball up to 60 ms after its window at q 0.05,
instead of whiffing): the bot won 80-99% everywhere, with 20-40-shot rallies. Reverted.
3. **On top of it, the CPU putting sitters away** (a q < 0.5 ball answered at q 0.95 to the open court,
with a chance from 0.2 at the shambler to 0.85 at the champion): no change. The auto-run reaches the
put-away too. Reverted.
- **Where the "harder" comes from.** The early whiff edge. A tap a little early starts the swing while
the ball is in the air, the swing waits for the bounce (`letBounce`), and the groundstroke window has run
out by then: a whiff at ~70 ms early (a tap 120 ms LATE still meets it). Before step 1 the same ball was
volleyed in the 1.8x window, so the edge sat near 130 ms. Sweep at level 26, rung 8, perfect thumb plus a
fixed bias: −70 ms is 56% contact, −50 ms q 0.24, 0 and +50 ms q 0.99, +70 ms q 0.52. The ring itself is
centred: contact comes 4-10 ms after its predicted moment, which is frame quantisation.
- **Shipped: the quality curve loses its cliff** (`TUNE.qSeamless`, rally modes, player only). q was 1 inside
`perfectMs` and `1 - off/half` outside it: a groundstroke fell from 1 to 0.43 one ms past PERFECT. It now
falls from 1 at PERFECT's edge to the window's edge. −30 ms is q 0.86 (was 0.61), +70 ms q 0.76 (was 0.52).
The window, PERFECT and the whiff edge are unchanged, and **the bot's win rate does not move**; this is a
feel fix, not the difficulty fix. Test: a tap 3 frames early of the kiss returns at q >= 0.45 (0.33 on
the old curve).
- **The decision for Oliver**, because each option changes what the game IS:
- (a) **Keep the error race and dial it**: the CPU's whiff curve gets a floor and a gentler decay so the
ladder slopes smoothly, styles get compensated. Cheap, predictable, still "who whiffs first".
- (b) **The follow-through, plus a CPU that can WIN points**: an early tap becomes a weak ball instead of a
whiff, and a sitter is punished by a put-away the auto-run does NOT fully cover (the run reacts late
after a weak ball of your own, or a put-away's pace is beyond its legs). Points then turn on shot
quality, which is what the opponent dials scale. More tennis, more tuning.
- (c) **Widen the early whiff edge only** (a smaller tail), keeping the error race: undoes most of the
"harder" without changing the model.
### Step 4 (b), Oliver's pick: the follow-through, and a CPU that can WIN a point (2026-10-14, v373)
Oliver: *"b"* (options above). Two halves, each useless alone (both were measured alone above):
- **THE FOLLOW-THROUGH** (`TUNE.swingTail` 0.06 s, rally modes, player, bounced balls only). A swing
meets a ball arriving up to 60 ms after its window closed at q 0.05. The early whiff edge goes from ~70
ms back to ~130 ms (where the volley window had it before step 1); past it, it is still a whiff. The
log says `q=0.05 · early 115ms`. Volleys keep their window: a tail on balls in the air raised the
back-court volley share.
- **THE SITTER IS PUNISHED** (`gameplay.punishes`): a player ball under `punishBelow` (0.5) is answered
with a put-away at a chance from `punishMin` 0.2 to `punishMax` 0.85 along the Climb's own progress
(`matchStep`, so its brisk bottom counts), classic by `DIFFS[].punish` (0.2/0.5/0.8). A put-away is
struck at `punishQ` 0.95, skips the style's soft shots, aims at `punishX` 3.5 m (the line; the rung's
width dial still bounds it, so the bottom of the ladder punishes without painting lines), and only
counts if no cap took it down (a low volley stays a block). The log says `THEM PUT-AWAY of your weak ball`.
- **...AND HE IS ON HIS HEELS** (`ball.heels`, `punishRead` 0.5 s): the auto-run stands for that long on a
put-away of his own sitter. This is the half that makes it work. The auto-run started on the strike's
frame with a perfect read, ~7 m/s plus a 1.75 m reach, and covered the whole court: a q 0.95 put-away
to the open court won 0 of 21, at the line 0-2 of 20. Read 0.25 s: ~5%; 0.4: ~20%; 0.55: ~70%.
**Feel risk:** he visibly stands still for half a second. If that reads as a bug on the phone, the
alternatives are a slowed (not frozen) run, or a shorter read with the put-away hit harder.
- **A step-2 regression it surfaced:** the drop's dig-out cap read `ball.spin < 1`, and the slicer hits
EVERY rally ball at spin 0.8, so every low contact against him (both sides) came back at `lowVolleyQ`.
Now `ball.soft && spin < 1`. THE SELLER went from 35% to 47-54% and normal rally lengths.
- **Measured** (Climb, level 26, 8 seeds × 500 s, point win %):
| rung | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| σ40 | 95 | 88 | 88 | 87 | 75 | 58 | 52 | 45 | 54 | 48 | 47 | 77 | 42 | 46 | 64 | 40 | 65 | 39 |
| σ60 | 95 | 88 | 83 | 78 | 62 | 55 | 53 | 43 | 47 | 46 | 36 | 61 | 30 | 43 | 57 | 28 | 55 | 28 |
The ladder slopes now, down to the Champion at 28-39%. Classic: easy ~80, medium ~57, hard ~30 (σ60, level 1).
- **Still open:**
- (1) **The net rushers** (Umpire, Club Pro, Director) are 15-25 points easier than their neighbours.
They win only ~35-55% of their net points, because the bot ALWAYS passes or lobs a net man, which is
their tag's answer played perfectly. The phone decides whether a human does that; if so, the dial is
their split-step read (`netReact`) or their put-away from the net.
- (2) The bottom three rungs are ~90%. That is easy for level 26, but the Climb is a marathon.
- Tests (167, nine seeds): the follow-through meets an ~80 ms early tap (and the old edge did not); the
put-away share climbs the ladder, goes for the line, never answers a good ball; a low slice drive is not
a dug-out drop. The baseline-shape test excludes the answer to a put-away (a man on his heels takes it in
the air legitimately), and synthetic returns in the suite now carry `pow: 1` (a stale `pow` from the
previous test read as a sitter).
### The player's way to the net and back, and the SMASH (2026-10-14, v374)
Oliver: *"Steuerung ans Netz gehen, wie machen wir das?"* Offered: (1) a long up-flick is the attack and he
follows it in, (2) a separate up-swipe while his own ball flies, (3) automatic after a strong ball (breaks
the one-meaning rule). He picked **(1)**, asked *"wie kommt man wieder zurück?"*, and added *"wenn man einen
Lob vom Gegner als Volley annimmt, soll man derb schmettern"*.
- **The gestures, by LENGTH only** (never context — the 2026-08-22 lob rule):
- up, short (< `attackMin` 90 px): the deep ball, you stay;
- up, long: **the attack**, the deep ball and you follow it in (`ball.approach = 'P'`), even onto a
man at the net — it is his call;
- down, short: the drop, you stay (a drop volley keeps the net);
- down, long: **the lob, and you go back behind it** (`ball.retreat`, `rallyHome`). That is the way back.
It is also automatic when you are lobbed and cannot get under it, and when the point ends.
Keys: up = attack, down = lob, as before. Both of your own runs go at full pace, and while the attack
flies `P.rushing` makes the reply a net man's plan from wherever he has got to (the CPU's rule).
- **The net has a price, as the CPU's does.**
- At the net (or rushing) the player split-steps for `netReact` (0.22 s, `ball.heels`) before he reads
the pass.
- The CPU, facing a net man and not lobbing, plays the PASS at the line away from him (`aim.pass`,
`punishX`, still bounded by the rung's width).
- Measured without both: an attacker won 78-95% of his net points. His volley reach is ~2.5 m and he
read instantly.
- **THE SMASH** (rally modes, player):
- A ball taken OUT OF THE AIR at `smashMinY` 1.9 m or higher, up to `smashMaxY` 2.5 (above
`CONTACT.maxY` 2.3, but under the ~2.8 m a CPU lob clears a net man by).
- Extra pace `smashPace` 0.8 × q on top of the forward flick's. Aimed at the line (`punishX`) on the
flicked side, else away from the body. The opponent reads it for `smashRead` 0.3 s. It pops "SMASH!",
and the log says `HIT q=1 SMASH`.
- Aimed deep down the middle (the first cut), it came back to the baseliner and won ~40%. At the line it
wins ~80%. The ring shows it (`ringCue`'s volley band is the smash band).
- **Under the lob, or not — DECIDED ONCE** (`plan.over`, latched with the plan). When the player at the net
is lobbed, `intercept` looks for the first point the lob comes down into the smash band that he reaches
at `smashRun` 0.25 of his legs. Found: he goes there and smashes. Not found: he turns and runs it down,
and whatever he meets on that run (in the air included) is a capped defensive ball, not a smash.
- Re-asked every frame, his backpedal brought the overhead into reach on the way, and every lob was
smashed.
- Measured (attacker, rungs 7/14/18, 3 seeds): smashRun 0.35 → 131 smashes for 141 lobs; 0.25 → 62 for
130, won 82%; 0.18 → 49.
- **Measured** (Climb, level 26, σ60, 8 seeds; `ATTACK=0.3` = the bot plays 30% of its baseline balls as
the attack): the base ladder is unchanged within the noise. Attacking is a real choice:
- better against the pushers and touch players (Steward 49 → 60, Mascot 42 → 49, the bottom rungs);
- worse against the big hitters, slicers and rushers (Roller 66 → 57, Seller 43 → 37, Club Pro 53 → 47);
- level against the Champion (33 / 28).
Points get short (4-6 shots).
- Tests (170, nine seeds): the long up-flick follows the ball in and the short one stays; the long
down-flick from the net goes back and a drop volley holds the net; a short lob is smashed hard into the
open court and a deep lob is not (blocked back if reached).
- `tools/rally-sim.mjs`: `ATTACK=0.3`, `NETX=1` (attacks, lobs faced at the net, smashes, smashes won), and
`T_<knob>=<value>` sets any TUNE dial for a sweep.
### The auto-run stops shaking (2026-10-14, v375)
Oliver: *"wenn der Autoplay den Ball versucht zu bekommen, zittert der Player schnell hin und her. bitte glätten"*.
**Measured first** (`tools/rally-sim.mjs JITTER=1`, `=2` prints target jumps, `=3` the reversals):
- the target jumped ~10 cm a frame (p90 0.2-0.4 m), and 15-25% of chase frames moved it more than 20 cm;
- there were 9-62 direction reversals inside 200 ms per five minutes of play.
Smoothing the target alone (`runSmooth`) cut the small jumps 7x and fixed nothing. The shake was the PLAN
flipping by metres, every other frame, between two candidates:
- **The cause: margins against a sliding grid.** `intercept` samples the flight every 10 ms, the game steps
16.7 ms, so the sample phase slides every frame. A candidate sitting on any margin passed one frame and
failed the next:
- a low slice whose bounce peaks at ~0.46 m against the 0.45 m back-court floor;
- a point on the back boundary (`rallyBehind`);
- a dying drop at the 0.35 m floor (frames with no candidate at all sent him home, 10 m, and back).
Judging reach from where he stood made it worse, since his own step moved the answer. Each margin patched
with hysteresis just moved the flip to the next margin.
- **The fix: the chase LATCHES its plan** (`P.chase.plan`). The flight is fixed when the ball is struck, so
the point chosen first stays the answer while his legs still make it in time, net of any time he stood
(split-step, a put-away's heels: `P.chase.lost`). It re-plans only when that fails. Reach is judged from
where the chase began plus the time he has run (`P.chase`). The margin hysteresis (`runKeep`, 6 cm of
height, 10 cm of back boundary, holding the plan on an empty frame) stays as a second line, and so does the
eased target and a start/stop hysteresis (`runStop` 0.12 m against the 0.35 m start). `setupPoint`
clears it all.
- **After:** 0 reversals at rungs 4/8/12/15/18. The timing sweep is unchanged (−30 ms q 0.91, kiss q 1.00).
The ring leads contact by 5-14 ms, which is frame quantisation.
- **Side effect: he no longer wastes steps, so he is a little stronger.**
- Climb (σ60, level 26): bottom rungs 94 → 99%, mid ladder about +5, top unchanged (Champion 33 → 30).
- Classic (σ60, level 1): easy ~80 → ~93, hard ~30 → ~37.
- Not retuned yet: put it to his phone first.
- Test: the ring-tapping player chasing at rungs 4/12/18 for a minute each makes at most 2 reversals inside
200 ms. The old code makes 26. The early-tap test's floor moved 0.45 → 0.42: he now stops 0.12 m from his
mark instead of 0.35, so contact comes a hair later. The old curve still reads 0.33.
### The run in and out is a DOUBLE TAP (2026-10-14, v376)
Oliver on v375: *"das Vorlaufen und Zurücklaufen sollte durch einen Doppeltipp ausgelöst werden"*. It
replaces v374's flick-length rule. The shot and the run are separate decisions now: the long up-flick is
only the deep hard ball, the long down-flick only the lob.
- **While his OWN ball flies** (rally modes, `ball.lastHit === 'P'`) there is nothing to hit, so a tap there
is not a swing at all. Two taps inside `dblTap` (0.35 s) are `moveCall`: from the back to the net
(`ball.approach = 'P'`), from the net (or while already running in) back (`ball.retreat`). A word pops
under him ("TO THE NET" / "BACK"). A single tap there does nothing (it used to be a pointless swish).
- **An incoming ball keeps every tap a swing**, so the two meanings never share a moment. The rule from
the 2026-08-22 lob holds: the meaning depends on whose ball is in the air, which he always knows, never
on where the opponent stands.
- **After his own serve it is serve and volley.** `P.rushing` no longer excludes the serve. His reply is a
net man's plan from wherever his run has got to.
- **The short-ball approach stays automatic** (step 1, decision (c)). A double tap during that run takes him
back.
- Input: a tap fires at lift if under 250 ms (input.js), and the canvas has `touch-action: none`, so the
browser's double-tap zoom never sees it.
- Measured (σ60, level 26, 8 seeds; the bot follows 30% of its deep flicks in with a double tap): the base
ladder is unchanged. Attacking still pays against the pushers (Steward 52 → 61) and costs against the big
hitters and the Champion (Brute 29 → 20, Champion 33 → 25). It is a little weaker than v374's, because he
sets off after the shot, not with it.
- Tests (172): a double tap from the back takes him in, one tap or two too far apart do nothing and never
swing, the long up-flick alone no longer runs; a double tap at the net takes him back, the lob alone does
not; an incoming ball keeps both taps swings.
- **Not built:** a lesson for it. The ghost hand teaches the tap and the side flick only; the double tap
is undiscoverable without being told. Add one if the phone test says so.
### The double tap is a STANCE, not one ball's run (2026-10-14, v377)
Oliver on v376: *"funktioniert irgendwie nicht — wenn man doppeltippt, geht er minimal vor"*. **Measured**
(`NETX=1`, the bot double-tapping behind 100% of its deep flicks):
- His own ball's flight covered only 4-9 m of the ~10 m to `netStand`.
- His next ball was usually met from mid-court, 7-10 m out, which is OUTSIDE `netFrom` (7 m).
- The approach flag lived for one ball, so the recovery after that volley (`rallyHome`: outside the net
zone → baseline) walked him straight back. Forward a little, then back: exactly the report.
**Now `P.post`** ('net' / 'base') is set by the double tap and holds:
- 'net' lasts until the next double tap, a lob he cannot get under and runs down (`plan.over === null` →
'base'), or the point's end (`setupPoint` clears it).
- `atNetHome` reads it first for the player, so every recovery heads for `netStand` at full pace.
`P.rushing` follows it, so every incoming ball is planned as a net man's.
- 'base' clears itself once he is back beyond `netFrom` + 0.5, so a short ball still pulls him in as before.
**After:** of his contacts while committed, ~35% are at the net (< 3.5 m), ~40% around the service line (the
first volley), ~20% at 7-10 m, and ~5% further. The stance is dropped only by lobs and point ends. Ladder
(σ60, 8 seeds): the base is unchanged; following in is now about neutral, still good against the pushers
(Steward 52 → 61), and no longer punished by the Brute and the Champion.
Test: committed to the net, a volley struck 9 m out keeps him coming in (< 7 m a second later); without the
stance the old recovery still takes him back.
# OPEN — leftovers, in rough priority order # OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks. Nothing here is broken; these are known gaps and unverified risks.
0. **THE STORY (2026-10-15) is LIVE on main (v385, merged from `story` on Oliver's "okay merge nach
main").** Phases 1-4 are built; open are the rest of phase 5 (Members' Night), a lesson for the double
tap, and story kills in the career stats. See "Session log: THE STORY, PHASES 1-4" and what follows.
00. **THE RALLY GOES FLAT (2026-10-13).** Every match converged on serve, both at the net,
reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the
opponent styles (v364) followed; the swing-log fixes ran to v371. Step 4: v372 measured why the ladder
could not climb (points decided by the player's whiffs); Oliver picked (b), shipped v373 (the follow-
through, put-aways, on his heels). Awaiting the phone verdict on v373, especially the half-second
flat-footed read and the net rushers. See "Step 4 (b)" above.
v374 adds the player's own net game: long up-flick = attack and follow in, long down-flick = lob and
go back, and the SMASH. See "The player's way to the net and back" above.
v375: the auto-run's shake is gone (a latched chase plan); he is a little stronger for it, so the bottom
of the Climb and classic easy may now be too easy. See "The auto-run stops shaking".
v376: the run to the net and back is a DOUBLE TAP while his own ball flies (replaces v374's flick length).
v377: the double tap is a stance (P.post) that holds until changed — v376 only moved him a few metres. Fallback tag `rally-baseline-v360`.
1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080 1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080
device px across). Oliver has not reported stutter, but he also has not played a late horde since. device px across). Oliver has not reported stutter, but he also has not played a late horde since.
**If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts. **If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts.
Binary file not shown.
+62 -1
View File
@@ -122,6 +122,51 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#qAsk { position: fixed; inset: 0; z-index: 6; background: rgba(0,0,0,0.55); #qAsk { position: fixed; inset: 0; z-index: 6; background: rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: center; } display: flex; align-items: center; justify-content: center; }
#quit[hidden], #qAsk[hidden] { display: none; } #quit[hidden], #qAsk[hidden] { display: none; }
/* THE STORY's screens — the storyboard's look: a dark club at night, Bangers shouting, Silkscreen
labelling, the weapons the only pink. One column, phone first; the world is paused beneath. */
#sty { position: fixed; inset: 0; z-index: 7; display: grid; place-items: center; overflow: hidden;
background: radial-gradient(circle at 50% 28%, var(--sty-glow, #3a1d16), #0b0a0c 72%);
color: #e8e8dc; font: 400 14px Silkscreen, monospace; -webkit-tap-highlight-color: transparent;
padding: env(safe-area-inset-top, 0px) max(16px, env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) max(16px, env(safe-area-inset-left, 0px)); }
#sty[hidden] { display: none; }
#sty .col { width: min(420px, 100%); display: grid; gap: 14px; justify-items: center; text-align: center; }
#sty .bgc { position: absolute; inset: 0; background-size: cover; background-position: center 30%;
image-rendering: pixelated; opacity: .55; }
#sty .bgc::after { content: ''; position: absolute; inset: 0; background: linear-gradient(#0009, #000d 80%); }
#sty .col { position: relative; }
#sty .lab { font: 400 10px Silkscreen, monospace; letter-spacing: 2px; color: #8fa0b8; text-transform: uppercase; }
#sty .gold { color: #e0cc72; } #sty .hot { color: #d9705f; } #sty .pink { color: #ff5cf0; }
#sty .huge { font: 400 92px/0.9 Bangers, Impact, sans-serif; text-shadow: 4px 4px 0 #000; margin: 0; }
#sty .big { font: 400 34px/1 Bangers, Impact, sans-serif; text-shadow: 3px 3px 0 #000; margin: 0; }
#sty .mid { font: 400 24px/1 Bangers, Impact, sans-serif; text-shadow: 2px 2px 0 #000; }
#sty img { image-rendering: pixelated; }
#sty .badge { width: 46px; }
#sty .stats { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: 6px 12px; text-align: left;
font: 400 18px 'Jersey 15', sans-serif; }
#sty .stats b { font-weight: 400; font-variant-numeric: tabular-nums; }
#sty .rack { width: 100%; display: flex; justify-content: space-around; align-items: end; gap: 6px; min-height: 92px;
padding: 12px 6px; border-block: 3px solid #6b4a2a; background: #0006; flex-wrap: wrap; }
#sty .rack div { display: grid; justify-items: center; gap: 4px; }
#sty .rack img { height: 40px; filter: drop-shadow(0 0 6px #ff5cf0); }
#sty .pips { width: 100%; display: grid; grid-template-columns: repeat(10, 1fr); gap: 3px; }
#sty .pips i { height: 10px; border-radius: 2px; background: #ffffff30; }
#sty .pips i.m { background: #e0cc72; } #sty .pips i.b { background: #d9705f; }
#sty .row { width: 100%; display: flex; justify-content: space-between; }
#sty .chips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
#sty .chip { font: 400 9px Silkscreen, monospace; letter-spacing: 1px; padding: 6px 8px; border: 1px solid #ffffff40; border-radius: 3px; }
#sty .next { width: 100%; height: 92px; border-radius: 4px; background-size: cover; background-position: center;
image-rendering: pixelated; display: flex; align-items: flex-end; padding: 8px; text-align: left; box-sizing: border-box; }
#sty .next .lab { text-shadow: 1px 1px 0 #000, 0 0 6px #000; }
#sty .btn { max-width: 320px; }
#sty .tap { animation: styBlink 1.2s steps(2) infinite; }
#sty .r { animation: styRise .5s ease-out both; } #sty .s { animation: stySlam .55s cubic-bezier(.2,.9,.3,1.2) both; }
#sty .d { animation: styDrop .6s cubic-bezier(.2,.8,.3,1.1) both; } #sty .p { animation: styPan 2.4s ease-out both; }
@keyframes styRise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
@keyframes stySlam { 0% { opacity: 0; transform: scale(2.2); } 60% { opacity: 1; transform: scale(.94); } 100% { transform: none; } }
@keyframes styDrop { 0% { opacity: 0; transform: translateY(-110px) rotate(-12deg); } 70% { opacity: 1; transform: translateY(6px) rotate(3deg); } 100% { transform: none; } }
@keyframes styPan { from { background-position: center 0%; } to { background-position: center 55%; } }
@keyframes styBlink { 50% { opacity: .3; } }
@media (prefers-reduced-motion: reduce) { #sty * { animation: none !important; } }
.qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px; .qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px;
width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; } width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; }
/* it is a QUESTION, and a question calls — the one Bangers line on this overlay */ /* it is a QUESTION, and a question calls — the one Bangers line on this overlay */
@@ -205,6 +250,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro .slogan b { color: #c9564a; } #intro .slogan b { color: #c9564a; }
/* which build is served — filled in by main.js, empty (and invisible) if the header is missing */ /* which build is served — filled in by main.js, empty (and invisible) if the header is missing */
#build { margin: 14px 0 0; font-size: 9px; color: #4a5a52; letter-spacing: 0.5px; } #build { margin: 14px 0 0; font-size: 9px; color: #4a5a52; letter-spacing: 0.5px; }
#mReset { display: block; margin: 6px auto 0; padding: 6px 10px; background: none; border: 1px solid #2c3a33;
color: #6b7a72; font: 400 9px Silkscreen, monospace; letter-spacing: 1px; cursor: pointer; }
#mReset.arm { color: #d9705f; border-color: #d9705f; }
#splash.busy #mReset { display: none; }
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame /* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */ court through between menus. */
@@ -556,6 +605,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
background-size: cover; background-position: center; image-rendering: pixelated; background-size: cover; background-position: center; image-rendering: pixelated;
text-align: left; cursor: pointer; } text-align: left; cursor: pointer; }
#modeList button.mode:last-child { grid-column: 1 / -1; } #modeList button.mode:last-child { grid-column: 1 / -1; }
/* THE ARCADE OPENS WITH THE STORY: a shut door is shown, greyed, with the act that opens it */
#modeList button.mode.locked, #mGore.locked { filter: grayscale(1) brightness(.5); cursor: default; }
#modeList button.mode .cb i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace; letter-spacing: 1px; color: #e0cc72; }
.nope { animation: nope .35s; }
@keyframes nope { 20%, 60% { transform: translateX(-5px); } 40%, 80% { transform: translateX(5px); } }
#vsHostLock { font: 400 10px Silkscreen, monospace; color: #e0cc72; letter-spacing: 1px; margin: 0 0 8px; }
#modeList button.mode::after { content: ''; position: absolute; inset: 0; pointer-events: none; #modeList button.mode::after { content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(0deg, rgba(6,10,8,.94), rgba(6,10,8,.2) 65%, transparent); } background: linear-gradient(0deg, rgba(6,10,8,.94), rgba(6,10,8,.2) 65%, transparent); }
#modeList .cb { position: relative; z-index: 1; } #modeList .cb { position: relative; z-index: 1; }
@@ -871,6 +926,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn sec" id="qYes">LEAVE MATCH</button> <button class="btn sec" id="qYes">LEAVE MATCH</button>
</div> </div>
</div> </div>
<!-- THE STORY's screens (2026-10-15, PROJECT.md "THE STORY", phase 3): the act title card, the cup's
ceremony with its loot, and the act card. Built by main.storyScreen; the world is paused under it. -->
<div id="sty" hidden></div>
<div id="intro" data-stage="splash"> <div id="intro" data-stage="splash">
<div class="card"> <div class="card">
<!-- The title screen IS the menu. A separate menu stage behind it was the same logo over the <!-- The title screen IS the menu. A separate menu stage behind it was the same logo over the
@@ -919,6 +977,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn sec" id="mRanks">WORLD BOARD</button> <button class="btn sec" id="mRanks">WORLD BOARD</button>
</div> </div>
<p id="build"></p> <p id="build"></p>
<!-- TEST ONLY (Oliver, 2026-10-15): start the story over. config.STORE.testReset hides it. -->
<button id="mReset" type="button">RESET STORY</button>
</div> </div>
</div> </div>
<!-- Additional solo modes; multiplayer has a separate splash entrance. --> <!-- Additional solo modes; multiplayer has a separate splash entrance. -->
@@ -1046,6 +1106,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off" <input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false"> autocapitalize="characters" spellcheck="false">
<h3>HOST</h3> <h3>HOST</h3>
<p id="vsHostLock"></p>
<button class="btn" id="vsHostC">CLASSIC</button> <button class="btn" id="vsHostC">CLASSIC</button>
<button class="btn" id="vsHostD">DEATH MATCH</button> <button class="btn" id="vsHostD">DEATH MATCH</button>
<h3>JOIN</h3> <h3>JOIN</h3>
@@ -1140,6 +1201,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
<span id="recLamp" hidden>REC</span> <span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=360"></script> <script type="module" src="src/main.js?v=387"></script>
</body> </body>
</html> </html>
+7
View File
@@ -126,6 +126,13 @@ def clean_profile(payload):
'at': at, 'at': at,
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX], 'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')} 'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
# THE STORY (2026-10-15): the act and the checkpoint, nothing else. The rack a checkpoint
# hands back stays on the device — a wiped phone restarts the act's chapter, not its middle.
st = p.get('story')
if isinstance(st, dict):
cp = str(st.get('cp', 'cup'))
out['story'] = {'act': max(1, min(int(st.get('act', 1)), 5)),
'cp': cp if cp in ('pro', 'cup', 'chap', 'mid', 'done') else 'cup'}
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return who, out return who, out
+3 -2
View File
@@ -1,6 +1,7 @@
// All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback. // All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback.
import { crowd } from './state.js'; // the aww carries an applause trail, and the stand has to match it import { crowd } from './state.js'; // the aww carries an applause trail, and the stand has to match it
import { GORE_BED } from './config.js'; // which track the gore bed is (metal, when Oliver has one)
let AC = null; let AC = null;
export const audio = () => { export const audio = () => {
@@ -210,7 +211,7 @@ export const ambient = () => {
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is // ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE // 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
// length as loopEnd, not the decoder's padded one. See the note on menuMusic. // length as loopEnd, not the decoder's padded one. See the note on menuMusic.
export const zombieMusic = () => { loopBed('zombie-mode.m4a', 800, 0.2, 20.47077); zombieDuck(false); }; export const zombieMusic = () => { loopBed(GORE_BED.file, GORE_BED.lowcut ?? 800, GORE_BED.vol, GORE_BED.loopEnd); zombieDuck(false); };
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not // Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at // an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone // boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
@@ -244,7 +245,7 @@ function fadeBed(file, out, secs = 0.7) {
b.g.gain.setValueAtTime(b.g.gain.value, t); // anchor at current level so a mid-fade toggle stays smooth b.g.gain.setValueAtTime(b.g.gain.value, t); // anchor at current level so a mid-fade toggle stays smooth
b.g.gain.linearRampToValueAtTime(out ? 0 : b.vol, t + secs); b.g.gain.linearRampToValueAtTime(out ? 0 : b.vol, t + secs);
} }
export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must match loopBed's, filename and all export const zombieDuck = out => fadeBed(GORE_BED.file, out); // key must match loopBed's, filename and all
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up // The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch. // on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
export const menuDuck = out => fadeBed('background-music.m4a', out); export const menuDuck = out => fadeBed('background-music.m4a', out);
+262 -12
View File
@@ -7,13 +7,14 @@
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as // (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.) // input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js'; import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel, WEAPONS, STORY, ROMAN, storyAct, storyRounds, storyStock, storyBossArt, storyBossChapter, STORY_HELP } from './config.js';
import { endRun, MODE_MATCH } from './stats.js'; import { endRun, MODE_MATCH } from './stats.js';
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.js'; import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js'; import { voice, zombieDuck, zombieMusic, aww, applause, shriek, boom, splat } from './audio.js';
import { haptic } from './haptics.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js'; import { cheer, aside, endBurst, deliverTin, showModal, logSwing, logPoint, advancePoint, scoreKey, grantWeapon } from './gameplay.js';
import { startDrillStream } from './drill.js'; import { startDrillStream } from './drill.js';
import { recordEvent } from './recorder.js'; import { recordEvent } from './recorder.js';
import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon
@@ -28,6 +29,9 @@ export function proceedAfterClear() {
// duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.) // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
// The chapter boss outranks everything here, including a set: a boss walking on into a standing // The chapter boss outranks everything here, including a set: a boss walking on into a standing
// wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat. // wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
// THE STORY's bosses ride the same slot, ahead of everything: the mid-boss at game 5, the act's
// boss at the chapter's last game (splatPoint arms G.storyBoss; the arcade's G.bossDue is never set in a chapter)
if (G.storyBoss) { const k = G.storyBoss; G.storyBoss = null; G.afterDuel = mode || 'game'; startStoryBoss(k); return; }
if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; } if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
// The set/horde beat outranks the duel; the duel just waits for the next clear. // The set/horde beat outranks the duel; the duel just waits for the next clear.
if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; } if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
@@ -68,6 +72,10 @@ export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped. // another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return; if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return;
// THE STORY HAS NO DUEL (Oliver, 2026-10-15): the rack is the cup's loot, the barrels and the
// restock after the mid-boss; healing is the mid-boss and the act start. The racket zombie still
// walks on in a chapter — as an ordinary body that returns balls.
if (G.story) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins G.weapon = 0; // tennis rules for the duel: plain balls, no tins
@@ -96,16 +104,16 @@ export function startDuel() {
// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one). // decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary // It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs. // instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) { export function startBossFight(chapter, design = null) {
if (G.bossFight || G.drill || G.match || G.bench || G.net) return; if (G.bossFight || G.drill || G.match || G.bench || G.net) return;
G.bossFight = chapter; G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false; G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
ball.active = false; setupPoint(); ball.active = false; setupPoint();
spawnChapterBoss(chapter); spawnChapterBoss(chapter, design);
zombieDuck(true); // the music drops out for it zombieDuck(true); // the music drops out for it
aside('ump_boss_arrives'); voice('sudden_death', 2.6, 0.85); // the stake is an event; the quip is benched (see aside()) aside('ump_boss_arrives'); voice('sudden_death', 2.6, 0.85); // the stake is an event; the quip is benched (see aside())
const art = bossArt(chapter); const art = design || bossArt(chapter);
// THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in // THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in
// the same slot and size as a routine set, and it read as the boss simply being there. This is on // the same slot and size as a routine set, and it read as the boss simply being there. This is on
// the sanctioned modal list (a boss stage is guaranteed calm — the two lines above just cleared // the sanctioned modal list (a boss stage is guaranteed calm — the two lines above just cleared
@@ -121,9 +129,21 @@ export function startBossFight(chapter) {
export function endBossFight() { export function endBossFight() {
if (!G.bossFight) return; if (!G.bossFight) return;
const c = G.bossFight; G.bossFight = 0; const c = G.bossFight; G.bossFight = 0;
const fight = G.story && G.story.fight;
if (G.story) G.story.fight = null;
zombieDuck(false); zombieDuck(false);
shots.length = 0; shots.length = 0;
cheer(3.4, 0.9); voice('unbelievable', 0.3); cheer(3.4, 0.9); voice('unbelievable', 0.3);
// THE ACT'S BOSS ENDS THE ACT: no game beat is handed back, nothing more walks on (bossFight stays
// up, which is what holds every spawn), and once the body has come apart the act card takes over
if (fight === 'end') {
G.bossFight = c; G.afterDuel = null;
G.story.winT = TUNE.bossDeathT + TUNE.bossWinT;
G.card = { title: 'EPIC!', sub1: storyBossArt(G.story.act, 'end').name + ' IS DOWN', color: UI.pick,
t: TUNE.bossWinT, wait: TUNE.bossDeathT };
ball.active = false; G.phase = 'over'; G.overT = G.story.winT + 1;
return;
}
// hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It // hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It
// used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game // used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game
// that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it // that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it
@@ -135,6 +155,7 @@ export function endBossFight() {
// card that repeats what the HUD already says is the clutter this pass is meant to remove.) // card that repeats what the HUD already says is the clutter this pass is meant to remove.)
G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT, G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT,
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
if (fight === 'mid') storyCheckpoint();
ball.active = false; ball.active = false;
// a duel the handback just started owns the phase; otherwise hold the boss beat, and never // a duel the handback just started owns the phase; otherwise hold the boss beat, and never
// SHORTEN a longer one the handback set (a changeover's 5 s outlasts this) // SHORTEN a longer one the handback set (a changeover's 5 s outlasts this)
@@ -362,9 +383,9 @@ export function watchdog(dt) {
// opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a // opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a
// felled body walks straight back on, and bleeding out costs a modal instead of the run. All of // felled body walks straight back on, and bleeding out costs a modal instead of the run. All of
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint. // it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
// `draw` is DEAD weight kept only for call-site arity — the cup runs easy to hard now // `rounds` (the old dead `draw` slot) lets THE STORY hand in its own cup (config.storyRounds: the
// (cupRoundsFor is deterministic, 2026-08-29) and nothing reads it. // final is the act boss's body); null is the league's ordinary cup.
export function startMatch(cup = false, draw = null, train = 0) { export function startMatch(cup = false, rounds = null, train = 0) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
// THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md). // THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md).
// Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module. // Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module.
@@ -379,7 +400,8 @@ export function startMatch(cup = false, draw = null, train = 0) {
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream // on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies // scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
// by the round's `need`), and where a won game goes (cupGameWon). See config.cupRoundsFor. // by the round's `need`), and where a won game goes (cupGameWon). See config.cupRoundsFor.
if (cup) { G.match.cup = { round: 0, g: 0, rounds: cupRoundsFor(G.league) }; cupDraw(); } if (G.story) G.league = storyAct(G.story.act).lg; // the act IS the league's world
if (cup) { G.match.cup = { round: 0, g: 0, rounds: rounds || cupRoundsFor(G.league) }; cupDraw(); }
G.matchFall = null; G.matchFall = null;
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated // mode's 4 was "too weak" here, felt not calculated
@@ -396,6 +418,14 @@ export function startMatch(cup = false, draw = null, train = 0) {
body: ['ONE BODY, ONE GAME AT A TIME', body: ['ONE BODY, ONE GAME AT A TIME',
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'], 'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'],
rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] }); rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] });
} else if (cup && G.story && storyUI.show) {
storyUI.show('act', { act: G.story.act }); // the act title card (it pauses the world itself)
} else if (cup && G.story) {
const A = storyAct(G.story.act), R = G.match.cup.rounds;
showModal({ title: 'ACT ' + ROMAN[G.story.act], color: UI.warm, strap: leagueOf(A.lg).name,
body: ['WIN THE CUP — ' + R.length + ' ROUNDS', R[R.length - 1].name + ': ' + matchFoe(A.rounds[R.length - 1]).name],
rules: ['WIN THE CUP — ' + R.length + ' ROUNDS',
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
} else if (cup && !G.seen.cup) { } else if (cup && !G.seen.cup) {
G.seen.cup = true; G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub, showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
@@ -685,7 +715,7 @@ function matchOpponent() {
showModal({ title: zb.foe.name, color: UI.hot, showModal({ title: zb.foe.name, color: UI.hot,
body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub), body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub),
foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line, foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line,
lvl, mine, peer, sub } }); lvl, mine, peer, sub, style: STYLES[zb.foe.style]?.tag } });
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
@@ -896,6 +926,9 @@ function cupGameWon(zb, banked = false) {
} }
// the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch) // the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch)
award('round'); award('round');
// THE STORY's final does not fall: the finalist stays on his feet for the ceremony, and then grows
// into the act's boss in front of you (storyMorph)
if (G.story && cup.round === cup.rounds.length - 1) return storyMorph(zb);
d.afterFall = () => { d.afterFall = () => {
matchHealBeat(); matchHealBeat();
cup.round++; cup.g = 0; cup.round++; cup.g = 0;
@@ -917,6 +950,7 @@ function cupGameWon(zb, banked = false) {
// summary over a still court, then the gated restart). Banked through the same endRun as every // summary over a still court, then the gated restart). Banked through the same endRun as every
// match run, not dead: the stats screen's deaths column keeps meaning what it means. // match run, not dead: the stats screen's deaths column keeps meaning what it means.
function cupWon() { function cupWon() {
if (G.story) return; // the story's final ends in storyMorph instead
const d = G.match; const d = G.match;
d.ending = false; d.mopT = 0; d.ending = false; d.mopT = 0;
G.interlude = false; G.interlude = false;
@@ -997,6 +1031,7 @@ export function resolveInterlude(side, reason = 'unspecified') {
// G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs. // G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs.
if (G.match.decided && !G.matchEarly) { ball.active = false; return; } if (G.match.decided && !G.matchEarly) { ball.active = false; return; }
if (!G.match.ending) recordEvent('point', { winner: side, reason }); if (!G.match.ending) recordEvent('point', { winner: side, reason });
if (!G.match.ending) logPoint(side, reason); // the swing log's WON/LOST line (scoreboard tap)
// netplay (death match): this phone judged the live flight — the verdict crosses the wire // netplay (death match): this phone judged the live flight — the verdict crosses the wire
// before it is applied, exactly as pointTo does for classic. netJudging() is false while a // before it is applied, exactly as pointTo does for classic. netJudging() is false while a
// remote verdict is being applied, which is what stops the echo. // remote verdict is being applied, which is what stops the echo.
@@ -1079,9 +1114,10 @@ export function startHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
const slow = G.story && G.story.help ? 1 + STORY_HELP.slow * G.story.help : 1; // THE STORY's help offer
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
// further behind on every set banked (the second horde was where every run ended) // further behind on every set banked (the second horde was where every run ended)
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow }; G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow / slow, mgRate: TUNE.mgRate * grow };
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
@@ -1090,3 +1126,217 @@ export function startHorde() {
G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave
return true; return true;
} }
// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY") ---------------------------------------------
// Five acts, one per league and court: the league cup, the transformation, one ten-game gore chapter
// with a mid-boss at STORY.midAt (the checkpoint) and the act's boss at its last game, then the act card. Same
// court-ownership contract as every mode in this file. The PLACE in the story lives in the profile
// (prof.story: act, checkpoint, the rack it hands back) because restartRun wipes G; G.story is only
// what this run is doing with it. Death is not permadeath here: it costs the attempt and restarts
// from the checkpoint (storyDeath, then main.maybeRestart re-enters through startStory).
// THE STORY's screens live in the page (main.storyScreen, phase 3). The hook is filled by main; headless
// (the suite) it stays null and every screen falls back to the plain card it replaced.
export const storyUI = { show: null };
export function startStory() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net || G.story) return false;
const s = prof.story;
if (s.cp === 'done') { s.act = 1; s.cp = 'cup'; s.rack = []; s.tries = 0; } // the story told again
prof.lg = Math.max(prof.lg, storyAct(s.act).lg); // the act's league is open to the arcade too
profSave();
if (s.cp === 'pro') {
// main.start leaves the world in 'tennis' for it: a real match, the turn still armed
G.story = { act: 1, part: 'pro', base: 0, fight: null, winT: 0 };
showModal({ title: 'PROLOGUE', color: UI.warm, strap: 'TERRE ROUGE · A SUMMER AFTERNOON',
body: ['A MATCH LIKE ANY OTHER', 'WIN A GAME'], rules: ['A MATCH LIKE ANY OTHER', 'WIN A GAME'] });
return true;
}
if (s.cp === 'cup') {
G.story = { act: s.act, part: 'cup', base: STORY.base(s.act), fight: null, winT: 0, help: s.help };
return startMatch(true, storyRounds(s.act));
}
return startStoryChapter(s.cp === 'mid');
}
// THE CHAPTER: the gore, from game 1 (or from game 6, after the mid-boss), on the act's court. The
// rack is the act's stock (or what the mid-boss checkpoint saved), the blood is full, and the
// arcade's director plays its games base+1..base+10 so every curve keeps its tuned slope.
export function startStoryChapter(fromMid = false, keepCrowd = false) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
const s = prof.story, act = s.act;
G.story = { act, part: 'chap', base: STORY.base(act), midDone: fromMid, fight: null, winT: 0, help: s.help };
G.league = storyAct(act).lg;
G.runGames = G.story.base + (fromMid ? STORY.midAt : 0);
score.games = [fromMid ? STORY.midAt : 0, 0]; score.pts = [0, 0]; G.sets = [0, 0]; G.killCount = 0;
score.server = 'P';
G.hp = G.hpMax = TUNE.playerHp + STORY_HELP.heart * s.help; // the help offer's heart
const stock = fromMid && s.rack.length ? s.rack : storyStock(act);
G.rack = []; G.temp = null;
for (const e of stock) if (WEAPONS[e.id]) grantWeapon(e.id, e.n);
G.weapon = 0;
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; G.storyBoss = null;
G.changeover = false; G.clearing = false; G.afterClear = null; G.afterDuel = null;
G.mgT = 0; endBurst(); G.boy = null;
if (!keepCrowd) zombies.length = 0;
pellets.length = 0; extras.length = 0; shots.length = 0;
s.cp = fromMid ? 'mid' : 'chap';
if (!fromMid) s.rack = stock.map(e => ({ ...e }));
profSave(); profPush();
zombieMusic(); // the gore bed — loaded here if no door loaded it yet
ball.active = false; setupPoint();
if (!keepCrowd) G.card = { title: 'CHAPTER ' + ROMAN[act], color: UI.hot, t: 3.2,
sub1: fromMid ? 'FROM GAME ' + (STORY.midAt + 1) + ' · ' + storyBossArt(act, 'end').name + ' WAITS AT ' + STORY.length
: STORY.length + ' GAMES · ' + storyBossArt(act, 'mid').name + ' AT ' + STORY.midAt };
return true;
}
// THE CUP IS WON — AND THE FINALIST GROWS (Oliver, 2026-10-15: "der besiegte Player-Zombie verwandelt
// sich in den sehr viel größeren Endgame-Zombie, gleicher Charakter nur größer, als Preview für das
// Endmatch des Akts, rennt aus dem Bild, während der Gore-Mode beginnt"). The beaten body does not
// fall: the ceremony (the loot) lands over a standing court, then the timeline in tickStory runs.
// 0.0 the ceremony modal (the world waits for its tap)
// 1.2 the applause is cut, "quiet, please", the camera closes in on him
// 2.8 THE TURN: hitstop, flash, he bursts — and the act's boss stands where he stood, growing
// 3.3 his name and "GAME <length>"
// 4.6 he runs out of the frame; the stands empty onto the court; the metal comes in
// 6.4 game 1 of the chapter, without a cut (the crowd that came down is its first wave)
// The spawn director, the boss's AI and serving all stay held by the interlude until the chapter.
const MORPH = { quiet: 1.2, turn: 2.8, name: 3.3, run: 4.6, play: 6.4 };
function storyMorph(zb) {
const d = G.match, act = G.story.act;
d.ending = true;
award('cup');
if (!d.train) { recordCupWin(G.league, levelAt(prof.xp)); recordCup(G.league, d.cup.rounds.length); }
cheer(4, 1); voice('unbelievable', 0.4);
const stock = storyStock(act);
// the match is over; its body stays on court for the turn (cine: nothing moves it)
G.match = null; G.matchFall = null;
if (zb) { zb.cine = true; zb.vx = zb.vz = 0; zb.pose = 'idle'; }
G.interlude = { introT: 99 }; // nothing serves, spawns or attacks through the scene
ball.active = false; G.phase = 'over'; G.overT = 99;
G.story.part = 'morph';
G.story.morph = { t: 0, x: zb ? zb.x : 0, z: zb ? zb.z : -1, body: zb, boss: null, stock };
prof.story.tries = 0; prof.story.help = 0; // a checkpoint passed: the help offer starts over
// the ceremony: the loot screen pauses the world, and the timeline starts when it is gone
if (storyUI.show) storyUI.show('loot', { act, stock, rounds: d.cup.rounds.length });
else showModal({ title: 'THE CUP IS YOURS', color: UI.pick,
body: ['…AND THESE FOR THE HORDES TO COME', stock.map(e => WEAPONS[e.id].label).join(' · ')] });
}
function tickMorph(dt) {
const m = G.story.morph, act = G.story.act;
const t0 = m.t, t = (m.t += dt);
const at = k => t0 < MORPH[k] && t >= MORPH[k];
if (at('quiet')) {
applause(0.3, 0.2); voice('quiet_dark', 0, 0.82);
G.zoom = { x: m.x, z: m.z, k: 2.1, t: 0, T: 3.2 }; // peaks (p = 0.5) on the turn
}
if (at('turn')) {
G.hitstop = 0.16; haptic('heavy'); G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 };
shriek(); boom(0.6); splat(1);
const i = zombies.indexOf(m.body); if (i >= 0) zombies.splice(i, 1);
explode(m.x, m.z);
spawnChapterBoss(storyBossChapter(act, 'end'), storyBossArt(act, 'end'));
const b = zombies[zombies.length - 1];
b.x = m.x; b.z = m.z; b.cine = true; b.full = b.scale; b.scale = b.full * 0.45;
m.boss = b;
}
if (m.boss && t >= MORPH.turn && t < MORPH.run) { // he grows into the frame
m.boss.scale = m.boss.full * Math.min(1, 0.45 + 0.55 * (t - MORPH.turn) / 0.5);
}
if (at('name')) G.card = { title: storyBossArt(act, 'end').name, sub1: 'GAME ' + STORY.length, color: UI.hot, t: 2.4 };
if (at('run')) {
// the stands empty onto the court: the chapter's first wave walks on from the far side
for (const [x, z, v] of [[-3.5, -1.6, 'a'], [2.5, -2.1, 'b'], [-1.2, -2.6, 'a'], [3.8, -1.2, 'b']]) spawnZombie(x, z, v);
G.shake = { t: 0.5, mag: 6 }; voice('remain_seated', 0, 0.95);
zombieMusic(); // THE METAL comes in here (config.GORE_BED)
}
if (m.boss && t >= MORPH.run) { // ...and he runs out of the picture, left
const step = 8 * dt;
m.boss.x -= step; m.boss.dist = (m.boss.dist || 0) + step;
}
if (t >= MORPH.play) {
const i = zombies.indexOf(m.boss); if (i >= 0) zombies.splice(i, 1);
G.story.morph = null;
G.interlude = false; G.story = null;
startStoryChapter(false, true);
G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 };
}
}
function startStoryBoss(kind) {
const act = G.story.act;
G.story.fight = kind;
startBossFight(storyBossChapter(act, kind), storyBossArt(act, kind));
// no story boss crosses the net — except THE CHAMPION, whose MATCH POINT rush IS the finale
const b = zombies.find(z => z.variant === 'B');
if (b) b.holdNet = !(act === 5 && kind === 'end');
}
// THE MID-BOSS IS DOWN: the checkpoint. The rack is topped back up to the act's stock (never down),
// the blood is full — the two things the duel used to pay — and both are saved, so a death in games
// 6 to 10 restarts here.
function storyCheckpoint() {
const s = prof.story;
G.story.midDone = true;
for (const e of storyStock(G.story.act)) {
const have = G.rack.find(r => r.id === e.id);
const n = e.n - (have ? have.n : 0);
if (n > 0) grantWeapon(e.id, n);
}
G.weapon = 0;
G.hp = G.hpMax;
s.cp = 'mid'; s.tries = 0; s.help = 0;
s.rack = G.rack.map(e => ({ id: e.id, n: e.n }));
profSave(); profPush();
G.card = { title: 'CHECKPOINT', sub1: 'RESTOCKED · FULL HEALTH', color: UI.pick, t: TUNE.bossWinT,
wait: TUNE.bossDeathT };
}
// THE ACT'S BOSS HAS COME APART: the act is cleared. The next act's cup is the new checkpoint and
// its league opens for every mode; act V tells the story to its end (the champion's flag).
function storyActCleared() {
const s = prof.story, act = G.story.act;
award('round');
if (act < 5) { s.act = act + 1; s.cp = 'cup'; prof.lg = Math.max(prof.lg, storyAct(act + 1).lg); }
else { s.cp = 'done'; prof.ch = 1; }
const deaths = s.deaths;
s.rack = []; s.tries = 0; s.deaths = 0; s.help = 0;
profSave(); profPush();
G.story.cleared = act;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
G.done = { won: true, t: 0, story: act };
// the act card (main.storyScreen) owns the handover; headless, the canvas summary does
if (storyUI.show) storyUI.show('clear', { act, deaths });
}
// Ticked from update beside tickMatchFall: the act boss's death beat before the act card.
export function tickStory(dt) {
if (!G.story) return;
if (G.story.morph) return tickMorph(dt);
if (G.story.winT > 0 && (G.story.winT -= dt) <= 0) { G.story.winT = 0; storyActCleared(); }
}
// THE PROLOGUE: one game of tennis against a LIVING opponent, and the old transformation exactly as
// it was written (gameplay.startTransition: the living man turns). It tells the outbreak once, and
// it is classic tennis's place in the story. When the transformation hands over (its 'go' beat),
// the dead take their seats for the first act's cup.
export function storyPrologueGo() {
if (!G.story || G.story.part !== 'pro') return;
storyPrologueDone(); // straight on: the act's card, then its cup
}
function storyPrologueDone() {
prof.story.cp = 'cup'; profSave(); profPush();
zombies.length = 0; pellets.length = 0; shots.length = 0;
G.story = null;
startStory();
}
// zombies.killPlayer hands a story death here instead of banking a run: the attempt is counted
// (the help offer reads it) and the checkpoint stands.
export function storyDeath() {
G.card = null; // the world freezes in 'eaten': a card would hang there
prof.story.tries++; prof.story.deaths++;
profSave(); profPush();
G.story.dead = true;
}
+191 -21
View File
@@ -74,6 +74,52 @@ export const TUNE = {
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not 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 // send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is. // gets there flatter and sooner, which is what a smash is.
// THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT"). RALLY MODES ONLY
// (classic + every match). Gore's tennis prologue and the duel keep SHOT_DEPTH and HOME. The rally
// ball used to land ON the service line (SHOT_DEPTH.mid 5.4) and both sides met it as early as
// they could, so every point became a volley exchange at the T at ~0.7 s per shot.
rallyMid: 2.8, // m inside the receiver's baseline a plain rally ball aims for (gore: 5.4)
rallyDeep: 1.9, // ...and a forward flick (gore: 3.0). Mistiming only ever SHORTENS these,
// so depth adds no long out
shortK: 3.0, // m of depth a quality-0 ball loses (rolled x0.5..1.5): a mistimed ball
// sits up SHORT, which is the ball a real opponent attacks
shortBall: 4.9, // m from the receiver's baseline past which a landing is SHORT: he runs
// in and takes it on the rise (serves never count)
netFrom: 7.0, // m from the net inside which a body is AT THE NET: it volleys, and it
// recovers to netStand instead of the baseline
netStand: 2.6, // m from the net a net player recovers to
rallyBack: 0.6, // m behind the baseline a baseliner recovers to
rallyBehind: 2.2, // m behind the baseline he will drop back to let a deep ball come down
rallyHit: 1.0, // m: a baseliner meets the ball falling through this height
rallyTop: 1.7, // m: ...and the player's auto-run accepts a kicking bounce up to here (chest high)
lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up
scoopY: 0.45, // m: a ball met lower than this after its bounce is dug out, same cap
// THE SOFT BALL (step 3): the down-flick, its length = how far. Drop at one end, lob at the other.
dropMax: 90, // px: a down-flick shorter than this is the DROP, this long or longer the LOB
dropNet: 2.0, // m past the net a shortest down-flick lands
dropApex: 2.0, // m its arc peaks at
dropSpin: 0.3, // share of its pace a drop keeps through the bounce (backspin: it dies)
spinLow: 0.5, // ...and how much of that loss backspin also takes off the rebound HEIGHT
lobDeep: 1.6, // m inside his baseline a full-length down-flick lands
lobApex: 5.5, // m its arc peaks at: an ATTACKING lob — over a net man's racket (CONTACT.maxY
// 2.3) yet quick. A 7 m moonball hung 2.4 s and every net man ran it down
softSitter: 5.5, // m from his baseline a badly timed soft ball drifts toward (a sitter)
softErr: 0.8, // ...and how far toward it at quality 0
lobTurn: 0.6, // leg speed of an opponent lobbed at the net: he turns and backpedals
netReact: 0.22, // s an opponent at the net stands reading the pass before he moves
dblTap: 0.35, // s: two taps this close while his OWN ball flies are the move — to the net, or back
smashMinY: 1.9, // m: a ball the player takes OUT OF THE AIR at least this high is a SMASH...
smashMaxY: 2.5, // ...up to this high (above CONTACT.maxY: the overhead reaches higher than a volley —
// but under the 2.8 m the opponent's lob clears a net man by, or he smashes it standing)
smashPace: 0.8, // ...extra pace on top of the forward flick's, scaled by timing
smashRead: 0.3,
runSmooth: 10, // 1/s: the auto-run's target eases toward each fresh plan instead of jumping to it
runSnap: 1.5, // m: a re-plan this far away (a new shot, the lob read) is taken at once
runStop: 0.12,
runKeep: 0.06, // s of reach slack intercept grants the point he is already running to (hysteresis) // m: a running auto-run stops this close to its target (it starts at 0.35)
smashRun: 0.25, // share of his legs a lobbed net man may need to get UNDER the lob for the smash —
// a short lob is smashed, a deep one is not (he turns and runs it down, lobTurn) // s the opponent stands reading a smash before he moves
softRead: 0.35, // s any opponent stands reading a soft ball (drop, lob) — they are disguised
aimError: 2.4, // m of scatter at quality 0 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 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 // ball further OUT (the chosen out-risk keeps its exact shape) but only
@@ -119,6 +165,18 @@ export const TUNE = {
// screen: a short swipe is a safe half-court ball, and only a committed // screen: a short swipe is a safe half-court ball, and only a committed
// one goes hunting the line, which is what makes going OUT a chosen risk. // one goes hunting the line, which is what makes going OUT a chosen risk.
perfectMs: 45, // |offset from window center| for PERFECT perfectMs: 45, // |offset from window center| for PERFECT
swingTail: 0.06, // s: rally modes, a groundstroke swing still meets a ball arriving this long after
// its window closes — the follow-through, q 0.05 (a weak short ball, not a whiff).
// 0 = the old edge: a tap ~70 ms early whiffed (step 4, see gameplay.trySwing)
punishBelow: 0.5, // a player ball under this q is a SITTER the CPU may put away (gameplay.punishes)
punishMin: 0.15, // ...the chance it does at the bottom of the ladder (0.2 until the flattening)
punishMax: 0.8, // ...and at the CHAMPION (0.85 until 2026-10-15; classic: DIFFS[].punish)
punishQ: 0.95, // the put-away's quality, played to the open court...
punishX: 3.5, // ...at this x, inside the singles line (4.1), bounded by the rung's width dial
punishRead: 0.5, // s the player's auto-run stands flat-footed on a put-away of his own sitter
qSeamless: 1, // rally modes: past perfectMs, q falls from 1 to the window edge with no step.
// 0 = the old curve, 1 - off/half, which dropped a groundstroke 1 ms past
// PERFECT straight to q 0.43 (step 4, 2026-10-14 — see gameplay.trySwing)
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes // The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
// back — duels use racket* dials), so these three are the "how long until the first game is won" // back — duels use racket* dials), so these three are the "how long until the first game is won"
// knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and // knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and
@@ -260,6 +318,7 @@ export const TUNE = {
bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp
// above just made that MORE likely, not less. Only 'charge' breaks the band. // above just made that MORE likely, not less. Only 'charge' breaks the band.
bossChargeV: 4.2, // m/s the physio rushes at once it commits bossChargeV: 4.2, // m/s the physio rushes at once it commits
bossNetStop: 1.0, // m short of the net a STORY boss's rush stops (holdNet): the threat without the reach
bossChargeT: 2.2, // s ONE RUSH takes, start to arrival — the charge speed is derived from it and bossChargeT: 2.2, // s ONE RUSH takes, start to arrival — the charge speed is derived from it and
// from how far away it is (bossAI), so the run-up reads the same every time and // from how far away it is (bossAI), so the run-up reads the same every time and
// is long enough to see coming. Was 1.5 s at a flat 4.2 m/s, which only reached // is long enough to see coming. Was 1.5 s at a flat 4.2 m/s, which only reached
@@ -560,6 +619,9 @@ export const TUNE = {
// the peer gift; reaching parity earns all of it. Never a penalty. // the peer gift; reaching parity earns all of it. Never a penalty.
peerRun: 0.08, // parity adds 8% legs on top of the permanent level bonus peerRun: 0.08, // parity adds 8% legs on top of the permanent level bonus
peerWindow: 0.12, // ...and 12% contact window. Skill still wins; grinding opens the margin peerWindow: 0.12, // ...and 12% contact window. Skill still wins; grinding opens the margin
legKnee: 6.0, // m/s past which an opponent's legs stop growing at full rate (gameplay.knee) —
// the player's auto-run is ~6.2. Was uncapped: the champion ran 15.7 m/s
legSoft: 0.15, // ...and the share of the growth past the knee that survives (champion ~7.5)
matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against
// the broken recovery (the player never walked back to centre, so every // the broken recovery (the player never walked back to centre, so every
// wide return looked like opponent strength) — with real footwork restored // wide return looked like opponent strength) — with real footwork restored
@@ -594,6 +656,12 @@ export const TUNE = {
// auto-run player can actually cover. Its scatter painted lines from game one // auto-run player can actually cover. Its scatter painted lines from game one
// ("still a LOT of unreachable balls"); now line-painting is the late ladder // ("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) matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer)
// ...and NEVER WIDER THAN THIS (Oliver, 2026-10-15: "ab Runner ist das Zombie-Game schon sehr schwer —
// die Schwierigkeitsleiter ein wenig abflachen"). Measured: the ladder fell off a cliff wherever its
// width crossed ~3.3 m — the auto-run's reach from the centre — at the Steward and again at the
// Seller, and from there every line could be painted. Capped at 3.0, the late ladder climbs through
// put-aways, flub, pace and open-court aim instead (bot, level 10: Runner 44 -> 68%, Champion 24 -> 33%)
matchWideMax: 3.0,
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate) 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 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. // the world on G.done, so this beat is the only thing that lets the gore fly.
@@ -607,6 +675,14 @@ export const TUNE = {
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre) // lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
ringTime: 1, // 1 = the ring is TIMED (gameplay.ringCue): gold while a touch now centres the contact in
// the swing window, at any pace. 0 = the old distance ring (matchRingSweet/Band)
ringBand: 0.35, // share of the half-window either side of the sweet moment that draws gold (edge = q 0.65)
ringLead: 0.9, // s before contact the timed ring appears
ringSmooth: 8, // 1/s: how fast the drawn ring eases toward each fresh solve (it counts down on its own)
praiseBehind: 1.0, // m behind the player's feet a rally's praise word is drawn (under him, off the ball's path)
flickLead: 0, // s a flick's swing is back-dated toward touch-down (v366 used 0.1 — his next log showed
// every 62-64 ms flick landing ~85 ms EARLY: a flick is timed by its swipe, not its touch)
// ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely // ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely
// make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same // make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same
// lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated // lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated
@@ -871,9 +947,9 @@ export const MENU = [{ mode: 'zombie' }, { mode: 'train' }, { mode: 'classic' }]
export const DIFFS = [ export const DIFFS = [
// EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD // EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD
// taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy // taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 }, { id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48, punish: 0.2 },
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 }, { id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66, punish: 0.5 },
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 }, { id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80, punish: 0.8 },
]; ];
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's // THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
@@ -949,6 +1025,77 @@ export const cupFixture = (lg, champion = 0) => {
sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' }; sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' };
}; };
// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY"): five acts, one per league and court. An act is
// the league cup (zombie tennis), the transformation, and one ten-game gore chapter with a MID-BOSS
// (STORY.midAt, the checkpoint) and the act's BOSS at its last game. Pure data; beats.js runs it.
// rounds the cup's rungs, in order. The FINAL is always the boss's own body (it grows into the
// boss), so acts II and IV play their band out of ladder order — cupRoundsFor is untouched.
// mid BOSS_ART index of the game-5 boss (the five designs one chapter per act leaves over)
// boss BOSS_ART index of the game-10 boss (the cup finalist, grown)
// pool weapons this act ADDS to the rack; the stock is every pool up to and including the act
// double the act's stock and restock pay two tins of each (league 5's double tins)
// arcade the arcade doors clearing this act opens (MODES ids, plus 'vs' for hosting a friend)
// The chapter plays the arcade's games base+1..base+length (base = (act-1)*20), so every director curve
// that reads G.runGames — spawn ramps, barrel gaps, machines, racket hp — keeps its tuned slope.
export const STORY = {
acts: [
{ lg: 1, rounds: [1, 2, 3, 4], mid: 8, boss: 0, pool: ['bomb', 'triple', 'fire'], arcade: ['train', 'classic'] },
{ lg: 2, rounds: [5, 6, 8, 7], mid: 6, boss: 5, pool: ['gun', 'beacon'], arcade: ['zombie'] },
{ lg: 3, rounds: [9, 10, 11, 12], mid: 1, boss: 2, pool: ['laser'], arcade: ['gore'] },
{ lg: 4, rounds: [13, 15, 14], mid: 4, boss: 3, pool: ['flamer', 'rocket'], arcade: ['vs'] },
{ lg: 5, rounds: [16, 17, 18], mid: 7, boss: 9, pool: [], double: true, arcade: [] },
],
// Oliver on v383: "Gore scheint mir etwas zu lang — statt 2x5 lieber 2x3 Spiele". Six games, the
// mid-boss after the third, the act's boss after the sixth — and the horde after the fourth, between
// them (a chapter's 'set' is hordeAt games: at the arcade's six it would land on the boss and vanish).
midAt: 3, // the mid-boss walks on after this many chapter games (and is the checkpoint)
length: 6, // games in a chapter; the act's boss walks on after the last
hordeAt: 4, // the chapter's set: the horde comes after this many games
base: act => (act - 1) * 20,
};
export const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V'];
// HELP WHEN STUCK (Oliver: "eine Erleichterung, aber nicht zu easy"): from the 3rd failed attempt at the
// same checkpoint the death screen offers one tier, at most two, said openly and declinable. Tennis gets
// the peer gift's shape (legs, swing window); gore one more heart and a slower director per tier.
export const STORY_HELP = { offerAt: 2, max: 2, run: 0.08, window: 0.12, heart: 1, slow: 0.25 };
// THE STORE's switches (PROJECT.md "LATER — shipping it"). Both OFF until the unlock IAP exists: the
// free tier ends on act I's card, and the arcade may become a premium door. premium() is the stub.
export const STORE = { freeTier: false, arcadePremium: false, testReset: true }; // testReset: the splash's RESET STORY button
// THE GORE BED — the one track that plays while the dead are being killed (gore, every story chapter,
// from the transformation's run-off on), ducked everywhere else. Oliver wants METAL here (2026-10-15)
// sent "Dystopian Survival" (2026-10-15). metal-loop.mp3 is that track made loopable: 0.5-214 s with
// its last ten seconds cross-faded (equal power) into its first ten, so the seam is inside the fade
// and the loop is 203.5 s exactly. MP3 at Oliver's call; the encoder's delay and padding are carried
// in the LAME tag and the decoder strips them (measured in Chromium: 8974350 frames, no silence at
// either end), and `loopEnd` pins the source length anyway. `vol` sets it near the menu theme's level
// (it is mastered at -14 LUFS, the old bed at -8); `lowcut` 40 Hz keeps the guitars' body, where the
// old bed's 800 Hz high-pass would have thinned metal to a radio. The old bed: zombie-mode.m4a,
// 20.47077 s, 0.2, 800.
export const GORE_BED = { file: 'metal-loop.mp3', loopEnd: 203.5, vol: 0.5, lowcut: 40 };
export const storyAct = act => STORY.acts[Math.max(1, Math.min(5, act)) - 1];
// the story cup: the league's own round names and needs, on the act's rung order
export const storyRounds = act => {
const A = storyAct(act);
return cupRoundsFor(A.lg).map((r, i) => ({ ...r, band: [A.rounds[i]] }));
};
// every weapon the act has earned so far, and how many tins of each the rack starts the chapter with
export const storyPool = act => STORY.acts.slice(0, Math.max(1, Math.min(5, act))).flatMap(a => a.pool);
export const storyStock = act => storyPool(act).map(id => ({ id, n: WEAPONS[id].tin * (storyAct(act).double ? 2 : 1) }));
export const storyBossArt = (act, kind) => BOSS_ART[storyAct(act)[kind === 'mid' ? 'mid' : 'boss']];
// strength: the act's two bosses stand where they would in the arcade's ten chapters (mid 2a-1, boss 2a)
export const storyBossChapter = (act, kind) => 2 * act - (kind === 'mid' ? 1 : 0);
// the front door's words for where the story stands (refreshSplash, the summaries)
export const storyFixture = s => {
const act = Math.max(1, Math.min(5, s.act)), lg = leagueOf(storyAct(act).lg);
const court = (COURTS.find(c => c.id === lg.court) || COURTS[0]).name;
if (s.cp === 'done') return { title: 'THE STORY IS TOLD', line: 'THE DEAD CLUB IS QUIET · PLAY IT AGAIN', court };
const where = s.cp === 'pro' ? 'PROLOGUE · A MATCH LIKE ANY OTHER'
: s.cp === 'chap' ? 'CHAPTER ' + ROMAN[act] + ' · FROM GAME 1'
: s.cp === 'mid' ? 'CHAPTER ' + ROMAN[act] + ' · FROM GAME ' + (STORY.midAt + 1)
: 'THE CUP · ' + storyRounds(act)[0].name + ': ' + matchFoe(storyAct(act).rounds[0]).name;
return { title: s.cp === 'pro' ? 'THE STORY' : 'ACT ' + ROMAN[act] + ' · ' + court, line: where, court };
};
// How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL. // How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL.
export const cupRoundName = (lg, reached) => { export const cupRoundName = (lg, reached) => {
const rounds = cupRoundsFor(lg); const rounds = cupRoundsFor(lg);
@@ -1185,54 +1332,77 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
// a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the // a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the
// ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled // ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled
// off the body it already has in the gore mode, same rule as the twelve before it. // off the body it already has in the gore mode, same rule as the twelve before it.
// THE STYLES (step 2 of the rally rework, 2026-10-14 — PROJECT.md "THE RALLY GOES FLAT"). How a body
// PLAYS, as dials, so a style is data and never a branch. Read in trySwing (what it does with the
// ball), rxPlan (when it comes in), racketAI (serve and volley) and doHit (the spin it carries).
// Context-chosen shots are fine HERE — the one-gesture rule binds the player's thumb, and a lob or
// a drop announces itself in its own arc.
// loft share of rally balls sent back as a high deep MOONBALL (the soft ball's long end)
// drop chance of a DROP when it stands inside its own baseline and you stand behind yours
// lob chance of a LOB when you stand at the net
// spin the bounce its rally balls carry: >1 heavy topspin (kicks up and on), <1 slice (low)
// charge m it lowers its short-ball line: it comes in on balls a baseliner would stay back for
// sv serves and volleys
// tag the walk-on's one line about it — the player has to know what he is facing to plan
export const STYLES = {
pusher: { loft: 0.45, lob: 0.5, tag: 'PUSHER — IT ALL COMES BACK HIGH' },
retriever: { loft: 0.15, lob: 0.35, tag: 'RETRIEVER — RUNS DOWN EVERYTHING' },
slicer: { spin: 0.8, drop: 0.25, tag: 'SLICER — LOW BALLS THAT STAY DOWN' },
power: { spin: 1.15, tag: 'BIG HITTER — HEAVY TOPSPIN' },
baseliner: { spin: 1.07, lob: 0.25, tag: 'BASELINER — PAINTS THE LINES' },
touch: { drop: 0.45, lob: 0.55, tag: 'TOUCH — DROPS YOU WHEN YOU HANG BACK' },
rusher: { charge: 1.6, sv: true, tag: 'NET RUSHER — PASS HIM OR LOB HIM' },
allcourt: { loft: 0.08, drop: 0.2, lob: 0.4, spin: 1.07, charge: 0.8, tag: 'ALL-COURT — A BIT OF EVERYTHING' },
};
const OPPONENTS = [ const OPPONENTS = [
{ short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh', { short: 'SHAMBLER', style: 'pusher', name: 'THE SHAMBLER', fam: 'sh',
line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 }, line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 },
{ short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb', { short: 'BALL BOY', style: 'retriever', name: 'THE BALL BOY', fam: 'bb',
line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 }, line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 },
{ short: 'PUNTER', name: 'THE SPECTATOR', fam: 'sp', { short: 'PUNTER', style: 'pusher', name: 'THE SPECTATOR', fam: 'sp',
line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 }, line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 },
{ short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk', { short: 'KEEPER', style: 'slicer', name: 'THE GROUNDSKEEPER', fam: 'gk',
line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 }, line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 },
// 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard // 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard
{ short: 'ROLLER', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95, { short: 'ROLLER', style: 'power', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95,
line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 }, line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 },
{ short: 'STEWARD', name: 'THE STEWARD', fam: 'st', { short: 'STEWARD', style: 'pusher', name: 'THE STEWARD', fam: 'st',
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 }, line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 },
// 7-8: the entertainment, and then the legs // 7-8: the entertainment, and then the legs
{ short: 'MASCOT', name: 'THE MASCOT', fam: 'ma', { short: 'MASCOT', style: 'touch', name: 'THE MASCOT', fam: 'ma',
line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 }, line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 },
{ short: 'RUNNER', name: 'THE RUNNER', fam: 'ru', { short: 'RUNNER', style: 'retriever', name: 'THE RUNNER', fam: 'ru',
line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 }, line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 },
// 9-12: the trades and the officials — width arrives here // 9-12: the trades and the officials — width arrives here
{ short: 'SELLER', name: 'THE STRAWBERRY SELLER', fam: 'sb', { short: 'SELLER', style: 'slicer', name: 'THE STRAWBERRY SELLER', fam: 'sb',
line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 }, line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 },
{ short: 'STRINGER', name: 'THE STRINGER', fam: 'sg', { short: 'STRINGER', style: 'power', name: 'THE STRINGER', fam: 'sg',
line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 }, line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 },
{ short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj', { short: 'JUDGE', style: 'baseliner', name: 'THE LINE JUDGE', fam: 'lj',
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 }, line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 },
{ short: 'UMPIRE', name: 'THE CHAIR UMPIRE', fam: 'cu', { short: 'UMPIRE', style: 'rusher', name: 'THE CHAIR UMPIRE', fam: 'cu',
line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 }, line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 },
{ short: 'PHYSIO', name: 'THE PHYSIO', fam: 'ph', { short: 'PHYSIO', style: 'touch', name: 'THE PHYSIO', fam: 'ph',
line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 }, line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 },
{ short: 'COACH', name: 'THE HEAD COACH', fam: 'hc', { short: 'COACH', style: 'allcourt', name: 'THE HEAD COACH', fam: 'hc',
line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 }, line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 },
{ short: 'CLUB PRO', name: 'THE CLUB PRO', { short: 'CLUB PRO', style: 'rusher', name: 'THE CLUB PRO',
line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 }, line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 },
// THE BRUTE, not "THE BULK": the gore mode's stats screen has called this body THE BRUTE since it // THE BRUTE, not "THE BULK": the gore mode's stats screen has called this body THE BRUTE since it
// was the pole-dragger (ENEMIES.g below), and one character may not carry two names. // was the pole-dragger (ENEMIES.g below), and one character may not carry two names.
// Final-league returns hunt the open court (smart) and have individual quality bands. Earlier // Final-league returns hunt the open court (smart) and have individual quality bands. Earlier
// opponents keep random lanes and racketQMax. Brute's legs must clear even league 4's fastest. // opponents keep random lanes and racketQMax. Brute's legs must clear even league 4's fastest.
{ short: 'BRUTE', name: 'THE BRUTE', fam: 'br', h: 2.0, { short: 'BRUTE', style: 'power', name: 'THE BRUTE', fam: 'br', h: 2.0,
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.12, pace: 1.34, wide: 1.24, flub: 0.66, smart: 0.50, qMin: 0.62, qMax: 0.76 }, line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.12, pace: 1.34, wide: 1.24, flub: 0.66, smart: 0.50, qMin: 0.62, qMax: 0.76 },
{ short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr', { short: 'DIRECTOR', style: 'rusher', name: 'THE DIRECTOR', fam: 'dr',
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60, smart: 0.70, qMin: 0.68, qMax: 0.83 }, line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60, smart: 0.70, qMin: 0.68, qMax: 0.83 },
// 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the // 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the
// serve aims at the body actually drawn): four family rolls could not make the art itself bulky // serve aims at the body actually drawn): four family rolls could not make the art itself bulky
// against the pose sheet — see _famroll.py's pair-reference note — so his size is stature. // against the pose sheet — see _famroll.py's pair-reference note — so his size is stature.
// Six new rungs stand between him and the start now, so he took the step up Oliver asked for: // Six new rungs stand between him and the start now, so he took the step up Oliver asked for:
// he reads the court faster, hits harder, paints wider, and misses half as often as he did. // he reads the court faster, hits harder, paints wider, and misses half as often as he did.
{ short: 'CHAMPION', name: 'THE CHAMPION', fam: 'ch', h: 2.1, { short: 'CHAMPION', style: 'allcourt', name: 'THE CHAMPION', fam: 'ch', h: 2.1,
line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38, smart: 0.85, qMin: 0.74, qMax: 0.90 }, line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38, smart: 0.85, qMin: 0.74, qMax: 0.90 },
]; ];
+566 -81
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; import { TUNE, UI, bossArt, STORY, STORY_HELP, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
@@ -11,7 +11,7 @@ import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { award, beginRunXp, prof, bonus, levelAt } from './profile.js'; import { award, beginRunXp, prof, bonus, levelAt } from './profile.js';
import { recordEvent, recordFrame } from './recorder.js'; import { recordEvent, recordFrame } from './recorder.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver, tickStory, storyPrologueGo } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
// ------------------------------------------------------------- ballistics ---- // ------------------------------------------------------------- ballistics ----
@@ -33,6 +33,21 @@ const strikeKick = () => {
if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at
}; };
// How much of its horizontal pace a ball keeps through a bounce: the court's own number, times the
// shot's SPIN (ball.spin, 1 = none). Only the soft ball sets it — a drop carries backspin and dies.
// Every planner that rolls a bounce forward reads this, or it predicts a ball that is not there.
const bXZ = () => TUNE.bounceXZ * (ball.spin || 1);
// ...and backspin keeps it LOW as well: the vertical rebound gives up part of what the pace does
const bY = () => TUNE.bounceY * (1 - TUNE.spinLow * (1 - (ball.spin || 1)));
// THE DIP ENDS AT THE BOUNCE (2026-10-14, Oliver's pick). A shot's extra gravity (quality topspin,
// the serve's spin) is its DIVE IN FLIGHT — it was carried through the bounce, so a "heavy topspin"
// ball skidded away LOW afterwards and nearly every contact in his logs was 0-25 cm off the grass.
// Real topspin kicks UP. After the bounce a ball falls under plain gravity; the kick itself is
// bY/bXZ's spin factor. Tennis modes only — gore's balls keep theirs. Every planner rolling a bounce
// forward switches here too (intercept, bounceContact, ringCue, matchOutOfReachFlight) or it would
// predict the old low ball.
const gAfter = g => (rallyFeel() || G.mode === 'tennis' ? TUNE.gravity : g);
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) { 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; 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 }; return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T };
@@ -72,6 +87,16 @@ let praiseN = 0;
// the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after // the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after
// shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/ // shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/
// bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment. // bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment.
// ...and in a rally it lands BELOW THE PLAYER, just behind his feet, and stays put (Oliver, 2026-10-14:
// "die Nachrichten wie shot of the day, textbook verdecken manchmal den Ball"). On the contact it rose
// straight into the path of the ball he had just struck; a serve's word sat on the far bounce, which
// is exactly where the return comes back through. The MOMENT is unchanged — only where it is drawn.
// Gore keeps its words on the bodies they belong to.
function praisePop(w, x, y, z) {
if (rallyFeel() || G.mode === 'tennis') popText(P.x, 0, P.z + TUNE.praiseBehind, w, undefined, true);
else popText(x, y, z, w);
}
function praiseWord() { function praiseWord() {
if (G.praiseT > 0) return null; if (G.praiseT > 0) return null;
G.praiseT = TUNE.praiseEvery; G.praiseT = TUNE.praiseEvery;
@@ -193,7 +218,7 @@ function doHit(ch, quality, aim) {
ball.x = ch.x + ch.facing * 0.5; ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3); ball.z = ch.z + (toFar ? -0.3 : 0.3);
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz, aimedLane = false, sway = 0; let tx, tz, aimedLane = false, sway = 0, soft = null;
let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace(); let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace();
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim 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; ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
@@ -243,14 +268,50 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x)); const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound); 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; // a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it,
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; // so the bottom of the ladder cannot paint lines even when it punishes)
if (aim && (aim.kill || aim.pass)) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
// ...and the player's SMASH goes for the open court the same way: his side if he flicked one, else
// away from the body across the net. Down the middle, deep, it came straight back to the baseliner
if (ch.side === 'P' && aim && aim.smash) {
const foe = G.match ? zombies.find(z => z.variant === 'r') : O;
tx = (sway ? Math.sign(sway) : foe && foe.x > 0 ? -1 : 1) * TUNE.punishX + err() * 0.3;
}
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
// THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always,
// and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the
// back corner (the opponent's moonball uses the space between). One gesture, one meaning, whoever is
// where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward
// softSitter — a drop that sits up, a lob that falls short: the easy ball either way.
// `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob.
// TWO shots, not a continuum (Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob ... mir
// ist ein Stoppball nicht gelungen"). The swing fires at flickMin and the length is read at
// contact, by when a thumb has usually travelled on — and on a continuum even 50 px already
// floated the ball to mid-court. Below dropMax it is the drop, at or past it the lob.
soft = rallyFeel() && aim.az > 0
? (aim.soft ?? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? 1 : 0))
: null;
if (soft !== null) {
const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline
const d = want + (TUNE.softSitter - want) * (1 - quality) * TUNE.softErr;
tz = toFar ? d : COURT.L - d;
} else {
const deep = rallyFeel() && aim.az <= 0;
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
}
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for // 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 // 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 // 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 // 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. // 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) s *= 1 + TUNE.smashBoost * quality;
// ...and an OVERHEAD taken out of the air is the real smash (trySwing marks it): hit it hard
if (ch.side === 'P' && aim.smash) s *= 1 + TUNE.smashPace * quality;
aimedLane = true; aimedLane = true;
} }
tx = Math.max(-5, Math.min(5, tx)); tx = Math.max(-5, Math.min(5, tx));
@@ -284,7 +345,7 @@ function doHit(ch, quality, aim) {
// balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung // balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung
// earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one. // earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one.
if (ch.side !== 'P') { if (ch.side !== 'P') {
const wide = (TUNE.matchWideBase + matchStep() * TUNE.matchWidePer) * (ch.foe?.wide || 1); const wide = Math.min(TUNE.matchWideMax, (TUNE.matchWideBase + matchStep() * TUNE.matchWidePer) * (ch.foe?.wide || 1));
tx = Math.max(-wide, Math.min(wide, tx)); tx = Math.max(-wide, Math.min(wide, tx));
} }
} }
@@ -298,8 +359,18 @@ function doHit(ch, quality, aim) {
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality }; G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
} }
// topspin: quality shots dive under extra effective gravity, which is what lets real // 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 // pace arc over the net and still land in — the core tennis trade-off. The soft ball carries none.
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); const g = soft !== null ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
// ...and its PACE IS DERIVED FROM ITS HEIGHT (the cut lob's ballistics, 4f4534e): to peak at
// `apex` it must leave at vy = sqrt(2g(apex - y)), which fixes the hang time, and the ground still
// to cover fixes the speed — the same arc from the baseline and from the net. After the
// opponent's pace dials on purpose: a floater is a floater from any racket.
if (soft !== null) {
const apex = TUNE.dropApex + (TUNE.lobApex - TUNE.dropApex) * soft;
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, apex - 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;
}
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); 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 // 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, // comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
@@ -315,7 +386,35 @@ function doHit(ch, quality, aim) {
} }
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = ch.side; ball.bounces = 0; // a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob
ball.spin = soft === null ? (aim && aim.spin) || 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2);
ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead)
// ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies).
// Never against a man already at the net — that ball is a PASS, and two volleyers is the old
// reflex duel at a quarter of a second per shot. ball.from is the striker's distance from the net.
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
ball.retreat = null; // his own way back is a DOUBLE TAP while this ball flies (moveCall), never the shot
ball.smash = !!(aim && aim.smash);
ball.from = Math.abs(ch.z - COURT.netZ);
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
// ...and a player AT THE NET (or rushing it) split-steps before he reads the pass, exactly as the CPU's
// net man does (netReact): with no read at all his 2.5 m volley reach made him unpassable
if (ch.side !== 'P' && rallyFeel() && (Math.abs(P.z - COURT.netZ) < TUNE.netFrom || P.rushing)) ball.heels = Math.max(ball.heels, TUNE.netReact);
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
if (ch.side !== 'P' && swingLog()) {
G.swung = false; G.rallyNear = null;
const land = predictLanding(), mark = !land ? '' : (() => {
const dx = land.x - P.x, inside = r2(COURT.L - land.z);
const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ;
return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`;
})();
const kind = aim && aim.kill ? 'PUT-AWAY of your weak ball' : soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
: soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball';
logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}`
+ (ball.approach === ch.side ? ' · coming in' : ''), px: r2(P.x), pz: r2(P.z) }); // where HE stood when it left
}
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts ball.kills = 0; // fresh shot: multi-kill streak restarts
@@ -338,7 +437,7 @@ function doHit(ch, quality, aim) {
if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs) if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs)
G.hitstop = 0.05; pock(0.5, 1600); G.hitstop = 0.05; pock(0.5, 1600);
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact 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); if (w) praisePop(w, ball.x, ball.y, ball.z);
} }
ch.pose = 'swing'; ch.poseT = 0; ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
@@ -531,7 +630,9 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp); perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.retreat = null; ball.smash = false; ball.spin = 1; ball.soft = false;
// a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot
ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ);
ball.pow = quality; ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts ball.kills = 0; // fresh serve: multi-kill streak restarts
ball.praise = null; // ...and any word a spent ball never got to pop dies with it ball.praise = null; // ...and any word a spent ball never got to pop dies with it
@@ -697,12 +798,9 @@ export function pointTo(side, reason) { // exported for net.js: a remote verdict
// applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE // applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE
// verdict is being applied, which is what stops the two phones echoing a point back and forth. // verdict is being applied, which is what stops the two phones echoing a point back and forth.
if (netJudging()) netSendPoint(side, reason); if (netJudging()) netSendPoint(side, reason);
if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung)
const n = G.rallyNear; G.passStreak++; // their ball died on your side untouched: two in a row and the ghost hand shows the tap (render.drawHitHint)
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`, logPoint(side, reason);
dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) });
G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint)
}
const gameOver = advancePoint(side); const gameOver = advancePoint(side);
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
sendBoy(); sendBoy();
@@ -794,13 +892,20 @@ export function splatPoint(side, quiet = false) {
G.runGames++; G.runGames++;
// the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance // the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance
// at a weapon, but you still have to win the point to be handed one. Fires at the next clear. // at a weapon, but you still have to win the point to be handed one. Fires at the next clear.
if (G.runGames === TUNE.duelFirst || // THE STORY's chapter has no duel, and its two bosses are its own: the mid-boss after game 5
// (the checkpoint) and the act's boss after the last (config.STORY). The arcade schedule below never runs there.
const chap = G.story && G.story.part === 'chap';
if (chap) {
const rel = G.runGames - G.story.base;
if (rel >= STORY.length) G.storyBoss = 'end';
else if (rel === STORY.midAt && !G.story.midDone) G.storyBoss = 'mid';
} else if (G.runGames === TUNE.duelFirst ||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0)) (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
G.duelDue = true; G.duelDue = true;
// ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from // ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from
// proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks // proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks
// everything else there — a boss must not walk on into a standing wave. // everything else there — a boss must not walk on into a standing wave.
if (G.runGames % TUNE.bossEvery === 0) if (!chap && G.runGames % TUNE.bossEvery === 0)
G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a
// long stretch with no clear skips a chapter rather than queueing one, which is intended // long stretch with no clear skips a chapter rather than queueing one, which is intended
@@ -808,7 +913,7 @@ export function splatPoint(side, quiet = false) {
if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
const i = side === 'P' ? 0 : 1; const i = side === 'P' ? 0 : 1;
if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card if (score.games[i] >= (chap ? STORY.hordeAt : 6)) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
else G.afterClear = 'game'; // even game: a beat, then the next wave else G.afterClear = 'game'; // even game: a beat, then the next wave
G.clearWinner = side; G.clearWinner = side;
@@ -1083,6 +1188,11 @@ function transition(dt) {
gore.push({ x: O.x, z: O.z, r: 1.1, a: 0.9 }); gore.push({ x: O.x, z: O.z, r: 1.1, a: 0.9 });
spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns
}); });
// THE STORY's prologue stops at the turn (Oliver, 2026-10-15: "direkt nach der Verwandlung das Tennis-
// Spiel fortfahren — momentan wechselt man kurz in den Gore-Mode, das ist verwirrend"): no mob, no gore
// beat — the dead take their seats and act I's cup carries the match on.
const pro = G.story && G.story.part === 'pro';
if (pro) { once('story', 5.2, () => { enterSplatter(true); storyPrologueGo(); }); return; }
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; }); once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95)); once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => { once('go', 8.2, () => {
@@ -1097,10 +1207,10 @@ function transition(dt) {
// NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull // NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull
// instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after // instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after
// the usual beat of inaction, and it keeps coming back whenever the player stalls. // the usual beat of inaction, and it keeps coming back whenever the player stalls.
function enterSplatter() { function enterSplatter(quiet = false) {
G.mode = 'splatter'; G.everTransitioned = true; G.mode = 'splatter'; G.everTransitioned = true;
score.server = 'P'; setupPoint(); score.server = 'P'; setupPoint();
voice('play', 0.4, 0.9); zombieMusic(); if (!quiet) { voice('play', 0.4, 0.9); zombieMusic(); } // (the story's prologue goes straight on to a cup)
G.idleHintT = TUNE.pullHintAfter; G.idleHintT = TUNE.pullHintAfter;
} }
@@ -1187,14 +1297,25 @@ const matchStep = () => {
const n = G.match.opp - 1; const n = G.match.opp - 1;
return n + (!G.net && !G.match.cup && !G.match.train ? 4 * Math.max(0, 1 - n / 7) : 0); return n + (!G.net && !G.match.cup && !G.match.train ? 4 * Math.max(0, 1 - n / 7) : 0);
}; };
const rzChaseSpeed = rz => TUNE.racketSpeed * leaguePace() // THE LEGS HAVE A KNEE (2026-10-14). The ladder multiplied speed rung on rung with nothing on top:
// 7.3 m/s at the RUNNER, 10.6 at the HEAD COACH, 15.7 at the CHAMPION — a sprinter's top speed and
// 2.5x the player's ~6.2. Under step 1's geometry a late opponent at the net then covered every pass.
// Up to legKnee nothing changes; past it only legSoft of the growth survives, so the order of the
// ladder (and every like-for-like speed test) holds while the champion lands near ~7.5. Wrong-footing,
// the read, placement and consistency are what climb past it now.
const knee = v => v <= TUNE.legKnee ? v : TUNE.legKnee + (v - TUNE.legKnee) * TUNE.legSoft;
const rzChaseSpeed = rz => knee(rzRawSpeed(rz));
const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace()
* (G.match ? (TUNE.matchSpeedBase + matchStep() * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) * (G.match ? (TUNE.matchSpeedBase + matchStep() * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1)
: 1 + TUNE.duelSpeedMax * duelSkill()) : 1 + TUNE.duelSpeedMax * duelSkill())
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1) * (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE // wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
// so the intercept planner and the reach tests believe it too (a surprised champion plans like // so the intercept planner and the reach tests believe it too (a surprised champion plans like
// a body that genuinely cannot get there, which is the whole point) // a body that genuinely cannot get there, which is the whole point)
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1); * (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1)
// ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is
// what makes the lob the answer to the net (the planner believes it too)
* (ball.lastHit === 'P' && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // HIS plan, not the player's
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from // Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own // the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
@@ -1221,8 +1342,8 @@ function matchOutOfReachFlight(rz, pred) {
const g = ball.g || TUNE.gravity; const g = ball.g || TUNE.gravity;
const spd = rzChaseSpeed(rz); const spd = rzChaseSpeed(rz);
const vyl = ball.vy - g * pred.t; // vertical speed at touchdown const vyl = ball.vy - g * pred.t; // vertical speed at touchdown
const t2 = Math.max(0.05, 2 * (-vyl * TUNE.bounceY) / g); const t2 = Math.max(0.05, 2 * (-vyl * bY()) / gAfter(g));
const vx2 = ball.vx * TUNE.bounceXZ, vz2 = ball.vz * TUNE.bounceXZ; const vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ();
const T = pred.t + t2; const T = pred.t + t2;
for (let i = 0; i <= 10; i++) { for (let i = 0; i <= 10; i++) {
const t = T * i / 10; const t = T * i / 10;
@@ -1238,7 +1359,7 @@ function onBounce() {
pock(0.35, 500); pock(0.35, 500);
if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice
// a deferred praise word that met no body lands with the ball — the DRAW was still perfect // a deferred praise word that met no body lands with the ball — the DRAW was still perfect
if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; } if (ball.praise) { praisePop(ball.praise, ball.x, 0.3, ball.z); ball.praise = null; }
if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up
explode(ball.x, ball.z); ball.bomb = false; explode(ball.x, ball.z); ball.bomb = false;
spentBall(); return; spentBall(); return;
@@ -1339,63 +1460,220 @@ export const rallyFeel = () => !!(G.match || G.classic);
// levels down is today's wall, parity gets the full gift, and being underlevelled is never a malus. // levels down is today's wall, parity gets the full gift, and being underlevelled is never a malus.
export const peerK = () => G.match && !G.net && (G.match.cup || G.match.train) export const peerK = () => G.match && !G.net && (G.match.cup || G.match.train)
? Math.max(0, Math.min(1, 1 - (foeLevel(G.match.opp) - levelAt(prof.xp)) / TUNE.peerGap)) : 0; ? Math.max(0, Math.min(1, 1 - (foeLevel(G.match.opp) - levelAt(prof.xp)) / TUNE.peerGap)) : 0;
export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) : 1; // ...and THE STORY's help offer rides the same two dials (config.STORY_HELP): fresher legs, a longer swing
export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) : 1; const helpK = k => G.story && G.story.help ? 1 + k * G.story.help : 1;
export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) * helpK(STORY_HELP.run) : 1;
export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) * helpK(STORY_HELP.window) : 1;
// legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never // legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never
// disagree about what those legs can get to (the rzChaseSpeed rule, player-side). // disagree about what those legs can get to (the rzChaseSpeed rule, player-side).
export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist) export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist)
* legLevel(); * legLevel();
// THE COURT HAS ITS LENGTH. How the receiver plays THIS shot, decided once as it leaves the racket
// (every strike clears ball.rx): at the net he volleys; from the back he lets it bounce, and a SHORT
// landing pulls him in to take it on the rise. Latched, so a man running in for a short ball does not
// turn volleyer the moment he crosses netFrom. Rally modes only — the callers gate it.
function rxPlan(ch) {
if (!ball.rx) {
const land = ball.bounces === 0 ? predictLanding() : null;
const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0;
const st = styleOf(ch);
// a body still RUSHING the net (serve and volley, a charge behind its approach) plays it as a
// net man from wherever its run has got to — a serve return never counts
ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || !!(ch.rushing && !G.wasServe),
short: !G.wasServe && depth > TUNE.shortBall - (st && st.charge || 0) };
}
return ball.rx;
}
// THE STYLES (step 2): how this body plays — the solo match's opponents only (see config.STYLES)
const styleOf = ch => (G.match && !G.net && ch && ch.foe && STYLES[ch.foe.style]) || null;
// ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape
const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net;
// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back
// behind his baseline. Read live — recovery always moves him away from netFrom, so it cannot dither.
// The one exception is the APPROACH: having taken a short ball, he follows it in.
// The PLAYER's double tap overrides all of that with a STANCE (P.post) that lasts until he changes it.
const atNetHome = ch => ch.side === 'P' && P.post ? P.post === 'net'
: (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || ball.approach === ch.side;
const rallyHome = ch => ({ x: 0, z: atNetHome(ch)
? COURT.netZ + (ch.side === 'P' ? TUNE.netStand : -TUNE.netStand)
: ch.side === 'P' ? COURT.L + TUNE.rallyBack : -TUNE.rallyBack });
// Where a baseliner meets this ball: after the bounce, falling through rallyHit, or at the top of a
// bounce that never gets that high. A SHORT ball is met on the RISE instead, moving in. Closed form
// off predictLanding, for racketAI (intercept walks the flight for itself).
function bounceContact(short) {
let g = ball.g || TUNE.gravity;
let { x, y, z, vx, vy, vz } = ball;
if (ball.bounces === 0) {
const p = predictLanding(); if (!p) return null;
x = p.x; z = p.z; y = 0;
vy = -(vy - g * p.t) * bY(); vx *= bXZ(); vz *= bXZ();
g = gAfter(g); // the dip ends at the bounce
}
const apex = y + Math.max(0, vy) ** 2 / (2 * g), h = Math.min(TUNE.rallyHit, apex);
const r = Math.sqrt(Math.max(0, vy * vy - 2 * g * (h - y)));
const at = t => ({ x: x + vx * t, z: z + vz * t });
const fall = at(Math.max(0, (vy + r) / g)), rise = at(Math.max(0, (vy - r) / g));
// a ball that will only come down past rallyBehind (a deep lob's high bounce) is taken on the RISE
// instead — waiting for it would mean standing in the stand, and above CONTACT.maxY it is unplayable
return short && vy > 0 || fall.z < -TUNE.rallyBehind ? rise : fall;
}
function intercept(side) { function intercept(side) {
const ch = side === 'O' ? O : P; const ch = side === 'O' ? O : P;
// G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the // G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the
// classic CPU speeds up or slows down. Same guard on every dial it scales. // classic CPU speeds up or slows down. Same guard on every dial it scales.
const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK(); const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK();
let { x, y, z, vx, vy, vz } = ball; let { x, y, z, vx, vy, vz } = ball;
const g = ball.g || TUNE.gravity, dt = 0.01; let g = ball.g || TUNE.gravity; const dt = 0.01; // g switches to plain gravity at the simulated bounce (gAfter)
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = HOME[side]; const home = rallyFeel() ? rallyHome(ch) : HOME[side];
let fallback = null, deepest = null, anyHit = null; const plan = rallyFeel() ? rxPlan(ch) : null;
// a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule)
if (plan && plan.net && (ball.z - ch.z) * vz < 0) {
const tc = (ch.z - z) / vz;
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > (side === 'P' && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY)) { plan.net = false; plan.lob = true; }
}
const back = plan && !plan.net; // a baseliner lets it bounce
// ...and only where his legs may go: the player is held on screen (xLimit), and since the dip ends at
// the bounce a kicking ball drifts on sideways long enough to plan him out to x 6.5-8.7 — he ran to
// the edge of the frame and watched it pass (his v369 log, 65.77 s and 84.26 s)
const xl = side === 'P' ? xLimit() : 99;
// ...and the PLAYER's reach is judged from where his chase BEGAN plus the time he has run since (P.chase),
// not from where he stands this frame. Judged live, his own step moved the answer: a deep point became
// reachable one frame and not the next, and the plan flipped 2.2 m every other frame (the shake).
const o = side === 'P' && P.chase ? P.chase : { x: ch.x, z: ch.z, t: 0 };
// ...with HYSTERESIS: the flight is sampled every 10 ms against a 16.7 ms frame, so a candidate on a
// margin passed one frame and failed the next as the grid slid — a low slice whose bounce peaks at ~0.46 m
// against the 0.45 m floor below, measured flipping the plan 2.2 m every other frame. The point he is
// already running to keeps runKeep of time and 6 cm of height slack.
const near = (px, pz) => side === 'P' && P.goal && Math.hypot(px - P.goal.x, pz - P.goal.z) < 0.6;
const can = (px, pz, i, k = 1) => Math.hypot(px - o.x, pz - o.z) / (spd * k) <= o.t + i * dt + (near(px, pz) ? TUNE.runKeep : 0);
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
// ...but the PLAYER lobbed at the net first looks for the overhead: the first point the lob comes down
// into his smash band (smashMaxY) that his backpedal reaches in time. A short lob is smashed; one he
// cannot get under is run down after its bounce, as before, and his reply is capped.
// DECIDED ONCE, when the lob is read (plan.over, latched like the plan itself): re-asked every frame, his
// backpedal toward the run-down brought the overhead into reach on the way, and every lob was smashed
if (side === 'P' && plan && plan.over) return plan.over;
const smashing = side === 'P' && plan && plan.lob && plan.over === undefined;
for (let i = 0; i < 300; i++) { for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; } if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); nb++; g = gAfter(g); }
if (smashing && nb === 0 && vy < 0 && y <= TUNE.smashMaxY - 0.2 && y >= TUNE.smashMinY && Math.abs(x) <= xl
&& can(x, z, i, TUNE.smashRun)) return (plan.over = { x, z, ti: o.t + i * dt });
// FROM THE BACK: meet it between its bounce and the next one — at the top or falling through
// rallyHit+0.3, from as far as rallyBehind behind the line; a short ball the first moment it is
// up off the grass. Only if nothing like that is reachable do the old candidates below apply.
// (up to rallyTop since the dip ends at the bounce: a kicking ball is taken at the top of its
// bounce, chest high, rather than chased forward and volleyed — 40% volleys was the first cut)
if (back && nb === 1 && i > 5 && y > (near(x, z) ? 0.39 : 0.45) && y < (plan.short ? TUNE.rallyHit + 0.6 : TUNE.rallyTop)) {
const inB = (side === 'P' ? z <= COURT.L + TUNE.rallyBehind + (near(x, z) ? 0.1 : 0) : z >= -TUNE.rallyBehind) && Math.abs(x) <= xl;
if (inB && can(x, z, i)) {
if (plan.short || vy < 1) return { x, z, ti: o.t + i * dt };
rise ||= { x, z, ti: o.t + i * dt };
}
}
// THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the // THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the
// near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the // near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the
// legal contact height by then) and the point went to the zombie. Seven of seven recorded // legal contact height by then) and the point went to the zombie. Seven of seven recorded
// losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting // losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting
// height: running forward onto the short ball is the tennis answer, and now it is the assist's. // height: running forward onto the short ball is the tennis answer, and now it is the assist's.
if (side === 'P' && rallyFeel() && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) { if (side === 'P' && rallyFeel() && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) {
const pt = { x, z: Math.min(24.5, z) }; const pt = { x, z: Math.min(24.5, z), ti: o.t + i * dt };
if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) anyHit = pt; if (Math.abs(x) <= xl && can(pt.x, pt.z, i)) anyHit = pt;
} }
if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) { if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) {
const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) }; const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z), ti: o.t + i * dt };
const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt; const reachable = Math.abs(x) <= xl && can(pt.x, pt.z, i);
if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time if (y > 0.7 && reachable && !back) return pt; // waist-high AND arrivable in time
if (reachable && !fallback) fallback = pt; // low but arrivable if (reachable && !fallback) fallback = pt; // low but arrivable
deepest = pt; // last resort: run at the deepest hittable point deepest = pt; // last resort: run at the deepest hittable point
} }
} }
return fallback || deepest || anyHit || home; // zone points first: meeting deep is still better if (smashing) { plan.over = null; if (P.post === 'net') P.post = 'base'; } // no overhead in reach: he runs it down, and stays back
// ...and a frame that finds NOTHING mid-chase holds the plan it had: a dying drop's bounce sits on the
// 0.35 m floor, and every other frame without a candidate sent him home, 10 m away, and back again
const hold = side === 'P' && P.chase && P.goal ? P.goal : null;
const to = rise || fallback || deepest || anyHit || hold || home; // zone points first: meeting deep is still better
// a ball no point of which he can reach still gets a run — to the nearest spot he is allowed to stand
return Math.abs(to.x) > xl ? { x: Math.sign(to.x) * xl, z: to.z, ti: to.ti } : to;
}
// THE GOLD RING, TIMED (2026-10-14). The approach ring used to go gold at a DISTANCE (matchRingSweet
// 2.7 m) — right for the ~20 m/s rally balls it was calibrated on, and too early for every slow ball
// steps 1-3 brought in: a pusher's moonball, a lob, a drop, a deep ball braked by its bounce. A
// gold-ring tap on a slow ball missed "30 ms after the window" (the swing log's first catch). Now the
// cue rolls the ball forward to the moment it enters the player's real contact reach (the same reach,
// height band and let-it-bounce rule trySwing applies) and is gold while a finger landing NOW centres
// that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring.
export function ringCue() {
if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null;
let g = ball.g || TUNE.gravity;
const dt = 1 / 120;
let { x, y, z, vx, vy, vz } = ball, b = ball.bounces;
// ...against where the player WILL be: the auto-run is still carrying him to its target at the pace
// he is running now, and measuring against where he stands made the cue jump 50-180 ms a frame
const sp = Math.hypot(P.vx || 0, P.vz || 0), tg = P.tgt;
const togo = tg ? Math.hypot(tg.x - P.x, tg.z - P.z) : 0;
for (let t = 0; t < 1.6; t += dt) {
const volley = b === 0;
const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const legal = y <= (volley && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY) && (y >= CONTACT.minY || vy > 0)
&& !(volley && y < COURT.netH && ball.rx && !ball.rx.net);
const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0;
const xl = xLimit(), px = Math.max(-xl, Math.min(xl, P.x + (tg ? tg.x - P.x : 0) * k)), pz = P.z + (tg ? tg.z - P.z : 0) * k;
if (legal && Math.hypot(x - px, (z - pz) * 0.8) <= reach) {
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel();
const sweet = TUNE.swingWindup + win / 2, band = win / 2 * TUNE.ringBand;
return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band };
}
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); g = gAfter(g); b++; if (b > 1) return null; }
}
return null;
} }
// ---------------------------------------------------------- miss diagnostics ---- // ---------------------------------------------------------- miss diagnostics ----
// Every player swing in tennis mode gets logged: HIT with quality, or MISS with the // Every player swing in a rally gets logged: HIT with quality, shot and how long the tap was, or
// gate that rejected it and the ball's closest approach. Balls that pass without a // MISS with the gate that rejected it and the ball's closest approach — and every point ends with
// swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest // a WON/LOST line naming why. Tap the scoreboard to see it; tap the log to COPY all of it (main.js).
// miss reason is drawn on the HUD (TUNE.missHud). // Inspect live via T.G.missLog; the latest miss reason can ride the HUD (TUNE.missHud).
// RALLY MODES since 2026-10-14 (Oliver: "bitte bau nochmal die Funktion ein die protokolliert warum
// ich einen Ball nicht bekommen habe"): it was gated on G.mode === 'tennis', and the match runs as
// 'splatter' under its interlude — so the Climb and the cup, the modes he plays, logged nothing.
const r2 = x => Math.round(x * 100) / 100; const r2 = x => Math.round(x * 100) / 100;
const swingLog = () => G.mode === 'tennis' || rallyFeel();
export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring) export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring)
entry.at = r2(G.idleT); entry.at = r2(G.idleT);
G.missLog.push(entry); G.missLog.push(entry);
if (G.missLog.length > 50) G.missLog.shift(); if (G.missLog.length > 200) G.missLog.shift();
if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT }; if (entry.result !== 'HIT' && entry.result !== 'WON') G.lastMiss = { text: entry.reason, at: G.idleT };
}
// The line a POINT ends on, so a run of MISS/HIT lines reads as a story: who took it and why, and —
// when it was lost with no swing at all — how close the ball came. Called by pointTo (classic) and
// by resolveInterlude (the match), the two places every verdict passes.
export function logPoint(side, reason) {
if (!swingLog()) return;
const mine = ball.lastHit === 'P', n = G.rallyNear;
const why = reason === 'out' ? (mine ? 'your ball out' : 'their ball out')
: reason === 'double_bounce' ? (mine ? 'bounced twice on their side' : 'bounced twice on your side')
: reason === 'double_fault' ? 'double fault'
: reason === 'dead_roller' ? 'ball died'
: reason === 'passed_player' ? 'passed you'
: reason === 'unreachable_after_bounce' || reason === 'unreachable_in_flight' ? 'called early: out of his reach'
: reason === 'body_destroyed' ? 'body destroyed' : reason || 'unspecified';
const unswung = side === 'O' && !mine && n && !G.swung;
logSwing({ result: side === 'P' ? 'WON' : 'LOST',
reason: why + (unswung ? ` — never swung, it came within ${r2(n.d)}m at y=${r2(n.y)}m` : ''),
...(unswung ? { dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) } : {}) });
} }
function logWhiff() { function logWhiff() {
const snaps = G.swingSnaps; G.swingSnaps = null; const snaps = G.swingSnaps; G.swingSnaps = null;
if (G.mode !== 'tennis') return; if (!swingLog()) return;
if (!snaps || !snaps.length) { if (!snaps || !snaps.length) {
logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' }); logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' });
return; return;
@@ -1419,14 +1697,35 @@ function logWhiff() {
reason = last.t < TUNE.swingWindup reason = last.t < TUNE.swingWindup
? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened` ? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened`
: `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`; : `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`;
} else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`; } else if (inWin.every(s => s.lb)) reason = 'waited for the bounce — still in the air below the tape (from the back you let it bounce)';
else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`;
else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`; else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`;
else reason = 'unclassified — in reach, in window, in height band'; // should not happen else reason = 'unclassified — in reach, in window, in height band'; // should not happen
} }
logSwing({ result: 'MISS', reason, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), logSwing({ result: 'MISS', reason: reason + ` · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t),
ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) }); ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z),
...(P.tgt ? { runTo: r2(P.tgt.x) + ',' + r2(P.tgt.z) } : {}) }); // where the auto-run was taking him
} }
// THE FOLLOW-THROUGH (step 4 (b), 2026-10-14). A tap a little EARLY on a baseline ball starts the swing
// while the ball is still in the air; the swing waits for the bounce (letBounce), and by then the
// groundstroke window has run out — a clean whiff at ~70 ms early, while a tap 120 ms LATE still met
// the ball. Before step 1 the same ball was volleyed in a 1.8x window, so it never came up. In the
// rally modes the swing now carries on for swingTail after its window and meets a ball arriving there
// at q 0.05: a weak short ball the opponent has to punish. Whether he does is what the ladder is.
const swingTail = isAI => !isAI && rallyFeel() ? TUNE.swingTail : 0;
// ...and the other half, without which the follow-through only made rallies endless: the CPU had NO
// way to win a point. It returned a floater exactly like a drive, its whiff rate is near zero past
// mid-ladder, and every point it took was a whiff of the player's — which no opponent dial touches, so
// the ladder could not climb. A sitter (the player's ball under punishBelow) is now PUT AWAY with a
// chance that IS the ladder: punishMin at the shambler to punishMax at the champion, classic by
// difficulty. Rally modes, solo only.
function punishes(ch) {
if (!rallyFeel() || G.net || ch.side === 'P' || (ball.pow ?? 1) >= TUNE.punishBelow) return false;
// the Climb's own progress (matchStep: its brisker bottom included), so a level-26 run is not filler
const k = G.diff ? (G.diff.punish ?? 0) : G.match ? TUNE.punishMin + (TUNE.punishMax - TUNE.punishMin) * Math.min(1, matchStep() / (LADDER - 1)) : 0;
return Math.random() < k;
}
function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// Contact happens if the ball is inside reach while the swing window is open. // Contact happens if the ball is inside reach while the swing window is open.
// Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue. // Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue.
@@ -1440,20 +1739,46 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); : TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel()); const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel());
const t = ch.swingT; const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win + (volley ? 0 : swingTail(isAI))) return; // the tail meets a BOUNCED ball
if (!ball.active || G.phase !== 'rally') return; if (!ball.active || G.phase !== 'rally') return;
// splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you — // splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you —
// volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.) // volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.)
const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P'; const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P';
if (ball.lastHit === ch.side && !own) return; if (ball.lastHit === ch.side && !own) return;
const dx = ball.x - ch.x, dz = ball.z - ch.z; const dx = ball.x - ch.x, dz = ball.z - ch.z;
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; // THE SMASH: the player's overhead reaches higher than a volley, out of the air, in the rally modes
const overhead = !isAI && volley && rallyFeel();
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > (overhead ? TUNE.smashMaxY : CONTACT.maxY)) return;
// ...unless he only reached it TURNING AND RUNNING (a lob whose overhead was out of reach when he read
// it): that one is met on the run, a defensive block like every run-down lob, however high
const smash = overhead && ball.y >= TUNE.smashMinY && !(ball.rx && ball.rx.lob && !ball.rx.over);
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
// planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while
// the bounce will still land inside this window. If it will not, this is the last chance and the
// low ball is dug out as a half-volley (the below-the-tape cap prices it): a swing a touch early
// used to WHIFF here, which read as the game eating a good swing (Oliver's log, 2026-10-14).
// The deadline is the window AFTER the bounce (the groundstroke one, shorter than the volley window
// this swing is running on now) — judged against the long one, it waited and then expired on the
// bounce frame (his v367 log, 8.98 s).
if (letBounce()) {
const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg;
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() + swingTail(isAI) - 0.03) return;
}
// quality: how centered in the window the contact lands // quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2; const center = TUNE.swingWindup + win / 2;
const off = Math.abs(t - center) / (win / 2); const off = Math.abs(t - center) / (win / 2);
let q = Math.max(0.05, 1 - off); let q = Math.max(0.05, 1 - off);
// ...WITHOUT A CLIFF in the rally modes (step 4, 2026-10-14). The line above runs from the centre, so
// q was 1 inside perfectMs and 0.43 a millisecond outside it: a groundstroke's half-window is ~80 ms
// and PERFECT owns 45 of them. The volley's 1.8x window used to hide that (q 0.69 at the edge); step
// 1 made the rally groundstrokes, and a 30 ms early tap came back at q 0.6 (0.89 now). Same window,
// same PERFECT, same whiff edge — only the step between them is gone. It does NOT move the ring bot's
// win rate: points are decided by whiffs, not weak balls (see PROJECT.md, step 4's measurement).
const p = TUNE.perfectMs / 1000, half = win / 2;
if (rallyFeel() && !isAI && TUNE.qSeamless && half > p)
q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p));
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
if (t > TUNE.swingWindup + win) q = 0.05; // the follow-through: met, barely
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
// the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the // the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the
// finger is usually still down at contact, and the vector under it NOW is the aim the player // finger is usually still down at contact, and the vector under it NOW is the aim the player
@@ -1465,19 +1790,57 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit). // makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit).
const smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0; const smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0;
// Keep the old random sequence for classic and for foes without smart aim. // Keep the old random sequence for classic and for foes without smart aim.
const smart = isAI && (G.diff || smartChance) && Math.random() < smartChance; // THE SITTER IS PUNISHED: rolled first and only on a sitter, so every other ball keeps its old sequence
const kill = isAI && punishes(ch);
const smart = kill || isAI && (G.diff || smartChance) && Math.random() < smartChance;
const aim = isAI const aim = isAI
? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1)) ? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1))
: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], : [ -1, 0, 1 ][Math.floor(Math.random() * 3)],
az: Math.random() < 0.25 ? -1 : 0 } az: Math.random() < 0.25 ? -1 : 0 }
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; : flick ? { ...flick } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; // a copy: the shot marks it (smash)
// THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop
// when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally
// ball it always hit, carrying its spin. One roll, so the shares are exactly the table's.
const st = isAI && !kill ? styleOf(ch) : null;
if (st) {
const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random();
const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5;
if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; }
else if (atNet) { aim.ax = P.x > 0 ? -1 : 1; aim.pass = true; aim.spin = st.spin; } // the PASS: at the line, away from him
else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; }
else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; }
else aim.spin = st.spin;
}
// A quality floor makes strong opponents sustain pressure, instead of only improving their // A quality floor makes strong opponents sustain pressure, instead of only improving their
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor. // luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin; const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin)); if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
if (!isAI && G.mode === 'tennis') { if (kill) q = Math.max(q, TUNE.punishQ);
const q0 = q; // (the log names whichever cap below took it down)
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
// on its first frame in reach is often low, and capping those made every rally ball a sitter.
// ...and SOFT backspin balls only (step 4): step 2's slicer hits every rally ball at spin 0.8, so this
// capped every low contact against him, his own included — invisible until a sitter got punished.
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
// That is the lob's real reward — rarely the winner itself, nearly always the point.
if (rallyFeel() && ball.rx && ball.rx.lob && !smash) q = Math.min(q, TUNE.lowVolleyQ); // ...unless he got UNDER it
if (kill && q >= TUNE.punishQ) aim.kill = true;
if (smash) { aim.smash = true; praisePop('SMASH!', ball.x, ball.y, ball.z); } // a put-away only if no cap took it down (a low volley is a block)
if (!isAI && swingLog()) {
G.swingSnaps = null; G.rallyNear = null; G.swingSnaps = null; G.rallyNear = null;
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 }); const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
: aim.az < 0 ? ' deep' : '';
const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : '';
const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels'
: ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop';
// ...and WHICH WAY the timing missed: contact before the window's centre means the ball was
// already there — the swing came LATE; after it, the swing came EARLY
const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`;
logSwing({ result: 'HIT', reason: `q=${r2(q)}${smash ? ' SMASH' : kind}${lane}${volley && !smash ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) });
} }
doHit(ch, q, aim); doHit(ch, q, aim);
ch.swingT = -1; ch.swingT = -1;
@@ -1486,6 +1849,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns // racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns
// your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead. // your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead.
function racketAI(rz, dt) { function racketAI(rz, dt) {
if (rz.cine) return; // THE STORY's transformation holds the finalist where he stands
expirePose(rz, dt); expirePose(rz, dt);
rz.swingCool -= dt; rz.swingCool -= dt;
const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0 const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0
@@ -1495,6 +1859,8 @@ function racketAI(rz, dt) {
const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored
&& !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0 && !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0
&& !(rz.serveHold > 0); && !(rz.serveHold > 0);
// ...and while its own ball flies, is it RUSHING the net behind it? (serve and volley, an approach)
if (recovering) rz.rushing = ball.approach === 'O';
// ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on // ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on
// the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben // the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben
// die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue. // die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue.
@@ -1521,19 +1887,41 @@ function racketAI(rz, dt) {
: G.match ? TUNE.aiRead : G.match ? TUNE.aiRead
// the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible) // the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible)
: TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill(); : TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill();
const aimX = recovering ? HOME.O.x : pred ? ball.x + (pred.x - ball.x) * read : ball.x; // THE COURT HAS ITS LENGTH (match only): from the back he waits BEHIND the bounce for the ball to
// come down — standing ON it made him volley 90-100% of everything from the T — and a short ball
// pulls him in. At the net he holds his depth on the ball's line, unless it will clear his racket:
// that is a lob, and he turns and runs it down like a baseliner.
const plan = G.match && incoming ? rxPlan(rz) : null;
if (plan && plan.net && ball.vz < 0) {
const tc = (rz.z - ball.z) / ball.vz, gg = ball.g || TUNE.gravity;
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
}
// ...and AT THE NET he needs a moment to read the pass: a volleyer reacts, he does not pre-know.
// Without it a body with these legs covered every passing lane the instant the ball left.
if (G.match) G.aiReactT += dt;
// ...and a SOFT ball is read late from anywhere: until the strike it looks like any other swing,
// which is the whole reason a drop or a lob wins points in the real game. Rallies are untouched.
const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead
|| ball.smash && G.aiReactT < TUNE.smashRead);
const c = plan && !plan.net ? bounceContact(plan.short) : null;
const netX = plan && plan.net ? ball.x + ball.vx * Math.max(0, (rz.z - ball.z) / ball.vz) : null;
const tgtX = c ? c.x : netX ?? (pred && pred.x);
const home = G.match ? rallyHome(rz) : HOME.O;
const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x;
// Read the landing depth immediately in a match, including rising returns. Blending with // Read the landing depth immediately in a match, including rising returns. Blending with
// the ball's current depth would send every receiver towards the net before it arrives. // the ball's current depth would send every receiver towards the net before it arrives.
const aimZ = recovering ? HOME.O.z : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z; const aimZ = recovering ? home.z : c ? c.z : netX !== null ? Math.max(rz.z, COURT.netZ - TUNE.netStand)
const tz = Math.max(1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ)); : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) { if (d > 0.15 && !reading) {
// THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard, // THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard,
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's // so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a // 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
// whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase. // whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase.
// (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share) // (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share)
const step = Math.min(rzChaseSpeed(rz) * (recovering ? 0.5 : 1) * dt, d); // (recovery walks at half pace — but a body charging the net behind its shot SPRINTS)
const step = Math.min(rzChaseSpeed(rz) * (recovering && !rz.rushing ? 0.5 : 1) * dt, d);
rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
if (rz.pose !== 'swing') rz.pose = 'run'; if (rz.pose !== 'swing') rz.pose = 'run';
@@ -1559,7 +1947,7 @@ function racketAI(rz, dt) {
const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 }; const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 };
if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0 if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach && Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach
&& ball.y < CONTACT.maxY && ball.y < CONTACT.maxY && !letBounce()
&& (G.match ? ball.y >= CONTACT.minY || ball.vy > 0 : ball.y > CONTACT.minY)) { && (G.match ? ball.y >= CONTACT.minY || ball.vy > 0 : ball.y > CONTACT.minY)) {
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away, // ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
// which across a game is an opponent that beats itself. // which across a game is an opponent that beats itself.
@@ -1571,6 +1959,7 @@ function racketAI(rz, dt) {
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
if (Math.random() < flub) { if (Math.random() < flub) {
recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach }); recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach });
if (swingLog()) logSwing({ result: 'THEM', reason: 'whiffed it' });
rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
} }
else { else {
@@ -1673,7 +2062,7 @@ function meleeCooldown() {
TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
} }
export function keyPressed(code) { export function keyPressed(code, lead = 0, flick = false) {
// THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no // THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no
// ball and no scoring, so every other meaning a key has here is meaningless inside it. // ball and no scoring, so every other meaning a key has here is meaningless inside it.
if (G.bench) return benchKey(code); if (G.bench) return benchKey(code);
@@ -1762,10 +2151,45 @@ export function keyPressed(code) {
hitServe(P, q, sway || hard ? { sx: sway, hard } : null); hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
return; return;
} }
if (G.phase === 'rally' && P.swingT < 0) { // THE DOUBLE TAP IS HIS LEGS (2026-10-14, Oliver: "das Vorlaufen und Zurücklaufen sollte durch einen
recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK() }); // Doppeltipp ausgelöst werden"). While his OWN ball is flying there is nothing to hit, so a tap there is
P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; // not a swing: two inside dblTap are the move — in to the net from the back, back from the net. An
// incoming ball keeps every tap a swing, so the two meanings never share a moment. (v374 put this on the
// flick's length; the shot and the run are separate decisions now.)
if (rallyFeel() && G.phase === 'rally' && ball.active && ball.lastHit === 'P' && !flick) {
if (G.moveTapT > 0) { G.moveTapT = 0; moveCall(); } else G.moveTapT = TUNE.dblTap;
return;
} }
if (G.phase === 'rally' && P.swingT < 0) {
recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK(), lead });
// A TAP COUNTS FROM THE MOMENT THE FINGER LANDS (rally modes). A tap is only known to BE a tap
// when the finger lifts — it could still turn into a flick — so it used to swing ~100-150 ms
// after the player meant it. Inside step 1's groundstroke window that collapsed the contact
// quality of a tap made exactly on the gold ring from 0.82 to 0.09 (Oliver, 2026-10-14: "trotz
// gutem Timing nicht mehr bekommen"). input.js hands the touch's age in as `lead`, and the swing
// clock starts that far along: the timing is judged from touch-down, the ball still has to be
// there for the contact.
// A FLICK is NOT back-dated by default (TUNE.flickLead 0): his v367 log showed every 62-64 ms flick
// landing 76-92 ms EARLY once v366 timed flicks from touch-down — a flick is timed by its swipe,
// not by the finger landing. The touch's age is still logged for both gestures.
P.swingT = rallyFeel() ? (flick ? Math.min(lead, TUNE.flickLead) : lead) : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true;
P.tap = Math.round(lead * 1000); P.flick = flick; // ...and the log says which gesture, and how long it took to fire
}
}
// In to the net, or back from it: the double tap's one meaning is "the other end of my half". At the net
// (or already running in) it is the way back; anywhere else it is the way in. rallyHome reads the flags
// while his ball flies, the run goes at full pace, and his reply is planned from wherever he has got to.
// It is a STANCE, not one ball's run (Oliver on v376: "wenn man doppeltippt, geht er minimal vor"): his own
// ball's flight covers 4-9 m of the 10 to the net, his next ball was met from mid-court, outside the net
// zone, and the recovery after it walked him straight back to the baseline. P.post holds until the next
// double tap, a lob he has to run down, or the point's end.
function moveCall() {
const goingIn = atNetHome(P);
P.post = goingIn ? 'base' : 'net';
ball.approach = goingIn ? null : 'P';
ball.retreat = goingIn ? 'P' : null;
popText(P.x, 0, P.z + TUNE.praiseBehind, goingIn ? 'BACK' : 'TO THE NET', UI.warm, true);
} }
function expirePose(ch, dt) { function expirePose(ch, dt) {
@@ -1837,6 +2261,7 @@ function mopRound(b, dt) {
// ---------------------------------------------------------------- update ---- // ---------------------------------------------------------------- update ----
export function update(dt) { export function update(dt) {
recordFrame(dt); recordFrame(dt);
if (G.moveTapT > 0) G.moveTapT -= dt; // the double tap's window (keyPressed)
if (G.hitstop > 0) { G.hitstop -= dt; return; } if (G.hitstop > 0) { G.hitstop -= dt; return; }
// THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the // THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the
// crowd is still clapping it down — and the only thing running is the clock the summary and its // crowd is still clapping it down — and the only thing running is the clock the summary and its
@@ -1932,7 +2357,7 @@ export function update(dt) {
// walking between points" rule is untouched — nothing arms a burst except the player. // walking between points" rule is untouched — nothing arms a burst except the player.
if (G.phase === 'rally' || G.mgT > 0) { if (G.phase === 'rally' || G.mgT > 0) {
const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes if (swingLog() && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
} }
@@ -1962,7 +2387,22 @@ export function update(dt) {
// included. The assist puts you there; the volley timing stays honest. // included. The assist puts you there; the volley timing stays honest.
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55); // ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn)
// HIS OWN WAY IN AND OUT runs at full pace (the attack's follow-in, the lob's retreat), and while
// his attack flies he is RUSHING: the reply is planned as a net man's from wherever he has got to
// committed to the net he is a net man for every ball until he reaches it (a double tap behind his serve is serve and volley)
if (P.post === 'base' && Math.abs(P.z - COURT.netZ) > TUNE.netFrom + 0.5) P.post = null; // back: the old rules again (a short ball still pulls him in)
P.rushing = P.post ? P.post === 'net' : ball.lastHit === 'P' ? ball.approach === 'P' : P.rushing;
const ownRun = !chasing && rallyFeel() && (P.post || ball.lastHit === 'P' && (ball.approach === 'P' || ball.retreat === 'P'));
let rate = TUNE.playerSpeed * aK * dt * (chasing || ownRun ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
// ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run
// started on the strike's frame with a perfect read and covered the whole court — no put-away ever
// won a point, and the CPU still had no way to take one
const key = ball.lastHit + (chasing ? 'c' : 'r');
if (!chasing) P.chase = null;
else if (!P.chase || P.goalKey !== key) P.chase = { x: P.x, z: P.z, t: 0, lost: 0, plan: null }; // where this chase began
if (chasing && ball.heels > 0) { ball.heels -= dt; rate = 0; if (P.chase) P.chase.lost += dt; }
else if (P.chase) P.chase.t += dt; // the time he has had to run, for intercept's reach
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists // Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
// for a reason: // for a reason:
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid // GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
@@ -1974,11 +2414,33 @@ export function update(dt) {
// unreachable": one wide return pulled you off centre and you STAYED there, so the next // unreachable": one wide return pulled you off centre and you STAYED there, so the next
// ball to the opposite corner was lost before it was struck. Not a strong CPU — a player // ball to the opposite corner was lost before it was struck. Not a strong CPU — a player
// who never walked back. // who never walked back.
const tgt = chasing ? intercept('P') // ...and the plan is LATCHED for the chase: the flight is fixed the moment the ball is struck, so the
// point chosen first stays the answer while his legs still make it in time (net of any time he stood:
// a split-step, a put-away's heels). Re-asked every frame, every margin in intercept — a bounce that
// peaks on a height floor, a point on the back boundary — flipped the plan by metres as the 10 ms
// sampling grid slid against the frame: the shake Oliver saw. Hysteresis there only moved the margin.
const c = P.chase, spdP = TUNE.playerSpeed * aK;
const latched = c && c.plan && c.plan.ti !== undefined
&& Math.hypot(c.plan.x - c.x, c.plan.z - c.z) / spdP <= c.plan.ti - (c.lost - c.plan.lost0) + 0.02;
const raw = chasing ? (latched ? c.plan : (c ? (c.plan = { ...intercept('P'), lost0: c.lost }) : intercept('P')))
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z } : G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z }
: HOME.P; : rallyFeel() ? rallyHome(P) : HOME.P;
// THE RUN, SMOOTHED (2026-10-14, Oliver: "wenn der Autoplay den Ball versucht zu bekommen, zittert der
// Player schnell hin und her"). intercept asks every frame for the EARLIEST point he reaches in time,
// and his own step moves that point: the target jumped ~10 cm a frame (p90 0.3 m) and flipped across
// him, start/stop at the deadzone's edge, dozens of reversals a minute. It now EASES toward each plan
// (runSmooth); a new shot or a re-plan further than runSnap (the lob read) is still taken at once.
const jump = P.goal ? Math.hypot(raw.x - P.goal.x, raw.z - P.goal.z) : Infinity;
if (!P.goal || P.goalKey !== key || jump > TUNE.runSnap) P.goal = { x: raw.x, z: raw.z };
else { const k = Math.min(1, dt * TUNE.runSmooth); P.goal = { x: P.goal.x + (raw.x - P.goal.x) * k, z: P.goal.z + (raw.z - P.goal.z) * k }; }
P.goalKey = key;
const tgt = P.goal;
P.tgt = tgt; // the timed gold ring (ringCue) predicts the contact from where this run ends
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it // deadzone WITH HYSTERESIS: start at 0.35, and once running, carry on to runStop — a target hovering
// at the edge no longer toggles him between a step and a stand every other frame
P.running = d > (P.running ? TUNE.runStop : 0.35);
if (P.running) { // deadzone: stand ready near the spot instead of dithering over it
P.x += dx / d * Math.min(rate, d); P.x += dx / d * Math.min(rate, d);
P.z += dz / d * Math.min(rate, d); P.z += dz / d * Math.min(rate, d);
assist = dx; assist = dx;
@@ -2015,6 +2477,19 @@ export function update(dt) {
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report. // mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
// vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead. // vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead.
else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } } else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } }
// THE GOLD RING, STEADY (Oliver, 2026-10-14: "der Treffer-/Zielkreis zittert jetzt manchmal").
// ringCue re-solves every frame against where the auto-run is taking him, and a target that shifts
// a few centimetres moved the predicted moment by a frame or two — the ring's size twitched and its
// gold blinked. The drawn cue COUNTS DOWN on its own clock and only eases toward each fresh solve
// (TUNE.ringSmooth), and a frame with no solve holds it instead of dropping it.
if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && (rallyFeel() || G.mode === 'tennis')) {
const c = ringCue();
if (c && !G.ring) G.ring = { ...c };
else if (c) { const e = G.ring.eta - dt; G.ring = { ...c, eta: e + (c.eta - e) * Math.min(1, dt * TUNE.ringSmooth) }; }
else if (G.ring && (G.ring.hold = (G.ring.hold || 0) + dt) < 0.12) G.ring.eta -= dt;
else G.ring = null;
if (G.ring) G.ring.gold = Math.abs(G.ring.eta - G.ring.sweet) < G.ring.band;
} else G.ring = null;
// The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent // The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
// in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him // in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
// off the edge of the screen either. // off the edge of the screen either.
@@ -2032,18 +2507,20 @@ export function update(dt) {
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
if (P.swingT >= 0) { if (P.swingT >= 0) {
P.swingT += dt; P.swingT += dt;
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) if (swingLog() && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame)
const volley = ball.bounces === 0; const volley = ball.bounces === 0;
(G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y, (G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y,
bz: ball.z, bounces: ball.bounces, bz: ball.z, bounces: ball.bounces, lb: letBounce(),
r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() }); // the reach trySwing really tests — the match's longer racket included, or a near miss reads wide
r: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1),
w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() });
} }
trySwing(P, false); // a contact resets swingT to -1 inside trySwing trySwing(P, false); // a contact resets swingT to -1 inside trySwing
// volley-length window while a hostile shot is in the air too, or the swing clock expires at // volley-length window while a hostile shot is in the air too, or the swing clock expires at
// 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance // 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance
const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1) const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1)
* winLevel(); // the clock must outlive the widened window or the level's gift is dead time * winLevel(); // the clock must outlive the widened window or the level's gift is dead time
if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing if (P.swingT > TUNE.swingWindup + Math.max(win, TUNE.swingWindow * winLevel() + swingTail(false)) + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
} }
expirePose(P, dt); expirePose(P, dt);
@@ -2070,7 +2547,8 @@ export function update(dt) {
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is // The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
// pure upside for weapon supply. // pure upside for weapon supply.
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled
&& !G.story) // the story has no duel at all
startDuel(); startDuel();
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
// the duel is played under tennis lights: lerp the palette home while it runs, back after // the duel is played under tennis lights: lerp the palette home while it runs, back after
@@ -2105,7 +2583,7 @@ export function update(dt) {
// the flight shortens the earliest-reachable point jumps around its solution — each jump // the flight shortens the earliest-reachable point jumps around its solution — each jump
// flips his run direction, faster and faster. He commits to the stored point unless the // flips his run direction, faster and faster. He commits to the stored point unless the
// fresh solve moves it meaningfully (or the chase resets between shots). // fresh solve moves it meaningfully (or the chase resets between shots).
const fresh = incoming ? intercept('O') : HOME.O; const fresh = incoming ? intercept('O') : rallyFeel() ? rallyHome(O) : HOME.O;
if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35) if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35)
G.aiTgt = fresh; G.aiTgt = fresh;
const tgt = G.aiTgt; const tgt = G.aiTgt;
@@ -2123,7 +2601,7 @@ export function update(dt) {
if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) { if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) {
// same radius trySwing uses for contact, so an armed swing actually connects // same radius trySwing uses for contact, so an armed swing actually connects
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0; && ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0 && !letBounce();
if (near) { if (near) {
// rubber band: every game HE has taken makes him flub more — the opening match must not // rubber band: every game HE has taken makes him flub more — the opening match must not
// be a wall a new player grinds against for rounds (see aiFlubLoss in config). // be a wall a new player grinds against for rounds (see aiFlubLoss in config).
@@ -2215,8 +2693,9 @@ export function update(dt) {
const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step
ball.x -= ball.vx * back; ball.z -= ball.vz * back; ball.x -= ball.vx * back; ball.z -= ball.vz * back;
} }
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY; ball.y = 0; ball.vy = -ball.vy * bY();
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ; ball.vx *= bXZ(); ball.vz *= bXZ();
ball.g = gAfter(ball.g); // the dip ends at the bounce: a topspin ball kicks UP now (gAfter)
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
onBounce(); onBounce();
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
@@ -2231,8 +2710,13 @@ export function update(dt) {
} else if (ball.bounces === 1) pointTo(ball.lastHit, null); } else if (ball.bounces === 1) pointTo(ball.lastHit, null);
} }
} }
// a racket zombie's return that gets past you (crosses your baseline) is a point for THEM // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM.
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { // IN THE MATCH "past you" means past your REACH (2026-10-14, Oliver's v366 log): since step 1 a
// baseliner waits up to rallyBehind BEHIND his line, so the old line — 0.3 m behind the baseline —
// called the point while the ball was still on its way to his racket. Every deep topspin ball the
// auto-run met deep was a "passed you" with no swing. The duel keeps the old line.
const passZ = G.match ? Math.max(COURT.L + 0.3, P.z + TUNE.reach * TUNE.matchReach + 0.3) : COURT.L + 0.3;
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > passZ) {
ball.active = false; ball.active = false;
if (G.interlude) resolveInterlude('O', 'passed_player'); // sudden death: its passing shot ends the duel if (G.interlude) resolveInterlude('O', 'passed_player'); // sudden death: its passing shot ends the duel
else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way
@@ -2285,6 +2769,7 @@ export function update(dt) {
// ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over' // ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over'
// block so it can hand the point back before that block puts the player in a serve stance. // block so it can hand the point back before that block puts the player in a serve stance.
tickMatchFall(dt); tickMatchFall(dt);
tickStory(dt);
if (G.phase === 'over') { if (G.phase === 'over') {
G.overT -= dt; G.overT -= dt;
if (G.overT <= 0) { if (G.overT <= 0) {
+7 -3
View File
@@ -207,7 +207,11 @@ cv.addEventListener('touchmove', e => {
? { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, ? { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0,
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
fx: dx / dist, fy: dy / dist, mag: dist } : null; fx: dx / dist, fy: dy / dist, mag: dist } : null;
if (G.swipe && !stick.swung && G.phase === 'rally') { stick.swung = true; keyPressed('Space'); } // The touch's age rides along for the swing log; keyPressed decides how much of it the swing
// clock is back-dated by (TUNE.flickLead — 0: a flick is timed by its swipe, his v367 log).
if (G.swipe && !stick.swung && G.phase === 'rally') {
stick.swung = true; keyPressed('Space', (performance.now() - stick.t0) / 1000, true);
}
} }
}, { passive: false }); }, { passive: false });
@@ -223,7 +227,7 @@ const endTouch = e => {
// (a bare hold used to fire at a free auto-target; that was the whole exploit) // (a bare hold used to fire at a free auto-target; that was the whole exploit)
if (aim && dist >= TUNE.pullMin) dragServe(aim); if (aim && dist >= TUNE.pullMin) dragServe(aim);
} }
else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space', dt / 1000); } // tap — dead if it was a near-miss at the rack; timed from touch-down (keyPressed)
else if (!stick.adopted && dist > TUNE.flickMin else if (!stick.adopted && dist > TUNE.flickMin
&& (stick.swung || dt < FLICK_MS || (rallyFeel() && G.phase === 'rally'))) { && (stick.swung || dt < FLICK_MS || (rallyFeel() && G.phase === 'rally'))) {
// flick = aimed swing. The FLICK_MS deadline only still applies where the swing could NOT // flick = aimed swing. The FLICK_MS deadline only still applies where the swing could NOT
@@ -235,7 +239,7 @@ const endTouch = e => {
az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0,
fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim
t: performance.now() }; t: performance.now() };
if (!stick.swung) keyPressed('Space'); // the crossing already swung — never twice per gesture if (!stick.swung) keyPressed('Space', dt / 1000, true); // the crossing already swung — never twice per gesture
} }
stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null; stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null;
} }
+201 -12
View File
@@ -3,13 +3,13 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER } from './config.js'; import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER , storyFixture, storyAct , STORY, ROMAN, storyBossArt , STORY_HELP, STORE } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs , startStory , storyUI } from './beats.js';
import { netConnect, netHooks, netAgain } from './net.js'; import { netConnect, netHooks, netAgain } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } from './render.js'; import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { voice, ambient, menuMusic, menuDuck, sfx, boom, whoosh, applause, pock } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js'; import { cv } from './dom.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
@@ -116,14 +116,27 @@ let pickedDiff = remembered('diff') || 'medium';
const modeOf = id => MODES.find(x => x.id === id); const modeOf = id => MODES.find(x => x.id === id);
const modeList = document.getElementById('modeList'); const modeList = document.getElementById('modeList');
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} }; const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// THE ARCADE OPENS WITH THE STORY (Oliver, 2026-10-15: "nur die bereits über die Hauptstory
// freigeschalteten Spielmodi spielbar machen"): a door is open once the act that unlocks it is cleared
// (config.STORY acts[].arcade), and everything is open once the story is told. A locked door is shown,
// greyed, with the act that opens it — pointed at, never hidden. STORE.arcadePremium is the stub for
// making the arcade a premium door; premium() answers true until the unlock IAP exists.
const premium = () => true;
const arcadeAct = id => STORY.acts.findIndex(a => a.arcade.includes(id)) + 1; // 0 = never gated
const arcadeOpen = id => {
const n = arcadeAct(id), s = profile.prof.story;
return (!n || s.cp === 'done' || s.act > n) && (!STORE.arcadePremium || premium());
};
const lockLine = id => 'CLEAR ACT ' + ROMAN[arcadeAct(id)] + ' TO OPEN';
function buildTops() { function buildTops() {
modeList.innerHTML = ''; modeList.innerHTML = '';
for (const { mode } of MENU) { for (const { mode } of MENU) {
const card = modeOf(mode), b = document.createElement('button'); const card = modeOf(mode), b = document.createElement('button'), open = arcadeOpen(mode);
b.className = 'mode'; b.className = 'mode' + (open ? '' : ' locked');
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`; b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b></span>`; b.innerHTML = `<span class="cb"><b>${card.name}</b>${open ? '' : `<i>${lockLine(mode)}</i>`}</span>`;
b.addEventListener('click', () => { b.addEventListener('click', () => {
if (!arcadeOpen(mode)) { b.classList.remove('nope'); void b.offsetWidth; b.classList.add('nope'); return; }
pickMode(mode); pickMode(mode);
go(mode === 'train' ? 'train' : 'setup'); go(mode === 'train' ? 'train' : 'setup');
}); });
@@ -161,6 +174,10 @@ const vsName = document.getElementById('vsName'), vsCode = document.getElementBy
const vsSay = (id, text) => { document.getElementById(id).textContent = text; }; const vsSay = (id, text) => { document.getElementById(id).textContent = text; };
function vsOpen(code = '') { function vsOpen(code = '') {
// hosting is the arcade door act IV opens; joining a friend's room is always free
const host = arcadeOpen('vs');
for (const id of ['vsHostC', 'vsHostD']) document.getElementById(id).disabled = !host;
document.getElementById('vsHostLock').textContent = host ? '' : lockLine('vs');
vsName.value = stats.savedName(); vsName.value = stats.savedName();
vsCode.value = code; vsCode.value = code;
vsPick.hidden = false; vsWait.hidden = true; vsPick.hidden = false; vsWait.hidden = true;
@@ -371,6 +388,7 @@ function leavePlayer(saved) {
if (back === 'card') { buildCard(); go('card'); } if (back === 'card') { buildCard(); go('card'); }
else if (back === 'setup') go('setup'); else if (back === 'setup') go('setup');
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); } else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
else if (back === 'story' && saved) start('story');
else go('splash'); else go('splash');
} }
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false)); document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
@@ -633,8 +651,14 @@ function refreshSplash() {
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%'; document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
// THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use // THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use
// (config.cupFixture) — minus the round count, which lives in the tournament's own modal now. // (config.cupFixture) — minus the round count, which lives in the tournament's own modal now.
const fx = cupFixture(profile.prof.lg, profile.prof.ch); // THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands
const fx = storyFixture(profile.prof.story);
document.getElementById('msTitle').textContent = fx.title; document.getElementById('msTitle').textContent = fx.title;
// the arcade's gore door says what opens it while it is shut
const gOpen = arcadeOpen('gore'), mg = document.getElementById('mGore');
mg.classList.toggle('locked', !gOpen);
mg.querySelector('i').textContent = gOpen ? 'NO CUP. JUST CARNAGE.' : lockLine('gore');
document.getElementById('mCourt').style.display = gOpen ? '' : 'none'; // (its CSS display beats [hidden])
document.getElementById('msLine').textContent = fx.line; document.getElementById('msLine').textContent = fx.line;
document.getElementById('mCourt').textContent = 'COURT · ' + document.getElementById('mCourt').textContent = 'COURT · ' +
(COURTS.find(c => c.id === picked) || COURTS[0]).name; (COURTS.find(c => c.id === picked) || COURTS[0]).name;
@@ -651,7 +675,8 @@ function start(modeId = pickedMode) {
selectKit(pickedKit); selectSkin(pickedSkin); selectKit(pickedKit); selectSkin(pickedSkin);
// THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other // THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other
// mode — the climb included, it is an exhibition — plays your pick (among the unlocked). // mode — the climb included, it is an exhibition — plays your pick (among the unlocked).
selectCourt(modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked); selectCourt(modeId === 'story' ? leagueOf(storyAct(profile.prof.story.act).lg).court
: modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes // ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
@@ -669,6 +694,9 @@ function start(modeId = pickedMode) {
if (modeId === 'cup') { intoSplatter(); startMatch(true); } // cupDraw shuffles per round if (modeId === 'cup') { intoSplatter(); startMatch(true); } // cupDraw shuffles per round
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch) // training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); } if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
// THE STORY: the act's court, the splatter world under it, and the checkpoint the profile holds
// (the PROLOGUE is the one door that stays in 'tennis': a real match, the old turn armed in it)
if (modeId === 'story') { if (profile.prof.story.cp !== 'pro') intoSplatter(); startStory(); }
} }
// Career totals and detailed records live one level beneath the profile. // Career totals and detailed records live one level beneath the profile.
@@ -723,17 +751,146 @@ document.addEventListener('visibilitychange', () => {
} }
}); });
addEventListener('pagehide', () => recorder.saveRecording()); addEventListener('pagehide', () => recorder.saveRecording());
// ---- THE STORY's screens (2026-10-15, PROJECT.md "THE STORY", phase 3) ------------------------------
// In-game, animated, in the storyboard's look: the act title card, the cup's ceremony with its loot,
// and the act card. Each pauses the world while it is up (the same `paused` the quit chip uses) and
// resolves with the door that closed it. beats asks for them through storyUI.show.
const sty = document.getElementById('sty');
let styClose = null, styDoors = false;
const art = n => `assets/${n}.png?v=${SPRV}`;
const courtOf = act => { const lg = leagueOf(storyAct(act).lg); return { lg, court: COURTS.find(c => c.id === lg.court) || COURTS[0] }; };
const ARCADE_NAMES = { train: 'TRAINING', classic: 'CLASSIC TENNIS', zombie: 'THE CLIMB', gore: 'SPLATTER GORE', vs: 'VS A FRIEND' };
const delay = (k, more = '') => `style="animation-delay:${(k * 0.15).toFixed(2)}s;${more}"`; // one style attribute per element
// THE SOUND OF THE SCREENS (Oliver: "kleine Soundeffekte — die Waffen könnten das Klacken haben, das
// auch beim Waffenwechsel im Gore-Mode spielt"). Each cue is timed to the animation it belongs to and
// is dropped if the screen is tapped away first.
let styTimers = [];
const at = (secs, fn) => styTimers.push(setTimeout(fn, secs * 1000));
const CLICK = () => sfx('weapon-click2.mp3', 0.7); // the rack's own click (gameplay.selectWeapon)
function storyScreen(kind, d) {
const act = d.act, { lg, court } = courtOf(act);
let html = '', glow = '#3a1d16', auto = 0;
styTimers.forEach(clearTimeout); styTimers = [];
if (kind === 'act') {
auto = 2.8;
glow = '#1d2233';
html = `<div class="bgc p" style="background-image:url(${art(court.tile)})"></div>
<div class="col">
<div class="lab gold r">${lg.name}</div>
<h1 class="huge s" ${delay(1)}>ACT ${ROMAN[act]}</h1>
<div class="big gold r" ${delay(3)}>${court.name}</div>
<div class="lab r" ${delay(5)}>WIN THE CUP · CLEAR THE COURT</div>
<img class="badge r" ${delay(6)} src="${art('badge_l' + lg.id)}" alt="">
<div class="lab tap">TAP</div>
</div>`;
whoosh(0.3); at(0.3, () => boom(0.45)); // the court flies in, ACT n lands
} else if (kind === 'loot') {
const xd = profile.runDelta(), mid = storyBossArt(act, 'mid').name;
html = `<div class="col">
<div class="lab gold r">CUP WON · ${court.name}</div>
<div class="big r" ${delay(1)}>YOU CLEARED THE COURT</div>
<div class="stats r" ${delay(2)}>
<span class="lab">ROUNDS</span><b>${d.rounds} / ${d.rounds}</b>
<span class="lab">XP THIS RUN</span><b>+${xd.xp}</b>
<span class="lab">LEVEL</span><b>${xd.to}</b></div>
<div class="lab r" ${delay(3, 'color:#e8e8dc')}>…AND GAINED THESE FOR THE HORDES TO COME</div>
<div class="rack">${d.stock.map((e, k) => `<div class="d" style="animation-delay:${(0.7 + k * 0.22).toFixed(2)}s">
<img src="${art(WEAPONS[e.id].icon)}" alt=""><span class="lab pink">${WEAPONS[e.id].label} ×${e.n}</span></div>`).join('')}</div>
<div class="r" style="width:100%;display:grid;gap:6px;animation-delay:${(0.9 + d.stock.length * 0.22).toFixed(2)}s">
<div class="lab hot">CHAPTER ${ROMAN[act]} · ${STORY.length} GAMES</div>
<div class="pips">${Array.from({ length: STORY.length }, (_, k) => `<i class="${k === STORY.midAt - 1 ? 'm' : k === STORY.length - 1 ? 'b' : ''}"></i>`).join('')}</div>
<div class="row"><span class="lab gold">${STORY.midAt} · ${mid}</span><span class="lab hot">${STORY.length} · ???</span></div></div>
<button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button>
</div>`;
applause(2.4, 0.45); at(0.3, () => pock(0.5, 700));
// each tin lands at 70% of its 0.6 s drop — that is where it clicks into the rack
d.stock.forEach((e, k) => at(0.7 + k * 0.22 + 0.42, CLICK));
} else if (kind === 'clear') {
// THE FREE TIER ends here, on a win (STORE.freeTier, off until the unlock exists): act I's card
// names the next act and points at the full game instead of continuing into it
const wall = STORE.freeTier && !premium() && act === 1;
const done = act >= 5, next = done ? null : courtOf(act + 1);
const opens = storyAct(act).arcade.map(id => ARCADE_NAMES[id]).filter(Boolean);
glow = '#3b3216';
html = `<div class="col">
<div class="lab gold r">ACT ${ROMAN[act]} CLEARED</div>
<div class="big s" ${delay(1)}>${done ? 'THE DEAD CLUB IS QUIET' : court.name + ' IS QUIET'}</div>
<img class="badge s" ${delay(2)} src="${art(done ? 'badge_champ' : 'badge_l' + lg.id)}" alt="">
<div class="stats r" ${delay(3)}>
<span class="lab">BODIES</span><b>${stats.runKills ? stats.runKills() : 0}</b>
<span class="lab">BOSSES</span><b>2 / 2</b>
<span class="lab">DEATHS THIS ACT</span><b>${d.deaths}</b></div>
${opens.length ? `<div class="r" ${delay(5, 'display:grid;gap:6px')}><div class="lab">NEW IN ARCADE</div>
<div class="chips">${opens.map(n => `<span class="chip">${n}</span>`).join('')}</div></div>` : ''}
${next ? `<div class="next r" ${delay(7, `background-image:linear-gradient(#0006,#000e),url(${art(next.court.tile)})`)}>
<div><div class="lab gold">NEXT · ACT ${ROMAN[act + 1]}</div><div class="mid">${next.court.name}</div></div></div>`
: `<div class="mid gold r" ${delay(7)}>THE STORY IS TOLD</div>`}
${wall ? `<div class="lab gold r" ${delay(8)}>THE STORY CONTINUES IN THE FULL GAME</div>` : ''}
<button class="btn big r" ${delay(9)} data-door="${wall ? 'club' : 'next'}">${done || wall ? 'THE CLUBHOUSE' : 'CONTINUE'}</button>
${done || wall ? '' : `<button class="btn r" ${delay(10)} data-door="club">THE CLUBHOUSE</button>`}
</div>`;
applause(3.2, 0.55); at(0.4, () => boom(0.4)); // the act is down; the badge lands
opens.forEach((_, k) => at(0.85 + k * 0.12, () => pock(0.55, 1100 + k * 180)));
}
sty.style.setProperty('--sty-glow', glow);
sty.innerHTML = html; sty.hidden = false; paused = true;
styDoors = /data-door/.test(html);
const t0 = performance.now();
return new Promise(res => {
styClose = door => {
if (performance.now() - t0 < 700) return; // a tap still on its way from the court cannot skip it
sty.hidden = true; sty.innerHTML = ''; paused = false; styClose = null;
styTimers.forEach(clearTimeout); styTimers = [];
res(door);
};
if (auto) setTimeout(() => styClose && styClose('auto'), auto * 1000);
});
}
sty.addEventListener('click', e => {
if (!styClose) return;
const b = e.target.closest('[data-door]');
if (b) styClose(b.dataset.door); else if (!styDoors) styClose('tap');
});
storyUI.show = (kind, d) => storyScreen(kind, d).then(door => {
if (kind !== 'clear') return;
// the act card IS the act's ending: the run closes here, and the story picks up where it now stands
restartRun(); profile.push(); refreshSplash();
intro.hidden = false; quitChip.hidden = true;
if (door === 'next' && profile.prof.story.cp !== 'done') start('story'); else go('splash');
});
const goMode = () => { buildTops(); go('mode'); }; const goMode = () => { buildTops(); go('mode'); };
document.getElementById('mStart').addEventListener('click', () => { document.getElementById('mStart').addEventListener('click', () => {
if (!profile.prof.setup) { playerReturn = 'cup'; buildPlayer(); go('player'); return; } if (!profile.prof.setup) { playerReturn = 'story'; buildPlayer(); go('player'); return; }
pickMode('cup'); start('cup'); start('story');
}); });
document.getElementById('mOther').addEventListener('click', goMode); document.getElementById('mOther').addEventListener('click', goMode);
// RESET STORY (Oliver, 2026-10-15: "ich brauche zumindest während des Tests einen Button, der meinen
// Spielstand wieder resettet"). Two taps, so a stray one costs nothing. It puts the story back at the
// prologue and takes back what the story hands out — league, cups, the champion's flag — and keeps the
// level (xp is the server mirror's one-way ratchet). The push makes the mirror agree at once: pull()
// moves the story only forward, and a mirror still holding act III would hand it straight back.
{
const b = document.getElementById('mReset');
b.hidden = !STORE.testReset;
let armT = -Infinity; // (0 would count as armed in a page younger than 3 s)
b.addEventListener('click', () => {
if (performance.now() - armT > 3000) { armT = performance.now(); b.textContent = 'TAP AGAIN TO RESET'; b.classList.add('arm'); return; }
const p = profile.prof;
p.story = profile.freshStory(); p.lg = 1; p.ch = 0; p.cups = {}; p.at = {}; p.out = 0;
profile.save(); profile.push();
armT = -Infinity; b.textContent = 'STORY RESET'; b.classList.remove('arm');
refreshSplash(); buildTops();
setTimeout(() => { b.textContent = 'RESET STORY'; }, 1600);
});
}
document.getElementById('mVs').addEventListener('click', () => vsOpen()); document.getElementById('mVs').addEventListener('click', () => vsOpen());
document.getElementById('mGore').addEventListener('click', () => { document.getElementById('mGore').addEventListener('click', () => {
if (!arcadeOpen('gore')) return;
pickMode('gore'); start('gore'); pickMode('gore'); start('gore');
}); });
document.getElementById('mCourt').addEventListener('click', () => { document.getElementById('mCourt').addEventListener('click', () => {
if (!arcadeOpen('gore')) return;
pickMode('gore'); go('setup'); pickMode('gore'); go('setup');
}); });
document.getElementById('cdStats').addEventListener('click', openStats); document.getElementById('cdStats').addEventListener('click', openStats);
@@ -1023,6 +1180,7 @@ const maybeRestart = e => {
// resets both phones itself and the menu never comes back. It refuses (and falls through to the // resets both phones itself and the menu never comes back. It refuses (and falls through to the
// clubhouse) if the friend has already hung up. // clubhouse) if the friend has already hung up.
if (door === 'rematch' && netAgain()) return; if (door === 'rematch' && netAgain()) return;
const story = !!G.story; // restartRun wipes G — the story's place is in the profile
restartRun(); restartRun();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet profile.push(); // the run's xp is banked — mirror it while the moment is quiet
refreshSplash(); // the run just ended: the splash count has moved refreshSplash(); // the run just ended: the splash count has moved
@@ -1035,6 +1193,12 @@ const maybeRestart = e => {
// prompt it may also have earned (prGo hands the run on), then the title. // prompt it may also have earned (prGo hands the run on), then the title.
const promo = profile.takePromo(); const promo = profile.takePromo();
if (promo) { postCup(promo); buildPromo(promo, run); go('promo'); return; } if (promo) { postCup(promo); buildPromo(promo, run); go('promo'); return; }
// THE STORY: TRY AGAIN / CONTINUE re-enter at the checkpoint; anywhere else is the club
if (story) {
if (door === 'help') { profile.prof.story.help = Math.min(STORY_HELP.max, profile.prof.story.help + 1); profile.save(); }
if (door === 'story' || door === 'help') start('story'); else go('splash');
return;
}
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here // ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
// silently barred two of the three modes from ever reaching the prompt // silently barred two of the three modes from ever reaching the prompt
// ...on a board that still EXISTS: classic tennis left the world board with the stats streamline // ...on a board that still EXISTS: classic tennis left the world board with the stats streamline
@@ -1042,7 +1206,7 @@ const maybeRestart = e => {
if (run && run.newKillBest && run.score > 0 && run.mode !== stats.MODE_CLASSIC) { askName(run); return; } if (run && run.newKillBest && run.score > 0 && run.mode !== stats.MODE_CLASSIC) { askName(run); return; }
// the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN // the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN
// re-deals the mode that just ended (pickedMode is what started it, cup included) // re-deals the mode that just ended (pickedMode is what started it, cup included)
if (door === 'next') { pickMode('cup'); start('cup'); return; } if (door === 'next') { start('story'); return; } // THE STORY is the fixture now
if (door === 'again') { start(pickedMode); return; } if (door === 'again') { start(pickedMode); return; }
go('splash'); go('splash');
}; };
@@ -1078,7 +1242,31 @@ cv.addEventListener('pointerdown', e => {
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
const y = (e.clientY - r.top) / r.height * (cv.height / RES); const y = (e.clientY - r.top) / r.height * (cv.height / RES);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) G.logShow = !G.logShow; // the scoreboard if (x >= 6 && x <= 138 && y >= 6 && y <= 36) G.logShow = !G.logShow; // the scoreboard
else if (G.logShow) { // ...and a tap on the open log COPIES all of it, to paste into a debug session
const L = logGeom();
if (x >= L.x && x <= L.x + L.w && y >= L.y && y <= L.y + L.h) copySwingLog();
}
}); });
// The whole ring as text (Oliver, 2026-10-14: "ich würde dir das gerne mal zum Debuggen geben"):
// a header naming the build, the mode and the body across the net, then one line per entry with
// its measured fields. Clipboard first (a tap is the user gesture it needs); the share sheet where
// a webview refuses it.
function copySwingLog() {
const v = (document.querySelector('script[src*="main.js"]')?.src.match(/v=(\d+)/) || [])[1] || '?';
const foe = G.match && !G.net ? matchFoe(G.match.opp) : null;
const head = `SPLATTER TENNIS v${v} · ${G.match ? (G.match.cup ? 'cup' : G.match.train ? 'training' : 'climb') : G.classic ? 'classic' : G.mode}`
+ (foe ? ` · ${foe.short} (${foe.style})` : G.diff ? ` · ${G.diff.name}` : '') + ` · ${new Date().toISOString()}`;
const lines = G.missLog.map(e => {
const { at, result, reason, ...rest } = e;
const extra = Object.keys(rest).length ? ' {' + Object.entries(rest).map(([k, val]) => k + '=' + val).join(' ') + '}' : '';
return `${at}s ${result} ${reason}${extra}`;
});
const text = [head, ...lines].join('\n');
const done = ok => { G.flash = { text: ok ? 'SWING LOG COPIED' : 'COPY FAILED', t: 1.2 }; };
const share = () => navigator.share ? navigator.share({ text }).then(() => done(true), () => done(false)) : done(false);
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(() => done(true), share);
else share();
}
@@ -1135,5 +1323,6 @@ requestAnimationFrame(frame);
window.T = { TUNE, ball, P, O, score, G, zombies, keyPressed, SPR, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, window.T = { TUNE, ball, P, O, score, G, zombies, keyPressed, SPR, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch,
stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall, stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall,
start, intoSplatter, startHorde, sendBoy, // the two run doors + the wave: the promo rig (tools/) stages every clip through them start, intoSplatter, startHorde, sendBoy, // the two run doors + the wave: the promo rig (tools/) stages every clip through them
profile, startStory, // THE STORY: read and set its place from the console (prof.story)
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+5 -3
View File
@@ -69,21 +69,23 @@ export function netSendShot() {
if (!G.net || !G.net.ready) return; if (!G.net || !G.net.ready) return;
send({ t: 'shot', x: r3(ball.x), y: r3(ball.y), z: r3(ball.z), send({ t: 'shot', x: r3(ball.x), y: r3(ball.y), z: r3(ball.z),
vx: r3(ball.vx), vy: r3(ball.vy), vz: r3(ball.vz), vx: r3(ball.vx), vy: r3(ball.vy), vz: r3(ball.vz),
g: r3(ball.g), pow: r3(ball.pow || 0), serve: !!G.wasServe }); g: r3(ball.g), pow: r3(ball.pow || 0), spin: r3(ball.spin || 1), serve: !!G.wasServe });
} }
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here // the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
export function netApplyShot(m) { export function netApplyShot(m) {
const s = mirror(m); const s = mirror(m);
ball.active = true; ball.collected = false; ball.bounces = 0; ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null; ball.retreat = null; ball.smash = false;
ball.x = s.x; ball.y = m.y; ball.z = s.z; ball.x = s.x; ball.y = m.y; ball.z = s.z;
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz; ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
ball.g = m.g; ball.pow = m.pow; ball.g = m.g; ball.pow = m.pow; ball.spin = m.spin || 1; // the soft ball's backspin: both phones bounce it alike
ball.soft = false;
ball.lastHit = 'O'; ball.lastHit = 'O';
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; ball.chain = 0; ball.praise = null; ball.kills = 0; ball.bomb = false; ball.flame = false; ball.wid = 'ball'; ball.chain = 0; ball.praise = null; ball.kills = 0;
G.wasServe = !!m.serve; // arms the receiver's own service-box call G.wasServe = !!m.serve; // arms the receiver's own service-box call
G.phase = 'rally'; G.swung = false; G.rallyNear = null; G.phase = 'rally'; G.swung = false; G.rallyNear = null;
O.x = s.x; O.z = s.z; // the strike happened where the ball says it did O.x = s.x; O.z = s.z; // the strike happened where the ball says it did
ball.from = Math.abs(O.z - COURT.netZ); // ...which is what the receiver's approach rule reads
O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0; O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0;
// ...AND IT MAKES A NOISE (Oliver, 2026-09-11: "der ballsound des gegners wird nicht abgespielt"). // ...AND IT MAKES A NOISE (Oliver, 2026-09-11: "der ballsound des gegners wird nicht abgespielt").
// Every other opponent in the game is simulated locally and cracks through gameplay's own strike // Every other opponent in the game is simulated locally and cracks through gameplay's own strike
+38 -5
View File
@@ -34,17 +34,37 @@ const cleanCups = (c, cap = 9) => {
return out; return out;
}; };
// THE STORY (2026-10-15, PROJECT.md "THE STORY"): where the player stands in it. `act` 1..5, `cp` the
// checkpoint to restart from ('pro' the prologue, 'cup' the act's cup, 'chap' the chapter's first
// game, 'mid' after the mid-boss, 'done' the story is told), `rack` the weapons that checkpoint
// hands back, `tries` failed attempts since the checkpoint (the help offer reads it). An ACT is the
// unit that is saved; the checkpoints live inside it.
const CPS = ['pro', 'cup', 'chap', 'mid', 'done'];
export const freshStory = () => ({ act: 1, cp: 'pro', rack: [], tries: 0, deaths: 0, help: 0 });
export const cleanStory = s => {
if (!s || typeof s !== 'object') return freshStory();
const rack = Array.isArray(s.rack) ? s.rack.filter(e => e && typeof e.id === 'string' && e.n > 0).slice(0, 9)
.map(e => ({ id: e.id.slice(0, 12), n: Math.min(99, e.n | 0) })) : [];
return { act: Math.min(5, Math.max(1, s.act | 0 || 1)), cp: CPS.includes(s.cp) ? s.cp : 'cup',
rack, tries: Math.max(0, Math.min(99, s.tries | 0)), deaths: Math.max(0, Math.min(999, s.deaths | 0)), help: Math.max(0, Math.min(2, s.help | 0)) };
};
function load() { function load() {
try { try {
const p = JSON.parse(localStorage.getItem(KEY) || 'null'); const p = JSON.parse(localStorage.getItem(KEY) || 'null');
if (p && typeof p === 'object') { if (p && typeof p === 'object') {
return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)), // A PROFILE FROM BEFORE THE STORY starts the story from the top (Oliver: the testers play the
ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0, // whole thing). Its level stays — xp is the server mirror's one-way ratchet and cannot go
cups: cleanCups(p.cups), at: cleanCups(p.at, 50), // down — but the league, the cups and the champion flag were earned on the old main path,
out: Math.max(0, Math.min(99, p.out | 0)) }; // and the story is the main path now: it hands them out again, act by act.
const pre = !p.story;
return { v: 2, xp: Math.max(0, p.xp | 0), lg: pre ? 1 : Math.min(5, Math.max(1, p.lg | 0 || 1)),
ch: !pre && p.ch ? 1 : 0, setup: p.setup ? 1 : 0,
cups: pre ? {} : cleanCups(p.cups), at: pre ? {} : cleanCups(p.at, 50),
out: pre ? 0 : Math.max(0, Math.min(99, p.out | 0)), story: cleanStory(p.story) };
} }
} catch {} } catch {}
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, at: {}, out: 0 }; return { v: 2, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, at: {}, out: 0, story: freshStory() };
} }
export const prof = load(); export const prof = load();
@@ -185,8 +205,21 @@ export async function pull() {
if (!r.ok) return; if (!r.ok) return;
const p = (await r.json()).p; const p = (await r.json()).p;
if (!p) return; if (!p) return;
// the story's place comes back FORWARD only, and only from a mirror that has one: a mirror
// written before the story carries the old main path's league and cups, which the story hands
// out again — adopting them would skip acts the tester is meant to play
const story = p.story && typeof p.story === 'object';
if (story) {
const t = cleanStory(p.story), here = prof.story;
const rank = s => s.cp === 'done' ? 99 : s.act * 10 + ['pro', 'cup', 'chap', 'mid'].indexOf(s.cp);
if (rank(t) > rank(here)) { prof.story = { ...t, rack: [], tries: 0, cp: t.cp === 'mid' ? 'chap' : t.cp }; save(); }
}
if ((p.xp | 0) > prof.xp) { if ((p.xp | 0) > prof.xp) {
prof.xp = p.xp | 0; prof.xp = p.xp | 0;
prof.setup = prof.setup || (p.setup ? 1 : 0); // the identity is done whichever path it was done on
if (!story) { runFrom = levelAt(prof.xp); save(); }
}
if (story && (p.xp | 0) >= prof.xp) {
prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1)); prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1));
prof.ch = prof.ch || (p.ch ? 1 : 0); prof.ch = prof.ch || (p.ch ? 1 : 0);
prof.setup = prof.setup || (p.setup ? 1 : 0); prof.setup = prof.setup || (p.setup ? 1 : 0);
+62 -22
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js'; import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName } from './config.js'; import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName , STORY, ROMAN, storyFixture, storyBossArt, STORY_HELP } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS, FRAMES } from './crowd.js'; import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
@@ -1070,13 +1070,21 @@ function drawApproachRing() {
// core tennis legible. Reported three times before it was believed. The gore mode is left out on // core tennis legible. Reported three times before it was believed. The gore mode is left out on
// purpose: there is no rally there, the ball comes back off bodies. // purpose: there is no rally there, the ball comes back off bodies.
if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return; if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return;
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric
if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back
const p = proj(ball.x, ball.y, ball.z); const p = proj(ball.x, ball.y, ball.z);
if (p.s <= 0) return; if (p.s <= 0) return;
const inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand;
const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring
const r = rSweet * Math.max(1, d / TUNE.matchRingSweet); // the closing ring — they kiss at sweet let inBand, r;
if (TUNE.ringTime) { // TIMED (gameplay.ringCue): kiss = touch now
const c = G.ring; // gameplay's STEADY copy of ringCue
if (!c || c.eta > TUNE.ringLead || c.eta < c.sweet - c.band) return; // not yet, or the moment has passed
inBand = c.gold;
r = rSweet * (1 + Math.max(0, c.eta - c.sweet) * 4); // closes onto the target as the moment nears
} else { // the old DISTANCE ring, for A/B
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d > TUNE.matchRingFar || ball.vz < 0) return;
inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand;
r = rSweet * Math.max(1, d / TUNE.matchRingSweet);
}
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)'; ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke();
@@ -1117,6 +1125,12 @@ function drawApexBand() {
// walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing, // walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing,
// and input.js hit-tests the same function: one source, so a moved button cannot stop responding // and input.js hit-tests the same function: one source, so a moved button cannot stop responding
// (the rack learned this the hard way — see rackGeom). // (the rack learned this the hard way — see rackGeom).
// THE SWING LOG's panel — drawn here, hit-tested by main.js's copy tap (one source, the rackGeom rule)
export function logGeom() {
const rows = Math.min(9, G.missLog.length);
return { x: 6, y: 40, w: W - 12, h: rows * 10 + 16, rows };
}
export function benchButtons() { export function benchButtons() {
// FOUR across now: FALL plays the real collapse-and-rise beat (beats.benchFall), which is a SEQUENCE // FOUR across now: FALL plays the real collapse-and-rise beat (beats.benchFall), which is a SEQUENCE
// and not a pose, so it could not be one of the stepped stops. // and not a pose, so it could not be one of the stepped stops.
@@ -2369,6 +2383,8 @@ function modalRows(m, inner, skipBody) {
const push = r => { rows.push(r); return r; }; const push = r => { rows.push(r); return r; };
if (m.foe) { if (m.foe) {
push({ kind: 'split', h: m.foe.peer ? 38 : 26 }); push({ kind: 'split', h: m.foe.peer ? 38 : 26 });
// HOW IT PLAYS (the styles, step 2): one bold line, because a plan needs to know what it faces
if (m.foe.style) { const lead = wrapText(m.foe.style, F(8, 700), inner); push({ kind: 'rule', lead, h: lead.length * 12 + 4 }); }
const sub = []; const sub = [];
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 }); if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
@@ -2867,7 +2883,11 @@ export function render() {
ctx.save(); ctx.save();
ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end
ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (1 - f) * 14), p.col || '#ffffff'); // kept on screen: a word under a player out wide (the rally's praise, v371) is centred on his feet,
// and "SHOT OF THE DAY" off a man near the left tramline lost its first letters on the iPhone
const hw = ctx.measureText(p.text).width / 2 + 3;
const x = hw * 2 < W ? Math.min(W - hw, Math.max(hw, pr.x)) : W / 2;
shadowText(p.text, Math.round(x), Math.round(pr.y - (p.still ? 0 : (1 - f) * 14)), p.col || '#ffffff');
ctx.restore(); ctx.restore();
} }
// (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The // (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The
@@ -3006,11 +3026,17 @@ export function render() {
// the arcade names the RUNG and counts d.felled (THE LEAGUES: opp is a band position now) // the arcade names the RUNG and counts d.felled (THE LEAGUES: opp is a band position now)
: G.match ? `${matchFoe(G.match.opp).short} · ${G.match.felled || 0} FELLED` : G.match ? `${matchFoe(G.match.opp).short} · ${G.match.felled || 0} FELLED`
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}` : G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
// THE STORY's chapter counts its own ten (its games run on the arcade's scale underneath)
: G.story && G.story.part === 'morph' ? `ACT ${ROMAN[G.story.act]} · THE CUP IS YOURS`
: G.story && G.story.part === 'pro' ? 'PROLOGUE'
: G.story && G.story.part === 'chap'
? `ACT ${ROMAN[G.story.act]} · GAME ${Math.min(STORY.length, G.runGames - G.story.base + 1)} / ${STORY.length}`
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8); : `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
} }
// ...and NOT during a match: nothing on that court is killed for points, so the pips were both a // ...and NOT during a match: nothing on that court is killed for points, so the pips were both a
// lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel. // lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel.
if (splat && !G.match && !G.classic && !benchOn && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above if (splat && !G.match && !G.classic && !benchOn && TUNE.killPips && TUNE.killsPerPoint > 1
&& !(G.story && G.story.part !== 'chap')) { // the story's prologue and its scene have no kills to count // kills-to-next-point pips, on the panel above
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8); ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
for (let k = 0; k < TUNE.killsPerPoint; k++) { for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7); ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
@@ -3052,12 +3078,21 @@ export function render() {
// "OUT IN SEMI-FINAL" reads like a telegram: the round names carry their own article when // "OUT IN SEMI-FINAL" reads like a telegram: the round names carry their own article when
// they have one (THE FINAL, ROUND ONE) and want one when they do not. // they have one (THE FINAL, ROUND ONE) and want one when they do not.
const theRound = n => /^(THE |ROUND )/.test(n) ? n : 'THE ' + n; const theRound = n => /^(THE |ROUND )/.test(n) ? n : 'THE ' + n;
const title = dead // THE STORY's endings: a death names the checkpoint it goes back to, a cleared act names the
// next one. Nothing here is a run summary — the story keeps its place in the profile.
const st = G.story;
const sf = st ? storyFixture(prof.story) : null;
const title = st ? (dead ? (cup ? 'OUT IN ' + theRound(cup.rounds[Math.min(cup.round, cup.rounds.length - 1)].name) + '.' : 'EATEN.')
: G.done.story >= 5 ? 'THE DEAD CLUB IS QUIET.' : 'ACT ' + ROMAN[G.done.story] + ' CLEARED.')
: dead
? (cup ? 'OUT IN ' + theRound(cupRoundName(G.league, cup.round + 1)) + '.' ? (cup ? 'OUT IN ' + theRound(cupRoundName(G.league, cup.round + 1)) + '.'
: m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') : m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
: G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.') : G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.')
: won ? 'GAME, SET, MATCH.' : 'BEATEN.'; : won ? 'GAME, SET, MATCH.' : 'BEATEN.';
const line = dead const line = st ? (dead ? (cup ? 'ACT ' + ROMAN[st.act] + ' · BACK TO ROUND ONE'
: 'GAME ' + Math.min(STORY.length, G.runGames - st.base + 1) + ' OF ' + STORY.length + ' · ATTEMPT ' + prof.story.tries)
: storyBossArt(G.done.story, 'end').name + ' FELL')
: dead
? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT' ? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT'
: 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league)))) : 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league))))
: m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' : m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
@@ -3068,7 +3103,7 @@ export function render() {
// THE FIXTURE. A won cup parks a ceremony (the promotion) — that IS the handover, so the // THE FIXTURE. A won cup parks a ceremony (the promotion) — that IS the handover, so the
// summary points at it instead of at the next draw. // summary points at it instead of at the next draw.
const promo = hasPromo(); const promo = hasPromo();
const fx = cupFixture(prof.lg, prof.ch); const fx = storyFixture(prof.story); // THE STORY is the main path: every ending points back at it
const xd = runDelta(), pr = progress(); const xd = runDelta(), pr = progress();
const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART; const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART;
@@ -3082,7 +3117,7 @@ export function render() {
const lineLines = wrapText(line, F(9), inner); const lineLines = wrapText(line, F(9), inner);
// the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's // the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's
// felled count beside it was two bests answering different questions in the same box // felled count beside it was two bests answering different questions in the same box
const mark = G.newBest || cup ? null const mark = G.newBest || cup || st ? null
: G.best && G.best.games > 0 : G.best && G.best.games > 0
? (m ? 'BEST ' + G.best.games + ' FELLED' ? (m ? 'BEST ' + G.best.games + ' FELLED'
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES' : G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
@@ -3092,7 +3127,11 @@ export function render() {
// 2026-09-11). Netplay used to end on THE CLUBHOUSE alone, which meant a fresh room code and // 2026-09-11). Netplay used to end on THE CLUBHOUSE alone, which meant a fresh room code and
// a second trip through the lobby to play the same person again. // a second trip through the lobby to play the same person again.
const online = !promo && !!(G.net && G.net.ready && !G.net.dead); const online = !promo && !!(G.net && G.net.ready && !G.net.dead);
const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD' // THE HELP OFFER (config.STORY_HELP): from the third attempt at a checkpoint, said openly
const help = st && dead && prof.story.tries >= STORY_HELP.offerAt && prof.story.help < STORY_HELP.max;
const offer = !help ? '' : cup ? 'THE TRAINER OFFERS FRESHER LEGS AND A LONGER SWING'
: 'THE CLUB DOCTOR OFFERS ' + STORY_HELP.heart + ' MORE HEART AND A SLOWER HORDE';
const fxLines = ready ? wrapText(help ? offer : st ? sf.line : promo ? 'THE CLUB WANTS A WORD'
: online ? 'YOUR FRIEND IS STILL ON THE LINE' : online ? 'YOUR FRIEND IS STILL ON THE LINE'
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : []; : G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
let h = pad + headLines.length * 24 + lineLines.length * 13 + 6; let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
@@ -3157,7 +3196,7 @@ export function render() {
ctx.font = F(7, 0); ctx.font = F(7, 0);
shadowText('NEXT', inX, y + 6, UI.dim); shadowText('NEXT', inX, y + 6, UI.dim);
ctx.font = F(9, 700); ctx.font = F(9, 700);
shadowText(promo ? 'THE PROMOTION' : online ? 'ANOTHER DUEL' shadowText(st ? sf.title : promo ? 'THE PROMOTION' : online ? 'ANOTHER DUEL'
: G.net ? 'BACK TO THE CLUBHOUSE' : fx.title, : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title,
inX + 34, y + 6, UI.warm); inX + 34, y + 6, UI.warm);
y += 16; y += 16;
@@ -3176,16 +3215,17 @@ export function render() {
const againLabel = G.classic ? 'CLASSIC AGAIN' const againLabel = G.classic ? 'CLASSIC AGAIN'
: G.match && G.match.train ? 'TRAIN AGAIN' : G.match && G.match.train ? 'TRAIN AGAIN'
: G.match ? 'CLIMB AGAIN' : 'GORE AGAIN'; : G.match ? 'CLIMB AGAIN' : 'GORE AGAIN';
const second = solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE'] const second = st ? (help ? ['story', 'TRY AGAIN'] : ['club', 'THE CLUBHOUSE'])
: solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE']
: ['again', againLabel]) : ['again', againLabel])
: online ? ['club', 'THE CLUBHOUSE'] : null; : online ? ['club', 'THE CLUBHOUSE'] : null;
const bw = second && !stack ? Math.round((inner - 8) / 2) : inner; const bw = second && !stack ? Math.round((inner - 8) / 2) : inner;
// "THE DRAW" was tennis for the field of entrants — and nothing is drawn any more since the // "THE DRAW" was tennis for the field of entrants — and nothing is drawn any more since the
// cup went deterministic (2026-08-29). The button names what it opens instead. // cup went deterministic (2026-08-29). The button names what it opens instead.
const label = promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE CUP' const label = st ? (help ? 'TRY AGAIN · WITH HELP' : dead ? 'TRY AGAIN' : 'CONTINUE') : promo ? 'TAKE THE FLOOR' : solo ? 'THE STORY'
: online ? 'REMATCH' : 'THE CLUBHOUSE'; : online ? 'REMATCH' : 'THE CLUBHOUSE';
drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true); drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true);
endBtns.push({ id: solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN }); endBtns.push({ id: st ? (help ? 'help' : 'story') : solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN });
if (second) { if (second) {
const sx = stack ? inX : inX + bw + 8, sy = stack ? y + MODAL_BTN + 8 : y; const sx = stack ? inX : inX + bw + 8, sy = stack ? y + MODAL_BTN + 8 : y;
drawButton(sx, sy, bw, MODAL_BTN, second[1], UI.warm, false); drawButton(sx, sy, bw, MODAL_BTN, second[1], UI.warm, false);
@@ -3294,15 +3334,15 @@ export function render() {
ctx.font = F(8); ctx.fillStyle = '#ff9060'; ctx.font = F(8); ctx.fillStyle = '#ff9060';
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
} }
if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly if (G.logShow) { // scoreboard tap: recent swing diagnostics; tap the panel to COPY the whole log
const list = G.missLog.slice(-9), lh = 10, top = 40; const L = logGeom(), list = G.missLog.slice(-L.rows), lh = 10;
ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16); ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(L.x, L.y, L.w, L.h);
ctx.font = F(7, 0); ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.textAlign = 'left';
ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — TAP HERE TO COPY · SCOREBOARD HIDES', 10, L.y + 5);
const maxCh = Math.floor((W - 20) / 4.3); const maxCh = Math.floor((W - 20) / 4.3);
list.forEach((e, i) => { list.forEach((e, i) => {
ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060'; ctx.fillStyle = e.result === 'HIT' || e.result === 'WON' ? '#7fd07f' : e.result === 'THEM' ? UI.dim : '#ff9060';
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh); ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, L.y + 16 + i * lh);
}); });
} }
if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it
+8 -2
View File
@@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false, active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, retreat: null, smash: false, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false,
// NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the // NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the
// striker's phone that is mid-roll, a whole court away from the call, and it read as the game // striker's phone that is mid-roll, a whole court away from the call, and it read as the game
// loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring // loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring
@@ -184,6 +184,12 @@ export const G = {
rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) rallyNear: null, // closest the incoming ball got this rally (for never-swung passes)
swung: false, // player swung at least once this rally — whiff log supersedes PASSED swung: false, // player swung at least once this rally — whiff log supersedes PASSED
lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly
moveTapT: 0, // the double tap's open window (gameplay.keyPressed, moveCall)
// THE STORY's run state (beats.startStory). Wiped by restartRun like everything on G — the place in
// the story lives in the profile (prof.story); this is only what THIS run is doing with it.
story: null, // {act, part: 'cup'|'chap', base, midDone, fight: 'mid'|'end'|null, winT}
storyBoss: null, // 'mid' | 'end' — a story boss is due at the next clear (splatPoint arms it)
ring: null, // the drawn gold ring's steady cue (gameplay update smooths ringCue)
logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump
}; };
@@ -205,7 +211,7 @@ export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() { export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; P.chase = P.goal = P.goalKey = P.post = null; P.running = false; P.rushing = false; ball.approach = null; ball.retreat = null; ball.smash = false;
G.swingSnaps = null; G.rallyNear = null; G.swung = false; G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
// (the MATCH falls through to the tennis branch below: // (the MATCH falls through to the tennis branch below:
+1
View File
@@ -75,6 +75,7 @@ export function tally(variant, weapon) {
// A shot that took two or more at once — counted ONCE per shot, on the transition to 2, so a // A shot that took two or more at once — counted ONCE per shot, on the transition to 2, so a
// massacre of six is one multi-kill and not five. // massacre of six is one multi-kill and not five.
export function multiKill() { run.mk++; } export function multiKill() { run.mk++; }
export const runKills = () => run.k; // THE STORY's act card counts the bodies of the run in hand
// The run is over: fold it into the record and hand back the best run so far. `dead` separates a // The run is over: fold it into the record and hand back the best run so far. `dead` separates a
// death from a tab closed mid-match — both are worth recording, only one is a death. // death from a tab closed mid-match — both are worth recording, only one is a death.
+27 -7
View File
@@ -3,13 +3,13 @@
// your baseline — one crossing it is a point for THEM. // your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R, storyPool, STORY_HELP } from './config.js';
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
import { drillKill, drillCaught } from './drill.js'; import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one import { resolveInterlude, endBossFight, storyDeath } from './beats.js'; // runtime-only cycle, like the gameplay one
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
import { award, bonus, prof, recordCup } from './profile.js'; import { award, bonus, prof, recordCup } from './profile.js';
@@ -29,8 +29,8 @@ export const shots = []; // {x, y, z, vx, vy, vz, g, t}
// the impact — the answer to "what did I just do" appears where you are already looking, not in // the impact — the answer to "what did I just do" appears where you are already looking, not in
// the announcement band. Render draws them, gameplay's update ticks them, killed in resetZombies. // the announcement band. Render draws them, gameplay's update ticks them, killed in resetZombies.
export const pops = []; // {x, y, z, text, col, t, T} export const pops = []; // {x, y, z, text, col, t, T}
export function popText(x, y, z, text, col) { export function popText(x, y, z, text, col, still = false) {
pops.push({ x, y, z, text, col, t: 0.85, T: 0.85 }); pops.push({ x, y, z, text, col, still, t: 0.85, T: 0.85 }); // still: does not rise (the rally's praise, under the player)
if (pops.length > 10) pops.shift(); if (pops.length > 10) pops.shift();
} }
@@ -83,6 +83,14 @@ let lastBarrel = null;
// run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in // run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in
// a row, which is the part a player actually notices. // a row, which is the part a player actually notices.
function pickBarrelWeapon() { function pickBarrelWeapon() {
// THE STORY: a barrel holds what the act has earned (config.storyPool) — the act decides the rack,
// so a barrel in act I is a bomb, a triple or fire, never the laser the story hands out in act III
if (G.story) {
const own = storyPool(G.story.act).filter(id => id !== lastBarrel);
const id = own.length ? own[Math.floor(Math.random() * own.length)] : lastBarrel || 'bomb';
lastBarrel = id;
return id;
}
const pool = []; const pool = [];
for (const [id, b] of Object.entries(BARREL_WEAPONS)) { for (const [id, b] of Object.entries(BARREL_WEAPONS)) {
if (G.runGames < b.from) continue; if (G.runGames < b.from) continue;
@@ -132,8 +140,10 @@ export function spawnMachine() {
// slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it // slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it
// reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a // reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a
// body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing. // body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing.
export function spawnChapterBoss(chapter) { export function spawnChapterBoss(chapter, design = null) {
const art = bossArt(chapter); // which of the boss designs this chapter is (every tenth = THE CHAMPION) // which of the boss designs this chapter is (every tenth = THE CHAMPION) — or, in THE STORY, the
// act's own design at the act's strength (config.storyBossArt / storyBossChapter)
const art = design || bossArt(chapter);
// Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a // Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a
// design row that carries nothing but art behaves exactly the way a boss did before there were any. // design row that carries nothing but art behaves exactly the way a boss did before there were any.
const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1)); const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1));
@@ -192,6 +202,10 @@ function bossAI(zb, dt) {
// turns and withdraws the instant it arrives. A rush that misses is over. // turns and withdraws the instant it arrives. A rush that misses is over.
zb.x += Math.sign(zb.lane - zb.x) * Math.min(Math.abs(zb.lane - zb.x), TUNE.bossStrafe * 2 * dt); zb.x += Math.sign(zb.lane - zb.x) * Math.min(Math.abs(zb.lane - zb.x), TUNE.bossStrafe * 2 * dt);
zb.z += zb.chargeV * dt; zb.z += zb.chargeV * dt;
// THE STORY's bosses keep their own half (Oliver, 2026-10-15: the strawberry seller as act I's first
// boss was "way too hard — or it should not cross the net"): the rush runs at you and stops short
// of the net, a threat you read and punish rather than an instant death. (beats.startStoryBoss)
if (zb.holdNet) zb.z = Math.min(zb.z, COURT.netZ - TUNE.bossNetStop);
if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // spent: it turns and walks back if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // spent: it turns and walks back
} else { } else {
// AND IT FALLS BACK. A rush that ended where it landed left the boss parked on top of you for // AND IT FALLS BACK. A rush that ended where it landed left the boss parked on top of you for
@@ -317,6 +331,11 @@ export function killPlayer() {
// It goes through the SAME endRun with the mode tagged and its own two headline numbers in the // It goes through the SAME endRun with the mode tagged and its own two headline numbers in the
// record's two headline slots (see the shape note there), so the best is derived from the record // record's two headline slots (see the shape note there), so the best is derived from the record
// rather than stored a second time in its own key and left to drift. // rather than stored a second time in its own key and left to drift.
// THE STORY HAS NO PERMADEATH: the death costs the attempt, not the story (prof.story keeps the
// checkpoint), and it banks nothing on the boards — the story is not a high-score run, and its
// chapter games are numbered on the arcade's scale (STORY.base), which would post act V's game
// 85 as a gore best. The summary reads G.story and offers the retry.
if (G.story) { storyDeath(); return; }
const m = G.match; const m = G.match;
// the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The // the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The
// arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and // arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and
@@ -1055,7 +1074,8 @@ export function updateZombies(dt) {
if (spawnT <= 0 && zombies.length < cap) { if (spawnT <= 0 && zombies.length < cap) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant()); spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
spawnT = G.testMode ? TUNE.testSpawnEvery spawnT = G.testMode ? TUNE.testSpawnEvery
: Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel)); : Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel))
* (G.story && G.story.help ? 1 + STORY_HELP.slow * G.story.help : 1); // THE STORY's help offer
} }
} }
} }
+987 -14
View File
File diff suppressed because it is too large. Load diff
+271
View File
@@ -0,0 +1,271 @@
// Rally geometry probe: `node tools/rally-sim.mjs` (RUNGS=1,6,12,18 SECS=240, CLASSIC=1 RUNGS=0,1,2 for
// the three classic difficulties). Plays the real modules headless with test.js's autopilot (auto-run,
// a fixed tap window, a random lane/deep flick) and prints WHERE each side meets the ball, how often
// it volleys, how long each shot gives the other side, and where rally balls land. It measures the
// shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT".
// Since step 4: BOT=human (default; JIT, BIAS ms), CLIMB=1, XP, WHY=1, PROBE=1, LOG=1|all, SUMMARY=1,
// JSON=1 — see PROJECT.md "Step 4, measured first". Pool 8 seeds before reading a win rate.
// ATTACK=0.3 (the long up-flick share), NETX=1 (net/smash tally), KILL=1 (put-aways), T_<knob>=<v> sets TUNE.
// JITTER=1 (auto-run shake: reversals, target jumps), =2 prints big jumps, =3 prints fast reversals.
// ponytail: the platform stubs are copied from test.js; fold this into a test once the rework lands.
let _seed = (Number(process.env.SEED) || 20260811) >>> 0;
Math.random = () => { // xorshift32 — small, fast, good enough for jitter
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 4294967296;
};
// ---- platform stubs, installed before the game modules load ----------------
const absorb = new Proxy(function () {}, {
get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb),
set: () => true,
apply: () => absorb,
construct: () => absorb,
});
const fakeCtx = () => new Proxy({}, {
get: (t, p) => {
if (p === 'canvas') return fakeCanvas();
if (p === 'measureText') return () => ({ width: 0 });
if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h });
if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} });
return () => undefined;
},
set: () => true,
});
function fakeCanvas() {
return {
width: 270, height: 480, style: {},
getContext: () => fakeCtx(),
addEventListener() {}, removeEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }),
toDataURL: () => '',
};
}
const store = new Map();
globalThis.localStorage = {
getItem: k => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
};
globalThis.document = {
getElementById: () => fakeCanvas(),
createElement: () => fakeCanvas(),
addEventListener() {}, removeEventListener() {},
body: { style: {}, appendChild() {} },
};
globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional)
globalThis.AudioContext = function () { return absorb; };
globalThis.webkitAudioContext = globalThis.AudioContext;
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
// haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL
// navigator and it is getter-only, so the stub has to be defined over it rather than assigned.
globalThis.hapticLog = [];
Object.defineProperty(globalThis, 'navigator', {
configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } });
globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
globalThis.requestAnimationFrame = () => 0;
globalThis.devicePixelRatio = 2;
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
const R = new URL('../src/', import.meta.url).href;
const state = await import(R + 'state.js');
const { G, P, ball, score } = state;
const gp = await import(R + 'gameplay.js');
const { update, keyPressed, restartRun } = gp;
const beats = await import(R + 'beats.js');
const { zombies } = await import(R + 'zombies.js');
const { TUNE, COURT } = await import(R + 'config.js');
const prof = await import(R + 'profile.js');
const DT = 1 / 60;
const RUNGS = (process.env.RUNGS || '1,6,12,18').split(',').map(Number);
const SECS = Number(process.env.SECS || 240);
TUNE.autoAssist = 1.15;
for (const [k, v] of Object.entries(process.env)) if (k.startsWith('T_')) TUNE[k.slice(2)] = Number(v); // T_punishRead=0.4 etc.
const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0;
const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
// BOT=human (the default): taps on the timed gold ring like a player (JIT ms of timing spread, a
// 120 ms tap), and plays the game a little — a lob or a pass against a net man, the odd drop when
// the opponent hangs back. BOT=dist is the original fixed-distance tapper with random flicks.
const HUMAN = (process.env.BOT || 'human') === 'human';
const ATTACK = Number(process.env.ATTACK || 0); // share of baseline shots followed in: a deep flick, then a DOUBLE TAP
const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7;
const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
const netx = { attacks: 0, smash: 0, smashWon: 0, lobbed: 0, open: null, p0: 0 };
const jit = { shown: 0, rev: 0, fastRev: 0, lastRev: -99, last: 0, jumps: [], frames: 0 };
const probe = [], kills = []; let kOpen = null, kpow = 1;
const bot = { armed: false, fire: -1, jit: null, swipe: null };
const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n;
return { ax: Math.abs(fx) > 0.38 ? Math.sign(fx) : 0, az: Math.abs(fy) > 0.38 ? Math.sign(fy) : 0, fx, fy, mag }; };
function humanAim() {
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
const oz = rz ? Math.abs(rz.z - COURT.netZ) : 12, r = Math.random();
const away = rz && rz.x > 0 ? -1 : 1; // the side he is not on
if (oz < TUNE.netFrom) return r < 0.4 ? sw(0, 1, 150) : sw(away, -0.3, 120); // net man: lob him or pass him
if (oz > COURT.netZ + 0.5 && Math.abs(P.z - COURT.netZ) < COURT.netZ - 1.5 && r < 0.15) return sw(0, 1, 50); // drop
if (ATTACK && P.post !== 'net' && Math.abs(P.z - COURT.netZ) >= TUNE.netFrom && Math.random() < ATTACK) { bot.dbl = true; return sw(away * 0.3, -1, 130); } // the attack
if (r < 0.45) return sw(away, -0.2, 110); // to the open side
if (r < 0.65) return sw(0, -1, 80); // deep through the middle
return null; // a plain tap
}
const rows = [], land = [], pts = [], whys = [];
for (const rung of RUNGS) {
restartRun(); Object.assign(bot, { armed: false, fire: -1, jit: null, swipe: null });
prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo();
G.mode = 'splatter'; G.everTransitioned = true; state.setupPoint();
const cfgm = await import(R + 'config.js');
if (process.env.CLASSIC) { G.mode = 'tennis'; G.everTransitioned = false; G.classic = true; G.diff = cfgm.DIFFS[rung]; state.setupPoint(); }
// CLIMB=1: the arcade run (its own step boost at the bottom), pinned to this rung like the suite does
else if (process.env.CLIMB) { beats.startMatch(false, null, rung); G.match.train = false; }
else beats.startMatch(false, null, rung);
const why = {};
let prevE = null, lastHitE = null; // LOST is filed under how it came about: the player's last act before it
G.missLog.push = function (e) {
if (e.result === 'LOST') {
const k = /never swung/.test(e.reason) ? 'never swung' : prevE && prevE.result === 'MISS' ? 'MISS ' + prevE.reason.split(' — ')[0].replace(/[\d.]+m?s?/g, '#').slice(0, 30)
: lastHitE && /q=0\.[0-4]/.test(lastHitE.reason) ? 'after weak hit q<.5' : 'after good hit';
why['L: ' + k] = (why['L: ' + k] || 0) + 1;
} else if (e.result === 'WON') why.WON = (why.WON || 0) + 1;
if (e.result === 'HIT') lastHitE = e;
if (e.result === 'WON' || e.result === 'LOST') lastHitE = null;
if (e.result !== 'THEM') prevE = e;
return Array.prototype.push.call(this, e);
};
G.modal = null; G.modalQ.length = 0;
for (let i = 0; i < 2.2 * 60; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
const lands = [];
const speeds = [];
const hits = []; // {side, z(dist from own baseline), bounced, t}
let prevLast = ball.lastHit, prevB = ball.bounces, rally = 0, rallies = [], lastHitT = null, t = 0;
let swipe = null, pts0 = [...score.pts], net = { P: false, O: false };
const won = { P: 0, O: 0, Pnet: 0, PnetWon: 0, Onet: 0, OnetWon: 0 };
for (let i = 0; i < SECS * 60; i++) {
G.modal = null; G.modalQ.length = 0; if (process.env.CLIMB && G.match) { G.match.opp = rung; G.match.train = false; G.hp = G.hpMax; }
if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; }
t += DT;
if (HUMAN && (ball.lastHit !== 'O' || !ball.active || G.phase !== 'rally')) { bot.jit = null; if (i !== bot.fire) { bot.armed = false; bot.fire = -1; } }
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (HUMAN) {
// the finger lands when the ring says "now" give or take a human's timing (JIT ms, gaussian),
// and a tap lifts LAG frames later carrying its age; a flick fires on the land (its crossing)
if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !bot.armed) {
const c = gp.ringCue();
if (c) {
if (bot.jit === null) bot.jit = Math.max(-2, Math.min(2, gauss())) * JIT + Number(process.env.BIAS || 0) / 1000;
if (c.eta - c.sweet + bot.jit <= 0 || c.eta <= 0) { bot.land = { i, eta: c.eta, sweet: c.sweet, b: ball.bounces }; bot.armed = true; bot.jit = null; bot.swipe = humanAim(); bot.fire = i + (bot.swipe ? 0 : LAG); }
}
}
if (i === bot.fire) { G.swipe = bot.swipe; keyPressed('Space', bot.swipe ? 0 : LAG / 60, !!bot.swipe); }
if (bot.dbl && ball.lastHit === 'P' && ball.active) { bot.dbl = false; bot.taps = [i + 4, i + 12]; } // follow it in: the double tap
if (bot.taps && bot.taps.includes(i)) keyPressed('Space', 0.1);
}
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d < 2.9 && d > 2.2 && P.swingT < 0) {
const lane = [-1, 0, 1][Math.floor(Math.random() * 3)], mag = 40 + Math.random() * 80;
const fy = Math.random() < 0.4 ? -0.6 : 0, fx = lane * 0.8;
const n = Math.hypot(fx, fy) || 1;
swipe = lane || fy ? { ax: lane, az: fy < 0 ? -1 : 0, fx: fx / n, fy: fy / n, mag } : null;
G.swipe = swipe;
keyPressed('Space');
}
}
const lh = ball.lastHit;
const px0 = P.x, pz0 = P.z, tg0 = P.tgt;
if (process.env.PROBE && lh === 'O' && bot.land && P.swingT >= 0) bot.land.sw = P.swingT, bot.land.d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), bot.land.y = ball.y, bot.land.bb = ball.bounces;
update(DT);
if (lh !== 'P' && ball.lastHit === 'P' && ball.active && ball.smash) { netx.smash++; netx.open = 'smash'; }
if (P.post === 'net' && !netx.wasIn) netx.attacks++; // a run in (the double tap, or a short ball)
netx.wasIn = P.post === 'net';
// every contact of his made while committed to the net: how far from the net (the "minimal vor" check)
if (lh === 'O' && ball.lastHit === 'P' && (P.post === 'net' || netx.postWas === 'net')) (netx.after ||= []).push(+Math.abs(P.z - COURT.netZ).toFixed(1));
if (netx.postWas === 'net' && P.post !== 'net') netx.dropped = (netx.dropped || 0) + 1, netx.why = [...(netx.why || []), (G.phase !== 'rally' ? 'pointEnd' : ball.rx && ball.rx.lob ? 'lob' : 'other')];
netx.postWas = P.post;
if (lh === 'P' && ball.lastHit === 'O' && ball.active && ball.soft && Math.abs(P.z - COURT.netZ) < TUNE.netFrom) netx.lobbed++;
if (netx.open && (G.phase !== 'rally' || !ball.active)) { const d0 = score.pts[0] - netx.p0; if (d0 > 0) netx.smashWon++; netx.open = null; }
if (netx.open === null) netx.p0 = score.pts[0];
if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; }
if (ball.lastHit !== 'O') G.swipe = null;
// KILL=1: every put-away (a q >= .95 CPU ball straight after a player ball under .5) — where it went,
// how far the player had to go, how long he had, and whether he got it back
// JITTER=1: the auto-run's shake — a direction reversal of the x step inside a chase, and how far the
// target jumps between frames (the two things a "zittert hin und her" can be made of)
if (process.env.JITTER && G.phase === 'rally' && ball.active && ball.lastHit === 'O' && lh === 'O') {
const dx = P.x - px0;
if (Math.abs(dx) > 1e-4) { if (jit.last && Math.sign(dx) !== Math.sign(jit.last)) { jit.rev++; if (i - jit.lastRev < 12) { jit.fastRev++; if (process.env.JITTER === '3' && jit.shown++ < 12) console.log('rev', i, 'dx', dx.toFixed(3), 'prev', jit.last.toFixed(3), 'P', P.x.toFixed(2), P.z.toFixed(2), 'tgt', P.tgt && P.tgt.x.toFixed(2), P.tgt && P.tgt.z.toFixed(2), 'swingT', P.swingT.toFixed(2), 'heels', ball.heels.toFixed(2)); } jit.lastRev = i; } jit.last = dx; }
if (tg0 && P.tgt) { const j = Math.hypot(P.tgt.x - tg0.x, P.tgt.z - tg0.z); jit.jumps.push(j); }
if (process.env.JITTER === '2' && tg0 && P.tgt && Math.hypot(P.tgt.x - tg0.x, P.tgt.z - tg0.z) > 0.5 && jit.shown++ < 25) console.log('jump', i, 'from', tg0.x.toFixed(2), tg0.z.toFixed(2), 'to', P.tgt.x.toFixed(2), P.tgt.z.toFixed(2), 'P', P.x.toFixed(2), P.z.toFixed(2), 'ball', ball.y.toFixed(2), ball.z.toFixed(2), 'b', ball.bounces, JSON.stringify(ball.rx));
jit.frames++;
} else jit.last = 0;
if (process.env.KILL) {
if (lh === 'P' && ball.lastHit === 'O' && kpow < 0.5 && ball.pow >= 0.95) {
const L = gp.predictLanding(); kills.push({ px: +P.x.toFixed(1), pz: +P.z.toFixed(1), lx: L && +L.x.toFixed(1), lz: L && +L.z.toFixed(1), tl: L && +L.t.toFixed(2), v: +Math.hypot(ball.vx, ball.vz).toFixed(1), got: null }); kOpen = kills[kills.length - 1];
}
if (kOpen && ball.lastHit === 'P' && lh === 'O') { kOpen.got = +ball.pow.toFixed(2); kOpen = null; }
if (kOpen && (!ball.active || G.phase !== 'rally')) { kOpen.got = 'LOST'; kOpen.gap = +(G.rallyNear ? G.rallyNear.d : -1).toFixed(2); kOpen = null; }
if (ball.lastHit === 'P') kpow = ball.pow;
}
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) {
const ch = ball.lastHit === 'P' ? P : rz;
if (ch && !G.wasServe) {
const fromNet = Math.abs(ch.z - COURT.netZ);
speeds.push({ side: ball.lastHit, v: Math.hypot(ball.vx, ball.vz) });
if (fromNet < 6.4) net[ball.lastHit] = true;
hits.push({ side: ball.lastHit, fromNet, bounced: prevB > 0, dt: lastHitT === null ? null : t - lastHitT, y: ball.y });
}
lastHitT = t; rally++;
}
const d0 = score.pts[0] - pts0[0], d1 = score.pts[1] - pts0[1];
if (d0 > 0 || d1 > 0) {
const w = d0 > 0 ? 'P' : 'O'; won[w]++;
for (const sd of ['P', 'O']) if (net[sd]) { won[sd + 'net']++; if (w === sd) won[sd + 'netWon']++; }
}
if (d0 || d1) net = { P: false, O: false };
pts0 = [...score.pts];
if (G.phase !== 'rally' && rally) { rallies.push(rally); rally = 0; lastHitT = null; }
if (ball.active && prevB === 0 && ball.bounces === 1 && !G.wasServe) {
const fromBase = ball.lastHit === 'P' ? ball.z : COURT.L - ball.z;
lands.push({ side: ball.lastHit, fromBase });
}
prevLast = ball.active ? ball.lastHit : null; prevB = ball.bounces;
}
for (const side of ['P', 'O']) {
const h = hits.filter(x => x.side === side);
rows.push({ rung, side, shots: h.length,
'medDistNet': +med(h.map(x => x.fromNet)).toFixed(1),
'<6.4m(svcline)%': pct(h, x => x.fromNet < 6.4),
'<3.5m(net)%': pct(h, x => x.fromNet < 3.5),
'volley%': pct(h, x => !x.bounced),
'medTimeToHit_s': +med(h.filter(x => x.dt).map(x => x.dt)).toFixed(2),
'medContactY': +med(h.map(x => x.y)).toFixed(2),
});
}
for (const side of ['P', 'O']) {
const l = lands.filter(x => x.side === side).map(x => x.fromBase);
const v = speeds.filter(x => x.side === side).map(x => x.v);
land.push({ rung, hitter: side, bounces: l.length, medLandFromBaseline: +med(l).toFixed(1),
'deep<3m%': pct(l, x => x < 3), 'svcbox>5.5m%': pct(l, x => x > 5.5), medPace: +med(v).toFixed(1) });
}
const oh = hits.filter(x => x.side === 'O');
pts.push({ rung, foe: (zombies.find(z => z.variant === 'r')?.foe?.short) || '', 'P win%': Math.round(100 * won.P / Math.max(1, won.P + won.O)),
'shots/pt': +(rallies.reduce((a, b) => a + b, 0) / Math.max(1, rallies.length)).toFixed(1), 'O at net%': pct(oh, x => x.fromNet < 6.4), Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' });
whys.push({ rung, ...why });
rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) });
}
if (!process.env.SUMMARY) { console.table(rows); console.table(land); }
if (process.env.NETX) console.log(JSON.stringify(netx));
if (process.env.JITTER) { const j = jit.jumps.sort((a, b) => a - b), q = f => +(j[Math.floor(j.length * f)] || 0).toFixed(3);
console.log(JSON.stringify({ chaseFrames: jit.frames, reversals: jit.rev, fastReversals_lt200ms: jit.fastRev, tgtJump_p50: q(0.5), p90: q(0.9), p99: q(0.99), max: q(0.9999), over20cm: j.filter(x => x > 0.2).length })); }
if (process.env.KILL) { console.table(kills.slice(0, 30)); const l = kills.filter(k => k.got === 'LOST').length; console.log('put-aways', kills.length, 'won by CPU', l); }
if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); }
if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); }
console.table(pts);
if (process.env.WHY) console.table(whys);
if (process.env.LOG) console.log(G.missLog.slice(process.env.LOG === "all" ? 0 : -25).map(e => `${e.at} ${e.result} ${e.reason}`).join("\n"));