// In-game HUD: Back (top-left), pre-order button (top-right, label flips // to "Pre-order pack" while rollers are selected - the rollers ship in the // accessory pack), quickbar (bottom-left: colour palette + loco switch), // telemetry stack (bottom-right) and the LOADING ROLLERS line while the // roller stack streams in. // // Chrome reads as small comic PANELS: each group sits in a thick cream // keyline frame on a dark glass plate (1px ink inset between glass and // frame), with a caption box (cartouche) overlapping the frame's corner - // flat cream (orange for the shop CTA) fill, ink keyline, hard 2px offset // shadow, half in / half out like a caption on a comic panel's edge. Same // tokens as the landing (Anton, orange, cream), zero radius everywhere. // Title-menu CTAs stay ComicButton. // // Whole HUD is hidden while the title/pause overlay is up; touch mode // strips it down to the thumbs + Back. import { useEffect, useRef, useState } from "react"; import Box from "@mui/material/Box"; import { keyframes } from "@mui/material/styles"; import { useGame, gameApi } from "../store.js"; import { VARIANT_LABELS, VARIANT_SWATCH_HEX } from "../game/variants.js"; import { uiClick, isMuted, setMuted } from "../game/audio.js"; import { ORANGE, MONO } from "../theme.js"; import { ANTON, COMIC_INK, CREAM } from "./comic.jsx"; // Matrix-style letter scramble: on change every glyph flips through random // charset entries, then locks to its target left-to-right over ~0.45 s. // Monospace keeps the width stable mid-scramble. const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*<>/=+"; function useScramble(target) { const [text, setText] = useState(target.toUpperCase()); const timer = useRef(null); const shown = useRef(target.toUpperCase()); useEffect(() => { const t = target.toUpperCase(); if (shown.current === t) return; shown.current = t; if (timer.current) clearInterval(timer.current); const n = t.length; const DUR = 450; // ms until the last letter locks const rnd = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]; const t0 = performance.now(); timer.current = setInterval(() => { const k = Math.floor(((performance.now() - t0) / DUR) * n); if (k >= n) { clearInterval(timer.current); timer.current = null; setText(t); return; } let out = t.slice(0, k); for (let i = k; i < n; i++) out += rnd(); setText(out); }, 40); return () => timer.current && clearInterval(timer.current); }, [target]); return text; } const recBlink = keyframes` 0%, 58% { opacity: 1; } 59%, 100% { opacity: 0.12; } `; const chipPop = keyframes` 0% { transform: scale(0.86); } 55% { transform: scale(1.1); } 100% { transform: scale(1); } `; const HUD_H = 48; const FRAME_W = 2; // cream keyline thickness const PANEL_PAD = 5; // air between the frame and the printed content // Height left for the cells inside frame + padding; colour swatches use it // as their width too so they stay square. const CELL_H = HUD_H - 2 * (FRAME_W + PANEL_PAD); const CELL_GAP = 4; // gap between cells; the glass ground shows through const FRAME = "rgba(250, 248, 242, 0.9)"; const GLASS = "rgba(8, 8, 12, 0.6)"; // The panel frame: a thick cream keyline (comic ink weight, HUD read) // around a dark glass plate, with a 1px ink inset between frame and glass // so it reads like a printed panel, and a few px of glass padding so the // content has air inside the frame (the glass ground fills the padding). // The inset is a pseudo-element so it draws over the cells (an inset // box-shadow would be painted under them). Overflow clipping lives on the // inner content wrapper, not the plate, so the caption box can hang over // the frame's corner. const plateSx = { position: "relative", display: "inline-flex", height: HUD_H, boxSizing: "border-box", border: `${FRAME_W}px solid ${FRAME}`, padding: `${PANEL_PAD}px`, borderRadius: 0, background: GLASS, "&::after": { content: '""', position: "absolute", inset: 0, pointerEvents: "none", zIndex: 2, boxShadow: `inset 0 0 0 1px ${COMIC_INK}`, }, }; // Caption box (cartouche) printed over the panel's top corner, half in / // half out - the landing's print language: flat fill, ink keyline, hard // offset shadow, zero radius. Decorative only: no pointer events, no // layout footprint (absolute over the frame). const CAPTION_FILLS = { cream: CREAM, orange: ORANGE }; const captionBaseSx = { position: "absolute", top: -9, zIndex: 3, pointerEvents: "none", userSelect: "none", whiteSpace: "nowrap", fontFamily: ANTON, fontSize: "0.58rem", letterSpacing: "0.09em", textTransform: "uppercase", lineHeight: 1, padding: "3px 6px 2px", color: COMIC_INK, border: `2px solid ${COMIC_INK}`, borderRadius: 0, boxShadow: `2px 2px 0 ${COMIC_INK}`, }; function HudPlate({ caption, captionSide = "left", captionFill = "cream", children, sx }) { return ( {children} {caption ? ( {caption} ) : null} ); } // Speaker icon in the same square-stroke language as the back arrow: // body + cone always, waves when live, a hard ink cross when muted. function SpeakerIcon({ muted }) { return ( {muted ? ( ) : ( <> )} ); } function BackArrowIcon() { return ( ); } const hudHitSx = { appearance: "none", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "0.45em", height: "100%", px: "0.95rem", margin: 0, border: "none", borderRadius: 0, background: "transparent", color: CREAM, cursor: "pointer", fontFamily: ANTON, fontSize: "0.92rem", letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none", lineHeight: 1, whiteSpace: "nowrap", WebkitTapHighlightColor: "transparent", userSelect: "none", transition: "background 0.15s ease, color 0.15s ease, filter 0.15s ease", "&:focus-visible": { outline: `2px dashed ${CREAM}`, outlineOffset: -5, }, }; function BackButton() { return ( { uiClick(); useGame.setState({ menuOpen: true }); }} sx={{ ...hudHitSx, "&:hover": { color: ORANGE, background: "rgba(255, 122, 47, 0.12)", }, "&:active": { filter: "brightness(0.92)" }, }} > Back ); } export const SHOP_URL = "https://store.pollen-robotics.com/collections/microduck"; // Exported: the title/pause overlay renders the same plate in the same // spot, so the pause fade reads as the button staying put, not two // different buttons swapping. export function PreorderButton({ sx }) { const locoWant = useGame((s) => s.locoWant); const text = useScramble(locoWant === "rollers" ? "Pre-order pack" : "Pre-order"); return ( uiClick()} sx={{ ...hudHitSx, background: ORANGE, color: COMIC_INK, "&:hover": { filter: "brightness(1.07)" }, "&:active": { filter: "brightness(0.92)" }, }} > {text} ); } export function Quickbar() { const variant = useGame((s) => s.variant); const locoWant = useGame((s) => s.locoWant); return ( {Object.entries(VARIANT_SWATCH_HEX).map(([name, hex]) => { const selected = name === variant; const label = VARIANT_LABELS[name] ?? name; return ( { uiClick(); gameApi.setVariant?.(name); }} sx={{ appearance: "none", width: CELL_H, // square cell inside frame + padding height: "100%", p: 0, background: hex, border: "none", borderRadius: 0, cursor: "pointer", WebkitTapHighlightColor: "transparent", boxShadow: selected ? `inset 0 0 0 2px ${CREAM}` : "none", opacity: selected ? 1 : 0.7, animation: selected ? `${chipPop} 0.28s ease` : "none", transition: "opacity 0.15s ease, box-shadow 0.15s ease", "&:hover": { opacity: 1 }, "&:active": { animation: "none", filter: "brightness(0.92)" }, "&:focus-visible": { outline: `2px dashed ${CREAM}`, outlineOffset: -5, }, "@media (prefers-reduced-motion: reduce)": { animation: "none" }, }} /> ); })} {[ { name: "legs", label: "Feet" }, { name: "rollers", label: "Rollers" }, ].map(({ name, label }) => { const selected = locoWant === name; return ( { uiClick(); gameApi.requestLoco?.(name); }} sx={{ ...hudHitSx, px: "1.05rem", background: selected ? ORANGE : "transparent", color: selected ? COMIC_INK : "rgba(250, 248, 242, 0.72)", "&:hover": { color: selected ? COMIC_INK : CREAM, background: selected ? ORANGE : "rgba(255, 122, 47, 0.12)", }, "&:active": { filter: "brightness(0.92)" }, }} > {label} ); })} {/* Sound is fully disabled for now (SOUND_DISABLED in audio.js) - the mute toggle comes back with the finished sound design. */} {/* */} ); } // Mute / unmute panel: same cartouche language as the other groups. The // toggle ramps audio.js's master gain (~50 ms) and persists; unmuting // plays the HUD click as confirmation (muting stays silent, obviously). export function SoundToggle() { const [muted, setMutedState] = useState(isMuted); const toggle = () => { const next = !muted; setMuted(next); setMutedState(next); if (!next) uiClick(); }; return ( ); } function Telemetry() { const t = useGame((s) => s.telemetry); const odo = t.odo < 1000 ? `${t.odo.toFixed(1)}M` : `${(t.odo / 1000).toFixed(2)}KM`; const lines = []; if (t.peers) lines.push(`${t.peers + 1} ONLINE`); lines.push(`${t.speed.toFixed(2)}M/S \u00b7 ODO ${odo}`); lines.push(`FPS ${t.fps} \u00b7 CTRL ${t.ctrlHz}HZ`); return ( {lines.join("\n")} ); } function OsdLoad() { const rollersLoading = useGame((s) => s.rollersLoading); if (!rollersLoading) return null; return ( LOADING ROLLERS {"\u2588"} ); } export default function Hud() { const entered = useGame((s) => s.entered); const menuOpen = useGame((s) => s.menuOpen); const touchMode = useGame((s) => s.touchMode); if (!entered || menuOpen) return null; return ( <> {!touchMode && ( <> )} ); }