Download app/src/ui/TitleMenu.jsx from pollen-robotics/microduck-simulator: direct link, hf CLI and curl.
- Browser
- Download file 26.5 kB
-
https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/5d813593e1e166b359ac7aa13163ac68a3de776a/app/src/ui/TitleMenu.jsx
- Command line
-
hf download hf://spaces/pollen-robotics/microduck-simulator@5d813593e1e166b359ac7aa13163ac68a3de776a/app/src/ui/TitleMenu.jsx
-
curl -L -o TitleMenu.jsx https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/5d813593e1e166b359ac7aa13163ac68a3de776a/app/src/ui/TitleMenu.jsx
26.5 kB
| // 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 { playConfirmBloop } from "../game/audio.js"; | |
| import { INK, ORANGE, MONO } from "../theme.js"; | |
| import { ComicButton, ComicTitle, HalftoneRamp, ANTON, CREAM } from "./comic.jsx"; | |
| import MenuDuck, { preloadMenuDuck, isMenuDuckReady } from "./MenuDuck.jsx"; | |
| import { PreorderButton } from "./Hud.jsx"; | |
| import { readLayoutMap, resolveKeycaps } from "./keyboard-layout.js"; | |
| 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; } | |
| `; | |
| // Title-screen blink for the "press Enter" prompt - a soft pulse, not a | |
| // hard arcade strobe, so it invites rather than nags. | |
| const promptPulse = keyframes` | |
| from { opacity: 0.8; } | |
| to { opacity: 0.18; } | |
| `; | |
| const spin = keyframes` | |
| to { transform: rotate(360deg); } | |
| `; | |
| // 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)", | |
| })); | |
| // Arcade-style cheat strip pinned at the bottom of the title screen: one | |
| // keycap group + label per move, scannable in a second. The CTA is the | |
| // screen's centre of gravity - players enter first and learn in game. | |
| // Controls bind physical KeyboardEvent.code positions, so letter keycaps | |
| // carry `codes` and get their printed label resolved for the active layout | |
| // (QWERTY fallback where the Keyboard Map API is unavailable). | |
| const SHORTCUTS = { | |
| kb: [ | |
| { caps: ["\u2191\u2190\u2193\u2192"], name: "Move" }, | |
| { codes: ["KeyQ", "KeyE"], name: "Kick" }, | |
| { codes: ["KeyR"], name: "Sit" }, | |
| { codes: ["KeyG"], name: "Pick up" }, | |
| { codes: ["KeyC"], name: "Camera" }, | |
| { caps: ["Space"], name: "Reset" }, | |
| ], | |
| pad: [ | |
| { caps: ["LS"], name: "Move" }, | |
| { caps: ["LB", "RB"], name: "Kick" }, | |
| { caps: ["Y"], name: "Head" }, | |
| { caps: ["RS"], name: "Camera" }, | |
| { caps: ["X", "\u2193"], name: "Sit" }, | |
| ], | |
| touch: [ | |
| { caps: ["Stick"], name: "Move" }, | |
| { caps: ["A"], round: true, name: "Kick" }, | |
| { caps: ["B"], round: true, name: "Quack" }, | |
| ], | |
| }; | |
| // kb's hint is built at render time from the resolved layout (see kbHint). | |
| const HINTS = { | |
| pad: "A ground pick \u00b7 RT quack \u00b7 hold LT wheee \u00b7 R3 chase", | |
| touch: "drag to orbit \u00b7 pinch to zoom", | |
| }; | |
| // Per-colourway screen accent: the title fill and the halftone pool | |
| // follow the duck on stage. Cream keeps the landing's brand orange (the | |
| // classic duck's trim colour); lavender and sky use the press-kit swatch | |
| // hexes (shared with the vitrine picker); graphite's swatch is too dark | |
| // for type on ink, so it wears the palette's light warm gray instead. | |
| const VARIANT_ACCENTS = { | |
| classic: ORANGE, | |
| charcoal: "#b9b5ae", | |
| purple: "#bfa9cf", | |
| blue: "#a9dbe8", | |
| }; | |
| // #rrggbb -> rgba() at the halftone's alpha. | |
| const tint = (hex, a) => | |
| `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`; | |
| const KB_CODES = SHORTCUTS.kb.flatMap((t) => t.codes ?? []); | |
| function useKeycaps(enabled) { | |
| const [keycaps, setKeycaps] = useState(() => resolveKeycaps(KB_CODES, null)); | |
| useEffect(() => { | |
| if (!enabled) return; | |
| let cancelled = false; | |
| let latest = 0; | |
| const read = async () => { | |
| const ticket = ++latest; | |
| const map = await readLayoutMap(); | |
| // Overlapping reads can settle out of order; only the newest paints. | |
| if (!cancelled && ticket === latest) setKeycaps(resolveKeycaps(KB_CODES, map)); | |
| }; | |
| read(); | |
| const keyboard = navigator.keyboard; | |
| keyboard?.addEventListener?.("layoutchange", read); | |
| return () => { | |
| cancelled = true; | |
| keyboard?.removeEventListener?.("layoutchange", read); | |
| }; | |
| }, [enabled]); | |
| return keycaps; | |
| } | |
| function closeMenu() { | |
| useGame.setState({ menuOpen: false }); | |
| if (!useGame.getState().entered) useGame.setState({ entered: true }); | |
| // Menu-confirm bloop. On the first entry the `entered` latch above has | |
| // just created the shared AudioContext inside this very gesture, so the | |
| // bloop rides the unlock (the synth resume-retries if still suspended). | |
| playConfirmBloop(); | |
| } | |
| 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 variant = useGame((s) => s.variant); | |
| const bootDone = useGame((s) => s.bootDone); | |
| const bootFailed = useGame((s) => s.bootFailed); | |
| const [closing, setClosing] = useState(false); | |
| const prevOpen = useRef(menuOpen); | |
| // A plugged-in gamepad wins over the touch tutorial. | |
| const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb"; | |
| const { labels, moveHint } = useKeycaps(tutorialVariant === "kb"); | |
| // Touch devices and narrow (mobile-sized) screens skip the 3D stage | |
| // entirely: no duck column, and the GLB is never even downloaded. The | |
| // width check matches the layout's md breakpoint, so the stage never | |
| // renders where the column would stack under the text anyway. Everyone | |
| // else holds a spinner until the stage's assets are in, so the content | |
| // reveal and the duck's entrance scan cue together. The stage is a | |
| // module singleton, so pause reopens resolve instantly (no second | |
| // spinner). | |
| const [wide, setWide] = useState( | |
| () => window.matchMedia("(min-width: 900px)").matches, | |
| ); | |
| useEffect(() => { | |
| const mq = window.matchMedia("(min-width: 900px)"); | |
| const onChange = () => setWide(mq.matches); | |
| mq.addEventListener("change", onChange); | |
| return () => mq.removeEventListener("change", onChange); | |
| }, []); | |
| const showDuck = !touchMode && wide; | |
| const [duckReady, setDuckReady] = useState(isMenuDuckReady); | |
| useEffect(() => { | |
| if (!showDuck || duckReady) return; | |
| let on = true; | |
| preloadMenuDuck() | |
| .catch(() => {}) | |
| .then(() => { if (on) setDuckReady(true); }); | |
| return () => { on = false; }; | |
| }, [showDuck, duckReady]); | |
| // The display face too: Anton is the screen's voice (title, CTA, | |
| // strip) and would otherwise FOUT in after the reveal. It loads | |
| // lazily on first use, so nudge it explicitly - the content is gated | |
| // and would never request it. Timeout so a stuck font never wedges | |
| // the menu behind the spinner. | |
| const [fontsReady, setFontsReady] = useState( | |
| () => !!document.fonts?.check?.("1rem Anton"), | |
| ); | |
| useEffect(() => { | |
| if (fontsReady) return; | |
| let on = true; | |
| const done = () => { if (on) setFontsReady(true); }; | |
| const anton = document.fonts?.load | |
| ? document.fonts.load("1rem Anton") | |
| : Promise.resolve(); | |
| const allFonts = document.fonts?.ready ?? Promise.resolve(); | |
| Promise.all([anton, allFonts]).then(done, done); | |
| const t = setTimeout(done, 4000); | |
| return () => { on = false; clearTimeout(t); }; | |
| }, [fontsReady]); | |
| // The brand lockup images too (formerly the preboot veil's job): the | |
| // corner chrome is part of the gated reveal, so the logo must not pop | |
| // in after it. Capped so a broken asset never wedges the menu. | |
| const [brandReady, setBrandReady] = useState(false); | |
| useEffect(() => { | |
| if (brandReady) return; | |
| let on = true; | |
| const done = () => { if (on) setBrandReady(true); }; | |
| const load = (src) => | |
| new Promise((res) => { | |
| const img = new Image(); | |
| img.src = signed(src); | |
| if (img.complete && img.naturalWidth) res(); | |
| else { | |
| img.addEventListener("load", res, { once: true }); | |
| img.addEventListener("error", res, { once: true }); | |
| } | |
| }); | |
| Promise.all([ | |
| load("./assets/duck-head-mark.webp"), | |
| load("./assets/duck-head-mark-open.webp"), | |
| ]).then(done, done); | |
| const t = setTimeout(done, 2500); | |
| return () => { on = false; clearTimeout(t); }; | |
| }, [brandReady]); | |
| // The game core too: it boots eagerly behind the title screen (MuJoCo | |
| // WASM, scene geometry, policy sessions), saturating the main thread | |
| // in ~10-40 ms chunks for a couple of seconds. Revealing the menu | |
| // mid-boot made the duck's entrance scan stutter (profiled: the scan | |
| // window sat inside the boot's busy region), so the spinner holds | |
| // until the boot settles - the reveal then animates on a free thread | |
| // and Waddle-in is instant. bootFailed releases the gate: the halt | |
| // screen owns the viewport at that point. | |
| const ready = | |
| (duckReady || !showDuck) && | |
| fontsReady && | |
| brandReady && | |
| (bootDone || bootFailed); | |
| // 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); | |
| }, []); | |
| // Gamepad twin of the handler above: A (button 0) enters / resumes | |
| // through the same closeMenu() as the CTA click; Start (9) or Select (8) | |
| // reopens the overlay in-game, like Escape. Polled with a light rAF loop | |
| // because the game's own gamepad source only exists once the core has | |
| // booted (and its actions are game-facing). Edge state persists across | |
| // the open/close transition, so one press fires exactly one action. | |
| useEffect(() => { | |
| const prev = { enter: false, menu: false }; | |
| // The A press that closes the menu is applied one frame LATE on | |
| // purpose: closing unlocks the game inputs synchronously, and the | |
| // in-game gamepad source polls in the same rAF round - if it sees the | |
| // press only after the unlock, its own A edge fires (groundPick). The | |
| // one-frame delay guarantees it polls the press at least once while | |
| // the pause lock still gates it, so by the unlock frame its edge | |
| // state already knows the button is down and the entering press is | |
| // swallowed. | |
| let enterPending = false; | |
| let raf; | |
| const poll = () => { | |
| raf = requestAnimationFrame(poll); | |
| if (enterPending) { | |
| enterPending = false; | |
| if (useGame.getState().menuOpen) closeMenu(); | |
| } | |
| // Same pad preference as controls/gamepad.js: standard mapping first. | |
| const pads = [...(navigator.getGamepads?.() ?? [])].filter((p) => p && p.connected); | |
| const gp = pads.find((p) => p.mapping === "standard") ?? pads[0]; | |
| if (!gp) { | |
| prev.enter = false; | |
| prev.menu = false; | |
| return; | |
| } | |
| const enter = !!gp.buttons[0]?.pressed; | |
| const menu = !!(gp.buttons[9]?.pressed || gp.buttons[8]?.pressed); | |
| const enterEdge = enter && !prev.enter; | |
| const menuEdge = menu && !prev.menu; | |
| prev.enter = enter; | |
| prev.menu = menu; | |
| const s = useGame.getState(); | |
| if (enterEdge && s.menuOpen) { | |
| enterPending = true; | |
| return; | |
| } | |
| if (menuEdge && !s.menuOpen && s.entered && !s.biosVisible) | |
| useGame.setState({ menuOpen: true }); | |
| }; | |
| raf = requestAnimationFrame(poll); | |
| return () => cancelAnimationFrame(raf); | |
| }, []); | |
| if (!menuOpen && !closing) return null; | |
| // Letter keycaps resolved for the active layout (QWERTY fallback). | |
| const shortcuts = | |
| tutorialVariant === "kb" | |
| ? SHORTCUTS.kb.map((t) => | |
| t.codes ? { ...t, caps: t.codes.map((code) => labels[code]) } : t, | |
| ) | |
| : SHORTCUTS[tutorialVariant]; | |
| // The strip's footnote names the letter movement cluster for the active | |
| // layout too ("ZQSD works too" reads wrong on QWERTY and Dvorak). | |
| const kbHint = `${moveHint.replace(/^arrows or /, "")} works too \u00b7 drag to orbit \u00b7 scroll to zoom`; | |
| const ctaLabel = entered ? "Resume" : "Waddle in"; | |
| // Blinking key prompt under the CTA; touch has no key to press. | |
| const enterHint = padConnected ? "press A" : touchMode ? null : "press Enter"; | |
| const accent = VARIANT_ACCENTS[variant] ?? ORANGE; | |
| 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={tint(accent, 0.16)} | |
| size={20} | |
| corner="top-left" | |
| reach={60} | |
| /> | |
| {/* Corner chrome, shared grammar with the vitrine header (drawn | |
| duck head, top-left) and the in-game HUD (pre-order, top-right): | |
| the title screen wears the same screen furniture as the game. | |
| Gated with the rest of the reveal so the loading state is a | |
| single centered spinner on bare ink. */} | |
| {ready && ( | |
| <Box | |
| aria-hidden | |
| sx={{ | |
| position: "absolute", | |
| top: "1.25rem", | |
| left: "1.5rem", | |
| zIndex: 1, | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "0.65rem", | |
| userSelect: "none", | |
| ...row(0, brandIn), | |
| "&:hover .duck-closed": { opacity: 0 }, | |
| "&:hover .duck-open": { opacity: 1 }, | |
| }} | |
| > | |
| <Box | |
| component="span" | |
| sx={{ | |
| position: "relative", | |
| display: "block", | |
| height: "2.1rem", | |
| filter: "drop-shadow(2px 2px 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 | |
| component="span" | |
| sx={{ | |
| fontFamily: ANTON, | |
| fontSize: "1rem", | |
| letterSpacing: "0.05em", | |
| textTransform: "uppercase", | |
| color: "#fff", | |
| display: { xs: "none", sm: "block" }, | |
| }} | |
| > | |
| Microduck | |
| </Box> | |
| </Box> | |
| )} | |
| {/* The in-game HUD's shop plate, verbatim: same component, same | |
| fixed spot, so pausing never swaps it for a lookalike. */} | |
| {ready && <PreorderButton sx={{ ...row(0.1) }} />} | |
| {/* Loading gate: one centered spinner on bare ink until logo, | |
| fonts, the duck stage's assets and the game boot are all in - | |
| then the whole composition (corner chrome included) reveals at | |
| once (row stagger + entrance scan). */} | |
| {!ready && ( | |
| <Box | |
| role="status" | |
| aria-label="Loading" | |
| sx={{ | |
| m: "auto", | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| gap: "1.1rem", | |
| }} | |
| > | |
| <Box | |
| aria-hidden | |
| sx={{ | |
| width: "2.4rem", | |
| height: "2.4rem", | |
| borderRadius: "50%", | |
| border: "3px solid rgba(255, 255, 255, 0.14)", | |
| borderTopColor: accent, | |
| animation: `${spin} 0.8s linear infinite`, | |
| }} | |
| /> | |
| <Typography | |
| sx={{ | |
| fontFamily: MONO, | |
| fontSize: "0.64rem", | |
| fontWeight: 600, | |
| letterSpacing: "0.16em", | |
| textTransform: "uppercase", | |
| color: "rgba(255, 255, 255, 0.5)", | |
| }} | |
| > | |
| Loading | |
| </Typography> | |
| </Box> | |
| )} | |
| {/* Split title screen: brand + CTA column on the left, the live | |
| duck stage on the right (stacked on narrow screens). The row | |
| stretches to fill the space between the corner chrome and the | |
| bottom strip, so the duck stands ON the strip's rule - the | |
| screen has a floor instead of elements floating mid-air. */} | |
| {ready && ( | |
| <> | |
| <Box | |
| sx={{ | |
| position: "relative", | |
| display: "flex", | |
| flexDirection: { xs: "column", md: "row" }, | |
| alignItems: "stretch", | |
| justifyContent: "center", | |
| gap: { xs: "0.4rem", md: "1.5rem" }, | |
| width: "100%", | |
| maxWidth: "min(66rem, 94vw)", | |
| flex: "1 1 auto", | |
| minHeight: 0, | |
| pt: { xs: "2.6rem", md: 0 }, | |
| }} | |
| > | |
| <Box | |
| sx={{ | |
| position: "relative", | |
| flex: { md: "0 1 33rem" }, | |
| width: "100%", | |
| maxWidth: { xs: "min(48rem, 92vw)", md: "none" }, | |
| mx: { xs: "auto", md: 0 }, | |
| // Game-menu grammar: the text block rides the column's vertical | |
| // centre on desktop; left-anchored there, centered when stacked | |
| // or when the duck column is absent (touch mode). | |
| display: "flex", | |
| flexDirection: "column", | |
| justifyContent: "center", | |
| alignItems: { xs: "center", md: showDuck ? "flex-start" : "center" }, | |
| textAlign: { xs: "center", md: showDuck ? "left" : "center" }, | |
| }} | |
| > | |
| {/* The landing hero's mistracked-VHS treatment: orange fill with | |
| ink drop + chroma ghosts, hollow echo line below. */} | |
| <ComicTitle | |
| component="h1" | |
| tone="dark" | |
| accent={accent} | |
| fontSize="clamp(3.4rem, 9vw, 6.2rem)" | |
| lines={[ | |
| { text: "Microduck" }, | |
| { text: "Simulator", variant: "outline", scale: 0.78 }, | |
| ]} | |
| sx={{ ...row(0.08) }} | |
| /> | |
| <Typography | |
| sx={{ | |
| mx: { xs: "auto", md: 0 }, | |
| mt: "1.1rem", | |
| "@media (max-height: 700px)": { mt: "0.8rem" }, | |
| maxWidth: "34ch", | |
| 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> | |
| {/* CTA + key prompt travel as one block so the prompt stays | |
| centred under the button whatever the column's alignment. */} | |
| <Box | |
| sx={{ | |
| display: "inline-flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| mt: "2rem", | |
| "@media (max-height: 700px)": { mt: "1.3rem" }, | |
| ...row(0.32), | |
| }} | |
| > | |
| <ComicButton | |
| scheme="orange" | |
| size="medium" | |
| onDark | |
| data-cta="1" | |
| onClick={closeMenu} | |
| > | |
| {/* Pad prompt: ink circle with the A face button, shown while a | |
| gamepad is connected - it mirrors the A-to-enter binding of | |
| the poll loop above. Decorative; the text label carries the | |
| meaning. */} | |
| {padConnected && ( | |
| <Box | |
| component="span" | |
| aria-hidden | |
| sx={{ | |
| display: "inline-flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| width: "1.5em", | |
| height: "1.5em", | |
| borderRadius: "50%", | |
| background: INK, | |
| color: CREAM, | |
| fontSize: "0.72em", | |
| lineHeight: 1, | |
| }} | |
| > | |
| A | |
| </Box> | |
| )} | |
| {ctaLabel} | |
| </ComicButton> | |
| {enterHint && ( | |
| <Typography | |
| aria-hidden | |
| sx={{ | |
| mt: "0.85rem", | |
| fontFamily: MONO, | |
| fontSize: "0.66rem", | |
| fontWeight: 600, | |
| letterSpacing: "0.14em", | |
| textTransform: "uppercase", | |
| color: "rgba(255, 255, 255, 0.6)", | |
| ...row(0.4), | |
| }} | |
| > | |
| <Box | |
| component="span" | |
| sx={{ | |
| display: "inline-block", | |
| animation: `${promptPulse} 0.95s ease-in-out infinite alternate`, | |
| "@media (prefers-reduced-motion: reduce)": { | |
| animation: "none", | |
| opacity: 0.6, | |
| }, | |
| }} | |
| > | |
| {enterHint} | |
| </Box> | |
| </Typography> | |
| )} | |
| </Box> | |
| </Box> | |
| {/* Right column: the duck's simulated twin, live - head follows the | |
| pointer, click quacks. Its colourway tracks the in-game duck. No | |
| backdrop, no fake shadow: the render alone, standing on the | |
| bottom strip's rule like a character select. Touch devices skip | |
| the stage entirely (no column, no GLB download). */} | |
| {showDuck && ( | |
| <Box | |
| sx={{ | |
| position: "relative", | |
| flex: { xs: "1 1 auto", md: "1 1 22rem" }, | |
| width: "100%", | |
| minHeight: { xs: "min(30vh, 16rem)", md: 0 }, | |
| ...row(0.16, brandIn), | |
| }} | |
| > | |
| <MenuDuck sx={{ position: "absolute", inset: 0 }} /> | |
| </Box> | |
| )} | |
| </Box> | |
| {/* Bottom cheat strip: every move on one wrapping line, arcade | |
| instruction-card style. Reference material, not the main event - | |
| it sits at the screen's bottom edge, out of the CTA's way. */} | |
| <Box | |
| sx={{ | |
| position: "relative", | |
| width: "100%", | |
| textAlign: "center", | |
| mt: "1.2rem", | |
| pt: "1.15rem", | |
| borderTop: "1px solid rgba(255, 255, 255, 0.08)", | |
| ...row(0.5), | |
| }} | |
| > | |
| <Box | |
| sx={{ | |
| display: "flex", | |
| flexWrap: "wrap", | |
| justifyContent: "center", | |
| alignItems: "center", | |
| gap: "0.55rem 1.25rem", | |
| // Strip keycaps are a notch smaller than the CTA scale. | |
| "& kbd": { | |
| minWidth: "1.45rem", | |
| height: "1.45rem", | |
| p: "0 0.34rem", | |
| fontSize: "0.6rem", | |
| }, | |
| }} | |
| > | |
| {shortcuts.map((s) => ( | |
| <Box | |
| key={s.name} | |
| sx={{ | |
| display: "inline-flex", | |
| alignItems: "center", | |
| gap: "0.45rem", | |
| }} | |
| > | |
| <Box sx={{ display: "inline-flex", gap: "0.22rem" }}> | |
| {/* Index keys: exotic layouts can print the same glyph twice. */} | |
| {s.caps.map((c, i) => ( | |
| <Kbd key={i} round={s.round ? 1 : 0}>{c}</Kbd> | |
| ))} | |
| </Box> | |
| <Box | |
| component="span" | |
| sx={{ | |
| fontFamily: ANTON, | |
| fontSize: "0.74rem", | |
| letterSpacing: "0.07em", | |
| textTransform: "uppercase", | |
| color: "rgba(255, 255, 255, 0.88)", | |
| }} | |
| > | |
| {s.name} | |
| </Box> | |
| </Box> | |
| ))} | |
| </Box> | |
| <Typography | |
| sx={{ | |
| mt: "0.7rem", | |
| fontFamily: MONO, | |
| fontSize: "0.62rem", | |
| fontWeight: 600, | |
| letterSpacing: "0.12em", | |
| textTransform: "uppercase", | |
| color: "rgba(255, 255, 255, 0.34)", | |
| }} | |
| > | |
| {tutorialVariant === "kb" ? kbHint : HINTS[tutorialVariant]} | |
| </Typography> | |
| </Box> | |
| </> | |
| )} | |
| </Box> | |
| ); | |
| } | |