Story phase 3: the act card, the ceremony with its loot, the act's end

Three animated screens in the storyboard's look, built in the page over
a paused world:
- The act title card: the act's court pans in behind ACT n, its league,
  its court and its goal; it closes itself after three seconds or on a
  tap.
- The cup's ceremony: YOU CLEARED THE COURT, the rounds and the run's
  xp, then the act's weapons drop into a rack one by one ("…and gained
  these for the hordes to come"), and the chapter preview with the
  mid-boss named and game 10 still "???" until the transformation.
- The act card: what the act cost (bodies, bosses, deaths), what it
  opened in the arcade, and the next act's court; CONTINUE goes straight
  on, THE CLUBHOUSE goes home.

The checkpoint keeps its in-game banner. Headless, every screen falls
back to the plain card it replaced. v381, 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:18:00 +02:00
1 parent ff63c832cc
commit ce27bbc48b
6 files changed
+163 -15

No files matched your search

+91 -2
View File
@@ -3,10 +3,10 @@
// 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 } 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 } 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 } from './beats.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs , startStory , storyUI } from './beats.js';
import { netConnect, netHooks, netAgain } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
@@ -729,6 +729,95 @@ document.addEventListener('visibilitychange', () => {
}
});
addEventListener('pagehide', () => recorder.saveRecording());
// ---- THE STORY's screens (2026-10-15, PROJECT.md "THE STORY", phase 3) ------------------------------
// In-game, animated, in the storyboard's look: the act title card, the cup's ceremony with its loot,
// and the act card. Each pauses the world while it is up (the same `paused` the quit chip uses) and
// resolves with the door that closed it. beats asks for them through storyUI.show.
const sty = document.getElementById('sty');
let styClose = null, styDoors = false;
const art = n => `assets/${n}.png?v=${SPRV}`;
const courtOf = act => { const lg = leagueOf(storyAct(act).lg); return { lg, court: COURTS.find(c => c.id === lg.court) || COURTS[0] }; };
const ARCADE_NAMES = { train: 'TRAINING', classic: 'CLASSIC TENNIS', zombie: 'THE CLIMB', gore: 'SPLATTER GORE', vs: 'VS A FRIEND' };
const delay = (k, more = '') => `style="animation-delay:${(k * 0.15).toFixed(2)}s;${more}"`; // one style attribute per element
function storyScreen(kind, d) {
const act = d.act, { lg, court } = courtOf(act);
let html = '', glow = '#3a1d16', auto = 0;
if (kind === 'act') {
auto = 2.8;
glow = '#1d2233';
html = `<div class="bgc p" style="background-image:url(${art(court.tile)})"></div>
<div class="col">
<div class="lab gold r">${lg.name}</div>
<h1 class="huge s" ${delay(1)}>ACT ${ROMAN[act]}</h1>
<div class="big gold r" ${delay(3)}>${court.name}</div>
<div class="lab r" ${delay(5)}>WIN THE CUP · CLEAR THE COURT</div>
<img class="badge r" ${delay(6)} src="${art('badge_l' + lg.id)}" alt="">
<div class="lab tap">TAP</div>
</div>`;
} else if (kind === 'loot') {
const xd = profile.runDelta(), mid = storyBossArt(act, 'mid').name;
html = `<div class="col">
<div class="lab gold r">CUP WON · ${court.name}</div>
<div class="big r" ${delay(1)}>YOU CLEARED THE COURT</div>
<div class="stats r" ${delay(2)}>
<span class="lab">ROUNDS</span><b>${d.rounds} / ${d.rounds}</b>
<span class="lab">XP THIS RUN</span><b>+${xd.xp}</b>
<span class="lab">LEVEL</span><b>${xd.to}</b></div>
<div class="lab r" ${delay(3, 'color:#e8e8dc')}>…AND GAINED THESE FOR THE HORDES TO COME</div>
<div class="rack">${d.stock.map((e, k) => `<div class="d" style="animation-delay:${(0.7 + k * 0.22).toFixed(2)}s">
<img src="${art(WEAPONS[e.id].icon)}" alt=""><span class="lab pink">${WEAPONS[e.id].label} ×${e.n}</span></div>`).join('')}</div>
<div class="r" style="width:100%;display:grid;gap:6px;animation-delay:${(0.9 + d.stock.length * 0.22).toFixed(2)}s">
<div class="lab hot">CHAPTER ${ROMAN[act]} · ${STORY.length} GAMES</div>
<div class="pips">${Array.from({ length: STORY.length }, (_, k) => `<i class="${k === STORY.midAt - 1 ? 'm' : k === STORY.length - 1 ? 'b' : ''}"></i>`).join('')}</div>
<div class="row"><span class="lab gold">${STORY.midAt} · ${mid}</span><span class="lab hot">${STORY.length} · ???</span></div></div>
<button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button>
</div>`;
} else if (kind === 'clear') {
const done = act >= 5, next = done ? null : courtOf(act + 1);
const opens = storyAct(act).arcade.map(id => ARCADE_NAMES[id]).filter(Boolean);
glow = '#3b3216';
html = `<div class="col">
<div class="lab gold r">ACT ${ROMAN[act]} CLEARED</div>
<div class="big s" ${delay(1)}>${done ? 'THE DEAD CLUB IS QUIET' : court.name + ' IS QUIET'}</div>
<img class="badge s" ${delay(2)} src="${art(done ? 'badge_champ' : 'badge_l' + lg.id)}" alt="">
<div class="stats r" ${delay(3)}>
<span class="lab">BODIES</span><b>${stats.runKills ? stats.runKills() : 0}</b>
<span class="lab">BOSSES</span><b>2 / 2</b>
<span class="lab">DEATHS THIS ACT</span><b>${d.deaths}</b></div>
${opens.length ? `<div class="r" ${delay(5, 'display:grid;gap:6px')}><div class="lab">NEW IN ARCADE</div>
<div class="chips">${opens.map(n => `<span class="chip">${n}</span>`).join('')}</div></div>` : ''}
${next ? `<div class="next r" ${delay(7, `background-image:linear-gradient(#0006,#000e),url(${art(next.court.tile)})`)}>
<div><div class="lab gold">NEXT · ACT ${ROMAN[act + 1]}</div><div class="mid">${next.court.name}</div></div></div>`
: `<div class="mid gold r" ${delay(7)}>THE STORY IS TOLD</div>`}
<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>`}
</div>`;
}
sty.style.setProperty('--sty-glow', glow);
sty.innerHTML = html; sty.hidden = false; paused = true;
styDoors = /data-door/.test(html);
const t0 = performance.now();
return new Promise(res => {
styClose = door => {
if (performance.now() - t0 < 700) return; // a tap still on its way from the court cannot skip it
sty.hidden = true; sty.innerHTML = ''; paused = false; styClose = null; res(door);
};
if (auto) setTimeout(() => styClose && styClose('auto'), auto * 1000);
});
}
sty.addEventListener('click', e => {
if (!styClose) return;
const b = e.target.closest('[data-door]');
if (b) styClose(b.dataset.door); else if (!styDoors) styClose('tap');
});
storyUI.show = (kind, d) => storyScreen(kind, d).then(door => {
if (kind !== 'clear') return;
// the act card IS the act's ending: the run closes here, and the story picks up where it now stands
restartRun(); profile.push(); refreshSplash();
intro.hidden = false; quitChip.hidden = true;
if (door === 'next' && profile.prof.story.cp !== 'done') start('story'); else go('splash');
});
const goMode = () => { buildTops(); go('mode'); };
document.getElementById('mStart').addEventListener('click', () => {
if (!profile.prof.setup) { playerReturn = 'story'; buildPlayer(); go('player'); return; }