Story phase 4: help when stuck, the arcade opens with the story
- From the third attempt at a checkpoint, the death screen offers TRY AGAIN · WITH HELP, at most two tiers, said openly. In the cup the help is fresher legs and a longer swing; in gore, one more heart and a slower director and horde. Every checkpoint passed resets the offer. - The arcade shows only what the story has opened. Training and Classic open after act I, the Climb after II, Splatter Gore after III, and hosting a friend after IV (joining stays free). Locked doors are shown greyed with the act that opens them. - The non-story endings point at the story instead of the cup. - Store switches for the free tier (act I's card points at the full game) and a premium arcade, both off. - The prologue's HUD no longer overlaps the kill pips. PROJECT.md logs phases 1-4. One new test. v382, staging only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce27bbc48b
commit
789bf41a9a
10 files changed
+191
-34
No files matched your search
+9
-8
@@ -7,7 +7,7 @@
|
||||
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
|
||||
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
|
||||
|
||||
import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel, WEAPONS, STORY, ROMAN, storyAct, storyRounds, storyStock, storyBossArt, storyBossChapter } from './config.js';
|
||||
import { TUNE, UI, bossArt, matchFoe, STYLES, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel, WEAPONS, STORY, ROMAN, storyAct, storyRounds, storyStock, storyBossArt, storyBossChapter, STORY_HELP } from './config.js';
|
||||
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';
|
||||
@@ -1114,9 +1114,10 @@ export function startHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||||
const slow = G.story && G.story.help ? 1 + STORY_HELP.slow * G.story.help : 1; // THE STORY's help offer
|
||||
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
|
||||
// further behind on every set banked (the second horde was where every run ended)
|
||||
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow };
|
||||
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow / slow, mgRate: TUNE.mgRate * grow };
|
||||
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
|
||||
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
|
||||
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
|
||||
@@ -1151,7 +1152,7 @@ export function startStory() {
|
||||
return true;
|
||||
}
|
||||
if (s.cp === 'cup') {
|
||||
G.story = { act: s.act, part: 'cup', base: STORY.base(s.act), fight: null, winT: 0 };
|
||||
G.story = { act: s.act, part: 'cup', base: STORY.base(s.act), fight: null, winT: 0, help: s.help };
|
||||
return startMatch(true, storyRounds(s.act));
|
||||
}
|
||||
return startStoryChapter(s.cp === 'mid');
|
||||
@@ -1163,12 +1164,12 @@ export function startStory() {
|
||||
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 };
|
||||
G.story = { act, part: 'chap', base: STORY.base(act), midDone: fromMid, fight: null, winT: 0, help: s.help };
|
||||
G.league = storyAct(act).lg;
|
||||
G.runGames = G.story.base + (fromMid ? STORY.midAt : 0);
|
||||
score.games = [fromMid ? STORY.midAt : 0, 0]; score.pts = [0, 0]; G.sets = [0, 0]; G.killCount = 0;
|
||||
score.server = 'P';
|
||||
G.hp = G.hpMax = TUNE.playerHp;
|
||||
G.hp = G.hpMax = TUNE.playerHp + STORY_HELP.heart * s.help; // the help offer's heart
|
||||
const stock = fromMid && s.rack.length ? s.rack : storyStock(act);
|
||||
G.rack = []; G.temp = null;
|
||||
for (const e of stock) if (WEAPONS[e.id]) grantWeapon(e.id, e.n);
|
||||
@@ -1215,7 +1216,7 @@ function storyMorph(zb) {
|
||||
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;
|
||||
prof.story.tries = 0; prof.story.help = 0; // a checkpoint passed: the help offer starts over
|
||||
// the ceremony: the loot screen pauses the world, and the timeline starts when it is gone
|
||||
if (storyUI.show) storyUI.show('loot', { act, stock, rounds: d.cup.rounds.length });
|
||||
else showModal({ title: 'THE CUP IS YOURS', color: UI.pick,
|
||||
@@ -1282,7 +1283,7 @@ function storyCheckpoint() {
|
||||
}
|
||||
G.weapon = 0;
|
||||
G.hp = G.hpMax;
|
||||
s.cp = 'mid'; s.tries = 0;
|
||||
s.cp = 'mid'; s.tries = 0; s.help = 0;
|
||||
s.rack = G.rack.map(e => ({ id: e.id, n: e.n }));
|
||||
profSave(); profPush();
|
||||
G.card = { title: 'CHECKPOINT', sub1: 'RESTOCKED · FULL HEALTH', color: UI.pick, t: TUNE.bossWinT,
|
||||
@@ -1297,7 +1298,7 @@ function storyActCleared() {
|
||||
if (act < 5) { s.act = act + 1; s.cp = 'cup'; prof.lg = Math.max(prof.lg, storyAct(act + 1).lg); }
|
||||
else { s.cp = 'done'; prof.ch = 1; }
|
||||
const deaths = s.deaths;
|
||||
s.rack = []; s.tries = 0; s.deaths = 0;
|
||||
s.rack = []; s.tries = 0; s.deaths = 0; s.help = 0;
|
||||
profSave(); profPush();
|
||||
G.story.cleared = act;
|
||||
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
||||
|
||||
@@ -1043,6 +1043,13 @@ export const STORY = {
|
||||
base: act => (act - 1) * 20,
|
||||
};
|
||||
export const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V'];
|
||||
// HELP WHEN STUCK (Oliver: "eine Erleichterung, aber nicht zu easy"): from the 3rd failed attempt at the
|
||||
// same checkpoint the death screen offers one tier, at most two, said openly and declinable. Tennis gets
|
||||
// the peer gift's shape (legs, swing window); gore one more heart and a slower director per tier.
|
||||
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 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 => {
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||
|
||||
import { TUNE, UI, bossArt, STORY, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js';
|
||||
import { TUNE, UI, bossArt, STORY, STORY_HELP, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||||
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||
import { haptic } from './haptics.js';
|
||||
@@ -1456,8 +1456,10 @@ export const rallyFeel = () => !!(G.match || G.classic);
|
||||
// levels down is today's wall, parity gets the full gift, and being underlevelled is never a malus.
|
||||
export const peerK = () => G.match && !G.net && (G.match.cup || G.match.train)
|
||||
? Math.max(0, Math.min(1, 1 - (foeLevel(G.match.opp) - levelAt(prof.xp)) / TUNE.peerGap)) : 0;
|
||||
export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) : 1;
|
||||
export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) : 1;
|
||||
// ...and THE STORY's help offer rides the same two dials (config.STORY_HELP): fresher legs, a longer swing
|
||||
const helpK = k => G.story && G.story.help ? 1 + k * G.story.help : 1;
|
||||
export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) * helpK(STORY_HELP.run) : 1;
|
||||
export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) * helpK(STORY_HELP.window) : 1;
|
||||
// legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never
|
||||
// disagree about what those legs can get to (the rzChaseSpeed rule, player-side).
|
||||
export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist)
|
||||
|
||||
+40
-8
@@ -3,7 +3,7 @@
|
||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
||||
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
||||
|
||||
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER , storyFixture, storyAct , STORY, ROMAN, storyBossArt } from './config.js';
|
||||
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER , storyFixture, storyAct , STORY, ROMAN, storyBossArt , STORY_HELP, STORE } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
|
||||
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs , startStory , storyUI } from './beats.js';
|
||||
@@ -116,14 +116,27 @@ let pickedDiff = remembered('diff') || 'medium';
|
||||
const modeOf = id => MODES.find(x => x.id === id);
|
||||
const modeList = document.getElementById('modeList');
|
||||
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
|
||||
// THE ARCADE OPENS WITH THE STORY (Oliver, 2026-10-15: "nur die bereits über die Hauptstory
|
||||
// freigeschalteten Spielmodi spielbar machen"): a door is open once the act that unlocks it is cleared
|
||||
// (config.STORY acts[].arcade), and everything is open once the story is told. A locked door is shown,
|
||||
// greyed, with the act that opens it — pointed at, never hidden. STORE.arcadePremium is the stub for
|
||||
// making the arcade a premium door; premium() answers true until the unlock IAP exists.
|
||||
const premium = () => true;
|
||||
const arcadeAct = id => STORY.acts.findIndex(a => a.arcade.includes(id)) + 1; // 0 = never gated
|
||||
const arcadeOpen = id => {
|
||||
const n = arcadeAct(id), s = profile.prof.story;
|
||||
return (!n || s.cp === 'done' || s.act > n) && (!STORE.arcadePremium || premium());
|
||||
};
|
||||
const lockLine = id => 'CLEAR ACT ' + ROMAN[arcadeAct(id)] + ' TO OPEN';
|
||||
function buildTops() {
|
||||
modeList.innerHTML = '';
|
||||
for (const { mode } of MENU) {
|
||||
const card = modeOf(mode), b = document.createElement('button');
|
||||
b.className = 'mode';
|
||||
const card = modeOf(mode), b = document.createElement('button'), open = arcadeOpen(mode);
|
||||
b.className = 'mode' + (open ? '' : ' locked');
|
||||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||||
b.innerHTML = `<span class="cb"><b>${card.name}</b></span>`;
|
||||
b.innerHTML = `<span class="cb"><b>${card.name}</b>${open ? '' : `<i>${lockLine(mode)}</i>`}</span>`;
|
||||
b.addEventListener('click', () => {
|
||||
if (!arcadeOpen(mode)) { b.classList.remove('nope'); void b.offsetWidth; b.classList.add('nope'); return; }
|
||||
pickMode(mode);
|
||||
go(mode === 'train' ? 'train' : 'setup');
|
||||
});
|
||||
@@ -161,6 +174,10 @@ const vsName = document.getElementById('vsName'), vsCode = document.getElementBy
|
||||
const vsSay = (id, text) => { document.getElementById(id).textContent = text; };
|
||||
|
||||
function vsOpen(code = '') {
|
||||
// hosting is the arcade door act IV opens; joining a friend's room is always free
|
||||
const host = arcadeOpen('vs');
|
||||
for (const id of ['vsHostC', 'vsHostD']) document.getElementById(id).disabled = !host;
|
||||
document.getElementById('vsHostLock').textContent = host ? '' : lockLine('vs');
|
||||
vsName.value = stats.savedName();
|
||||
vsCode.value = code;
|
||||
vsPick.hidden = false; vsWait.hidden = true;
|
||||
@@ -637,6 +654,11 @@ function refreshSplash() {
|
||||
// THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands
|
||||
const fx = storyFixture(profile.prof.story);
|
||||
document.getElementById('msTitle').textContent = fx.title;
|
||||
// the arcade's gore door says what opens it while it is shut
|
||||
const gOpen = arcadeOpen('gore'), mg = document.getElementById('mGore');
|
||||
mg.classList.toggle('locked', !gOpen);
|
||||
mg.querySelector('i').textContent = gOpen ? 'NO CUP. JUST CARNAGE.' : lockLine('gore');
|
||||
document.getElementById('mCourt').style.display = gOpen ? '' : 'none'; // (its CSS display beats [hidden])
|
||||
document.getElementById('msLine').textContent = fx.line;
|
||||
document.getElementById('mCourt').textContent = 'COURT · ' +
|
||||
(COURTS.find(c => c.id === picked) || COURTS[0]).name;
|
||||
@@ -773,6 +795,9 @@ function storyScreen(kind, d) {
|
||||
<button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button>
|
||||
</div>`;
|
||||
} 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
|
||||
const wall = STORE.freeTier && !premium() && act === 1;
|
||||
const done = act >= 5, next = done ? null : courtOf(act + 1);
|
||||
const opens = storyAct(act).arcade.map(id => ARCADE_NAMES[id]).filter(Boolean);
|
||||
glow = '#3b3216';
|
||||
@@ -789,8 +814,9 @@ function storyScreen(kind, d) {
|
||||
${next ? `<div class="next r" ${delay(7, `background-image:linear-gradient(#0006,#000e),url(${art(next.court.tile)})`)}>
|
||||
<div><div class="lab gold">NEXT · ACT ${ROMAN[act + 1]}</div><div class="mid">${next.court.name}</div></div></div>`
|
||||
: `<div class="mid gold r" ${delay(7)}>THE STORY IS TOLD</div>`}
|
||||
<button class="btn big r" ${delay(9)} data-door="next">${done ? 'THE CLUBHOUSE' : 'CONTINUE'}</button>
|
||||
${done ? '' : `<button class="btn r" ${delay(10)} data-door="club">THE CLUBHOUSE</button>`}
|
||||
${wall ? `<div class="lab gold r" ${delay(8)}>THE STORY CONTINUES IN THE FULL GAME</div>` : ''}
|
||||
<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>`;
|
||||
}
|
||||
sty.style.setProperty('--sty-glow', glow);
|
||||
@@ -826,9 +852,11 @@ document.getElementById('mStart').addEventListener('click', () => {
|
||||
document.getElementById('mOther').addEventListener('click', goMode);
|
||||
document.getElementById('mVs').addEventListener('click', () => vsOpen());
|
||||
document.getElementById('mGore').addEventListener('click', () => {
|
||||
if (!arcadeOpen('gore')) return;
|
||||
pickMode('gore'); start('gore');
|
||||
});
|
||||
document.getElementById('mCourt').addEventListener('click', () => {
|
||||
if (!arcadeOpen('gore')) return;
|
||||
pickMode('gore'); go('setup');
|
||||
});
|
||||
document.getElementById('cdStats').addEventListener('click', openStats);
|
||||
@@ -1132,7 +1160,11 @@ const maybeRestart = e => {
|
||||
const promo = profile.takePromo();
|
||||
if (promo) { postCup(promo); buildPromo(promo, run); go('promo'); return; }
|
||||
// THE STORY: TRY AGAIN / CONTINUE re-enter at the checkpoint; anywhere else is the club
|
||||
if (story) { if (door === 'story') start('story'); else go('splash'); return; }
|
||||
if (story) {
|
||||
if (door === 'help') { profile.prof.story.help = Math.min(STORY_HELP.max, profile.prof.story.help + 1); profile.save(); }
|
||||
if (door === 'story' || door === 'help') start('story'); else go('splash');
|
||||
return;
|
||||
}
|
||||
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
|
||||
// silently barred two of the three modes from ever reaching the prompt
|
||||
// ...on a board that still EXISTS: classic tennis left the world board with the stats streamline
|
||||
@@ -1140,7 +1172,7 @@ const maybeRestart = e => {
|
||||
if (run && run.newKillBest && run.score > 0 && run.mode !== stats.MODE_CLASSIC) { askName(run); return; }
|
||||
// the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN
|
||||
// re-deals the mode that just ended (pickedMode is what started it, cup included)
|
||||
if (door === 'next') { pickMode('cup'); start('cup'); return; }
|
||||
if (door === 'next') { start('story'); return; } // THE STORY is the fixture now
|
||||
if (door === 'again') { start(pickedMode); return; }
|
||||
go('splash');
|
||||
};
|
||||
|
||||
+2
-2
@@ -40,13 +40,13 @@ const cleanCups = (c, cap = 9) => {
|
||||
// hands back, `tries` failed attempts since the checkpoint (the help offer reads it). An ACT is the
|
||||
// unit that is saved; the checkpoints live inside it.
|
||||
const CPS = ['pro', 'cup', 'chap', 'mid', 'done'];
|
||||
export const freshStory = () => ({ act: 1, cp: 'pro', rack: [], tries: 0, deaths: 0 });
|
||||
export const freshStory = () => ({ act: 1, cp: 'pro', rack: [], tries: 0, deaths: 0, help: 0 });
|
||||
export const cleanStory = s => {
|
||||
if (!s || typeof s !== 'object') return freshStory();
|
||||
const rack = Array.isArray(s.rack) ? s.rack.filter(e => e && typeof e.id === 'string' && e.n > 0).slice(0, 9)
|
||||
.map(e => ({ id: e.id.slice(0, 12), n: Math.min(99, e.n | 0) })) : [];
|
||||
return { act: Math.min(5, Math.max(1, s.act | 0 || 1)), cp: CPS.includes(s.cp) ? s.cp : 'cup',
|
||||
rack, tries: Math.max(0, Math.min(99, s.tries | 0)), deaths: Math.max(0, Math.min(999, s.deaths | 0)) };
|
||||
rack, tries: Math.max(0, Math.min(99, s.tries | 0)), deaths: Math.max(0, Math.min(999, s.deaths | 0)), help: Math.max(0, Math.min(2, s.help | 0)) };
|
||||
};
|
||||
|
||||
function load() {
|
||||
|
||||
+13
-8
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName , STORY, ROMAN, storyFixture, storyBossArt } from './config.js';
|
||||
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName , STORY, ROMAN, storyFixture, storyBossArt, STORY_HELP } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
|
||||
import { RAISERS, FRAMES } from './crowd.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
|
||||
@@ -3028,14 +3028,15 @@ export function render() {
|
||||
: 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 === 'pro' ? 'PROLOGUE'
|
||||
: 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);
|
||||
}
|
||||
// ...and NOT during a match: nothing on that court is killed for points, so the pips were both a
|
||||
// lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel.
|
||||
if (splat && !G.match && !G.classic && !benchOn && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
|
||||
if (splat && !G.match && !G.classic && !benchOn && TUNE.killPips && TUNE.killsPerPoint > 1
|
||||
&& !(G.story && G.story.part !== 'chap')) { // the story's prologue and its scene have no kills to count // kills-to-next-point pips, on the panel above
|
||||
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
|
||||
for (let k = 0; k < TUNE.killsPerPoint; k++) {
|
||||
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
|
||||
@@ -3102,7 +3103,7 @@ export function render() {
|
||||
// THE FIXTURE. A won cup parks a ceremony (the promotion) — that IS the handover, so the
|
||||
// summary points at it instead of at the next draw.
|
||||
const promo = hasPromo();
|
||||
const fx = cupFixture(prof.lg, prof.ch);
|
||||
const fx = storyFixture(prof.story); // THE STORY is the main path: every ending points back at it
|
||||
const xd = runDelta(), pr = progress();
|
||||
const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART;
|
||||
|
||||
@@ -3126,7 +3127,11 @@ export function render() {
|
||||
// 2026-09-11). Netplay used to end on THE CLUBHOUSE alone, which meant a fresh room code and
|
||||
// a second trip through the lobby to play the same person again.
|
||||
const online = !promo && !!(G.net && G.net.ready && !G.net.dead);
|
||||
const fxLines = ready ? wrapText(st ? sf.line : promo ? 'THE CLUB WANTS A WORD'
|
||||
// THE HELP OFFER (config.STORY_HELP): from the third attempt at a checkpoint, said openly
|
||||
const help = st && dead && prof.story.tries >= STORY_HELP.offerAt && prof.story.help < STORY_HELP.max;
|
||||
const offer = !help ? '' : cup ? 'THE TRAINER OFFERS FRESHER LEGS AND A LONGER SWING'
|
||||
: 'THE CLUB DOCTOR OFFERS ' + STORY_HELP.heart + ' MORE HEART AND A SLOWER HORDE';
|
||||
const fxLines = ready ? wrapText(help ? offer : st ? sf.line : promo ? 'THE CLUB WANTS A WORD'
|
||||
: online ? 'YOUR FRIEND IS STILL ON THE LINE'
|
||||
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
|
||||
let h = pad + headLines.length * 24 + lineLines.length * 13 + 6;
|
||||
@@ -3210,17 +3215,17 @@ export function render() {
|
||||
const againLabel = G.classic ? 'CLASSIC AGAIN'
|
||||
: G.match && G.match.train ? 'TRAIN AGAIN'
|
||||
: G.match ? 'CLIMB AGAIN' : 'GORE AGAIN';
|
||||
const second = st ? ['club', 'THE CLUBHOUSE']
|
||||
const second = st ? (help ? ['story', 'TRY AGAIN'] : ['club', 'THE CLUBHOUSE'])
|
||||
: solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE']
|
||||
: ['again', againLabel])
|
||||
: online ? ['club', 'THE CLUBHOUSE'] : null;
|
||||
const bw = second && !stack ? Math.round((inner - 8) / 2) : inner;
|
||||
// "THE DRAW" was tennis for the field of entrants — and nothing is drawn any more since the
|
||||
// cup went deterministic (2026-08-29). The button names what it opens instead.
|
||||
const label = st ? (dead ? 'TRY AGAIN' : 'CONTINUE') : promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE CUP'
|
||||
const label = st ? (help ? 'TRY AGAIN · WITH HELP' : dead ? 'TRY AGAIN' : 'CONTINUE') : promo ? 'TAKE THE FLOOR' : solo ? 'THE STORY'
|
||||
: online ? 'REMATCH' : 'THE CLUBHOUSE';
|
||||
drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true);
|
||||
endBtns.push({ id: st ? 'story' : solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN });
|
||||
endBtns.push({ id: st ? (help ? 'help' : 'story') : solo ? 'next' : online ? 'rematch' : 'club', x: inX, y, w: bw, h: MODAL_BTN });
|
||||
if (second) {
|
||||
const sx = stack ? inX : inX + bw + 8, sy = stack ? y + MODAL_BTN + 8 : y;
|
||||
drawButton(sx, sy, bw, MODAL_BTN, second[1], UI.warm, false);
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
// your baseline — one crossing it is a point for THEM.
|
||||
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
|
||||
|
||||
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R, storyPool } from './config.js';
|
||||
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R, storyPool, STORY_HELP } from './config.js';
|
||||
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js';
|
||||
import { haptic } from './haptics.js';
|
||||
@@ -1070,7 +1070,8 @@ export function updateZombies(dt) {
|
||||
if (spawnT <= 0 && zombies.length < cap) {
|
||||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
|
||||
spawnT = G.testMode ? TUNE.testSpawnEvery
|
||||
: Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel));
|
||||
: Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel))
|
||||
* (G.story && G.story.help ? 1 + STORY_HELP.slow * G.story.help : 1); // THE STORY's help offer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user