Streamline mobile menus and refresh splash logo
This commit is contained in:
1 parent
a399e4929c
commit
a09856f8c9
6 files changed
+569
-483
No files matched your search
+77
@@ -4986,6 +4986,83 @@ shot simulations from the recording (20 seeds each, not a deterministic full-mat
|
||||
return all six soft Champion winners; wide winners against earlier foes and surprise misses remain.
|
||||
Phone balance still needs Oliver's next recording; no further numeric difficulty increase here.
|
||||
|
||||
## Session log: THE MENU HAS ONE FRONT DOOR (2026-09-07, LOCAL PREVIEW ONLY)
|
||||
|
||||
Oliver approved the portrait menu audit with two corrections: **Vs a Friend stands on its own**,
|
||||
and **Other Games needs a prominent splash entrance**. Implemented locally for him to click through
|
||||
before any deployment; no commit or push made in this pass.
|
||||
|
||||
- Splash keeps the direct league fixture and direct Gore start. Other Games and Vs a Friend are
|
||||
substantial sibling buttons; World Board stays below. Gore's named court link opens its setup.
|
||||
- Other Games contains only **The Climb, Training, Classic Tennis**, in two poster rows. No duplicate
|
||||
cup/Gore, no Training/Casual categories, and Arcade is consistently named The Climb.
|
||||
- Classic's difficulty is selected inside its setup. Training chooses the opponent BEFORE setup;
|
||||
Serve Up always starts the selected match. The redundant cup setup is removed. First-time cup
|
||||
identity entry resumes the cup instead of losing the destination in the game picker.
|
||||
- Player appearance lives in the shared identity editor, accessible from profile or a compact setup
|
||||
row. Save/Back returns to the caller; existing players see SAVE PLAYER. Swatches and menu back
|
||||
buttons have 44px targets; the in-game exit retains its HUD art with a larger invisible hit area.
|
||||
- Available courts show first; locked courts are disabled compact rows inside a native disclosure.
|
||||
Picks persist immediately. Setup titles name the actual mode and training shows the chosen foe.
|
||||
- Stats belongs to the profile and returns there. The profile retains career figures and a fold-out
|
||||
league roster; duplicate lifetime totals live only in Stats. Playtest recording is a collapsed
|
||||
native details block below the records; ON/FULL is visible in its summary when applicable.
|
||||
- Play On is the primary action in the exit dialog. The world board correctly selects Cup after a
|
||||
league match; Classic falls back to Gore, since there is no Classic leaderboard tab.
|
||||
|
||||
Validation: `node test.js` — **142 pass**. `tools/check-menu.js` is a local browser-tool regression
|
||||
(no package, excluded from the served image with the existing tools directory): **34 checks pass**
|
||||
covering fresh cup onboarding, real mode starts, difficulty/opponent identity, editor returns,
|
||||
locked courts, profile/stats/recording, room cancellation, and 320×568 / 390×844 portrait geometry.
|
||||
Manual screenshots reviewed for splash, additional modes, setups, profile, stats and player editor.
|
||||
No gameplay tuning or court mode-transition code changed. **Await Oliver's local review; not deployed.**
|
||||
|
||||
## Session log: MENU PREVIEW POLISH (2026-09-07, LOCAL ONLY)
|
||||
|
||||
Oliver's next local review supersedes the collapsed defaults above: **all three disclosures open
|
||||
by default** (locked courts, profile league, playtest recording), while remaining user-toggleable.
|
||||
Other Games and Vs a Friend now have truly centred labels, without the offsetting chevrons.
|
||||
Cup and Gore share a 76px button height and the same title/subtitle layout. Each button fades from
|
||||
its existing solid colour on the left into its original mode poster on the right, using CSS only.
|
||||
The menu card's old 370px content-width cap is removed: width 100% with border-box padding fills
|
||||
both narrow phones and wider preview panes without horizontal overflow; viewport minimum height
|
||||
and sticky footers are retained.
|
||||
|
||||
Validation: **39 browser checks pass**, including equal CTA heights, centred labels, open defaults,
|
||||
and a full-width/full-height card at 768×1024, alongside the existing 320×568 and 390×844 flows.
|
||||
Screenshots reviewed at both phone sizes. Still local; no commit, push or deployment.
|
||||
|
||||
Local visual follow-up (same day): Oliver prefers roughly **one third colour / two thirds art**.
|
||||
The Cup/Gore gradient now reveals the poster sooner. Other Games uses the existing arcade poster,
|
||||
Vs a Friend uses its original multiplayer poster, both with the same soft colour-to-art treatment
|
||||
and centred, shadowed labels. Button dimensions and navigation are unchanged; still local only.
|
||||
|
||||
Local visual follow-up: a small blood drop grows at the Gore button's lower-right edge, falls
|
||||
across the court link onto Vs a Friend, slowly traces down its face and drops off its bottom edge.
|
||||
Two synchronized CSS pseudo-element animations repeat every 18 seconds after an initial 3-second
|
||||
delay; the trail fades between drops. Their geometry shares the responsive friend-button height.
|
||||
Decorative only (`pointer-events: none`), active only on a loaded splash, disabled for reduced
|
||||
motion. No new assets, timers or gameplay changes. Browser checks cover alignment, synchronized
|
||||
clocks, non-interception, reduced motion and stopping off-splash; still local, not deployed.
|
||||
|
||||
Local logo follow-up: Oliver approved integrating the generated logo proposal. The splash now
|
||||
uses `assets/logo-v2.png`, a transparent PNG with brighter red lettering, lime Tennis lettering
|
||||
and a larger ball in the blood underline. The original `assets/logo.png` is retained. Reviewed
|
||||
at 320×568 and 390×844: clean transparency, correct loading and no horizontal overflow. Local only.
|
||||
|
||||
Logo colour correction: Oliver found the integrated version a little too bright. The splash
|
||||
now tones it down with CSS `saturate(.78) brightness(.84)`, preserving the exact artwork and alpha.
|
||||
After trying the proposed ball glow, Oliver decided to remove it entirely: **no tennis-ball glow
|
||||
or pulse; blood is enough**. Only the small drop from the logo's blood underline remains, every
|
||||
14 seconds (first after 4 seconds). It is a CSS decoration anchored to the responsive logo,
|
||||
ignores pointer events, runs only on the loaded, visible splash and respects reduced motion.
|
||||
The blood version passed all 42 menu browser checks before the glow was removed. Still local only.
|
||||
|
||||
Release approval (2026-09-07): Oliver approved this menu/logo revision for staging and production.
|
||||
Release cache version: `main.js?v=353`. Pre-release validation: 142 game tests and all 42 mobile
|
||||
menu browser checks pass. Deploy staging first, verify the served release, then promote the same
|
||||
commit to production. Staging URL: `https://splatter-tennis-staging.wuwi.one`.
|
||||
|
||||
# OPEN — leftovers, in rough priority order
|
||||
|
||||
Nothing here is broken; these are known gaps and unverified risks.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 988 KiB |
+243
-210
@@ -117,6 +117,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
system font and drew a smooth glyph inside a pixel HUD */
|
||||
color: var(--cream); font: 700 14px Silkscreen, monospace; line-height: 1;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
#qChip::after { content: ""; position: absolute; top: -4px; right: -4px; min-width: 44px; min-height: 44px; width: 100%; height: 100%; }
|
||||
#quit button:active { background: var(--red); border-color: var(--bad); color: #f2ead6; }
|
||||
#qAsk { position: fixed; inset: 0; z-index: 6; background: rgba(0,0,0,0.55);
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
@@ -165,10 +166,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
#hot { position: fixed; top: 0; left: 0; width: 26vw; max-width: 150px; height: 15vh;
|
||||
max-height: 130px; z-index: 4; }
|
||||
#intro .card { font: 400 12px Silkscreen, monospace; color: var(--cream); text-align: center;
|
||||
line-height: 1.9; padding: 16px 14px 20px; width: min(370px, 100vw - 28px); /* CONTENT width (content-box) beside 14px+14px padding:
|
||||
94vw put the card 5-28px past the viewport on every phone (0.94w+28 > w below
|
||||
466px) and #intro's overflow-y:auto computes overflow-x to auto — that was the
|
||||
sliver of horizontal scroll Oliver caught on the splash */
|
||||
line-height: 1.9; width: 100%; min-width: 0; box-sizing: border-box;
|
||||
/* Padding belongs inside the full-width card, including on wider preview panes. */
|
||||
animation: drop 0.5s ease-out; }
|
||||
@keyframes drop { from { transform: translateY(-14px); opacity: 0; } }
|
||||
/* EVERY SCREEN FILLS THE VIEWPORT, and every screen is the same three bands: something pinned to
|
||||
@@ -180,9 +179,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
#intro .card { display: flex; flex-direction: column; min-height: 100dvh; padding: 0 14px; }
|
||||
/* the slack band: the title sits in the middle of whatever is left over */
|
||||
.mid { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 12px 0; }
|
||||
/* the logo is generated pixel art (assets/logo.png), not stacked CSS text */
|
||||
#intro .logo { display: block; width: min(300px, 74vw); height: auto; margin: 0 auto 6px;
|
||||
image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
|
||||
/* the logo is generated pixel art (assets/logo-v2.png), not stacked CSS text */
|
||||
#intro .logoArt { position: relative; width: min(300px, 74vw); margin: 0 auto 6px; }
|
||||
#intro .logo { display: block; width: 100%; height: auto;
|
||||
image-rendering: pixelated; filter: saturate(.78) brightness(.84) drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
|
||||
.logoArt::after { content: ''; position: absolute; pointer-events: none; opacity: 0; }
|
||||
/* The drop follows the blood underline in logo-v2.png at every viewport size. */
|
||||
.logoArt::after { left: 62%; top: 94%; width: 1.3%; height: 5%;
|
||||
background: #861d18; border-radius: 35% 35% 55% 55%; transform-origin: top;
|
||||
box-shadow: inset 1px 0 rgba(196,69,48,.45); }
|
||||
#intro:not([hidden])[data-stage="splash"] #splash:not(.busy) .logoArt::after {
|
||||
animation: logoBloodDrop 14s 4s infinite; }
|
||||
@keyframes logoBloodDrop {
|
||||
0% { opacity: 0; transform: scale(.5, .15); }
|
||||
3% { opacity: .9; transform: scale(.8, .5); }
|
||||
10% { opacity: .9; transform: translateY(2px) scale(1, 1.1); animation-timing-function: ease-in; }
|
||||
14% { opacity: .8; transform: translateY(12px) scale(.7, 1.3); }
|
||||
17%, 100% { opacity: 0; transform: translateY(20px) scale(.6, .8); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#intro:not([hidden])[data-stage="splash"] #splash:not(.busy) .logoArt::after { animation: none; }
|
||||
}
|
||||
/* the slogan carries most of the gap between the logo's blood and the first button */
|
||||
#intro .slogan { margin: 8px 0 20px; font-size: 11px; color: #a8a09e; letter-spacing: 0.5px; }
|
||||
#intro .slogan b { color: #c9564a; }
|
||||
@@ -191,13 +208,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
|
||||
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
|
||||
court through between menus. */
|
||||
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #vs, #train, #player, #promo, #card { display: none; }
|
||||
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #vs, #train, #player, #promo, #card { display: none; }
|
||||
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
|
||||
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
|
||||
screen whose content does not fill it */
|
||||
#intro[data-stage="setup"] #setup,
|
||||
#intro[data-stage="mode"] #stmode,
|
||||
#intro[data-stage="modesub"] #stsub,
|
||||
#intro[data-stage="stats"] #stats,
|
||||
#intro[data-stage="ranks"] #ranks,
|
||||
#intro[data-stage="secret"] #secret,
|
||||
@@ -221,18 +237,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
#intro.deal > .card > div:not(#splash) > *:nth-child(3) { animation-delay: 0.16s; }
|
||||
#intro.deal > .card > div:not(#splash) > *:nth-child(4) { animation-delay: 0.23s; }
|
||||
#intro.deal > .card > div:not(#splash) > *:nth-child(n+5) { animation-delay: 0.30s; }
|
||||
/* the poster wall deals its ROOMS, not one lump: the list is the whole screen there, so letting
|
||||
it arrive as a single band wastes the entrance. The list itself stops animating — a moving
|
||||
child inside a moving parent starts at twice the offset and reads as a stutter. */
|
||||
#intro.deal #stmode > #modeList { animation: none; }
|
||||
#intro.deal #modeList > .mgroup {
|
||||
animation: dealIn 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
|
||||
#intro.deal #modeList > .mgroup:nth-child(1) { animation-delay: 0.09s; }
|
||||
#intro.deal #modeList > .mgroup:nth-child(2) { animation-delay: 0.17s; }
|
||||
#intro.deal #modeList > .mgroup:nth-child(3) { animation-delay: 0.25s; }
|
||||
#intro.deal #modeList > button { animation: dealIn 0.28s ease-out backwards; }
|
||||
#intro.deal #modeList > button:nth-child(2) { animation-delay: 0.07s; }
|
||||
#intro.deal #modeList > button:nth-child(3) { animation-delay: 0.14s; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#intro.deal > .card > div:not(#splash) > *,
|
||||
#intro.deal #modeList > .mgroup { animation: none; } }
|
||||
#intro.deal > .card > div:not(#splash) > *, #intro.deal #modeList > button { animation: none; }
|
||||
}
|
||||
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
|
||||
display:grid above would otherwise win over the hidden attribute */
|
||||
#intro[hidden] { display: none; }
|
||||
@@ -258,7 +269,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
.hdr h2 { padding-left: 2px; } /* (its face is set with the other titles above) */
|
||||
/* UPRIGHT, not Bangers: the announcement face is drawn slanted, and a slanted arrow read as
|
||||
italic on every screen. Silkscreen's ASCII < > are straight pixel arrows. */
|
||||
.chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line);
|
||||
.chev { width: 44px; height: 44px; flex: none; background: none; border: 2px solid var(--line);
|
||||
color: var(--acid); font: 700 14px Silkscreen, monospace; line-height: 1; cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent; }
|
||||
/* the BODY COUNT headline row: three boxed figures sharing one frame */
|
||||
@@ -304,27 +315,27 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
/* the secondary doors NAME (Jersey) and carry a NUMBER (Silkscreen, acid) — the text reduction
|
||||
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; }
|
||||
#qYes { 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
|
||||
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; }
|
||||
#whoCtl, #pCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
#who2 { display: flex; gap: 12px; align-items: flex-start; }
|
||||
#pCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
/* The live player. `position: static` is not decoration: the bare `canvas` rule above is the COURT
|
||||
— fixed, full-bleed, 100dvh — and it catches any canvas in the document, so this one flew to the
|
||||
top-left corner of the screen until it was told otherwise. Every geometry property that rule
|
||||
sets has to be undone here. The buffer is the sprite's own 96x224, so width follows height. */
|
||||
#look, #pLook { position: static; display: block; flex: none; margin: 0;
|
||||
#pLook { position: static; display: block; flex: none; margin: 0;
|
||||
width: auto; height: 176px; touch-action: auto; image-rendering: pixelated; }
|
||||
/* the tour is now TWO WORDS, not two portraits — the live player next to them is the picture, and
|
||||
a second drawing of the same man (front-facing, and deaf to every colour on this screen) was
|
||||
both a duplicate and the reason the court list started below the fold */
|
||||
#tours, #pTours { display: flex; gap: 7px; }
|
||||
#tours button, #pTours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
|
||||
#pTours { display: flex; gap: 7px; }
|
||||
#pTours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
|
||||
color: var(--dim); font: 400 10px Silkscreen, monospace; letter-spacing: 1px;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
#tours button.on, #pTours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
|
||||
#pTours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
|
||||
/* THE IDENTITY SCREEN (the leagues, phase 1): name row = the input plus the die. */
|
||||
#nameRow { display: flex; gap: 8px; }
|
||||
#nameRow .tin { flex: 1; margin: 0; }
|
||||
@@ -538,110 +549,57 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
text-decoration: none; letter-spacing: 0.5px; }
|
||||
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
|
||||
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
|
||||
/* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one
|
||||
"a checkbox and a button combined"). Every card is full-bright and equal because every card is
|
||||
a live answer to the header's question; tapping answers and advances. The only memory shown is
|
||||
the LAST tag — an indicator, not a selection. A border that means "SERVE UP will use this"
|
||||
exists solely on the final screen, where a confirm button actually follows. */
|
||||
#modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px;
|
||||
margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom;
|
||||
image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit;
|
||||
text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||
position: relative; }
|
||||
/* THE CAPTION'S VEIL (2026-08-31). The blur went in and came straight back out — on
|
||||
nearest-neighbour art it read as a smear, not as depth (Oliver: "sieht komisch aus"). What is
|
||||
left is what he asked for instead: a gradient RISING FROM THE BOTTOM EDGE, solid where the
|
||||
words are and gone by the time it reaches the art. Same job, no filter, no edge. */
|
||||
#modeList button.mode::after, #modeSub button.mode::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 62%;
|
||||
background: 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%);
|
||||
pointer-events: none; }
|
||||
/* the words and the chips ride ABOVE the veil — a pseudo-element is the last thing painted */
|
||||
#modeList .cb, #modeSub .cb { position: relative; z-index: 2; }
|
||||
#modeList b, #modeSub b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); }
|
||||
/* the subtitle carries the mode's one-line rules over busy art, so it gets WHITE at full
|
||||
opacity, a size up from the court rows' 9px, and a solid black underlay — "barely readable"
|
||||
at cream-on-crowd was the report from the first live look */
|
||||
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0;
|
||||
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; }
|
||||
/* THE SECTIONS (THE SEASON): the wall is three rooms — one path up, a room to practise in, a
|
||||
room where nothing counts — so each is a real section with its posters INSIDE it, not a label
|
||||
floating over a stack. The heading is deliberately NOT the announcement face (Oliver: it read
|
||||
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
|
||||
grouping, which is what air is for. */
|
||||
#modeList .mgroup { margin: 0 0 10px; }
|
||||
#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; }
|
||||
/* 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'
|
||||
own captions) */
|
||||
#modeList .mgroup > header { margin: 0 0 11px; text-align: left; }
|
||||
/* THE SHELF LABELS SHOUT, quietly (2026-08-31, Oliver tried three: Bangers first, then the HUD
|
||||
face, then the platform's UI face, and came back to the announcement face). It works here now
|
||||
because the header lost its rule and its band and gained air: the size does the hierarchy
|
||||
(20 against the screen title's 26) instead of the typeface having to. */
|
||||
/* SCOPED TO THE HEADER, not to the section: the posters live INSIDE .mgroup now, so a bare
|
||||
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). */
|
||||
#modeList .mgroup > header b { display: block; letter-spacing: 0.5px; color: var(--acid);
|
||||
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
|
||||
line was two foreign voices in one header */
|
||||
#modeList .mgroup > header i { display: block; margin: 4px 0 0; letter-spacing: 0.5px;
|
||||
color: var(--dim); font-style: normal; text-shadow: none;
|
||||
font: 400 9px/1.4 Silkscreen, monospace; }
|
||||
#modeList .mgroup button.mode:last-child { margin-bottom: 0; }
|
||||
/* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are
|
||||
full-height posters centred on their scene, and the difficulty chips are plain acid-outline
|
||||
buttons stacked tall — art for EASY/MEDIUM/HARD would be forced, and the chip reads faster. */
|
||||
/* alone on their own screens, the posters get room to be posters — and the first card clears
|
||||
the header by the same breath the h3 rows used to give the old single page */
|
||||
#stmode #modeList, #stsub #modeSub { margin-top: 12px; }
|
||||
#stmode button.mode, #modeSub button.mode { min-height: 148px; }
|
||||
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
#modeSub button.mode { background-position: center; }
|
||||
#modeSub button.chip { flex: 1 1 100%; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; padding: 26px 16px; background: var(--panel); border: 2px solid var(--line);
|
||||
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%;
|
||||
/* The additional modes fit in two rows; the friend has a door of its own on the splash. */
|
||||
#modeList { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0; }
|
||||
#modeList button.mode { display: flex; align-items: flex-end; position: relative; min-width: 0;
|
||||
min-height: 150px; padding: 12px; border: 2px solid var(--line); color: var(--cream);
|
||||
background-size: cover; background-position: center; image-rendering: pixelated;
|
||||
text-align: left; cursor: pointer; }
|
||||
#modeList button.mode:last-child { grid-column: 1 / -1; }
|
||||
#modeList button.mode::after { content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(0deg, rgba(6,10,8,.94), rgba(6,10,8,.2) 65%, transparent); }
|
||||
#modeList .cb { position: relative; z-index: 1; }
|
||||
#modeList .cb b { font: 400 29px/1 'Jersey 15', cursive; letter-spacing: .5px; }
|
||||
.playerLink { display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
width: 100%; min-height: 60px; margin: 16px 0 0; padding: 10px 12px; text-align: left;
|
||||
background: var(--panel); border: 2px solid var(--line); color: var(--cream); cursor: pointer; }
|
||||
.playerLink b { display: block; font: 22px/1 'Jersey 15', cursive; }
|
||||
.playerLink i { display: block; margin-top: 5px; font: 9px Silkscreen, monospace;
|
||||
color: var(--dim); font-style: normal; }
|
||||
.playerLink u { font: 17px 'Jersey 15', cursive; text-decoration: none; color: var(--acid); }
|
||||
.opponentRow { display: flex; align-items: center; gap: 14px; margin-top: 18px; text-align: left; }
|
||||
.opponentRow img { height: 50px; width: 48px; object-fit: contain; image-rendering: pixelated; }
|
||||
.opponentRow b { display: block; font: 24px 'Jersey 15', cursive; }
|
||||
.opponentRow i { font: 10px Silkscreen, monospace; font-style: normal; color: var(--dim); }
|
||||
.setupNote { margin: 18px 0 0; font: 10px/1.6 Silkscreen, monospace; color: var(--dim); }
|
||||
#sDifficulty[hidden], #sOpponent[hidden], #sClimb[hidden], #lockedCourts[hidden] { display: none; }
|
||||
#difficultyList { margin: 0; }
|
||||
#difficultyList button { font: 20px 'Jersey 15', cursive; }
|
||||
.disclosure { margin: 14px 0; text-align: left; border-top: 1px solid var(--line); }
|
||||
.disclosure > summary { display: flex; align-items: center; justify-content: space-between;
|
||||
min-height: 48px; gap: 10px; list-style: none; cursor: pointer; color: var(--cream);
|
||||
font: 18px/1.2 'Jersey 15', cursive; letter-spacing: .5px; }
|
||||
.disclosure > summary::-webkit-details-marker { display: none; }
|
||||
.disclosure > summary::after { content: '+'; color: var(--acid); font: 16px Silkscreen, monospace; }
|
||||
.disclosure[open] > summary::after { content: '−'; }
|
||||
#cdStats { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 0;
|
||||
min-height: 52px; border-color: #627340; color: var(--acid); text-align: left; font-size: 23px; }
|
||||
#pNote[hidden] { display: none; }
|
||||
:is(#courtList, #lockedCourtList) button.court { display: flex; align-items: center; gap: 11px; width: 100%;
|
||||
margin: 0 0 8px; padding: 7px; background: var(--panel);
|
||||
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
||||
#courtList img { width: 84px; height: 60px; display: block; image-rendering: pixelated; flex: none;
|
||||
:is(#courtList, #lockedCourtList) button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
||||
:is(#courtList, #lockedCourtList) img { width: 84px; height: 60px; display: block; image-rendering: pixelated; flex: none;
|
||||
object-fit: cover; }
|
||||
#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; }
|
||||
:is(#courtList, #lockedCourtList) .cb, #modeList .cb { flex: 1; min-width: 0; }
|
||||
:is(#courtList, #lockedCourtList) b, #modeList 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);
|
||||
:is(#courtList, #lockedCourtList) b { font: 400 22px/1.1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
|
||||
:is(#courtList, #lockedCourtList) 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; }
|
||||
:is(#courtList, #lockedCourtList) i[hidden] { display: none; }
|
||||
:is(#courtList, #lockedCourtList) i, #modeList 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
|
||||
drift away from what the ball actually does on it. */
|
||||
.meter { display: flex; align-items: center; gap: 6px; margin-top: 4px;
|
||||
@@ -661,21 +619,105 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
z-index: 5; color: #ffd8ca; background: #59251f; padding: 4px 6px;
|
||||
font: 10px Silkscreen, monospace; pointer-events: none; }
|
||||
#recLamp[hidden], body:has(#intro:not([hidden])) #recLamp { display: none; }
|
||||
#recorder { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 14px; }
|
||||
#recorder { margin-top: 24px; margin-bottom: 24px; }
|
||||
#recorder p { font: 11px/1.5 Silkscreen, monospace; color: var(--dim); }
|
||||
#recorder .btn { min-height: 44px; }
|
||||
#recorder .btn:disabled { opacity: 0.4; }
|
||||
#recNew[hidden] { display: none; }
|
||||
/* Gore gets a front door; the records become quiet navigation, with full touch targets. */
|
||||
#mGore { display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
padding: 9px 12px; border-color: #804338; background: #281b19; color: #f1d6bf; }
|
||||
#mGore b { font: 400 23px/1.1 Bangers, cursive; letter-spacing: 1px; }
|
||||
#mGore i { margin: 0; color: #d5aa91; font: 400 9px/1.5 Silkscreen, monospace; letter-spacing: 0; }
|
||||
/* Equal-sized entrances: solid mode colour fades into each mode's original poster. */
|
||||
#mStart, #mGore { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
box-sizing: border-box; height: 76px; min-height: 76px; padding: 10px 12px; gap: 4px;
|
||||
background-size: cover; background-position: center; image-rendering: pixelated;
|
||||
color: #f6e7dc; text-align: center; }
|
||||
#mStart { background-image: linear-gradient(90deg, #a4231f 0%, #a4231f 18%,
|
||||
rgba(164,35,31,.80) 30%, rgba(164,35,31,.12) 50%, rgba(0,0,0,.22) 100%), url('assets/mode_cup.png'); }
|
||||
#mGore { border-color: #804338; background-color: #281b19;
|
||||
background-image: linear-gradient(90deg, #281b19 0%, #281b19 18%,
|
||||
rgba(40,27,25,.80) 30%, rgba(40,27,25,.12) 50%, rgba(0,0,0,.22) 100%), url('assets/mode_gore.png'); }
|
||||
#mStart b, #mGore b { font: 400 26px/1.1 Bangers, cursive; letter-spacing: 1px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,.85); }
|
||||
#mStart i, #mGore i { margin: 0; color: #f0d8c8; font: 400 9px/1.5 Silkscreen, monospace;
|
||||
letter-spacing: .3px; text-shadow: 0 1px 3px #000; }
|
||||
.splashLinks { display: flex; gap: 4px; margin-top: 5px; }
|
||||
.splashLinks .btn.sec { flex: 1; width: auto; min-height: 44px; padding: 7px 2px;
|
||||
background: transparent; border-color: transparent; color: #ccc4b5;
|
||||
font-size: 16px; letter-spacing: 0; }
|
||||
.splashLinks #mOther { flex: 1.5; }
|
||||
.splashDoors { --door-height: 58px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 4px; }
|
||||
.splashDoors .btn.sec { display: flex; align-items: center; justify-content: center;
|
||||
min-height: var(--door-height); padding: 10px; text-align: center; font-size: 22px; letter-spacing: .3px; }
|
||||
#mOther { border-color: #8eaa4d; color: #e6efbf; background-color: #263421;
|
||||
background-image: linear-gradient(90deg, #263421 0%, #263421 18%,
|
||||
rgba(38,52,33,.80) 30%, rgba(38,52,33,.20) 50%, rgba(0,0,0,.38) 100%), url('assets/mode_arcade.png'); }
|
||||
#mVs { border-color: #607e6b; color: #edf1e8; background-color: #26322c;
|
||||
background-image: linear-gradient(90deg, #26322c 0%, #26322c 18%,
|
||||
rgba(38,50,44,.80) 30%, rgba(38,50,44,.20) 50%, rgba(0,0,0,.38) 100%), url('assets/mode_vs.png'); }
|
||||
#mOther, #mVs { background-size: cover; background-position: center; image-rendering: pixelated;
|
||||
text-shadow: 0 1px 2px #000, 0 0 5px #000; }
|
||||
/* One occasional drop: Gore's lower edge -> friend button -> a slow run down its face.
|
||||
50px bridges the 44px court link, 4px row gap and the friend's 2px border.
|
||||
Both pieces share one CSS clock; no game-loop work, DOM additions or touch interception. */
|
||||
#mVs { position: relative; --blood-gap: 50px; }
|
||||
#mVs::before, #mVs::after { content: ''; position: absolute; pointer-events: none; opacity: 0; }
|
||||
#mVs::before { z-index: 2; right: 14px; top: calc(-1 * var(--blood-gap)); width: 6px; height: 9px;
|
||||
background: linear-gradient(90deg, #690b12 0%, #bb2027 55%, #e04436 75%, #8e1019 100%);
|
||||
clip-path: polygon(40% 0, 60% 0, 100% 55%, 100% 78%, 70% 100%, 30% 100%, 0 78%, 0 55%); }
|
||||
#mVs::after { z-index: 1; right: 16px; top: 0; width: 2px; height: calc(100% - 3px);
|
||||
background: #9a1820; box-shadow: 1px 0 #510b10; transform-origin: top; }
|
||||
#intro[data-stage="splash"] #splash:not(.busy) #mVs::before { animation: menuBloodDrop 18s 3s infinite; }
|
||||
#intro[data-stage="splash"] #splash:not(.busy) #mVs::after { animation: menuBloodTrail 18s 3s infinite; }
|
||||
@keyframes menuBloodDrop {
|
||||
0% { opacity: 0; transform: translateY(0) scale(.25, .4); }
|
||||
2% { opacity: 1; transform: translateY(1px) scale(.9, 1.1); }
|
||||
5% { opacity: 1; transform: translateY(4px) scale(1, 1.3); animation-timing-function: ease-in; }
|
||||
7% { opacity: 1; transform: translateY(var(--blood-gap)) scale(.8, 1.2); }
|
||||
7.5% { opacity: 1; transform: translateY(var(--blood-gap)) scale(1.7, .55); }
|
||||
9% { opacity: 1; transform: translateY(calc(var(--blood-gap) + 2px)) scale(1); animation-timing-function: linear; }
|
||||
32% { opacity: 1; transform: translateY(calc(var(--blood-gap) + var(--door-height) - 10px)) scale(1, 1.15); }
|
||||
35% { opacity: .8; transform: translateY(calc(var(--blood-gap) + var(--door-height) + 8px)) scale(.7, 1.3); }
|
||||
36%, 100% { opacity: 0; transform: translateY(calc(var(--blood-gap) + var(--door-height) + 16px)); }
|
||||
}
|
||||
@keyframes menuBloodTrail {
|
||||
0%, 7% { opacity: 0; transform: scaleY(0); }
|
||||
7.5% { opacity: .85; transform: scaleY(.04); }
|
||||
9% { opacity: .85; transform: scaleY(.06); animation-timing-function: linear; }
|
||||
32% { opacity: .85; transform: scaleY(1); }
|
||||
35% { opacity: .6; transform: scaleY(1); }
|
||||
48%, 100% { opacity: 0; transform: scaleY(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#intro[data-stage="splash"] #splash:not(.busy) #mVs::before,
|
||||
#intro[data-stage="splash"] #splash:not(.busy) #mVs::after { animation: none; }
|
||||
}
|
||||
.courtLink { display: block; width: 100%; min-height: 44px; padding: 6px 0; border: 0;
|
||||
background: transparent; color: #d5aa91; font: 10px Silkscreen, monospace; cursor: pointer; }
|
||||
.splashLinks { margin-top: 0; }
|
||||
#splash #build { margin-top: 3px; }
|
||||
@media (max-height: 680px) {
|
||||
#intro .logoArt { width: min(220px, 55vw); }
|
||||
#intro .slogan { font-size: 9px; margin: 4px 0 8px; }
|
||||
#splash .mid { padding: 6px 0; }
|
||||
#splash #plaque { margin-top: 8px; }
|
||||
.splashDoors { --door-height: 50px; }
|
||||
.splashDoors .btn.sec { font-size: 20px; }
|
||||
#splash .foot { padding-top: 4px; }
|
||||
#modeList button.mode { min-height: 128px; }
|
||||
}
|
||||
:is(#courtList, #lockedCourtList) button.court.lock { opacity: .7; }
|
||||
#lockedCourtList button.court img { width: 56px; height: 40px; }
|
||||
#lockedCourtList .meter { display: none; }
|
||||
.tabs button { min-height: 44px; }
|
||||
#pTours button { min-height: 44px; }
|
||||
/* Colour choices span the screen: six swatches still have 44px targets on a 320px phone. */
|
||||
#who2 { align-items: center; }
|
||||
#pLook { height: 144px; }
|
||||
#pTours { flex-direction: column; }
|
||||
.playerColours { display: flex; flex-direction: column; gap: 7px; margin: 12px 0 20px; }
|
||||
.playerColours .swrow { gap: 5px; }
|
||||
.playerColours .swrow button { min-height: 44px; aspect-ratio: auto; height: 44px; }
|
||||
.playerColours .swlab:not(:first-child) { margin-top: 8px; }
|
||||
#pName { min-width: 0; }
|
||||
#intro h3 span { font-size: 9px; }
|
||||
button:focus-visible, summary:focus-visible { outline: 2px solid var(--acid); outline-offset: 3px; }
|
||||
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */
|
||||
.foot.bare { border: 0; background: none; }
|
||||
.foot.bare .btn.big { margin-bottom: 10px; }
|
||||
@@ -696,6 +738,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
#intro.deal #splash #plaque { animation-delay: 0.02s; }
|
||||
#intro.deal #splash #mStart { animation-delay: 0.12s; }
|
||||
#intro.deal #splash #mGore { animation-delay: 0.19s; }
|
||||
#intro.deal #splash #mCourt { animation-delay: 0.23s; }
|
||||
#intro.deal #splash .foot.bare > .splashDoors { animation-delay: 0.26s; }
|
||||
/* the navigation row is a CLASS, so it needs the same weight as the shorthand above
|
||||
to override its delay — the id selectors around it get there on their own */
|
||||
#intro.deal #splash .foot.bare > .splashLinks { animation-delay: 0.26s; }
|
||||
@@ -711,7 +755,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
#boot u { display: block; margin-top: 7px; text-align: center; text-decoration: none;
|
||||
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
|
||||
#splash.busy #boot { display: block; }
|
||||
#splash.busy .btn, #splash.busy .splashLinks, #splash.busy #build { display: none; }
|
||||
#splash.busy .btn, #splash.busy .splashLinks, #splash.busy .splashDoors, #splash.busy .courtLink, #splash.busy #build { display: none; }
|
||||
/* NOTHING DATA-DRIVEN DURING THE BOOT (2026-08-31, Oliver: "die oberfläche ist hier regelmäßig
|
||||
in einem undefinierten zustand"). The plaque used to stand there as a skeleton, which is still
|
||||
a plaque saying LEVEL 1 to a level-25 player for as long as the profile takes to land. The
|
||||
@@ -744,6 +788,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
font-style: normal; color: var(--dim); letter-spacing: 1px; }
|
||||
/* a GRID, not a wrapping flex row: `flex: 1 1 62px` gave the last row's tiles the whole width
|
||||
to share, so a shelf of nine came out in three different sizes (Oliver) */
|
||||
#stW > .none { grid-column: 1 / -1; }
|
||||
#stW { display: grid; grid-template-columns: repeat(auto-fill, minmax(66px, 1fr)); gap: 6px; }
|
||||
.wtile { padding: 8px 2px; border: 2px solid var(--line); background: var(--panel); }
|
||||
.wtile img { width: 34px; height: 34px; object-fit: contain; display: block; margin: 0 auto 2px;
|
||||
@@ -818,12 +863,12 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
<canvas id="c" width="480" height="270"></canvas>
|
||||
<div id="safe"></div>
|
||||
<div id="hint">move: WASD/arrows · serve/swing: SPACE · aim: hold a direction while swinging · T: tuning panel</div>
|
||||
<div id="quit" hidden><button id="qChip">X</button></div>
|
||||
<div id="quit" hidden><button id="qChip" aria-label="Pause match">X</button></div>
|
||||
<div id="qAsk" hidden>
|
||||
<div class="qBox">
|
||||
<p>LEAVE THE MATCH?</p>
|
||||
<button class="btn big" id="qYes">BACK TO THE MENU</button>
|
||||
<button class="btn sec" id="qNo">PLAY ON</button>
|
||||
<button class="btn big" id="qNo">PLAY ON</button>
|
||||
<button class="btn sec" id="qYes">LEAVE MATCH</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="intro" data-stage="splash">
|
||||
@@ -840,7 +885,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
<div id="splash" class="busy">
|
||||
<div id="hot"></div>
|
||||
<div class="mid">
|
||||
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
||||
<div class="logoArt"><img class="logo" src="assets/logo-v2.png" alt="Splatter Tennis"></div>
|
||||
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
||||
<!-- THE PLAYER PLAQUE (the leagues): centred under the title on a translucent surface,
|
||||
so the foot's blood pool stays visible. One tap opens the card (refreshSplash fills). -->
|
||||
@@ -864,66 +909,41 @@ 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;
|
||||
Gore has its own secondary door; the other modes live behind OTHER GAMES. -->
|
||||
<button class="btn big" id="mStart"><b id="msTitle">THE CUP</b><i id="msLine"></i></button>
|
||||
<button class="btn sec" id="mGore"><b>SPLATTER GORE</b><i>NO CUP.<br>JUST CARNAGE.</i></button>
|
||||
<div class="splashLinks">
|
||||
<button class="btn sec" id="mGore"><b>SPLATTER GORE</b><i>NO CUP. JUST CARNAGE.</i></button>
|
||||
<button class="courtLink" id="mCourt" aria-label="Choose court for Splatter Gore"></button>
|
||||
<div class="splashDoors">
|
||||
<button class="btn sec" id="mOther">OTHER GAMES</button>
|
||||
<button class="btn sec" id="mStats">STATS</button>
|
||||
<button class="btn sec" id="mRanks">BOARD</button>
|
||||
<button class="btn sec" id="mVs">VS A FRIEND</button>
|
||||
</div>
|
||||
<div class="splashLinks">
|
||||
<button class="btn sec" id="mRanks">WORLD BOARD</button>
|
||||
</div>
|
||||
<p id="build"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- tour tiles and court rows are both built from tables in main.js (config.TOURS / config.COURTS):
|
||||
adding either is a table row, never markup -->
|
||||
<!-- THE SETUP IS A WIZARD now (Oliver: one screen of posters + chips + tiles + meters was
|
||||
"really overwhelming"). Three stages, tap advances: the mode posters, then the mode's own
|
||||
second step when it has one (classic: difficulty, zombie tennis: arcade or the cup), then
|
||||
who-and-where with SERVE UP. Every step comes up pre-selected from last time, so a
|
||||
returning player is three taps from serving. -->
|
||||
<!-- Additional solo modes; multiplayer has a separate splash entrance. -->
|
||||
<div id="stmode">
|
||||
<div class="hdr"><button class="chev" id="mBack"><</button><h2>PICK A GAME</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="mBack"><</button><h2>OTHER GAMES</h2></div>
|
||||
<div id="modeList"></div>
|
||||
</div>
|
||||
<div id="stsub">
|
||||
<div class="hdr"><button class="chev" id="subBack"><</button><h2 id="subTitle">DIFFICULTY</h2></div>
|
||||
<div id="modeSub"></div>
|
||||
</div>
|
||||
<div id="setup">
|
||||
<div class="hdr"><button class="chev" id="sBack"><</button><h2>MATCH SETUP</h2></div>
|
||||
<!-- ONE PLAYER BLOCK. It was four stacked sections — two big portrait tiles, then the live
|
||||
player, then a kit row, then a skin row — which said the same thing twice (the tiles were
|
||||
a second, FRONT-FACING drawing of a man who does not react to any of the colours) and ran
|
||||
the court off the bottom of the phone. The tiles are gone: the live player IS the picture,
|
||||
so who-you-are shrinks to two words, and every control that changes him stands beside him
|
||||
where you can watch it land. Kit and skin rows are built in main.js from the tables. -->
|
||||
<h3 id="s01">01 · PLAYER</h3>
|
||||
<div id="who">
|
||||
<canvas id="look" width="128" height="224"></canvas>
|
||||
<div id="whoCtl">
|
||||
<div id="tours"></div>
|
||||
<u class="swlab">KIT<i id="kitName"></i></u>
|
||||
<div id="kitRow" class="swrow"></div>
|
||||
<u class="swlab">SKIN<i id="skinName"></i></u>
|
||||
<div id="skinRow" class="swrow"></div>
|
||||
</div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="sBack"><</button><h2 id="sTitle">MATCH SETUP</h2></div>
|
||||
<button id="sEdit" class="playerLink">
|
||||
<span><b id="sPlayerName"></b><i id="sPlayerKit"></i></span><u>EDIT PLAYER</u>
|
||||
</button>
|
||||
<div id="sDifficulty" hidden>
|
||||
<h3>DIFFICULTY</h3>
|
||||
<div id="difficultyList" class="tabs"></div>
|
||||
</div>
|
||||
<!-- THE LEAGUE PANEL (the leagues): on the league modes this stands where the player block
|
||||
does elsewhere — the identity already lives on the card, so the setup shows the thing
|
||||
that matters HERE instead: your league, writ large, and who of its band you have
|
||||
already felled (all-time, from the stats' own per-foe tallies). Built by
|
||||
main.buildLeaguePanel, toggled by syncSetup. -->
|
||||
<div id="lgPanel">
|
||||
<div id="lgHead">
|
||||
<img id="lgBadge" alt="">
|
||||
<div><b id="lgName"></b><i id="lgSub"></i></div>
|
||||
</div>
|
||||
<div id="lgFoes"></div>
|
||||
</div>
|
||||
<h3 id="s02">02 · COURT</h3>
|
||||
<div id="sOpponent" class="opponentRow" hidden><img alt=""><span><i>OPPONENT</i><b></b></span></div>
|
||||
<p id="sClimb" class="setupNote" hidden></p>
|
||||
<h3>COURT<span>PACE · BOUNCE</span></h3>
|
||||
<div id="courtList"></div>
|
||||
<div class="foot">
|
||||
<button class="btn big" id="cStart">SERVE UP</button>
|
||||
</div>
|
||||
<details id="lockedCourts" class="disclosure" open>
|
||||
<summary id="lockedCourtLabel">LOCKED COURTS</summary>
|
||||
<div id="lockedCourtList"></div>
|
||||
</details>
|
||||
<div class="foot"><button class="btn big" id="cStart">SERVE UP</button></div>
|
||||
</div>
|
||||
<!-- THE IDENTITY (the leagues, phase 1): the first boot asks who walks on — a name (prefilled
|
||||
with a zombie name, the die rolls another), the tour and the colours. Everything here
|
||||
@@ -931,7 +951,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
keys), so the two screens cannot disagree about who you are. Rebuilt on every visit by
|
||||
main.buildPlayer, so the .on states track the setup screen's. -->
|
||||
<div id="player">
|
||||
<div class="hdr"><button class="chev" id="pBack"><</button><h2>YOUR PLAYER</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="pBack"><</button><h2>YOUR PLAYER</h2></div>
|
||||
<h3>NAME<span>YOURS ON EVERY BOARD</span></h3>
|
||||
<div id="nameRow">
|
||||
<input id="pName" class="tin" maxlength="12" autocomplete="off" spellcheck="false">
|
||||
@@ -942,14 +962,16 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
<canvas id="pLook" width="128" height="224"></canvas>
|
||||
<div id="pCtl">
|
||||
<div id="pTours"></div>
|
||||
<u class="swlab">KIT<i id="pKitName"></i></u>
|
||||
<div id="pKitRow" class="swrow"></div>
|
||||
<u class="swlab">SKIN<i id="pSkinName"></i></u>
|
||||
<div id="pSkinRow" class="swrow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playerColours">
|
||||
<u class="swlab">KIT<i id="pKitName"></i></u>
|
||||
<div id="pKitRow" class="swrow"></div>
|
||||
<u class="swlab">SKIN<i id="pSkinName"></i></u>
|
||||
<div id="pSkinRow" class="swrow"></div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<p class="cent">THE CLUB WILL LEARN YOUR NAME.</p>
|
||||
<p class="cent" id="pNote">THE CLUB WILL LEARN YOUR NAME.</p>
|
||||
<button class="btn big" id="pGo">ENTER THE CLUB</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -975,7 +997,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
main.buildCard from the profile + stats; the honours row shows every badge slot, earned
|
||||
bright and unearned dark. -->
|
||||
<div id="card">
|
||||
<div class="hdr"><button class="chev" id="cdBack"><</button><h2 id="cdName">YOUR PLAYER</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="cdBack"><</button><h2 id="cdName">YOUR PLAYER</h2></div>
|
||||
<div id="cdTop">
|
||||
<canvas id="cdLook" width="128" height="224"></canvas>
|
||||
<div id="cdMeta">
|
||||
@@ -989,10 +1011,19 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
<h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3>
|
||||
<div id="cdBadges"></div>
|
||||
<h3>THE LEDGER<span>ALL TIME</span></h3>
|
||||
<h3>CAREER</h3>
|
||||
<div class="figs" id="cdFigs1"></div>
|
||||
<div style="height:8px"></div>
|
||||
<div class="figs" id="cdFigs2"></div>
|
||||
<button class="btn sec" id="cdStats">STATS<span aria-hidden="true">></span></button>
|
||||
<details class="disclosure" id="cdLeagueDetails" open>
|
||||
<summary>YOUR LEAGUE</summary>
|
||||
<div id="lgPanel">
|
||||
<div id="lgHead">
|
||||
<img id="lgBadge" alt="">
|
||||
<div><b id="lgName"></b><i id="lgSub"></i></div>
|
||||
</div>
|
||||
<div id="lgFoes"></div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="foot bare">
|
||||
<button class="btn sec" id="cdEdit">EDIT PLAYER<i>NAME · TOUR · COLOURS</i></button>
|
||||
</div>
|
||||
@@ -1000,7 +1031,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
<!-- THE TRAINING COURT's opponent picker: the whole ladder face-up, tap a body to drill it.
|
||||
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
|
||||
<div id="train">
|
||||
<div class="hdr"><button class="chev" id="trBack"><</button><h2>TRAINING</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="trBack"><</button><h2>TRAINING</h2></div>
|
||||
<h3>CHOOSE AN OPPONENT</h3>
|
||||
<div id="trainList"></div>
|
||||
</div>
|
||||
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
|
||||
@@ -1008,7 +1040,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
a socket is open, so the room code and its SHARE button stand where the buttons were and
|
||||
the game stays parked behind this overlay until the room fills (main.js, THE LOBBY). -->
|
||||
<div id="vs">
|
||||
<div class="hdr"><button class="chev" id="vsBack"><</button><h2>VS A FRIEND</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="vsBack"><</button><h2>VS A FRIEND</h2></div>
|
||||
<div id="vsPick">
|
||||
<h3>YOU</h3>
|
||||
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
|
||||
@@ -1036,30 +1068,31 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
|
||||
<div id="stats">
|
||||
<div class="hdr"><button class="chev" id="stBack"><</button><h2>BODY COUNT</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="stBack"><</button><h2>STATS</h2></div>
|
||||
<!-- NO TABS (2026-08-31). This screen carried three MODE tabs times three PERIOD tabs, and
|
||||
the same three boxes meant something different in every one of the nine states. It is
|
||||
your career now, in four numbers that always mean the same thing, plus the two things
|
||||
the screen is actually good at: the bodies you have collected, and the rack you fired. -->
|
||||
<div class="figs pair" id="stFigs"></div>
|
||||
<section id="recorder">
|
||||
<h3>PLAYTEST RECORDING</h3>
|
||||
|
||||
<h3>BY OPPONENT<span>ALL TIME</span></h3>
|
||||
<div id="stZ"></div>
|
||||
<h3>BY WEAPON</h3>
|
||||
<div id="stW"></div>
|
||||
<details id="recorder" class="disclosure" open>
|
||||
<summary id="recLabel">PLAYTEST RECORDING</summary>
|
||||
<p>Record league, Climb and training points to investigate difficulty. Export here after playing.</p>
|
||||
<p id="recStatus" role="status"></p>
|
||||
<button class="btn sec" id="recToggle">RECORD PLAYTEST</button>
|
||||
<button class="btn sec" id="recExport" disabled>EXPORT RECORDING</button>
|
||||
<button class="btn sec" id="recNew" hidden>REPLACE WITH NEW RECORDING</button>
|
||||
</section>
|
||||
<h3>BY OPPONENT<span>ALL TIME</span></h3>
|
||||
<div id="stZ"></div>
|
||||
<h3>BY WEAPON</h3>
|
||||
<div id="stW"></div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- THREE BOARDS, one per game mode — the three do not share a currency, so they cannot share
|
||||
a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column
|
||||
heading with the tab, because the number means something different on each one. -->
|
||||
<div id="ranks">
|
||||
<div class="hdr"><button class="chev" id="rkBack"><</button><h2>WORLD BOARD</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="rkBack"><</button><h2>WORLD BOARD</h2></div>
|
||||
<!-- TWO boards, not three (2026-08-31): classic tennis is out of competition by design now
|
||||
— no league, no record, no stakes — so ranking it contradicted the room it lives in. -->
|
||||
<div class="tabs" id="rkModes">
|
||||
@@ -1074,7 +1107,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
at it, which is the whole point: it replaced a triple-tap on the in-play scoreboard that
|
||||
players kept tripping by accident. -->
|
||||
<div id="secret">
|
||||
<div class="hdr"><button class="chev" id="scBack"><</button><h2>BACK ROOM</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="scBack"><</button><h2>BACK ROOM</h2></div>
|
||||
<div class="foot bare">
|
||||
<button class="btn big" id="scWeapons">WEAPON TEST</button>
|
||||
<button class="btn big" id="scBosses">BOSS TEST</button>
|
||||
@@ -1086,7 +1119,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
|
||||
roster puts it on this screen and never touches this markup -->
|
||||
<div id="bosses">
|
||||
<div class="hdr"><button class="chev" id="bsBack"><</button><h2>BOSS TEST</h2></div>
|
||||
<div class="hdr"><button class="chev" aria-label="Back" id="bsBack"><</button><h2>BOSS TEST</h2></div>
|
||||
<h3>PICK A CHAPTER<span>FULL ARSENAL</span></h3>
|
||||
<div id="bossList"></div>
|
||||
</div>
|
||||
@@ -1107,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
<span id="recLamp" hidden>REC</span>
|
||||
<script type="module" src="src/main.js?v=352"></script>
|
||||
<script type="module" src="src/main.js?v=353"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3
-32
@@ -833,7 +833,7 @@ export const MODES = [
|
||||
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
|
||||
// THE CLIMB (2026-08-29): the arcade is the survival marathon through the WHOLE earned ladder,
|
||||
// bottom up — its length grows with the career; the cup stays the league fixture. See beats.
|
||||
{ id: 'zombie', name: 'ARCADE', sub: 'THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY', art: 'mode_arcade' },
|
||||
{ id: 'zombie', name: 'THE CLIMB', sub: 'THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY', art: 'mode_arcade' },
|
||||
// `tag` is the poster's corner chip (main.banner): the cup is THE WAY UP — winning your
|
||||
// league's tournament is the one route to promotion, and the picker should say so standing.
|
||||
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'YOUR LEAGUE\'S ROUNDS · WIN IT TO RISE', art: 'mode_cup',
|
||||
@@ -846,37 +846,8 @@ export const MODES = [
|
||||
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
|
||||
];
|
||||
|
||||
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
|
||||
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two
|
||||
// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES
|
||||
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
|
||||
// THE ROOMS, NOT A SHELF (2026-08-31, THE SEASON). The wall used to deal five posters as equals
|
||||
// with the career hidden two taps inside ZOMBIE TENNIS, so nothing on screen said what any of them
|
||||
// was FOR — Oliver: "alles ist irgendwie beliebig". Three named groups answer that before a single
|
||||
// caption is read: one path up, a room to practise in, and a room where nothing counts. A `group`
|
||||
// row is a heading (main.buildTops), not a poster.
|
||||
//
|
||||
// The cup left the ZOMBIE TENNIS pocket for the top of the wall — it is the canonical path, and a
|
||||
// canonical path does not live behind a fan-out. Its two former siblings (the climb, the training
|
||||
// court) stand on their own in the training group, which also cost the second step its only other
|
||||
// job: CLASSIC's difficulty chips are all that still fan out.
|
||||
// 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 = [
|
||||
{ group: 'SEASON', layout: 'hero' },
|
||||
{ mode: 'cup' },
|
||||
{ group: 'TRAINING', layout: 'pair' },
|
||||
{ mode: 'zombie' },
|
||||
{ mode: 'train' },
|
||||
{ group: 'CASUAL', layout: 'stack' },
|
||||
{ mode: 'gore' },
|
||||
{ mode: 'classic', diffs: true },
|
||||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||||
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
|
||||
{ name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true },
|
||||
];
|
||||
// Additional solo modes. The splash owns the league fixture, Gore and Vs a Friend.
|
||||
export const MENU = [{ mode: 'zombie' }, { mode: 'train' }, { mode: 'classic' }];
|
||||
|
||||
// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING —
|
||||
// the quality distributions overlapped (0.45 floor either way), ±12% speed never binds because the
|
||||
|
||||
+111
-241
@@ -73,10 +73,7 @@ setupPoint();
|
||||
if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers'])
|
||||
document.fonts.load(f);
|
||||
|
||||
// The overlay is a four-stage machine: splash -> {setup, stats, ranks}. The title screen IS the
|
||||
// menu — a separate menu stage behind it showed the same logo on the same backdrop and read as the
|
||||
// title screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
|
||||
// flashes between screens; HIDDEN during play rather than removed, so a restart puts it back.
|
||||
// One opaque overlay stays mounted across every menu; the court is only revealed on start.
|
||||
const intro = document.getElementById('intro');
|
||||
const menuUp = () => !intro.hidden;
|
||||
const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||
@@ -90,6 +87,7 @@ let pickedTour = remembered('tour') || TOURS[0].id;
|
||||
// menus share, so its offset outlives the stage that earned it unless it is reset here — the one
|
||||
// place every stage change goes through.
|
||||
const go = stage => {
|
||||
if (stage === 'splash') refreshSplash();
|
||||
if (stage === 'stats') refreshRecorder();
|
||||
if (stage === 'setup') syncSetup();
|
||||
if (stage === 'train') buildTrain();
|
||||
@@ -112,99 +110,39 @@ function dealStage(stage) {
|
||||
intro.classList.add('deal');
|
||||
}
|
||||
|
||||
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
|
||||
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
|
||||
// tennis deals its two sub-mode cards, gore just is gore. `pickedMode` stays the RUN mode id
|
||||
// (classic/zombie/cup/gore) so start(), stats and the summary never learn about the grouping.
|
||||
// Additional solo modes. League, Gore and multiplayer have their own splash doors.
|
||||
let pickedMode = remembered('mode') || MODES[0].id;
|
||||
let pickedDiff = remembered('diff') || 'medium';
|
||||
const modeOf = id => MODES.find(x => x.id === id);
|
||||
const topOf = m => MENU.find(t => t.mode === m || (t.kids && t.kids.includes(m))) || MENU[0];
|
||||
const modeList = document.getElementById('modeList');
|
||||
const modeSub = document.getElementById('modeSub');
|
||||
// QUESTION SCREENS CARRY NO SELECTION (Oliver: a highlighted card that also navigates is "a
|
||||
// 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
|
||||
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
|
||||
// `opts.line` is the ONE second line the wall still carries: the fixture, under the hero's name.
|
||||
// The corner chips are gone (2026-08-31, Oliver: "sinnlos") — LAST told you what you played last
|
||||
// time, which the wall does not need to remember for you, and 18 RUNGS was a number about a mode
|
||||
// on a screen for choosing between modes.
|
||||
const banner = (card, cls, opts = {}) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = cls;
|
||||
// JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the
|
||||
// caption sits on a veil now (index.html, button.mode::after) that rises out of the bottom edge,
|
||||
// and stacking the old gradient under it only doubled the darkening.
|
||||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||||
// 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>`
|
||||
// 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>` : '');
|
||||
return b;
|
||||
};
|
||||
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
|
||||
// TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one
|
||||
// decision per screen, and a NEXT button under a single row of choices is a second tap for nothing.
|
||||
// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.)
|
||||
function buildSub(top) {
|
||||
modeSub.innerHTML = '';
|
||||
const t = top || topOf(pickedMode);
|
||||
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
|
||||
// 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');
|
||||
b.className = 'chip' + (i === DIFFS.length - 1 ? ' hot' : '');
|
||||
// no LAST chip here either (Oliver): a question screen carries no memory of its own answer —
|
||||
// the wizard's own rule, applied to the last screen that was still breaking it
|
||||
b.innerHTML = `<b>${d.name}</b><span class="pips">`
|
||||
+ [0, 1, 2].map(k => `<i${k <= i ? ' class="on"' : ''}></i>`).join('') + '</span>';
|
||||
b.addEventListener('click', () => {
|
||||
pickedDiff = d.id;
|
||||
try { localStorage.setItem('diff', d.id); } catch {}
|
||||
go('setup');
|
||||
});
|
||||
modeSub.appendChild(b);
|
||||
});
|
||||
}
|
||||
// the top posters are rebuilt on every visit so the LAST tag follows what was actually played
|
||||
function buildTops() {
|
||||
modeList.innerHTML = '';
|
||||
let shelf = modeList; // posters land in the section they belong to
|
||||
for (const t of MENU) {
|
||||
// A GROUP ROW OPENS A SECTION (2026-08-31, Oliver: the headings "sind sehr klein, vielleicht
|
||||
// können wir daraus richtige sektionen machen"). The posters go INSIDE it — a heading that
|
||||
// merely floats above a stack is a label, and the wall needs three rooms, not three labels.
|
||||
if (t.group) {
|
||||
shelf = document.createElement('section');
|
||||
shelf.className = 'mgroup ' + (t.layout || '');
|
||||
shelf.innerHTML = `<header><b>${t.group}</b></header><div class="shelf"></div>`;
|
||||
modeList.appendChild(shelf);
|
||||
shelf = shelf.querySelector('.shelf'); // the posters live in the row, not the section
|
||||
continue;
|
||||
}
|
||||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||
// the fixture is the hero and carries the only second line on the wall
|
||||
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode',
|
||||
{ line: t.mode === 'cup' ? 'LEAGUE ' + fx.lg + ' · ' + fx.line : null });
|
||||
for (const { mode } of MENU) {
|
||||
const card = modeOf(mode), b = document.createElement('button');
|
||||
b.className = 'mode';
|
||||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||||
b.innerHTML = `<span class="cb"><b>${card.name}</b></span>`;
|
||||
b.addEventListener('click', () => {
|
||||
// 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
|
||||
if (t.net) { vsOpen(); return; }
|
||||
// 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]));
|
||||
buildSub();
|
||||
go(t.diffs || t.kids ? 'modesub' : 'setup');
|
||||
pickMode(mode);
|
||||
go(mode === 'train' ? 'train' : 'setup');
|
||||
});
|
||||
shelf.appendChild(b);
|
||||
modeList.appendChild(b);
|
||||
}
|
||||
}
|
||||
buildTops();
|
||||
buildSub();
|
||||
const difficultyList = document.getElementById('difficultyList');
|
||||
for (const d of DIFFS) {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = d.name;
|
||||
b.dataset.id = d.id;
|
||||
b.addEventListener('click', () => {
|
||||
pickedDiff = d.id;
|
||||
try { localStorage.setItem('diff', d.id); } catch {}
|
||||
syncSetup();
|
||||
});
|
||||
difficultyList.appendChild(b);
|
||||
}
|
||||
|
||||
// ---- THE LOBBY. Oliver, 2026-08-15: *"multiplayer should be easier to setup — a share with link
|
||||
// next to the code; entering the code is done in some plain js confirm dialog thingy; somewhere
|
||||
@@ -266,7 +204,7 @@ const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of
|
||||
document.getElementById('vsCancel').addEventListener('click', vsQuit);
|
||||
document.getElementById('vsBack').addEventListener('click', () => {
|
||||
if (!vsWait.hidden) return vsQuit(); // waiting: the chevron cancels rather than leaves
|
||||
endNetVs(); goMode();
|
||||
endNetVs(); go('splash');
|
||||
});
|
||||
// the three hooks net.js calls back through — all of them no-ops once the court has been taken
|
||||
netHooks.paired = () => start('classic'); // start() refuses when the menu is already down (URL entry)
|
||||
@@ -279,38 +217,20 @@ netHooks.lost = text => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
|
||||
// the setup screen picks WHO and WHERE, and nothing on it commits you.
|
||||
const tourList = document.getElementById('tours');
|
||||
for (const t of TOURS) {
|
||||
const b = document.createElement('button');
|
||||
b.className = t.id === pickedTour ? 'on' : '';
|
||||
b.dataset.id = t.id; // syncSetup finds the row's buttons by id
|
||||
// just the word now — the live player beside it is the portrait, and the tile art was a SECOND
|
||||
// drawing of the same man that no colour on this screen could reach
|
||||
b.textContent = t.name.replace(' SINGLES', '');
|
||||
b.addEventListener('click', () => {
|
||||
pickedTour = t.id;
|
||||
for (const el of tourList.children) el.classList.toggle('on', el === b);
|
||||
refreshLook();
|
||||
});
|
||||
tourList.appendChild(b);
|
||||
}
|
||||
// THE LIVE PLAYER, redrawn on every tap of a tour, a kit or a skin. It draws the REAL sprite through
|
||||
// the REAL tint (drawLook), so there is no second recolour to keep in step with the first — the only
|
||||
// way this can show a wrong colour is if the court would show it too.
|
||||
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
|
||||
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
|
||||
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook'),
|
||||
const pLookCv = document.getElementById('pLook'),
|
||||
cdLookCv = document.getElementById('cdLook');
|
||||
let lookPoll = 0;
|
||||
function refreshLook() {
|
||||
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
|
||||
const k = KITS.find(e => e.id === pickedKit) || KITS[0];
|
||||
const s = SKINS.find(e => e.id === pickedSkin) || SKINS[0];
|
||||
// both live players — the setup screen's and the identity screen's — are the same man and must
|
||||
// move together; drawing to whichever is hidden is a no-op-cheap blit
|
||||
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s) & drawLook(cdLookCv, t, k, s);
|
||||
// The editor and profile draw the same kit; drawing the hidden one is a cheap blit.
|
||||
const ok = drawLook(pLookCv, t, k, s) & drawLook(cdLookCv, t, k, s);
|
||||
clearTimeout(lookPoll);
|
||||
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
|
||||
}
|
||||
@@ -341,26 +261,8 @@ const swatches = (rowId, nameId, table, keyName, get, set) => {
|
||||
};
|
||||
let pickedKit = remembered('kit') || KITS[0].id;
|
||||
let pickedSkin = remembered('skin') || SKINS[0].id;
|
||||
swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
|
||||
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
|
||||
refreshLook();
|
||||
|
||||
// THE CLIMB PANEL: the arcade's setup face — the marathon's length (grown by every promotion)
|
||||
// and your best run. Reuses the league panel's furniture; the foes row stays empty (up to 18
|
||||
// tiles is a wall, and the training picker already shows the roster face-up).
|
||||
function buildClimbPanel() {
|
||||
const lg = leagueOf(profile.prof.lg);
|
||||
document.getElementById('lgBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
|
||||
const name = document.getElementById('lgName');
|
||||
name.textContent = 'THE CLIMB';
|
||||
name.style.color = '';
|
||||
const best = stats.read('all', undefined, stats.MODE_MATCH).longest;
|
||||
// two deliberate lines — as one string the browser broke it wherever it fell (Oliver)
|
||||
document.getElementById('lgSub').innerHTML =
|
||||
`THE CLUB FROM THE BOTTOM — ${lg.band[1]} STAND` + (best ? `<br>BEST RUN: ${best} FELLED` : '');
|
||||
document.getElementById('lgFoes').innerHTML = '';
|
||||
}
|
||||
|
||||
// THE LEAGUE PANEL (the leagues): the cup's setup face — your league writ large and the band's
|
||||
// roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The identity
|
||||
// block these panels replace still shows for every other mode; the card owns it anyway.
|
||||
@@ -395,48 +297,44 @@ function buildLeaguePanel() {
|
||||
: '<br>THE CUP IS THE WAY UP — NEVER ENTERED');
|
||||
}
|
||||
|
||||
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
|
||||
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
|
||||
// On the LEAGUE MODES the player block steps aside for the league panel (Oliver: the identity is
|
||||
// on the card now — the setup should show the league and who of it you have already beaten).
|
||||
// A setup contains only decisions for this match. Identity lives in the player screen.
|
||||
function syncSetup() {
|
||||
const cupMode = pickedMode === 'cup', climbMode = pickedMode === 'zombie';
|
||||
document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP';
|
||||
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
|
||||
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 =
|
||||
cupMode ? 'YOUR LEAGUE' : climbMode ? 'THE CLIMB' : 'PLAYER';
|
||||
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
|
||||
document.getElementById('sTitle').textContent = modeOf(pickedMode).name;
|
||||
document.getElementById('sPlayerName').textContent = stats.savedName() || 'YOUR PLAYER';
|
||||
document.getElementById('sPlayerKit').textContent =
|
||||
(KITS.find(k => k.id === pickedKit) || KITS[0]).name;
|
||||
document.getElementById('sDifficulty').hidden = pickedMode !== 'classic';
|
||||
for (const b of difficultyList.children) {
|
||||
b.classList.toggle('on', b.dataset.id === pickedDiff);
|
||||
b.setAttribute('aria-pressed', b.dataset.id === pickedDiff);
|
||||
}
|
||||
const opponent = document.getElementById('sOpponent');
|
||||
opponent.hidden = pickedMode !== 'train';
|
||||
if (pickedMode === 'train') {
|
||||
const f = matchFoe(trainRung);
|
||||
opponent.querySelector('img').src = foeArt(f);
|
||||
opponent.querySelector('b').textContent = f.name;
|
||||
}
|
||||
const climb = document.getElementById('sClimb');
|
||||
climb.hidden = pickedMode !== 'zombie';
|
||||
climb.textContent = `THE CLUB, BOTTOM UP · ${leagueOf(profile.prof.lg).band[1]} OPPONENTS`;
|
||||
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
|
||||
// (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list.
|
||||
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
|
||||
? 'HOME COURT<span>SET BY YOUR LEAGUE</span>' : 'COURT<span>PACE · BOUNCE</span>';
|
||||
courtList.classList.toggle('fixed', cupMode);
|
||||
for (const el of courtList.children) {
|
||||
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
|
||||
let locked = 0;
|
||||
for (const el of courtButtons) {
|
||||
const open = courtUnlocked(el.dataset.id);
|
||||
el.classList.toggle('lock', !open && !cupMode);
|
||||
el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt
|
||||
: open && el.dataset.id === picked);
|
||||
el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : '';
|
||||
// an OPEN court says nothing — its two meters say it wordlessly (the text reduction). A locked
|
||||
// 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;
|
||||
el.disabled = !open;
|
||||
el.classList.toggle('lock', !open);
|
||||
el.classList.toggle('on', open && el.dataset.id === picked);
|
||||
el.setAttribute('aria-pressed', open && el.dataset.id === picked);
|
||||
const sub = el.querySelector('.cb > i');
|
||||
sub.textContent = open ? '' : 'LEAGUE ' + courtLeague(el.dataset.id).id;
|
||||
sub.hidden = open;
|
||||
(open ? courtList : lockedCourtList).appendChild(el);
|
||||
if (!open) locked++;
|
||||
}
|
||||
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],
|
||||
['skinRow', 'skinName', SKINS, pickedSkin]]) {
|
||||
for (const el of document.getElementById(rowId).children)
|
||||
el.classList.toggle('on', el.dataset.id === cur);
|
||||
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
|
||||
}
|
||||
refreshLook();}
|
||||
document.getElementById('lockedCourts').hidden = !locked;
|
||||
document.getElementById('lockedCourtLabel').textContent = `LOCKED COURTS · ${locked}`;
|
||||
}
|
||||
|
||||
// --- 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
|
||||
@@ -444,6 +342,8 @@ function syncSetup() {
|
||||
// are REBUILT on every visit: their .on states have to reflect taps made on the other screen.
|
||||
const pName = document.getElementById('pName');
|
||||
function buildPlayer() {
|
||||
document.getElementById('pGo').textContent = profile.prof.setup ? 'SAVE PLAYER' : 'ENTER THE CLUB';
|
||||
document.getElementById('pNote').hidden = !!profile.prof.setup;
|
||||
pName.value = stats.savedName() || profile.zombieName();
|
||||
const tRow = document.getElementById('pTours');
|
||||
tRow.innerHTML = '';
|
||||
@@ -466,17 +366,19 @@ function buildPlayer() {
|
||||
refreshLook();
|
||||
}
|
||||
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
|
||||
document.getElementById('pBack').addEventListener('click', () => {
|
||||
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
|
||||
go('splash'); // setup stays unset — it re-asks
|
||||
});
|
||||
function leavePlayer(saved) {
|
||||
const back = playerReturn; playerReturn = null;
|
||||
if (back === 'card') { buildCard(); go('card'); }
|
||||
else if (back === 'setup') go('setup');
|
||||
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
|
||||
else go('splash');
|
||||
}
|
||||
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
|
||||
document.getElementById('pGo').addEventListener('click', () => {
|
||||
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
|
||||
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
|
||||
try { localStorage.setItem('tour', pickedTour); } catch {}
|
||||
profile.prof.setup = 1; profile.save(); profile.push();
|
||||
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
|
||||
buildTops(); go('mode');
|
||||
leavePlayer(true);
|
||||
});
|
||||
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
|
||||
|
||||
@@ -493,6 +395,7 @@ const meter = (label, v, cls = '') =>
|
||||
`<div class="meter ${cls}" title="${label}"><s><i style="width:${
|
||||
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
|
||||
const courtList = document.getElementById('courtList');
|
||||
const lockedCourtList = document.getElementById('lockedCourtList'), courtButtons = [];
|
||||
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
|
||||
// moment you reach that league (settled: the unlock is the reward formula). A court no league
|
||||
// names stays free forever.
|
||||
@@ -510,34 +413,23 @@ for (const c of COURTS) {
|
||||
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
|
||||
// you no way to look at the three of them and no way back out of the screen.
|
||||
b.addEventListener('click', () => {
|
||||
if (pickedMode === 'cup') return; // the cup is the league fixture — the league picks
|
||||
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
|
||||
picked = c.id;
|
||||
for (const el of courtList.children) el.classList.toggle('on', el === b);
|
||||
try { localStorage.setItem('court', picked); } catch {}
|
||||
syncSetup();
|
||||
});
|
||||
courtButtons.push(b);
|
||||
courtList.appendChild(b);
|
||||
}
|
||||
// a remembered pick that is locked for THIS profile (fresh install, adopted mirror) falls back
|
||||
// to the league's own court rather than serving up a court the player has not earned
|
||||
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
|
||||
|
||||
// THE DRAW SCREEN IS GONE (2026-08-29, Oliver: "das ist doch jetzt eigentlich obsolet"). It was
|
||||
// built for the 18-rung ladder, where four bands rolling out of a deep hat was a ceremony worth a
|
||||
// screen; a league cup shuffles 2-3 faces the setup's league panel already shows face-up. The
|
||||
// shuffle itself survives INSIDE the run (cupDraw rolls without replacement, the final settled)
|
||||
// and each round still announces itself on the walk-on modal — only the screen is deleted. The
|
||||
// cup's big button says ENTER THE CUP (syncSetup), which is all the ritual the moment needs.
|
||||
// (It said ENTER THE DRAW until 2026-08-31 — a draw is a field of entrants, and nothing has been
|
||||
// drawn since the rounds went deterministic. Oliver had to ask what it meant, which settled it.)
|
||||
// The screen's mechanics live in git history (like the lob) if the ceremony is ever asked back.
|
||||
// the portrait: every family's own head crop (assets/_heads.py), the club pro's included — the
|
||||
// same file the in-play walk-on card uses, so the picker and the court show the same face
|
||||
const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
|
||||
|
||||
// ---- THE TRAINING PICKER (Oliver, 2026-08-28: "ein Trainingsbereich — Auswahl des Gegners, ein
|
||||
// Spiel, tap to replay, bis man keine Lust mehr hat"). The whole ladder face-up, one tile per rung,
|
||||
// wearing the same z_*head crop the draw and the walk-on wear. Tap = start — the wizard's own
|
||||
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
|
||||
// Training chooses the opponent first, then the shared setup confirms the court and starts.
|
||||
let trainRung = 1;
|
||||
const trainList = document.getElementById('trainList');
|
||||
// rebuilt on entry (go('train')): you can only train who the club has let you meet — every rung
|
||||
@@ -551,7 +443,7 @@ function buildTrain() {
|
||||
const out = profile.prof.out;
|
||||
const rung = n => {
|
||||
const f = matchFoe(n), b = document.createElement('button');
|
||||
b.addEventListener('click', () => { trainRung = n; start('train'); });
|
||||
b.addEventListener('click', () => { trainRung = n; go('setup'); });
|
||||
return [f, b];
|
||||
};
|
||||
// THE REVANCHE IS A ROW, not a tile in the grid (the text reduction): it is one body out of
|
||||
@@ -582,7 +474,7 @@ function buildTrain() {
|
||||
trainList.appendChild(goal);
|
||||
}
|
||||
}
|
||||
document.getElementById('trBack').addEventListener('click', () => go('setup'));
|
||||
document.getElementById('trBack').addEventListener('click', () => goMode());
|
||||
|
||||
// --- THE CHARACTER CARD (the leagues, phase 3). The splash's level strip is the door. The
|
||||
// honours row is a COLLECTION: every slot always visible, earned badges bright, the rest dark —
|
||||
@@ -661,35 +553,25 @@ function buildCard() {
|
||||
champ.innerHTML = `<img src="assets/badge_champ.png?v=${SPRV}" alt="" title="CHAMPION OF THE DEAD CLUB">`
|
||||
+ `<u>${profile.prof.ch ? 'CHAMPION' : '—'}</u>` + glints(profile.prof.ch);
|
||||
row.appendChild(champ);
|
||||
const g = stats.read('all', undefined, stats.MODE_GORE),
|
||||
z = stats.read('all', undefined, stats.MODE_MATCH),
|
||||
c = stats.read('all', undefined, stats.MODE_CLASSIC);
|
||||
// the ledger climbs: rows are [box id, [label, target, format]...]; every value starts at 0 and
|
||||
// counts up through the same easing the bars sweep with
|
||||
// THE CARD IS WHO YOU ARE, not what you have done (2026-08-31, the stats streamline): the
|
||||
// lifetime ledger lives on BODY COUNT, which is the screen for it. What stays here is the
|
||||
// CAREER — how far up the club you have got, and how far each cup let you.
|
||||
for (const [boxId, rows] of [
|
||||
['cdFigs1', [['LEAGUE', profile.prof.lg], ['CUPS WON', profile.prof.lg - 1 + profile.prof.ch],
|
||||
['BEST CUP', profile.cupBest(profile.prof.lg)]]],
|
||||
['cdFigs2', [['BODIES FELLED', g.kills + z.kills + c.kills],
|
||||
['DEEPEST CLIMB', z.longest],
|
||||
['RUNS', g.matches + z.matches + c.matches]]]]) {
|
||||
const box = document.getElementById(boxId);
|
||||
box.innerHTML = rows.map(([label]) => fig(label, '0')).join('');
|
||||
[...box.querySelectorAll('.fig i')].forEach((cell, i) => {
|
||||
const fmt = rows[i][2] || (v => v.toLocaleString('en-GB'));
|
||||
countUp(v => cell.textContent = fmt(v), rows[i][1]);
|
||||
});
|
||||
}
|
||||
const rows = [['LEAGUE', profile.prof.lg], ['CUPS WON', profile.prof.lg - 1 + profile.prof.ch],
|
||||
['BEST CUP', profile.cupBest(profile.prof.lg)]];
|
||||
const box = document.getElementById('cdFigs1');
|
||||
box.innerHTML = rows.map(([label]) => fig(label, '0')).join('');
|
||||
[...box.querySelectorAll('.fig i')].forEach((cell, i) => {
|
||||
countUp(v => cell.textContent = v.toLocaleString('en-GB'), rows[i][1]);
|
||||
});
|
||||
buildLeaguePanel();
|
||||
refreshLook();
|
||||
}
|
||||
document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });
|
||||
document.getElementById('cdBack').addEventListener('click', () => go('splash'));
|
||||
let playerReturn = null; // EDIT PLAYER borrows the identity screen and comes back to the card
|
||||
let playerReturn = null; // The editor returns to its caller; first-time cup entry resumes the cup.
|
||||
document.getElementById('cdEdit').addEventListener('click', () => {
|
||||
playerReturn = 'card'; buildPlayer(); go('player');
|
||||
});
|
||||
document.getElementById('sEdit').addEventListener('click', () => {
|
||||
playerReturn = 'setup'; buildPlayer(); go('player');
|
||||
});
|
||||
|
||||
// --- THE PROMOTION CEREMONY (the leagues). beats.cupWon parks it in the profile module; the tap
|
||||
// off the cup summary lands here (maybeRestart) before the title screen. The court tile IS the
|
||||
@@ -732,13 +614,11 @@ document.getElementById('prGo').addEventListener('click', () => {
|
||||
else go('splash');
|
||||
});
|
||||
|
||||
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
|
||||
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
|
||||
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
|
||||
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
|
||||
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
// Open an existing world board. Classic has no leaderboard; its fallback is Gore.
|
||||
const boardFor = m => m === 'cup' ? stats.MODE_CUP
|
||||
: m === 'zombie' || m === 'train' ? stats.MODE_MATCH : stats.MODE_GORE;
|
||||
|
||||
// The splash keeps career progress on the plaque; detailed records live behind STATS and BOARD.
|
||||
// The splash plaque opens the profile and its stats; the world board has its own door.
|
||||
// Re-read on each arrival because the preceding run may have moved the level or league.
|
||||
function refreshSplash() {
|
||||
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
|
||||
@@ -756,6 +636,8 @@ function refreshSplash() {
|
||||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||||
document.getElementById('msTitle').textContent = fx.title;
|
||||
document.getElementById('msLine').textContent = fx.line;
|
||||
document.getElementById('mCourt').textContent = 'COURT · ' +
|
||||
(COURTS.find(c => c.id === picked) || COURTS[0]).name;
|
||||
}
|
||||
|
||||
|
||||
@@ -789,8 +671,7 @@ function start(modeId = pickedMode) {
|
||||
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
|
||||
}
|
||||
|
||||
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
|
||||
// came to look at, and it saves a tap on the way in every single time
|
||||
// Career totals and detailed records live one level beneath the profile.
|
||||
const openStats = () => {
|
||||
stats.drawStats('all', SPRV); go('stats');
|
||||
};
|
||||
@@ -804,6 +685,7 @@ const recordingMeta = () => ({
|
||||
});
|
||||
function refreshRecorder() {
|
||||
const r = recorder.recordingInfo();
|
||||
document.getElementById('recLabel').textContent = 'PLAYTEST RECORDING' + (r.full ? ' · FULL' : r.active ? ' · ON' : '');
|
||||
document.getElementById('recStatus').textContent = r.warning || (r.full
|
||||
? 'Recording full. Export it, then start a new recording.'
|
||||
: (r.active ? 'Recording' : r.exists ? 'Paused' : 'Off') + ' · ' + r.points + ' points saved on this phone.');
|
||||
@@ -841,26 +723,20 @@ document.addEventListener('visibilitychange', () => {
|
||||
}
|
||||
});
|
||||
addEventListener('pagehide', () => recorder.saveRecording());
|
||||
// entering a question screen rebuilds it, so the LAST tag always names the run actually played —
|
||||
// and the FIRST visit ever detours through the identity screen (the leagues): the club wants a
|
||||
// name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the
|
||||
// question it interrupted nothing.
|
||||
const goMode = () => {
|
||||
if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
|
||||
buildTops(); go('mode');
|
||||
};
|
||||
// the fixture button IS the tournament — it skips the setup screen on purpose: the cup has no
|
||||
// court to pick (it plays the league's own) and no opponent to choose, so that screen was a
|
||||
// confirmation page with nothing on it to confirm
|
||||
const goMode = () => { buildTops(); go('mode'); };
|
||||
document.getElementById('mStart').addEventListener('click', () => {
|
||||
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
|
||||
if (!profile.prof.setup) { playerReturn = 'cup'; buildPlayer(); go('player'); return; }
|
||||
pickMode('cup'); start('cup');
|
||||
});
|
||||
document.getElementById('mOther').addEventListener('click', goMode);
|
||||
document.getElementById('mVs').addEventListener('click', () => vsOpen());
|
||||
document.getElementById('mGore').addEventListener('click', () => {
|
||||
pickMode('gore'); start('gore');
|
||||
});
|
||||
document.getElementById('mStats').addEventListener('click', openStats);
|
||||
document.getElementById('mCourt').addEventListener('click', () => {
|
||||
pickMode('gore'); go('setup');
|
||||
});
|
||||
document.getElementById('cdStats').addEventListener('click', openStats);
|
||||
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
|
||||
// whose names you came to look at
|
||||
let rkMode = stats.MODE_GORE;
|
||||
@@ -927,20 +803,14 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
|
||||
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
|
||||
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
|
||||
// handler always passes something.
|
||||
// SERVE UP starts the run — except in the cup, where the draw has to be made first. That screen
|
||||
// carries its own start button, so this stays the one place a run begins from the setup screen.
|
||||
document.getElementById('cStart').addEventListener('click', () => {
|
||||
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
|
||||
start();
|
||||
});
|
||||
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
|
||||
// Every setup's primary button starts exactly the match shown above it.
|
||||
document.getElementById('cStart').addEventListener('click', () => start());
|
||||
for (const id of ['mBack', 'rkBack', 'scBack'])
|
||||
document.getElementById(id).addEventListener('click', () => go('splash'));
|
||||
// the wizard walks back the way it came: who-and-where returns to the mode's own second step when
|
||||
// it has one, the second step returns to the posters
|
||||
document.getElementById('subBack').addEventListener('click', goMode);
|
||||
document.getElementById('stBack').addEventListener('click', () => { buildCard(); go('card'); });
|
||||
document.getElementById('sBack').addEventListener('click', () => {
|
||||
const t = topOf(pickedMode);
|
||||
if (t.diffs || t.kids) { buildSub(); go('modesub'); }
|
||||
if (pickedMode === 'gore') go('splash');
|
||||
else if (pickedMode === 'train') go('train');
|
||||
else goMode();
|
||||
});
|
||||
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Local menu regression: run with browser_run_code_unsafe(filename: "tools/check-menu.js").
|
||||
// Uses the browser tool's Playwright runtime; no package or test code ships to the game.
|
||||
async (hostPage) => {
|
||||
const context = await hostPage.context().browser().newContext({
|
||||
viewport: { width: 320, height: 568 }, isMobile: true, hasTouch: true,
|
||||
});
|
||||
const page = await context.newPage(), errors = [], checks = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
const check = (ok, label) => { if (!ok) throw new Error(label); checks.push(label); };
|
||||
const click = selector => page.locator(selector).click();
|
||||
const stage = () => page.locator('#intro').getAttribute('data-stage');
|
||||
const quit = async () => { await click('#qChip'); await click('#qYes'); };
|
||||
const fits = async selector => {
|
||||
await page.waitForTimeout(650); // let the intentional menu entrance finish
|
||||
return page.locator(selector).evaluate(e => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight;
|
||||
});
|
||||
};
|
||||
try {
|
||||
await page.goto('http://localhost:8641');
|
||||
await page.waitForFunction(() => !document.querySelector('#splash').classList.contains('busy'));
|
||||
for (const selector of ['#mStart', '#mGore', '#mOther', '#mVs', '#mRanks'])
|
||||
check(await fits(selector), '320px splash fits ' + selector);
|
||||
check(await page.evaluate(() => document.querySelector('#mStart').offsetHeight ===
|
||||
document.querySelector('#mGore').offsetHeight), 'Cup and Gore share one height');
|
||||
check(await page.locator('.splashDoors button').evaluateAll(bs => bs.every(b =>
|
||||
getComputedStyle(b).textAlign === 'center' && getComputedStyle(b).justifyContent === 'center')),
|
||||
'secondary door labels are centered');
|
||||
check(await page.evaluate(() => {
|
||||
const v = document.querySelector('#mVs'), g = document.querySelector('#mGore');
|
||||
const drop = getComputedStyle(v, '::before');
|
||||
const animations = document.getAnimations().filter(a => a.animationName?.startsWith('menuBlood'));
|
||||
return Math.abs(v.getBoundingClientRect().top + 2 + parseFloat(drop.top) - g.getBoundingClientRect().bottom) < 1
|
||||
&& drop.pointerEvents === 'none' && getComputedStyle(v, '::after').pointerEvents === 'none'
|
||||
&& animations.length === 2 && animations.every(a => a.effect.getTiming().duration === 18000);
|
||||
}), 'blood starts at Gore edge with synchronized decorative animations');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
check(await page.evaluate(() => !document.getAnimations().some(a => a.animationName?.startsWith('menuBlood'))),
|
||||
'blood respects reduced motion');
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await click('#mStart');
|
||||
check(await stage() === 'player', 'first cup requests identity');
|
||||
await page.locator('#pName').fill('MENU CHECK');
|
||||
await click('#pGo');
|
||||
check(await page.evaluate(() => document.querySelector('#intro').hidden && !!T.G.match.cup),
|
||||
'identity resumes the selected cup');
|
||||
await quit();
|
||||
await click('#mRanks');
|
||||
check(await page.locator('#rkModes .on').innerText() === 'THE CUP', 'cup opens its own world board');
|
||||
await click('#rkBack');
|
||||
await click('#mOther');
|
||||
check(await page.evaluate(() => !document.getAnimations().some(a => a.animationName?.startsWith('menuBlood'))),
|
||||
'blood animation stops away from splash');
|
||||
check((await page.locator('#modeList button').allTextContents()).join('|') ===
|
||||
'THE CLIMB|TRAINING|CLASSIC TENNIS', 'other games has exactly three additional solo modes');
|
||||
check(await fits('#modeList button:last-child'), 'all additional modes fit on small portrait');
|
||||
await page.getByRole('button', { name: 'CLASSIC TENNIS', exact: true }).click();
|
||||
check(await stage() === 'setup' && await page.locator('#sDifficulty').isVisible(),
|
||||
'classic opens setup with inline difficulty');
|
||||
await page.getByRole('button', { name: 'HARD', exact: true }).click();
|
||||
check(await stage() === 'setup', 'difficulty selects without navigating');
|
||||
check(await page.locator('#courtList button').count() === 1, 'unlocked court is shown first');
|
||||
check(await page.locator('#lockedCourts').evaluate(e => e.open), 'locked courts start expanded');
|
||||
check(await page.locator('#lockedCourtList button:disabled').count() === 4, 'locked courts cannot be selected');
|
||||
await click('#lockedCourtLabel');
|
||||
await click('#sEdit');
|
||||
await page.locator('#pName').fill('MENU EDIT');
|
||||
await page.locator('#pKitRow button[title="CLARET"]').click();
|
||||
await click('#pGo');
|
||||
check(await stage() === 'setup' && await page.locator('#sPlayerName').innerText() === 'MENU EDIT',
|
||||
'player edit returns to setup with saved identity');
|
||||
check(await page.locator('#sPlayerKit').innerText() === 'CLARET', 'setup reflects edited kit');
|
||||
await click('#cStart');
|
||||
check(await page.evaluate(() => T.G.classic && T.G.diff.id === 'hard'), 'serve starts chosen classic difficulty');
|
||||
await quit();
|
||||
await click('#mRanks');
|
||||
check(await page.locator('#rkModes .on').count() === 1, 'classic fallback selects an existing world board');
|
||||
await click('#rkBack');
|
||||
await click('#mOther');
|
||||
await page.getByRole('button', { name: 'TRAINING', exact: true }).click();
|
||||
check(await stage() === 'train', 'training chooses opponent before setup');
|
||||
await page.getByRole('button', { name: 'THE BALL BOY 02', exact: true }).click();
|
||||
check(await stage() === 'setup' && await page.locator('#sOpponent b').innerText() === 'THE BALL BOY',
|
||||
'training setup identifies chosen opponent');
|
||||
await click('#sBack');
|
||||
check(await stage() === 'train', 'training setup returns to opponents');
|
||||
await page.getByRole('button', { name: 'THE BALL BOY 02', exact: true }).click();
|
||||
await click('#cStart');
|
||||
check(await page.evaluate(() => T.G.match.train && T.G.match.opp === 2), 'serve starts selected training opponent');
|
||||
await quit();
|
||||
await click('#mGore');
|
||||
check(await page.evaluate(() => document.querySelector('#intro').hidden && T.G.mode === 'splatter' && !T.G.match),
|
||||
'Gore remains a direct start');
|
||||
await quit();
|
||||
await click('#mCourt');
|
||||
check(await stage() === 'setup' && await page.locator('#sTitle').innerText() === 'SPLATTER GORE',
|
||||
'Gore court link opens its setup');
|
||||
await click('#sBack');
|
||||
check(await stage() === 'splash', 'Gore setup returns to splash');
|
||||
await click('#mVs');
|
||||
await click('#vsHostC');
|
||||
await page.waitForFunction(() => /^[A-Z0-9]{4}$/.test(document.querySelector('#vsBig').textContent));
|
||||
await click('#vsCancel');
|
||||
await click('#vsBack');
|
||||
check(await stage() === 'splash' && await page.evaluate(() => !T.G.net), 'friend lobby cancels and returns directly to splash');
|
||||
await click('#plaque');
|
||||
check(await page.locator('#cdLeagueDetails').evaluate(e => e.open), 'league details start expanded');
|
||||
await click('#cdStats');
|
||||
check(await stage() === 'stats' && await page.locator('#recorder').evaluate(e => e.open),
|
||||
'stats belongs to profile and recording starts expanded');
|
||||
await click('#recToggle');
|
||||
check((await page.locator('#recLabel').innerText()).includes('ON'), 'recording state remains visible when collapsed');
|
||||
await click('#recToggle');
|
||||
await click('#recLabel');
|
||||
await click('#stBack');
|
||||
check(await stage() === 'card', 'stats returns to profile');
|
||||
await click('#cdEdit');
|
||||
check(await page.locator('#pGo').innerText() === 'SAVE PLAYER', 'existing identity uses save wording');
|
||||
check(await page.locator('#pKitRow button').evaluateAll(bs => bs.every(b => {
|
||||
const r = b.getBoundingClientRect(); return r.width >= 44 && r.height >= 44;
|
||||
})), 'colour swatches meet 44px targets at 320px');
|
||||
await click('#pBack');
|
||||
check(await stage() === 'card', 'editing back returns to profile');
|
||||
await click('#cdBack');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
check(await fits('#mVs'), 'friend door fits large portrait');
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
check(await fits('#intro .card') && await page.locator('#intro .card').evaluate(e =>
|
||||
e.getBoundingClientRect().width === innerWidth && e.getBoundingClientRect().height >= innerHeight),
|
||||
'menu card fills wider portrait viewport');
|
||||
check(errors.length === 0, 'no browser runtime errors: ' + errors.join('; '));
|
||||
return { passed: checks.length, checks };
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
Reference in new issue
Block a user