diff --git a/PROJECT.md b/PROJECT.md index 88e8813..9ff5e61 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/assets/audio/metal-loop.mp3 b/assets/audio/metal-loop.mp3 new file mode 100644 index 0000000..abbd516 Binary files /dev/null and b/assets/audio/metal-loop.mp3 differ diff --git a/index.html b/index.html index 25c4f5b..25016c0 100644 --- a/index.html +++ b/index.html @@ -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

+ + @@ -1195,6 +1201,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/audio.js b/src/audio.js index 904358d..8274379 100644 --- a/src/audio.js +++ b/src/audio.js @@ -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 diff --git a/src/beats.js b/src/beats.js index 32e6bc5..cc96ca4 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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 " // 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(); diff --git a/src/config.js b/src/config.js index 303e0df..de3b6dd 100644 --- a/src/config.js +++ b/src/config.js @@ -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 => { diff --git a/src/gameplay.js b/src/gameplay.js index 3441b5a..571b4b5 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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; } diff --git a/src/main.js b/src/main.js index d01edf2..75be2d4 100644 --- a/src/main.js +++ b/src/main.js @@ -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) {
TAP
`; + 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 = `
@@ -794,6 +802,9 @@ function storyScreen(kind, d) {
${STORY.midAt} · ${mid}${STORY.length} · ???
`; + 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) { ${done || wall ? '' : ``} `; + 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; diff --git a/test.js b/test.js index 3622ba4..71f74dd 100644 --- a/test.js +++ b/test.js @@ -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'); });