// 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 MenuReachy, { preloadMenuReachy, isMenuReachyReady } from "./MenuReachy.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 [reachyReady, setReachyReady] = useState(isMenuReachyReady); const [duckReady, setDuckReady] = useState(isMenuDuckReady); useEffect(() => { if (!showDuck || (reachyReady && duckReady)) return; let on = true; Promise.all([preloadMenuReachy().catch(() => {}), preloadMenuDuck().catch(() => {})]) .catch(() => {}) .then(() => { if (on) { setReachyReady(true); setDuckReady(true); } }); return () => { on = false; }; }, [showDuck, reachyReady, 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 = ((reachyReady && 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 ( {/* Theme-orange halftone pooling in the top-left corner, dots growing toward it - the landing's screen-tone on ink ground. */} {/* 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 && ( {/* 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. */} Microduck )} {/* The in-game HUD's shop plate, verbatim: same component, same fixed spot, so pausing never swaps it for a lookalike. */} {ready && } {/* 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 && ( Loading )} {/* 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 && ( <> {/* The landing hero's mistracked-VHS treatment: orange fill with ink drop + chroma ghosts, hollow echo line below. */} The exact same trained policies that drive the real robot, live in your browser. {/* CTA + key prompt travel as one block so the prompt stays centred under the button whatever the column's alignment. */} {/* 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 && ( A )} {ctaLabel} {enterHint && ( {enterHint} )} {/* 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 && ( )} {/* 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. */} {shortcuts.map((s) => ( {/* Index keys: exotic layouts can print the same glyph twice. */} {s.caps.map((c, i) => ( {c} ))} {s.name} ))} {tutorialVariant === "kb" ? kbHint : HINTS[tutorialVariant]} )} ); }