THE SEASON, phases 1-4: the front door is the fixture, and the club remembers
The tournament is the canonical path, so the game finally says so. The splash's primary button names the next cup fixture and enters the draw; the poster wall moved one tap down and deals three named rooms (the season, the training, out of competition) with the cup at the top. Two profile fields remember every attempt — rounds ever reached per league, and the body that put you out — so a lost cup is a line on the board instead of a null event, and the training court offers that body first, marked. The promotion names what waits in the new league. Overlays consolidated in the same pass: three modal drawers became one box with a masthead, hairline-separated rows and a real footer button (any tap still dismisses), and the run-end summary wears the same box — headline, xp, mark, and the fixture with ENTER THE DRAW beside PLAY AGAIN. The cup's own exit is a ceremony now: OUT IN THE QUARTER-FINAL, your best standing beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
063b01d2a5
commit
5c7521f867
10 files changed
+603
-204
No files matched your search
+67
-1
@@ -491,7 +491,73 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
|
||||
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
|
||||
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
|
||||
|
||||
## THE SEASON — the tournament is the story (2026-08-31, AGREED in discussion; NOT BUILT YET)
|
||||
## Session log: THE SEASON, PHASES 1-4 — the front door, the record, the rooms, the season beat (2026-08-31, STAGING ONLY)
|
||||
|
||||
All four phases of the plan below, plus the overlay consolidation Oliver asked for in the same
|
||||
breath ("die modals haben manchmal 2 boxen manchmal eine manchmal keine... der tap wird zu einem
|
||||
echten button im footer der box... zudem noch das run-ende, hier bitte auch mehr struktur").
|
||||
|
||||
**THE MODAL IS ONE BOX** (`render.drawModal`). Three drawers — the plain announcement, the rules
|
||||
card, the walk-on — became one renderer with slots: a MASTHEAD (art beside the title, with the
|
||||
body's strap under it), a stack of ROWS separated by hairlines (statement over consequence, the
|
||||
split figure of his level against yours, the blood pool as pips), and a FOOTER BUTTON. The payload
|
||||
is untouched: `title/body/icon/rules/foe` are the same fields beats.js has always sent, so the
|
||||
suite and every call site are unaffected — this is a layout. Two consequences worth knowing:
|
||||
- The entrance is now ONE object flying in from the right. The old per-element stagger would tear
|
||||
a box apart, and a box that assembles itself out of flying pieces is not a box.
|
||||
- The button says what HAPPENS (`PLAY ON` / `GOT IT` / `WALK ON`, `m.btn` overrides) instead of
|
||||
what gesture to make, and it appears only once the box has landed. **A tap anywhere still
|
||||
dismisses** — the button is the affordance, not a hit target. A modal you could only leave
|
||||
through one 26px strip would be a worse wall than the one we had.
|
||||
|
||||
**THE RUN-END WEARS THE SAME BOX, AND HANDS OVER.** Headline and the run's line, then the xp it
|
||||
paid, then the mark, then THE FIXTURE with its buttons — `ENTER THE DRAW` beside `PLAY AGAIN`
|
||||
(or `THE CLUBHOUSE` when the run just played was the cup). `render.endHit(nx, ny)` gives the
|
||||
rects back to `main.maybeRestart`, which is where the tap already lived; a tap anywhere else is
|
||||
the old behaviour exactly, so nothing that worked before stopped working. Netplay gets ONE door
|
||||
(the clubhouse): dealing a solo cup into a session whose socket is still tearing down is not a
|
||||
handover, it is a bug waiting for a phone verdict.
|
||||
|
||||
**THE CUP'S OWN EXIT IS A CEREMONY.** `OUT IN THE QUARTER-FINAL.` over `YOUR BEST STANDS: THE
|
||||
SEMI-FINAL` (or `THE BOARD NOTES IT` when the attempt IS the new record). The board's felled count
|
||||
is suppressed there — two bests answering different questions in one box was the wall of text this
|
||||
pass exists to remove.
|
||||
|
||||
**THE RECORD** (`profile.cups` / `profile.out`, `recordCup`, `cupBest`). Two small fields: rounds
|
||||
ever reached per league, and the rung of the body that ended the last attempt. Written by the two
|
||||
places a cup run can end (`zombies.killPlayer`, `beats.cupWon`), mirrored server-side beside the
|
||||
xp (`server.clean_profile` re-derives them like every other field), merged FORWARD on pull. Shown
|
||||
on the plaque (which stopped repeating "win the cup to advance" — the fixture button below says
|
||||
that), in the league panel, and in the summary. Training reps write nothing: a rep is not an
|
||||
attempt.
|
||||
|
||||
**THE FRONT DOOR IS THE FIXTURE.** `PLAY MATCH` became `LEAGUE 3 · THE CUP / 4 ROUNDS · ROUND ONE:
|
||||
THE STRAWBERRY SELLER` and enters the draw directly — the cup has no court to pick and no opponent
|
||||
to choose, so the setup screen there was a confirmation page with nothing on it to confirm. The
|
||||
poster wall moved one tap down behind `OTHER GAMES`. `config.cupFixture` is the single descriptor
|
||||
all three screens read, so the splash, the summary and the promotion cannot drift apart on its
|
||||
wording.
|
||||
|
||||
**THE ROOMS.** The wall deals three named groups now — THE SEASON (the cup, alone), THE TRAINING
|
||||
(the climb, the training court), OUT OF COMPETITION (gore, classic, a friend) — and the cup left
|
||||
the ZOMBIE TENNIS pocket for the top. That also emptied the second step of everything except
|
||||
CLASSIC's difficulty chips. `MENU` rows with a `group` key are headings, not posters.
|
||||
|
||||
**THE REVANCHE.** The training list puts the body that knocked you out of the cup FIRST, in a red
|
||||
tile that says `PUT YOU OUT OF THE CUP`. One remembered rung turns a shelf of eighteen equal tiles
|
||||
into the obvious answer to the last defeat — the cheapest coupling of Beiwerk to canon we have.
|
||||
|
||||
**THE SEASON BEAT** rides inside the promotion (no new screen): `NEXT: LEAGUE 4 · THE CUP` over
|
||||
its rounds and its first body — or, for the champion, the title he now has to hold.
|
||||
|
||||
Tests: two added (127 total) — the fixture descriptor against every league (including the
|
||||
champion's defence), and the record's forward-only write with the eliminator it names. `freshSplatter`
|
||||
resets `cups`/`out` for the same reason it resets the league: a cup ending writes them for real.
|
||||
|
||||
Open for the phone: whether the fixture button should ever go to the setup screen (it does not
|
||||
now), and whether the promotion's WALK ON should enter the draw instead of the clubhouse.
|
||||
|
||||
## THE SEASON — the tournament is the story (2026-08-31, AGREED in discussion; PHASES 1-4 BUILT ON STAGING — see the log above)
|
||||
|
||||
Oliver's diagnosis, and it is right: "alles ist irgendwie beliebig... die Menüführung und die Start-
|
||||
und Endscreens enforcen keine Story-Line". The cause is not a missing story — THE LEAGUES already
|
||||
|
||||
+36
-2
@@ -237,6 +237,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
hierarchy is carried by colour rather than by art. */
|
||||
.btn.big { padding: 13px 10px 11px; background: var(--red); border-color: #69201c; color: #f6e7dc;
|
||||
font: 400 26px Bangers, cursive; letter-spacing: 2px; line-height: 1.15; }
|
||||
/* THE FIXTURE BUTTON (THE SEASON): the shout stays the shout, and the fixture itself is the
|
||||
quiet line under it — the same two-register rule the cards follow (Bangers announces, Silkscreen
|
||||
informs). `b` is not bold here, it IS the announcement face. */
|
||||
.btn.big b { display: block; font: inherit; }
|
||||
.btn.big i { display: block; margin-top: 3px; font: 400 9px Silkscreen, monospace;
|
||||
font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; }
|
||||
.two { display: flex; gap: 8px; }
|
||||
.two .btn { margin-bottom: 0; }
|
||||
.btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace;
|
||||
@@ -413,11 +419,21 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
text-align: center; line-height: 1.35; }
|
||||
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
|
||||
letter-spacing: 1px; }
|
||||
/* THE REVANCHE TILE (THE SEASON): the one that put you out of the cup, marked and standing
|
||||
first. Red border and a red line, because it is the only tile on this screen with a reason. */
|
||||
#trainList button.put { border-color: var(--red); }
|
||||
#trainList button.put i { color: #e08a7c; }
|
||||
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
|
||||
draw and the walk-on wear), which reads at a third of the boss portrait's height */
|
||||
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
|
||||
/* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */
|
||||
.btn[disabled] { opacity: 0.32; cursor: default; }
|
||||
/* the fixture line under the new court: the same two registers the plaque uses — the name in
|
||||
the league's own acid, what it costs you in the quiet face */
|
||||
#prNext { margin: 12px 0 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
|
||||
letter-spacing: 1px; line-height: 1.6; }
|
||||
#prNext u { display: block; margin-top: 3px; font-size: 9px; color: var(--dim);
|
||||
text-decoration: none; letter-spacing: 0.5px; }
|
||||
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
|
||||
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
|
||||
/* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one
|
||||
@@ -439,6 +455,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
at cream-on-crowd was the report from the first live look */
|
||||
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0;
|
||||
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; }
|
||||
/* THE GROUP HEADINGS (THE SEASON): what the posters under them are FOR — one path up, a room to
|
||||
practise in, a room where nothing counts. Deliberately quiet: the posters are the loud objects
|
||||
on this screen, and a heading competing with them would just be a fourth poster. */
|
||||
#modeList .mgroup { margin: 16px 0 7px; }
|
||||
#modeList .mgroup:first-child { margin-top: 2px; }
|
||||
#modeList .mgroup b { display: block; font: 400 12px Silkscreen, monospace; letter-spacing: 2px;
|
||||
color: var(--acid); text-shadow: none; }
|
||||
#modeList .mgroup i { display: block; margin: 3px 0 0; font: 400 9px Silkscreen, monospace;
|
||||
font-style: normal; font-size: 9px; color: var(--dim); text-shadow: none; }
|
||||
/* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are
|
||||
full-height posters centred on their scene, and the difficulty chips are plain acid-outline
|
||||
buttons stacked tall — art for EASY/MEDIUM/HARD would be forced, and the chip reads faster. */
|
||||
@@ -632,7 +657,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
you can press before the sheets have landed is how you reach the stats screen with half
|
||||
the zombies missing. Swapped by #splash.busy, see main.js. -->
|
||||
<div id="boot"><s><i></i></s><u>LOADING</u></div>
|
||||
<button class="btn big" id="mStart">PLAY MATCH</button>
|
||||
<!-- THE FRONT DOOR IS THE FIXTURE (2026-08-31, THE SEASON). The primary button used to say
|
||||
PLAY MATCH and open a wall of posters, which asked the player a question the game
|
||||
already knows the answer to. It names the next cup fixture now and enters the draw;
|
||||
everything else lives one tap down, behind OTHER GAMES. -->
|
||||
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
|
||||
<button class="btn sec" id="mOther">OTHER GAMES<i>CLIMB · TRAINING · GORE · A FRIEND</i></button>
|
||||
<div class="two">
|
||||
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
|
||||
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button>
|
||||
@@ -730,6 +760,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
<p id="prLeague"></p>
|
||||
<img id="prCourt" alt="">
|
||||
<p id="prCourtName"></p>
|
||||
<!-- THE SEASON'S SECOND BEAT (2026-08-31): the promotion is also the season opener, so it
|
||||
names the fixture waiting in the new league rather than ending on a court tile. No
|
||||
extra screen — a tap between the player and the court is what we were avoiding. -->
|
||||
<p id="prNext"></p>
|
||||
</div>
|
||||
<div class="foot bare">
|
||||
<button class="btn big" id="prGo">WALK ON</button>
|
||||
@@ -871,6 +905,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=340"></script>
|
||||
<script type="module" src="src/main.js?v=341"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -74,12 +74,26 @@ def clean_profile(payload):
|
||||
return None
|
||||
p = payload['p']
|
||||
word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n]
|
||||
# THE CUP RECORD (THE SEASON): league -> rounds ever reached. Re-derived key by key like every
|
||||
# other field — five small ints, nothing else survives the trip.
|
||||
cups = {}
|
||||
raw = p.get('cups')
|
||||
if isinstance(raw, dict):
|
||||
for lg in range(1, 6):
|
||||
try:
|
||||
v = int(raw.get(str(lg), raw.get(lg, 0)))
|
||||
except (TypeError, ValueError):
|
||||
v = 0
|
||||
if v > 0:
|
||||
cups[str(lg)] = min(9, v)
|
||||
try:
|
||||
out = {'v': 1,
|
||||
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
|
||||
'lg': max(1, min(int(p.get('lg', 1)), 5)),
|
||||
'ch': 1 if p.get('ch') else 0,
|
||||
'setup': 1 if p.get('setup') else 0,
|
||||
'cups': cups,
|
||||
'out': max(0, min(int(p.get('out', 0)), 99)),
|
||||
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
|
||||
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
|
||||
except (TypeError, ValueError):
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, foeLevel } from './config.js';
|
||||
import { endRun, MODE_MATCH } from './stats.js';
|
||||
import { award, prof, save as profSave, push as profPush, setPromo, levelAt } from './profile.js';
|
||||
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup } from './profile.js';
|
||||
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
|
||||
@@ -928,6 +928,7 @@ function cupWon() {
|
||||
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true }); }
|
||||
profSave(); profPush();
|
||||
}
|
||||
if (!d.train) recordCup(G.league, d.cup.rounds.length); // the full record: you took the final
|
||||
const games = score.games[0] + score.games[1];
|
||||
const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH);
|
||||
G.best = b; G.newBest = b.newBest;
|
||||
|
||||
+36
-5
@@ -846,13 +846,25 @@ export const MODES = [
|
||||
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two
|
||||
// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES
|
||||
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
|
||||
// THE ROOMS, NOT A SHELF (2026-08-31, THE SEASON). The wall used to deal five posters as equals
|
||||
// with the career hidden two taps inside ZOMBIE TENNIS, so nothing on screen said what any of them
|
||||
// was FOR — Oliver: "alles ist irgendwie beliebig". Three named groups answer that before a single
|
||||
// caption is read: one path up, a room to practise in, and a room where nothing counts. A `group`
|
||||
// row is a heading (main.buildTops), not a poster.
|
||||
//
|
||||
// The cup left the ZOMBIE TENNIS pocket for the top of the wall — it is the canonical path, and a
|
||||
// canonical path does not live behind a fan-out. Its two former siblings (the climb, the training
|
||||
// court) stand on their own in the training group, which also cost the second step its only other
|
||||
// job: CLASSIC's difficulty chips are all that still fan out.
|
||||
export const MENU = [
|
||||
{ mode: 'classic', diffs: true },
|
||||
// the WAY-UP ribbon shows on THIS level too, so the click path to the promotion is marked
|
||||
// from the first screen of the picker (main.banner reads `tag`)
|
||||
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'],
|
||||
tag: 'THE WAY UP' },
|
||||
{ group: 'THE SEASON', sub: 'THE ONLY WAY UP THE CLUB' },
|
||||
{ mode: 'cup' },
|
||||
{ group: 'THE TRAINING', sub: 'SHARPEN UP · NOTHING HERE MOVES YOUR LEAGUE' },
|
||||
{ mode: 'zombie' },
|
||||
{ mode: 'train' },
|
||||
{ group: 'OUT OF COMPETITION', sub: 'NO LEAGUE, NO RECORD, NO STAKES' },
|
||||
{ mode: 'gore' },
|
||||
{ mode: 'classic', diffs: true },
|
||||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||||
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
|
||||
{ name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true },
|
||||
@@ -941,6 +953,25 @@ export const cupRoundsFor = lg => {
|
||||
return rounds.concat([{ name: 'THE FINAL', band: [hi], need: 2 }]);
|
||||
};
|
||||
|
||||
// THE SEASON (2026-08-31, PROJECT.md "THE SEASON"): what the club has you down for next. The
|
||||
// tournament is the canonical path and every screen that points at it — the splash's front door,
|
||||
// the run-end handover, the promotion's second beat — says the SAME sentence, because three
|
||||
// screens drifting apart on its wording is exactly how the story stopped reading as one. Pure
|
||||
// descriptor: it starts nothing, it only names the fixture.
|
||||
export const cupFixture = (lg, champion = 0) => {
|
||||
const rounds = cupRoundsFor(lg), first = rounds[0], foe = matchFoe(first.band[0]);
|
||||
const held = champion && lg >= LEAGUES.length; // the champion has nothing above him left
|
||||
return { lg, league: leagueOf(lg), rounds, rung: first.band[0], foe,
|
||||
title: held ? 'DEFEND THE TITLE' : 'LEAGUE ' + lg + ' · THE CUP',
|
||||
line: rounds.length + ' ROUNDS · ' + first.name + ': ' + foe.name };
|
||||
};
|
||||
|
||||
// How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL.
|
||||
export const cupRoundName = (lg, reached) => {
|
||||
const rounds = cupRoundsFor(lg);
|
||||
return reached >= rounds.length + 1 ? 'WON' : (rounds[reached - 1] || rounds[0]).name;
|
||||
};
|
||||
|
||||
export const TOURS = [
|
||||
// `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the
|
||||
// same operation cannot serve both.
|
||||
|
||||
+73
-14
@@ -3,12 +3,12 @@
|
||||
// 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, matchFoe, LADDER } 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 } 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 } from './beats.js';
|
||||
import { netConnect, netHooks } from './net.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } from './render.js';
|
||||
import { voice, ambient, menuMusic, menuDuck } 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';
|
||||
@@ -146,6 +146,14 @@ function buildSub(top) {
|
||||
function buildTops() {
|
||||
modeList.innerHTML = '';
|
||||
for (const t of MENU) {
|
||||
// a GROUP row is the heading over the posters under it — what that shelf is for (config MENU)
|
||||
if (t.group) {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'mgroup';
|
||||
h.innerHTML = `<b>${t.group}</b><i>${t.sub}</i>`;
|
||||
modeList.appendChild(h);
|
||||
continue;
|
||||
}
|
||||
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
|
||||
b.addEventListener('click', () => {
|
||||
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
|
||||
@@ -342,9 +350,15 @@ function buildLeaguePanel() {
|
||||
foes.appendChild(d);
|
||||
}
|
||||
const size = lg.band[1] - lg.band[0] + 1;
|
||||
document.getElementById('lgSub').textContent = felled >= size
|
||||
? 'ALL ' + size + ' FELLED — TAKE THE CUP'
|
||||
: felled + ' OF ' + size + ' FELLED · THE CUP IS THE WAY UP';
|
||||
// ...and THE RECORD (THE SEASON): how far this league's cup has ever let you get. It is the one
|
||||
// line that makes a lost attempt count for something, so it takes the second half of the sub
|
||||
// whenever there is one to show.
|
||||
const best = profile.cupBest(lg.id);
|
||||
document.getElementById('lgSub').innerHTML = (felled >= size
|
||||
? 'ALL ' + size + ' FELLED'
|
||||
: felled + ' OF ' + size + ' FELLED')
|
||||
+ (best ? '<br>BEST IN THE CUP: ' + cupRoundName(lg.id, best)
|
||||
: '<br>THE CUP IS THE WAY UP — NEVER ENTERED');
|
||||
}
|
||||
|
||||
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
|
||||
@@ -490,9 +504,19 @@ const trainList = document.getElementById('trainList');
|
||||
function buildTrain() {
|
||||
trainList.innerHTML = '';
|
||||
const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]);
|
||||
for (let n = 1; n <= top; n++) {
|
||||
// THE REVANCHE (THE SEASON): the body that ended your last cup attempt is marked and sits FIRST
|
||||
// in its own right — a practice list where every rung looks the same is a shelf, and this one
|
||||
// room in the game has an obvious reason to exist the moment you have just been put out.
|
||||
const out = profile.prof.out;
|
||||
const rungs = [];
|
||||
for (let n = 1; n <= top; n++) rungs.push(n);
|
||||
if (out >= 1 && out <= top) rungs.splice(rungs.indexOf(out), 1), rungs.unshift(out);
|
||||
for (const n of rungs) {
|
||||
const f = matchFoe(n), b = document.createElement('button');
|
||||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>No. ${n}</i>`;
|
||||
const put = n === out;
|
||||
if (put) b.className = 'put';
|
||||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
|
||||
+ `<i>${put ? 'PUT YOU OUT OF THE CUP' : 'No. ' + n}</i>`;
|
||||
b.addEventListener('click', () => { trainRung = n; start('train'); });
|
||||
trainList.appendChild(b);
|
||||
}
|
||||
@@ -605,6 +629,12 @@ function buildPromo(pr, run) {
|
||||
? 'THE HONOURS BOARD REMEMBERS YOUR NAME'
|
||||
: `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`;
|
||||
document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON';
|
||||
// ...and what the new league has you down for. The champion has nothing above him, so his beat
|
||||
// is the title he now has to hold instead of a fixture he has to win.
|
||||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||
document.getElementById('prNext').innerHTML = champion
|
||||
? `THE TITLE IS YOURS TO HOLD<u>EVERY CUP FROM HERE IS A DEFENCE</u>`
|
||||
: `NEXT: ${fx.title}<u>${fx.line}</u>`;
|
||||
}
|
||||
document.getElementById('prGo').addEventListener('click', () => {
|
||||
const run = promoRun; promoRun = null;
|
||||
@@ -657,11 +687,21 @@ function refreshSplash() {
|
||||
document.getElementById('plFrom').textContent = pr.level;
|
||||
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
|
||||
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
|
||||
// ...and the goal line carries THE RECORD now (THE SEASON): the fixture button below already
|
||||
// says what to do, so repeating "win the cup to advance" over it was the same sentence twice.
|
||||
// What the plaque can say that nothing else does is how far you have ever got.
|
||||
const bestR = profile.cupBest(profile.prof.lg);
|
||||
document.getElementById('plGoal').textContent = profile.prof.ch
|
||||
? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME'
|
||||
: profile.prof.lg >= LEAGUES.length
|
||||
? 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP — BECOME CHAMPION'
|
||||
: 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP TO ADVANCE';
|
||||
: bestR
|
||||
? 'THIS CUP · BEST SO FAR: ' + cupRoundName(profile.prof.lg, bestR)
|
||||
: 'THE CUP IS THE WAY UP — NEVER ENTERED';
|
||||
// THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use
|
||||
// (config.cupFixture). The record rides the sub line when there is one — a lost cup is the only
|
||||
// thing the club has to say about your last attempt, and it belongs where the next one starts.
|
||||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||
document.getElementById('msTitle').textContent = fx.title;
|
||||
document.getElementById('msLine').textContent = fx.line;
|
||||
summary();
|
||||
}
|
||||
|
||||
@@ -710,7 +750,14 @@ const goMode = () => {
|
||||
if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
|
||||
buildTops(); go('mode');
|
||||
};
|
||||
document.getElementById('mStart').addEventListener('click', goMode);
|
||||
// the fixture button IS the tournament — it skips the setup screen on purpose: the cup has no
|
||||
// court to pick (it plays the league's own) and no opponent to choose, so that screen was a
|
||||
// confirmation page with nothing on it to confirm
|
||||
document.getElementById('mStart').addEventListener('click', () => {
|
||||
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
|
||||
pickMode('cup'); summary(); start('cup');
|
||||
});
|
||||
document.getElementById('mOther').addEventListener('click', goMode);
|
||||
document.getElementById('mStats').addEventListener('click', openStats);
|
||||
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
|
||||
// whose names you came to look at
|
||||
@@ -997,10 +1044,18 @@ addEventListener('pagehide', () => {
|
||||
// NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so
|
||||
// the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the
|
||||
// bed that has been playing under the death summary simply carries on into the title screen.
|
||||
const maybeRestart = () => {
|
||||
const maybeRestart = e => {
|
||||
// ...and the classic ending taps out the same way, on its own shorter clock (G.done, no corpse)
|
||||
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
|
||||
if (G.modal || !ready) return;
|
||||
// WHICH DOOR (THE SEASON: "das ende reicht weiter"). The summary lays its own buttons out as it
|
||||
// draws — it measures its box, so only it knows where they landed — and hands the rects back
|
||||
// through endHit. A tap anywhere ELSE is the old behaviour exactly: back to the club.
|
||||
let door = null;
|
||||
if (e && e.clientX !== undefined) {
|
||||
const r = cv.getBoundingClientRect();
|
||||
door = endHit((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
|
||||
}
|
||||
restartRun();
|
||||
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
|
||||
refreshSplash(); // the run just ended: the splash count has moved
|
||||
@@ -1015,8 +1070,12 @@ const maybeRestart = () => {
|
||||
if (promo) { buildPromo(promo, run); go('promo'); 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
|
||||
if (run && run.newKillBest && run.score > 0) askName(run);
|
||||
else go('splash');
|
||||
if (run && run.newKillBest && run.score > 0) { 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'); summary(); start('cup'); return; }
|
||||
if (door === 'again') { start(pickedMode); return; }
|
||||
go('splash');
|
||||
};
|
||||
cv.addEventListener('pointerdown', maybeRestart);
|
||||
addEventListener('keydown', maybeRestart);
|
||||
|
||||
+38
-2
@@ -21,15 +21,29 @@ import { myId, savedName, saveName } from './stats.js';
|
||||
|
||||
const KEY = 'splatterProfile';
|
||||
|
||||
// THE CLUB REMEMBERS THE ATTEMPTS (2026-08-31, THE SEASON). Two small fields carry the whole
|
||||
// record: `cups` is league -> how many rounds you have ever reached in its cup (1 = round one,
|
||||
// rounds.length = you took the final), and `out` is the rung of the body that put you out last.
|
||||
// That is what turns a lost cup from a null event into a line on the board — and what lets the
|
||||
// training court offer the one that beat you. Bounded on load like everything else that comes
|
||||
// back off a device.
|
||||
const cleanCups = c => {
|
||||
const out = {};
|
||||
if (c && typeof c === 'object')
|
||||
for (let l = 1; l <= 5; l++) { const v = c[l] | 0; if (v > 0) out[l] = Math.min(9, v); }
|
||||
return out;
|
||||
};
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(KEY) || 'null');
|
||||
if (p && typeof p === 'object') {
|
||||
return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)),
|
||||
ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0 };
|
||||
ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0,
|
||||
cups: cleanCups(p.cups), out: Math.max(0, Math.min(99, p.out | 0)) };
|
||||
}
|
||||
} catch {}
|
||||
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0 };
|
||||
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, out: 0 };
|
||||
}
|
||||
|
||||
export const prof = load();
|
||||
@@ -111,6 +125,19 @@ export function bonus() {
|
||||
// quality is centre-normalised, so the same ms error scores higher
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the cup record ----
|
||||
// How far you have ever got in a league's cup, and who ended the last attempt. Written by the two
|
||||
// places a cup run can end (zombies.killPlayer, beats.cupWon) and read by the summary, the league
|
||||
// panel and the training court. A record only ever moves FORWARD — an early exit after a deep run
|
||||
// does not erase the deep run, it just is not news.
|
||||
export const cupBest = (lg = prof.lg) => prof.cups[lg] | 0;
|
||||
export function recordCup(lg, reached, rung = 0) {
|
||||
if (!(lg >= 1 && lg <= 5) || reached < 1) return;
|
||||
if (reached > cupBest(lg)) prof.cups[lg] = Math.min(9, reached);
|
||||
if (rung) prof.out = rung; // the LAST one to put you out, deep run or not
|
||||
save();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the promotion ----
|
||||
// A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart
|
||||
// consumes once the summary is tapped away). Module-local and consumed-on-read, the same shape as
|
||||
@@ -118,6 +145,9 @@ export function bonus() {
|
||||
let promo = null;
|
||||
export const setPromo = p => { promo = p; };
|
||||
export function takePromo() { const p = promo; promo = null; return p; }
|
||||
// ...and a PEEK, for the summary: a won cup parks a ceremony, and the handover has to point at
|
||||
// that instead of at the next draw. Never consumes — main.maybeRestart is the only consumer.
|
||||
export const hasPromo = () => !!promo;
|
||||
|
||||
// ---------------------------------------------------------------- the mirror ----
|
||||
// POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server
|
||||
@@ -149,6 +179,12 @@ export async function pull() {
|
||||
prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1));
|
||||
prof.ch = prof.ch || (p.ch ? 1 : 0);
|
||||
prof.setup = prof.setup || (p.setup ? 1 : 0);
|
||||
// the cup record comes back with the xp it was earned beside — merged forward, never down
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
const v = p.cups && p.cups[l] | 0;
|
||||
if (v > cupBest(l)) prof.cups[l] = Math.min(9, v);
|
||||
}
|
||||
prof.out = prof.out || Math.max(0, Math.min(99, p.out | 0));
|
||||
runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta
|
||||
save();
|
||||
}
|
||||
|
||||
+278
-178
@@ -1,13 +1,13 @@
|
||||
// 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 } 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 } 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';
|
||||
import { drillQueue, drillLegal, drillMult } from './drill.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
||||
import { runDelta, progress } from './profile.js';
|
||||
import { runDelta, progress, prof, cupBest, hasPromo } from './profile.js';
|
||||
|
||||
// ---------------------------------------------------------------- palette ----
|
||||
// tennis palette -> drained splatter palette; the transition lerps between them.
|
||||
@@ -2341,155 +2341,179 @@ function drawFlickHint() {
|
||||
const BACK_C = 1.70158;
|
||||
const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2;
|
||||
|
||||
// THE RULES CARD (2026-08-29, Oliver: the mode modals were walls of text too). Each rule is its
|
||||
// own boxed row — the statement in cream over its consequence in the modal's colour — and a rule
|
||||
// about BLOOD shows the pool as the game's own pips instead of a naked number. Driven by
|
||||
// m.rules: [{ lead, sub, pips }]; title/body keep carrying the words for the suite.
|
||||
function drawRulesModal(m, t, dx) {
|
||||
const boxW = Math.min(W - 28, 240), bx = Math.round(W / 2 - boxW / 2);
|
||||
const rowH = r => (r.sub ? 34 : 22) + (r.pips ? 12 : 0);
|
||||
const gap = 8;
|
||||
const blockH = 30 + m.rules.reduce((a, r) => a + rowH(r) + gap, 0) - gap;
|
||||
let y = Math.round(H / 2 - blockH / 2);
|
||||
const hx = dx(0, 1);
|
||||
ctx.font = B(24); ctx.fillStyle = m.color || UI.warm;
|
||||
if (hx) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) {
|
||||
ctx.save(); ctx.globalAlpha = a;
|
||||
ctx.fillText(m.title, W / 2 + Math.round(hx * (1 + f)), y + 8);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.fillText(m.title, W / 2 + hx, y + 8);
|
||||
y += 30;
|
||||
m.rules.forEach((r, i) => {
|
||||
const h = rowH(r), rx = dx(i + 1, -1);
|
||||
panel(bx + rx, y, boxW, h, 3, 'rgba(8,26,18,0.85)', 'rgba(120,150,130,0.4)');
|
||||
ctx.font = F(8, 700);
|
||||
shadowText(r.lead, W / 2 + rx, y + 12, UI.cream);
|
||||
// the consequence line: DIM for information, the modal's hot colour only when it threatens
|
||||
if (r.sub) { ctx.font = F(8, 0); shadowText(r.sub, W / 2 + rx, y + 25, r.hot ? (m.color || UI.warm) : UI.dim); }
|
||||
if (r.pips) { // the pool, in the HUD's own pip language
|
||||
const pg = 9, pw = (r.pips - 1) * pg;
|
||||
for (let k = 0; k < r.pips; k++) {
|
||||
ctx.beginPath(); ctx.arc(W / 2 + rx - pw / 2 + k * pg, y + h - 9, 2.6, 0, 7);
|
||||
ctx.fillStyle = '#8a1a12'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
// THE MODAL IS ONE BOX (2026-08-31, Oliver: "manchmal 2 boxen, manchmal eine, manchmal keine —
|
||||
// vielleicht immer in einer box, und der tap / press any key wird zu einem echten button im
|
||||
// footer der box"). Three drawers had grown here — the plain announcement, the rules card, the
|
||||
// walk-on — each with its own geometry, its own alignment and its own idea of what a box was.
|
||||
// They are ONE renderer now, with slots:
|
||||
//
|
||||
// THE MASTHEAD art (an icon or the body's mugshot) beside the title, with an optional strap
|
||||
// THE ROWS a statement over its consequence · a split figure (his level against yours)
|
||||
// · a blood pool drawn as the HUD's own pips — hairlines between them
|
||||
// THE FOOTER a real button. The prompt used to be a blinking line of text that said what
|
||||
// gesture to make; it says what HAPPENS now, and looks pressable.
|
||||
//
|
||||
// The PAYLOAD is unchanged — title/body/icon/rules/foe are the same fields beats.js has always
|
||||
// sent and the suite still reads. This is a layout, not new state. Any tap still dismisses (the
|
||||
// button is the affordance, not a hit target — a modal you can only leave through one 26px strip
|
||||
// would be a worse wall than the one we had).
|
||||
const MODAL_PAD = 12, MODAL_GAP = 9, MODAL_BTN = 26;
|
||||
const MODAL_FILL = 'rgba(8,22,16,0.93)', MODAL_EDGE = 'rgba(120,150,130,0.45)';
|
||||
const MODAL_RULE = 'rgba(120,150,130,0.22)';
|
||||
|
||||
// One row of the stack, measured. `inner` is the text width inside the box.
|
||||
function modalRows(m, inner) {
|
||||
const rows = [];
|
||||
const push = r => { rows.push(r); return r; };
|
||||
if (m.foe) {
|
||||
push({ kind: 'split', h: 32 });
|
||||
const sub = [];
|
||||
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
|
||||
if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
|
||||
} else if (m.rules) {
|
||||
for (const r of m.rules) {
|
||||
const lead = wrapText(r.lead, F(8, 700), inner);
|
||||
const sub = r.sub ? wrapText(r.sub, F(8, 0), inner) : [];
|
||||
push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips,
|
||||
h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 });
|
||||
}
|
||||
y += h + gap;
|
||||
});
|
||||
if (t >= 1 && Math.floor(G.idleT * 2) % 2) {
|
||||
ctx.font = F(8);
|
||||
shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2,
|
||||
Math.round(H / 2 + blockH / 2 + 24), UI.warm);
|
||||
} else {
|
||||
const lines = [];
|
||||
for (const l of m.body || []) {
|
||||
if (l === '') { lines.push(''); continue; } // the weapon test's deliberate blank
|
||||
lines.push(...wrapText(l, F(8, 0), inner));
|
||||
}
|
||||
if (lines.length) push({ kind: 'text', lines, h: lines.length * 12 + 2 });
|
||||
}
|
||||
ctx.textBaseline = 'top';
|
||||
return rows;
|
||||
}
|
||||
|
||||
// THE WALK-ON'S OWN FACE (2026-08-29, Oliver: the opponent card was "a wall of text" — six kinds
|
||||
// of information in one line stack). Designated zones instead: the MASTHEAD (mugshot beside the
|
||||
// name with the slogan under it), THE MEASURE (its level boxed against yours — the number wears
|
||||
// the modal's hot red while the body outranks you, the pick green once it does not), and the
|
||||
// round/lesson strip. Same machinery as every modal otherwise: the entrance lanes, the frozen
|
||||
// court, the blinking tap (drawModal below). m.title/m.body still carry the same words for the
|
||||
// suite and any legacy reader — this is a LAYOUT, not new state.
|
||||
function drawFoeModal(m, t, dx) {
|
||||
const f = m.foe, img = f.head && SPR[f.head];
|
||||
const boxW = Math.min(W - 24, 250), bx = Math.round(W / 2 - boxW / 2);
|
||||
const mugH = Math.round(Math.min(56, H * 0.22));
|
||||
const mugW = img ? Math.round(mugH * img.width / img.height) : 0;
|
||||
const slogan = wrapText(f.line, F(8, 0), boxW - (mugW ? mugW + 12 : 0)); // the shared wrapper sets the font
|
||||
const headH = Math.max(mugH, 26 + slogan.length * 12);
|
||||
const measureH = 36, gap = 14;
|
||||
const subH = f.sub.length ? f.sub.length * 12 + 4 : 0;
|
||||
const blockH = headH + gap + measureH + (subH ? gap - 4 + subH : 0);
|
||||
let y = Math.round(H / 2 - blockH / 2);
|
||||
// THE MASTHEAD flies from the right as one piece
|
||||
const hx = dx(0, 1);
|
||||
const nx = bx + (mugW ? mugW + 12 : 0) + hx;
|
||||
if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, bx + hx, y, mugW, mugH); }
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = B(20);
|
||||
ctx.fillStyle = m.color || UI.hot;
|
||||
ctx.fillText(f.name, nx, y + 10);
|
||||
ctx.font = F(8, 0);
|
||||
slogan.forEach((l, i) => shadowText(l, nx, y + 28 + i * 12, UI.cream));
|
||||
y += headH + gap;
|
||||
// THE MEASURE flies from the left: two boxed figures sharing one frame (the .figs language)
|
||||
const mx = dx(1, -1);
|
||||
const half = Math.round(boxW / 2);
|
||||
panel(bx + mx, y, boxW, measureH, 3, 'rgba(8,26,18,0.85)', 'rgba(120,150,130,0.4)');
|
||||
ctx.strokeStyle = 'rgba(120,150,130,0.4)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + mx + half + 0.5, y + 4); ctx.lineTo(bx + mx + half + 0.5, y + measureH - 4);
|
||||
ctx.stroke();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = F(7, 0);
|
||||
shadowText('ITS LEVEL', bx + mx + half / 2, y + 10, UI.dim);
|
||||
shadowText('YOURS', bx + mx + half * 1.5, y + 10, UI.dim);
|
||||
ctx.font = F(13, 700);
|
||||
shadowText(String(f.lvl), bx + mx + half / 2, y + 25, f.lvl > f.mine ? (m.color || UI.hot) : UI.pick);
|
||||
shadowText(String(f.mine), bx + mx + half * 1.5, y + 25, UI.cream);
|
||||
y += measureH;
|
||||
// the round / the lesson, from the left a beat later
|
||||
if (f.sub.length) {
|
||||
y += gap - 4;
|
||||
ctx.font = F(8, 0);
|
||||
f.sub.forEach((l, i) => shadowText(l, W / 2 + dx(2, -1), y + 6 + i * 12, UI.dim));
|
||||
}
|
||||
if (t >= 1 && Math.floor(G.idleT * 2) % 2) {
|
||||
ctx.font = F(8);
|
||||
shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2,
|
||||
Math.round(H / 2 + blockH / 2 + 24), UI.warm);
|
||||
}
|
||||
ctx.textBaseline = 'top';
|
||||
// Everything measured before anything is drawn: the box has to know its own height, and the title
|
||||
// has to know whether it fits beside the art before either is placed.
|
||||
function modalLayout(m) {
|
||||
const boxW = Math.min(W - 20, 268), pad = MODAL_PAD;
|
||||
const art = m.foe ? (m.foe.head && SPR[m.foe.head]) : (m.icon && SPR[m.icon]);
|
||||
const artH = art ? Math.round(Math.min(m.iconH || 46, H * 0.2)) : 0;
|
||||
const artW = art ? Math.round(artH * art.width / art.height) : 0;
|
||||
const headW = boxW - pad * 2 - (art ? artW + 10 : 0);
|
||||
// the title SHRINKS to its lane rather than running off the box — boss names and THE TRAINING
|
||||
// COURT are both titles, and one of them is twice as long as the other
|
||||
ctx.font = B(22);
|
||||
const tw = ctx.measureText(m.title).width;
|
||||
const tSize = tw > headW ? Math.max(13, Math.floor(22 * headW / tw)) : 22;
|
||||
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : [];
|
||||
const headH = Math.max(artH, 22 + strap.length * 12);
|
||||
const rows = modalRows(m, boxW - pad * 2);
|
||||
let h = pad + headH + MODAL_GAP + MODAL_BTN + pad;
|
||||
for (const r of rows) h += r.h + MODAL_GAP;
|
||||
return { boxW, pad, art, artW, artH, tSize, strap, headH, rows, h,
|
||||
x: Math.round(W / 2 - boxW / 2), y: Math.round(H / 2 - h / 2) };
|
||||
}
|
||||
|
||||
function drawModal(m) {
|
||||
// The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and
|
||||
// title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each
|
||||
// on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour
|
||||
// scaled image re-snaps its pixel columns every frame, which is the same boiling that once read
|
||||
// as HUD flicker. Legacy/test modals with no `in` field draw fully landed.
|
||||
// The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze). The box flies
|
||||
// in from the RIGHT as ONE object — the old per-line stagger would tear a box apart — and the
|
||||
// dim washes in first. Every x is ROUNDED: a fractional x on nearest-neighbour art re-snaps its
|
||||
// pixel columns every frame, which is the boiling that once read as HUD flicker.
|
||||
const t = m.in === undefined ? 1 : m.in;
|
||||
const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time
|
||||
const lane = k => { // 0 = off screen, 1 = home (may overshoot)
|
||||
const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN));
|
||||
return p >= 1 ? 1 : easeBack(p);
|
||||
};
|
||||
const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right
|
||||
ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first
|
||||
const p = Math.max(0, Math.min(1, t / 0.62));
|
||||
const dx = Math.round((1 - (p >= 1 ? 1 : easeBack(p))) * W * 0.9);
|
||||
ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
if (m.foe) return drawFoeModal(m, t, dx);
|
||||
if (m.rules) return drawRulesModal(m, t, dx);
|
||||
const body = m.body || [], img = m.icon && SPR[m.icon];
|
||||
// cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a
|
||||
// portrait-sized icon pushed the TAP prompt down onto the weapon rack
|
||||
const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0;
|
||||
const blockH = (iconH ? iconH + 12 : 0) + 26 + body.length * 13;
|
||||
let y = Math.round(H / 2 - blockH / 2);
|
||||
const headX = dx(0, 1); // icon and title travel together, as one masthead
|
||||
if (img) {
|
||||
const iw = Math.min(W - 24, iconH * img.width / img.height);
|
||||
ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH);
|
||||
y += iconH + 12;
|
||||
const L = modalLayout(m);
|
||||
const col = m.color || UI.warm;
|
||||
const x = L.x + dx, pad = L.pad, inX = x + pad;
|
||||
let y = L.y + pad;
|
||||
ctx.textBaseline = 'middle';
|
||||
panel(x, L.y, L.boxW, L.h, 4, MODAL_FILL, MODAL_EDGE);
|
||||
// THE MASTHEAD
|
||||
if (L.art) {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(L.art, inX, y + Math.round((L.headH - L.artH) / 2), L.artW, L.artH);
|
||||
}
|
||||
ctx.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face
|
||||
if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it
|
||||
ctx.save(); ctx.globalAlpha = a;
|
||||
ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.fillText(m.title, W / 2 + headX, y + 8); y += 26;
|
||||
const tx = inX + (L.art ? L.artW + 10 : 0);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = B(L.tSize);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fillText(m.title, tx, y + 11);
|
||||
ctx.font = F(8, 0);
|
||||
body.forEach((l, i) => { shadowText(l, W / 2 + dx(i + 1, -1), y + 6, UI.cream); y += 13; });
|
||||
// the prompt only exists once everything has landed — offering the tap mid-flight would be a lie,
|
||||
// dismissModal swallows it until then
|
||||
if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy
|
||||
ctx.font = F(8);
|
||||
shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22), UI.warm);
|
||||
L.strap.forEach((l, i) => shadowText(l, tx, y + 26 + i * 12, UI.cream));
|
||||
y += L.headH + MODAL_GAP;
|
||||
// THE ROWS
|
||||
for (const r of L.rows) {
|
||||
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5);
|
||||
ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke();
|
||||
if (r.kind === 'text') {
|
||||
ctx.font = F(8, 0); ctx.textAlign = 'left';
|
||||
r.lines.forEach((l, i) => l && shadowText(l, inX, y + 7 + i * 12, r.dim ? UI.dim : UI.cream));
|
||||
} else if (r.kind === 'rule') {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = F(8, 700);
|
||||
r.lead.forEach((l, i) => shadowText(l, inX, y + 7 + i * 12, UI.cream));
|
||||
let ry = y + 7 + r.lead.length * 12;
|
||||
// the consequence line: DIM for information, the modal's hot colour only when it threatens
|
||||
ctx.font = F(8, 0);
|
||||
r.sub.forEach((l, i) => shadowText(l, inX, ry + i * 12, r.hot ? col : UI.dim));
|
||||
if (r.pips) { // the pool, in the HUD's own pip language
|
||||
const py = ry + r.sub.length * 12 + 4;
|
||||
for (let k = 0; k < r.pips; k++) {
|
||||
ctx.beginPath(); ctx.arc(inX + 3 + k * 9, py, 2.6, 0, 7);
|
||||
ctx.fillStyle = '#8a1a12'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
}
|
||||
} else if (r.kind === 'split') {
|
||||
// HIS LEVEL AGAINST YOURS — one frame, two figures, a hairline between them. The number
|
||||
// wears the modal's hot colour while the body outranks you and the pick green once it
|
||||
// does not, which is the whole point of showing it.
|
||||
const half = Math.round((L.boxW - pad * 2) / 2), f = m.foe;
|
||||
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(inX + half + 0.5, y + 4); ctx.lineTo(inX + half + 0.5, y + r.h - 4);
|
||||
ctx.stroke();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = F(7, 0);
|
||||
shadowText('ITS LEVEL', inX + half / 2, y + 8, UI.dim);
|
||||
shadowText('YOURS', inX + half * 1.5, y + 8, UI.dim);
|
||||
ctx.font = F(13, 700);
|
||||
shadowText(String(f.lvl), inX + half / 2, y + 23, f.lvl > f.mine ? col : UI.pick);
|
||||
shadowText(String(f.mine), inX + half * 1.5, y + 23, UI.cream);
|
||||
}
|
||||
y += r.h + MODAL_GAP;
|
||||
}
|
||||
ctx.textBaseline = 'top';
|
||||
// THE FOOTER BUTTON — only once the box has landed. Offering the press mid-flight would be a
|
||||
// lie: dismissModal swallows the gesture until then.
|
||||
if (t >= 1) {
|
||||
const label = m.btn || (m.foe ? 'WALK ON' : m.rules ? 'GOT IT' : 'PLAY ON');
|
||||
const bw = Math.min(L.boxW - pad * 2, 168), bx = Math.round(x + L.boxW / 2 - bw / 2);
|
||||
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5);
|
||||
ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke();
|
||||
drawButton(bx, y, bw, MODAL_BTN, label, col, true);
|
||||
}
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||
}
|
||||
|
||||
// THE HANDOVER'S BUTTONS. Laid out by the summary as it draws (the box measures itself, so the
|
||||
// rects are only known then) and hit-tested by main.maybeRestart. A tap ANYWHERE else still ends
|
||||
// the run the way it always did — the buttons add a door, they never take the old one away.
|
||||
const endBtns = [];
|
||||
export function endHit(nx, ny) { // normalized [0,1] canvas coords -> button id or null
|
||||
const cx = nx * W, cy = ny * H;
|
||||
for (const b of endBtns)
|
||||
if (cx >= b.x - 4 && cx <= b.x + b.w + 4 && cy >= b.y - 6 && cy <= b.y + b.h + 6) return b.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The one button face the overlays share — filled when it is the thing to do, outlined when it is
|
||||
// the way out. Used by the modal footer and by the run-end summary, so the two screens cannot
|
||||
// drift into two button languages.
|
||||
function drawButton(x, y, w, h, label, col, primary) {
|
||||
panel(x, y, w, h, 3, primary ? col : 'rgba(8,22,16,0.9)', primary ? null : col);
|
||||
ctx.font = F(9, 700);
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = primary ? '#0b120e' : col;
|
||||
ctx.fillText(label, x + w / 2, y + h / 2 + 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ frame ----
|
||||
@@ -2981,69 +3005,145 @@ export function render() {
|
||||
drawDrillHud(); // the triage readout — no-op unless a drill is running
|
||||
drawBenchHud(); // ...and the sprite bench's, same rule
|
||||
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
|
||||
// THE END OF A RUN, whichever way it ended. Three modes end three different ways and they share
|
||||
// one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap
|
||||
// — because only the WORDS differ, and three copies of this drift apart the first time one moves.
|
||||
// eaten + gore GAME, SET, MASSACRE. n games, n sets
|
||||
// eaten + match BLED OUT. n opponents felled, n games
|
||||
// done + classic MATCH. / BEATEN. the set score
|
||||
// phase C for the death: the summary fades UP over the animation instead of cutting on top of it.
|
||||
// THE END OF A RUN, whichever way it ended — and the one screen in the game that has to HAND
|
||||
// SOMETHING OVER (2026-08-31, THE SEASON: "das ende reicht weiter"). It wears the modal's box
|
||||
// language for the same reason the modals got one: a wash with five free-floating lines on it
|
||||
// was a wall of text at the exact moment the player is deciding whether to open the game again.
|
||||
// THE HEADLINE how it ended, in the mode's own words
|
||||
// eaten + gore GAME, SET, MASSACRE. n games, n sets
|
||||
// eaten + match BLED OUT. n felled, n games
|
||||
// eaten + cup OUT IN THE SEMI-FINAL. the board notes it, and your best
|
||||
// done + classic GAME, SET, MATCH. / BEATEN.
|
||||
// THE RUN the xp it paid and the level it moved — meta absorbing permadeath
|
||||
// THE MARK your best on this board, or the record it just became
|
||||
// THE FIXTURE what the club has you down for next, and the button that goes there
|
||||
{
|
||||
const m = G.match && G.match.felled !== undefined ? G.match : null;
|
||||
const dead = G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY, done = G.done && G.done.t > 0.5;
|
||||
endBtns.length = 0;
|
||||
if (dead || done) {
|
||||
const clk = dead ? G.eatenT - EATEN_SUMMARY : G.done.t - 0.5;
|
||||
const fade = Math.min(1, clk / 0.6);
|
||||
const won = !dead && G.done.won;
|
||||
const cup = m && m.cup ? m.cup : null;
|
||||
ctx.save(); ctx.globalAlpha = fade;
|
||||
ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
||||
// The announcement face, at 26 rather than the card's 30: this is the longest string in the
|
||||
// game and at 30 it ran edge to edge on a 270px-wide portrait canvas.
|
||||
// the big line is reserved for the TOP league's cup (THE LEAGUES): a lower cup is a step
|
||||
// up the club, not the whole club — its ceremony (the promotion screen) follows anyway
|
||||
const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
|
||||
// THE WORDS. The cup's own exit is a ceremony, not a death: it says which round took you.
|
||||
// "OUT IN SEMI-FINAL" reads like a telegram: the round names carry their own article when
|
||||
// they have one (THE FINAL, ROUND ONE) and want one when they do not.
|
||||
const theRound = n => /^(THE |ROUND )/.test(n) ? n : 'THE ' + n;
|
||||
const title = dead
|
||||
? (cup ? 'OUT IN ' + theRound(cupRoundName(G.league, cup.round + 1)) + '.'
|
||||
: m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.')
|
||||
: G.done.cup ? (G.league >= 5 ? 'CHAMPION OF THE DEAD CLUB.' : 'THE CUP IS YOURS.')
|
||||
: won ? 'GAME, SET, MATCH.' : 'BEATEN.';
|
||||
const line = dead
|
||||
? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
|
||||
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS')
|
||||
? (cup ? (cup.round + 1 >= cupBest(G.league) ? 'THE BOARD NOTES IT'
|
||||
: 'YOUR BEST STANDS: ' + theRound(cupRoundName(G.league, cupBest(G.league))))
|
||||
: m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES'
|
||||
: 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS')
|
||||
: G.done.cup ? (G.league >= 5 ? 'THE CHAMPION FELL · ' : 'THE FINAL FELL · ')
|
||||
+ ((G.match && G.match.games) || G.runGames) + ' GAMES'
|
||||
: G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES';
|
||||
ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad);
|
||||
ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream);
|
||||
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
|
||||
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4));
|
||||
ctx.font = B(20);
|
||||
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
|
||||
} else if (G.best && G.best.games > 0) {
|
||||
ctx.font = F(8);
|
||||
shadowText(m ? 'BEST ' + G.best.games + ' FELLED'
|
||||
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
|
||||
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
|
||||
}
|
||||
// THE RUN'S XP — the moment meta-progress absorbs the permadeath (the leagues, phase 1).
|
||||
// Muted modes never award, so a training rep or a drill shows nothing here by construction.
|
||||
const xd = runDelta();
|
||||
// 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 xd = runDelta(), pr = progress();
|
||||
const ready = dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART;
|
||||
|
||||
const boxW = Math.min(W - 20, 288), pad = MODAL_PAD, inner = boxW - pad * 2;
|
||||
const headLines = wrapText(title, B(22), inner);
|
||||
const lineLines = wrapText(line, F(9), inner);
|
||||
// the cup keeps ITS OWN mark (the round reached, in the headline pair above) — the board's
|
||||
// felled count beside it was two bests answering different questions in the same box
|
||||
const mark = G.newBest || cup ? null
|
||||
: G.best && G.best.games > 0
|
||||
? (m ? 'BEST ' + G.best.games + ' FELLED'
|
||||
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
|
||||
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS')
|
||||
: null;
|
||||
const fxLines = ready ? wrapText(promo ? 'THE CLUB WANTS A WORD'
|
||||
: G.net ? 'THE CLUB IS WAITING' : fx.line, F(8, 0), inner) : [];
|
||||
let h = pad + headLines.length * 24 + lineLines.length * 13 + 4;
|
||||
if (xd.xp > 0) h += MODAL_GAP + 26;
|
||||
if (G.newBest || mark) h += MODAL_GAP + (G.newBest ? 20 : 13);
|
||||
if (ready) h += MODAL_GAP + 14 + fxLines.length * 12 + 6 + MODAL_BTN;
|
||||
h += pad;
|
||||
const x = Math.round(W / 2 - boxW / 2), y0 = Math.round(H / 2 - h / 2), inX = x + pad;
|
||||
panel(x, y0, boxW, h, 4, MODAL_FILL, MODAL_EDGE);
|
||||
let y = y0 + pad;
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||
ctx.font = B(22);
|
||||
headLines.forEach((l, i) => shadowText(l, inX, y + 12 + i * 24, won ? UI.pick : UI.bad));
|
||||
y += headLines.length * 24;
|
||||
ctx.font = F(9);
|
||||
lineLines.forEach((l, i) => shadowText(l, inX, y + 7 + i * 13, UI.cream));
|
||||
y += lineLines.length * 13 + 4;
|
||||
const rule = () => {
|
||||
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(x + 6, y + Math.round(MODAL_GAP / 2) + 0.5);
|
||||
ctx.lineTo(x + boxW - 6, y + Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke();
|
||||
y += MODAL_GAP;
|
||||
};
|
||||
// THE RUN'S XP — meta-progress absorbing permadeath. Muted modes never award, so a drill or
|
||||
// a training rep shows nothing here by construction.
|
||||
if (xd.xp > 0) {
|
||||
const pr = progress();
|
||||
rule();
|
||||
ctx.font = F(8);
|
||||
// +24/+34: clear of the NEW BEST line above, which is B(20) drawn top-baseline at +2
|
||||
shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to
|
||||
: ' · LEVEL ' + pr.level),
|
||||
W / 2, H / 2 + 24, xd.to > xd.from ? UI.pick : UI.cream);
|
||||
const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 36);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4);
|
||||
inX, y + 7, xd.to > xd.from ? UI.pick : UI.cream);
|
||||
const bw = inner, by = y + 18;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(inX, by, bw, 4);
|
||||
// the fill is a gradient (Oliver's call), green into gold — same ramp as the splash strip
|
||||
const gr = ctx.createLinearGradient(bx, 0, bx + bw, 0);
|
||||
const gr = ctx.createLinearGradient(inX, 0, inX + bw, 0);
|
||||
gr.addColorStop(0, '#3f9560'); gr.addColorStop(0.55, UI.pick); gr.addColorStop(1, '#e8b23a');
|
||||
ctx.fillStyle = gr; ctx.fillRect(bx, by, Math.round(bw * pr.frac), 4);
|
||||
ctx.fillStyle = gr; ctx.fillRect(inX, by, Math.round(bw * pr.frac), 4);
|
||||
y += 26;
|
||||
}
|
||||
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
|
||||
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) {
|
||||
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
|
||||
rule();
|
||||
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(clk * 4));
|
||||
ctx.font = B(18);
|
||||
shadowText('★ NEW BEST ★', inX, y + 10, hexA(UI.warm, pulse));
|
||||
y += 20;
|
||||
} else if (mark) {
|
||||
rule();
|
||||
ctx.font = F(8);
|
||||
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 50, UI.warm);
|
||||
shadowText(mark, inX, y + 6, UI.dim);
|
||||
y += 13;
|
||||
}
|
||||
// THE HANDOVER, gated well past the animation so a panic tap can't skip the summary
|
||||
// (main.js gates the restart on the same clock).
|
||||
if (ready) {
|
||||
rule();
|
||||
ctx.font = F(7, 0);
|
||||
shadowText('NEXT', inX, y + 5, UI.dim);
|
||||
ctx.font = F(9, 700);
|
||||
shadowText(promo ? 'THE PROMOTION' : G.net ? 'BACK TO THE CLUBHOUSE' : fx.title,
|
||||
inX + 34, y + 5, UI.warm);
|
||||
y += 14;
|
||||
ctx.font = F(8, 0);
|
||||
fxLines.forEach((l, i) => shadowText(l, inX, y + 6 + i * 12, UI.dim));
|
||||
y += fxLines.length * 12 + 6;
|
||||
// one button when the ceremony owns the tap, two when there is a real choice: the fixture
|
||||
// (the canonical path) and the way back to what you were just doing
|
||||
// NETPLAY ends on one door only: the other phone's socket is still being torn down, and
|
||||
// "enter the draw" would deal a solo cup into a live session.
|
||||
const solo = !promo && !G.net;
|
||||
const second = solo ? (cup || (G.done && G.done.cup) ? ['club', 'THE CLUBHOUSE']
|
||||
: ['again', 'PLAY AGAIN']) : null;
|
||||
const bw = second ? Math.round((inner - 8) / 2) : inner;
|
||||
const label = promo ? 'TAKE THE FLOOR' : solo ? 'ENTER THE DRAW' : 'THE CLUBHOUSE';
|
||||
drawButton(inX, y, bw, MODAL_BTN, label, UI.pick, true);
|
||||
endBtns.push({ id: solo ? 'next' : 'club', x: inX, y, w: bw, h: MODAL_BTN });
|
||||
if (second) {
|
||||
drawButton(inX + bw + 8, y, bw, MODAL_BTN, second[1], UI.warm, false);
|
||||
endBtns.push({ id: second[0], x: inX + bw + 8, y, w: bw, h: MODAL_BTN });
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -11,7 +11,7 @@ import { drillKill, drillCaught } from './drill.js';
|
||||
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
|
||||
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
|
||||
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
|
||||
import { award, bonus, prof } from './profile.js';
|
||||
import { award, bonus, prof, recordCup } from './profile.js';
|
||||
|
||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||
@@ -325,6 +325,10 @@ export function killPlayer() {
|
||||
: endRun(G.runGames, G.sets[0], G.splatT);
|
||||
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets
|
||||
if (m) { m.felled = felled; m.games = gamesPlayed; m.best = b.games; } // ...the death screen's words
|
||||
// THE CUP RECORD (THE SEASON): an attempt that ends here is still an attempt. The round you
|
||||
// were IN is the round you reached, and the rung holding the racket is the one the training
|
||||
// court will offer you next. Training reps are not attempts and never write it.
|
||||
if (m && m.cup && !m.train) recordCup(G.league, m.cup.round + 1, m.cup.rung || m.opp);
|
||||
}
|
||||
|
||||
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
|
||||
|
||||
@@ -115,6 +115,9 @@ function freshSplatter() {
|
||||
// XP is zeroed too: the level feeds real dials now (legs, window), and accumulated awards from
|
||||
// earlier tests would silently buff every later one.
|
||||
prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo();
|
||||
// THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly
|
||||
// the way the promotion did — the training court reads `out`, and a stale one reorders its list
|
||||
prof.prof.cups = {}; prof.prof.out = 0;
|
||||
G.mode = 'splatter';
|
||||
G.everTransitioned = true;
|
||||
setupPoint();
|
||||
@@ -1235,6 +1238,57 @@ test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the cha
|
||||
assert.ok(pr && pr.to === 2, 'the ceremony is parked for the menu to play');
|
||||
});
|
||||
|
||||
// ---- THE SEASON: the tournament is the story, so the club has to remember the attempts --------
|
||||
|
||||
test('THE SEASON: the fixture names the league, its rounds and the body that opens it', () => {
|
||||
for (const l of cfg.LEAGUES) {
|
||||
const fx = cfg.cupFixture(l.id), R = cfg.cupRoundsFor(l.id);
|
||||
assert.equal(fx.rung, R[0].band[0], l.name + ': the fixture opens on round one\'s rung');
|
||||
assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body');
|
||||
assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE',
|
||||
l.name + ': the title carries the league');
|
||||
assert.ok(fx.line.includes(R[0].name) && fx.line.startsWith(String(R.length)),
|
||||
l.name + ': the line counts the rounds and names the first');
|
||||
}
|
||||
// the champion has nothing above him: his fixture is a defence, not a promotion
|
||||
assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE');
|
||||
// ...and a record reads in the rounds' own words
|
||||
const R3 = cfg.cupRoundsFor(3);
|
||||
assert.equal(cfg.cupRoundName(3, 1), R3[0].name, 'one round reached is round one');
|
||||
assert.equal(cfg.cupRoundName(3, R3.length), 'THE FINAL', 'the last round is the final');
|
||||
});
|
||||
|
||||
test('THE SEASON: a cup exit is recorded, forward only, and names who put you out', () => {
|
||||
prof.prof.lg = 3;
|
||||
_startMatch(true); // league 3's cup, round one
|
||||
G.modal = null; G.modalQ.length = 0;
|
||||
G.match.cup.round = 2; // deep: the semi-final
|
||||
const rung = G.match.cup.rung;
|
||||
killPlayer();
|
||||
assert.equal(prof.cupBest(3), 3, 'the round you were in is the round you reached');
|
||||
assert.equal(prof.prof.out, rung, 'the body holding the racket is the one that put you out');
|
||||
assert.equal(prof.cupBest(1), 0, 'and it is that league\'s record, not the club\'s');
|
||||
// a shallower attempt afterwards leaves the record standing — and still updates the eliminator
|
||||
restartRun();
|
||||
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
|
||||
prof.prof.lg = 3;
|
||||
_startMatch(true);
|
||||
G.modal = null; G.modalQ.length = 0;
|
||||
G.match.cup.round = 0;
|
||||
const rung2 = G.match.cup.rung;
|
||||
killPlayer();
|
||||
assert.equal(prof.cupBest(3), 3, 'a worse run never erases a better one');
|
||||
assert.equal(prof.prof.out, rung2, 'but the LAST body to end an attempt is always the offer');
|
||||
// the training court is not an attempt and writes nothing
|
||||
const out = prof.prof.out;
|
||||
restartRun();
|
||||
G.mode = 'splatter'; G.everTransitioned = true; setupPoint();
|
||||
_startMatch(false, null, 2);
|
||||
G.modal = null; G.modalQ.length = 0;
|
||||
killPlayer();
|
||||
assert.equal(prof.prof.out, out, 'a training rep is a rep, not a cup attempt');
|
||||
});
|
||||
|
||||
test('THE CUP CLIMBS: rounds run easy to hard, deterministic, the final the top of the band', () => {
|
||||
// the shuffle retired with the draw screen (Oliver: "warum sollten wir noch mischen?") — a bad
|
||||
// roll paired the strongest challenger with the shortest round, inverting the tuned curve
|
||||
|
||||
Reference in new issue
Block a user