All of it found from the sprite bench, and most of it was the same mistake twice: a correction applied on top of art that already handled it. THE HEADLESS BODY SHRANK. "Every headless mode shrinks the whole opponent a little" — measured, 34.0 px healthy / 33.8 arm off / 27.8 headless on all EIGHT families, a flat -18%, and the FEET shrank 16-19% with it. A body that loses its head does not get smaller feet. drawZombie carried `if (st === 2) h = 1.52` to take a head off the top, and a headless sprite is still a FULL-HEIGHT frame, because the generator draws a gushing neck stump whose blood plume fills the space the head occupied: 18.3% of z_rw2's frame sits above its shoulders against 10.7% above z_rw1's head. So the art had taken the head off, the per-frame PNG scaling had handled the height, and the override took it off a third time. Wrong on the fallback skins too (z_cw2 is a SHORTER png, which the scaling handles): its body drew at 1.30 m against the healthy 1.78 m. Gone. The HP bar and the aim mark read config.zombieH directly, so they had been floating above a shrunken body for as long as it existed; they re-align for free. THE WOUNDED BODIES WALKED BACKWARDS, and this is not bench-only — racketAI and updateBench both set face as the sign of x movement, so a match did it too. The racket is what measures it, Oliver's own test: "if this racket is facing to the right I would assume that he also walks to the right." Its string bed keys out to an enclosed hole (or is painted pale on the master), so the hoop locates itself. The healthy stride is drawn racket-RIGHT (+0.38..+0.42) and every wound stride racket-LEFT (-0.39..-0.45): THE WOUNDED BODY IS DRAWN FACING THE OTHER WAY, on the master and therefore in the reference and therefore in all seven families. One flip rule cannot serve both rows — fixing the healthy walk inverted the wound walk, which is the second thing he saw. The flip is derived from the frame name now, and only for the WALK frames (w1/w1b/w2/w2b), because they are the only ones whose orientation tracks travel: the wound forehand measures racket-right like the healthy one, and a serve or a fall puts the racket overhead where it says nothing about direction. Checked against 18 frame names including the tour-suffixed ones. AND _famref.py PINS THOSE SIDES. That branch is only correct while the art stays this way, and this is the THIRD art inconsistency this session that reached the court because nothing measured it. A re-roll that mirrors a row now fails the reference build pointing at drawZombie's artFacesRight, instead of shipping. A DEAD WRITE, found on the way: racketAI set rz.facing, and zombies render off zb.face. `facing` is the PLAYER's field and uses the OPPOSITE convention (a char flips on facing < 0, a body on face > 0), so the match opponent's turn was never driven by its chase at all — it fell through to the generic sticky-face line. THE PRONE FRAME LAY ON ITS RACKET. "The player basically levitates over the ground held by his racket." Every frame is bottom-aligned on the ground point, and in the fall3 art the lowest ink is the racket hoop thrown out beside the body — z_shw1fall3 is 249x85 with the body's underside at y67 and rows 68-84 racket, so the torso floated a fifth of the frame up. TUNE.proneSink drops it by that fraction, putting the body line where fall2 leaves its knees. The shadow made it worse and is suppressed for the whole beat: the contact ellipse is sized off DRAWN WIDTH, and a prone frame is three times wider than a standing one, so the widest darkest shadow in the game was painted under the one body not standing on it. THE FALL CARRIES THE RUN. "When the opponent runs to the ball from left to right and then falls, the player must still move to the right while falling." The velocity is INHERITED (racketAI and updateBench both record zb.vx), so a body that went down standing still does not slide and one caught at a sprint slides furthest. It was exponential decay first, which put 93% of the travel in the first two frames — "the visible slide should also extend to the 3rd and 4th frame" — and no retuning fixes that: stretch a wall-clock decay far enough to move the prone body and it creeps for the whole floor hold. So the slide is a DISTANCE BUDGET shared across the frames (TUNE.fallSlideMix), eased out within each so it still decelerates. x is set absolutely from the start, so the total cannot drift with frame rate. 0.05 / 0.22 / 0.35 / 0.11 m — the landing skids and the rise drags itself the last of the way. THE BENCH GOT A FALL BUTTON to tune all of this, and it plays the REAL beat: same FALL table, same tickMatchFall, because a bench that re-implemented the timing would be refining a second copy of it. matchReady() is skipped while the bench owns the court (it runs setupPoint and re-places the body — a match doing match things inside an art test), the walk stop stops walking during a fall, and startFall reads the table LIVE via fallLen() so tuning the numbers cannot leave the 'over' beat expiring mid-collapse. FALL is 0.06 / 0.25 / 2.0 / 0.45 after a session in the bench. fall1 and fall2 are deliberately the same pace (0.87 and 0.88 m/s of slide at a 1.54 m/s entry) so the collapse reads as one movement decelerating into the ground; the 0.07 s fall2 it replaced gave each drawn frame ~4 display frames and read as a cut. THE LENS, portrait camZ 48 -> 49. The three camera numbers are not independent: holding the near player's drawn size fixed means camF = s_player * (camZ - zMax), holding the court's on-screen depth fixed means camY = depth / (s_player - camF/camZ), so 49 gives 795 and 11.23 and horizon re-derives to 43. Far opponent 33.1 -> 33.8 px, player unchanged, court band within a pixel. One metre is deliberate: at camZ 55 the umpire is already clipped off the right edge and by 70 the court reads top-down, and it narrows the player's lateral room because xVisible clamps him to what fits. A figScale experiment that scaled every body 1.2x got further (far body 40.6 px) but is NOT in here — it needs the vertical contact constants to scale with it (ball-vs-body is gated on ball.y < 1.9, which sits just above the tallest chest at 1.856 m, so the roller man would have gone invulnerable) and Oliver reverted it pending a real playtest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
30 KiB
HTML
437 lines
30 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 — up in the empty top-right corner now that the rack owns the bottom edge */
|
|
#hint { position: fixed; top: 6px; right: 10px; text-align: right;
|
|
font: 400 10px Silkscreen, monospace; color: #6b7688; }
|
|
@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 { 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="stats"] #stats,
|
|
#intro[data-stage="ranks"] #ranks,
|
|
#intro[data-stage="secret"] #secret,
|
|
#intro[data-stage="bosses"] #bosses,
|
|
#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 tour: two tiles SIDE BY SIDE, not two more full-width rows. The figures face the camera —
|
|
in play you only ever see your player from behind, so a rear view would show nothing. */
|
|
#tours { display: flex; gap: 8px; }
|
|
#tours button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line);
|
|
color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
#tours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
|
#tours img { height: 88px; width: auto; display: block; image-rendering: pixelated; }
|
|
#tours b { font: 400 11px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
|
|
/* 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; }
|
|
/* 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 tiles reuse the court row wholesale — same box, same selected state. Only the picture
|
|
differs: a court tile is a photo of a court and crops fine, a mode tile is a SPRITE and must be
|
|
shown whole, so it gets `contain` and a dark plate to stand on. */
|
|
#modeList img { object-fit: contain !important; background: rgba(0,0,0,0.35); }
|
|
#courtList button.court, #modeList 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, #modeList button.court.on { border-color: var(--acid); background: rgba(188,212,85,0.09); }
|
|
#courtList img, #modeList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none;
|
|
object-fit: cover; }
|
|
#courtList .cb, #modeList .cb { flex: 1; min-width: 0; }
|
|
#courtList b, #modeList b { display: block; font: 400 12px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
|
|
#courtList i, #modeList 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 { 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="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 -->
|
|
<div id="setup">
|
|
<div class="hdr"><button class="chev" id="sBack">‹</button><h2>MATCH SETUP</h2></div>
|
|
<h3>01 · MODE</h3>
|
|
<div id="modeList"></div>
|
|
<h3>02 · PLAYER</h3>
|
|
<div id="tours"></div>
|
|
<h3>03 · COURT</h3>
|
|
<div id="courtList"></div>
|
|
<div class="foot">
|
|
<p id="sSum"></p>
|
|
<button class="btn big" id="cStart">SERVE UP</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>
|
|
<div id="ranks">
|
|
<div class="hdr"><button class="chev" id="rkBack">‹</button><h2>WORLD BOARD</h2></div>
|
|
<h3>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=208"></script>
|
|
</body>
|
|
</html>
|