|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { useEffect, useRef, useState } from "react";
|
| import Box from "@mui/material/Box";
|
| import { keyframes } from "@mui/material/styles";
|
| import { useGame, gameApi, bootLog } from "../store.js";
|
| import { playBiosBeep, playBiosTick } from "../game/audio.js";
|
| import { INK, MONO } from "../theme.js";
|
|
|
| const postBlink = keyframes`
|
| 0%, 55% { opacity: 1; }
|
| 56%, 100% { opacity: 0; }
|
| `;
|
|
|
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
| const lineText = (e) => {
|
| if (e.raw) return e.label;
|
|
|
|
|
| if (e.status === null) return e.progress ? `${e.label} [${e.progress}]` : e.label;
|
| return `${e.label} `.padEnd(26, ".") + ` ${e.status}`;
|
| };
|
|
|
| export default function BiosOverlay() {
|
| const entered = useGame((s) => s.entered);
|
| const bootFailed = useGame((s) => s.bootFailed);
|
| const postRef = useRef(null);
|
| const startedRef = useRef(false);
|
| const [visible, setVisible] = useState(false);
|
| const [off, setOff] = useState(false);
|
|
|
| useEffect(() => {
|
|
|
|
|
|
|
| if ((!entered && !bootFailed) || startedRef.current) return;
|
| startedRef.current = true;
|
| playBios();
|
|
|
| }, [entered, bootFailed]);
|
|
|
| async function playBios() {
|
| const store = useGame.getState;
|
| setVisible(true);
|
| useGame.setState({ biosVisible: true });
|
|
|
|
|
|
|
| playBiosBeep();
|
| const postEl = postRef.current;
|
|
|
|
|
|
|
| let seed = 11;
|
| const rand = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
|
| const els = new Map();
|
| let i = 0;
|
| for (;;) {
|
| const { bootDone, bootFailed: failed } = store();
|
| if (i < bootLog.length) {
|
| const entry = bootLog[i++];
|
| const el = document.createElement("div");
|
| if (entry.halt) el.className = "halt";
|
| postEl.appendChild(el);
|
| els.set(entry, el);
|
| playBiosTick();
|
| if (entry.status === null && !bootDone && !failed) {
|
|
|
|
|
| el.textContent = lineText(entry);
|
| while (entry.status === null && !store().bootDone && !store().bootFailed) {
|
| await sleep(60);
|
| el.textContent = lineText(entry);
|
| }
|
| await sleep(90);
|
| el.textContent = lineText(entry);
|
| } else if (entry.raw || entry.status === null) {
|
|
|
| el.textContent = lineText(entry);
|
| await sleep(20 + 60 * rand());
|
| } else {
|
|
|
|
|
|
|
|
|
| const r = rand();
|
| const stall = r < 0.75 ? 20 * rand() : 150 + 250 * rand();
|
| if (stall < 45) {
|
| el.textContent = lineText(entry);
|
| await sleep(stall);
|
| } else {
|
| const prefix = `${entry.label} `;
|
| const nDots = Math.max(26 - prefix.length, 3);
|
| el.textContent = prefix;
|
| const per = stall / nDots;
|
| for (let d = 0; d < nDots; d++) {
|
| await sleep(per);
|
| el.textContent += ".";
|
| }
|
| await sleep(40);
|
| el.textContent = lineText(entry);
|
| }
|
| }
|
| } else if (bootDone) {
|
| break;
|
| } else {
|
|
|
|
|
|
|
| await sleep(failed ? 500 : 60);
|
| }
|
| }
|
| const ready = document.createElement("div");
|
| ready.textContent = "READY.";
|
| postEl.appendChild(ready);
|
| playBiosTick();
|
| await sleep(500);
|
| setOff(true);
|
|
|
|
|
|
|
| await sleep(500);
|
| setVisible(false);
|
| useGame.setState({ biosVisible: false });
|
|
|
| gameApi.startEntrance?.();
|
| }
|
|
|
| return (
|
| <Box
|
| sx={{
|
| position: "fixed",
|
| inset: 0,
|
| display: visible ? "flex" : "none",
|
| // POST readout sits bottom-left like a real BIOS, not centered
|
| alignItems: "flex-end",
|
| justifyContent: "flex-start",
|
| p: "2.2rem 2.4rem",
|
| background: INK,
|
| zIndex: 20,
|
| pointerEvents: "none",
|
| opacity: off ? 0 : 1,
|
| transition: "opacity 0.45s ease",
|
| }}
|
| >
|
| <Box
|
| ref={postRef}
|
| sx={{
|
| minWidth: "min(380px, 84vw)",
|
| // Grow upward from the bottom-left anchor; when the log outgrows
|
| // the viewport, clip the OLDEST lines at the top so the newest
|
| // line + cursor stay visible.
|
| maxHeight: "calc(100vh - 4.4rem)",
|
| display: "flex",
|
| flexDirection: "column",
|
| justifyContent: "flex-end",
|
| overflow: "hidden",
|
| // Raw 90s POST look: small, tight, unsmoothed monospace.
|
| fontFamily: MONO,
|
| fontSize: "12px",
|
| fontWeight: 400,
|
| lineHeight: 1.55,
|
| letterSpacing: 0,
|
| textTransform: "uppercase",
|
| textAlign: "left",
|
| // Robot orange (classic duck's BRIGHT_ORANGE, #ff7a2f in sRGB)
|
| color: "rgba(255, 122, 47, 0.92)",
|
| whiteSpace: "pre-wrap",
|
| overflowWrap: "break-word",
|
| "& > div:first-of-type": {
|
| color: "rgba(255, 255, 255, 0.85)",
|
| marginBottom: "0.55em",
|
| },
|
| // Blinking block cursor at the end of the newest line
|
| "& > div:last-of-type::after": {
|
| content: '"\\2588"',
|
| marginLeft: "0.15em",
|
| animation: `${postBlink} 1s steps(1) infinite`,
|
| },
|
| // Fatal boot failure: SYSTEM HALTED blinks like an old POST fault
|
| "& > div.halt": {
|
| color: "rgba(255, 82, 47, 0.95)",
|
| animation: `${postBlink} 1s steps(1) infinite`,
|
| },
|
| }}
|
| />
|
| </Box>
|
| );
|
| }
|
|
|