Oliver on v371: the praise word is cut off on the left on the iPhone. It is centred under the player's feet, so out wide it ran past the edge; every pop is now clamped to the canvas by its measured width. Step 4, measured first. The rally sim gets a human bot (taps on the timed ring with a timing spread), a Climb mode and a lost-point breakdown. At level 26 the ladder falls over six rungs and then goes flat: nearly every lost point is the player's own early whiff, the CPU barely ever misses, and the auto-run reaches everything, so no opponent dial decides points. A quality ramp, a follow-through and CPU put-aways were each measured and reverted; the options go to Oliver in PROJECT.md. Shipped: in the rally modes q no longer drops from 1 to 0.43 one ms past PERFECT; it falls smoothly to the window's edge (TUNE.qSeamless). A tap 30 ms early is q 0.86 instead of 0.61. Same window, same whiff edge. v372. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1146 lines
81 KiB
HTML
1146 lines
81 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Splatter Tennis</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<!-- save-to-homescreen: a bloody tennis ball. iOS needs the apple-* tags (it ignores the
|
||
manifest's display/name); Android reads the manifest. -->
|
||
<link rel="icon" type="image/png" href="assets/favicon.png">
|
||
<link rel="apple-touch-icon" href="assets/icon-180.png">
|
||
<link rel="manifest" href="manifest.webmanifest">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-title" content="Splatter">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="theme-color" content="#2b2735">
|
||
<!-- STAGING WEARS A DIFFERENT FACE. Both builds get added to the same phone's homescreen, and two
|
||
identical bloody tennis balls is a coin toss every time you open one. Driven by HOSTNAME, not by
|
||
the branch: this block is identical on staging and on main, so it never conflicts on a merge and
|
||
never has to be remembered. Prod is the one named host; everything else (staging, localhost, an
|
||
IP) is a test build and says so. Runs in <head>, before the install prompt can read any of it. -->
|
||
<script>
|
||
// (`Capacitor`: the SHELL is a prod build whatever its origin says — inside the app the hostname is
|
||
// `localhost`, which would otherwise put the staging face on the shipped game.)
|
||
if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNativePlatform?.()) {
|
||
for (const [sel, href] of [['link[rel=icon]', 'assets/favicon-stg.png'],
|
||
['link[rel=apple-touch-icon]', 'assets/icon-180-stg.png'],
|
||
['link[rel=manifest]', 'manifest-stg.webmanifest']])
|
||
document.querySelector(sel).setAttribute('href', href);
|
||
document.querySelector('meta[name=apple-mobile-web-app-title]').setAttribute('content', 'Splatter STG');
|
||
document.querySelector('meta[name=theme-color]').setAttribute('content', '#14334a');
|
||
document.title = 'Splatter Tennis — STAGING';
|
||
}
|
||
</script>
|
||
<style>
|
||
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack.
|
||
?v= like the sprites: server.py caches a VERSIONED asset for a year and an unversioned one for
|
||
a day, so these three files are fetched once per release instead of revalidated on every cold
|
||
start. Bump alongside SPRV in render.js if a font file is ever replaced. */
|
||
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
|
||
src: url('assets/fonts/Silkscreen-Regular.ttf?v=86') format('truetype'); }
|
||
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
|
||
src: url('assets/fonts/Silkscreen-Bold.ttf?v=86') format('truetype'); }
|
||
/* the ANNOUNCEMENT face. Silkscreen is the HUD and the instructions — small, even, quiet. The
|
||
things that shout (SET, CHAPTER, EPIC!, PERFECT) are a second register on purpose, so a message
|
||
tells you which kind it is before you have read a word of it. Bundled, like the other one. */
|
||
@font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
|
||
src: url('assets/fonts/Bangers-Regular.ttf?v=86') format('truetype'); }
|
||
/* ...and the UPRIGHT display face (2026-08-31). Bangers is drawn slanted — there is no upright
|
||
cut of it, which is also why the chevrons are Silkscreen — so everything that NAMES rather
|
||
than announces needed a third voice. Bungee held it for an afternoon; JERSEY 15 took it in
|
||
the menu-text-reduction handoff, because it sits on the same pixel grid as Silkscreen (it
|
||
reads as one family with the sprite art) and runs proportionally lighter, where Bungee was
|
||
single-weight and blocky. SIL OFL, licence beside it, SUBSET to the characters this game
|
||
actually sets — 9 KB; the pyftsubset command is in PROJECT.md. Sizes run ~+40% against the
|
||
Bungee values: Jersey sits lower in its box. */
|
||
@font-face { font-family: 'Jersey 15'; font-weight: 400; font-display: block;
|
||
src: url('assets/fonts/Jersey15-Subset.ttf?v=87') format('truetype'); }
|
||
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
|
||
laid out in the root's own box — and with the canvas position:fixed the root has no content and
|
||
collapses, so the gradient render.js sets had nowhere to go. */
|
||
html { height: 100%; height: 100dvh; }
|
||
html, body { margin: 0; padding: 0; background: #10141c; overflow: hidden; }
|
||
/* invisible probe: env() is only readable from CSS, so park the insets on an element and let
|
||
render.js measure them. The canvas stays FULL-BLEED (the stands under the notch look good) —
|
||
it is the HUD that has to move, see SAFE in render.js. */
|
||
#safe { position: fixed; top: 0; left: 0; width: 0; height: 0; pointer-events: none; visibility: hidden;
|
||
padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); }
|
||
/* The canvas STRETCHES to the viewport and render.js derives its logical height from the box it
|
||
actually got. Sizing it the other way round (compute a scale from innerWidth/innerHeight, then set
|
||
explicit px) meant it was laid out against whatever innerHeight reported at load — before iOS
|
||
Safari's toolbars had settled — and it stayed a strip short of the bottom forever after.
|
||
100dvh is the dynamic viewport height, so it tracks those toolbars. */
|
||
/* TOP inset only. The status bar is opaque UI and the scoreboard must clear it, so the canvas starts
|
||
below it — but the home indicator is a slim translucent bar, so the canvas runs all the way to the
|
||
PHYSICAL bottom edge and the court art fills it. Only the weapon RACK lifts clear of the indicator,
|
||
via SAFE.bottom in render.js. Reserving the bottom inset here instead just left a dead band.
|
||
100dvh is the dynamic viewport height, so it tracks iOS Safari's toolbars appearing/hiding. */
|
||
canvas { image-rendering: pixelated; display: block; position: fixed; touch-action: none;
|
||
top: env(safe-area-inset-top, 0px);
|
||
left: env(safe-area-inset-left, 0px);
|
||
width: calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
|
||
height: calc(100vh - env(safe-area-inset-top, 0px));
|
||
height: calc(100dvh - env(safe-area-inset-top, 0px)); }
|
||
/* ...but NOT installed. Launched from the homescreen, WebKit does not initialise the DYNAMIC
|
||
viewport until the geometry is "exercised" — 100dvh stays provisional until you rotate the
|
||
phone, which is exactly the bug. Standalone has no toolbars, so 100vh is both correct and
|
||
stable from the first frame; dvh only exists for the browser's disappearing toolbars. */
|
||
@media (display-mode: standalone) {
|
||
html { height: 100vh; }
|
||
canvas { height: calc(100vh - env(safe-area-inset-top, 0px)); }
|
||
}
|
||
/* desktop key hint — top-right, shifted left of the quit chip that owns the corner now */
|
||
#hint { position: fixed; top: 6px; right: 52px; text-align: right;
|
||
font: 400 10px Silkscreen, monospace; color: #6b7688; }
|
||
/* THE DOOR OUT (Oliver: every tennis screen lacked an exit — force-closing the app was the only
|
||
way back to the menu). A quiet chip during play; tapping it freezes the world and asks. */
|
||
/* THE CORNER IS THE DOOR. It used to hang a row below, under the BLOOD strip, which is where a
|
||
thumb never looks for an exit — the BLOOD panel steps aside for it instead (drawBlood in
|
||
render.js reserves this chip's width). Same recessed-panel language as the canvas HUD:
|
||
dark green field, a hairline of the board's own highlight, a 1px inner bevel — and it goes
|
||
crimson while pressed, because red is reserved for the thing you actually came to press. */
|
||
/* --hud is the canvas HUD's own 6-logical-px margin, converted to CSS px by render.js, so the
|
||
chip's top edge lands on the same line as the scoreboard's and the BLOOD panel's. */
|
||
#quit { position: fixed; z-index: 5;
|
||
top: calc(env(safe-area-inset-top, 0px) + var(--hud, 8px));
|
||
right: calc(env(safe-area-inset-right, 0px) + var(--hud, 8px)); }
|
||
/* ...and it is the BLOOD panel's own height: that panel is 24 logical px, which is four --hud. */
|
||
#quit button { width: calc(var(--hud, 8px) * 4); height: calc(var(--hud, 8px) * 4);
|
||
border-radius: 3px; display: grid; place-items: center;
|
||
background: rgba(8,26,18,0.82); border: 1px solid rgba(63,149,96,0.35);
|
||
/* the same drop shadow the canvas HUD carries (render.hudShadow): the chip sits on
|
||
the crowd like the scoreboard does, and half the overlay lifting off the
|
||
backdrop while the other half does not is worse than neither. */
|
||
box-shadow: inset 0 1px 0 rgba(242,234,214,0.10), 3px 3px 3px rgba(0,0,0,0.68);
|
||
/* a plain X, not ✕: Silkscreen has no U+2715, so the dingbat fell back to the
|
||
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; }
|
||
#quit[hidden], #qAsk[hidden] { display: none; }
|
||
.qBox { background: var(--solid); border: 2px solid var(--line); padding: 18px 16px;
|
||
width: min(320px, 84vw); display: flex; flex-direction: column; gap: 10px; }
|
||
/* it is a QUESTION, and a question calls — the one Bangers line on this overlay */
|
||
.qBox p { margin: 0 0 10px; font: 400 20px Bangers, cursive; color: var(--cream);
|
||
text-align: center; letter-spacing: 1px; }
|
||
@media (pointer: coarse) { #hint { display: none; } }
|
||
/* ...and it is UI like any other: gone while the splash is still loading (the keyboard hints
|
||
live outside #intro, so this reaches them through the ancestor) */
|
||
body:has(#splash.busy) #hint { display: none; }
|
||
/* ---- THE MENU PALETTE. The canvas HUD keeps its own blue-grey and gold; the menus are
|
||
deliberately a different room — dark court green, one acid accent, and one crimson reserved for
|
||
the thing you actually came here to press. Five names so a repaint is five edits, not fifty. */
|
||
/* COOL, not olive. The first pass sat around hue 80 — yellow-green — and read loud and warm next
|
||
to the art. These are the same idea rotated towards blue-green and desaturated: the field is
|
||
nearly neutral, the borders are quiet, and the acid accent is the only thing allowed to be
|
||
bright, so it still means "this is the number / this is selected". */
|
||
:root { --ink:#0e1714; --panel:rgba(42,62,54,0.42); --line:#2c4238; --acid:#bcd455;
|
||
--cream:#ccd7ca; --dim:#6c7f74; --red:#a4231f; --bad:#cf5a4e;
|
||
/* the same green as --panel over --ink, but OPAQUE: the pinned header and footer have
|
||
content scrolling underneath them and a translucent bar shows it through */
|
||
--solid:#16211d; }
|
||
/* touch-action is pan-y, not none: the BODY COUNT list is taller than a phone and has to scroll.
|
||
align-content is `safe center` rather than place-items — plain centring on a grid whose item
|
||
overflows pushes the top of the item out of the scrollport, where nothing can reach it. */
|
||
#intro { position: fixed; inset: 0; z-index: 10; touch-action: pan-y; background: var(--ink);
|
||
display: grid; grid-template-columns: 1fr; justify-items: center;
|
||
align-content: safe center; overflow-y: auto; overscroll-behavior: contain; }
|
||
/* the SPLASH sits on the court; the other stages are lists and want a flat field behind them.
|
||
The art is dimmed here rather than baked dark, so the same file can be reused at another level. */
|
||
#intro[data-stage="splash"] {
|
||
/* Three stops, not two. The logo needs a dark, quiet band behind it and the blood needs to be
|
||
READABLE, and those pull opposite ways — so the scrim is heaviest at the top, lifts over the
|
||
court where the stain is, and comes back down only far enough to seat the buttons. */
|
||
background: linear-gradient(rgba(12,20,17,0.60) 0%, rgba(12,20,17,0.30) 46%,
|
||
rgba(12,20,17,0.58) 100%),
|
||
url('assets/menu_bg.png?v=86') 50% 42% / cover no-repeat var(--ink);
|
||
image-rendering: pixelated; }
|
||
/* THE BACK-ROOM HOTSPOT: five taps here open it (main.js). Top-left CORNER, not the logo and not
|
||
the screen — a hidden door has to be somewhere you do not put your thumb by accident, and the
|
||
whole splash was reachable by every stray tap on the way to PLAY MATCH. Invisible on purpose,
|
||
and a child of #splash so it exists on no other stage. */
|
||
#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; 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
|
||
the top, something pinned to the bottom, and whatever is between them taking the slack. The
|
||
card is a full-height flex column, so `margin-top:auto` on the footer drops it to the bottom
|
||
when the content is short and `position:sticky` keeps it there once the content is long enough
|
||
to scroll. Centring the card instead left the buttons floating in the middle of a tall phone. */
|
||
#intro { align-content: stretch; }
|
||
#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-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; }
|
||
/* which build is served — filled in by main.js, empty (and invisible) if the header is missing */
|
||
#build { margin: 14px 0 0; font-size: 9px; color: #4a5a52; letter-spacing: 0.5px; }
|
||
/* 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, #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="stats"] #stats,
|
||
#intro[data-stage="ranks"] #ranks,
|
||
#intro[data-stage="secret"] #secret,
|
||
#intro[data-stage="bosses"] #bosses,
|
||
#intro[data-stage="train"] #train,
|
||
#intro[data-stage="vs"] #vs,
|
||
#intro[data-stage="player"] #player,
|
||
#intro[data-stage="promo"] #promo,
|
||
#intro[data-stage="card"] #card,
|
||
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1; }
|
||
/* ...and every screen DEALS ITS BANDS IN (2026-08-31, Oliver: "wäre natürlich auch toll für die
|
||
untermenüs"). The panel used to drop in as one slab; now its header, its content and its
|
||
footer arrive one after the other on the same overshoot the splash and the canvas modals use.
|
||
The children of the stage, not the stage itself — a slab that drops says "a screen appeared",
|
||
a deal says "the machine is laying this out for you". The splash keeps its own finer stagger
|
||
below (its two bands would otherwise fly as two lumps), hence the :not. */
|
||
#intro.deal > .card > div:not(#splash) > * {
|
||
animation: dealIn 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
|
||
#intro.deal > .card > div:not(#splash) > *:nth-child(1) { animation-delay: 0.02s; }
|
||
#intro.deal > .card > div:not(#splash) > *:nth-child(2) { animation-delay: 0.09s; }
|
||
#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; }
|
||
#intro.deal #stmode > #modeList { animation: none; }
|
||
#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 > 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; }
|
||
/* THREE REGISTERS NOW (2026-08-31). Bangers still ANNOUNCES — the primary button, PROMOTED.,
|
||
NEW PERSONAL BEST, and everything the canvas shouts (SET, CHAPTER, EPIC!). Silkscreen is
|
||
still the HUD: numbers, labels, anything you read at leisure. And the SCREEN TITLES moved to
|
||
Jersey 15: upright signage, which is what the strip at the top of a screen actually is — you
|
||
are not being shouted at, you are being told which room you are in. */
|
||
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
|
||
/* #intro h2 above carries an id, so this needs one too or the announcement face wins */
|
||
#intro .hdr h2 { font: 400 33px/1 'Jersey 15', Bangers, cursive; letter-spacing: 1px; }
|
||
/* A RUBRIC IS ONE WORD (2026-08-31, the text reduction), in the face that names rooms — and any
|
||
hint it carries is a chip on the right, not a second sentence. */
|
||
#intro h3 span { font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||
#intro h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 20px 0 9px;
|
||
font: 400 17px/1.2 'Jersey 15', Silkscreen, monospace; color: var(--acid);
|
||
letter-spacing: 2px; text-align: left; }
|
||
/* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */
|
||
.hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line);
|
||
background: var(--solid);
|
||
padding-top: max(10px, env(safe-area-inset-top)); }
|
||
.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: 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 */
|
||
.figs { display: flex; border: 2px solid var(--line); background: var(--panel); }
|
||
/* four numbers read better two-up than four-across on a phone — at four the labels wrap and
|
||
the row becomes a wall of two-line captions */
|
||
.figs.pair { display: grid; grid-template-columns: 1fr 1fr; margin-top: 14px; }
|
||
.figs.pair .fig:nth-child(2n) { border-left: 2px solid var(--line); }
|
||
.figs.pair .fig:nth-child(n+3) { border-top: 2px solid var(--line); }
|
||
.fig { flex: 1; min-width: 0; padding: 8px 8px 9px; text-align: center; }
|
||
/* ...only in a ROW. In the two-up grid the same rule gave cell 3 a left border hard against the
|
||
container's own — the doubled line Oliver spotted; the grid draws its own seams below. */
|
||
.figs:not(.pair) .fig + .fig { border-left: 2px solid var(--line); }
|
||
/* THE NUMBER NEVER MOVES (2026-08-31, Oliver: "manchmal sind die überschriften ein- manchmal
|
||
zweizeilig, das drückt die zahl runter"). The label gets two lines of room whether it needs
|
||
them or not, so every figure in a row sits on the same baseline. */
|
||
.fig b { display: flex; align-items: flex-start; justify-content: center; min-height: 22px;
|
||
font: 400 9px/1.25 Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||
.fig i { display: block; font: 700 18px Silkscreen, monospace; color: var(--acid);
|
||
font-style: normal; letter-spacing: 1px; }
|
||
.fig.bad i { color: var(--bad); }
|
||
/* one button look for every menu screen */
|
||
/* OPAQUE, not the translucent panel fill. On the list screens it makes no visible difference —
|
||
they sit on flat ink — but on the splash a see-through button lets the court's white lines run
|
||
straight through the lettering, and the button stops reading as a button. */
|
||
.btn { display: block; width: 100%; margin: 0 auto 10px; padding: 12px 10px;
|
||
background: var(--solid); border: 2px solid var(--line); color: var(--cream);
|
||
font: 400 13px Silkscreen, monospace; letter-spacing: 1px; cursor: pointer;
|
||
-webkit-tap-highlight-color: transparent; }
|
||
/* The generated button plates are gone with the tutorial. The primary is a flat crimson block
|
||
now: it survives being resized, which a fixed-size plate crop never did, and the mockup's
|
||
hierarchy is carried by colour rather than by art. */
|
||
.btn.big { padding: 13px 10px 11px; background: var(--red); border-color: #69201c; color: #f6e7dc;
|
||
font: 400 26px Bangers, cursive; letter-spacing: 2px; line-height: 1.15; }
|
||
/* THE FIXTURE BUTTON (THE SEASON): the shout stays the shout, and the fixture itself is the
|
||
quiet line under it — the same two-register rule the cards follow (Bangers announces, Silkscreen
|
||
informs). `b` is not bold here, it IS the announcement face. */
|
||
.btn.big b { display: block; font: inherit; }
|
||
.btn.big i { display: block; margin-top: 3px; font: 400 9px Silkscreen, monospace;
|
||
font-style: normal; letter-spacing: 0.5px; color: #f0cfc4; }
|
||
.two { display: flex; gap: 8px; }
|
||
.two .btn { margin-bottom: 0; }
|
||
/* 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; }
|
||
#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. */
|
||
#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. */
|
||
#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 */
|
||
#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; }
|
||
#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; }
|
||
#pDice { width: 52px; flex: none; margin: 0; padding: 0; font-size: 20px; line-height: 1; }
|
||
/* a LOCKED court: the tile stays visible — it is a goal — but it answers no tap and sits back */
|
||
.court.lock { opacity: 0.45; filter: saturate(0.4); cursor: default; }
|
||
.court.lock i { color: var(--bad); }
|
||
/* THE PROMOTION CEREMONY: one centred column, the walk-on moment of the meta game. The court
|
||
tile is the reward made visible; the staggered drops are cheap CSS, no rAF (menu parks it). */
|
||
#promo .mid { gap: 10px; }
|
||
#prTag { margin: 0; font: 700 10px Silkscreen, monospace; color: var(--dim); letter-spacing: 3px;
|
||
animation: drop 0.5s ease-out; }
|
||
#prTitle { font: 400 44px Bangers, cursive; color: var(--acid); letter-spacing: 2px; margin: 0;
|
||
animation: drop 0.5s ease-out 0.15s backwards; }
|
||
#prLeague { margin: 0; font: 700 12px Silkscreen, monospace; color: var(--cream);
|
||
letter-spacing: 2px; animation: drop 0.5s ease-out 0.3s backwards; }
|
||
#prCourt { width: 74%; max-width: 300px; margin: 10px auto 0; align-self: center;
|
||
border: 2px solid var(--line);
|
||
image-rendering: pixelated; animation: drop 0.5s ease-out 0.5s backwards; }
|
||
#prCourtName { margin: 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
|
||
letter-spacing: 1.5px; animation: drop 0.5s ease-out 0.62s backwards; }
|
||
/* THE CHARACTER CARD (the leagues, phase 3): the player writ large — sprite, crest, level,
|
||
honours, ledger. The honours row is a collection with visible empty slots: the unearned
|
||
badges sit dark, which is the whole incentive. */
|
||
#cdTop { display: flex; gap: 14px; align-items: flex-start; margin-top: 14px; }
|
||
#cdLook { position: static; display: block; flex: none; margin: 0; width: auto; height: 200px;
|
||
touch-action: auto; image-rendering: pixelated; }
|
||
#cdMeta { flex: 1; min-width: 0; display: grid; gap: 8px 11px; align-items: center;
|
||
grid-template-columns: auto 1fr;
|
||
grid-template-areas: "crest league" "crest level" "bar bar" "xp xp" "career career"; }
|
||
/* THE CREST STANDS BESIDE THE LEAGUE (2026-08-31, Oliver: on its own row it pushed the text
|
||
down), so the two things that say WHERE YOU ARE share a line instead of stacking. */
|
||
#cdBadge { grid-area: crest; width: 56px; image-rendering: pixelated; align-self: center;
|
||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
|
||
#cdLeague { grid-area: league; align-self: end; font: 700 10px/1.5 Silkscreen, monospace;
|
||
text-decoration: none; letter-spacing: 1.5px; }
|
||
#cdLeague i { display: block; font-style: normal; font-size: 9px; opacity: 0.85; }
|
||
#cdBar { grid-area: bar; }
|
||
#cdXp { grid-area: xp; }
|
||
#cdCareer { grid-area: career; }
|
||
#cdLevel { grid-area: level; align-self: start; font: 400 30px Bangers, cursive;
|
||
color: var(--cream); letter-spacing: 1.5px; }
|
||
#cdXp { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: var(--dim);
|
||
text-decoration: none; letter-spacing: 1px; }
|
||
/* THE HONOURS ROW: a shield per league, and under an earned one the INSCRIPTION — the level it
|
||
was taken at (the cup board's tie-break, in the one place a once-in-a-career figure belongs). */
|
||
#cdBadges { display: flex; gap: 10px; align-items: flex-end; }
|
||
#cdBadges .badge { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||
align-items: center; gap: 4px; }
|
||
/* ...and it CATCHES THE LIGHT: three points on the shield, dark for most of a long cycle and
|
||
flaring for a fraction of it (main.glints). Not a fire — a glint, the way metal behaves. */
|
||
#cdBadges .badge em { position: absolute; width: 4px; height: 4px; border-radius: 50%;
|
||
background: #fff6dc; box-shadow: 0 0 7px 2px rgba(255,231,170,0.9);
|
||
opacity: 0; pointer-events: none; animation-name: glint;
|
||
animation-timing-function: ease-out; animation-iteration-count: infinite; }
|
||
@keyframes glint {
|
||
0%, 12%, 100% { opacity: 0; transform: scale(0.3); }
|
||
4% { opacity: 1; transform: scale(1); }
|
||
8% { opacity: 0.35; transform: scale(0.6); } }
|
||
@media (prefers-reduced-motion: reduce) { #cdBadges .badge em { display: none; } }
|
||
#cdBadges img { height: 46px; image-rendering: pixelated; }
|
||
#cdBadges .badge u { font: 700 7px Silkscreen, monospace; color: var(--acid); letter-spacing: 0.5px;
|
||
text-decoration: none; white-space: nowrap; }
|
||
/* AN UNEARNED SLOT IS DEAD (2026-08-31): the grey sits on the CELL, not the image, so nothing
|
||
can animate over it — the breathing halo used to, because `filter` in a keyframe outranks a
|
||
static one, and a stale `img:not(.dark)` selector (from when the class hung on the image) was
|
||
handing that halo to every shield on the row, lit or not. That is why the dark slots glowed
|
||
in colour instead of standing grey and still. */
|
||
#cdBadges .badge.dark { filter: grayscale(1) brightness(0.38); }
|
||
#cdBadges .badge.dark u { color: #3f4d45; }
|
||
/* ...and the halo itself is GONE from this row (Oliver): the glints do that job now, and a
|
||
permanent breathing glow under an occasional flash was two effects saying the same thing. */
|
||
.rkbadge { height: 15px; margin-right: 6px; image-rendering: pixelated; flex: none; }
|
||
/* THE LEAGUE PANEL: the setup's league-mode face. The badge breathes like the card's, the
|
||
name is the announcement face in the league's own colour, and the band's roster stands in a
|
||
row — felled bodies in colour with their all-time count, the rest grey and waiting. */
|
||
#lgPanel { display: none; }
|
||
#lgHead { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||
#lgBadge { height: 56px; image-rendering: pixelated;
|
||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
|
||
#lgHead b { display: block; font: 400 24px Bangers, cursive; letter-spacing: 1.5px; }
|
||
#lgHead i { display: block; margin-top: 3px; font: 400 9px Silkscreen, monospace;
|
||
color: var(--dim); font-style: normal; letter-spacing: 1px; }
|
||
#lgFoes { display: flex; gap: 7px; }
|
||
.lgFoe { flex: 1; min-width: 0; padding: 7px 2px 6px; background: var(--panel);
|
||
border: 2px solid var(--line); text-align: center; }
|
||
.lgFoe img { height: 42px; image-rendering: pixelated; }
|
||
.lgFoe b { display: block; margin-top: 4px; font: 700 8px Silkscreen, monospace;
|
||
color: var(--cream); letter-spacing: 0.5px; white-space: nowrap; overflow: hidden; }
|
||
.lgFoe i { display: block; margin-top: 2px; font: 400 8px Silkscreen, monospace;
|
||
color: var(--acid); font-style: normal; }
|
||
.lgFoe.un { opacity: 0.55; }
|
||
.lgFoe.un img { filter: grayscale(1) brightness(0.6); }
|
||
.lgFoe.un i { color: var(--dim); }
|
||
/* the band's top rung is the final — it wears the door's own red */
|
||
.lgFoe.fin { border-color: var(--red); }
|
||
/* league modes: the venue is the league's own and not a choice — one row, no tap */
|
||
#courtList.fixed .court { cursor: default; }
|
||
/* the WAY-UP RIBBON, horizontal (Oliver's call after two diagonal cuts — a rotated pixel font
|
||
was the one anti-aliased thing in the menu): a red band running in from OVER the left border,
|
||
dissolving rightward through a real PIXEL FADE — an inline-SVG mask of 6px cells thresholded
|
||
against a Bayer matrix (four density steps), the dither the game's own art would use, never a
|
||
smooth gradient. The BAND lives on ::before and carries the mask; the HOST carries the text
|
||
and a drop-shadow FILTER — a filter on the parent shadows the already-masked child, so the
|
||
shadow survives the mask and follows the dither blocks (a box-shadow on the masked element
|
||
itself was eaten with it). */
|
||
.way { position: absolute; top: 12px; left: -8px; z-index: 2; white-space: nowrap;
|
||
padding: 8px 64px 8px 16px; font: 700 13px Silkscreen, monospace; font-style: normal;
|
||
color: #fff;
|
||
text-shadow: 1px 1px 0 rgba(0,0,0,0.55);
|
||
filter: drop-shadow(0 2px 3px rgba(0,0,0,0.5));
|
||
letter-spacing: 1px; text-decoration: none; }
|
||
.way::before { content: ''; position: absolute; inset: 0; z-index: -1; background: var(--red);
|
||
border-top: 1px solid rgba(255,255,255,0.35); border-bottom: 1px solid #69201c;
|
||
-webkit-mask-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%20200%2024%22%20shape-rendering=%22crispEdges%22%3E%3Cg%20fill=%22%23000%22%3E%3Crect%20x=%220%22%20y=%220%22%20width=%22140%22%20height=%2224%22/%3E%3Crect%20x=%22140%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3C/g%3E%3C/svg%3E");
|
||
-webkit-mask-size: 100% 100%; -webkit-mask-repeat: no-repeat;
|
||
mask-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%20200%2024%22%20shape-rendering=%22crispEdges%22%3E%3Cg%20fill=%22%23000%22%3E%3Crect%20x=%220%22%20y=%220%22%20width=%22140%22%20height=%2224%22/%3E%3Crect%20x=%22140%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22140%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22146%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%226%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22152%22%20y=%2218%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22158%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%220%22%20width=%226%22%20height=%226%22/%3E%3Crect%20x=%22170%22%20y=%2212%22%20width=%226%22%20height=%226%22/%3E%3C/g%3E%3C/svg%3E");
|
||
mask-size: 100% 100%; mask-repeat: no-repeat; }
|
||
/* THE PLAYER PLAQUE: a LETTERHEAD, not a centred stack (Oliver: the stack sat cramped) — the
|
||
crest on the left, name over the big LEVEL · LEAGUE line beside it, the chevron on the
|
||
right; the bar runs the full width below and the goal line closes left-aligned. Translucent
|
||
surface, generous rows — the splash art, blood pool included, stays visible around it. */
|
||
/* ONE ROW (2026-08-31, the text reduction): crest, then name and level on one baseline, the bar
|
||
under them with the level it fills toward at its right end. The GOAL LINE is gone — the
|
||
fixture button right below says what to do, and saying it twice was the whole complaint. */
|
||
#plaque { position: relative; display: flex; align-items: center; gap: 13px; text-align: left;
|
||
margin: 24px auto 0; width: min(330px, 92%); box-sizing: border-box;
|
||
padding: 13px 36px 13px 15px; background: rgba(8, 14, 10, 0.48); border: 0;
|
||
border-radius: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||
#plBadge { flex: none; height: 50px; image-rendering: pixelated;
|
||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); }
|
||
#plCol { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||
#plTop { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||
#plName { font: 400 21px 'Jersey 15', Bangers, cursive; color: var(--cream); letter-spacing: 0.5px;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
/* the CALL belongs to the button below, not to the plaque — the level is a number now */
|
||
#plLv { flex: none; font: 700 10px Silkscreen, monospace; color: var(--acid); letter-spacing: 1px;
|
||
text-decoration: none; }
|
||
/* the bar wears the level it fills TOWARD at its right end — "▓▓░░ 13" says which way it runs
|
||
without a word of explanation (Oliver read a bare bar as total progress) */
|
||
#plRow { display: flex; align-items: center; gap: 7px; }
|
||
#plRow u, #cdBar u { font: 700 9px Silkscreen, monospace; color: var(--cream);
|
||
text-decoration: none; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); }
|
||
#plaque em { position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
||
font: 700 15px Silkscreen, monospace; font-style: normal; color: var(--acid); }
|
||
#plRow s, #cdBar s, #cdCareer s { flex: 1; height: 6px; background: rgba(255,255,255,0.10);
|
||
border: 1px solid var(--line); text-decoration: none; display: block; }
|
||
#cdBar s { height: 8px; }
|
||
#cdBar { display: flex; align-items: center; gap: 8px; }
|
||
#cdCareer { display: flex; align-items: center; gap: 8px; }
|
||
#cdCareer u { font: 700 8px Silkscreen, monospace; color: var(--dim); text-decoration: none;
|
||
letter-spacing: 1px; }
|
||
#plRow s i, #cdBar s i, #cdCareer s i { display: block; height: 100%; width: 0%;
|
||
background: linear-gradient(90deg, #3f9560, var(--acid) 55%, #e8b23a);
|
||
transition: width 0.4s ease; }
|
||
#cdBar s i, #cdCareer s i { transition: width 0.7s cubic-bezier(0.2, 0.7, 0.3, 1); }
|
||
/* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is
|
||
the whole UI — the pick has no meters and no art to show, and a labelled tile per colour would
|
||
be five times the screen for a choice you make by looking at the colour. */
|
||
.swlab { display: flex; justify-content: space-between; margin-top: 2px; text-decoration: none;
|
||
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1.5px; }
|
||
.swlab i { font-style: normal; color: var(--cream); }
|
||
.swrow { display: flex; gap: 6px; }
|
||
.swrow button { flex: 1; aspect-ratio: 1; min-width: 0; padding: 0; border: 2px solid var(--line);
|
||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||
.swrow button.on { border-color: var(--acid); box-shadow: inset 0 0 0 2px var(--ink); }
|
||
/* THE BOSS PICKER (back room). Two columns of portraits, because you pick a boss by looking at
|
||
him — the tile is mostly art. Same plate as the tour tiles above; the grid is what makes ten
|
||
of them fit on a phone. */
|
||
#bossList, #trainList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
|
||
#bossList button, #trainList button { display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||
padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line);
|
||
color: inherit; font: inherit; cursor: pointer;
|
||
-webkit-tap-highlight-color: transparent; }
|
||
#bossList img { height: 78px; width: auto; display: block; image-rendering: pixelated; }
|
||
#bossList b, #trainList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
|
||
text-align: center; line-height: 1.35; }
|
||
#bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
|
||
letter-spacing: 1px; }
|
||
/* THE REVANCHE ROW (THE SEASON, widened in the text reduction): the one that put you out of the
|
||
cup, across the top of the grid — one body out of eighteen with an actual reason, so it gets
|
||
the width to say so in three words. */
|
||
#trainList button.put { grid-column: 1 / -1; flex-direction: row; align-items: center; gap: 12px;
|
||
padding: 10px 12px; text-align: left; position: relative;
|
||
border-color: var(--red); background: rgba(164,35,31,0.16); }
|
||
#trainList button.put img { height: 52px; }
|
||
#trainList button.put > span { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px;
|
||
text-align: left; }
|
||
#trainList button.put b { font: 400 24px/1.05 'Jersey 15', Silkscreen, monospace; text-align: left; }
|
||
#trainList button.put i { color: #e08a7c; letter-spacing: 1px; }
|
||
#trainList button.put em { position: absolute; right: 12px; font: 700 15px Silkscreen, monospace;
|
||
font-style: normal; color: var(--bad); }
|
||
/* the rung's NAME (Jersey) over its number — "No. 7" was three characters of nothing */
|
||
#trainList b { font: 400 17px/1 'Jersey 15', Silkscreen, monospace; letter-spacing: 0.5px; }
|
||
/* ...and the list ends on the league that opens the rest of the ladder, drawn as the goal it is */
|
||
#trainList .goal { grid-column: 1 / -1; padding: 14px 6px; border: 2px dashed var(--line);
|
||
text-align: center; }
|
||
#trainList .goal b { display: block; color: #4a5a52; }
|
||
#trainList .goal i { font: 400 9px Silkscreen, monospace; font-style: normal; color: #4a5a52;
|
||
letter-spacing: 1px; }
|
||
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
|
||
draw and the walk-on wear), which reads at a third of the boss portrait's height */
|
||
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
|
||
/* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */
|
||
.btn[disabled] { opacity: 0.32; cursor: default; }
|
||
/* the fixture line under the new court: the same two registers the plaque uses — the name in
|
||
the league's own acid, what it costs you in the quiet face */
|
||
#prNext { margin: 12px 0 0; font: 400 10px Silkscreen, monospace; color: var(--acid);
|
||
letter-spacing: 1px; line-height: 1.6; }
|
||
#prNext u { display: block; margin-top: 3px; font-size: 9px; color: var(--dim);
|
||
text-decoration: none; letter-spacing: 0.5px; }
|
||
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
|
||
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
|
||
/* 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; }
|
||
: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; }
|
||
: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 */
|
||
: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; }
|
||
: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;
|
||
font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||
.meter u { flex: none; width: 46px; text-decoration: none; }
|
||
.meter s { flex: 1; height: 5px; background: rgba(0,0,0,0.45); text-decoration: none; display: block; }
|
||
.meter s > i { display: block; height: 100%; background: var(--acid); }
|
||
.meter.b s > i { background: #4e8fa6; }
|
||
/* the commit strip. #intro is the scrollport, so sticky resolves against it and the button stays
|
||
reachable however long the list above grows. */
|
||
/* margin-top:auto pins it to the BOTTOM of a short screen; sticky keeps it there on a long one */
|
||
.foot { position: sticky; bottom: 0; z-index: 2; margin: auto -14px 0; padding: 10px 14px;
|
||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||
border-top: 2px solid var(--line); background: var(--solid); }
|
||
.foot .btn { margin-bottom: 0; }
|
||
#recLamp { position: fixed; right: 9px; top: max(52px, calc(env(safe-area-inset-top) + 44px));
|
||
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 { 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; }
|
||
/* 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; }
|
||
.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; }
|
||
/* THE DEAL-IN (2026-08-31, Oliver: "es wäre toll, wenn sich die buttons nach dem laden aufbauen
|
||
würden — arcade style"). The canvas modals have always arrived on an easeOutBack overshoot
|
||
rather than decelerating politely into place; this is that same snap, in CSS, for the one
|
||
moment the menu has an entrance to make. It plays on EVERY arrival at the title screen, not
|
||
just the boot (Oliver) — main.dealSplash re-arms the class, because an animation whose class
|
||
never left cannot restart itself. */
|
||
@keyframes dealIn {
|
||
from { opacity: 0; transform: translateX(-56px); }
|
||
60% { opacity: 1; }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
#intro.deal #splash #plaque,
|
||
#intro.deal #splash .foot.bare > * {
|
||
animation: dealIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; }
|
||
#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; }
|
||
#intro.deal #splash #build { animation-delay: 0.33s; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
#intro.deal #splash #plaque, #intro.deal #splash .foot.bare > * { animation: none; } }
|
||
/* the boot bar wears the stats screen's kill bar, doing duty as a loading bar. While #splash is
|
||
.busy it stands in the buttons' place — same band, same height, so nothing jumps when it goes. */
|
||
#boot { display: none; margin-bottom: 10px; }
|
||
#boot s { display: block; height: 6px; background: rgba(0,0,0,0.45); text-decoration: none; }
|
||
#boot s > i { display: block; width: 0; height: 100%; background: var(--acid);
|
||
transition: width 0.15s linear; }
|
||
#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 .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
|
||
title and the court stay — they are the background and they are never undefined. */
|
||
#splash.busy #plaque { visibility: hidden; } /* hidden, but it KEEPS ITS SPACE: display:none
|
||
let the title slide down the screen and jump back up the moment the profile landed */
|
||
#brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
|
||
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
|
||
.tabs button { flex: 1; padding: 11px 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; }
|
||
.tabs button.on { border-color: var(--acid); color: var(--acid); }
|
||
/* one opponent. The sprite is the walk frame the game already draws, sat on its own baseline. */
|
||
.zrow { display: flex; align-items: center; gap: 11px; padding: 9px 10px; margin-bottom: 6px;
|
||
border: 2px solid var(--line); background: var(--panel); text-align: left; }
|
||
.zrow.boss { border-color: #6e2a24; background: rgba(110,42,36,0.22); }
|
||
.zrow img { width: 48px; height: 62px; object-fit: contain; object-position: bottom;
|
||
image-rendering: pixelated; flex: none; }
|
||
.zb { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr auto; gap: 0 8px;
|
||
line-height: 1.4; }
|
||
.zb b { font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.zb > span { font: 700 15px Silkscreen, monospace; color: var(--acid); text-align: right; }
|
||
.zrow.boss .zb > span { color: var(--bad); }
|
||
.pb { grid-column: 1 / -1; height: 6px; margin-top: 4px; background: rgba(0,0,0,0.45); }
|
||
.pb s { display: block; height: 100%; background: var(--acid); text-decoration: none; }
|
||
.zrow.boss .pb s { background: var(--bad); }
|
||
.zb i { grid-column: 1 / -1; margin-top: 4px; font: 400 9px Silkscreen, monospace;
|
||
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;
|
||
image-rendering: pixelated; }
|
||
.wtile b { display: block; font: 400 11px Silkscreen, monospace; color: var(--acid); }
|
||
.wtile i { display: block; margin-top: 3px; font: 400 7px/1.3 Silkscreen, monospace;
|
||
font-style: normal; color: var(--dim); letter-spacing: 0.5px; }
|
||
.none { margin: 4px 0 0; font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px;
|
||
text-align: left; }
|
||
#stFoot { display: flex; justify-content: space-between; align-items: baseline;
|
||
border-top: 2px solid var(--line); }
|
||
#stFoot b { font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
|
||
#stFoot i { font: 700 16px Silkscreen, monospace; color: var(--acid); font-style: normal; }
|
||
.rkrow { display: flex; align-items: center; gap: 10px; padding: 8px 10px; margin-bottom: 5px;
|
||
border: 2px solid var(--line); background: var(--panel); text-align: left; }
|
||
.rkrow.me { border-color: var(--acid); background: rgba(188,212,85,0.11); }
|
||
.rkrow b { flex: none; width: 68px; font: 700 12px Silkscreen, monospace; color: var(--dim); }
|
||
.rkrow.me b { color: var(--acid); }
|
||
.rkrow span { flex: 1; min-width: 0; font: 400 11px Silkscreen, monospace; color: var(--cream);
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.rkrow i { font: 400 11px Silkscreen, monospace; font-style: normal; color: var(--dim); }
|
||
/* ---- typed fields. Two screens ask for typing — the name prompt and the netplay lobby — and
|
||
both want the same field: big, centred, acid on the panel, no rounded corners anywhere near it.
|
||
One class, two size overrides. */
|
||
.cent { text-align: center; }
|
||
.tin { display: block; width: 100%; box-sizing: border-box; margin: 0 auto;
|
||
padding: 12px 10px; background: var(--solid); border: 2px solid var(--line);
|
||
border-radius: 0; color: var(--acid); font: 700 16px Silkscreen, monospace;
|
||
letter-spacing: 3px; text-align: center; text-transform: uppercase;
|
||
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
|
||
.tin:focus { outline: none; border-color: var(--acid); }
|
||
.tin::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 12px; }
|
||
#newbest h2 { font-size: 30px; margin-bottom: 6px; }
|
||
#nbScore { margin: 0 0 22px; font: 400 11px Silkscreen, monospace; color: var(--dim);
|
||
letter-spacing: 1px; }
|
||
#nbScore b { color: var(--acid); font-weight: 400; }
|
||
#nbName { margin-bottom: 10px; padding: 14px 10px; font-size: 20px; }
|
||
#nbName::placeholder { font-size: 14px; }
|
||
/* ---- THE NETPLAY LOBBY. Everything the mode needs before there is a court: who you are, the
|
||
two ways to host, and the friend's code. The room you are waiting in replaces the picker in
|
||
place (#vsWait), so the code and the SHARE button stand where the buttons were. */
|
||
#vsErr:empty { display: none; }
|
||
#vsErr { margin: 10px 0 0; padding: 8px; border: 2px solid #6e2a24; background: rgba(110,42,36,0.22);
|
||
font: 400 10px Silkscreen, monospace; color: var(--bad); letter-spacing: 1px;
|
||
text-align: center; }
|
||
#vs .btn { margin-bottom: 8px; }
|
||
/* the room takes the whole screen the picker had: the code floats in the middle band and CANCEL
|
||
sits under your thumb, same three-band anchoring as every other screen */
|
||
#vsWait { display: flex; flex-direction: column; flex: 1; }
|
||
#vsWait[hidden] { display: none; }
|
||
.joinrow { display: flex; gap: 8px; align-items: stretch; }
|
||
.joinrow .tin { flex: 1; letter-spacing: 8px; }
|
||
/* JOIN is the act, not a label beside a box: the crimson plate every primary button in the game
|
||
wears, and NO padding of its own — the row stretches it to exactly the field's height, which
|
||
its own 13px padding could only ever approximate. */
|
||
/* #vs .btn's margin-bottom outranks a bare .joinrow .btn — and a margin on a stretched flex item
|
||
is exactly the 8px the button was coming up short by */
|
||
#vs .joinrow .btn { flex: none; width: 104px; margin: 0; padding: 0 6px; background: var(--red);
|
||
border-color: #69201c; color: #f6e7dc; font: 400 22px Bangers, cursive;
|
||
letter-spacing: 2px; }
|
||
/* the code is the biggest thing on the screen — it is read aloud across a room as often as it is
|
||
shared, and a four-letter code you have to squint at is the whole friction being removed */
|
||
#vsBig { margin: 4px 0 20px; font: 700 42px Silkscreen, monospace; color: var(--acid);
|
||
letter-spacing: 10px; text-indent: 10px; }
|
||
#vsMsg { margin-top: 12px; }
|
||
/* the h3 rows are label-left/hint-right everywhere else; this one has no hint and sits over a
|
||
centred code, so it centres too (text-align cannot reach it — the row is a flex box) */
|
||
#vsWHdr { justify-content: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<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" aria-label="Pause match">X</button></div>
|
||
<div id="qAsk" hidden>
|
||
<div class="qBox">
|
||
<p>LEAVE THE MATCH?</p>
|
||
<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">
|
||
<div class="card">
|
||
<!-- The title screen IS the menu. A separate menu stage behind it was the same logo over the
|
||
same backdrop with two buttons, so it read as the title screen failing to advance. -->
|
||
<!-- three bands: the figures at the top, the title floating in the middle, the buttons under
|
||
your thumb. Same anchoring as every other screen. -->
|
||
<!-- BUSY FROM THE FIRST PAINT (2026-08-31, Oliver: "zeigt sich während des ladens noch immer
|
||
die box mit LEVEL 1 und springt dann erst auf die tatsächlichen werte"). This module is
|
||
deferred, so the browser paints this markup BEFORE main.js can add the class — the
|
||
placeholder values in the tags below were on screen for the whole of that gap. The class
|
||
ships in the HTML now; main.js only ever takes it OFF (see the boot gate there). -->
|
||
<div id="splash" class="busy">
|
||
<div id="hot"></div>
|
||
<div class="mid">
|
||
<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). -->
|
||
<button id="plaque">
|
||
<img id="plBadge" alt="">
|
||
<span id="plCol">
|
||
<span id="plTop"><b id="plName"></b><u id="plLv">LEVEL 1</u></span>
|
||
<span id="plRow"><s><i id="plFill"></i></s><u id="plTo">2</u></span>
|
||
</span>
|
||
<em>></em>
|
||
</button>
|
||
</div>
|
||
<div class="foot bare">
|
||
<!-- THE BOOT BAR. Every sprite is requested the moment render.js is imported; this only
|
||
shows how far that got, and stands where the buttons will be until it is done — a menu
|
||
you can press before the sheets have landed is how you reach the stats screen with half
|
||
the zombies missing. Swapped by #splash.busy, see main.js. -->
|
||
<div id="boot"><s><i></i></s><u>LOADING</u></div>
|
||
<!-- THE FRONT DOOR IS THE FIXTURE (2026-08-31, THE SEASON). The primary button used to say
|
||
PLAY MATCH and open a wall of posters, which asked the player a question the game
|
||
already knows the answer to. It names the next cup fixture now and enters the draw;
|
||
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. 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="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>
|
||
<!-- Additional solo modes; multiplayer has a separate splash entrance. -->
|
||
<div id="stmode">
|
||
<div class="hdr"><button class="chev" aria-label="Back" id="mBack"><</button><h2>OTHER GAMES</h2></div>
|
||
<div id="modeList"></div>
|
||
</div>
|
||
<div id="setup">
|
||
<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>
|
||
<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>
|
||
<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
|
||
writes the SAME stores the setup screen reads (stats' name key, the picker localStorage
|
||
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" 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">
|
||
<button class="btn" id="pDice" aria-label="roll a zombie name">🎲</button>
|
||
</div>
|
||
<h3>THE BODY</h3>
|
||
<div id="who2">
|
||
<canvas id="pLook" width="128" height="224"></canvas>
|
||
<div id="pCtl">
|
||
<div id="pTours"></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" id="pNote">THE CLUB WILL LEARN YOUR NAME.</p>
|
||
<button class="btn big" id="pGo">ENTER THE CLUB</button>
|
||
</div>
|
||
</div>
|
||
<!-- THE PROMOTION (the leagues): the ceremony between the cup summary and the title screen.
|
||
Built by main.buildPromo from the profile's fresh league; the court tile IS the reward. -->
|
||
<div id="promo">
|
||
<div class="mid cent">
|
||
<p id="prTag">THE CUP IS YOURS</p>
|
||
<h2 id="prTitle">PROMOTED.</h2>
|
||
<p id="prLeague"></p>
|
||
<img id="prCourt" alt="">
|
||
<p id="prCourtName"></p>
|
||
<!-- THE SEASON'S SECOND BEAT (2026-08-31): the promotion is also the season opener, so it
|
||
names the fixture waiting in the new league rather than ending on a court tile. No
|
||
extra screen — a tap between the player and the court is what we were avoiding. -->
|
||
<p id="prNext"></p>
|
||
</div>
|
||
<div class="foot bare">
|
||
<button class="btn big" id="prGo">WALK ON</button>
|
||
</div>
|
||
</div>
|
||
<!-- THE CHARACTER CARD (the leagues): tap the splash's level strip to open. Built by
|
||
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" 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">
|
||
<img id="cdBadge" alt="">
|
||
<u id="cdLeague"></u>
|
||
<b id="cdLevel">LEVEL 1</b>
|
||
<div id="cdBar"><u id="cdFrom">1</u><s><i id="cdFill"></i></s><u id="cdTo">2</u></div>
|
||
<u id="cdXp"></u>
|
||
<div id="cdCareer"><u>CAREER</u><s><i id="cdCareerFill"></i></s></div>
|
||
</div>
|
||
</div>
|
||
<h3>HONOURS<span>WIN THE CUP TO CLIMB</span></h3>
|
||
<div id="cdBadges"></div>
|
||
<h3>CAREER</h3>
|
||
<div class="figs" id="cdFigs1"></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>
|
||
</div>
|
||
<!-- 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" 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),
|
||
the host's one decision, and the friend's code. #vsWait replaces the picker in place once
|
||
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" 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"
|
||
autocapitalize="characters" spellcheck="false">
|
||
<h3>HOST</h3>
|
||
<button class="btn" id="vsHostC">CLASSIC</button>
|
||
<button class="btn" id="vsHostD">DEATH MATCH</button>
|
||
<h3>JOIN</h3>
|
||
<div class="joinrow">
|
||
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
|
||
autocapitalize="characters" spellcheck="false">
|
||
<button class="btn" id="vsJoin">GO</button>
|
||
</div>
|
||
<p id="vsErr"></p>
|
||
</div>
|
||
<div id="vsWait" hidden>
|
||
<div class="mid cent">
|
||
<h3 id="vsWHdr" class="cent">ROOM CODE</h3>
|
||
<p id="vsBig">····</p>
|
||
<button class="btn big" id="vsShare">SEND THE CODE</button>
|
||
<p class="none cent" id="vsMsg">WAITING FOR THEM TO JOIN</p>
|
||
</div>
|
||
<div class="foot bare"><button class="btn" id="vsCancel">CANCEL</button></div>
|
||
</div>
|
||
</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" 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>
|
||
|
||
<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>
|
||
</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" 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">
|
||
<button class="on" data-m="u">THE CUP</button>
|
||
<button data-m="g">SPLATTER</button>
|
||
<button data-m="z">THE CLIMB</button>
|
||
</div>
|
||
<h3 id="rkHdr">BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
|
||
<div id="rkList"></div>
|
||
</div>
|
||
<!-- THE BACK ROOM. Five taps on the logo (main.js) and nothing else — no button anywhere hints
|
||
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" 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>
|
||
<button class="btn big" id="scDrill">DRILL TEST</button>
|
||
<button class="btn big" id="scMatch">MATCH TEST</button>
|
||
<button class="btn big" id="scBench">SPRITE BENCH</button>
|
||
</div>
|
||
</div>
|
||
<!-- 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" 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>
|
||
<!-- shown only when a run beats your own best kill count — see main.js askName -->
|
||
<div id="newbest">
|
||
<div class="mid">
|
||
<h2 class="cent">NEW PERSONAL BEST</h2>
|
||
<p id="nbScore"></p>
|
||
<input id="nbName" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
|
||
autocapitalize="characters" spellcheck="false">
|
||
<p class="none cent" id="nbMsg">PUT IT ON THE WORLD BOARD.</p>
|
||
</div>
|
||
<div class="foot bare">
|
||
<button class="btn big" id="nbSend">SIGN THE BOARD</button>
|
||
<button class="btn" id="nbSkip">NOT THIS TIME</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span id="recLamp" hidden>REC</span>
|
||
<script type="module" src="src/main.js?v=372"></script>
|
||
</body>
|
||
</html>
|