Oliver, on the picker: the gender tiles "themselves have the player but front facing and that does not react to the kit and skin — so that's double somehow and wastes space", the screen "is pretty crowded now", and the women's wash was "a little too subtle on the white — could be more". THE TILES ARE GONE. They were a second drawing of the same man, facing the wrong way and deaf to every colour on the screen, and they cost the height that pushed the court list under the fold. Who-you-are is two words now, and the live player — which does react — stands beside every control that changes him, so a tap and its result are a thumb apart. Four stacked sections became one block; all four courts and SERVE UP fit a phone without scrolling. THE WASH goes 0.30/0.93 -> 0.55/0.86: dyed from across the court, folds intact. Past about 0.70 it stops reading as tennis whites at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
587 lines
41 KiB
HTML
587 lines
41 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>
|
|
if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
|
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'); }
|
|
/* 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. */
|
|
/* below the BLOOD panel's strip (top ~30 logical px on the right in the splatter modes), so the
|
|
door never sits on the pips — one position, one meaning, every mode */
|
|
#quit { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 40px); right: 8px; z-index: 5; }
|
|
#quit button { width: 34px; height: 34px; background: rgba(14,23,20,0.72); border: 2px solid var(--line);
|
|
color: var(--dim); font: 400 15px Silkscreen, monospace; cursor: pointer;
|
|
-webkit-tap-highlight-color: transparent; }
|
|
#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; }
|
|
.qBox p { margin: 0 0 6px; font: 400 12px Silkscreen, monospace; color: var(--cream);
|
|
text-align: center; letter-spacing: 1px; }
|
|
@media (pointer: coarse) { #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; padding: 16px 14px 20px; width: min(370px, 94vw);
|
|
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.png), not stacked CSS text */
|
|
#intro .logo { display: block; width: min(300px, 74vw); height: auto; margin: 0 auto 6px;
|
|
image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
|
|
/* the 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, #stsub, #bracket { display: none; }
|
|
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
|
|
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
|
|
screen whose content does not fill it */
|
|
#intro[data-stage="setup"] #setup,
|
|
#intro[data-stage="mode"] #stmode,
|
|
#intro[data-stage="modesub"] #stsub,
|
|
#intro[data-stage="stats"] #stats,
|
|
#intro[data-stage="ranks"] #ranks,
|
|
#intro[data-stage="secret"] #secret,
|
|
#intro[data-stage="bosses"] #bosses,
|
|
#intro[data-stage="bracket"] #bracket,
|
|
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
|
|
animation: drop 0.3s ease-out; }
|
|
/* 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; }
|
|
/* Screen titles and the primary button are BANGERS — the same announcement face the canvas uses
|
|
for SET and CHAPTER. A screen title is a thing that shouts. Everything that is a NUMBER or a
|
|
label stays Silkscreen, so the two registers keep meaning what they have always meant. */
|
|
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
|
|
#intro h3 { display: flex; justify-content: space-between; margin: 20px 0 9px;
|
|
font: 400 10px Silkscreen, monospace; color: var(--dim); 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; }
|
|
.chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line);
|
|
color: var(--acid); font: 400 20px Bangers, cursive; 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); }
|
|
.fig { flex: 1; min-width: 0; padding: 7px 6px; text-align: center; }
|
|
.fig + .fig { border-left: 2px solid var(--line); }
|
|
.fig b { display: block; font: 400 9px 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; }
|
|
.two { display: flex; gap: 8px; }
|
|
.two .btn { margin-bottom: 0; }
|
|
.btn.sec i { display: block; margin-top: 4px; font: 400 9px Silkscreen, monospace;
|
|
font-style: normal; color: var(--dim); letter-spacing: 0.5px; }
|
|
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
|
|
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
|
|
#who { display: flex; gap: 12px; align-items: flex-start; }
|
|
#whoCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
|
/* The live player. `position: static` is not decoration: the bare `canvas` rule above is the COURT
|
|
— fixed, full-bleed, 100dvh — and it catches any canvas in the document, so this one flew to the
|
|
top-left corner of the screen until it was told otherwise. Every geometry property that rule
|
|
sets has to be undone here. The buffer is the sprite's own 96x224, so width follows height. */
|
|
#look { position: static; display: block; flex: none; margin: 0;
|
|
width: auto; height: 176px; touch-action: auto; image-rendering: pixelated; }
|
|
/* the tour is now TWO WORDS, not two portraits — the live player next to them is the picture, and
|
|
a second drawing of the same man (front-facing, and deaf to every colour on this screen) was
|
|
both a duplicate and the reason the court list started below the fold */
|
|
#tours { display: flex; gap: 7px; }
|
|
#tours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
|
|
color: var(--dim); font: 400 10px Silkscreen, monospace; letter-spacing: 1px;
|
|
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
#tours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
|
|
/* 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 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
|
|
#bossList 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 { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
|
|
text-align: center; line-height: 1.35; }
|
|
#bossList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
|
|
letter-spacing: 1px; }
|
|
/* THE DRAW. Four rungs read top-down as a ladder, because that is what the mode is — the bracket
|
|
shape a real draw is printed in needs width a phone has not got, and half of it would be blank
|
|
anyway (you only ever play one side of it). The rows spin their portrait and their name while
|
|
the draw is being made, and .set is what a landed one wears: the placeholder look is on the
|
|
ROW, so a slot that has stopped is obvious at a glance and START stays out of reach until the
|
|
last of them lands. The final wears .set from the first frame — it was never in doubt. */
|
|
#cupRows { display: flex; flex-direction: column; gap: 8px; }
|
|
.cupRow { display: grid; grid-template-columns: 54px 1fr auto; grid-template-rows: auto auto;
|
|
align-items: center; column-gap: 10px; row-gap: 2px;
|
|
padding: 7px 10px; background: var(--panel); border: 2px solid var(--line);
|
|
/* the connector: every round but the first hangs off the one above it */
|
|
position: relative; }
|
|
.cupRow + .cupRow::before { content: ''; position: absolute; left: 35px; top: -10px; width: 2px;
|
|
height: 10px; background: var(--line); }
|
|
.cupRow img { grid-column: 1; grid-row: 1 / span 2; width: 54px; height: 54px;
|
|
object-fit: contain; object-position: center; image-rendering: pixelated;
|
|
/* mid-spin the faces flicker past unreadable; dimming them says "still rolling"
|
|
without a second element to keep in sync */
|
|
opacity: 0.5; filter: grayscale(0.7); }
|
|
.cupRow u { grid-column: 2; grid-row: 1; font: 400 8px Silkscreen, monospace; color: var(--dim);
|
|
letter-spacing: 1.5px; text-decoration: none; text-align: left; }
|
|
.cupRow b { grid-column: 2; grid-row: 2; font: 400 11px Silkscreen, monospace; color: var(--dim);
|
|
letter-spacing: 0.5px; line-height: 1.35; text-align: left; }
|
|
.cupRow i { grid-column: 3; grid-row: 1 / span 2; font: 400 8px Silkscreen, monospace;
|
|
font-style: normal; color: var(--dim); letter-spacing: 1px; white-space: nowrap; }
|
|
.cupRow.set img { opacity: 1; filter: none; }
|
|
.cupRow.set b { color: var(--cream); }
|
|
.cupRow.fin { border-color: var(--red); }
|
|
.cupRow.fin.set b { color: var(--acid); }
|
|
/* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */
|
|
.btn[disabled] { opacity: 0.32; cursor: default; }
|
|
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
|
|
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
|
|
/* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one
|
|
"a checkbox and a button combined"). Every card is full-bright and equal because every card is
|
|
a live answer to the header's question; tapping answers and advances. The only memory shown is
|
|
the LAST tag — an indicator, not a selection. A border that means "SERVE UP will use this"
|
|
exists solely on the final screen, where a confirm button actually follows. */
|
|
#modeList button.mode, #modeSub button.mode { display: flex; align-items: flex-end; width: 100%; min-height: 96px;
|
|
margin: 0 0 8px; padding: 7px 9px; background-size: cover; background-position: center bottom;
|
|
image-rendering: pixelated; border: 2px solid var(--line); color: inherit; font: inherit;
|
|
text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
position: relative; }
|
|
.last { position: absolute; top: 7px; right: 9px; font: 400 8px Silkscreen, monospace;
|
|
color: var(--acid); background: rgba(0,0,0,0.72); padding: 3px 6px;
|
|
letter-spacing: 1px; border: 1px solid rgba(188,212,85,0.5); }
|
|
#modeList b, #modeSub b { text-shadow: 0 1px 0 #000, 0 0 6px rgba(0,0,0,0.9); }
|
|
/* the subtitle carries the mode's one-line rules over busy art, so it gets WHITE at full
|
|
opacity, a size up from the court rows' 9px, and a solid black underlay — "barely readable"
|
|
at cream-on-crowd was the report from the first live look */
|
|
#modeList i, #modeSub i { color: #fff; font-size: 10px; margin-bottom: 0;
|
|
text-shadow: 0 1px 0 #000, 1px 0 0 #000, -1px 0 0 #000, 0 -1px 0 #000, 0 0 7px #000; }
|
|
/* THE SECOND STEP (config.MENU) has its own SCREEN in the wizard: sub-mode cards are
|
|
full-height posters centred on their scene, and the difficulty chips are plain acid-outline
|
|
buttons stacked tall — art for EASY/MEDIUM/HARD would be forced, and the chip reads faster. */
|
|
/* alone on their own screens, the posters get room to be posters — and the first card clears
|
|
the header by the same breath the h3 rows used to give the old single page */
|
|
#stmode #modeList, #stsub #modeSub { margin-top: 12px; }
|
|
#stmode button.mode, #modeSub button.mode { min-height: 148px; }
|
|
#modeSub { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
#modeSub button.mode { background-position: center; }
|
|
#modeSub button.chip { flex: 1 1 100%; padding: 22px 0; background: var(--panel); border: 2px solid var(--line);
|
|
color: var(--cream); font: 400 13px Silkscreen, monospace; letter-spacing: 2px;
|
|
cursor: pointer; -webkit-tap-highlight-color: transparent; position: relative; }
|
|
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
|
|
margin: 0 0 8px; padding: 7px; background: var(--panel);
|
|
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
|
|
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
#courtList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
|
#courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none;
|
|
object-fit: cover; }
|
|
#courtList .cb, #modeList .cb, #modeSub .cb { flex: 1; min-width: 0; }
|
|
#courtList b, #modeList b, #modeSub b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
|
|
#courtList i, #modeList i, #modeSub i { display: block; margin: 3px 0 6px; font: 400 9px Silkscreen, monospace; color: var(--dim); 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; }
|
|
/* 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 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 .two, #splash.busy #build { display: none; }
|
|
#sSum, #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; }
|
|
#stW { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.wtile { flex: 1 1 62px; max-width: 84px; 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); }
|
|
.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); }
|
|
/* ---- the name prompt. It is the only screen in the game that asks for typing, so the field is
|
|
deliberately big, centred and already focused: on a phone this appears with the keyboard
|
|
rising under it and there is no room for a second tap to find the input. */
|
|
.cent { text-align: center; }
|
|
#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 { display: block; width: 100%; box-sizing: border-box; margin: 0 auto 10px;
|
|
padding: 14px 10px; background: var(--solid); border: 2px solid var(--line);
|
|
border-radius: 0; color: var(--acid); font: 700 20px Silkscreen, monospace;
|
|
letter-spacing: 3px; text-align: center; text-transform: uppercase;
|
|
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
|
|
#nbName:focus { outline: none; border-color: var(--acid); }
|
|
#nbName::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 14px; }
|
|
</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">✕</button></div>
|
|
<div id="qAsk" hidden>
|
|
<div class="qBox">
|
|
<p>LEAVE THE MATCH?</p>
|
|
<button class="btn big" id="qYes">BACK TO THE MENU</button>
|
|
<button class="btn sec" id="qNo">PLAY ON</button>
|
|
</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. -->
|
|
<div id="splash">
|
|
<div id="hot"></div>
|
|
<div class="mid">
|
|
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
|
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
|
</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>
|
|
<button class="btn big" id="mStart">PLAY MATCH</button>
|
|
<div class="two">
|
|
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
|
|
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button>
|
|
</div>
|
|
<p id="build"></p>
|
|
</div>
|
|
</div>
|
|
<!-- tour tiles and court rows are both built from tables in main.js (config.TOURS / config.COURTS):
|
|
adding either is a table row, never markup -->
|
|
<!-- THE SETUP IS A WIZARD now (Oliver: one screen of posters + chips + tiles + meters was
|
|
"really overwhelming"). Three stages, tap advances: the mode posters, then the mode's own
|
|
second step when it has one (classic: difficulty, zombie tennis: arcade or the cup), then
|
|
who-and-where with SERVE UP. Every step comes up pre-selected from last time, so a
|
|
returning player is three taps from serving. -->
|
|
<div id="stmode">
|
|
<div class="hdr"><button class="chev" id="mBack">‹</button><h2>PICK A GAME</h2></div>
|
|
<div id="modeList"></div>
|
|
</div>
|
|
<div id="stsub">
|
|
<div class="hdr"><button class="chev" id="subBack">‹</button><h2 id="subTitle">DIFFICULTY</h2></div>
|
|
<div id="modeSub"></div>
|
|
</div>
|
|
<div id="setup">
|
|
<div class="hdr"><button class="chev" id="sBack">‹</button><h2>MATCH SETUP</h2></div>
|
|
<!-- ONE PLAYER BLOCK. It was four stacked sections — two big portrait tiles, then the live
|
|
player, then a kit row, then a skin row — which said the same thing twice (the tiles were
|
|
a second, FRONT-FACING drawing of a man who does not react to any of the colours) and ran
|
|
the court off the bottom of the phone. The tiles are gone: the live player IS the picture,
|
|
so who-you-are shrinks to two words, and every control that changes him stands beside him
|
|
where you can watch it land. Kit and skin rows are built in main.js from the tables. -->
|
|
<h3>01 · PLAYER</h3>
|
|
<div id="who">
|
|
<canvas id="look" width="96" height="224"></canvas>
|
|
<div id="whoCtl">
|
|
<div id="tours"></div>
|
|
<u class="swlab">KIT<i id="kitName"></i></u>
|
|
<div id="kitRow" class="swrow"></div>
|
|
<u class="swlab">SKIN<i id="skinName"></i></u>
|
|
<div id="skinRow" class="swrow"></div>
|
|
</div>
|
|
</div>
|
|
<h3>02 · COURT</h3>
|
|
<div id="courtList"></div>
|
|
<div class="foot">
|
|
<p id="sSum"></p>
|
|
<button class="btn big" id="cStart">SERVE UP</button>
|
|
</div>
|
|
</div>
|
|
<!-- THE DRAW (cup mode only). SERVE UP lands here instead of on a court: the four rounds roll
|
|
through their own band of the ladder and stop, and what you watched land is what walks on
|
|
(main.js hands the settled rungs to startMatch). The rows are built in main.js from
|
|
config.CUP_ROUNDS, so a round added there needs no markup. -->
|
|
<div id="bracket">
|
|
<div class="hdr"><button class="chev" id="brBack">‹</button><h2>THE DRAW</h2></div>
|
|
<h3>FOUR ROUNDS<span id="brState">DRAWING…</span></h3>
|
|
<div id="cupRows"></div>
|
|
<div class="foot">
|
|
<p id="brSum"></p>
|
|
<button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button>
|
|
</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" id="stBack">‹</button><h2 id="stTitle">BODY COUNT</h2></div>
|
|
<!-- WHICH MODE, then which period. The three modes do not measure the same thing (see the
|
|
record-shape note in stats.endRun), so this strip changes the whole screen under it —
|
|
the title, the three figures, the roster's heading and the footer. -->
|
|
<div class="tabs" id="stModes">
|
|
<button class="on" data-m="g">SPLATTER</button>
|
|
<button data-m="z">TENNIS</button>
|
|
<button data-m="c">CLASSIC</button>
|
|
</div>
|
|
<div class="tabs" id="stTabs">
|
|
<button class="on" data-p="all">ALL TIME</button>
|
|
<button data-p="last">LAST RUN</button>
|
|
<button data-p="7d">7 DAYS</button>
|
|
</div>
|
|
<div class="figs" id="stFigs"></div>
|
|
<h3 id="stZHdr">BY OPPONENT<span id="stPeriod"></span></h3>
|
|
<div id="stZ"></div>
|
|
<h3 id="stWHdr">BY WEAPON</h3>
|
|
<div id="stW"></div>
|
|
<div class="foot" id="stFoot"></div>
|
|
</div>
|
|
<!-- THREE BOARDS, one per game mode — the three do not share a currency, so they cannot share
|
|
a ranking. Same tab strip as the stats screen's, and stats.drawRanks rewrites the column
|
|
heading with the tab, because the number means something different on each one. -->
|
|
<div id="ranks">
|
|
<div class="hdr"><button class="chev" id="rkBack">‹</button><h2>WORLD BOARD</h2></div>
|
|
<div class="tabs" id="rkModes">
|
|
<button class="on" data-m="g">SPLATTER</button>
|
|
<button data-m="z">TENNIS</button>
|
|
<button data-m="c">CLASSIC</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" 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" 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>
|
|
<script type="module" src="src/main.js?v=265"></script>
|
|
</body>
|
|
</html>
|