diff --git a/PROJECT.md b/PROJECT.md index 86ff629..d256fe4 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/assets/logo-v2.png b/assets/logo-v2.png new file mode 100644 index 0000000..b7eeb7b Binary files /dev/null and b/assets/logo-v2.png differ diff --git a/index.html b/index.html index de9d636..82468bb 100644 --- a/index.html +++ b/index.html @@ -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
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
- +
@@ -840,7 +885,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
- +

Fifteen. Thirty. Forty. Dead.

@@ -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. --> - -
- - +
-

PICK A GAME

+

OTHER GAMES

-
-

DIFFICULTY

-
-
-

MATCH SETUP

- -

01 · PLAYER

-
- -
-
- KIT -
- SKIN -
-
+

MATCH SETUP

+ + - -
-
- -
-
-
-
-

02 · COURT

+ + +

COURTPACE · BOUNCE

-
- -
+
+ LOCKED COURTS +
+
+
-

YOUR PLAYER

+

YOUR PLAYER

NAMEYOURS ON EVERY BOARD

@@ -942,14 +962,16 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
- KIT -
- SKIN -
+
+ KIT +
+ SKIN +
+
-

THE CLUB WILL LEARN YOUR NAME.

+

THE CLUB WILL LEARN YOUR NAME.

@@ -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. -->
-

YOUR PLAYER

+

YOUR PLAYER

@@ -989,10 +1011,19 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat

HONOURSWIN THE CUP TO CLIMB

-

THE LEDGERALL TIME

+

CAREER

-
-
+ +
+ YOUR LEAGUE +
+
+ +
+
+
+
+
@@ -1000,7 +1031,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
-

TRAINING

+

TRAINING

+

CHOOSE AN OPPONENT

-

VS A FRIEND

+

VS A FRIEND

YOU

-

BODY COUNT

+

STATS

-
-

PLAYTEST RECORDING

+ +

BY OPPONENTALL TIME

+
+

BY WEAPON

+
+
+ PLAYTEST RECORDING

Record league, Climb and training points to investigate difficulty. Export here after playing.

-
-

BY OPPONENTALL TIME

-
-

BY WEAPON

-
+
-

WORLD BOARD

+

WORLD BOARD

@@ -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. -->
-

BACK ROOM

+

BACK ROOM

@@ -1086,7 +1119,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
-

BOSS TEST

+

BOSS TEST

PICK A CHAPTERFULL ARSENAL

@@ -1107,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
- + diff --git a/src/config.js b/src/config.js index bc8c661..f12c7bc 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/main.js b/src/main.js index 9b3d9a6..bdbe616 100644 --- a/src/main.js +++ b/src/main.js @@ -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 = `${card.name}${opts.line ? `${opts.line}` : ''}` - // the standing answer to "how do I advance", worn by the poster it is true of (config MODES) - + (card.tag ? `${card.tag}` : ''); - 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 = `${d.name}` - + [0, 1, 2].map(k => ``).join('') + ''; - 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 = `
${t.group}
`; - 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 = `${card.name}`; 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 ? `
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() { : '
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 COURTSET BY YOUR LEAGUE' : 'COURTPACE · BOUNCE'; - 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 = '') => `
`; 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 = `` + `${profile.prof.ch ? 'CHAMPION' : '—'}` + 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')); diff --git a/tools/check-menu.js b/tools/check-menu.js new file mode 100644 index 0000000..5dd2742 --- /dev/null +++ b/tools/check-menu.js @@ -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(); } +}