Download index.html from FormaLau/microduck-reachy-simulator: direct link, hf CLI and curl.
- Browser
- Download file 52.4 kB
-
https://huggingface.co/spaces/FormaLau/microduck-reachy-simulator/resolve/fa03d3d92d22df70c49a85cbddfdfd3bcd853436/index.html
- Command line
-
hf download hf://spaces/FormaLau/microduck-reachy-simulator@fa03d3d92d22df70c49a85cbddfdfd3bcd853436/index.html
-
curl -L -o index.html https://huggingface.co/spaces/FormaLau/microduck-reachy-simulator/resolve/fa03d3d92d22df70c49a85cbddfdfd3bcd853436/index.html
52.4 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Microduck</title> | |
| <style> | |
| :root { | |
| --ink: #08080c; | |
| --orange: #ff7a2f; | |
| --yellow: var(--orange); | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| background: var(--ink); | |
| color: rgba(255, 255, 255, 0.85); | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| overflow: hidden; | |
| } | |
| #scene { position: fixed; inset: 0; z-index: 1; } | |
| #scene canvas { display: block; } | |
| /* Pre-boot veil: covers everything until the title page's logo and | |
| fonts are in, so the menu never pops in half-assembled. Light grey | |
| spinner, centered on the ink. */ | |
| #preboot { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 40; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: var(--ink); | |
| opacity: 1; | |
| transition: opacity 0.25s ease; | |
| } | |
| #preboot.off { opacity: 0; pointer-events: none; } | |
| #preboot .spin { | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| border: 2px solid rgba(255, 255, 255, 0.14); | |
| border-top-color: rgba(255, 255, 255, 0.55); | |
| animation: preboot-spin 0.8s linear infinite; | |
| } | |
| @keyframes preboot-spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| #preboot .spin { animation-duration: 1.6s; } | |
| } | |
| .halftone { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 2; | |
| pointer-events: none; | |
| background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1.5px); | |
| background-size: 21px 21px; | |
| mask-image: linear-gradient(to bottom, transparent 30%, black 100%); | |
| -webkit-mask-image: linear-gradient(to bottom, transparent 30%, black 100%); | |
| } | |
| /* ββ CRT / old-TV overlay ββββββββββββββββββββββββββββββββββββββ | |
| Independent full-viewport layers stacked over the 3D scene at | |
| z-index 5: above the canvas and the halftone dots, below the | |
| HUD (z 10), the BIOS boot screen (z 20) and the welcome modal | |
| (z 30) - so the interface stays crisp and only the scene gets | |
| the old-TV treatment. | |
| Each effect lives in its own child div + CSS block and can be | |
| removed by deleting that block and its <div id="crt"> child: | |
| .crt-scanlines fine horizontal lines + faint RGB fringe | |
| .crt-noise boiling static grain (tiled SVG turbulence) | |
| .crt-vignette darkened edges, curved-glass feel (optional) | |
| Tuning knobs (custom properties on #crt): | |
| --crt-scanline-opacity line strength (0 = off) | |
| --crt-scanline-pitch line spacing in px | |
| --crt-noise-opacity grain strength (0 = off) | |
| --crt-vignette-opacity edge darkening (0 = off) | |
| Performance: the only animation is a transform-only jitter on | |
| the noise layer, so everything stays on the compositor and | |
| never triggers repaints of the WebGL canvas underneath. */ | |
| #crt { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 5; | |
| pointer-events: none; | |
| overflow: hidden; | |
| --crt-scanline-opacity: 0.13; | |
| --crt-scanline-pitch: 3px; | |
| --crt-noise-opacity: 0.045; | |
| --crt-vignette-opacity: 0.42; | |
| } | |
| /* SCANLINES: one thin dark line every --crt-scanline-pitch, plus a | |
| very faint vertical RGB fringe (aperture-grille tint). Static, | |
| zero runtime cost. */ | |
| #crt .crt-scanlines { | |
| position: absolute; | |
| inset: 0; | |
| background: | |
| repeating-linear-gradient( | |
| to bottom, | |
| rgba(0, 0, 0, 0.55) 0, | |
| rgba(0, 0, 0, 0.55) 1px, | |
| transparent 1px, | |
| transparent var(--crt-scanline-pitch) | |
| ), | |
| repeating-linear-gradient( | |
| to right, | |
| rgba(255, 70, 60, 0.05) 0, | |
| rgba(255, 70, 60, 0.05) 1px, | |
| rgba(70, 255, 90, 0.035) 1px, | |
| rgba(70, 255, 90, 0.035) 2px, | |
| rgba(70, 110, 255, 0.05) 2px, | |
| rgba(70, 110, 255, 0.05) 3px | |
| ); | |
| opacity: var(--crt-scanline-opacity); | |
| } | |
| /* NOISE: a small tiled SVG turbulence texture (white specks with | |
| noisy alpha), jittered between four offsets with steps() so the | |
| grain "boils" like broadcast static. The layer is oversized so | |
| the jumps never reveal an edge; only transform animates. */ | |
| #crt .crt-noise { | |
| position: absolute; | |
| inset: -80px; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| background-size: 160px 160px; | |
| opacity: var(--crt-noise-opacity); | |
| animation: crt-noise 0.5s steps(1) infinite; | |
| will-change: transform; | |
| } | |
| @keyframes crt-noise { | |
| 0% { transform: translate(0, 0); } | |
| 25% { transform: translate(-45px, 30px); } | |
| 50% { transform: translate(25px, -55px); } | |
| 75% { transform: translate(-30px, -15px); } | |
| 100% { transform: translate(0, 0); } | |
| } | |
| /* VIGNETTE (optional): gently darkened corners, like the curved | |
| glass of a CRT tube. Static. */ | |
| #crt .crt-vignette { | |
| position: absolute; | |
| inset: 0; | |
| background: radial-gradient( | |
| 115% 90% at 50% 50%, | |
| transparent 62%, | |
| rgba(0, 0, 0, var(--crt-vignette-opacity)) 100% | |
| ); | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| #crt .crt-noise { animation: none; } | |
| } | |
| /* BIOS/POST boot readout: hidden while the welcome modal is up (the | |
| real load runs silently behind it); rl.js reveals it after the | |
| first "Waddle in" and replays / tracks the milestones over an | |
| opaque dark screen before fading into the scene. */ | |
| #loading { | |
| position: fixed; | |
| inset: 0; | |
| display: none; | |
| /* POST readout sits bottom-left like a real BIOS, not centered */ | |
| align-items: flex-end; | |
| justify-content: flex-start; | |
| padding: 2.2rem 2.4rem; | |
| background: var(--ink); | |
| z-index: 20; | |
| pointer-events: none; | |
| opacity: 1; | |
| transition: opacity 0.45s ease; | |
| } | |
| #loading.off { opacity: 0; } | |
| #loading .post { | |
| min-width: min(380px, 84vw); | |
| /* Grow upward from the bottom-left anchor; when the log outgrows | |
| the viewport, clip the OLDEST lines at the top (content is | |
| bottom-aligned) so the newest line + cursor stay visible. */ | |
| max-height: calc(100vh - 4.4rem); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: flex-end; | |
| overflow: hidden; | |
| /* Raw 90s POST look: small, tight, unsmoothed monospace - no glow, | |
| no tracking, plain weight. Reads like a firmware screen, not a | |
| styled terminal. */ | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| font-size: 12px; | |
| font-weight: 400; | |
| line-height: 1.55; | |
| letter-spacing: 0; | |
| text-transform: uppercase; | |
| text-align: left; | |
| /* Robot orange (classic duck's BRIGHT_ORANGE, #ff7a2f in sRGB) */ | |
| color: rgba(255, 122, 47, 0.92); | |
| white-space: pre-wrap; | |
| overflow-wrap: break-word; | |
| } | |
| #loading .post > div:first-child { | |
| color: rgba(255, 255, 255, 0.85); | |
| margin-bottom: 0.55em; | |
| } | |
| /* Blinking block cursor at the end of the newest line */ | |
| #loading .post > div:last-child::after { | |
| content: "\2588"; | |
| margin-left: 0.15em; | |
| animation: post-blink 1s steps(1) infinite; | |
| } | |
| /* Fatal boot failure: SYSTEM HALTED blinks like an old POST fault */ | |
| #loading .post > div.halt { | |
| color: rgba(255, 82, 47, 0.95); | |
| animation: post-blink 1s steps(1) infinite; | |
| } | |
| @keyframes post-blink { | |
| 0%, 55% { opacity: 1; } | |
| 56%, 100% { opacity: 0; } | |
| } | |
| /* Bottom edges: controls left, key hints right */ | |
| #hud { | |
| position: fixed; | |
| bottom: 1.4rem; | |
| left: 1.6rem; | |
| right: 1.6rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-end; | |
| gap: 1rem; | |
| z-index: 10; | |
| pointer-events: none; | |
| } | |
| #hud .stack { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 0.45rem; | |
| pointer-events: auto; | |
| } | |
| #hud .row { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| #hud button { | |
| appearance: none; | |
| border: 1px solid rgba(255, 255, 255, 0.14); | |
| background: rgba(255, 255, 255, 0.04); | |
| color: rgba(255, 255, 255, 0.8); | |
| font: inherit; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| padding: 0.5rem 1rem; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease; | |
| } | |
| #hud button:hover { background: rgba(255, 255, 255, 0.1); } | |
| #hud button.on { | |
| background: #ffc61e; | |
| border-color: #ffc61e; | |
| color: #08080c; | |
| } | |
| /* Active policy/mode readout, top-right: bright orange caption over a | |
| big white monospace name. rl.js scrambles the letters on change | |
| (Matrix-style), so the span is plain flow - no slot-machine | |
| transforms. Monospace keeps the width stable mid-scramble. */ | |
| #mode-wrap { | |
| position: fixed; | |
| top: 1.4rem; | |
| right: 1.6rem; | |
| z-index: 10; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| pointer-events: none; | |
| text-align: right; | |
| } | |
| #mode-wrap .caption { | |
| display: block; | |
| margin-bottom: 0.35rem; | |
| font-size: 0.78rem; | |
| font-weight: 600; | |
| letter-spacing: 0.24em; | |
| text-transform: uppercase; | |
| color: var(--orange); | |
| text-shadow: 0 0 10px rgba(255, 122, 47, 0.35); | |
| } | |
| .mode { | |
| min-width: 8ch; | |
| min-height: 1.2em; | |
| font-size: 1.7rem; | |
| font-weight: 600; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.95); | |
| text-align: right; | |
| text-shadow: 0 0 12px rgba(255, 255, 255, 0.28); | |
| line-height: 1.2; | |
| } | |
| /* In-game back-to-menu button, top-left */ | |
| #back-btn { | |
| position: fixed; | |
| top: 1.4rem; | |
| left: 1.6rem; | |
| z-index: 10; | |
| appearance: none; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.45em; | |
| border: 1px solid rgba(255, 255, 255, 0.16); | |
| background: rgba(8, 8, 12, 0.55); | |
| color: rgba(255, 255, 255, 0.75); | |
| font: inherit; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| padding: 0.6rem 1.15rem; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; | |
| } | |
| #back-btn:hover { | |
| color: #fff; | |
| border-color: rgba(255, 255, 255, 0.4); | |
| background: rgba(255, 255, 255, 0.06); | |
| } | |
| #back-btn:focus-visible { | |
| outline: 2px solid var(--orange); | |
| outline-offset: 2px; | |
| } | |
| /* In-game quick picks, bottom-left: colour dots + legs/rollers, live. | |
| Same pill language as the vitrine DuckPlayground. */ | |
| #quickbar { | |
| position: fixed; | |
| bottom: 1.4rem; | |
| left: 1.6rem; | |
| z-index: 10; | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 0.6rem; | |
| } | |
| #quickbar .dots, | |
| #quickbar .seg { | |
| display: inline-flex; | |
| gap: 0.24rem; | |
| padding: 0.26rem; | |
| border: 1px solid rgba(255, 255, 255, 0.14); | |
| border-radius: 999px; | |
| background: rgba(8, 8, 12, 0.55); | |
| } | |
| #quickbar .dots button { | |
| appearance: none; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: auto; | |
| height: 2.3rem; | |
| padding: 0 0.5rem; | |
| border: none; | |
| border-radius: 999px; | |
| background: transparent; | |
| cursor: pointer; | |
| } | |
| #quickbar .dots button .dot { | |
| width: 1.4rem; | |
| height: 1.4rem; | |
| border-radius: 50%; | |
| opacity: 0.45; | |
| transition: transform 0.2s ease, opacity 0.2s ease; | |
| } | |
| #quickbar .dots button:hover .dot { opacity: 1; } | |
| #quickbar .dots button.on .dot { | |
| opacity: 1; | |
| transform: scale(1.15); | |
| } | |
| #quickbar .seg button { | |
| appearance: none; | |
| border: none; | |
| background: transparent; | |
| height: 2.3rem; | |
| padding: 0 1.05rem; | |
| border-radius: 999px; | |
| font: inherit; | |
| font-size: 0.76rem; | |
| font-weight: 600; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.45); | |
| cursor: pointer; | |
| transition: color 0.2s ease, background 0.2s ease; | |
| } | |
| #quickbar .seg button.on { | |
| background: rgba(255, 122, 47, 0.14); | |
| color: var(--orange); | |
| } | |
| #quickbar .dots button:focus-visible, | |
| #quickbar .seg button:focus-visible { | |
| outline: 2px solid var(--orange); | |
| outline-offset: 2px; | |
| } | |
| /* Narrow screens: pull the quickbar back a notch so it stays on one | |
| line and clear of the bottom-right telemetry. */ | |
| @media (max-width: 480px) { | |
| #quickbar .dots button { height: 2rem; padding: 0 0.42rem; } | |
| #quickbar .dots button .dot { width: 1.2rem; height: 1.2rem; } | |
| #quickbar .seg button { height: 2rem; padding: 0 0.8rem; font-size: 0.68rem; } | |
| } | |
| /* Touch overlay: Game Boy thumbs, coarse-pointer devices only | |
| (rl.js mirrors the touch source's connected flag onto | |
| body.touch-mode). Left thumb = virtual stick, right thumb = A/B | |
| caps on the classic diagonal (A up-right, B down-left). */ | |
| #touch-ui { display: none; } | |
| body.touch-mode #touch-ui { display: block; } | |
| /* Touch mode strips the HUD down to the thumbs: no quickbar, no mode | |
| readout, no telemetry - the stick and caps own the bottom edge. | |
| Back stays (top-left) to reach the menu. */ | |
| body.touch-mode #quickbar, | |
| body.touch-mode #mode-wrap, | |
| body.touch-mode #ctrl-hz { display: none ; } | |
| /* Floating-stick zone: the whole lower-left quadrant grabs the stick, | |
| and the base re-anchors under the finger wherever it lands. Sits | |
| BELOW the quickbar/back button in z so their taps still land; the | |
| resting stick inside marks the zone when idle. */ | |
| #touch-zone { | |
| position: fixed; | |
| left: 0; | |
| right: 50%; | |
| top: 30%; | |
| bottom: 0; | |
| z-index: 9; | |
| touch-action: none; | |
| } | |
| #touch-stick { | |
| position: absolute; | |
| left: 1.3rem; | |
| bottom: 2rem; | |
| width: 8.2rem; | |
| height: 8.2rem; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 255, 255, 0.18); | |
| background: rgba(8, 8, 12, 0.4); | |
| pointer-events: none; /* the zone owns the pointer */ | |
| } | |
| #touch-stick .nub { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| width: 3.6rem; | |
| height: 3.6rem; | |
| margin: -1.8rem 0 0 -1.8rem; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 255, 255, 0.35); | |
| background: rgba(255, 255, 255, 0.14); | |
| pointer-events: none; | |
| } | |
| #touch-stick.live { border-color: rgba(255, 122, 47, 0.55); } | |
| #touch-stick.live .nub { | |
| background: rgba(255, 122, 47, 0.45); | |
| border-color: var(--orange); | |
| } | |
| #touch-btns { | |
| position: fixed; | |
| right: 1.3rem; | |
| bottom: 2.2rem; | |
| z-index: 12; | |
| width: 9.4rem; | |
| height: 8.4rem; | |
| } | |
| #touch-btns .capwrap { | |
| position: absolute; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 0.3rem; | |
| } | |
| #touch-btns .cap-a { right: 0; top: 0; } | |
| #touch-btns .cap-b { left: 0; bottom: 0; } | |
| #touch-btns button { | |
| appearance: none; | |
| width: 4.2rem; | |
| height: 4.2rem; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 255, 255, 0.22); | |
| background: rgba(8, 8, 12, 0.5); | |
| color: rgba(255, 255, 255, 0.85); | |
| font: inherit; | |
| font-size: 1.3rem; | |
| font-weight: 700; | |
| cursor: pointer; | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| #touch-btns button.down { | |
| background: var(--orange); | |
| border-color: var(--orange); | |
| color: var(--ink); | |
| } | |
| #touch-btns .capwrap span { | |
| font-size: 0.55rem; | |
| font-weight: 600; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.45); | |
| } | |
| /* Colour swatches */ | |
| #swatches { gap: 0.6rem; } | |
| #swatches button { | |
| width: 1.5rem; | |
| height: 1.5rem; | |
| padding: 0; | |
| border-radius: 50%; | |
| border: 2px solid rgba(255, 255, 255, 0.18); | |
| } | |
| #swatches button:hover { transform: scale(1.15); } | |
| #swatches button.on { | |
| border-color: #fff; | |
| transform: scale(1.15); | |
| } | |
| #hud .keys { | |
| font-size: 0.7rem; | |
| letter-spacing: 0.08em; | |
| color: rgba(255, 255, 255, 0.4); | |
| text-align: right; | |
| line-height: 1.9; | |
| pointer-events: auto; | |
| } | |
| /* Corner group captions */ | |
| #hud .caption { | |
| display: block; | |
| font-size: 0.55rem; | |
| font-weight: 600; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.28); | |
| } | |
| #hud .keys .caption { margin-bottom: 0.45rem; } | |
| /* Keycap chips in the hints */ | |
| #hud .keys .key { | |
| display: inline-block; | |
| min-width: 1.5em; | |
| padding: 0.12em 0.4em; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| border-bottom-width: 2px; | |
| border-radius: 5px; | |
| background: rgba(255, 255, 255, 0.05); | |
| color: rgba(255, 255, 255, 0.75); | |
| font-size: 0.62rem; | |
| font-weight: 600; | |
| line-height: 1.35; | |
| text-align: center; | |
| transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; | |
| } | |
| #hud .keys .key.lit { | |
| background: var(--yellow); | |
| border-color: var(--yellow); | |
| color: var(--ink); | |
| } | |
| #pad-hint { display: none; } | |
| body.pad-connected #pad-hint { display: inline; } | |
| /* Mini command sticks: dot = the twist the policy actually receives */ | |
| #sticks { | |
| display: flex; | |
| gap: 0.7rem; | |
| justify-content: flex-end; | |
| margin-bottom: 0.5rem; | |
| } | |
| .stickwrap { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 0.15rem; | |
| } | |
| .stickwrap span { | |
| font-size: 0.55rem; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.3); | |
| line-height: 1; | |
| } | |
| .stickbox { | |
| position: relative; | |
| width: 46px; | |
| height: 46px; | |
| border-radius: 50%; | |
| border: 1px solid rgba(255, 255, 255, 0.16); | |
| background: rgba(255, 255, 255, 0.03); | |
| } | |
| .stickdot { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| width: 12px; | |
| height: 12px; | |
| margin: -6px 0 0 -6px; | |
| border-radius: 50%; | |
| background: rgba(255, 255, 255, 0.35); | |
| transition: background 0.15s ease; | |
| } | |
| .stickbox.live .stickdot { background: var(--yellow); } | |
| /* Camcorder OSD: REC light + VHS counter over the telemetry, all in | |
| a phosphor-glow monospace. No scanlines - the type does the work. */ | |
| #stats-wrap { | |
| position: fixed; | |
| top: 6.1rem; /* below the mode readout */ | |
| right: 1.6rem; | |
| z-index: 10; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-end; | |
| gap: 0.2rem; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| } | |
| .osd-top { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 0.9rem; | |
| font-size: 0.82rem; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| color: rgba(255, 255, 255, 0.88); | |
| text-shadow: 0 0 7px rgba(255, 255, 255, 0.28); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .osd-rec { | |
| color: #ff6a48; | |
| text-shadow: 0 0 9px rgba(255, 90, 60, 0.55); | |
| } | |
| .osd-rec .rec-dot { | |
| display: inline-block; | |
| animation: rec-blink 1.2s steps(1) infinite; | |
| } | |
| @keyframes rec-blink { | |
| 0%, 58% { opacity: 1; } | |
| 59%, 100% { opacity: 0.12; } | |
| } | |
| /* Telemetry stack, bottom-right: peers / speed + odometer / loop | |
| rates, one line each, quietest at the bottom. */ | |
| #ctrl-hz { | |
| position: fixed; | |
| bottom: 1.4rem; | |
| right: 1.6rem; | |
| z-index: 10; | |
| pointer-events: none; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| font-size: 0.62rem; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.35); | |
| font-variant-numeric: tabular-nums; | |
| text-shadow: 0 0 6px rgba(255, 255, 255, 0.15); | |
| white-space: pre-line; | |
| text-align: right; | |
| line-height: 1.8; | |
| } | |
| /* Radar minimap: top-down arena view in the top-right OSD stack */ | |
| #minimap { | |
| width: 132px; | |
| height: 132px; | |
| margin-top: 0.45rem; | |
| pointer-events: none; | |
| background: rgba(0, 0, 0, 0.38); | |
| border: 1px solid rgba(255, 122, 47, 0.4); | |
| border-radius: 2px; | |
| } | |
| /* BIOS-style line while the roller variant streams in on first switch */ | |
| #osd-load[hidden] { display: none; } | |
| #osd-load { | |
| font-size: 0.68rem; | |
| letter-spacing: 0.14em; | |
| color: var(--orange); | |
| text-shadow: 0 0 8px rgba(255, 122, 47, 0.4); | |
| } | |
| .osd-load-cursor { | |
| display: inline-block; | |
| margin-left: 0.15em; | |
| animation: rec-blink 0.8s steps(1) infinite; | |
| } | |
| /* Temporarily hidden HUD pieces (CSS-only so the DOM, ids and JS | |
| wiring stay intact and each can be re-enabled by deleting a line): | |
| - #osd-time: the VHS play/time counter (REC + ONLINE stay); | |
| - #hud .keys: the keycap hints + mini joysticks block; | |
| - #hud .stack: the in-game colour swatch pill (the welcome modal | |
| still clicks the hidden #swatches buttons programmatically). */ | |
| #osd-time { display: none; } | |
| #hud .keys { display: none; } | |
| #hud .stack { display: none; } | |
| .osd-rec { display: none; } | |
| #minimap { display: none; } | |
| /* Roller mode: legs-only actions (kicks, sit) are inert - fade their | |
| hints; rl.js toggles body.rollers on every variant switch. */ | |
| body.rollers .legs-only { opacity: 0.25; } | |
| @media (max-width: 700px) { | |
| #hud { flex-direction: column; align-items: flex-start; } | |
| #hud .keys { display: none; } | |
| } | |
| /* HUD stays off while the title flow / pause overlay is up. */ | |
| body.welcome-open #hud, | |
| body.welcome-open #mode-wrap, | |
| body.welcome-open #stats-wrap, | |
| body.welcome-open #back-btn, | |
| body.welcome-open #quickbar, | |
| body.welcome-open #touch-ui, | |
| body.welcome-open #ctrl-hz { display: none ; } | |
| /* ββ Title / pause overlay (full viewport, no card) ββββββββββββββ */ | |
| #welcome[hidden] { display: none; } | |
| #welcome { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 30; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 2.4rem 1.4rem 1.8rem; | |
| background: var(--ink); | |
| opacity: 1; | |
| overflow-y: auto; | |
| transition: opacity 0.35s ease, background 0.4s ease; | |
| } | |
| #welcome.closing { opacity: 0; pointer-events: none; } | |
| /* Title-page entrance: every row fades up once, in reading order. | |
| One soft ease, small travel, ~80 ms between rows - staged, not | |
| showy. The brand alone adds a whisper of scale so the page opens | |
| on it. Replays on every open (pause included): the modal is | |
| display:none while hidden, so animations restart naturally. */ | |
| @keyframes row-in { | |
| from { transform: translateY(12px); opacity: 0; } | |
| to { transform: none; opacity: 1; } | |
| } | |
| @keyframes brand-in { | |
| from { transform: translateY(10px) scale(0.94); opacity: 0; } | |
| to { transform: none; opacity: 1; } | |
| } | |
| /* Wide enough for the two-line title; the controls grid below keeps | |
| its own 36rem cap so the tiles don't stretch. */ | |
| #welcome .wstep { | |
| width: 100%; | |
| max-width: min(48rem, 92vw); | |
| text-align: center; | |
| } | |
| #welcome .wstep[hidden] { display: none; } | |
| #welcome .wstep > *, | |
| #welcome .foot { | |
| animation: row-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; | |
| } | |
| #welcome .wstep > :nth-child(1) { animation-name: brand-in; } | |
| #welcome .wstep > :nth-child(2) { animation-delay: 0.08s; } | |
| #welcome .wstep > :nth-child(3) { animation-delay: 0.16s; } | |
| #welcome .wstep > :nth-child(4) { animation-delay: 0.24s; } | |
| #welcome .wstep > :nth-child(5) { animation-delay: 0.32s; } | |
| #welcome .wstep > :nth-child(n+6) { animation-delay: 0.4s; } | |
| #welcome .wstep > .orbit-hint { animation-delay: 0.48s; } | |
| #welcome .foot { animation-delay: 0.56s; } | |
| @media (prefers-reduced-motion: reduce) { | |
| #welcome .wstep > *, | |
| #welcome .foot { animation: none; } | |
| } | |
| #welcome .facts { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.6rem; | |
| margin-bottom: 1.15rem; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: rgba(255, 255, 255, 0.78); | |
| } | |
| #welcome .facts span:not(:last-child)::after { | |
| content: "Β·"; | |
| color: var(--orange); | |
| padding-left: 0.6em; | |
| } | |
| /* Vitrine brand lockup, centered above the title: drawn duck head + | |
| name. Hovering swaps to the open-beak frame, same hard sprite swap | |
| as the vitrine header. */ | |
| #welcome .wbrand { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.55rem; | |
| margin-bottom: 1.3rem; | |
| user-select: none; | |
| } | |
| #welcome .wbrand-head { | |
| position: relative; | |
| display: block; | |
| height: 3rem; | |
| } | |
| #welcome .wbrand-head .duck-closed { | |
| display: block; | |
| height: 100%; | |
| width: auto; | |
| } | |
| /* The open frame's canvas (460x333) is a hair wider/taller than the | |
| closed one (454x269): the dropped jaw sticks out past the head. | |
| Offsets pin the head in place while the jaw hangs below. */ | |
| #welcome .wbrand-head .duck-open { | |
| position: absolute; | |
| top: -0.75%; | |
| left: -1.1%; | |
| width: 101.3%; | |
| max-width: none; | |
| height: auto; | |
| opacity: 0; | |
| } | |
| #welcome .wbrand:hover .duck-closed { opacity: 0; } | |
| #welcome .wbrand:hover .duck-open { opacity: 1; } | |
| #welcome .wbrand-name { | |
| font-weight: 700; | |
| letter-spacing: -0.03em; | |
| font-size: 1.6rem; | |
| color: #fff; | |
| } | |
| /* Landing-grade h1: same type conventions as the vitrine hero | |
| (600, tight tracking, line-height 1), the loudest line on the page. */ | |
| #welcome .title { | |
| margin: 0 auto; | |
| max-width: 24ch; | |
| font-size: clamp(2.4rem, 5vw, 3.75rem); | |
| font-weight: 600; | |
| letter-spacing: -0.03em; | |
| color: #fff; | |
| line-height: 1.02; | |
| text-wrap: balance; | |
| } | |
| #welcome .label { | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| letter-spacing: 0.18em; | |
| text-indent: 0.18em; | |
| text-transform: uppercase; | |
| color: var(--orange); | |
| } | |
| #welcome .tagline { | |
| margin: 1.25rem auto 0; | |
| max-width: 36ch; | |
| font-size: clamp(1.05rem, 1.5vw, 1.3rem); | |
| line-height: 1.5; | |
| letter-spacing: -0.012em; | |
| color: rgba(255, 255, 255, 0.72); | |
| text-wrap: balance; | |
| } | |
| #welcome .grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 0.55rem; | |
| margin: 1.4rem auto 0; | |
| max-width: 36rem; | |
| } | |
| #welcome .grid-pad { display: none; } | |
| /* Touch device: the tutorial shows the two thumbs instead of the | |
| keycaps. A plugged-in gamepad still wins (pad rules below). */ | |
| #welcome .grid-touch { display: none; } | |
| #welcome .touch-hint { display: none; } | |
| body.touch-mode #welcome .grid-kb { display: none; } | |
| body.touch-mode #welcome .grid-touch { display: grid; } | |
| body.touch-mode #welcome .kb-hint { display: none; } | |
| body.touch-mode #welcome .touch-hint { display: block; } | |
| #welcome .grid-touch kbd.cap { border-radius: 50%; min-width: 1.9rem; } | |
| #welcome .kb-hint { display: block; } | |
| #welcome .pad-hint { display: none; } | |
| body.pad-connected #welcome .grid-kb, | |
| body.pad-connected #welcome .grid-touch { display: none; } | |
| body.pad-connected #welcome .grid-pad { display: grid; } | |
| body.pad-connected #welcome .kb-hint, | |
| body.pad-connected #welcome .touch-hint { display: none; } | |
| body.pad-connected #welcome .pad-hint { display: block; } | |
| #welcome .tile { | |
| padding: 0.85rem 0.45rem 0.75rem; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| border-radius: 18px; | |
| background: rgba(255, 255, 255, 0.035); | |
| } | |
| #welcome .caps { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.2rem; | |
| height: 3.3rem; | |
| } | |
| #welcome .caps .krow { | |
| display: flex; | |
| gap: 0.22rem; | |
| justify-content: center; | |
| } | |
| #welcome kbd { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 1.65rem; | |
| height: 1.65rem; | |
| padding: 0 0.45rem; | |
| font: inherit; | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| color: #fff; | |
| background: #14141c; | |
| border: 2px solid var(--ink); | |
| border-radius: 8px; | |
| box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.82); | |
| } | |
| #welcome .caps.cluster kbd { min-width: 1.4rem; height: 1.4rem; padding: 0 0.3rem; font-size: 0.62rem; } | |
| #welcome .t-name { | |
| margin-top: 0.55rem; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| letter-spacing: -0.01em; | |
| text-transform: none; | |
| text-indent: 0; | |
| color: #fff; | |
| } | |
| #welcome .t-hint { | |
| margin-top: 0.18rem; | |
| font-size: 0.62rem; | |
| font-weight: 600; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: var(--orange); | |
| } | |
| #welcome .orbit-hint { | |
| margin-top: 1rem; | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| letter-spacing: 0.08em; | |
| color: rgba(255, 255, 255, 0.4); | |
| } | |
| #welcome .foot { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 1rem; | |
| margin-top: 1.6rem; | |
| width: 100%; | |
| } | |
| #welcome .wbtn { | |
| appearance: none; | |
| border: 3px solid var(--ink); | |
| background: var(--orange); | |
| color: var(--ink); | |
| font: inherit; | |
| font-size: 1.02rem; | |
| font-weight: 600; | |
| letter-spacing: -0.01em; | |
| text-transform: none; | |
| text-indent: 0; | |
| padding: 0.85rem 1.9rem; | |
| border-radius: 999px; | |
| box-shadow: 0 0 0 3px #fff; | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| #welcome .wbtn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 0 0 3px #fff, 0 10px 26px rgba(255, 122, 47, 0.3); | |
| } | |
| #welcome .wbtn:active { | |
| transform: translateY(1px); | |
| box-shadow: 0 0 0 3px #fff; | |
| } | |
| #welcome .wbtn:focus-visible { | |
| outline: 2px solid #fff; | |
| outline-offset: 4px; | |
| } | |
| @media (max-width: 480px) { | |
| #welcome .wbrand-head { height: 2.4rem; } | |
| #welcome .wbrand-name { font-size: 1.3rem; } | |
| #welcome .title { font-size: 2rem; } | |
| #welcome .tagline { font-size: 0.95rem; } | |
| #welcome .grid { grid-template-columns: repeat(2, 1fr); } | |
| /* Three touch tiles hold one line even on a phone */ | |
| #welcome .grid-touch { grid-template-columns: repeat(3, 1fr); } | |
| #welcome .tile { padding: 0.65rem 0.35rem 0.6rem; } | |
| #welcome .caps { height: 2.8rem; } | |
| #welcome .wbtn { font-size: 0.95rem; padding: 0.75rem 1.5rem; } | |
| } | |
| @media (max-height: 700px) { | |
| #welcome .tagline { margin-top: 0.85rem; } | |
| #welcome .foot { margin-top: 1.1rem; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="preboot" aria-hidden="true"><div class="spin"></div></div> | |
| <div id="scene"></div> | |
| <div class="halftone"></div> | |
| <!-- CRT overlay: each child is one effect; delete a div together with | |
| its CSS block in <style> to drop that effect. --> | |
| <div id="crt" aria-hidden="true"> | |
| <div class="crt-scanlines"></div> | |
| <div class="crt-noise"></div> | |
| <div class="crt-vignette"></div> | |
| </div> | |
| <button type="button" id="back-btn">← Back</button> | |
| <div id="quickbar"> | |
| <div class="dots" id="quick-colors" role="group" aria-label="Duck colours"></div> | |
| <div class="seg" id="quick-loco" role="group" aria-label="Locomotion mode"> | |
| <button type="button" data-loco="legs" class="on" aria-pressed="true">Legs</button> | |
| <button type="button" data-loco="rollers" aria-pressed="false">Rollers</button> | |
| </div> | |
| </div> | |
| <div id="touch-ui"> | |
| <div id="touch-zone"><div id="touch-stick"><div class="nub"></div></div></div> | |
| <div id="touch-btns"> | |
| <div class="capwrap cap-a"> | |
| <button type="button" id="touch-a" aria-label="Kick">A</button> | |
| <span>Kick</span> | |
| </div> | |
| <div class="capwrap cap-b"> | |
| <button type="button" id="touch-b" aria-label="Quack">B</button> | |
| <span>Quack</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="mode-wrap"> | |
| <span class="caption">Mode</span> | |
| <div class="mode" id="mode-label"><span class="mode-text">Run</span></div> | |
| </div> | |
| <div id="stats-wrap"> | |
| <div class="osd-top"> | |
| <span class="osd-rec"><span class="rec-dot">●</span> REC</span> | |
| <span id="osd-time">▶ 00:00:00</span> | |
| </div> | |
| <canvas id="minimap" width="264" height="264" aria-hidden="true"></canvas> | |
| <div id="osd-load" hidden>LOADING ROLLERS<span class="osd-load-cursor">█</span></div> | |
| </div> | |
| <div id="ctrl-hz"></div> | |
| <div id="hud" hidden> | |
| <div class="stack"> | |
| <span class="caption">Colour</span> | |
| <div class="row" id="swatches"></div> | |
| </div> | |
| <div class="keys"> | |
| <span class="caption">Controls</span> | |
| <div id="sticks"> | |
| <div class="stickwrap"> | |
| <div class="stickbox"><div class="stickdot" id="dot-move"></div></div> | |
| <span>move</span> | |
| </div> | |
| <div class="stickwrap"> | |
| <div class="stickbox"><div class="stickdot" id="dot-turn"></div></div> | |
| <span>turn</span> | |
| </div> | |
| </div> | |
| <span class="hint"><span class="key" id="key-fwd">↑</span><span class="key" id="key-back">↓</span> move</span> | |
| <span class="hint"><span class="key" id="key-turnl">←</span><span class="key" id="key-turnr">→</span> turn</span><br /> | |
| <span class="hint"><span class="key" id="key-roll">R</span> <span id="key-roll-label">roll</span></span> | |
| <span class="hint legs-only"><span class="key" id="key-kickl">A</span><span class="key" id="key-kickr">E</span> kick</span> | |
| <span class="hint"><span class="key" id="key-ball">B</span> ball</span> | |
| <span class="hint"><span class="key" id="key-cam">C</span> camera</span> | |
| <span class="hint"><span class="key" id="key-loco">M</span> rollers</span> | |
| <span class="hint"><span class="key" id="key-reset">Space</span> reset</span> | |
| drag to orbit · scroll to zoom<br /> | |
| <span id="pad-hint">gamepad: | |
| <span class="hint"><span class="key" id="key-pad-x">X</span> <span id="key-pad-x-label">roll</span></span> | |
| <span class="hint"><span class="key" id="key-pad-y">Y</span> ball</span> | |
| <span class="hint legs-only"><span class="key" id="key-pad-rb">RB</span><span class="key" id="key-pad-lb">LB</span> kick</span> | |
| <span class="hint"><span class="key" id="key-pad-rs">RS</span> camera</span> | |
| <span class="hint"><span class="key" id="key-pad-r3">R3</span> chase</span> | |
| <span class="hint legs-only"><span class="key" id="key-pad-sit">⇓</span> sit</span> | |
| <span class="hint"><span class="key" id="key-pad-run">⇑</span> run · hold: rollers</span> | |
| <span class="hint"><span class="key" id="key-pad-rt">RT</span> quack</span> | |
| </span> | |
| </div> | |
| </div> | |
| <div id="loading"><div class="post"></div></div> | |
| <div id="welcome" hidden role="dialog" aria-modal="true" aria-label="Microduck"> | |
| <div class="wstep" id="wstep-0"> | |
| <div class="wbrand" aria-hidden="true"> | |
| <span class="wbrand-head"> | |
| <img class="duck-closed" id="wbrand-head-closed" alt="" /> | |
| <img class="duck-open" id="wbrand-head-open" alt="" /> | |
| </span> | |
| <span class="wbrand-name">MicroDuck</span> | |
| </div> | |
| <div class="facts"> | |
| <span>MuJoCo physics</span> | |
| <span>ONNX policies at 50 Hz</span> | |
| </div> | |
| <h1 class="title">The Microduck simulator, live in your browser</h1> | |
| <p class="tagline">The exact same trained policies that drive the real robot.</p> | |
| <div class="grid grid-kb"> | |
| <div class="tile"> | |
| <div class="caps cluster"> | |
| <span class="krow"><kbd>↑</kbd></span> | |
| <span class="krow"><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd></span> | |
| </div> | |
| <div class="t-name">Move</div> | |
| <div class="t-hint">arrows or ZQSD</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><span class="krow"><kbd>A</kbd><kbd>E</kbd></span></div> | |
| <div class="t-name">Kick</div> | |
| <div class="t-hint">left / right</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>R</kbd></div> | |
| <div class="t-name">Roll</div> | |
| <div class="t-hint">barrel roll</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>B</kbd></div> | |
| <div class="t-name">Ball</div> | |
| <div class="t-hint">pop a fresh one</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>C</kbd></div> | |
| <div class="t-name">Camera</div> | |
| <div class="t-hint">toggle chase</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>Space</kbd></div> | |
| <div class="t-name">Reset</div> | |
| <div class="t-hint">fresh start</div> | |
| </div> | |
| </div> | |
| <div class="grid grid-pad"> | |
| <div class="tile"> | |
| <div class="caps"><kbd>LS</kbd></div> | |
| <div class="t-name">Move</div> | |
| <div class="t-hint">left stick</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><span class="krow"><kbd>LB</kbd><kbd>RB</kbd></span></div> | |
| <div class="t-name">Kick</div> | |
| <div class="t-hint">left / right</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>X</kbd></div> | |
| <div class="t-name">Roll</div> | |
| <div class="t-hint">barrel roll</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>Y</kbd></div> | |
| <div class="t-name">Ball</div> | |
| <div class="t-hint">pop a fresh one</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>RS</kbd></div> | |
| <div class="t-name">Camera</div> | |
| <div class="t-hint">orbit</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd>↓</kbd></div> | |
| <div class="t-name">Sit</div> | |
| <div class="t-hint">stand up</div> | |
| </div> | |
| </div> | |
| <div class="grid grid-touch"> | |
| <div class="tile"> | |
| <div class="caps"><kbd>Stick</kbd></div> | |
| <div class="t-name">Move</div> | |
| <div class="t-hint">left thumb</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd class="cap">A</kbd></div> | |
| <div class="t-name">Kick</div> | |
| <div class="t-hint">tap it</div> | |
| </div> | |
| <div class="tile"> | |
| <div class="caps"><kbd class="cap">B</kbd></div> | |
| <div class="t-name">Quack</div> | |
| <div class="t-hint">hold for beak</div> | |
| </div> | |
| </div> | |
| <p class="orbit-hint kb-hint">drag to orbit · scroll to zoom</p> | |
| <p class="orbit-hint pad-hint">R3 chase · RT quack</p> | |
| <p class="orbit-hint touch-hint">drag to orbit · pinch to zoom</p> | |
| </div> | |
| <div class="foot"> | |
| <button type="button" class="wbtn" id="welcome-cta">Waddle in</button> | |
| </div> | |
| </div> | |
| <script> | |
| // Single-page title / pause menu: pitch + controls + CTA. The first | |
| // "Waddle in" cues the BIOS; later Esc / the in-game Menu button | |
| // reopen it as pause. The in-game quickbar (bottom-left) picks the | |
| // colour and locomotion live: colour clicks the hidden #swatches | |
| // buttons, loco goes through rl.setLoco via a small reconciler that | |
| // retries until the game allows the switch (mid-roll, respawn, ...). | |
| (() => { | |
| const modal = document.getElementById("welcome"); | |
| const sign = new URLSearchParams(location.search).get("__sign"); | |
| const signedUrl = (u) => (sign ? `${u}?__sign=${encodeURIComponent(sign)}` : u); | |
| document.getElementById("wbrand-head-closed").src = signedUrl("./assets/duck-head-mark.webp"); | |
| document.getElementById("wbrand-head-open").src = signedUrl("./assets/duck-head-mark-open.webp"); | |
| const cta = document.getElementById("welcome-cta"); | |
| // ββ Quickbar: colour + locomotion, applied live βββββββββββββββββ | |
| const want = { color: "classic", loco: "legs" }; | |
| const gameSwatch = (name) => | |
| document.querySelector(`#swatches button[aria-label="${name} colours"]`); | |
| const reconcile = () => { | |
| let done = true; | |
| const g = gameSwatch(want.color); | |
| if (g) { | |
| syncDotColors(); | |
| if (!g.classList.contains("on")) g.click(); | |
| } else done = false; | |
| const rl = window.rl; | |
| if (rl) { | |
| if (want.loco === "rollers") rl.ensureRollers().catch(() => {}); | |
| if (rl.loco !== want.loco) { | |
| done = false; | |
| if (!rl.locoSwitching) rl.setLoco(want.loco); | |
| } | |
| } else if (want.loco !== "legs") done = false; | |
| return done; | |
| }; | |
| let reconciler = null; | |
| const kick = () => { | |
| if (reconciler || reconcile()) return; | |
| reconciler = setInterval(() => { | |
| if (reconcile()) { clearInterval(reconciler); reconciler = null; } | |
| }, 250); | |
| }; | |
| const DOTS = { classic: "#ff7a2f", charcoal: "#2f2f33", purple: "#7a5fc9", blue: "#6f9ec4" }; | |
| const dotsEl = document.getElementById("quick-colors"); | |
| const dotBtns = new Map(); | |
| for (const [name, hex] of Object.entries(DOTS)) { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.setAttribute("aria-label", `${name} colours`); | |
| const dot = document.createElement("span"); | |
| dot.className = "dot"; | |
| dot.style.background = hex; | |
| b.appendChild(dot); | |
| b.addEventListener("click", () => { want.color = name; syncPickers(); kick(); }); | |
| dotsEl.appendChild(b); | |
| dotBtns.set(name, b); | |
| } | |
| const syncDotColors = () => { | |
| for (const [name, b] of dotBtns) { | |
| const g = gameSwatch(name); | |
| if (g) b.querySelector(".dot").style.background = g.style.background; | |
| } | |
| }; | |
| const segBtns = [...document.querySelectorAll("#quick-loco button")]; | |
| for (const b of segBtns) { | |
| b.addEventListener("click", () => { want.loco = b.dataset.loco; syncPickers(); kick(); }); | |
| } | |
| const syncPickers = () => { | |
| for (const [name, b] of dotBtns) { | |
| b.classList.toggle("on", name === want.color); | |
| b.setAttribute("aria-pressed", String(name === want.color)); | |
| } | |
| for (const b of segBtns) { | |
| b.classList.toggle("on", b.dataset.loco === want.loco); | |
| b.setAttribute("aria-pressed", String(b.dataset.loco === want.loco)); | |
| } | |
| }; | |
| syncPickers(); | |
| // Reflect game-side switches (M key, pad hold) back into the bar. | |
| setInterval(() => { | |
| if (reconciler) return; | |
| const on = document.querySelector("#swatches button.on"); | |
| if (on) want.color = (on.getAttribute("aria-label") || "").split(" ")[0] || want.color; | |
| if (window.rl && !window.rl.locoSwitching) want.loco = window.rl.loco; | |
| syncDotColors(); | |
| syncPickers(); | |
| }, 500); | |
| const pollPad = () => { | |
| const gp = [...(navigator.getGamepads?.() ?? [])].find((p) => p && p.connected); | |
| document.body.classList.toggle("pad-connected", !!gp); | |
| }; | |
| window.addEventListener("gamepadconnected", pollPad); | |
| window.addEventListener("gamepaddisconnected", pollPad); | |
| pollPad(); | |
| // Touch tutorial variant needs the class before rl.js boots; the | |
| // game loop keeps mirroring the touch source afterwards. Latching: | |
| // (pointer: coarse) OR any real touch proves the device (DevTools | |
| // device modes and hybrid laptops often fail the media query), and | |
| // the __microduckTouched flag hands the proof to controls/touch.js. | |
| const coarse = matchMedia("(pointer: coarse)"); | |
| const armTouch = () => { | |
| window.__microduckTouched = true; | |
| document.body.classList.add("touch-mode"); | |
| }; | |
| if (coarse.matches) armTouch(); | |
| else coarse.addEventListener("change", () => coarse.matches && armTouch()); | |
| window.addEventListener("touchstart", armTouch, { once: true, passive: true }); | |
| // ?touch=1 forces the thumbs on any device - desktop testing hook. | |
| if (new URLSearchParams(location.search).get("touch") === "1") armTouch(); | |
| // ββ Menu open / close ββββββββββββββββββββββββββββββββββββββββββββ | |
| const open = () => { | |
| modal.hidden = false; | |
| modal.classList.remove("closing"); | |
| document.body.classList.add("welcome-open"); | |
| document.dispatchEvent(new CustomEvent("microduck:menu-open")); | |
| cta.textContent = window.__microduckEntered ? "Resume" : "Waddle in"; | |
| }; | |
| const close = () => { | |
| modal.classList.add("closing"); | |
| document.body.classList.remove("welcome-open"); | |
| document.dispatchEvent(new CustomEvent("microduck:menu-close")); | |
| setTimeout(() => { modal.hidden = true; }, 380); | |
| if (!window.__microduckEntered) { | |
| window.__microduckEntered = true; | |
| document.dispatchEvent(new CustomEvent("microduck:enter")); | |
| } | |
| }; | |
| // Pre-boot veil: hold the title page until its logo and the fonts | |
| // are in (grey spinner meanwhile), capped so a broken asset can | |
| // never wall the menu off. | |
| const preboot = document.getElementById("preboot"); | |
| const dropPreboot = () => { | |
| preboot.classList.add("off"); | |
| setTimeout(() => preboot.remove(), 300); | |
| }; | |
| if (new URLSearchParams(location.search).get("boot") === "1") { | |
| modal.hidden = true; | |
| window.__microduckEntered = true; | |
| dropPreboot(); | |
| document.dispatchEvent(new CustomEvent("microduck:enter")); | |
| } else { | |
| const headImg = document.getElementById("wbrand-head-closed"); | |
| const imgReady = new Promise((res) => { | |
| if (headImg.complete && headImg.naturalWidth) res(); | |
| else { | |
| headImg.addEventListener("load", res, { once: true }); | |
| headImg.addEventListener("error", res, { once: true }); | |
| } | |
| }); | |
| const fontsReady = document.fonts?.ready ?? Promise.resolve(); | |
| const cap = new Promise((res) => setTimeout(res, 2500)); | |
| Promise.race([Promise.all([imgReady, fontsReady]), cap]).then(() => { | |
| open(); | |
| dropPreboot(); | |
| }); | |
| } | |
| cta.addEventListener("click", close); | |
| document.getElementById("back-btn").addEventListener("click", open); | |
| const biosUp = () => document.getElementById("loading")?.style.display === "flex"; | |
| window.addEventListener("keydown", (e) => { | |
| if (e.code === "Enter" && !modal.hidden) { | |
| if (e.target instanceof HTMLButtonElement && e.target !== cta) return; | |
| e.preventDefault(); | |
| close(); | |
| return; | |
| } | |
| if (e.code !== "Escape") return; | |
| if (biosUp()) return; | |
| if (!modal.hidden) { | |
| if (window.__microduckEntered) close(); | |
| return; | |
| } | |
| if (window.__microduckEntered) open(); | |
| }); | |
| })(); | |
| </script> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <!-- rl.js is imported dynamically with the private-space ?__sign JWT | |
| appended: a plain src attribute can 401 when the browser blocks the | |
| *.static.hf.space auth cookie inside the hub iframe. --> | |
| <script type="module"> | |
| const V = "70"; // bump on rl.js changes to defeat heuristic caching | |
| const sign = new URLSearchParams(location.search).get("__sign"); | |
| const suffix = sign ? `&__sign=${encodeURIComponent(sign)}` : ""; | |
| import(`./rl.js?v=${V}${suffix}`); | |
| </script> | |
| </body> | |
| </html> | |