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:
1 parent
ce27bbc48b
commit
789bf41a9a
10 files changed
+191
-34
No files matched your search
+83
-1
@@ -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'
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
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,
|
||||
reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
|
||||
|
||||
+8
-1
@@ -601,6 +601,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
background-size: cover; background-position: center; image-rendering: pixelated;
|
||||
text-align: left; cursor: pointer; }
|
||||
#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;
|
||||
background: linear-gradient(0deg, rgba(6,10,8,.94), rgba(6,10,8,.2) 65%, transparent); }
|
||||
#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"
|
||||
autocapitalize="characters" spellcheck="false">
|
||||
<h3>HOST</h3>
|
||||
<p id="vsHostLock"></p>
|
||||
<button class="btn" id="vsHostC">CLASSIC</button>
|
||||
<button class="btn" id="vsHostD">DEATH MATCH</button>
|
||||
<h3>JOIN</h3>
|
||||
@@ -1188,6 +1195,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</html>
|
||||
+9
-8
@@ -7,7 +7,7 @@
|
||||
// (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.)
|
||||
|
||||
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 { 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';
|
||||
@@ -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
|
||||
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 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
|
||||
// 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
|
||||
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)
|
||||
@@ -1151,7 +1152,7 @@ export function startStory() {
|
||||
return true;
|
||||
}
|
||||
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 startStoryChapter(s.cp === 'mid');
|
||||
@@ -1163,12 +1164,12 @@ export function startStory() {
|
||||
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 };
|
||||
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;
|
||||
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);
|
||||
@@ -1215,7 +1216,7 @@ function storyMorph(zb) {
|
||||
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.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,
|
||||
@@ -1282,7 +1283,7 @@ function storyCheckpoint() {
|
||||
}
|
||||
G.weapon = 0;
|
||||
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 }));
|
||||
profSave(); profPush();
|
||||
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); }
|
||||
else { s.cp = 'done'; prof.ch = 1; }
|
||||
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();
|
||||
G.story.cleared = act;
|
||||
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
||||
|
||||
@@ -1043,6 +1043,13 @@ export const STORY = {
|
||||
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 };
|
||||
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 => {
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
// 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 { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.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.
|
||||
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;
|
||||
export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) : 1;
|
||||
export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) : 1;
|
||||
// ...and THE STORY's help offer rides the same two dials (config.STORY_HELP): fresher legs, a longer swing
|
||||
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
|
||||
// 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)
|
||||
|
||||
+40
-8
@@ -3,7 +3,7 @@
|
||||
// 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)
|
||||
|
||||
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 { 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';
|
||||
@@ -116,14 +116,27 @@ let pickedDiff = remembered('diff') || 'medium';
|
||||
const modeOf = id => MODES.find(x => x.id === id);
|
||||
const modeList = document.getElementById('modeList');
|
||||
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() {
|
||||
modeList.innerHTML = '';
|
||||
for (const { mode } of MENU) {
|
||||
const card = modeOf(mode), b = document.createElement('button');
|
||||
b.className = 'mode';
|
||||
const card = modeOf(mode), b = document.createElement('button'), open = arcadeOpen(mode);
|
||||
b.className = 'mode' + (open ? '' : ' locked');
|
||||
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', () => {
|
||||
if (!arcadeOpen(mode)) { b.classList.remove('nope'); void b.offsetWidth; b.classList.add('nope'); return; }
|
||||
pickMode(mode);
|
||||
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; };
|
||||
|
||||
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();
|
||||
vsCode.value = code;
|
||||
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
|
||||
const fx = storyFixture(profile.prof.story);
|
||||
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('mCourt').textContent = 'COURT · ' +
|
||||
(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>
|
||||
</div>`;
|
||||
} 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';
|
||||
@@ -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)})`)}>
|
||||
<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>`}
|
||||
<button class="btn big r" ${delay(9)} data-door="next">${done ? 'THE CLUBHOUSE' : 'CONTINUE'}</button>
|
||||
${done ? '' : `<button class="btn r" ${delay(10)} data-door="club">THE CLUBHOUSE</button>`}
|
||||
${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>`;
|
||||
}
|
||||
sty.style.setProperty('--sty-glow', glow);
|
||||
@@ -826,9 +852,11 @@ document.getElementById('mStart').addEventListener('click', () => {
|
||||
document.getElementById('mOther').addEventListener('click', goMode);
|
||||
document.getElementById('mVs').addEventListener('click', () => vsOpen());
|
||||
document.getElementById('mGore').addEventListener('click', () => {
|
||||
if (!arcadeOpen('gore')) return;
|
||||
pickMode('gore'); start('gore');
|
||||
});
|
||||
document.getElementById('mCourt').addEventListener('click', () => {
|
||||
if (!arcadeOpen('gore')) return;
|
||||
pickMode('gore'); go('setup');
|
||||
});
|
||||
document.getElementById('cdStats').addEventListener('click', openStats);
|
||||
@@ -1132,7 +1160,11 @@ const maybeRestart = e => {
|
||||
const promo = profile.takePromo();
|
||||
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 === '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
|
||||
// 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
|
||||
@@ -1140,7 +1172,7 @@ const maybeRestart = e => {
|
||||
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
|
||||
// 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; }
|
||||
go('splash');
|
||||
};
|
||||
|
||||
+2
-2
@@ -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
|
||||
// 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 });
|
||||
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)) };
|
||||
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() {
|
||||
|
||||
+13
-8
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
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 { RAISERS, FRAMES } from './crowd.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}`
|
||||
// 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 · WIN A GAME'
|
||||
: 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);
|
||||
}
|
||||
// ...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.
|
||||
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);
|
||||
for (let k = 0; k < TUNE.killsPerPoint; k++) {
|
||||
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
|
||||
// summary points at it instead of at the next draw.
|
||||
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 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
|
||||
// a second trip through the lobby to play the same person again.
|
||||
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'
|
||||
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
|
||||
let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
|
||||
@@ -3210,17 +3215,17 @@ export function render() {
|
||||
const againLabel = G.classic ? 'CLASSIC AGAIN'
|
||||
: G.match && G.match.train ? 'TRAIN 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']
|
||||
: ['again', againLabel])
|
||||
: online ? ['club', 'THE CLUBHOUSE'] : null;
|
||||
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
|
||||
// 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';
|
||||
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) {
|
||||
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);
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
// your baseline — one crossing it is a point for THEM.
|
||||
// (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 { 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';
|
||||
@@ -1070,7 +1070,8 @@ export function updateZombies(dt) {
|
||||
if (spawnT <= 0 && zombies.length < cap) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
const state = await import('./src/state.js');
|
||||
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 { 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');
|
||||
// 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
|
||||
@@ -3787,6 +3787,26 @@ test('THE STORY: death is not permadeath — it banks nothing and restarts from
|
||||
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', () => {
|
||||
intoStory(1, 'chap');
|
||||
const seen = new Set();
|
||||
|
||||
Reference in new issue
Block a user