Files
splatter-tennis/index.html
T
wuwiandClaude Opus 5 b1f8ea3240 Wind the serve up with the pull, and stop the frames flickering
Four new frames (p_pull1..4) turn the slingshot drag into the serve's wind-up:
racket above the head, behind the head, behind the back, fully coiled, picked off
pullFrac — the same 0..1 that sets the shot's power — so the gesture and the man
are one movement and the release snaps to the smash.

The two bounce frames are one drawing now. p_bounce2 is p_bounce with the free
arm sheared up 12px and nothing else touched, so the swap cannot flicker, and it
follows the ball instead of its rising phase; BOUNCE_IDLE was re-measured off the
sprite so the ball tops out under the palm. p_toss had the tossed ball drawn into
it — cut, canvas height kept, because the game scales him off img.height.

The runner: hip centres sat -2.0/+7.1/-6.0/-0.1 px off the sprite centre, a 13px
lateral jump at 12 frames a second, and Oliver's diagnosis was right that all four
frames leaned the same way and lifted the same leg. Frames 3 and 4 mirrored, all
four padded to one width with the hip on the centre line.

assets/_derive.py holds the three derivations, since "the same drawing with one
small difference" is the one thing the generator cannot draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:51:30 +02:00

103 lines
6.5 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, #intro .kb { 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)); }
#intro .slogan { margin: 8px 0 16px; font-size: 12px; color: #b6a0a4; letter-spacing: 0.5px; }
#intro .slogan b { color: #c9564a; }
#intro .how { display: inline-block; text-align: left; color: #cbd4e2; line-height: 2; }
#intro .how p { margin: 0; }
#intro .how span { display: inline-block; width: 30px; }
#intro em { color: #8a96ab; font-style: normal; font-size: 12px; }
/* 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; }
#intro .start { margin-top: 18px; color: #e0cc72; font-weight: bold; letter-spacing: 1px; animation: blink 1.1s step-end infinite; }
@keyframes blink { 50% { opacity: 0.25; } }
</style>
</head>
<body>
<canvas id="c" width="480" height="270"></canvas>
<div id="safe"></div>
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<div id="intro">
<div class="card">
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
<div class="how">
<p><span>👆</span> tap — serve + swing</p>
<p><span>👉</span> flick — aimed shot</p>
<p><span>✋</span> drag — run <em>(auto-run is on)</em></p>
<p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p>
</div>
<p class="start">TAP TO START</p>
<p id="build"></p>
</div>
</div>
<script type="module" src="main.js?v=77"></script>
</body>
</html>