Story after Oliver's first test: metal, a straight prologue, 2x3 chapters

- The gore bed is "Dystopian Survival", made loopable: the last ten
  seconds cross-faded into the first ten, 203.5 s, as MP3 at 128k (the
  decoder strips the encoder's delay, so the seam has no gap). High-pass
  at 40 Hz instead of 800, level matched to the menu theme.
- The prologue no longer drops into gore for a moment: after the turn,
  act I's card and cup take over directly.
- RESET STORY under the build stamp (two taps; test only). It resets
  the story, league, cups and title, keeps the level, and pushes at once
  so the mirror cannot hand the old act back.
- The story screens have sound: a whoosh and a boom for the act card,
  applause and the rack's own click as each weapon lands, applause and
  a pock per arcade door on the act's end.
- Chapters are six games: mid-boss after three, the horde after four,
  the act boss after six.

v384, 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 15:12:52 +02:00
1 parent 9e6d566c08
commit 16d0532f97
9 files changed
+119 -30

No files matched your search

+32
View File
@@ -636,6 +636,38 @@ 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, Oliver's first test (2026-10-15, v384, STAGING ONLY)
Oliver: *"after my first test it all made a good impression"*, plus five asks.
- **The metal track** ("Dystopian Survival", 3:36). `assets/audio/metal-loop.mp3` is 0.5-214 s with the
last 10 s cross-faded (equal power, `acrossfade qsin`) into the first 10. The seam sits inside the
fade and the loop is exactly 203.5 s.
- Built with a static ffmpeg in the scratchpad: decode to WAV first, then three `-ss/-t` inputs. Two
`atrim`s of ONE input read out of order yield an empty crossfade.
- It was AAC first; MP3 at Oliver's call. The LAME tag carries the encoder delay and padding, and the
decoder strips them: 8974350 frames measured in Chromium, signal at both ends. `GORE_BED.loopEnd`
pins it anyway.
- `lowcut` is 40 Hz (the old 800 Hz high-pass would gut metal) and `vol` is 0.5: it is mastered at
-14 LUFS against the old bed's -8.
- The open-source Chromium used for tests cannot decode AAC at all, the old beds included. Phones
can; that was never a file defect.
- **The prologue goes straight on** (*"momentan wechselt man kurz in den Gore-Mode, das ist
verwirrend"*). In the story the old transformation stops after the turn: no mob, no "remain
seated", no gore beat. At 5.2 s, `enterSplatter(quiet)` and act I's card and cup take over.
- Because the quiet entry no longer loads the gore bed, the chapter start and the transformation's
run-off call `zombieMusic()` (load if needed, then un-duck) instead of `zombieDuck(false)`.
- **RESET STORY** (test only, `STORE.testReset`) under the splash's build stamp. Two taps within 3 s.
- It resets the story, league, cups and champion flag; the level stays.
- It pushes at once, or `pull()` (forward-only) would hand the old act back.
- Gotcha: `armT` started at 0, which counts as armed in a page younger than 3 s.
- **Screen sounds:**
- Act card: a whoosh and a boom when the title lands.
- Ceremony: applause, and the rack's own `weapon-click2` as each tin lands, at 70% of its drop.
- Act card at the end: applause, a boom for the badge, a rising pock per arcade door.
- Timers are cleared when a screen is tapped away.
- **Chapters are 2×3** (*"statt 2x5 lieber 2x3"*): `STORY.midAt` 3, `length` 6, and `hordeAt` 4. A
chapter's "set" is four games: at the arcade's six the set would land on the act boss, and the horde
would vanish behind him. The test pins one horde after game four.
## 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.
Binary file not shown.
+7 -1
View File
@@ -250,6 +250,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro .slogan b { color: #c9564a; }
/* which build is served — filled in by main.js, empty (and invisible) if the header is missing */
#build { margin: 14px 0 0; font-size: 9px; color: #4a5a52; letter-spacing: 0.5px; }
#mReset { display: block; margin: 6px auto 0; padding: 6px 10px; background: none; border: 1px solid #2c3a33;
color: #6b7a72; font: 400 9px Silkscreen, monospace; letter-spacing: 1px; cursor: pointer; }
#mReset.arm { color: #d9705f; border-color: #d9705f; }
#splash.busy #mReset { display: none; }
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
@@ -973,6 +977,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn sec" id="mRanks">WORLD BOARD</button>
</div>
<p id="build"></p>
<!-- TEST ONLY (Oliver, 2026-10-15): start the story over. config.STORE.testReset hides it. -->
<button id="mReset" type="button">RESET STORY</button>
</div>
</div>
<!-- Additional solo modes; multiplayer has a separate splash entrance. -->
@@ -1195,6 +1201,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=383"></script>
<script type="module" src="src/main.js?v=384"></script>
</body>
</html>
+1 -1
View File
@@ -211,7 +211,7 @@ export const ambient = () => {
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
export const zombieMusic = () => { loopBed(GORE_BED.file, 800, GORE_BED.vol, GORE_BED.loopEnd); zombieDuck(false); };
export const zombieMusic = () => { loopBed(GORE_BED.file, GORE_BED.lowcut ?? 800, GORE_BED.vol, GORE_BED.loopEnd); zombieDuck(false); };
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
+7 -8
View File
@@ -11,7 +11,7 @@ import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketS
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';
import { voice, zombieDuck, aww, applause, shriek, boom, splat } from './audio.js';
import { voice, zombieDuck, zombieMusic, aww, applause, shriek, boom, splat } from './audio.js';
import { haptic } from './haptics.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, logPoint, advancePoint, scoreKey, grantWeapon } from './gameplay.js';
@@ -30,7 +30,7 @@ export function proceedAfterClear() {
// The chapter boss outranks everything here, including a set: a boss walking on into a standing
// wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
// THE STORY's bosses ride the same slot, ahead of everything: the mid-boss at game 5, the act's
// boss at game 10 (splatPoint arms G.storyBoss; the arcade's G.bossDue is never set in a chapter)
// boss at the chapter's last game (splatPoint arms G.storyBoss; the arcade's G.bossDue is never set in a chapter)
if (G.storyBoss) { const k = G.storyBoss; G.storyBoss = null; G.afterDuel = mode || 'game'; startStoryBoss(k); return; }
if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
// The set/horde beat outranks the duel; the duel just waits for the next clear.
@@ -1129,7 +1129,7 @@ export function startHorde() {
// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY") ---------------------------------------------
// Five acts, one per league and court: the league cup, the transformation, one ten-game gore chapter
// with a mid-boss at game 5 (the checkpoint) and the act's boss at game 10, then the act card. Same
// with a mid-boss at STORY.midAt (the checkpoint) and the act's boss at its last game, then the act card. Same
// court-ownership contract as every mode in this file. The PLACE in the story lives in the profile
// (prof.story: act, checkpoint, the rack it hands back) because restartRun wipes G; G.story is only
// what this run is doing with it. Death is not permadeath here: it costs the attempt and restarts
@@ -1182,7 +1182,7 @@ export function startStoryChapter(fromMid = false, keepCrowd = false) {
s.cp = fromMid ? 'mid' : 'chap';
if (!fromMid) s.rack = stock.map(e => ({ ...e }));
profSave(); profPush();
zombieDuck(false);
zombieMusic(); // the gore bed — loaded here if no door loaded it yet
ball.active = false; setupPoint();
if (!keepCrowd) G.card = { title: 'CHAPTER ' + ROMAN[act], color: UI.hot, t: 3.2,
sub1: fromMid ? 'FROM GAME ' + (STORY.midAt + 1) + ' · ' + storyBossArt(act, 'end').name + ' WAITS AT ' + STORY.length
@@ -1197,7 +1197,7 @@ export function startStoryChapter(fromMid = false, keepCrowd = false) {
// 0.0 the ceremony modal (the world waits for its tap)
// 1.2 the applause is cut, "quiet, please", the camera closes in on him
// 2.8 THE TURN: hitstop, flash, he bursts — and the act's boss stands where he stood, growing
// 3.3 his name and "GAME 10"
// 3.3 his name and "GAME <length>"
// 4.6 he runs out of the frame; the stands empty onto the court; the metal comes in
// 6.4 game 1 of the chapter, without a cut (the crowd that came down is its first wave)
// The spawn director, the boss's AI and serving all stay held by the interlude until the chapter.
@@ -1249,7 +1249,7 @@ function tickMorph(dt) {
// the stands empty onto the court: the chapter's first wave walks on from the far side
for (const [x, z, v] of [[-3.5, -1.6, 'a'], [2.5, -2.1, 'b'], [-1.2, -2.6, 'a'], [3.8, -1.2, 'b']]) spawnZombie(x, z, v);
G.shake = { t: 0.5, mag: 6 }; voice('remain_seated', 0, 0.95);
zombieDuck(false); // the chapter's music comes in here (metal: phase 5)
zombieMusic(); // THE METAL comes in here (config.GORE_BED)
}
if (m.boss && t >= MORPH.run) { // ...and he runs out of the picture, left
const step = 8 * dt;
@@ -1311,7 +1311,6 @@ function storyActCleared() {
export function tickStory(dt) {
if (!G.story) return;
if (G.story.morph) return tickMorph(dt);
if (G.story.proT > 0 && (G.story.proT -= dt) <= 0) return storyPrologueDone();
if (G.story.winT > 0 && (G.story.winT -= dt) <= 0) { G.story.winT = 0; storyActCleared(); }
}
@@ -1321,7 +1320,7 @@ export function tickStory(dt) {
// the dead take their seats for the first act's cup.
export function storyPrologueGo() {
if (!G.story || G.story.part !== 'pro') return;
G.story.proT = 2.4; // the mob is seen walking on before the act opens
storyPrologueDone(); // straight on: the act's card, then its cup
}
function storyPrologueDone() {
prof.story.cp = 'cup'; profSave(); profPush();
+18 -9
View File
@@ -1020,7 +1020,7 @@ export const cupFixture = (lg, champion = 0) => {
// ---- THE STORY (2026-10-15, PROJECT.md "THE STORY"): five acts, one per league and court. An act is
// the league cup (zombie tennis), the transformation, and one ten-game gore chapter with a MID-BOSS
// at game 5 (the checkpoint) and the act's BOSS at game 10. Pure data; beats.js runs it.
// (STORY.midAt, the checkpoint) and the act's BOSS at its last game. Pure data; beats.js runs it.
// rounds the cup's rungs, in order. The FINAL is always the boss's own body (it grows into the
// boss), so acts II and IV play their band out of ladder order — cupRoundsFor is untouched.
// mid BOSS_ART index of the game-5 boss (the five designs one chapter per act leaves over)
@@ -1028,7 +1028,7 @@ export const cupFixture = (lg, champion = 0) => {
// pool weapons this act ADDS to the rack; the stock is every pool up to and including the act
// double the act's stock and restock pay two tins of each (league 5's double tins)
// arcade the arcade doors clearing this act opens (MODES ids, plus 'vs' for hosting a friend)
// The chapter plays the arcade's games base+1..base+10 (base = (act-1)*20), so every director curve
// The chapter plays the arcade's games base+1..base+length (base = (act-1)*20), so every director curve
// that reads G.runGames — spawn ramps, barrel gaps, machines, racket hp — keeps its tuned slope.
export const STORY = {
acts: [
@@ -1038,8 +1038,12 @@ export const STORY = {
{ lg: 4, rounds: [13, 15, 14], mid: 4, boss: 3, pool: ['flamer', 'rocket'], arcade: ['vs'] },
{ lg: 5, rounds: [16, 17, 18], mid: 7, boss: 9, pool: [], double: true, arcade: [] },
],
midAt: 5, // the mid-boss walks on after this many chapter games (and is the checkpoint)
length: 10, // games in a chapter; the act's boss walks on after the last
// Oliver on v383: "Gore scheint mir etwas zu lang — statt 2x5 lieber 2x3 Spiele". Six games, the
// mid-boss after the third, the act's boss after the sixth — and the horde after the fourth, between
// them (a chapter's 'set' is hordeAt games: at the arcade's six it would land on the boss and vanish).
midAt: 3, // the mid-boss walks on after this many chapter games (and is the checkpoint)
length: 6, // games in a chapter; the act's boss walks on after the last
hordeAt: 4, // the chapter's set: the horde comes after this many games
base: act => (act - 1) * 20,
};
export const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V'];
@@ -1049,13 +1053,18 @@ export const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V'];
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 STORE = { freeTier: false, arcadePremium: false, testReset: true }; // testReset: the splash's RESET STORY button
// THE GORE BED — the one track that plays while the dead are being killed (gore, every story chapter,
// from the transformation's run-off on), ducked everywhere else. Oliver wants METAL here (2026-10-15)
// and is finding the track: drop it into assets/audio and change these three values. `loopEnd` is the
// SOURCE length in seconds for an AAC file (its padded last packet clicks at the seam otherwise), 0
// for a WAV; `vol` is the bed's level under the umpire.
export const GORE_BED = { file: 'zombie-mode.m4a', loopEnd: 20.47077, vol: 0.2 };
// sent "Dystopian Survival" (2026-10-15). metal-loop.mp3 is that track made loopable: 0.5-214 s with
// its last ten seconds cross-faded (equal power) into its first ten, so the seam is inside the fade
// and the loop is 203.5 s exactly. MP3 at Oliver's call; the encoder's delay and padding are carried
// in the LAME tag and the decoder strips them (measured in Chromium: 8974350 frames, no silence at
// either end), and `loopEnd` pins the source length anyway. `vol` sets it near the menu theme's level
// (it is mastered at -14 LUFS, the old bed at -8); `lowcut` 40 Hz keeps the guitars' body, where the
// old bed's 800 Hz high-pass would have thinned metal to a radio. The old bed: zombie-mode.m4a,
// 20.47077 s, 0.2, 800.
export const GORE_BED = { file: 'metal-loop.mp3', loopEnd: 203.5, vol: 0.5, lowcut: 40 };
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 => {
+9 -5
View File
@@ -893,7 +893,7 @@ export function splatPoint(side, quiet = false) {
// the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance
// at a weapon, but you still have to win the point to be handed one. Fires at the next clear.
// THE STORY's chapter has no duel, and its two bosses are its own: the mid-boss after game 5
// (the checkpoint) and the act's boss after game 10. The arcade schedule below never runs there.
// (the checkpoint) and the act's boss after the last (config.STORY). The arcade schedule below never runs there.
const chap = G.story && G.story.part === 'chap';
if (chap) {
const rel = G.runGames - G.story.base;
@@ -913,7 +913,7 @@ export function splatPoint(side, quiet = false) {
if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
const i = side === 'P' ? 0 : 1;
if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
if (score.games[i] >= (chap ? STORY.hordeAt : 6)) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
else G.afterClear = 'game'; // even game: a beat, then the next wave
G.clearWinner = side;
@@ -1188,13 +1188,17 @@ function transition(dt) {
gore.push({ x: O.x, z: O.z, r: 1.1, a: 0.9 });
spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns
});
// THE STORY's prologue stops at the turn (Oliver, 2026-10-15: "direkt nach der Verwandlung das Tennis-
// Spiel fortfahren — momentan wechselt man kurz in den Gore-Mode, das ist verwirrend"): no mob, no gore
// beat — the dead take their seats and act I's cup carries the match on.
const pro = G.story && G.story.part === 'pro';
if (pro) { once('story', 5.2, () => { enterSplatter(true); storyPrologueGo(); }); return; }
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => {
enterSplatter();
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
storyPrologueGo(); // THE STORY's prologue: the first act's cup takes over from here
});
}
@@ -1203,10 +1207,10 @@ function transition(dt) {
// NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull
// instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after
// the usual beat of inaction, and it keeps coming back whenever the player stalls.
function enterSplatter() {
function enterSplatter(quiet = false) {
G.mode = 'splatter'; G.everTransitioned = true;
score.server = 'P'; setupPoint();
voice('play', 0.4, 0.9); zombieMusic();
if (!quiet) { voice('play', 0.4, 0.9); zombieMusic(); } // (the story's prologue goes straight on to a cup)
G.idleHintT = TUNE.pullHintAfter;
}
+36 -2
View File
@@ -9,7 +9,7 @@ import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZo
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';
import { voice, ambient, menuMusic, menuDuck, sfx, boom, whoosh, applause, pock } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
import { haptic } from './haptics.js';
@@ -761,9 +761,16 @@ 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
// THE SOUND OF THE SCREENS (Oliver: "kleine Soundeffekte — die Waffen könnten das Klacken haben, das
// auch beim Waffenwechsel im Gore-Mode spielt"). Each cue is timed to the animation it belongs to and
// is dropped if the screen is tapped away first.
let styTimers = [];
const at = (secs, fn) => styTimers.push(setTimeout(fn, secs * 1000));
const CLICK = () => sfx('weapon-click2.mp3', 0.7); // the rack's own click (gameplay.selectWeapon)
function storyScreen(kind, d) {
const act = d.act, { lg, court } = courtOf(act);
let html = '', glow = '#3a1d16', auto = 0;
styTimers.forEach(clearTimeout); styTimers = [];
if (kind === 'act') {
auto = 2.8;
glow = '#1d2233';
@@ -776,6 +783,7 @@ function storyScreen(kind, d) {
<img class="badge r" ${delay(6)} src="${art('badge_l' + lg.id)}" alt="">
<div class="lab tap">TAP</div>
</div>`;
whoosh(0.3); at(0.3, () => boom(0.45)); // the court flies in, ACT n lands
} else if (kind === 'loot') {
const xd = profile.runDelta(), mid = storyBossArt(act, 'mid').name;
html = `<div class="col">
@@ -794,6 +802,9 @@ function storyScreen(kind, d) {
<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>`;
applause(2.4, 0.45); at(0.3, () => pock(0.5, 700));
// each tin lands at 70% of its 0.6 s drop — that is where it clicks into the rack
d.stock.forEach((e, k) => at(0.7 + k * 0.22 + 0.42, CLICK));
} 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
@@ -818,6 +829,8 @@ function storyScreen(kind, d) {
<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>`;
applause(3.2, 0.55); at(0.4, () => boom(0.4)); // the act is down; the badge lands
opens.forEach((_, k) => at(0.85 + k * 0.12, () => pock(0.55, 1100 + k * 180)));
}
sty.style.setProperty('--sty-glow', glow);
sty.innerHTML = html; sty.hidden = false; paused = true;
@@ -826,7 +839,9 @@ function storyScreen(kind, d) {
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);
sty.hidden = true; sty.innerHTML = ''; paused = false; styClose = null;
styTimers.forEach(clearTimeout); styTimers = [];
res(door);
};
if (auto) setTimeout(() => styClose && styClose('auto'), auto * 1000);
});
@@ -850,6 +865,25 @@ document.getElementById('mStart').addEventListener('click', () => {
start('story');
});
document.getElementById('mOther').addEventListener('click', goMode);
// RESET STORY (Oliver, 2026-10-15: "ich brauche zumindest während des Tests einen Button, der meinen
// Spielstand wieder resettet"). Two taps, so a stray one costs nothing. It puts the story back at the
// prologue and takes back what the story hands out — league, cups, the champion's flag — and keeps the
// level (xp is the server mirror's one-way ratchet). The push makes the mirror agree at once: pull()
// moves the story only forward, and a mirror still holding act III would hand it straight back.
{
const b = document.getElementById('mReset');
b.hidden = !STORE.testReset;
let armT = -Infinity; // (0 would count as armed in a page younger than 3 s)
b.addEventListener('click', () => {
if (performance.now() - armT > 3000) { armT = performance.now(); b.textContent = 'TAP AGAIN TO RESET'; b.classList.add('arm'); return; }
const p = profile.prof;
p.story = profile.freshStory(); p.lg = 1; p.ch = 0; p.cups = {}; p.at = {}; p.out = 0;
profile.save(); profile.push();
armT = -Infinity; b.textContent = 'STORY RESET'; b.classList.remove('arm');
refreshSplash(); buildTops();
setTimeout(() => { b.textContent = 'RESET STORY'; }, 1600);
});
}
document.getElementById('mVs').addEventListener('click', () => vsOpen());
document.getElementById('mGore').addEventListener('click', () => {
if (!arcadeOpen('gore')) return;
+9 -4
View File
@@ -2080,7 +2080,7 @@ test('the menus own the theme, and hand the court back its silence', () => {
// never bring it BACK from the duck start() put it under — and nothing ducked the court's own
// beds at all. The menu was silent and noisy at once, permanently, from the first run onward.
const d = audio.bedTest.ducked, THEME = 'background-music.m4a',
AMB = 'tennis-ambient-loop.wav', ZOM = 'zombie-mode.m4a';
AMB = 'tennis-ambient-loop.wav', ZOM = cfg.GORE_BED.file; // the gore bed (metal since 2026-10-15)
audio.menuMusic(); // on the menu
assert.ok(!d.has(THEME), 'the theme plays on the menu');
audio.menuDuck(true); audio.ambient(); audio.zombieMusic(); // ...a run starts
@@ -3737,7 +3737,7 @@ test('THE STORY: the prologue is a living match that turns, and then the first a
assert.ok(G.match && G.match.cup && G.story && G.story.part === 'cup', 'act I\'s cup takes the court');
});
test('THE STORY: a chapter has no duel, a mid-boss checkpoint at five and the act boss at ten', () => {
test('THE STORY: a chapter has no duel, a mid-boss checkpoint, a horde between the bosses, and the act boss last', () => {
prof.prof.lg = 1;
intoStory(2, 'chap');
assert.equal(G.runGames, cfg.STORY.base(2));
@@ -3756,7 +3756,12 @@ test('THE STORY: a chapter has no duel, a mid-boss checkpoint at five and the ac
update(DT);
assert.ok(!G.interlude, 'the racket zombie is an ordinary body in the story');
zombies.length = 0;
for (let g = 0; g < cfg.STORY.length - cfg.STORY.midAt; g++) { storyGame(); G.modal = null; }
let horde = false;
for (let g = 0; g < cfg.STORY.length - cfg.STORY.midAt; g++) {
storyGame(); G.modal = null;
if (G.pendingHorde) { horde = true; assert.equal(G.runGames - G.story.base, cfg.STORY.hordeAt, 'the horde comes after game ' + cfg.STORY.hordeAt); G.pendingHorde = false; }
}
assert.ok(horde, 'one horde, between the two bosses');
const boss = zombies.find(z => z.variant === 'B');
assert.ok(G.bossFight && boss, 'the act boss walks on after game ten');
assert.equal(boss.art.name, cfg.storyBossArt(2, 'end').name, 'act II\'s boss is the sponsor mascot');
@@ -3825,7 +3830,7 @@ test('THE STORY: a profile from before the story starts it from the top, keeping
const t = prof.cleanStory({ act: 9, cp: 'nonsense', rack: [{ id: 'bomb', n: 500 }, { n: 2 }], tries: -3 });
assert.deepEqual([t.act, t.cp, t.tries], [5, 'cup', 0]);
assert.deepEqual(t.rack, [{ id: 'bomb', n: 99 }]);
assert.equal(cfg.storyFixture({ act: 1, cp: 'mid' }).line, 'CHAPTER I · FROM GAME 6');
assert.equal(cfg.storyFixture({ act: 1, cp: 'mid' }).line, 'CHAPTER I · FROM GAME ' + (cfg.STORY.midAt + 1));
assert.equal(cfg.storyFixture({ act: 5, cp: 'done' }).title, 'THE STORY IS TOLD');
});