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:
1 parent
9e6d566c08
commit
16d0532f97
9 files changed
+119
-30
No files matched your search
+32
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user