Story phase 2: the transformation and the prologue
At match point of an act's cup final the beaten finalist does not fall. The ceremony lands (the loot, still a plain card), then the applause is cut, the camera closes in, and on the turn he bursts and the act's boss stands where he stood, growing into the frame with his name and "GAME 10". He runs out of the picture to the left while the stands empty onto the court, and game 1 of the chapter begins without a cut: the crowd that came down is its first wave. The interlude holds serving, spawns and the boss's AI through the scene. The prologue is one game of tennis against a living opponent with the old transformation exactly as written; when it hands over, act I's cup takes the court. Two new tests. v380, staging only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75b450f105
commit
ff63c832cc
6 files changed
+153
-21
No files matched your search
+1
-1
@@ -1140,6 +1140,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=379"></script>
|
||||
<script type="module" src="src/main.js?v=380"></script>
|
||||
</body>
|
||||
</html>
|
||||
+102
-17
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user