Story phase 4: help when stuck, the arcade opens with the story

- From the third attempt at a checkpoint, the death screen offers TRY
  AGAIN · WITH HELP, at most two tiers, said openly. In the cup the help
  is fresher legs and a longer swing; in gore, one more heart and a
  slower director and horde. Every checkpoint passed resets the offer.
- The arcade shows only what the story has opened. Training and Classic
  open after act I, the Climb after II, Splatter Gore after III, and
  hosting a friend after IV (joining stays free). Locked doors are shown
  greyed with the act that opens them.
- The non-story endings point at the story instead of the cup.
- Store switches for the free tier (act I's card points at the full
  game) and a premium arcade, both off.
- The prologue's HUD no longer overlaps the kill pips.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-15 14:22:25 +02:00
1 parent ce27bbc48b
commit 789bf41a9a
10 files changed
+191 -34

No files matched your search

+83 -1
View File
@@ -636,6 +636,87 @@ Each phase ships to staging and gets a phone verdict, and tests ride along with
5. **LATER.** The metal bed (when Oliver has a track: start at the transformation, gore only) and Members' 5. **LATER.** The metal bed (when Oliver has a track: start at the transformation, gore only) and Members'
Night (option C). Night (option C).
## 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')`).
**Not done / open:**
- Phase 5 (the metal bed when Oliver has a track; Members' Night).
- 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
@@ -5907,7 +5988,8 @@ stance the old recovery still takes him back.
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 agreed and planned (five phases), NOT built.** See "THE STORY" above. 0. **THE STORY (2026-10-15): phases 1-4 BUILT ON STAGING (v380-v382), awaiting Oliver's phone verdict; prod still
v379.** See "Session log: THE STORY, PHASES 1-4" and "THE STORY".
00. **THE RALLY GOES FLAT (2026-10-13).** Every match converged on serve, both at the net, 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 reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
+8 -1
View File
@@ -601,6 +601,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; }
@@ -1094,6 +1100,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>
@@ -1188,6 +1195,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=381"></script> <script type="module" src="src/main.js?v=382"></script>
</body> </body>
</html> </html>
+9 -8
View File
@@ -7,7 +7,7 @@
// (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, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel, WEAPONS, STORY, ROMAN, storyAct, storyRounds, storyStock, storyBossArt, storyBossChapter } 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';
@@ -1114,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)
@@ -1151,7 +1152,7 @@ export function startStory() {
return true; return true;
} }
if (s.cp === 'cup') { if (s.cp === 'cup') {
G.story = { act: s.act, part: 'cup', base: STORY.base(s.act), fight: null, winT: 0 }; 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 startMatch(true, storyRounds(s.act));
} }
return startStoryChapter(s.cp === 'mid'); return startStoryChapter(s.cp === 'mid');
@@ -1163,12 +1164,12 @@ export function startStory() {
export function startStoryChapter(fromMid = false, keepCrowd = false) { export function startStoryChapter(fromMid = false, keepCrowd = false) {
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;
const s = prof.story, act = s.act; const s = prof.story, act = s.act;
G.story = { act, part: 'chap', base: STORY.base(act), midDone: fromMid, fight: null, winT: 0 }; G.story = { act, part: 'chap', base: STORY.base(act), midDone: fromMid, fight: null, winT: 0, help: s.help };
G.league = storyAct(act).lg; G.league = storyAct(act).lg;
G.runGames = G.story.base + (fromMid ? STORY.midAt : 0); 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.games = [fromMid ? STORY.midAt : 0, 0]; score.pts = [0, 0]; G.sets = [0, 0]; G.killCount = 0;
score.server = 'P'; score.server = 'P';
G.hp = G.hpMax = TUNE.playerHp; 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); const stock = fromMid && s.rack.length ? s.rack : storyStock(act);
G.rack = []; G.temp = null; G.rack = []; G.temp = null;
for (const e of stock) if (WEAPONS[e.id]) grantWeapon(e.id, e.n); for (const e of stock) if (WEAPONS[e.id]) grantWeapon(e.id, e.n);
@@ -1215,7 +1216,7 @@ function storyMorph(zb) {
ball.active = false; G.phase = 'over'; G.overT = 99; ball.active = false; G.phase = 'over'; G.overT = 99;
G.story.part = 'morph'; G.story.part = 'morph';
G.story.morph = { t: 0, x: zb ? zb.x : 0, z: zb ? zb.z : -1, body: zb, boss: null, stock }; 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.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 // 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 }); if (storyUI.show) storyUI.show('loot', { act, stock, rounds: d.cup.rounds.length });
else showModal({ title: 'THE CUP IS YOURS', color: UI.pick, else showModal({ title: 'THE CUP IS YOURS', color: UI.pick,
@@ -1282,7 +1283,7 @@ function storyCheckpoint() {
} }
G.weapon = 0; G.weapon = 0;
G.hp = G.hpMax; G.hp = G.hpMax;
s.cp = 'mid'; s.tries = 0; s.cp = 'mid'; s.tries = 0; s.help = 0;
s.rack = G.rack.map(e => ({ id: e.id, n: e.n })); s.rack = G.rack.map(e => ({ id: e.id, n: e.n }));
profSave(); profPush(); profSave(); profPush();
G.card = { title: 'CHECKPOINT', sub1: 'RESTOCKED · FULL HEALTH', color: UI.pick, t: TUNE.bossWinT, G.card = { title: 'CHECKPOINT', sub1: 'RESTOCKED · FULL HEALTH', color: UI.pick, t: TUNE.bossWinT,
@@ -1297,7 +1298,7 @@ function storyActCleared() {
if (act < 5) { s.act = act + 1; s.cp = 'cup'; prof.lg = Math.max(prof.lg, storyAct(act + 1).lg); } 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; } else { s.cp = 'done'; prof.ch = 1; }
const deaths = s.deaths; const deaths = s.deaths;
s.rack = []; s.tries = 0; s.deaths = 0; s.rack = []; s.tries = 0; s.deaths = 0; s.help = 0;
profSave(); profPush(); profSave(); profPush();
G.story.cleared = act; G.story.cleared = act;
ball.active = false; G.flash = null; G.modal = null; G.card = null; ball.active = false; G.flash = null; G.modal = null; G.card = null;
+7
View File
@@ -1043,6 +1043,13 @@ export const STORY = {
base: act => (act - 1) * 20, base: act => (act - 1) * 20,
}; };
export const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V']; 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 };
export const storyAct = act => STORY.acts[Math.max(1, Math.min(5, act)) - 1]; 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 // the story cup: the league's own round names and needs, on the act's rung order
export const storyRounds = act => { export const storyRounds = act => {
+5 -3
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, STORY, 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 { 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';
@@ -1456,8 +1456,10 @@ 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)
+40 -8
View File
@@ -3,7 +3,7 @@
// 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 , storyFixture, storyAct , STORY, ROMAN, storyBossArt } 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 , startStory , storyUI } from './beats.js'; import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs , startStory , storyUI } from './beats.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;
@@ -637,6 +654,11 @@ function refreshSplash() {
// THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands // THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands
const fx = storyFixture(profile.prof.story); 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;
@@ -773,6 +795,9 @@ function storyScreen(kind, d) {
<button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button> <button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button>
</div>`; </div>`;
} else if (kind === 'clear') { } 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 done = act >= 5, next = done ? null : courtOf(act + 1);
const opens = storyAct(act).arcade.map(id => ARCADE_NAMES[id]).filter(Boolean); const opens = storyAct(act).arcade.map(id => ARCADE_NAMES[id]).filter(Boolean);
glow = '#3b3216'; glow = '#3b3216';
@@ -789,8 +814,9 @@ function storyScreen(kind, d) {
${next ? `<div class="next r" ${delay(7, `background-image:linear-gradient(#0006,#000e),url(${art(next.court.tile)})`)}> ${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><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>`} : `<div class="mid gold r" ${delay(7)}>THE STORY IS TOLD</div>`}
<button class="btn big r" ${delay(9)} data-door="next">${done ? 'THE CLUBHOUSE' : 'CONTINUE'}</button> ${wall ? `<div class="lab gold r" ${delay(8)}>THE STORY CONTINUES IN THE FULL GAME</div>` : ''}
${done ? '' : `<button class="btn r" ${delay(10)} data-door="club">THE CLUBHOUSE</button>`} <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>`; </div>`;
} }
sty.style.setProperty('--sty-glow', glow); sty.style.setProperty('--sty-glow', glow);
@@ -826,9 +852,11 @@ document.getElementById('mStart').addEventListener('click', () => {
document.getElementById('mOther').addEventListener('click', goMode); document.getElementById('mOther').addEventListener('click', goMode);
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);
@@ -1132,7 +1160,11 @@ const maybeRestart = e => {
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 // THE STORY: TRY AGAIN / CONTINUE re-enter at the checkpoint; anywhere else is the club
if (story) { if (door === 'story') start('story'); else go('splash'); return; } 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
@@ -1140,7 +1172,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');
}; };
+2 -2
View File
@@ -40,13 +40,13 @@ const cleanCups = (c, cap = 9) => {
// hands back, `tries` failed attempts since the checkpoint (the help offer reads it). An ACT is the // 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. // unit that is saved; the checkpoints live inside it.
const CPS = ['pro', 'cup', 'chap', 'mid', 'done']; const CPS = ['pro', 'cup', 'chap', 'mid', 'done'];
export const freshStory = () => ({ act: 1, cp: 'pro', rack: [], tries: 0, deaths: 0 }); export const freshStory = () => ({ act: 1, cp: 'pro', rack: [], tries: 0, deaths: 0, help: 0 });
export const cleanStory = s => { export const cleanStory = s => {
if (!s || typeof s !== 'object') return freshStory(); 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) 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) })) : []; .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', 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)) }; 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() {
+13 -8
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js'; import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName , STORY, ROMAN, storyFixture, storyBossArt } 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';
@@ -3028,14 +3028,15 @@ export function render() {
: 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) // 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 === 'morph' ? `ACT ${ROMAN[G.story.act]} · THE CUP IS YOURS`
: G.story && G.story.part === 'pro' ? 'PROLOGUE · WIN A GAME' : G.story && G.story.part === 'pro' ? 'PROLOGUE'
: G.story && G.story.part === 'chap' : G.story && G.story.part === 'chap'
? `ACT ${ROMAN[G.story.act]} · GAME ${Math.min(STORY.length, G.runGames - G.story.base + 1)} / ${STORY.length}` ? `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);
@@ -3102,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;
@@ -3126,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(st ? sf.line : 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;
@@ -3210,17 +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 = st ? ['club', 'THE CLUBHOUSE'] const second = st ? (help ? ['story', 'TRY AGAIN'] : ['club', 'THE CLUBHOUSE'])
: solo ? (cup || (G.done && G.done.cup) ? ['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 = st ? (dead ? 'TRY AGAIN' : 'CONTINUE') : 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: st ? 'story' : 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);
+3 -2
View File
@@ -3,7 +3,7 @@
// 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, storyPool } 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';
@@ -1070,7 +1070,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
} }
} }
} }
+21 -1
View File
@@ -90,7 +90,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
const state = await import('./src/state.js'); const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state; const { G, P, O, score, ball, setupPoint } = state;
const { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); const { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs, startStory, startStoryChapter } = await import('./src/beats.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs, startStory, startStoryChapter, startHorde } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js'); const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court // player would, so the tests step a live court
@@ -3787,6 +3787,26 @@ test('THE STORY: death is not permadeath — it banks nothing and restarts from
assert.equal(G.hp, G.hpMax); assert.equal(G.hp, G.hpMax);
}); });
test('THE STORY: the help offer — fresher legs and a longer swing in the cup, a heart and a slower horde in gore', () => {
const H = cfg.STORY_HELP;
intoStory(1, 'cup');
const leg0 = _legLevel(), win0 = _winLevel();
G.story.help = 2;
assert.ok(Math.abs(_legLevel() / leg0 - (1 + 2 * H.run)) < 1e-9, 'legs');
assert.ok(Math.abs(_winLevel() / win0 - (1 + 2 * H.window)) < 1e-9, 'swing window');
endMatch(); G.story = null;
prof.prof.story.help = 2;
intoStory(1, 'chap');
prof.prof.story.help = 2;
G.story = null; assert.ok(startStoryChapter(false));
assert.equal(G.hpMax, TUNE.playerHp + 2 * H.heart, 'a heart per tier');
G.sets = [1, 0]; startHorde();
const slow = G.horde.rate;
G.horde = null; G.story.help = 0; startHorde();
assert.ok(Math.abs(G.horde.rate / slow - (1 + 2 * H.slow)) < 1e-9, 'the horde comes slower');
G.horde = null;
});
test('THE STORY: a barrel holds what the act has earned', () => { test('THE STORY: a barrel holds what the act has earned', () => {
intoStory(1, 'chap'); intoStory(1, 'chap');
const seen = new Set(); const seen = new Set();