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:
wuwiandClaude Opus 5.5 committed 2026-10-15 14:22:25 +02:00
1 parent ce27bbc48b
commit 789bf41a9a
10 files changed
+191 -34

No files matched your search

+13 -8
View File
@@ -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);