File size: 12,890 Bytes
89f5399 3570fe8 89f5399 3570fe8 10c600e 89f5399 3570fe8 89f5399 3570fe8 10c600e 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 89f5399 3570fe8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 | // Title / pause overlay: a full-page ink screen printed in the landing's
// comic DA - big Anton title with the ink-drop + cyan/magenta aberration
// treatment, a comic-block CTA on an acid plate, and an orange halftone
// ramp pooling in the top-left corner. Colour and locomotion live in the
// in-game HUD quickbar, so the intro's only action is Waddle in.
//
// The first "Waddle in" cues the BIOS; later Esc / the in-game Back button
// reopen the overlay as pause ("Resume"). The ?boot=1 bypass lives in
// App.jsx and never mounts this component.
import { useEffect, useRef, useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { keyframes, styled } from "@mui/material/styles";
import { useGame } from "../store.js";
import { signed } from "../game/signed.js";
import { INK, ORANGE, MONO } from "../theme.js";
import { ComicButton, ComicTitle, HalftoneRamp, ANTON } from "./comic.jsx";
const rowIn = keyframes`
from { transform: translateY(12px); opacity: 0; }
to { transform: none; opacity: 1; }
`;
const brandIn = keyframes`
from { transform: translateY(10px) scale(0.94); opacity: 0; }
to { transform: none; opacity: 1; }
`;
// One soft ease, small travel, ~80 ms between rows - staged, not showy.
const row = (delay, name = rowIn) => ({
animation: `${name} 0.55s cubic-bezier(0.22, 1, 0.36, 1) both`,
animationDelay: `${delay}s`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
});
const Kbd = styled("kbd")(({ round }) => ({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: round ? "1.9rem" : "1.65rem",
height: "1.65rem",
padding: "0 0.45rem",
font: "inherit",
fontSize: "0.68rem",
fontWeight: 600,
color: "#fff",
background: "#14141c",
border: `2px solid ${INK}`,
borderRadius: round ? "50%" : 8,
boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
}));
const TILES = {
kb: [
{ caps: "cluster-arrows", name: "Move", hint: "arrows or ZQSD" },
{ caps: ["A", "E"], name: "Kick", hint: "left / right" },
{ caps: ["R"], name: "Sit", hint: "tap again to stand" },
{ caps: ["G"], name: "Pick up", hint: "beak to the ground" },
{ caps: ["C"], name: "Camera", hint: "toggle chase" },
{ caps: ["Space"], name: "Reset", hint: "fresh start" },
],
pad: [
{ caps: ["LS"], name: "Move", hint: "left stick" },
{ caps: ["LB", "RB"], name: "Kick", hint: "left / right" },
{ caps: ["Y"], name: "Head", hint: "sticks move the head" },
{ caps: ["RS"], name: "Camera", hint: "orbit" },
{ caps: ["X", "\u2193"], name: "Sit", hint: "tap again to stand" },
],
touch: [
{ caps: ["Stick"], name: "Move", hint: "left thumb" },
{ caps: ["A"], round: true, name: "Kick", hint: "tap it" },
{ caps: ["B"], round: true, name: "Quack", hint: "hold for beak" },
],
};
const HINTS = {
kb: "drag to orbit \u00b7 scroll to zoom",
pad: "A ground pick \u00b7 RT quack \u00b7 hold LT wheee \u00b7 R3 chase",
touch: "drag to orbit \u00b7 pinch to zoom",
};
function closeMenu() {
useGame.setState({ menuOpen: false });
if (!useGame.getState().entered) useGame.setState({ entered: true });
}
export default function TitleMenu() {
const menuOpen = useGame((s) => s.menuOpen);
const entered = useGame((s) => s.entered);
const padConnected = useGame((s) => s.padConnected);
const touchMode = useGame((s) => s.touchMode);
const [closing, setClosing] = useState(false);
const prevOpen = useRef(menuOpen);
// Keep the overlay mounted through the 0.35 s closing fade.
useEffect(() => {
const was = prevOpen.current;
prevOpen.current = menuOpen;
if (was && !menuOpen) {
setClosing(true);
const t = setTimeout(() => setClosing(false), 380);
return () => clearTimeout(t);
}
}, [menuOpen]);
// Enter enters / resumes; Esc toggles the pause menu (never over the
// BIOS readout).
useEffect(() => {
const onKey = (e) => {
const s = useGame.getState();
if (e.code === "Enter" && s.menuOpen) {
if (e.target instanceof HTMLButtonElement && e.target.dataset.cta !== "1") return;
e.preventDefault();
closeMenu();
return;
}
if (e.code !== "Escape") return;
if (s.biosVisible) return;
if (s.menuOpen) {
if (s.entered) closeMenu();
return;
}
if (s.entered) useGame.setState({ menuOpen: true });
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
if (!menuOpen && !closing) return null;
// A plugged-in gamepad wins over the touch tutorial.
const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
const tiles = TILES[tutorialVariant];
const ctaLabel = entered ? "Resume" : "Waddle in";
return (
<Box
role="dialog"
aria-modal="true"
aria-label="Microduck"
sx={{
position: "fixed",
inset: 0,
zIndex: 30,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
p: "2.4rem 1.4rem 1.8rem",
background: INK,
opacity: menuOpen ? 1 : 0,
pointerEvents: menuOpen ? "auto" : "none",
overflowY: "auto",
transition: "opacity 0.35s ease, background 0.4s ease",
}}
>
{/* Theme-orange halftone pooling in the top-left corner, dots
growing toward it - the landing's screen-tone on ink ground. */}
<HalftoneRamp
color="rgba(255, 122, 47, 0.16)"
size={20}
corner="top-left"
reach={60}
/>
<Box
sx={{
position: "relative",
width: "100%",
maxWidth: "min(48rem, 92vw)",
textAlign: "center",
m: "auto 0",
}}
>
{/* Vitrine brand lockup: drawn duck head + name. Hovering swaps to
the open-beak frame, same hard sprite swap as the vitrine. */}
<Box
aria-hidden
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
mb: "1.15rem",
userSelect: "none",
...row(0, brandIn),
"&:hover .duck-closed": { opacity: 0 },
"&:hover .duck-open": { opacity: 1 },
}}
>
<Box
component="span"
sx={{
position: "relative",
display: "block",
height: { xs: "2.4rem", sm: "3rem" },
filter: "drop-shadow(3px 3px 0 rgba(0, 0, 0, 0.5))",
}}
>
<Box
component="img"
className="duck-closed"
alt=""
src={signed("./assets/duck-head-mark.webp")}
sx={{ display: "block", height: "100%", width: "auto" }}
/>
{/* The open frame's canvas (460x333) is a hair wider/taller
than the closed one (454x269); offsets pin the head while
the jaw hangs below. */}
<Box
component="img"
className="duck-open"
alt=""
src={signed("./assets/duck-head-mark-open.webp")}
sx={{
position: "absolute",
top: "-0.75%",
left: "-1.1%",
width: "101.3%",
maxWidth: "none",
height: "auto",
opacity: 0,
}}
/>
</Box>
</Box>
{/* The landing hero's mistracked-VHS treatment: orange fill with
ink drop + chroma ghosts, hollow echo line below. */}
<ComicTitle
component="h1"
tone="dark"
accent={ORANGE}
fontSize="clamp(3.1rem, 10vw, 5.4rem)"
lines={[
{ text: "Microduck" },
{ text: "Simulator", variant: "outline", scale: 0.6 },
]}
sx={{ ...row(0.08) }}
/>
<Typography
sx={{
mx: "auto",
mt: "1.1rem",
"@media (max-height: 700px)": { mt: "0.8rem" },
maxWidth: "36ch",
fontSize: { xs: "0.95rem", sm: "1.05rem" },
lineHeight: 1.5,
letterSpacing: "-0.012em",
color: "rgba(255, 255, 255, 0.72)",
textWrap: "balance",
...row(0.24),
}}
>
The exact same trained policies that drive the real robot,
live in your browser.
</Typography>
<Box
sx={{
display: "grid",
// Three columns from ~500px up (two rows of tiles); narrow
// phones fall back to two.
gridTemplateColumns:
tutorialVariant === "touch" ? "repeat(3, 1fr)" : "repeat(2, 1fr)",
"@media (min-width: 500px)": {
gridTemplateColumns: "repeat(3, 1fr)",
},
gap: "0.55rem",
m: "1.35rem auto 0",
maxWidth: "36rem",
...row(0.32),
}}
>
{tiles.map((t) => (
<Box
key={t.name}
sx={{
p: { xs: "0.65rem 0.35rem 0.6rem", sm: "0.8rem 0.45rem 0.7rem" },
border: "1px solid rgba(255, 255, 255, 0.09)",
background: "rgba(255, 255, 255, 0.035)",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "0.2rem",
height: { xs: "2.8rem", sm: "3.3rem" },
// Arrow-cluster keycaps are a notch smaller so two rows
// fit.
"& .cluster kbd": {
minWidth: "1.4rem",
height: "1.4rem",
p: "0 0.3rem",
fontSize: "0.62rem",
},
}}
>
{t.caps === "cluster-arrows" ? (
<Box
className="cluster"
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.2rem",
}}
>
<Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
<Kbd>{"\u2191"}</Kbd>
</Box>
<Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
<Kbd>{"\u2190"}</Kbd>
<Kbd>{"\u2193"}</Kbd>
<Kbd>{"\u2192"}</Kbd>
</Box>
</Box>
) : (
<Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
{t.caps.map((c) => (
<Kbd key={c} round={t.round ? 1 : 0}>{c}</Kbd>
))}
</Box>
)}
</Box>
<Box
sx={{
mt: "0.5rem",
fontFamily: ANTON,
fontSize: "0.8rem",
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "#fff",
}}
>
{t.name}
</Box>
<Box
sx={{
mt: "0.16rem",
fontSize: "0.6rem",
fontWeight: 600,
letterSpacing: "0.11em",
textTransform: "uppercase",
color: ORANGE,
}}
>
{t.hint}
</Box>
</Box>
))}
</Box>
<Typography
sx={{
mt: "0.85rem",
fontFamily: MONO,
fontSize: "0.64rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "rgba(255, 255, 255, 0.38)",
...row(0.4),
}}
>
{HINTS[tutorialVariant]}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "center",
mt: "1.7rem",
"@media (max-height: 700px)": { mt: "1.2rem" },
...row(0.48),
}}
>
<ComicButton
scheme="orange"
size="medium"
onDark
data-cta="1"
onClick={closeMenu}
>
{ctaLabel}
</ComicButton>
</Box>
</Box>
</Box>
);
}
|