A test mode for trying the new content without playing twenty games to reach it. Three taps on the scoreboard toggles it, three more turns it off, and a single tap is still the miss-log overlay. Same shape as the umpire triple-tap cheat it sits next to. It OVERRIDES the difficulty director rather than editing it, so the wave is the same whether you opened it in game 1 or game 40 and the run keeps running underneath: 8 bodies, a fixed 1.15 m/s shamble, a spawn every 1.6 s, and pickVariant pretending to be at game 20 so every body type can turn up. All four barrel weapons sit on the rack and are topped back up each frame, because committing a burst spends the whole tin and a sandbox that runs dry is not one. A barrel rolls out every 14 s so the pickup itself is testable too. The third tap must NOT toggle logShow: taps one and two already toggled it twice and it is back where it started, so toggling again left the miss-log overlay switched on behind you every single time. Also records the trap that cost a debugging round getting here: index.html loads main.js?v=N, and fetching 'main.js' does not bust that URL -- so main.js stayed stale through a reload while every other module was fresh, and the tap handler appeared to do nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
12 KiB
HTML
185 lines
12 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">
|
|
<style>
|
|
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack */
|
|
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
|
|
src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); }
|
|
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
|
|
src: url('assets/fonts/Silkscreen-Bold.ttf') 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; } }
|
|
#intro { position: fixed; inset: 0; z-index: 10; touch-action: none; display: grid; place-items: center;
|
|
background: radial-gradient(circle at 50% 34%, rgba(34,16,20,0.92), rgba(8,10,16,0.96)); }
|
|
#intro .card { font: 400 11px Silkscreen, monospace; color: #dfe6f2; text-align: center; line-height: 1.9;
|
|
padding: 18px 26px; max-width: 92vw; animation: drop 0.5s ease-out; }
|
|
@keyframes drop { from { transform: translateY(-14px); opacity: 0; } }
|
|
/* the logo is generated pixel art now (assets/logo.png), not stacked CSS text */
|
|
#intro .logo { display: block; width: min(320px, 78vw); height: auto; margin: 0 auto 8px;
|
|
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: 10px 0 26px; font-size: 12px; color: #b6a0a4; 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: 10px 0 0; font-size: 9px; color: #5a6270; 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, #courts, #tutorial { display: none; }
|
|
#intro[data-stage="splash"] #splash { display: block; }
|
|
#intro[data-stage="courts"] #courts { display: block; animation: drop 0.3s ease-out; }
|
|
#intro[data-stage="tutorial"] #tutorial { display: block; 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; }
|
|
/* headline: taller than the body text, so a screen reads as a screen and not as a list */
|
|
#intro h2 { margin: 0 0 20px; font: 400 15px Silkscreen, monospace; color: #e0cc72;
|
|
letter-spacing: 3px; line-height: 1.5; }
|
|
/* one button look for every menu screen */
|
|
.btn { display: block; width: min(340px, 84vw); margin: 0 auto 10px; padding: 12px 10px;
|
|
background: rgba(255,255,255,0.04); border: 2px solid #3b4354; color: #dfe6f2;
|
|
font: 400 12px Silkscreen, monospace; letter-spacing: 1px; cursor: pointer;
|
|
-webkit-tap-highlight-color: transparent; }
|
|
.btn.go { border-color: #e0cc72; color: #e0cc72; }
|
|
/* The two menu buttons wear generated plates. The LETTERING stays live text on top rather than
|
|
being drawn into the art: generated type is a lottery and would not match the rest of the HUD.
|
|
A text-shadow is what keeps it readable where a drip runs behind it. The border is CSS, not
|
|
art — the plate crops are interior-only, so both buttons get the same clean frame. */
|
|
.btn.plate, .btn.plate2 { padding: 15px 10px; font-size: 15px; letter-spacing: 2px;
|
|
background-size: 100% 100%; background-repeat: no-repeat;
|
|
image-rendering: pixelated; text-shadow: 0 2px 0 rgba(0,0,0,0.9); }
|
|
/* No gold frame on START any more — the sweep below is what draws the eye, and the two buttons
|
|
now share one border so the pair reads as a pair. */
|
|
.btn.plate { background-image: url('assets/btn_start.png?v=94'); color: #f7ecb8;
|
|
border-color: #3b4354; position: relative; overflow: hidden; }
|
|
/* a light passing over the sign: across in ~0.8s, then still for the rest of the cycle.
|
|
pointer-events:none so the sweep can never eat the tap. */
|
|
.btn.plate::after { content: ''; position: absolute; inset: 0; pointer-events: none;
|
|
background: linear-gradient(105deg, transparent 42%, rgba(255,255,255,0.34) 50%,
|
|
transparent 58%);
|
|
transform: translateX(-140%); animation: shine 4.5s ease-in-out infinite; }
|
|
@keyframes shine { 0% { transform: translateX(-140%); } 18%, 100% { transform: translateX(140%); } }
|
|
@media (prefers-reduced-motion: reduce) { .btn.plate::after { animation: none; opacity: 0; } }
|
|
.btn.plate2 { background-image: url('assets/btn_tut.png?v=94'); color: #d6dfec; }
|
|
.btn.back { font-size: 10px; padding: 9px 10px; color: #8a96ab; }
|
|
/* 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. */
|
|
#courts button.court { display: flex; align-items: center; gap: 12px; width: min(340px, 84vw);
|
|
margin: 0 auto 8px; padding: 6px; background: rgba(255,255,255,0.04);
|
|
border: 2px solid #3b4354; color: inherit; font: inherit; text-align: left;
|
|
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
#courts button.court.on { border-color: #e0cc72; background: rgba(224,204,114,0.10); }
|
|
#courts img { width: 108px; height: 72px; display: block; image-rendering: pixelated; flex: none; }
|
|
#courts b { display: block; font: 400 11px Silkscreen, monospace; color: #dfe6f2; letter-spacing: 0.5px; }
|
|
#courts i { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: #8a96ab; font-style: normal; }
|
|
#courts .acts { margin-top: 16px; }
|
|
/* tutorial: Marvin on the left, his line and the picture on the right */
|
|
#tutorial .page { display: flex; align-items: flex-end; gap: 14px; width: min(360px, 88vw);
|
|
margin: 0 auto; text-align: left; min-height: 190px; }
|
|
#tutorial .marvin { width: 84px; flex: none; image-rendering: pixelated; align-self: flex-end; }
|
|
#tutorial .say { flex: 1; }
|
|
#tutorial .say p { margin: 0 0 10px; font: 400 10px Silkscreen, monospace; color: #dfe6f2; line-height: 1.9; }
|
|
#tutorial .say em { color: #e0cc72; font-style: normal; }
|
|
/* the panel is 2:1 and it is the point of the page, so it gets the full card width above the
|
|
Marvin row rather than being squeezed into the text column beside him */
|
|
#tutorial .shot { display: block; width: min(360px, 88vw); margin: 0 auto 12px;
|
|
image-rendering: pixelated; border: 2px solid #2a3140; }
|
|
#tutorial .dots { margin: 14px 0 10px; font: 400 10px Silkscreen, monospace; color: #5a6270; letter-spacing: 3px; }
|
|
#tutorial .acts { display: flex; gap: 8px; width: min(340px, 84vw); margin: 0 auto; }
|
|
#tutorial .acts .btn { margin: 0; flex: 1; }
|
|
</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. The
|
|
control list that used to sit here is the tutorial's job now. -->
|
|
<div id="splash">
|
|
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
|
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
|
|
<button class="btn plate" id="mStart">START GAME</button>
|
|
<button class="btn plate2" id="mTut">TUTORIAL</button>
|
|
<p id="build"></p>
|
|
</div>
|
|
<!-- court rows are built from config.COURTS in main.js: adding a court is a table row, never markup -->
|
|
<div id="courts">
|
|
<h2>CHOOSE YOUR COURT</h2>
|
|
<div id="courtList"></div>
|
|
<div class="acts">
|
|
<button class="btn go" id="cStart">START GAME</button>
|
|
<button class="btn back" id="cBack">BACK</button>
|
|
</div>
|
|
</div>
|
|
<!-- tutorial pages are built from a table in main.js -->
|
|
<div id="tutorial">
|
|
<h2>HOW IT WORKS</h2>
|
|
<img class="shot" id="tShot" alt="">
|
|
<div class="page">
|
|
<img class="marvin" id="tMarvin" src="assets/marvin1.png" alt="">
|
|
<div class="say" id="tSay"></div>
|
|
</div>
|
|
<p class="dots" id="tDots"></p>
|
|
<div class="acts">
|
|
<button class="btn back" id="tBack">BACK</button>
|
|
<button class="btn go" id="tNext">NEXT</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script type="module" src="main.js?v=100"></script>
|
|
</body>
|
|
</html>
|