Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0576f24614 | ||
|
|
c8d43fbe15 | ||
|
|
2c9c7b84fd | ||
|
|
16d0532f97 | ||
|
|
9e6d566c08 | ||
|
|
789bf41a9a | ||
|
|
ce27bbc48b | ||
|
|
ff63c832cc | ||
|
|
75b450f105 | ||
|
|
5e1c6abba4 | ||
|
|
f00b9d9fb2 | ||
|
|
ba8d13a867 | ||
|
|
48d1069e40 | ||
|
|
bb72a01a3e | ||
|
|
599d5b0506 | ||
|
|
4406aeafdb | ||
|
|
5dd5d71c33 | ||
|
|
d561050af8 | ||
|
|
8e4b298d92 | ||
|
|
e25047ed0c | ||
|
|
fe090f2fba | ||
|
|
dfdb699f9d | ||
|
|
5bbde720ad | ||
|
|
d110e12321 | ||
|
|
180288ef8f | ||
|
|
72760a9d1e | ||
|
|
f9b0dd2efb | ||
|
|
4e7c816a25 | ||
|
|
f33d46366a |
No files matched your search
+949
@@ -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
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
Reference in new issue
Block a user