Put a menu in front of the game, with Marvin to explain it
The overlay is a four-stage machine now — splash -> menu -> {courts, tutorial}
— swapped by data-stage while the backdrop and card frame stay mounted, so the
live court never flashes through between screens.
Court tiles SELECT only. Tapping one used to start the match on the spot, which
gave you no way to compare the three and no way back out; START GAME and BACK
are their own buttons now. Headlines are 15px against the 11px body, so a screen
reads as a screen rather than as a list.
The tutorial is a data table: a Marvin pose, an optional picture already in
assets/, and his lines. Seven pages covering tennis, the turn, the slingshot,
the rack and the duel. Marvin is four new frames off x_boymop1 — waving,
pointing, shrugging, and clutching his mop in terror.
The theme no longer starts at boot. A title screen that plays at you before you
have touched it is rude, and the autoplay policy would have held it silent until
that first tap anyway.
Death returns to the menu instead of the title. The reload stays — it is the
honest full reset — and a sessionStorage flag is what picks the stage on the way
back in.
Machine-gun rounds are tennis balls, so they are drawn as balls and take the
perspective. A fixed 4x4 fillRect was exactly as big at the far baseline as at
your feet, which read as square confetti pinned to the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60f7ce2cf1
commit
9a65a50cc4
10 files changed
+199
-44
No files matched your search
+14
@@ -454,6 +454,20 @@ death summary is the end menu, and the zombie bed ducks out as it comes in. Two
|
|||||||
bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several
|
bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several
|
||||||
seconds later, over the match.
|
seconds later, over the match.
|
||||||
|
|
||||||
|
**The overlay is a four-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps
|
||||||
|
`splash -> menu -> {courts, tutorial}` while the backdrop and card frame stay mounted, because a
|
||||||
|
screen that unmounts the overlay flashes the live court through between menus. Court tiles SELECT
|
||||||
|
only; `START GAME` and `BACK` are their own buttons. The tutorial is a data table (`TUT` in main.js)
|
||||||
|
of Marvin poses, an optional picture already in `assets/`, and his lines — adding a page is a row.
|
||||||
|
|
||||||
|
**The menu theme starts on the FIRST TAP, never at boot** (`go()` calls `menuMusic`). A title screen
|
||||||
|
that plays at you before you touch it is rude, and on a phone the autoplay policy would have held it
|
||||||
|
silent until that tap regardless.
|
||||||
|
|
||||||
|
**Death returns to the MENU, not the splash.** `maybeRestart` still reloads — that is the honest
|
||||||
|
full reset and nothing has to unwind a run by hand — but it sets `sessionStorage.splatterStage` on
|
||||||
|
the way out, and boot consumes it to open at the menu.
|
||||||
|
|
||||||
**The theme is AAC** (`background-music.m4a`, 768 KB — the WAV was 8.3 MB, and this one downloads on
|
**The theme is AAC** (`background-music.m4a`, 768 KB — the WAV was 8.3 MB, and this one downloads on
|
||||||
every page load before the player has decided to play). `loopBed` takes the FULL filename now,
|
every page load before the player has decided to play). `loopBed` takes the FULL filename now,
|
||||||
because the beds are a mix of `.wav` and `.m4a`; `fadeBed`'s key must match it exactly, extension
|
because the beds are a mix of `.wav` and `.m4a`; `fadeBed`'s key must match it exactly, extension
|
||||||
|
|||||||
@@ -439,5 +439,19 @@
|
|||||||
"check": "loose",
|
"check": "loose",
|
||||||
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN TWO frames of him standing at the baseline BOUNCING A BALL on the ground before serving, with NO ball drawn. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both bouncing frames his racket is held out to the side in his RIGHT hand \u2014 which is on the LEFT-HAND SIDE OF THE IMAGE \u2014 and his other arm is stretched DOWN AND OUT to the RIGHT-HAND SIDE OF THE IMAGE, well clear of his body, with the palm open and facing the ground as if patting a ball. CRITICAL: the two bouncing frames are otherwise the IDENTICAL drawing \u2014 same stance, same legs, same head, same racket, same size \u2014 and the ONLY difference between them is the height of that patting hand: in the first it is low, near knee height; in the second it is higher, near his waist. Draw NO ball in any frame.",
|
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN TWO frames of him standing at the baseline BOUNCING A BALL on the ground before serving, with NO ball drawn. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both bouncing frames his racket is held out to the side in his RIGHT hand \u2014 which is on the LEFT-HAND SIDE OF THE IMAGE \u2014 and his other arm is stretched DOWN AND OUT to the RIGHT-HAND SIDE OF THE IMAGE, well clear of his body, with the palm open and facing the ground as if patting a ball. CRITICAL: the two bouncing frames are otherwise the IDENTICAL drawing \u2014 same stance, same legs, same head, same racket, same size \u2014 and the ONLY difference between them is the height of that patting hand: in the first it is low, near knee height; in the second it is higher, near his waist. Draw NO ball in any frame.",
|
||||||
"palette_ref": "o_idle1"
|
"palette_ref": "o_idle1"
|
||||||
|
},
|
||||||
|
"marvin": {
|
||||||
|
"sprites": [
|
||||||
|
"marvin1",
|
||||||
|
"marvin2",
|
||||||
|
"marvin3",
|
||||||
|
"marvin4"
|
||||||
|
],
|
||||||
|
"ref_only": [
|
||||||
|
"x_boymop1"
|
||||||
|
],
|
||||||
|
"anchor": 0,
|
||||||
|
"check": "loose",
|
||||||
|
"subject": "The reference is ONE young ball boy in a WHITE polo shirt and WHITE shorts with brown hair, seen from the FRONT, holding a floor mop. Draw FOUR frames of that SAME boy \u2014 same face, same brown hair, same white kit, same white shoes, same size, same front-facing view \u2014 with a gormless, dim-witted, slightly cross-eyed expression in every frame. THE FOUR FRAMES ARE FOUR DIFFERENT POSES: (1) WAVING hello with his free hand raised beside his head, big goofy open-mouthed grin, the mop standing upright in his other hand; (2) POINTING away to the side with one arm stuck straight out and his mouth open explaining something, mop upright in the other hand; (3) SHRUGGING with both palms turned up and his shoulders hunched, utterly baffled, eyebrows up, the mop tucked under one arm; (4) TERRIFIED \u2014 eyes wide as saucers, mouth open in a yell, both hands clutching the mop up across his chest like a weapon. Each figure stands upright at the same height with both feet on the ground. The whole boy and the whole mop are fully inside the canvas with clear space around them. The canvas contains EXACTLY FOUR figures and no others."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -111,10 +111,10 @@ export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); //
|
|||||||
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
|
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
|
||||||
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
|
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
|
||||||
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
|
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
|
||||||
// Menu theme: the splash, the court picker, the death summary. Barely highpassed — this one is
|
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
|
||||||
// MUSIC, not an atmosphere bed, so the low end is the point. Started at boot so it is already
|
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
|
||||||
// running by the time the title is on screen; on a phone the autoplay policy holds the context
|
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
|
||||||
// suspended until the first tap, and it becomes audible then (see loopBed).
|
// the autoplay policy would have held it silent until that tap anyway.
|
||||||
// AAC, not WAV: 48 s of stereo music is 8.3 MB as a WAV and this one downloads on every page load,
|
// AAC, not WAV: 48 s of stereo music is 8.3 MB as a WAV and this one downloads on every page load,
|
||||||
// before the player has decided to play anything. 768 KB encoded, and the master is exactly 48.000 s
|
// before the player has decided to play anything. 768 KB encoded, and the master is exactly 48.000 s
|
||||||
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
|
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
|
||||||
|
|||||||
+62
-12
@@ -76,29 +76,54 @@
|
|||||||
#build { margin: 10px 0 0; font-size: 9px; color: #5a6270; letter-spacing: 0.5px; }
|
#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; }
|
#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; } }
|
@keyframes blink { 50% { opacity: 0.25; } }
|
||||||
/* court select — the intro's second stage. One overlay, two panels, .picking swaps them, so the
|
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
|
||||||
backdrop and the card frame never flash between screens. */
|
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
|
||||||
#courts { display: none; }
|
court through between menus. */
|
||||||
#intro.picking #splash { display: none; }
|
#splash, #menu, #courts, #tutorial { display: none; }
|
||||||
#intro.picking #courts { display: block; animation: drop 0.3s ease-out; }
|
#intro[data-stage="splash"] #splash { display: block; }
|
||||||
#courts h2 { margin: 0 0 14px; font: 400 11px Silkscreen, monospace; color: #e0cc72; letter-spacing: 2px; }
|
#intro[data-stage="menu"] #menu { display: block; animation: drop 0.3s ease-out; }
|
||||||
|
#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; }
|
||||||
|
/* 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; }
|
||||||
|
.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
|
/* 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. */
|
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
|
||||||
#courts button { display: flex; align-items: center; gap: 12px; width: min(340px, 84vw);
|
#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);
|
margin: 0 auto 8px; padding: 6px; background: rgba(255,255,255,0.04);
|
||||||
border: 2px solid #3b4354; color: inherit; font: inherit; text-align: left;
|
border: 2px solid #3b4354; color: inherit; font: inherit; text-align: left;
|
||||||
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||||
#courts button.on { border-color: #e0cc72; background: rgba(224,204,114,0.10); }
|
#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 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 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 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; }
|
||||||
|
#tutorial .shot { display: block; max-width: 100%; max-height: 108px; margin: 0 0 8px;
|
||||||
|
image-rendering: pixelated; }
|
||||||
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="c" width="480" height="270"></canvas>
|
<canvas id="c" width="480" height="270"></canvas>
|
||||||
<div id="safe"></div>
|
<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="hint">move: WASD/arrows · serve/swing: SPACE · aim: hold a direction while swinging · T: tuning panel</div>
|
||||||
<div id="intro">
|
<div id="intro" data-stage="splash">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div id="splash">
|
<div id="splash">
|
||||||
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
|
||||||
@@ -113,10 +138,35 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
<p id="build"></p>
|
<p id="build"></p>
|
||||||
</div>
|
</div>
|
||||||
<!-- rows are built from config.COURTS in main.js: adding a court is a table row, never markup -->
|
<div id="menu">
|
||||||
<div id="courts"><h2>CHOOSE YOUR COURT</h2></div>
|
<h2>SPLATTER TENNIS</h2>
|
||||||
|
<button class="btn go" id="mStart">START GAME</button>
|
||||||
|
<button class="btn" id="mTut">TUTORIAL</button>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=90"></script>
|
<!-- tutorial pages are built from a table in main.js -->
|
||||||
|
<div id="tutorial">
|
||||||
|
<h2>HOW IT WORKS</h2>
|
||||||
|
<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=91"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -39,34 +39,97 @@ setupPoint();
|
|||||||
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
|
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
|
||||||
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
|
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
|
||||||
|
|
||||||
// intro overlay, two stages: the first tap opens court select, picking a court starts play and
|
// The overlay is a four-stage machine: splash -> menu -> {courts, tutorial}. One overlay throughout,
|
||||||
// unlocks audio (iOS needs the gesture, and the SECOND tap is still a gesture — the pick is what
|
// swapped by data-stage, so the court never flashes between screens.
|
||||||
// has to unlock it, not the splash).
|
|
||||||
const intro = document.getElementById('intro');
|
const intro = document.getElementById('intro');
|
||||||
const courts = document.getElementById('courts');
|
|
||||||
const lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
|
const lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
|
||||||
|
let picked = lastCourt || COURTS[0].id;
|
||||||
|
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
|
||||||
|
|
||||||
|
// --- court rows, built from the table
|
||||||
|
const courtList = document.getElementById('courtList');
|
||||||
for (const c of COURTS) {
|
for (const c of COURTS) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.className = c.id === (lastCourt || COURTS[0].id) ? 'on' : ''; // your last court comes up pre-picked
|
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
|
||||||
b.innerHTML = `<img src="assets/${c.tile}.png?v=81" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
|
b.innerHTML = `<img src="assets/${c.tile}.png?v=81" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
|
||||||
b.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); start(c.id); });
|
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
|
||||||
courts.appendChild(b);
|
// you no way to look at the three of them and no way back out of the screen.
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
picked = c.id;
|
||||||
|
for (const el of courtList.children) el.classList.toggle('on', el === b);
|
||||||
|
});
|
||||||
|
courtList.appendChild(b);
|
||||||
}
|
}
|
||||||
menuMusic(); // the title screen has a theme. Silent until the first tap on a phone — see loopBed.
|
|
||||||
function openPicker() {
|
function start() {
|
||||||
if (!intro.parentNode) return;
|
if (!intro.parentNode) return;
|
||||||
intro.classList.add('picking');
|
selectCourt(picked);
|
||||||
menuMusic(); // ...and this call is the one that resumes it: the tap IS the gesture
|
|
||||||
}
|
|
||||||
function start(id) {
|
|
||||||
if (!intro.parentNode) return;
|
|
||||||
selectCourt(id);
|
|
||||||
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
|
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
|
||||||
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
|
||||||
}
|
}
|
||||||
// a tap anywhere on the splash opens the picker; on the picker itself only the buttons act, so a
|
document.getElementById('mStart').addEventListener('click', () => go('courts'));
|
||||||
// stray tap beside a tile can't start a match on a court you didn't choose.
|
document.getElementById('mTut').addEventListener('click', () => { tutPage = 0; drawTut(); go('tutorial'); });
|
||||||
intro.addEventListener('pointerdown', e => { e.preventDefault(); openPicker(); });
|
document.getElementById('cStart').addEventListener('click', start);
|
||||||
|
document.getElementById('cBack').addEventListener('click', () => go('menu'));
|
||||||
|
|
||||||
|
// --- the tutorial, narrated by Marvin the mop boy. Pure data: a page is a Marvin pose, an optional
|
||||||
|
// picture already in assets/, and his lines.
|
||||||
|
const TUT = [
|
||||||
|
{ pose: 1, say: ["I'm <em>MARVIN</em>. I mop the court.",
|
||||||
|
"Lately there is a great deal more to mop.",
|
||||||
|
"Let me show you how this goes."] },
|
||||||
|
{ pose: 2, shot: 'court_grass', say: ["First it is just <em>TENNIS</em>.",
|
||||||
|
"<em>TAP</em> once to toss, tap again to serve.",
|
||||||
|
"<em>FLICK</em> toward where you want it.",
|
||||||
|
"<em>DRAG</em> to run — though he mostly runs himself."] },
|
||||||
|
{ pose: 4, say: ["Then, at some point, the other fellow...",
|
||||||
|
"stops being the other fellow.",
|
||||||
|
"Nobody stops the match. Nobody ever does."] },
|
||||||
|
{ pose: 2, shot: 'x_howto', say: ["Now you do not rally. You <em>AIM</em>.",
|
||||||
|
"<em>PULL BACK</em> from your man and let go.",
|
||||||
|
"The further you pull, the harder it lands.",
|
||||||
|
"A soft one only makes them cross."] },
|
||||||
|
{ pose: 2, shot: 'w_bomb', say: ["I bring tins out to the rack down there.",
|
||||||
|
"<em>BOMBS</em>, <em>TRIPLES</em>, <em>FIRE</em>, and the <em>GUN</em>.",
|
||||||
|
"Tap the rack to load one, then serve it.",
|
||||||
|
"Do try not to hit me with them."] },
|
||||||
|
{ pose: 3, say: ["Sometimes the umpire calls order back.",
|
||||||
|
"One point, tennis rules, against one of them.",
|
||||||
|
"The lines are live again. Do not miss."] },
|
||||||
|
{ pose: 1, say: ["That is the lot. Mind the deep ones.",
|
||||||
|
"And if it all goes wrong —",
|
||||||
|
"I will be along with the mop."] },
|
||||||
|
];
|
||||||
|
let tutPage = 0;
|
||||||
|
const tSay = document.getElementById('tSay'), tMarvin = document.getElementById('tMarvin');
|
||||||
|
const tDots = document.getElementById('tDots'), tNext = document.getElementById('tNext');
|
||||||
|
function drawTut() {
|
||||||
|
const p = TUT[tutPage];
|
||||||
|
tMarvin.src = `assets/marvin${p.pose}.png?v=91`;
|
||||||
|
tSay.innerHTML = (p.shot ? `<img class="shot" src="assets/${p.shot}.png?v=91" alt="">` : '')
|
||||||
|
+ p.say.map(l => `<p>${l}</p>`).join('');
|
||||||
|
tDots.textContent = TUT.map((_, i) => i === tutPage ? '●' : '·').join('');
|
||||||
|
tNext.textContent = tutPage === TUT.length - 1 ? 'DONE' : 'NEXT';
|
||||||
|
}
|
||||||
|
tNext.addEventListener('click', () => {
|
||||||
|
if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('menu');
|
||||||
|
});
|
||||||
|
document.getElementById('tBack').addEventListener('click', () => {
|
||||||
|
if (tutPage > 0) { tutPage--; drawTut(); } else go('menu');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tap anywhere on the SPLASH moves to the menu — and that tap is the gesture that unlocks audio,
|
||||||
|
// which is why the theme is not started at boot any more. Every later screen has real buttons, so
|
||||||
|
// the overlay itself must not swallow taps beside them.
|
||||||
|
intro.addEventListener('pointerdown', e => {
|
||||||
|
if (intro.dataset.stage !== 'splash') return;
|
||||||
|
e.preventDefault(); go('menu');
|
||||||
|
});
|
||||||
|
// Coming back from a run: the death screen reloads (an honest full reset) and asks, through
|
||||||
|
// sessionStorage, to land on the MENU rather than the splash — you have already seen the title.
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem('splatterStage') === 'menu') { sessionStorage.removeItem('splatterStage'); go('menu'); }
|
||||||
|
} catch {}
|
||||||
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
||||||
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
||||||
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
||||||
@@ -74,19 +137,29 @@ fetch('main.js', { method: 'HEAD' })
|
|||||||
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
||||||
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
||||||
.catch(() => {}); // no header, no line — never a broken start screen
|
.catch(() => {}); // no header, no line — never a broken start screen
|
||||||
// keyboard: any key opens the picker, then 1..N picks a court and Enter/Space takes the
|
// Keyboard, per stage: a key leaves the splash; on the court screen 1..N picks and Enter starts;
|
||||||
// highlighted one. Not `once` any more — it has two stages to get through.
|
// Escape backs out. Not `once` any more — there are four stages to get through.
|
||||||
addEventListener('keydown', e => {
|
addEventListener('keydown', e => {
|
||||||
if (!intro.parentNode) return;
|
if (!intro.parentNode) return;
|
||||||
if (!intro.classList.contains('picking')) { openPicker(); return; }
|
const stage = intro.dataset.stage;
|
||||||
|
if (stage === 'splash') return go('menu');
|
||||||
|
if (e.key === 'Escape') return go(stage === 'menu' ? 'splash' : 'menu');
|
||||||
|
if (stage === 'menu') { if (e.key === 'Enter' || e.key === ' ') go('courts'); return; }
|
||||||
|
if (stage === 'tutorial') { if (e.key === 'Enter' || e.key === ' ') tNext.click(); return; }
|
||||||
const n = parseInt(e.key, 10);
|
const n = parseInt(e.key, 10);
|
||||||
if (n >= 1 && n <= COURTS.length) start(COURTS[n - 1].id);
|
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
|
||||||
else if (e.key === 'Enter' || e.key === ' ') start(lastCourt || COURTS[0].id);
|
else if (e.key === 'Enter' || e.key === ' ') start();
|
||||||
});
|
});
|
||||||
|
|
||||||
// eaten: any tap or key restarts — but only once the death animation has played out, so a
|
// eaten: any tap or key restarts — but only once the death animation has played out, so a
|
||||||
// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
|
// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
|
||||||
const maybeRestart = () => { if (!G.modal && G.mode === 'eaten' && G.eatenT > EATEN_RESTART) location.reload(); }; // ponytail: reload = honest full reset
|
// The reload IS the reset (nothing here has to unwind a run by hand); the flag is what makes it
|
||||||
|
// come back to the menu instead of the title screen.
|
||||||
|
const maybeRestart = () => {
|
||||||
|
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
|
||||||
|
try { sessionStorage.setItem('splatterStage', 'menu'); } catch {}
|
||||||
|
location.reload(); // ponytail: reload = honest full reset
|
||||||
|
};
|
||||||
cv.addEventListener('pointerdown', maybeRestart);
|
cv.addEventListener('pointerdown', maybeRestart);
|
||||||
addEventListener('keydown', maybeRestart);
|
addEventListener('keydown', maybeRestart);
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function proj(x, y, z) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- sprites ----
|
// ---------------------------------------------------------------- sprites ----
|
||||||
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
|
||||||
const SPRV = 77; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
const SPRV = 78; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
|
||||||
const SPR = {};
|
const SPR = {};
|
||||||
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
|
||||||
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
|
||||||
@@ -954,11 +954,15 @@ export function render() {
|
|||||||
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
: pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
|
||||||
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
|
||||||
}
|
}
|
||||||
// machine-gun pellets: flat tracers with a tiny ground shadow
|
// Machine-gun rounds: they are TENNIS BALLS, fired small and hard, so they are drawn as balls and
|
||||||
|
// they take the perspective like everything else. A fixed 4x4 fillRect stayed exactly as big at
|
||||||
|
// the far baseline as at your feet, which read as square confetti pinned to the screen rather than
|
||||||
|
// as something travelling away from you. Radius is a shade over half the match ball's 0.09.
|
||||||
for (const pe of pellets) {
|
for (const pe of pellets) {
|
||||||
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
|
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
|
||||||
const pp = proj(pe.x, pe.y, pe.z);
|
const pp = proj(pe.x, pe.y, pe.z), r = Math.max(1, pp.s * 0.05);
|
||||||
ctx.fillStyle = UI.warm; ctx.fillRect((pp.x | 0) - 2, (pp.y | 0) - 2, 4, 4); // fat round — it hits like one
|
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(pp.x, pp.y, r, 0, 7); ctx.fill();
|
||||||
|
ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(pp.x, pp.y, Math.max(0.5, r * 0.45), 0, 7); ctx.fill(); // hot core: it reads sharp, not soft
|
||||||
}
|
}
|
||||||
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
|
// the chapter boss's shockwave: a ring of blood mist blown out through the crowd noise
|
||||||
if (G.bossBurst) {
|
if (G.bossBurst) {
|
||||||
|
|||||||
Reference in new issue
Block a user