cut.py --short builds a social-length version from the same takes: one line per witness cut inside their takes, then the challengers beat, "Quiet, please", "Play", the splat and Reg's stinger. Each cut keeps its own build folder and output file, so cut 2, cut 3 and the short all stay. words.py gives local Whisper word timestamps for cutting inside lines. 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=358"></script>
|
||
</body>
|
||
</html>
|