From 789bf41a9a2295821e98d5a136658de667d91034 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 15 Oct 2026 14:22:25 +0200 Subject: [PATCH] Story phase 4: help when stuck, the arcade opens with the story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PROJECT.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++- index.html | 9 +++++- src/beats.js | 17 +++++----- src/config.js | 7 +++++ src/gameplay.js | 8 +++-- src/main.js | 48 +++++++++++++++++++++++----- src/profile.js | 4 +-- src/render.js | 21 ++++++++----- src/zombies.js | 5 +-- test.js | 22 ++++++++++++- 10 files changed, 191 insertions(+), 34 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 2558e99..563537d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/index.html b/index.html index 4a066bb..fef0482 100644 --- a/index.html +++ b/index.html @@ -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

HOST

+

JOIN

@@ -1188,6 +1195,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index fc28ae6..32e6bc5 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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; diff --git a/src/config.js b/src/config.js index 97edb7f..9fdef24 100644 --- a/src/config.js +++ b/src/config.js @@ -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 => { diff --git a/src/gameplay.js b/src/gameplay.js index 4e89786..3441b5a 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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) diff --git a/src/main.js b/src/main.js index ea95ff2..d01edf2 100644 --- a/src/main.js +++ b/src/main.js @@ -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 = `${card.name}`; + b.innerHTML = `${card.name}${open ? '' : `${lockLine(mode)}`}`; 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) { `; } 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 ? `` : `
THE STORY IS TOLD
`} - - ${done ? '' : ``} + ${wall ? `
THE STORY CONTINUES IN THE FULL GAME
` : ''} + + ${done || wall ? '' : ``} `; } 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'); }; diff --git a/src/profile.js b/src/profile.js index 801535f..1814763 100644 --- a/src/profile.js +++ b/src/profile.js @@ -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() { diff --git a/src/render.js b/src/render.js index 9f1c4f3..2918014 100644 --- a/src/render.js +++ b/src/render.js @@ -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); diff --git a/src/zombies.js b/src/zombies.js index 564521d..c5df419 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -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 } } } diff --git a/test.js b/test.js index 85e26db..3622ba4 100644 --- a/test.js +++ b/test.js @@ -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();