The menu text reduction: the design handoff, built
Sixty words on the wall became a dozen. The handoff's own summary of the rule:
Jersey 15 NAMES (screen titles, rooms, cards), Bangers CALLS exactly once per
screen (the fixture, the primary button, a modal headline), Silkscreen INFORMS
(numbers, labels, chips) — and a sentence survives only where it carries a
decision. Everything else moved into a bar, a pip or the modal that opens with
the mode.
· Jersey 15 bundled subset (9 KB) and Bungee retired after one afternoon
· SPLASH: the plaque is one row, the goal line is gone (the fixture button
below said it already), the two doors carry a number each
· PICK A GAME: rooms are one word, posters are their names, the fixture is a
hero with the only second line — the whole wall fits one screen now
· MATCH SETUP: unnumbered blocks, the meters' legend once instead of a
description per court, no summary footer
· DIFFICULTY: three pips instead of three sentences
· TRAINING: the revanche is a full-width row, rungs are numbers, the list
ends on the league that opens the rest of the ladder
· VS: three paragraphs became YOU / HOST / JOIN
· MODALS: one rule per row (the sub was the lead again in other words), the
blood pool as pips on that same line, and the walk-on's two levels in ONE
face at ONE size — a comparison drawn at two sizes is a headline with a
footnote (Oliver)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
Copyright 2023 The Bungee Project Authors (https://github.com/djrrb/Bungee)
|
Copyright 2023 The Soft Type Project Authors (https://github.com/scfried/soft-type-jersey)
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
This license is copied below, and is also available with a FAQ at:
|
This license is copied below, and is also available with a FAQ at:
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Handoff: Menüführung — Text reduzieren (Splatter Tennis)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Die funktionalen Screens von Splatter Tennis (Splash, PICK A GAME, MATCH SETUP, DIFFICULTY,
|
||||||
|
TRAINING, VS A FRIEND und die In-Game-Modals) tragen heute mehr Text als eine Entscheidung
|
||||||
|
braucht — auf `PICK A GAME` rund 60 Wörter, verteilt auf drei Rubriken mit Straps plus sechs
|
||||||
|
Poster mit je einer Regelzeile. Das Ergebnis dieser Session: eine reduzierte Fassung aller
|
||||||
|
funktionalen Screens (~12–18 Wörter auf der Wand) und ein Wechsel der aufrechten Display-Schrift
|
||||||
|
von **Bungee** auf **Jersey 15**.
|
||||||
|
|
||||||
|
Gameplay, Stats-Screens und Canvas-HUD sind bewusst NICHT Teil dieses Pakets.
|
||||||
|
|
||||||
|
## About the Design Files
|
||||||
|
`Menues.dc.html` ist eine **Design-Referenz in HTML** — ein Prototyp, der Aussehen und Struktur
|
||||||
|
zeigt, kein Produktionscode zum Kopieren. Splatter Tennis ist Vanilla JS ohne Build-Step
|
||||||
|
(`CLAUDE.md`, Punkt 5: „Zero dependencies, no build step, no framework"), also wird die Fassung
|
||||||
|
in der bestehenden Umgebung nachgebaut: DOM-Menüs in `index.html` + `src/main.js`, Canvas-Modals
|
||||||
|
in `src/render.js`, Texte in `src/config.js`. Kein neues Framework, keine neuen Dateien ausser
|
||||||
|
der Schriftdatei.
|
||||||
|
|
||||||
|
Die Datei enthält mehrere Runden untereinander, neueste oben. Verbindlich ist **Runde 5**
|
||||||
|
(„GESETZT — ALLE SCREENS IN JERSEY 15"). Darunter liegen als Historie: Runde 4 (Schrift-Kandidaten
|
||||||
|
Bebas Neue / Oswald / Jersey 15), Runde 3a (derselbe Satz in Bebas), Runde 2 (Bungee vs. Bangers),
|
||||||
|
Runde 1 (die zwei Grundrichtungen) und ganz unten der **IST-Stand**, pixelgenau aus dem heutigen
|
||||||
|
`index.html` nachgebaut — nützlich als Vor/Nach-Vergleich.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
**High-fidelity.** Palette, Masse, Schriftgrössen und Copy sind aus dem Repo übernommen
|
||||||
|
(`index.html` CSS, `config.MODES` / `MENU` / `COURTS` / `LEAGUES` / `OPPONENTS`, `beats.js`
|
||||||
|
Modal-Payloads). Alle Sprites/Poster im Prototyp sind echte Repo-Assets. Übernahme darf 1:1
|
||||||
|
erfolgen.
|
||||||
|
|
||||||
|
## Die drei Register (die eigentliche Entscheidung)
|
||||||
|
| Rolle | heute | neu |
|
||||||
|
|---|---|---|
|
||||||
|
| benennen — Screen-Titel, Rubriken, Karten- und Poster-Namen | Bungee | **Jersey 15** |
|
||||||
|
| rufen — einmal pro Screen: Fixture, Primär-Button, Modal-Headline | Bangers | Bangers (unverändert) |
|
||||||
|
| informieren — Zahlen, Labels, Chips | Silkscreen | Silkscreen (unverändert) |
|
||||||
|
|
||||||
|
Begründung Bungee → Jersey 15: Bungee ist Single-Weight und sehr fett/blockig; Jersey 15 sitzt
|
||||||
|
auf demselben Pixelraster wie Silkscreen (wirkt als Schriftfamilie mit der Sprite-Art), läuft
|
||||||
|
proportional und deutlich leichter. Beide sind SIL OFL.
|
||||||
|
|
||||||
|
**Grössen-Umrechnung Bungee → Jersey 15: ca. +40 %** (Jersey sitzt tiefer im Kasten und läuft
|
||||||
|
leichter). Konkrete Werte im Abschnitt „Design Tokens".
|
||||||
|
|
||||||
|
## Schrift einbauen
|
||||||
|
1. `Jersey15-Regular.ttf` von Google Fonts holen, wie Bungee subsetten (`pyftsubset`, Befehl
|
||||||
|
steht in `PROJECT.md`) → `assets/fonts/Jersey15-Subset.ttf`.
|
||||||
|
2. In `index.html` das `@font-face` von Bungee ersetzen (`?v=` mitziehen wie bei den anderen drei):
|
||||||
|
```css
|
||||||
|
@font-face { font-family: 'Jersey 15'; font-weight: 400; font-display: block;
|
||||||
|
src: url('assets/fonts/Jersey15-Subset.ttf?v=87') format('truetype'); }
|
||||||
|
```
|
||||||
|
3. Alle `Bungee`-Vorkommen in `index.html` auf `'Jersey 15'` umstellen (Liste unten),
|
||||||
|
`Bungee-Subset.ttf` und `Bungee-OFL.txt` löschen, `Jersey15-OFL.txt` daneben legen.
|
||||||
|
4. `main.js?v=N` in `index.html` bumpen (`CLAUDE.md`, Punkt 4).
|
||||||
|
|
||||||
|
## Screens / Views
|
||||||
|
|
||||||
|
### 1 — Splash (`#splash` in index.html)
|
||||||
|
**Zweck:** Eintritt; die Plakette zeigt wer du bist, der Primär-Button das nächste Fixture.
|
||||||
|
|
||||||
|
Änderungen:
|
||||||
|
- **Plakette (`#plaque`)** wird eine Zeile statt vier Grid-Areas: Wappen links (50px), rechts
|
||||||
|
Name + `LEVEL 12` in einer Baseline-Zeile, darunter der XP-Balken mit der Ziel-Zahl rechts.
|
||||||
|
**`#plGoal` entfällt komplett** — „WIN THE CUP TO REACH LEAGUE 4" sagt der Fixture-Button
|
||||||
|
darunter schon. Grid-Areas `name/lv/bar/goal` werden zu einem Flex-Row mit zwei Spalten.
|
||||||
|
- Name: Jersey 15 21px, `#ccd7ca`, `letter-spacing:0.5px` (war Silkscreen 700 13px)
|
||||||
|
- `LEVEL 12`: Silkscreen 700 10px, `#bcd455` (war Bangers 26px als `#plLv` — der Ruf gehört
|
||||||
|
dem Button unten, nicht der Plakette)
|
||||||
|
- Balken unverändert (6px, `#2c4238` Rahmen, Gradient `#3f9560 → #bcd455 55% → #e8b23a`)
|
||||||
|
- **Fixture-Button (`#mStart`)** bleibt Bangers 26px; die zweite Zeile (`#msLine`) verliert das
|
||||||
|
Rundenpräfix: `ROUND ONE: THE STRAWBERRY SELLER` statt
|
||||||
|
`4 ROUNDS · ROUND ONE: THE STRAWBERRY SELLER`. → in `config.cupFixture` die `line` kürzen
|
||||||
|
(`rounds.length + ' ROUNDS · '` fällt weg; die Rundenzahl steht im Modal).
|
||||||
|
- **`#mOther`** verliert seinen Strap (`<i>CLIMB · TRAINING · GORE · A FRIEND</i>` löschen),
|
||||||
|
Label auf Jersey 15 19px.
|
||||||
|
- **`#mStats` / `#mRanks`**: Straps ersetzt durch je eine Zahl aus lokalem Stand —
|
||||||
|
`STATS / 4 812` (Kill-Count, wie heute) und `BOARD / BEST 612` (eigener Bestwert; **kein**
|
||||||
|
Weltrang, der Splash lädt `/data/scores.json` nicht). Label Jersey 15 16px, Zahl
|
||||||
|
Silkscreen 700 10px `#bcd455`.
|
||||||
|
- Logo, Slogan (`Fifteen. Thirty. Forty. Dead.`), `#build` unverändert.
|
||||||
|
|
||||||
|
### 2 — PICK A GAME (`#stmode` / `#modeList`, gebaut in `main.buildTops`)
|
||||||
|
**Zweck:** einen Modus wählen. Das ist der Screen, der „erschlägt".
|
||||||
|
|
||||||
|
- **Rubriken** (`#modeList .mgroup > header`) verlieren ihren Strap (`<i>`) und werden ein Wort:
|
||||||
|
`THE SEASON → SEASON`, `THE TRAINING → TRAINING`, `OUT OF COMPETITION → CASUAL`.
|
||||||
|
Jersey 15 17px, `#bcd455`, `letter-spacing:2px`. → `config.MENU`: `sub` der drei `group`-Rows
|
||||||
|
löschen, `group`-Text kürzen; in `main.buildTops` das `<i>` aus dem `header`-Template nehmen.
|
||||||
|
- **Poster** tragen nur noch den Namen — `<i>${card.sub}</i>` fällt in `main.banner()` weg.
|
||||||
|
Damit werden die `sub`-Felder in `config.MODES` für die Wand irrelevant (Aufbewahren lohnt:
|
||||||
|
die Modals nutzen dieselben Sätze).
|
||||||
|
- **Hero-Poster** (das Fixture, `mode:'cup'`) ist der einzige mit einer zweiten Zeile und dem
|
||||||
|
einzigen Ruf: Name in **Bangers 32px**, darunter die Fixture-Zeile in Silkscreen 10px
|
||||||
|
`#f0cfc4`. Höhe 142px. `.way`-Ribbon („THE WAY UP") unverändert.
|
||||||
|
- **Übrige Poster** in Jersey 15, Höhe gestaffelt statt einheitlich 148px:
|
||||||
|
- TRAINING-Raum: zwei Poster **nebeneinander**, je `flex:1`, Höhe 106px, Name 27px
|
||||||
|
- CASUAL-Raum: drei Poster gestapelt, Höhe 84px, Name 30px
|
||||||
|
- **Regeln, die wirklich anders sind, bleiben als Chip** (`.last`-Optik, Silkscreen 8px, acid,
|
||||||
|
`rgba(0,0,0,0.72)` Grund): `18 RUNGS` auf TRAINING, `LAST` auf dem zuletzt gespielten.
|
||||||
|
- Ergebnis: der ganze Wall passt in 844px (heute zwei Bildschirme). Gerechnet mit
|
||||||
|
Header 64 + Padding 14 + 142/106/84 + Gaps 17/8.
|
||||||
|
|
||||||
|
### 3 — MATCH SETUP (`#setup`)
|
||||||
|
- `<h3 id="s01">01 · PLAYER</h3>` / `02 · COURT` → Rubrik-Zeile wie auf der Wand:
|
||||||
|
Jersey 15 17px acid, Text `PLAYER` / `COURT`. Die Nummerierung entfällt (es sind zwei Blöcke
|
||||||
|
auf einem Screen, keine Schritte).
|
||||||
|
- Über der Platzliste steht die Legende **einmal**: rechts in der Rubrik-Zeile
|
||||||
|
`BOUNCE · PACE` (Silkscreen 8px, dim).
|
||||||
|
- **Platz-Zeilen** (`#courtList button.court`) verlieren `<i>${court.sub}</i>` — Name
|
||||||
|
(Jersey 15 19px) plus die zwei Meter, die dasselbe wortlos sagen. Bildhöhe 66 → 60px.
|
||||||
|
- **Gesperrte Plätze** (`.court.lock`) behalten Bild und Namen, statt einer Erklärung nur
|
||||||
|
`LEAGUE 4` in `#cf5a4e`, Silkscreen 9px.
|
||||||
|
- **Tour-Tiles**: `MEN'S SINGLES` / `WOMEN'S SINGLES` → `MEN'S` / `WOMEN'S`, Jersey 15 16px.
|
||||||
|
- **`#sSum`** (die Zusammenfassungszeile über SERVE UP) entfällt — alle drei genannten Werte
|
||||||
|
sind auf dem Screen sichtbar markiert.
|
||||||
|
- `SERVE UP` bleibt Bangers 26px auf `#a4231f`.
|
||||||
|
|
||||||
|
### 4 — DIFFICULTY (`#stsub` / `#modeSub button.chip`, aus `config.DIFFS`)
|
||||||
|
- Chip wird zweispaltig: Name links (Jersey 15 30px), **drei Pips** rechts (je 10×10px, kein
|
||||||
|
Radius) — 1/2/3 gefüllt. EASY/MEDIUM acid `#bcd455`, HARD `#cf5a4e`; leere Pips
|
||||||
|
`rgba(255,255,255,0.12)`.
|
||||||
|
- Kein Erklärtext. Padding 26px/16px, `gap:10px`.
|
||||||
|
- `LAST`-Chip bleibt oben rechts im gewählten.
|
||||||
|
|
||||||
|
### 5 — TRAINING (`#train` / `#trainList`, gebaut in `main.buildTrain`)
|
||||||
|
- `<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>` entfällt — der Screen-Titel
|
||||||
|
`TRAINING` genügt.
|
||||||
|
- **Revanche-Tile** (`profile.prof.out`) wird eine **breite Zeile über dem Raster**: Kopf 52px
|
||||||
|
links, Name (Jersey 15 24px), darunter `PUT YOU OUT` (statt `PUT YOU OUT OF THE CUP`) in
|
||||||
|
`#e08a7c`, Chevron rechts. Rahmen `#a4231f`, Grund `rgba(164,35,31,0.16)`.
|
||||||
|
- **Übrige Rungs** bleiben das 2-spaltige Raster: Kopf 44px, Name Jersey 15 17px zentriert,
|
||||||
|
Rangnummer zweistellig (`01`, `02`, …) statt `No. 1` — Silkscreen 8px dim.
|
||||||
|
- **Letzte Kachel** ist ein Ziel, keine Liste: gestrichelter Rahmen `#2c4238`, `LEAGUE 4` und
|
||||||
|
`13 — 15` in `#4a5a52`. (Heute endet die Liste einfach am Band-Cap.)
|
||||||
|
|
||||||
|
### 6 — VS A FRIEND (`#vs` / `#vsPick`)
|
||||||
|
- Die drei `<h3>` mit Straps (`01 · YOUR NAME / ON THEIR SCOREBOARD`, `02 · HOST A MATCH /
|
||||||
|
THEY COME TO YOU`, `OR JOIN THEIRS / WITH THEIR CODE`) werden drei Wörter:
|
||||||
|
**`YOU` / `HOST` / `JOIN`**, Jersey 15 17px acid.
|
||||||
|
- Host-Buttons: `CLASSIC TENNIS` / `DEATH MATCH` → `CLASSIC` / `DEATH MATCH` nebeneinander,
|
||||||
|
Jersey 15 19px, Padding 20px.
|
||||||
|
- Join-Zeile: Code-Feld plus Button `GO` (statt `JOIN` — daneben steht die Rubrik JOIN),
|
||||||
|
Feld-Letterspacing 6px.
|
||||||
|
- `#vsErr`-Meldungen unverändert (`THE CODE IS FOUR LETTERS`).
|
||||||
|
|
||||||
|
### 7 — In-Game-Modals (`render.drawModal`, Payloads aus `beats.js`)
|
||||||
|
Die Box-Sprache aus dem 2026-08-31-Pass bleibt (`MODAL_PAD 14`, `MODAL_GAP 13`, `MODAL_BTN 30`,
|
||||||
|
`MODAL_FOOT 8`, Breite `min(W-44, 244)`, Fill `rgba(8,22,16,0.93)`, Edge `rgba(120,150,130,0.45)`,
|
||||||
|
Rules `rgba(120,150,130,0.22)`). Reduziert wird der **Inhalt einer Row**:
|
||||||
|
|
||||||
|
- **Eine Regel pro Row, keine Unterzeile.** Heute rendert `modalRows` pro `m.rules`-Eintrag
|
||||||
|
`lead` + `sub`; der `sub` formuliert in fast allen Fällen den `lead` nur um
|
||||||
|
(`YOUR LEAGUE, ROUND BY ROUND` / `WIN IT ALL — MOVE UP A LEAGUE`). Neu: **eine** Zeile, die
|
||||||
|
das Ergebnis nennt — `WIN IT ALL — MOVE UP A LEAGUE`. Also in `beats.js` die `rules`-Arrays
|
||||||
|
auf je einen String reduzieren und `modalRows` entsprechend vereinfachen; `m.body` bleibt als
|
||||||
|
Fallback unverändert.
|
||||||
|
- **Blut ist Pips, kein Satz**: Row `POINTS DRAW BLOOD` (gekürzt von `ITS POINTS DRAW BLOOD`)
|
||||||
|
plus `TUNE.matchPlayerHp` Pips **in derselben Zeile rechts**, statt Zeile + Unterzeile
|
||||||
|
(`AT ZERO YOU ARE EATEN`) + Pips darunter.
|
||||||
|
- **Masthead**: Titel Bangers 27px in der Modal-Farbe, darunter EINE Silkscreen-10px-Zeile.
|
||||||
|
Beim Rules-Modal ist das die Sachzeile (`LEAGUE 3 · 4 ROUNDS`) — hier landet die Rundenzahl,
|
||||||
|
die aus `cupFixture.line` genommen wurde. Beim Walk-on bleibt es `foe.line`
|
||||||
|
(`CREAM WITH THAT?`).
|
||||||
|
- **Walk-on** (Gegner): Kopf-Sprite 56px, Name, `foe.line`, dann EINE Row mit den zwei Leveln
|
||||||
|
gegenüber (`LEVEL 14` hot links, `YOURS 12` dim rechts) — statt zwei Textzeilen plus
|
||||||
|
Split-Figur.
|
||||||
|
- Footer-Button unverändert (`WALK ON` / `GOT IT` / `PLAY ON`, `m.btn` überschreibt).
|
||||||
|
|
||||||
|
### 8 — Quit-Modal (`#qAsk` / `.qBox`)
|
||||||
|
Unverändert übernehmen — es ist schon lean. Nur `LEAVE THE MATCH?` von Silkscreen 12px auf
|
||||||
|
**Bangers 20px** (es ist eine Frage, die ruft), `PLAY ON` auf Jersey 15 17px.
|
||||||
|
|
||||||
|
## Interactions & Behavior
|
||||||
|
Keine Änderung an Navigation oder Zustandslogik. Alles Bestehende bleibt:
|
||||||
|
- `go(stage)` / `#intro[data-stage]`, ein Overlay, vier Panels
|
||||||
|
- Tap auf ein Poster antwortet UND geht weiter (`main.buildTops`), `pickMode` schreibt
|
||||||
|
`localStorage.mode`
|
||||||
|
- Die Deal-in-Animation (`#intro.deal`, `dealIn` 0.28s `cubic-bezier(0.34,1.56,0.64,1)`,
|
||||||
|
Delays 0.02/0.09/0.16/0.23/0.30s, `.mgroup`-Staffel 0.09/0.17/0.25s) gilt weiter — die
|
||||||
|
Rubriken bleiben `<section class="mgroup">`, damit sie greift
|
||||||
|
- `prefers-reduced-motion: reduce` schaltet sie weiter ab
|
||||||
|
- Modal-Dismiss-Regeln unverändert (`m.in < 1` schluckt die Gesture, Tap überall entlässt,
|
||||||
|
`G.seen.<key>` gated)
|
||||||
|
- Swipe-links-zurück im Shell bleibt unberührt
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
Nichts Neues. Zwei Werte werden neu GELESEN, beide existieren schon:
|
||||||
|
- `profile.prof.out` (der Rung, der dich aus dem Cup geworfen hat) → Revanche-Zeile; wird
|
||||||
|
bereits von `buildTrain` genutzt
|
||||||
|
- der eigene Bestwert für die BOARD-Kachel → aus `stats` (lokal), **nicht** vom Server
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
Unverändert aus `index.html`:
|
||||||
|
```
|
||||||
|
--ink #0e1714 --panel rgba(42,62,54,0.42) --line #2c4238
|
||||||
|
--acid #bcd455 --cream #ccd7ca --dim #6c7f74
|
||||||
|
--red #a4231f --bad #cf5a4e --solid #16211d
|
||||||
|
Poster-Veil: linear-gradient(0deg, rgba(6,10,8,0.94) 0%, rgba(6,10,8,0.86) 22%,
|
||||||
|
rgba(6,10,8,0.5) 58%, rgba(6,10,8,0) 100%) — Höhe 52–62 % je Kachelhöhe
|
||||||
|
Modal: fill rgba(8,22,16,0.93) · edge rgba(120,150,130,0.45) · rule rgba(120,150,130,0.22)
|
||||||
|
Rahmen: 2px überall in den Menüs, 1px im Canvas-Modal
|
||||||
|
```
|
||||||
|
|
||||||
|
Typografie neu (Bungee-Wert → Jersey-15-Wert):
|
||||||
|
```
|
||||||
|
Screen-Titel (.hdr h2) 23px → 33px/1 letter-spacing 1px
|
||||||
|
Rubrik / Raum-Label 15px → 17px letter-spacing 2px
|
||||||
|
Poster-Name, Hero — → Bangers 32px/0.95 (der eine Ruf)
|
||||||
|
Poster-Name, TRAINING-Paar 17px → 27px/1
|
||||||
|
Poster-Name, CASUAL-Stapel 19px → 30px/1
|
||||||
|
Karten-/Platz-Name 15px → 22px
|
||||||
|
Difficulty-Chip 22px → 30px
|
||||||
|
Training-Tile-Name 12px → 17px/1
|
||||||
|
Sekundär-Button 13px → 18px
|
||||||
|
Zahlen, Labels, Chips Silkscreen 8–10px (unverändert)
|
||||||
|
Primär-Button Bangers 26px (unverändert)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
Alle Assets im Prototyp stammen aus dem Repo, unverändert — nichts muss neu gezeichnet werden:
|
||||||
|
`assets/logo.png`, `menu_bg.png`, `mode_cup|arcade|train|gore|classic|vs.png`,
|
||||||
|
`court_clay|hard|grass|gloom.png`, `badge_l3.png`, `z_{sh,bb,sp,gk,rm,st,ma,ru,sb,sg,lj,cu}head.png`,
|
||||||
|
`fonts/{Silkscreen-Regular,Silkscreen-Bold,Bangers-Regular}.ttf`.
|
||||||
|
NEU nötig: `fonts/Jersey15-Subset.ttf` (Google Fonts, SIL OFL) — ersetzt `Bungee-Subset.ttf`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `Menues.dc.html` — der Prototyp. Runde 5 oben ist verbindlich; darunter Runde 4 (Schriftwahl),
|
||||||
|
3a (Bebas-Fassung), 2 (Bungee vs. Bangers), 1 (Grundrichtungen), IST-Stand.
|
||||||
|
- `support.js` — Runtime des Prototyps, für die Implementierung irrelevant.
|
||||||
|
- `assets/` — die Repo-Assets, die der Prototyp lädt.
|
||||||
|
|
||||||
|
Betroffene Repo-Dateien: `index.html` (CSS + Markup der Panels), `src/main.js`
|
||||||
|
(`banner`, `buildTops`, `buildSub`, `buildTrain`, `refreshSplash`), `src/config.js`
|
||||||
|
(`MENU`-Gruppen, `cupFixture.line`, ggf. `MODES.sub`), `src/render.js` (`modalRows`,
|
||||||
|
`modalLayout`), `src/beats.js` (die `rules`-Payloads).
|
||||||
|
|
||||||
|
## Vor dem Commit
|
||||||
|
`node test.js` muss grün sein (`CLAUDE.md`, Punkt 2) — die Fixture-Deskriptor-Tests hängen an
|
||||||
|
`cupFixture`, also die `line`-Kürzung dort mit anpassen. `main.js?v=N` bumpen.
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -47,13 +47,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
@font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
|
@font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
|
||||||
src: url('assets/fonts/Bangers-Regular.ttf?v=86') format('truetype'); }
|
src: url('assets/fonts/Bangers-Regular.ttf?v=86') format('truetype'); }
|
||||||
/* ...and the UPRIGHT display face (2026-08-31). Bangers is drawn slanted — there is no upright
|
/* ...and the UPRIGHT display face (2026-08-31). Bangers is drawn slanted — there is no upright
|
||||||
cut of it, which is also why the chevrons are Silkscreen — so the shelf labels that wanted to
|
cut of it, which is also why the chevrons are Silkscreen — so everything that NAMES rather
|
||||||
shout without leaning needed a third voice. Bungee is an arcade/signage face, upper case by
|
than announces needed a third voice. Bungee held it for an afternoon; JERSEY 15 took it in
|
||||||
design, and it stays in-world where a system sans did not. SIL OFL, licence beside it.
|
the menu-text-reduction handoff, because it sits on the same pixel grid as Silkscreen (it
|
||||||
SUBSET to the characters this game actually sets (ASCII plus the few marks the copy uses):
|
reads as one family with the sprite art) and runs proportionally lighter, where Bungee was
|
||||||
10 KB instead of 119 — pyftsubset, and the command is in PROJECT.md if it ever needs redoing. */
|
single-weight and blocky. SIL OFL, licence beside it, SUBSET to the characters this game
|
||||||
@font-face { font-family: 'Bungee'; font-weight: 400; font-display: block;
|
actually sets — 9 KB; the pyftsubset command is in PROJECT.md. Sizes run ~+40% against the
|
||||||
src: url('assets/fonts/Bungee-Subset.ttf?v=86') format('truetype'); }
|
Bungee values: Jersey sits lower in its box. */
|
||||||
|
@font-face { font-family: 'Jersey 15'; font-weight: 400; font-display: block;
|
||||||
|
src: url('assets/fonts/Jersey15-Subset.ttf?v=87') format('truetype'); }
|
||||||
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
|
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
|
||||||
laid out in the root's own box — and with the canvas position:fixed the root has no content and
|
laid out in the root's own box — and with the canvas position:fixed the root has no content and
|
||||||
collapses, so the gradient render.js sets had nowhere to go. */
|
collapses, so the gradient render.js sets had nowhere to go. */
|
||||||
@@ -121,7 +123,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
#quit[hidden], #qAsk[hidden] { display: none; }
|
#quit[hidden], #qAsk[hidden] { display: none; }
|
||||||
.qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px;
|
.qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px;
|
||||||
width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; }
|
width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; }
|
||||||
.qBox p { margin: 0 0 6px; font: 400 12px Silkscreen, monospace; color: var(--cream);
|
/* it is a QUESTION, and a question calls — the one Bangers line on this overlay */
|
||||||
|
.qBox p { margin: 0 0 10px; font: 400 20px Bangers, cursive; color: var(--cream);
|
||||||
text-align: center; letter-spacing: 1px; }
|
text-align: center; letter-spacing: 1px; }
|
||||||
@media (pointer: coarse) { #hint { display: none; } }
|
@media (pointer: coarse) { #hint { display: none; } }
|
||||||
/* ...and it is UI like any other: gone while the splash is still loading (the keyboard hints
|
/* ...and it is UI like any other: gone while the splash is still loading (the keyboard hints
|
||||||
@@ -236,14 +239,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
/* THREE REGISTERS NOW (2026-08-31). Bangers still ANNOUNCES — the primary button, PROMOTED.,
|
/* THREE REGISTERS NOW (2026-08-31). Bangers still ANNOUNCES — the primary button, PROMOTED.,
|
||||||
NEW PERSONAL BEST, and everything the canvas shouts (SET, CHAPTER, EPIC!). Silkscreen is
|
NEW PERSONAL BEST, and everything the canvas shouts (SET, CHAPTER, EPIC!). Silkscreen is
|
||||||
still the HUD: numbers, labels, anything you read at leisure. And the SCREEN TITLES moved to
|
still the HUD: numbers, labels, anything you read at leisure. And the SCREEN TITLES moved to
|
||||||
Bungee: upright signage, which is what the strip at the top of a screen actually is — you are
|
Jersey 15: upright signage, which is what the strip at the top of a screen actually is — you
|
||||||
not being shouted at, you are being told which room you are in. */
|
are not being shouted at, you are being told which room you are in. */
|
||||||
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
|
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
|
||||||
/* #intro h2 above carries an id, so this needs one too or the announcement face wins */
|
/* #intro h2 above carries an id, so this needs one too or the announcement face wins */
|
||||||
#intro .hdr h2 { font: 400 23px/1.2 Bungee, Bangers, cursive; letter-spacing: 0.5px; }
|
#intro .hdr h2 { font: 400 33px/1 'Jersey 15', Bangers, cursive; letter-spacing: 1px; }
|
||||||
#intro h3 { display: flex; justify-content: space-between; margin: 20px 0 9px;
|
/* A RUBRIC IS ONE WORD (2026-08-31, the text reduction), in the face that names rooms — and any
|
||||||
font: 400 10px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px;
|
hint it carries is a chip on the right, not a second sentence. */
|
||||||
text-align: left; }
|
#intro h3 span { font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||||||
|
#intro h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 20px 0 9px;
|
||||||
|
font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; color: var(--acid);
|
||||||
|
letter-spacing: 2px; text-align: left; }
|
||||||
/* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */
|
/* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */
|
||||||
.hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px;
|
.hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px;
|
||||||
padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line);
|
padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line);
|
||||||
@@ -284,8 +290,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; }
|
font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; }
|
||||||
.two { display: flex; gap: 8px; }
|
.two { display: flex; gap: 8px; }
|
||||||
.two .btn { margin-bottom: 0; }
|
.two .btn { margin-bottom: 0; }
|
||||||
.btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace;
|
/* the secondary doors NAME (Jersey) and carry a NUMBER (Silkscreen, acid) — the text reduction
|
||||||
font-style: normal; color: var(--dim); letter-spacing: 0.5px; }
|
turned "RANKINGS / WORLD BOARD" into "BOARD / BEST 612": a label and a fact, no description */
|
||||||
|
.btn.sec { font: 400 18px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
|
||||||
|
#qNo { font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
|
||||||
|
.btn.sec i { display: block; margin-top: 3px; font: 700 10px Silkscreen, monospace;
|
||||||
|
font-style: normal; color: var(--acid); letter-spacing: 1px; }
|
||||||
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
|
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
|
||||||
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
|
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
|
||||||
#who, #who2 { display: flex; gap: 12px; align-items: flex-start; }
|
#who, #who2 { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
@@ -396,32 +406,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
crest on the left, name over the big LEVEL · LEAGUE line beside it, the chevron on the
|
crest on the left, name over the big LEVEL · LEAGUE line beside it, the chevron on the
|
||||||
right; the bar runs the full width below and the goal line closes left-aligned. Translucent
|
right; the bar runs the full width below and the goal line closes left-aligned. Translucent
|
||||||
surface, generous rows — the splash art, blood pool included, stays visible around it. */
|
surface, generous rows — the splash art, blood pool included, stays visible around it. */
|
||||||
#plaque { position: relative; display: grid; column-gap: 14px; align-items: center; text-align: left;
|
/* ONE ROW (2026-08-31, the text reduction): crest, then name and level on one baseline, the bar
|
||||||
grid-template-columns: auto 1fr;
|
under them with the level it fills toward at its right end. The GOAL LINE is gone — the
|
||||||
grid-template-areas: "badge name" "badge lv" "bar bar" "goal goal";
|
fixture button right below says what to do, and saying it twice was the whole complaint. */
|
||||||
|
#plaque { position: relative; display: flex; align-items: center; gap: 13px; text-align: left;
|
||||||
margin: 24px auto 0; width: min(330px, 92%); box-sizing: border-box;
|
margin: 24px auto 0; width: min(330px, 92%); box-sizing: border-box;
|
||||||
padding: 18px 34px 15px 18px; background: rgba(8, 14, 10, 0.48); border: 0;
|
padding: 13px 36px 13px 15px; background: rgba(8, 14, 10, 0.48); border: 0;
|
||||||
border-radius: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
border-radius: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||||
#plBadge { grid-area: badge; height: 54px; image-rendering: pixelated;
|
#plBadge { flex: none; height: 50px; image-rendering: pixelated;
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
|
||||||
#plName { grid-area: name; align-self: end; padding-bottom: 3px;
|
#plCol { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||||
font: 700 13px Silkscreen, monospace; color: var(--cream); letter-spacing: 2px;
|
#plTop { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||||
white-space: nowrap; overflow: hidden; }
|
#plName { font: 400 21px 'Jersey 15', Bangers, cursive; color: var(--cream); letter-spacing: 0.5px;
|
||||||
#plLv { grid-area: lv; align-self: start; font: 400 26px Bangers, cursive; color: var(--acid);
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
letter-spacing: 1.5px; text-decoration: none; text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
|
/* the CALL belongs to the button below, not to the plaque — the level is a number now */
|
||||||
/* the bar wears its ENDPOINTS — "20 ▓▓░░ 21" says "fill toward the next level" without a
|
#plLv { flex: none; font: 700 10px Silkscreen, monospace; color: var(--acid); letter-spacing: 1px;
|
||||||
word of explanation (Oliver read the bare bar as total progress) */
|
text-decoration: none; }
|
||||||
#plRow { grid-area: bar; display: flex; align-items: center; gap: 7px; width: auto;
|
/* the bar wears the level it fills TOWARD at its right end — "▓▓░░ 13" says which way it runs
|
||||||
margin-top: 15px; }
|
without a word of explanation (Oliver read a bare bar as total progress) */
|
||||||
|
#plRow { display: flex; align-items: center; gap: 7px; }
|
||||||
#plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream);
|
#plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream);
|
||||||
text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); }
|
text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); }
|
||||||
/* NO nowrap here: the longest goal line is ~380px and a nowrap child sets the CARD's
|
|
||||||
min-content past the viewport — that was the phantom horizontal scrollbar Oliver spotted */
|
|
||||||
/* LITERAL gold — there is no --warm in the page palette (UI.warm is a CANVAS colour), and an
|
|
||||||
unresolvable var() painted this line black */
|
|
||||||
#plGoal { grid-area: goal; margin-top: 10px; font: 400 9px Silkscreen, monospace;
|
|
||||||
font-style: normal; color: #e0cc72; letter-spacing: 1px;
|
|
||||||
text-shadow: 1px 1px 0 rgba(0,0,0,0.5); text-align: left; line-height: 1.6; }
|
|
||||||
#plaque em { position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
#plaque em { position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
||||||
font: 700 15px Silkscreen, monospace; font-style: normal; color: var(--acid); }
|
font: 700 15px Silkscreen, monospace; font-style: normal; color: var(--acid); }
|
||||||
#plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10);
|
#plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10);
|
||||||
@@ -458,10 +463,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
text-align: center; line-height: 1.35; }
|
text-align: center; line-height: 1.35; }
|
||||||
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
|
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
|
||||||
letter-spacing: 1px; }
|
letter-spacing: 1px; }
|
||||||
/* THE REVANCHE TILE (THE SEASON): the one that put you out of the cup, marked and standing
|
/* THE REVANCHE ROW (THE SEASON, widened in the text reduction): the one that put you out of the
|
||||||
first. Red border and a red line, because it is the only tile on this screen with a reason. */
|
cup, across the top of the grid — one body out of eighteen with an actual reason, so it gets
|
||||||
#trainList button.put { border-color: var(--red); }
|
the width to say so in three words. */
|
||||||
#trainList button.put i { color: #e08a7c; }
|
#trainList button.put { grid-column: 1 / -1; flex-direction: row; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px; text-align: left; position: relative;
|
||||||
|
border-color: var(--red); background: rgba(164,35,31,0.16); }
|
||||||
|
#trainList button.put img { height: 52px; }
|
||||||
|
#trainList button.put > span { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px;
|
||||||
|
text-align: left; }
|
||||||
|
#trainList button.put b { font: 400 24px/1.05 'Jersey 15', Silkscreen, monospace; text-align: left; }
|
||||||
|
#trainList button.put i { color: #e08a7c; letter-spacing: 1px; }
|
||||||
|
#trainList button.put em { position: absolute; right: 12px; font: 700 15px Silkscreen, monospace;
|
||||||
|
font-style: normal; color: var(--bad); }
|
||||||
|
/* the rung's NAME (Jersey) over its number — "No. 7" was three characters of nothing */
|
||||||
|
#trainList b { font: 400 17px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
|
||||||
|
/* ...and the list ends on the league that opens the rest of the ladder, drawn as the goal it is */
|
||||||
|
#trainList .goal { grid-column: 1 / -1; padding: 14px 6px; border: 2px dashed var(--line);
|
||||||
|
text-align: center; }
|
||||||
|
#trainList .goal b { display: block; color: #4a5a52; }
|
||||||
|
#trainList .goal i { font: 400 9px Silkscreen, monospace; font-style: normal; color: #4a5a52;
|
||||||
|
letter-spacing: 1px; }
|
||||||
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
|
/* 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 */
|
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; }
|
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
|
||||||
@@ -511,8 +533,26 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it
|
as a second card headline and the two competed); Silkscreen, big and letter-spaced, keeps it
|
||||||
a label of a room. No rule, no left band either — the air above each heading does the
|
a label of a room. No rule, no left band either — the air above each heading does the
|
||||||
grouping, which is what air is for. */
|
grouping, which is what air is for. */
|
||||||
#modeList .mgroup { margin: 0 0 12px; }
|
#modeList .mgroup { margin: 0 0 10px; }
|
||||||
#modeList .mgroup + .mgroup { margin-top: 26px; }
|
#modeList .mgroup + .mgroup { margin-top: 17px; }
|
||||||
|
/* HOW A ROOM DEALS ITS POSTERS (the text reduction). The wall used to be six identical 148px
|
||||||
|
slabs and ran over two screens; the fixture is a HERO now, the training pair stands side by
|
||||||
|
side, and the casual three stack shorter. All of it fits one screen. */
|
||||||
|
#modeList .mgroup .shelf { display: flex; flex-direction: column; }
|
||||||
|
#modeList .mgroup.pair .shelf { flex-direction: row; gap: 8px; }
|
||||||
|
#modeList .mgroup.pair button.mode { flex: 1; min-width: 0; min-height: 106px; margin-bottom: 0; }
|
||||||
|
#modeList .mgroup.stack button.mode { min-height: 96px; }
|
||||||
|
#modeList .mgroup.hero button.mode { min-height: 142px; }
|
||||||
|
/* the poster names: Jersey NAMES, and the hero is the one poster that CALLS (Bangers) */
|
||||||
|
#modeList .cb b { font: 400 30px/1 'Jersey 15', Bangers, cursive; letter-spacing: 0.5px; }
|
||||||
|
#modeList .mgroup.pair .cb b { font-size: 27px; }
|
||||||
|
#modeList .mgroup.hero .cb b { font: 400 32px/0.95 Bangers, cursive; letter-spacing: 1px; }
|
||||||
|
/* ...and the hero's second line is the fixture, the only strap left on the wall */
|
||||||
|
#modeList .mgroup.hero .cb i { color: #f0cfc4; font-size: 10px; margin-top: 3px; }
|
||||||
|
/* a fact that changes what a mode IS, not a description of it. Top right like LAST, and UNDER
|
||||||
|
it on the one poster that could wear both (the chip is appended after LAST in main.banner) */
|
||||||
|
#modeList .last.chip { top: 7px; }
|
||||||
|
#modeList .last + .last.chip { top: 30px; }
|
||||||
/* text-align rides down from the card, which centres everything on these screens — the section
|
/* text-align rides down from the card, which centres everything on these screens — the section
|
||||||
header is a header, and headers on this screen are left-aligned (the .hdr title, the posters'
|
header is a header, and headers on this screen are left-aligned (the .hdr title, the posters'
|
||||||
own captions) */
|
own captions) */
|
||||||
@@ -525,7 +565,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
descendant selector here restyled every caption on the wall (it stayed invisible while this
|
descendant selector here restyled every caption on the wall (it stayed invisible while this
|
||||||
rule still named Silkscreen — the moment the family changed, the posters changed with it). */
|
rule still named Silkscreen — the moment the family changed, the posters changed with it). */
|
||||||
#modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid);
|
#modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid);
|
||||||
text-shadow: none; font: 400 15px/1.3 Bungee, Bangers, cursive; }
|
text-shadow: none; font: 400 17px/1.2 'Jersey 15', Bangers, cursive;
|
||||||
|
letter-spacing: 2px; }
|
||||||
/* ...and the strap goes back to the quiet register under it: a shouted line over a system-sans
|
/* ...and the strap goes back to the quiet register under it: a shouted line over a system-sans
|
||||||
line was two foreign voices in one header */
|
line was two foreign voices in one header */
|
||||||
#modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px;
|
#modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px;
|
||||||
@@ -541,18 +582,31 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
#stmode button.mode, #modeSub button.mode { min-height: 148px; }
|
#stmode button.mode, #modeSub button.mode { min-height: 148px; }
|
||||||
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
|
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
#modeSub button.mode { background-position: center; }
|
#modeSub button.mode { background-position: center; }
|
||||||
#modeSub button.chip { flex: 1 1 100%; padding: 22px 0; background: var(--panel); border: 2px solid var(--line);
|
#modeSub button.chip { flex: 1 1 100%; display: flex; align-items: center; justify-content: space-between;
|
||||||
color: var(--cream); font: 400 13px Silkscreen, monospace; letter-spacing: 2px;
|
gap: 10px; padding: 26px 16px; background: var(--panel); border: 2px solid var(--line);
|
||||||
cursor: pointer; -webkit-tap-highlight-color: transparent; position: relative; }
|
color: var(--cream); cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||||
|
position: relative; text-align: left; }
|
||||||
|
#modeSub button.chip b { font: 400 30px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 1px; }
|
||||||
|
/* how hard it is, counted: as many pips lit as the chip is steep, and the last one wears the
|
||||||
|
colour the game uses for a threat */
|
||||||
|
.pips { display: flex; gap: 5px; }
|
||||||
|
.pips i { width: 10px; height: 10px; background: rgba(255,255,255,0.12); }
|
||||||
|
.pips i.on { background: var(--acid); }
|
||||||
|
#modeSub button.chip.hot .pips i.on { background: var(--bad); }
|
||||||
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
|
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
|
||||||
margin: 0 0 8px; padding: 7px; background: var(--panel);
|
margin: 0 0 8px; padding: 7px; background: var(--panel);
|
||||||
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
|
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
|
||||||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||||
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
||||||
#courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none;
|
#courtList img { width: 84px; height: 60px; display: block; image-rendering: pixelated; flex: none;
|
||||||
object-fit: cover; }
|
object-fit: cover; }
|
||||||
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
|
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
|
||||||
#courtList b, #modeList b, #modeSub b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
|
#courtList b, #modeList b, #modeSub b { display: block; font: 700 15px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
|
||||||
|
/* the court NAMES itself (Jersey) and its two meters say the rest — the description line is gone */
|
||||||
|
#courtList b { font: 400 22px/1.1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
|
||||||
|
#courtList i { margin: 3px 0 5px; font: 700 9px Silkscreen, monospace; color: var(--bad);
|
||||||
|
letter-spacing: 1px; }
|
||||||
|
#courtList i[hidden] { display: none; }
|
||||||
#courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; }
|
#courtList i, #modeList i, #modeSub i { display: block; margin: 4px 0 6px; font: 400 10px Silkscreen, monospace; color: #c9c4b8; font-style: normal; }
|
||||||
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
|
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
|
||||||
drift away from what the ball actually does on it. */
|
drift away from what the ball actually does on it. */
|
||||||
@@ -613,7 +667,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
title and the court stay — they are the background and they are never undefined. */
|
title and the court stay — they are the background and they are never undefined. */
|
||||||
#splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
|
#splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
|
||||||
let the title slide down the screen and jump back up the moment the profile landed */
|
let the title slide down the screen and jump back up the moment the profile landed */
|
||||||
#sSum, #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
|
#brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
|
||||||
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||||||
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
|
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
|
||||||
.tabs button { flex: 1; padding: 11px 2px; background: var(--panel); border: 2px solid var(--line);
|
.tabs button { flex: 1; padding: 11px 2px; background: var(--panel); border: 2px solid var(--line);
|
||||||
@@ -738,10 +792,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). -->
|
so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). -->
|
||||||
<button id="plaque">
|
<button id="plaque">
|
||||||
<img id="plBadge" alt="">
|
<img id="plBadge" alt="">
|
||||||
<b id="plName"></b>
|
<span id="plCol">
|
||||||
<u id="plLv">LEVEL 1</u>
|
<span id="plTop"><b id="plName"></b><u id="plLv">LEVEL 1</u></span>
|
||||||
<span id="plRow"><u id="plFrom">1</u><s><i id="plFill"></i></s><u id="plTo">2</u></span>
|
<span id="plRow"><s><i id="plFill"></i></s><u id="plTo">2</u></span>
|
||||||
<i id="plGoal"></i>
|
</span>
|
||||||
<em>></em>
|
<em>></em>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -756,10 +810,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
already knows the answer to. It names the next cup fixture now and enters the draw;
|
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. -->
|
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 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>
|
<button class="btn sec" id="mOther">OTHER GAMES</button>
|
||||||
<div class="two">
|
<div class="two">
|
||||||
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
|
<button class="btn sec" id="mStats">STATS<i id="mKills">0</i></button>
|
||||||
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button>
|
<button class="btn sec" id="mRanks">BOARD<i id="mBest">BEST 0</i></button>
|
||||||
</div>
|
</div>
|
||||||
<p id="build"></p>
|
<p id="build"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -813,7 +867,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
<h3 id="s02">02 · COURT</h3>
|
<h3 id="s02">02 · COURT</h3>
|
||||||
<div id="courtList"></div>
|
<div id="courtList"></div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<p id="sSum"></p>
|
|
||||||
<button class="btn big" id="cStart">SERVE UP</button>
|
<button class="btn big" id="cStart">SERVE UP</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -893,7 +946,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
|
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
|
||||||
<div id="train">
|
<div id="train">
|
||||||
<div class="hdr"><button class="chev" id="trBack"><</button><h2>TRAINING</h2></div>
|
<div class="hdr"><button class="chev" id="trBack"><</button><h2>TRAINING</h2></div>
|
||||||
<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>
|
|
||||||
<div id="trainList"></div>
|
<div id="trainList"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
|
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
|
||||||
@@ -903,17 +955,17 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
<div id="vs">
|
<div id="vs">
|
||||||
<div class="hdr"><button class="chev" id="vsBack"><</button><h2>VS A FRIEND</h2></div>
|
<div class="hdr"><button class="chev" id="vsBack"><</button><h2>VS A FRIEND</h2></div>
|
||||||
<div id="vsPick">
|
<div id="vsPick">
|
||||||
<h3>01 · YOUR NAME<span>ON THEIR SCOREBOARD</span></h3>
|
<h3>YOU</h3>
|
||||||
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
|
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
|
||||||
autocapitalize="characters" spellcheck="false">
|
autocapitalize="characters" spellcheck="false">
|
||||||
<h3>02 · HOST A MATCH<span>THEY COME TO YOU</span></h3>
|
<h3>HOST</h3>
|
||||||
<button class="btn" id="vsHostC">CLASSIC TENNIS</button>
|
<button class="btn" id="vsHostC">CLASSIC</button>
|
||||||
<button class="btn" id="vsHostD">DEATH MATCH</button>
|
<button class="btn" id="vsHostD">DEATH MATCH</button>
|
||||||
<h3>OR JOIN THEIRS<span>WITH THEIR CODE</span></h3>
|
<h3>JOIN</h3>
|
||||||
<div class="joinrow">
|
<div class="joinrow">
|
||||||
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
|
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
|
||||||
autocapitalize="characters" spellcheck="false">
|
autocapitalize="characters" spellcheck="false">
|
||||||
<button class="btn" id="vsJoin">JOIN</button>
|
<button class="btn" id="vsJoin">GO</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="vsErr"></p>
|
<p id="vsErr"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -999,6 +1051,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=341"></script>
|
<script type="module" src="src/main.js?v=342"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
|
// (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.)
|
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
|
||||||
|
|
||||||
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, foeLevel } from './config.js';
|
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js';
|
||||||
import { endRun, MODE_MATCH } from './stats.js';
|
import { endRun, MODE_MATCH } from './stats.js';
|
||||||
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup } 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 { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
|
||||||
@@ -85,8 +85,8 @@ export function startDuel() {
|
|||||||
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
|
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
|
||||||
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'],
|
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'],
|
||||||
// both stakes named (the old card only sold the win): a lost duel is a real cost
|
// both stakes named (the old card only sold the win): a lost duel is a real cost
|
||||||
rules: [{ lead: "IT'S TENNIS AGAIN — ONE POINT", sub: 'WIN: THEY HAND YOU A WEAPON' },
|
rules: ['WIN IT AND THEY HAND YOU A WEAPON',
|
||||||
{ lead: 'LOSE: THE DEAD TAKE A GAME', sub: 'AND FIVE MORE CLIMB THE FENCE', hot: true }] });
|
{ lead: 'LOSE IT AND FIVE MORE CLIMB THE FENCE', hot: true }] });
|
||||||
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
|
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,17 +394,15 @@ export function startMatch(cup = false, draw = null, train = 0) {
|
|||||||
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
|
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
|
||||||
body: ['ONE BODY, ONE GAME AT A TIME',
|
body: ['ONE BODY, ONE GAME AT A TIME',
|
||||||
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'],
|
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'],
|
||||||
rules: [{ lead: 'ONE BODY, ONE GAME AT A TIME', sub: 'FELL IT OR BLEED OUT — IT COMES BACK' },
|
rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] });
|
||||||
{ lead: 'NO RUN IS BANKED', sub: 'REPS PAY QUARTER XP' }] });
|
|
||||||
} else if (cup && !G.seen.cup) {
|
} else if (cup && !G.seen.cup) {
|
||||||
G.seen.cup = true;
|
G.seen.cup = true;
|
||||||
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
|
showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
|
||||||
// plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys
|
// plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys
|
||||||
body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE',
|
body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE',
|
||||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
||||||
rules: [{ lead: 'YOUR LEAGUE, ROUND BY ROUND', sub: 'WIN IT ALL — MOVE UP A LEAGUE' },
|
rules: ['WIN IT ALL — MOVE UP A LEAGUE',
|
||||||
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true,
|
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
|
||||||
pips: TUNE.matchPlayerHp }] });
|
|
||||||
} else if (!cup && !train && !G.seen.match) {
|
} else if (!cup && !train && !G.seen.match) {
|
||||||
G.seen.match = true;
|
G.seen.match = true;
|
||||||
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
|
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
|
||||||
@@ -416,15 +414,13 @@ export function startMatch(cup = false, draw = null, train = 0) {
|
|||||||
? { title: 'DEATH MATCH', color: UI.hot,
|
? { title: 'DEATH MATCH', color: UI.hot,
|
||||||
body: ['WIN THE POINT — WOUND YOUR FRIEND',
|
body: ['WIN THE POINT — WOUND YOUR FRIEND',
|
||||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
||||||
rules: [{ lead: 'WIN THE POINT', sub: 'WOUND YOUR FRIEND' },
|
rules: ['WIN THE POINT — WOUND YOUR FRIEND',
|
||||||
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true,
|
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }
|
||||||
pips: TUNE.matchPlayerHp }] }
|
|
||||||
: { title: 'THE CLIMB', color: UI.hot,
|
: { title: 'THE CLIMB', color: UI.hot,
|
||||||
body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY',
|
body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY',
|
||||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
||||||
rules: [{ lead: 'THE WHOLE CLUB, BOTTOM UP', sub: 'ONE GAME PER BODY — HOW FAR CAN YOU GET?' },
|
rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?',
|
||||||
{ lead: 'ITS POINTS DRAW BLOOD', sub: 'AT ZERO YOU ARE EATEN', hot: true,
|
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
|
||||||
pips: TUNE.matchPlayerHp }] });
|
|
||||||
}
|
}
|
||||||
matchOpponent();
|
matchOpponent();
|
||||||
matchReady();
|
matchReady();
|
||||||
|
|||||||
@@ -856,13 +856,17 @@ export const MODES = [
|
|||||||
// canonical path does not live behind a fan-out. Its two former siblings (the climb, the training
|
// 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
|
// 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.
|
// job: CLASSIC's difficulty chips are all that still fan out.
|
||||||
|
// THE TEXT REDUCTION (2026-08-31, design handoff): a room is ONE WORD and the posters carry only
|
||||||
|
// their names. The straps went with the sentences — a shelf that has to explain itself is a shelf
|
||||||
|
// nobody reads. `layout` is how the room deals its posters: the fixture is a HERO with the one
|
||||||
|
// call on the screen, the training pair stands side by side, the casual three stack shorter.
|
||||||
export const MENU = [
|
export const MENU = [
|
||||||
{ group: 'THE SEASON', sub: 'THE ONLY WAY UP THE CLUB' },
|
{ group: 'SEASON', layout: 'hero' },
|
||||||
{ mode: 'cup' },
|
{ mode: 'cup' },
|
||||||
{ group: 'THE TRAINING', sub: 'SHARPEN UP · NOTHING HERE MOVES YOUR LEAGUE' },
|
{ group: 'TRAINING', layout: 'pair' },
|
||||||
{ mode: 'zombie' },
|
{ mode: 'zombie' },
|
||||||
{ mode: 'train' },
|
{ mode: 'train' },
|
||||||
{ group: 'OUT OF COMPETITION', sub: 'NO LEAGUE, NO RECORD, NO STAKES' },
|
{ group: 'CASUAL', layout: 'stack' },
|
||||||
{ mode: 'gore' },
|
{ mode: 'gore' },
|
||||||
{ mode: 'classic', diffs: true },
|
{ mode: 'classic', diffs: true },
|
||||||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||||||
@@ -963,7 +967,10 @@ export const cupFixture = (lg, champion = 0) => {
|
|||||||
const held = champion && lg >= LEAGUES.length; // the champion has nothing above him left
|
const held = champion && lg >= LEAGUES.length; // the champion has nothing above him left
|
||||||
return { lg, league: leagueOf(lg), rounds, rung: first.band[0], foe,
|
return { lg, league: leagueOf(lg), rounds, rung: first.band[0], foe,
|
||||||
title: held ? 'DEFEND THE TITLE' : 'LEAGUE ' + lg + ' · THE CUP',
|
title: held ? 'DEFEND THE TITLE' : 'LEAGUE ' + lg + ' · THE CUP',
|
||||||
line: rounds.length + ' ROUNDS · ' + first.name + ': ' + foe.name };
|
// the ROUND COUNT moved to `sub` (the text reduction): it belongs in the modal that
|
||||||
|
// explains the tournament, not on the button you press to enter it
|
||||||
|
line: first.name + ': ' + foe.name,
|
||||||
|
sub: 'LEAGUE ' + lg + ' · ' + rounds.length + ' ROUNDS' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL.
|
// How far a cup record reads, in the rounds' own words: 3 of 4 rounds reached is THE SEMI-FINAL.
|
||||||
|
|||||||
@@ -124,15 +124,21 @@ const modeSub = document.getElementById('modeSub');
|
|||||||
// checkbox and a button combined"). Every poster is full-bright and equal — all of them are live
|
// checkbox and a button combined"). Every poster is full-bright and equal — all of them are live
|
||||||
// answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The
|
// answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The
|
||||||
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
|
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
|
||||||
const banner = (card, cls, last) => {
|
// `opts.line` is the ONE second line the wall still carries (the fixture, under the hero's name);
|
||||||
|
// `opts.chip` is a fact that changes what a mode IS rather than describing it (18 RUNGS).
|
||||||
|
const banner = (card, cls, last, opts = {}) => {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.className = cls;
|
b.className = cls;
|
||||||
// JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the
|
// JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the
|
||||||
// caption sits on a BLURRED veil now (index.html, button.mode::after) that fades in out of
|
// caption sits on a veil now (index.html, button.mode::after) that rises out of the bottom edge,
|
||||||
// nothing from above, and stacking the old gradient under it only doubled the darkening.
|
// and stacking the old gradient under it only doubled the darkening.
|
||||||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||||||
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>`
|
// THE POSTER IS ITS NAME (2026-08-31, the text reduction). The mode's one-line rule used to sit
|
||||||
|
// under every name on the wall — sixty words to make one choice. The rules live in the modal
|
||||||
|
// that opens with the mode; MODES.sub is still their source, it just no longer prints here.
|
||||||
|
b.innerHTML = `<span class="cb"><b>${card.name}</b>${opts.line ? `<i>${opts.line}</i>` : ''}</span>`
|
||||||
+ (last ? '<u class="last">LAST</u>' : '')
|
+ (last ? '<u class="last">LAST</u>' : '')
|
||||||
|
+ (opts.chip ? `<u class="last chip">${opts.chip}</u>` : '')
|
||||||
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
|
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
|
||||||
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
|
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
|
||||||
return b;
|
return b;
|
||||||
@@ -145,23 +151,26 @@ function buildSub(top) {
|
|||||||
modeSub.innerHTML = '';
|
modeSub.innerHTML = '';
|
||||||
const t = top || topOf(pickedMode);
|
const t = top || topOf(pickedMode);
|
||||||
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
|
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
|
||||||
if (t.diffs) for (const d of DIFFS) {
|
// THE DIFFICULTY IS A COUNT, not a paragraph (2026-08-31, the text reduction): the name, and
|
||||||
|
// three pips of which as many are lit as the chip is hard. Nothing else — the CPU explains
|
||||||
|
// itself in the first rally, and it always did.
|
||||||
|
if (t.diffs) DIFFS.forEach((d, i) => {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.className = 'chip';
|
b.className = 'chip' + (i === DIFFS.length - 1 ? ' hot' : '');
|
||||||
b.innerHTML = d.name + (d.id === pickedDiff ? '<u class="last">LAST</u>' : '');
|
b.innerHTML = `<b>${d.name}</b><span class="pips">`
|
||||||
|
+ [0, 1, 2].map(k => `<i${k <= i ? ' class="on"' : ''}></i>`).join('') + '</span>'
|
||||||
|
+ (d.id === pickedDiff ? '<u class="last">LAST</u>' : '');
|
||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
pickedDiff = d.id;
|
pickedDiff = d.id;
|
||||||
try { localStorage.setItem('diff', d.id); } catch {}
|
try { localStorage.setItem('diff', d.id); } catch {}
|
||||||
summary();
|
|
||||||
go('setup');
|
go('setup');
|
||||||
});
|
});
|
||||||
modeSub.appendChild(b);
|
modeSub.appendChild(b);
|
||||||
}
|
});
|
||||||
else if (t.kids) for (const id of t.kids) {
|
else if (t.kids) for (const id of t.kids) {
|
||||||
const b = banner(modeOf(id), 'mode', id === pickedMode);
|
const b = banner(modeOf(id), 'mode', id === pickedMode);
|
||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
pickMode(id);
|
pickMode(id);
|
||||||
summary();
|
|
||||||
go('setup');
|
go('setup');
|
||||||
});
|
});
|
||||||
modeSub.appendChild(b);
|
modeSub.appendChild(b);
|
||||||
@@ -177,12 +186,18 @@ function buildTops() {
|
|||||||
// merely floats above a stack is a label, and the wall needs three rooms, not three labels.
|
// merely floats above a stack is a label, and the wall needs three rooms, not three labels.
|
||||||
if (t.group) {
|
if (t.group) {
|
||||||
shelf = document.createElement('section');
|
shelf = document.createElement('section');
|
||||||
shelf.className = 'mgroup';
|
shelf.className = 'mgroup ' + (t.layout || '');
|
||||||
shelf.innerHTML = `<header><b>${t.group}</b><i>${t.sub}</i></header>`;
|
shelf.innerHTML = `<header><b>${t.group}</b></header><div class="shelf"></div>`;
|
||||||
modeList.appendChild(shelf);
|
modeList.appendChild(shelf);
|
||||||
|
shelf = shelf.querySelector('.shelf'); // the posters live in the row, not the section
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
|
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||||
|
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t,
|
||||||
|
// the fixture is the hero and carries the only second line on the wall; the training court
|
||||||
|
// carries the one fact that is not a description — how much of the club it holds
|
||||||
|
{ line: t.mode === 'cup' ? 'LEAGUE ' + fx.lg + ' · ' + fx.line : null,
|
||||||
|
chip: t.mode === 'train' ? LADDER + ' RUNGS' : null });
|
||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
|
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
|
||||||
// the stats screen or the summary could name, and the lobby starts the game itself
|
// the stats screen or the summary could name, and the lobby starts the game itself
|
||||||
@@ -190,7 +205,6 @@ function buildTops() {
|
|||||||
// a group remembers which child you were on; entering it fresh lands on its first card
|
// a group remembers which child you were on; entering it fresh lands on its first card
|
||||||
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
|
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
|
||||||
buildSub();
|
buildSub();
|
||||||
summary();
|
|
||||||
go(t.diffs || t.kids ? 'modesub' : 'setup');
|
go(t.diffs || t.kids ? 'modesub' : 'setup');
|
||||||
});
|
});
|
||||||
shelf.appendChild(b);
|
shelf.appendChild(b);
|
||||||
@@ -286,7 +300,6 @@ for (const t of TOURS) {
|
|||||||
pickedTour = t.id;
|
pickedTour = t.id;
|
||||||
for (const el of tourList.children) el.classList.toggle('on', el === b);
|
for (const el of tourList.children) el.classList.toggle('on', el === b);
|
||||||
refreshLook();
|
refreshLook();
|
||||||
summary();
|
|
||||||
});
|
});
|
||||||
tourList.appendChild(b);
|
tourList.appendChild(b);
|
||||||
}
|
}
|
||||||
@@ -398,15 +411,17 @@ function syncSetup() {
|
|||||||
document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP';
|
document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP';
|
||||||
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
|
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
|
||||||
document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none';
|
document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none';
|
||||||
|
// THE TEXT REDUCTION: two blocks on one screen, not two numbered steps
|
||||||
document.getElementById('s01').textContent =
|
document.getElementById('s01').textContent =
|
||||||
cupMode ? '01 · YOUR LEAGUE' : climbMode ? '01 · THE CLIMB' : '01 · PLAYER';
|
cupMode ? 'YOUR LEAGUE' : climbMode ? 'THE CLIMB' : 'PLAYER';
|
||||||
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
|
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
|
||||||
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
|
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
|
||||||
// the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice
|
// the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice
|
||||||
// (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list.
|
// (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list.
|
||||||
const homeCourt = leagueOf(profile.prof.lg).court;
|
const homeCourt = leagueOf(profile.prof.lg).court;
|
||||||
|
// ...and the meters get their legend ONCE, in the heading, instead of a line under every court
|
||||||
document.getElementById('s02').innerHTML = cupMode
|
document.getElementById('s02').innerHTML = cupMode
|
||||||
? '02 · HOME COURT<span>SET BY YOUR LEAGUE</span>' : '02 · COURT';
|
? 'HOME COURT<span>SET BY YOUR LEAGUE</span>' : 'COURT<span>PACE · BOUNCE</span>';
|
||||||
courtList.classList.toggle('fixed', cupMode);
|
courtList.classList.toggle('fixed', cupMode);
|
||||||
for (const el of courtList.children) {
|
for (const el of courtList.children) {
|
||||||
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
|
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
|
||||||
@@ -415,8 +430,11 @@ function syncSetup() {
|
|||||||
el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt
|
el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt
|
||||||
: open && el.dataset.id === picked);
|
: open && el.dataset.id === picked);
|
||||||
el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : '';
|
el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : '';
|
||||||
// the sub line carries the lock and names the price — the tile stays visible, as a goal should
|
// an OPEN court says nothing — its two meters say it wordlessly (the text reduction). A locked
|
||||||
el.querySelector('i').textContent = open ? c.sub : 'LOCKED — REACH ' + l.name;
|
// one names the league that opens it, and nothing else: the tile stays visible, as a goal should
|
||||||
|
const sub = el.querySelector('i');
|
||||||
|
sub.textContent = open ? '' : 'LEAGUE ' + l.id;
|
||||||
|
sub.hidden = open;
|
||||||
}
|
}
|
||||||
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
|
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
|
||||||
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
|
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
|
||||||
@@ -425,8 +443,7 @@ function syncSetup() {
|
|||||||
el.classList.toggle('on', el.dataset.id === cur);
|
el.classList.toggle('on', el.dataset.id === cur);
|
||||||
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
|
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
|
||||||
}
|
}
|
||||||
refreshLook(); summary();
|
refreshLook();}
|
||||||
}
|
|
||||||
|
|
||||||
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
|
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
|
||||||
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
|
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
|
||||||
@@ -477,8 +494,10 @@ pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElem
|
|||||||
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
|
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
|
||||||
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
|
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
|
||||||
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
|
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
|
||||||
|
// the label is a TITLE, not a printed word (the text reduction): the legend stands once over the
|
||||||
|
// list — two bars in a fixed order need naming once, not on every row
|
||||||
const meter = (label, v, cls = '') =>
|
const meter = (label, v, cls = '') =>
|
||||||
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
|
`<div class="meter ${cls}" title="${label}"><s><i style="width:${
|
||||||
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
|
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
|
||||||
const courtList = document.getElementById('courtList');
|
const courtList = document.getElementById('courtList');
|
||||||
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
|
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
|
||||||
@@ -491,7 +510,7 @@ for (const c of COURTS) {
|
|||||||
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
|
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
|
||||||
b.dataset.id = c.id;
|
b.dataset.id = c.id;
|
||||||
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
|
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
|
||||||
+ `<b>${c.name}</b><i>${c.sub}</i>`
|
+ `<b>${c.name}</b><i hidden></i>` // the sub only ever carries a LOCK now (syncSetup)
|
||||||
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
|
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
|
||||||
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
|
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
|
||||||
+ '</span>';
|
+ '</span>';
|
||||||
@@ -502,7 +521,6 @@ for (const c of COURTS) {
|
|||||||
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
|
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
|
||||||
picked = c.id;
|
picked = c.id;
|
||||||
for (const el of courtList.children) el.classList.toggle('on', el === b);
|
for (const el of courtList.children) el.classList.toggle('on', el === b);
|
||||||
summary();
|
|
||||||
});
|
});
|
||||||
courtList.appendChild(b);
|
courtList.appendChild(b);
|
||||||
}
|
}
|
||||||
@@ -538,18 +556,38 @@ function buildTrain() {
|
|||||||
// in its own right — a practice list where every rung looks the same is a shelf, and this one
|
// 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.
|
// room in the game has an obvious reason to exist the moment you have just been put out.
|
||||||
const out = profile.prof.out;
|
const out = profile.prof.out;
|
||||||
const rungs = [];
|
const rung = n => {
|
||||||
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');
|
const f = matchFoe(n), b = document.createElement('button');
|
||||||
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'); });
|
b.addEventListener('click', () => { trainRung = n; start('train'); });
|
||||||
|
return [f, b];
|
||||||
|
};
|
||||||
|
// THE REVANCHE IS A ROW, not a tile in the grid (the text reduction): it is one body out of
|
||||||
|
// eighteen and the only one with a reason, so it gets the width to say so — and says it in
|
||||||
|
// three words, because the screen it sits on is already called TRAINING.
|
||||||
|
if (out >= 1 && out <= top) {
|
||||||
|
const [f, b] = rung(out);
|
||||||
|
b.className = 'put';
|
||||||
|
b.innerHTML = `<img src="${foeArt(f)}" alt=""><span><b>${f.name}</b><i>PUT YOU OUT</i></span>`
|
||||||
|
+ '<em>></em>';
|
||||||
trainList.appendChild(b);
|
trainList.appendChild(b);
|
||||||
}
|
}
|
||||||
|
for (let n = 1; n <= top; n++) {
|
||||||
|
if (n === out) continue;
|
||||||
|
const [f, b] = rung(n);
|
||||||
|
// the rung is a NUMBER on a list of numbers — two digits, no "No."
|
||||||
|
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
|
||||||
|
+ `<i>${String(n).padStart(2, '0')}</i>`;
|
||||||
|
trainList.appendChild(b);
|
||||||
|
}
|
||||||
|
// ...and the list ENDS somewhere: the next league's band is drawn as the goal it is, not left
|
||||||
|
// to stop dead at the cap with no explanation (the handoff's "Ziel, keine Liste")
|
||||||
|
if (top < LADDER) {
|
||||||
|
const next = LEAGUES.find(l => l.band[0] > top);
|
||||||
|
const goal = document.createElement('div');
|
||||||
|
goal.className = 'goal';
|
||||||
|
if (next) goal.innerHTML = `<b>LEAGUE ${next.id}</b><i>${next.band[0]} — ${next.band[1]}</i>`;
|
||||||
|
trainList.appendChild(goal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.getElementById('trBack').addEventListener('click', () => go('setup'));
|
document.getElementById('trBack').addEventListener('click', () => go('setup'));
|
||||||
|
|
||||||
@@ -679,62 +717,36 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD
|
|||||||
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||||
|
|
||||||
// what the setup footer says you are about to play, and what it is worth beating
|
// what the setup footer says you are about to play, and what it is worth beating
|
||||||
function summary() {
|
|
||||||
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
|
|
||||||
const lg = leagueOf(profile.prof.lg);
|
|
||||||
const cupMode = pickedMode === 'cup';
|
|
||||||
const c = COURTS.find(x => x.id === (cupMode ? lg.court : picked)) || COURTS[0];
|
|
||||||
const m = MODES.find(x => x.id === pickedMode) || MODES[0];
|
|
||||||
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
|
|
||||||
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
|
|
||||||
// (see cupWon), so its summary reads the same book it writes.
|
|
||||||
const best = stats.read('all', undefined, boardFor(m.id)).longest;
|
|
||||||
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
|
|
||||||
: best === 1 ? ' GAME' : ' GAMES';
|
|
||||||
const name = m.id === 'classic'
|
|
||||||
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
|
|
||||||
document.getElementById('sSum').innerHTML =
|
|
||||||
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${cupMode
|
|
||||||
? ' · LEAGUE ' + lg.id : ''}</span>`
|
|
||||||
// training banks nothing, so there is no mark to beat and the footer must not invent one
|
|
||||||
+ `<span>${m.id === 'train' ? 'NO RUN, NO DEATH — REPS PAY ¼ XP'
|
|
||||||
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
|
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
|
||||||
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
|
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
|
||||||
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
|
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
|
||||||
// has just ended and the count has moved.
|
// has just ended and the count has moved.
|
||||||
function refreshSplash() {
|
function refreshSplash() {
|
||||||
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
|
// THE TEXT REDUCTION (2026-08-31): the two secondary doors carry a NUMBER each instead of a
|
||||||
|
// description — what you have done, and your own best on the board they open. The board figure
|
||||||
|
// is read locally on purpose: the splash does not fetch the world board.
|
||||||
|
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB');
|
||||||
|
document.getElementById('mBest').textContent =
|
||||||
|
'BEST ' + stats.bestScore(boardFor(pickedMode)).toLocaleString('en-GB');
|
||||||
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
|
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
|
||||||
// moved it (same rule as the kill count above). The goal line is the standing answer to "what
|
// moved it (same rule as the kill count above). One row now: crest, name and level, and the bar
|
||||||
// do I do to advance": win the CURRENT league's cup, become champion, or rest on the crown.
|
// under them wearing the level it fills toward. The goal LINE is gone — the fixture button below
|
||||||
|
// says what to do, and it said it twice.
|
||||||
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
|
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
|
||||||
document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
|
document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
|
||||||
document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER';
|
document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER';
|
||||||
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level + ' · LEAGUE ' + lg.id;
|
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level;
|
||||||
document.getElementById('plFrom').textContent = pr.level;
|
|
||||||
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
|
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
|
||||||
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
|
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'
|
|
||||||
: 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
|
// 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
|
// (config.cupFixture) — minus the round count, which lives in the tournament's own modal now.
|
||||||
// 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);
|
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||||
document.getElementById('msTitle').textContent = fx.title;
|
document.getElementById('msTitle').textContent = fx.title;
|
||||||
document.getElementById('msLine').textContent = fx.line;
|
document.getElementById('msLine').textContent = fx.line;
|
||||||
summary();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
|
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
|
||||||
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
|
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
|
||||||
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
|
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
|
||||||
@@ -785,7 +797,7 @@ const goMode = () => {
|
|||||||
// confirmation page with nothing on it to confirm
|
// confirmation page with nothing on it to confirm
|
||||||
document.getElementById('mStart').addEventListener('click', () => {
|
document.getElementById('mStart').addEventListener('click', () => {
|
||||||
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
|
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
|
||||||
pickMode('cup'); summary(); start('cup');
|
pickMode('cup'); start('cup');
|
||||||
});
|
});
|
||||||
document.getElementById('mOther').addEventListener('click', goMode);
|
document.getElementById('mOther').addEventListener('click', goMode);
|
||||||
document.getElementById('mStats').addEventListener('click', openStats);
|
document.getElementById('mStats').addEventListener('click', openStats);
|
||||||
@@ -1106,7 +1118,7 @@ const maybeRestart = e => {
|
|||||||
if (run && run.newKillBest && run.score > 0) { askName(run); return; }
|
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
|
// 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)
|
// 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 === 'next') { pickMode('cup'); start('cup'); return; }
|
||||||
if (door === 'again') { start(pickedMode); return; }
|
if (door === 'again') { start(pickedMode); return; }
|
||||||
go('splash');
|
go('splash');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2368,16 +2368,21 @@ function modalRows(m, inner, skipBody) {
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
const push = r => { rows.push(r); return r; };
|
const push = r => { rows.push(r); return r; };
|
||||||
if (m.foe) {
|
if (m.foe) {
|
||||||
push({ kind: 'split', h: 32 });
|
push({ kind: 'split', h: 26 });
|
||||||
const sub = [];
|
const sub = [];
|
||||||
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner));
|
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 });
|
if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 });
|
||||||
} else if (m.rules) {
|
} else if (m.rules) {
|
||||||
for (const r of m.rules) {
|
// ONE RULE, ONE LINE (2026-08-31, the menu-text-reduction handoff). Every row used to carry a
|
||||||
const lead = wrapText(r.lead, F(8, 700), inner);
|
// statement and a consequence, and the consequence was usually the statement again in other
|
||||||
const sub = r.sub ? wrapText(r.sub, F(8, 0), inner) : [];
|
// words ("YOUR LEAGUE, ROUND BY ROUND" / "WIN IT ALL — MOVE UP A LEAGUE"). The line that
|
||||||
push({ kind: 'rule', lead, sub, hot: r.hot, pips: r.pips,
|
// names the RESULT is the one that survives. A rule is a string, or an object when it needs
|
||||||
h: lead.length * 12 + sub.length * 12 + (r.pips ? 12 : 0) + 2 });
|
// the threat colour or the blood pool. `pips` ride the SAME line, hard right.
|
||||||
|
for (const raw of m.rules) {
|
||||||
|
const r = typeof raw === 'string' ? { lead: raw } : raw;
|
||||||
|
const room = inner - (r.pips ? r.pips * 9 + 12 : 0);
|
||||||
|
const lead = wrapText(r.lead, F(8, 700), room);
|
||||||
|
push({ kind: 'rule', lead, hot: r.hot, pips: r.pips, h: lead.length * 12 + 4 });
|
||||||
}
|
}
|
||||||
} else if (!skipBody) {
|
} else if (!skipBody) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@@ -2415,7 +2420,11 @@ function modalLayout(m) {
|
|||||||
// ...and only when it actually FITS there: a line that wraps in the narrow column belongs under
|
// ...and only when it actually FITS there: a line that wraps in the narrow column belongs under
|
||||||
// the head as an ordinary row, not as a ragged column beside a picture
|
// the head as an ordinary row, not as a ragged column beside a picture
|
||||||
const beside = pairs && wrapped.length <= bodyLines.length + 1;
|
const beside = pairs && wrapped.length <= bodyLines.length + 1;
|
||||||
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) : beside ? wrapped : [];
|
// ONE line under the title: the body's slogan on a walk-on, the mode's own fact on a rules card
|
||||||
|
// (`m.strap` — LEAGUE 3 · 4 ROUNDS, the round count the fixture button gave up), or the two
|
||||||
|
// lines of an illustrated announcement.
|
||||||
|
const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW)
|
||||||
|
: m.strap ? wrapText(m.strap, F(8, 0), headW) : beside ? wrapped : [];
|
||||||
const headH = Math.max(artH, 22 + strap.length * 12);
|
const headH = Math.max(artH, 22 + strap.length * 12);
|
||||||
const rows = modalRows(m, boxW - pad * 2, beside);
|
const rows = modalRows(m, boxW - pad * 2, beside);
|
||||||
let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad;
|
let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad;
|
||||||
@@ -2464,34 +2473,28 @@ function drawModal(m) {
|
|||||||
} else if (r.kind === 'rule') {
|
} else if (r.kind === 'rule') {
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.font = F(8, 700);
|
ctx.font = F(8, 700);
|
||||||
r.lead.forEach((l, i) => shadowText(l, inX, y + 7 + i * 12, UI.cream));
|
// the rule wears the modal's hot colour only when it threatens; otherwise it is a statement
|
||||||
let ry = y + 7 + r.lead.length * 12;
|
r.lead.forEach((l, i) => shadowText(l, inX, y + 8 + i * 12, r.hot ? col : UI.cream));
|
||||||
// the consequence line: DIM for information, the modal's hot colour only when it threatens
|
if (r.pips) { // the pool, in the HUD's own pip language, ON THE SAME LINE
|
||||||
ctx.font = F(8, 0);
|
const px = x + L.boxW - pad - (r.pips - 1) * 9;
|
||||||
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++) {
|
for (let k = 0; k < r.pips; k++) {
|
||||||
ctx.beginPath(); ctx.arc(inX + 3 + k * 9, py, 2.6, 0, 7);
|
ctx.beginPath(); ctx.arc(px + k * 9, y + 8, 2.6, 0, 7);
|
||||||
ctx.fillStyle = '#8a1a12'; ctx.fill();
|
ctx.fillStyle = '#8a1a12'; ctx.fill();
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke();
|
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (r.kind === 'split') {
|
} else if (r.kind === 'split') {
|
||||||
// HIS LEVEL AGAINST YOURS — one frame, two figures, a hairline between them. The number
|
// HIS LEVEL AGAINST YOURS, on ONE line: his on the left, yours hard right. SAME FACE, SAME
|
||||||
// wears the modal's hot colour while the body outranks you and the pick green once it
|
// SIZE for both (Oliver) — a comparison drawn at two sizes is not a comparison, it is a
|
||||||
// does not, which is the whole point of showing it.
|
// headline with a footnote. Only the COLOUR differs, and only where it means something: his
|
||||||
const half = Math.round((L.boxW - pad * 2) / 2), f = m.foe;
|
// number wears the modal's hot colour while he outranks you, the pick green once he does not.
|
||||||
ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1;
|
const f = m.foe;
|
||||||
ctx.beginPath(); ctx.moveTo(inX + half + 0.5, y + 4); ctx.lineTo(inX + half + 0.5, y + r.h - 4);
|
ctx.font = F(10, 700);
|
||||||
ctx.stroke();
|
ctx.textAlign = 'left';
|
||||||
ctx.textAlign = 'center';
|
shadowText('LEVEL ' + f.lvl, inX, y + 9, f.lvl > f.mine ? col : UI.pick);
|
||||||
ctx.font = F(7, 0);
|
ctx.textAlign = 'right';
|
||||||
shadowText('ITS LEVEL', inX + half / 2, y + 8, UI.dim);
|
shadowText('YOURS ' + f.mine, x + L.boxW - pad, y + 9, UI.dim);
|
||||||
shadowText('YOURS', inX + half * 1.5, y + 8, UI.dim);
|
ctx.textAlign = 'left';
|
||||||
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;
|
y += r.h + MODAL_GAP;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ export function read(period, runs = load(), mode = MODE_GORE) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YOUR best number on a board, from your own runs. The splash shows it beside the world board's
|
||||||
|
// door (the text reduction) — a local read on purpose: the splash does not fetch the board.
|
||||||
|
export function bestScore(mode = MODE_GORE) {
|
||||||
|
const B = boardOf(mode);
|
||||||
|
return load().filter(r => modeOf(r) === mode).reduce((m, r) => Math.max(m, B.score(r)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// the two figures the splash carries
|
// the two figures the splash carries
|
||||||
export function total() {
|
export function total() {
|
||||||
const a = read('all');
|
const a = read('all');
|
||||||
|
|||||||
@@ -1247,8 +1247,11 @@ test('THE SEASON: the fixture names the league, its rounds and the body that ope
|
|||||||
assert.equal(fx.foe.name, cfg.matchFoe(R[0].band[0]).name, l.name + ': and names that body');
|
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',
|
assert.ok(fx.title.includes(String(l.id)) || fx.title === 'DEFEND THE TITLE',
|
||||||
l.name + ': the title carries the league');
|
l.name + ': the title carries the league');
|
||||||
assert.ok(fx.line.includes(R[0].name) && fx.line.startsWith(String(R.length)),
|
// the text reduction moved the round COUNT off the button and into the modal's sub line
|
||||||
l.name + ': the line counts the rounds and names the first');
|
assert.ok(fx.line.includes(R[0].name) && fx.line.includes(fx.foe.name),
|
||||||
|
l.name + ': the line names the first round and the body in it');
|
||||||
|
assert.ok(fx.sub.includes(String(R.length)) && fx.sub.includes(String(l.id)),
|
||||||
|
l.name + ': the sub carries the league and how many rounds it runs');
|
||||||
}
|
}
|
||||||
// the champion has nothing above him: his fixture is a defence, not a promotion
|
// 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');
|
assert.equal(cfg.cupFixture(cfg.LEAGUES.length, 1).title, 'DEFEND THE TITLE');
|
||||||
|
|||||||