diff --git a/index.html b/index.html index 11bad8c..375ef5a 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index afb7269..bde8c0e 100644 --- a/src/beats.js +++ b/src/beats.js @@ -11,7 +11,8 @@ 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 } from './audio.js'; +import { voice, zombieDuck, 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'; import { startDrillStream } from './drill.js'; @@ -924,6 +925,9 @@ function cupGameWon(zb, banked = false) { } // the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch) award('round'); + // THE STORY's final does not fall: the finalist stays on his feet for the ceremony, and then grows + // into the act's boss in front of you (storyMorph) + if (G.story && cup.round === cup.rounds.length - 1) return storyMorph(zb); d.afterFall = () => { matchHealBeat(); cup.round++; cup.g = 0; @@ -945,7 +949,7 @@ function cupGameWon(zb, banked = false) { // summary over a still court, then the gated restart). Banked through the same endRun as every // match run, not dead: the stats screen's deaths column keeps meaning what it means. function cupWon() { - if (G.story) return storyCupWon(); + if (G.story) return; // the story's final ends in storyMorph instead const d = G.match; d.ending = false; d.mopT = 0; G.interlude = false; @@ -1132,9 +1136,15 @@ export function startStory() { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net || G.story) return false; const s = prof.story; if (s.cp === 'done') { s.act = 1; s.cp = 'cup'; s.rack = []; s.tries = 0; } // the story told again - if (s.cp === 'pro') s.cp = 'cup'; // (the prologue is phase 2 — until then, act I) prof.lg = Math.max(prof.lg, storyAct(s.act).lg); // the act's league is open to the arcade too profSave(); + if (s.cp === 'pro') { + // main.start leaves the world in 'tennis' for it: a real match, the turn still armed + G.story = { act: 1, part: 'pro', base: 0, fight: null, winT: 0 }; + showModal({ title: 'PROLOGUE', color: UI.warm, strap: 'TERRE ROUGE · A SUMMER AFTERNOON', + body: ['A MATCH LIKE ANY OTHER', 'WIN A GAME'], rules: ['A MATCH LIKE ANY OTHER', 'WIN A GAME'] }); + return true; + } if (s.cp === 'cup') { G.story = { act: s.act, part: 'cup', base: STORY.base(s.act), fight: null, winT: 0 }; return startMatch(true, storyRounds(s.act)); @@ -1145,7 +1155,7 @@ export function startStory() { // THE CHAPTER: the gore, from game 1 (or from game 6, after the mid-boss), on the act's court. The // rack is the act's stock (or what the mid-boss checkpoint saved), the blood is full, and the // arcade's director plays its games base+1..base+10 so every curve keeps its tuned slope. -export function startStoryChapter(fromMid = false) { +export function startStoryChapter(fromMid = false, keepCrowd = false) { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; const s = prof.story, act = s.act; G.story = { act, part: 'chap', base: STORY.base(act), midDone: fromMid, fight: null, winT: 0 }; @@ -1161,35 +1171,92 @@ export function startStoryChapter(fromMid = false) { G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; G.storyBoss = null; G.changeover = false; G.clearing = false; G.afterClear = null; G.afterDuel = null; G.mgT = 0; endBurst(); G.boy = null; - zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; + if (!keepCrowd) zombies.length = 0; + pellets.length = 0; extras.length = 0; shots.length = 0; s.cp = fromMid ? 'mid' : 'chap'; if (!fromMid) s.rack = stock.map(e => ({ ...e })); profSave(); profPush(); zombieDuck(false); ball.active = false; setupPoint(); - G.card = { title: 'CHAPTER ' + ROMAN[act], color: UI.hot, t: 3.2, + 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 : STORY.length + ' GAMES · ' + storyBossArt(act, 'mid').name + ' AT ' + STORY.midAt }; return true; } -// THE CUP IS WON: the cup board still notes it, the act moves to its chapter, and the rack is filled -// with what the act has earned. (Phase 2 puts the transformation between the two; phase 3 the -// animated loot screen. This is the plain version of both.) -function storyCupWon() { - const d = G.match; +// THE CUP IS WON — AND THE FINALIST GROWS (Oliver, 2026-10-15: "der besiegte Player-Zombie verwandelt +// sich in den sehr viel größeren Endgame-Zombie, gleicher Charakter nur größer, als Preview für das +// Endmatch des Akts, rennt aus dem Bild, während der Gore-Mode beginnt"). The beaten body does not +// fall: the ceremony (the loot) lands over a standing court, then the timeline in tickStory runs. +// 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" +// 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. +const MORPH = { quiet: 1.2, turn: 2.8, name: 3.3, run: 4.6, play: 6.4 }; +function storyMorph(zb) { + const d = G.match, act = G.story.act; + d.ending = true; award('cup'); if (!d.train) { recordCupWin(G.league, levelAt(prof.xp)); recordCup(G.league, d.cup.rounds.length); } cheer(4, 1); voice('unbelievable', 0.4); - const stock = storyStock(G.story.act); - endMatch(); - G.story = null; // startStoryChapter stamps the chapter's own + const stock = storyStock(act); + // the match is over; its body stays on court for the turn (cine: nothing moves it) + G.match = null; G.matchFall = null; + if (zb) { zb.cine = true; zb.vx = zb.vz = 0; zb.pose = 'idle'; } + G.interlude = { introT: 99 }; // nothing serves, spawns or attacks through the scene + ball.active = false; G.phase = 'over'; G.overT = 99; + G.story.part = 'morph'; + G.story.morph = { t: 0, x: zb ? zb.x : 0, z: zb ? zb.z : -1, body: zb, boss: null, stock }; prof.story.tries = 0; - startStoryChapter(false); - showModal({ title: 'THE CUP IS YOURS', color: UI.pick, + // the ceremony: phase 3 makes this the animated loot screen; the timeline starts when it is gone + showModal({ title: 'THE CUP IS YOURS', color: UI.pick, story: { act, stock }, body: ['…AND THESE FOR THE HORDES TO COME', stock.map(e => WEAPONS[e.id].label).join(' · ')] }); } +function tickMorph(dt) { + const m = G.story.morph, act = G.story.act; + const t0 = m.t, t = (m.t += dt); + const at = k => t0 < MORPH[k] && t >= MORPH[k]; + if (at('quiet')) { + applause(0.3, 0.2); voice('quiet_dark', 0, 0.82); + G.zoom = { x: m.x, z: m.z, k: 2.1, t: 0, T: 3.2 }; // peaks (p = 0.5) on the turn + } + if (at('turn')) { + G.hitstop = 0.16; haptic('heavy'); G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 }; + shriek(); boom(0.6); splat(1); + const i = zombies.indexOf(m.body); if (i >= 0) zombies.splice(i, 1); + explode(m.x, m.z); + spawnChapterBoss(storyBossChapter(act, 'end'), storyBossArt(act, 'end')); + const b = zombies[zombies.length - 1]; + b.x = m.x; b.z = m.z; b.cine = true; b.full = b.scale; b.scale = b.full * 0.45; + m.boss = b; + } + if (m.boss && t >= MORPH.turn && t < MORPH.run) { // he grows into the frame + m.boss.scale = m.boss.full * Math.min(1, 0.45 + 0.55 * (t - MORPH.turn) / 0.5); + } + if (at('name')) G.card = { title: storyBossArt(act, 'end').name, sub1: 'GAME ' + STORY.length, color: UI.hot, t: 2.4 }; + if (at('run')) { + // 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) + } + if (m.boss && t >= MORPH.run) { // ...and he runs out of the picture, left + const step = 8 * dt; + m.boss.x -= step; m.boss.dist = (m.boss.dist || 0) + step; + } + if (t >= MORPH.play) { + const i = zombies.indexOf(m.boss); if (i >= 0) zombies.splice(i, 1); + G.story.morph = null; + G.interlude = false; G.story = null; + startStoryChapter(false, true); + G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 }; + } +} + function startStoryBoss(kind) { const act = G.story.act; G.story.fight = kind; @@ -1232,7 +1299,25 @@ function storyActCleared() { // Ticked from update beside tickMatchFall: the act boss's death beat before the act card. export function tickStory(dt) { - if (G.story && G.story.winT > 0 && (G.story.winT -= dt) <= 0) { G.story.winT = 0; storyActCleared(); } + 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(); } +} + +// THE PROLOGUE: one game of tennis against a LIVING opponent, and the old transformation exactly as +// it was written (gameplay.startTransition: the living man turns). It tells the outbreak once, and +// it is classic tennis's place in the story. When the transformation hands over (its 'go' beat), +// 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 +} +function storyPrologueDone() { + prof.story.cp = 'cup'; profSave(); profPush(); + zombies.length = 0; pellets.length = 0; shots.length = 0; + G.story = null; + startStory(); } // zombies.killPlayer hands a story death here instead of banking a run: the attempt is counted diff --git a/src/gameplay.js b/src/gameplay.js index a24cf3e..4e89786 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -11,7 +11,7 @@ import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; import { award, beginRunXp, prof, bonus, levelAt } from './profile.js'; import { recordEvent, recordFrame } from './recorder.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; -import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver, tickStory } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing +import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver, tickStory, storyPrologueGo } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal // ------------------------------------------------------------- ballistics ---- @@ -1194,6 +1194,7 @@ function transition(dt) { 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 }); } @@ -1842,6 +1843,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns // your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead. function racketAI(rz, dt) { + if (rz.cine) return; // THE STORY's transformation holds the finalist where he stands expirePose(rz, dt); rz.swingCool -= dt; const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0 diff --git a/src/main.js b/src/main.js index 04a2445..2f261d2 100644 --- a/src/main.js +++ b/src/main.js @@ -673,7 +673,8 @@ function start(modeId = pickedMode) { // training: the picked rung rides the match machinery with the ladder parked (beats.startMatch) if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); } // THE STORY: the act's court, the splatter world under it, and the checkpoint the profile holds - if (modeId === 'story') { intoSplatter(); startStory(); } + // (the PROLOGUE is the one door that stays in 'tennis': a real match, the old turn armed in it) + if (modeId === 'story') { if (profile.prof.story.cp !== 'pro') intoSplatter(); startStory(); } } // Career totals and detailed records live one level beneath the profile. diff --git a/src/render.js b/src/render.js index ef6a4d8..9f1c4f3 100644 --- a/src/render.js +++ b/src/render.js @@ -3027,6 +3027,8 @@ export function render() { : G.match ? `${matchFoe(G.match.opp).short} · ${G.match.felled || 0} FELLED` : G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}` // THE STORY's chapter counts its own ten (its games run on the arcade's scale underneath) + : G.story && G.story.part === 'morph' ? `ACT ${ROMAN[G.story.act]} · THE CUP IS YOURS` + : G.story && G.story.part === 'pro' ? 'PROLOGUE · WIN A GAME' : G.story && G.story.part === 'chap' ? `ACT ${ROMAN[G.story.act]} · GAME ${Math.min(STORY.length, G.runGames - G.story.base + 1)} / ${STORY.length}` : `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8); diff --git a/test.js b/test.js index 6c9c55f..85e26db 100644 --- a/test.js +++ b/test.js @@ -3653,7 +3653,7 @@ function intoStory(act, cp) { G.modal = null; G.modalQ.length = 0; } function winStoryCup() { - for (let i = 0; i < 120 * 60 && G.story && G.story.part === 'cup'; i++) { + for (let i = 0; i < 120 * 60 && G.story && (G.story.part === 'cup' || G.story.part === 'morph'); i++) { G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) resolveInterlude('P', 'test'); @@ -3695,6 +3695,48 @@ test('THE STORY: the act\'s cup is won into its chapter — no promotion, the st assert.equal(G.runGames, cfg.STORY.base(1), 'the chapter starts at its own game 1'); }); +test('THE STORY: the beaten finalist grows into the act boss, runs off, and game 1 begins without a cut', () => { + intoStory(1, 'cup'); + const cup = G.match.cup; + cup.round = cup.rounds.length - 1; cup.g = cup.rounds[cup.round].need - 1; // match point of the final + G.match.opp = cup.rung = cup.rounds[cup.round].band[0]; + let seen = { morph: false, boss: null, grew: 0, left: false, crowd: 0, served: false }; + for (let i = 0; i < 60 * 60 && !(G.story && G.story.part === 'chap'); i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; + if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) + resolveInterlude('P', 'test'); + update(DT); + if (G.story && G.story.part === 'morph') { + seen.morph = true; + assert.ok(G.interlude, 'nothing serves or spawns through the scene'); + const b = zombies.find(z => z.variant === 'B'); + if (b) { seen.boss = b.art.name; seen.grew = Math.max(seen.grew, b.scale); if (b.x < -6) seen.left = true; } + seen.crowd = Math.max(seen.crowd, zombies.filter(z => z.variant === 'a' || z.variant === 'b').length); + } + } + assert.ok(seen.morph, 'the final ends in the transformation'); + assert.equal(seen.boss, cfg.storyBossArt(1, 'end').name, 'the keeper grows into the groundskeeper'); + assert.ok(seen.left, 'he runs out of the frame'); + assert.ok(seen.crowd >= 3, 'the stands empty onto the court'); + assert.equal(G.story && G.story.part, 'chap', 'game 1 of the chapter'); + assert.ok(!zombies.some(z => z.variant === 'B'), 'the boss is gone until game ten'); + assert.ok(zombies.length >= 3, 'the crowd that came down is the first wave — no cut, no clear'); + assert.ok(!G.interlude, 'play is open'); +}); + +test('THE STORY: the prologue is a living match that turns, and then the first act opens', () => { + restartRun(); + prof.prof.story = { act: 1, cp: 'pro', rack: [], tries: 0 }; + assert.equal(G.mode, 'tennis', 'a real match'); + assert.ok(startStory()); + G.modal = null; G.modalQ.length = 0; + assert.equal(G.story.part, 'pro'); + forceZombies(); // the old turn, exactly as written + for (let i = 0; i < 20 * 60 && !(G.match && G.match.cup); i++) { G.modal = null; G.modalQ.length = 0; update(DT); } + assert.equal(prof.prof.story.cp, 'cup', 'the prologue is behind you'); + 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', () => { prof.prof.lg = 1; intoStory(2, 'chap');