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

+40 -8
View File
@@ -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');
};