import React, { FC, useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom/client"; declare const require: any; import { Button, ButtonContainer, ButtonIncrement, ButtonSwitch, ClearHandler, Display, DrawHandler, Footer, Info, Link, Modal, Pair, PanelSplit, Paragraph, Section, Title, Toast } from "./components"; import "./app.css"; export type Callback<T> = (owner: T, params?: Record<string, any>) => void; /** * Abstract class that implements the basic functionality * part of the definition of the Observer pattern. * * @see {@link https://en.wikipedia.org/wiki/Observer_pattern} */ export class Observable { private events: Record<string, [Callback<this>]> = {}; bind(event: string, callback: Callback<this>) { const callbacks = this.events[event] ?? []; if (callbacks.includes(callback)) return; callbacks.push(callback); this.events[event] = callbacks; } trigger(event: string, params?: Record<string, any>) { const callbacks = this.events[event] ?? []; callbacks.forEach((c) => c(this, params)); } } export type RomInfo = { name?: string; data?: Uint8Array; size?: number; extra?: Record<string, string | undefined>; }; export interface ObservableI { bind(event: string, callback: Callback<this>): void; trigger(event: string): void; } /** * Top level interface that declares the main abstract * interface of an emulator structured entity. * Should allow typical hardware operations to be performed. */ export interface Emulator extends ObservableI { /** * Obtains the descriptive name of the emulator. * * @returns The descriptive name of the emulator. */ getName(): string; /** * Obtains a semantic version string for the current * version of the emulator. * * @returns The semantic version string. * @see {@link https://semver.org} */ getVersion(): string; /** * Obtains a URL to the page describing the current version * of the emulator. * * @returns A URL to the page describing the current version * of the emulator. */ getVersionUrl(): string; /** * Obtains the pixel format of the emulator's display * image buffer (eg: RGB). * * @returns The pixel format used for the emulator's * image buffer. */ getPixelFormat(): PixelFormat; /** * Obtains the complete image buffer as a sequence of * bytes that respects the current pixel format from * `getPixelFormat()`. This method returns an in memory * pointer to the heap and not a copy. * * @returns The byte based image buffer that respects * the emulator's pixel format. */ getImageBuffer(): Uint8Array; /** * Obtains information about the ROM that is currently * loaded in the emulator. * * @returns Structure containing the information about * the ROM that is currently loaded in the emulator. */ getRomInfo(): RomInfo; /** * Returns the current logic framerate of the running * emulator. * * @return The current logic framerate of the running * emulator. */ getFramerate(): number; /** * Toggle the running state of the emulator between paused * and running, prevents consumers from the need to access * the current running state of the emulator to implement * a logic toggle. */ toggleRunning(): void; pause(): void; resume(): void; /** * Resets the emulator machine to the start state and * re-loads the ROM that is currently set in the emulator. */ reset(): void; } /** * Enumeration that describes the multiple pixel * formats and the associated size in bytes. */ export enum PixelFormat { RGB = 3, RGBA = 4 } type AppProps = { emulator: Emulator; backgrounds?: string[]; }; export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { const [paused, setPaused] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [backgroundIndex, setBackgroundIndex] = useState(0); const [romInfo, setRomInfo] = useState<RomInfo>({}); const [framerate, setFramerate] = useState(0); const [keyaction, setKeyaction] = useState<string>(); const [modalTitle, setModalTitle] = useState<string>(); const [modalText, setModalText] = useState<string>(); const [modalVisible, setModalVisible] = useState(false); const [toastText, setToastText] = useState<string>(); const [toastError, setToastError] = useState(false); const [toastVisible, setToastVisible] = useState(false); const toastCounterRef = useRef(0); const frameRef = useRef<boolean>(false); const errorRef = useRef<boolean>(false); const modalCallbackRef = useRef<(value: boolean | PromiseLike<boolean>) => void>(); useEffect(() => { document.body.style.backgroundColor = `#${getBackground()}`; }, [backgroundIndex]); useEffect(() => { switch (keyaction) { case "Escape": setFullscreen(false); setKeyaction(undefined); break; case "Fullscreen": setFullscreen(!fullscreen); setKeyaction(undefined); break; } }, [keyaction]); useEffect(() => { document.addEventListener("keydown", (event) => { if (event.key === "Escape") { setKeyaction("Escape"); event.stopPropagation(); event.preventDefault(); } if (event.key === "f" && event.ctrlKey === true) { setKeyaction("Fullscreen"); event.stopPropagation(); event.preventDefault(); } }); emulator.bind("booted", () => { const romInfo = emulator.getRomInfo(); setRomInfo(romInfo); }); emulator.bind("message", (_, params = {}) => { showToast(params.text, params.error, params.timeout); }); }, []); const getPauseText = () => (paused ? "Resume" : "Pause"); const getPauseIcon = () => paused ? require("../res/play.svg") : require("../res/pause.svg"); const getBackground = () => backgrounds[backgroundIndex]; const showModal = async ( text: string, title = "Alert" ): Promise<boolean> => { setModalText(text); setModalTitle(title); setModalVisible(true); const result = (await new Promise((resolve) => { modalCallbackRef.current = resolve; })) as boolean; return result; }; const showToast = async (text: string, error = false, timeout = 3500) => { setToastText(text); setToastError(error); setToastVisible(true); toastCounterRef.current++; const counter = toastCounterRef.current; await new Promise((resolve) => { setTimeout(() => { if (counter !== toastCounterRef.current) return; setToastVisible(false); resolve(true); }, timeout); }); }; const onModalConfirm = () => { if (modalCallbackRef.current) { modalCallbackRef.current(true); modalCallbackRef.current = undefined; } setModalVisible(false); }; const onModalCancel = () => { if (modalCallbackRef.current) { modalCallbackRef.current(false); modalCallbackRef.current = undefined; } setModalVisible(false); }; const onToastCancel = () => { setToastVisible(false); }; const onPauseClick = () => { emulator.toggleRunning(); setPaused(!paused); }; const onResetClick = () => { emulator.reset(); }; const onBenchmarkClick = async () => { const result = await showModal( "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!", "Confirm" ); await showToast( result ? "Will run the benchmark as fast as possible" : "Will not run the benchmark", !result ); }; const onFullscreenClick = () => { setFullscreen(!fullscreen); }; const onThemeClick = () => { setBackgroundIndex((backgroundIndex + 1) % backgrounds.length); }; const onMinimize = () => { setFullscreen(!fullscreen); }; const onDrawHandler = (handler: DrawHandler) => { if (frameRef.current) return; frameRef.current = true; emulator.bind("frame", () => { handler(emulator.getImageBuffer(), PixelFormat.RGB); setFramerate(emulator.getFramerate()); }); }; const onClearHandler = (handler: ClearHandler) => { if (errorRef.current) return; errorRef.current = true; emulator.bind("error", async () => { await handler(undefined, require("../res/storm.png"), 0.2); }); }; return ( <div className="app"> <Modal title={modalTitle} text={modalText} visible={modalVisible} onConfirm={onModalConfirm} onCancel={onModalCancel} /> <Toast text={toastText} error={toastError} visible={toastVisible} onCancel={onToastCancel} /> <Footer color={getBackground()}> Built with ❤️ by{" "} <Link href="https://joao.me" target="_blank"> João Magalhães </Link> </Footer> <PanelSplit left={ <div style={{ marginTop: 78 }}> <Display fullscreen={fullscreen} onDrawHandler={onDrawHandler} onClearHandler={onClearHandler} onMinimize={onMinimize} /> </div> } > <Title text={emulator.getName()} version={emulator.getVersion()} versionUrl={emulator.getVersionUrl()} iconSrc={require("../res/thunder.png")} ></Title> <Section> <Paragraph> This is a{" "} <Link href="https://en.wikipedia.org/wiki/Game_Boy" target="_blank" > Game Boy </Link>{" "} emulator built using the{" "} <Link href="https://www.rust-lang.org" target="_blank"> Rust Programming Language </Link>{" "} and is running inside this browser with the help of{" "} <Link href="https://webassembly.org/" target="_blank"> WebAssembly </Link> . </Paragraph> <Paragraph> You can check the source code of it at{" "} <Link href="https://gitlab.stage.hive.pt/joamag/boytacean" target="_blank" > GitLab </Link> . </Paragraph> <Paragraph> TIP: Drag and Drop ROM files to the Browser to load the ROM. </Paragraph> </Section> <Section> <ButtonContainer> <Button text={getPauseText()} image={getPauseIcon()} imageAlt="pause" onClick={onPauseClick} /> <Button text={"Reset"} image={require("../res/reset.svg")} imageAlt="reset" onClick={onResetClick} /> <Button text={"Benchmark"} image={require("../res/bolt.svg")} imageAlt="benchmark" onClick={onBenchmarkClick} /> <Button text={"Fullscreen"} image={require("../res/maximise.svg")} imageAlt="maximise" onClick={onFullscreenClick} /> <Button text={"Theme"} image={require("../res/marker.svg")} imageAlt="theme" onClick={onThemeClick} /> </ButtonContainer> <Info> <Pair key="rom" name={"ROM"} value={romInfo.name ?? "-"} /> <Pair key="rom-size" name={"ROM Size"} value={romInfo.name ? `${romInfo.size} bytes` : "-"} /> <Pair key="rom-type" name={"ROM Type"} value={ romInfo.extra?.romType ? `${romInfo.extra?.romType}` : "-" } /> <Pair key="framerate" name={"Framerate"} value={`${framerate} fps`} /> <Pair key="button-tobias" name={"Button Increment"} valueNode={ <ButtonIncrement value={200} delta={100} min={0} suffix={"Hz"} /> } /> <Pair key="button-cpu" name={"Button Switch"} valueNode={ <ButtonSwitch options={["NEO", "CLASSIC"]} size={"large"} style={["simple"]} onChange={(v) => alert(v)} /> } /> </Info> </Section> </PanelSplit> </div> ); }; export const startApp = ( element: string, emulator: Emulator, backgrounds: string[] ) => { const elementRef = document.getElementById(element); if (!elementRef) { return; } const root = ReactDOM.createRoot(elementRef); root.render(<App emulator={emulator} backgrounds={backgrounds} />); }; export default App;