Skip to content
Snippets Groups Projects
app.tsx 28.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • import React, { FC, ReactNode, useEffect, useRef, useState } from "react";
    
    import ReactDOM from "react-dom/client";
    
    
    declare const require: any;
    
    
        ButtonIncrement,
        ButtonSwitch,
    
        ClearHandler,
    
        Display,
    
        Footer,
    
        Help,
    
        KeyboardChip8,
    
        PanelSplit,
    
        Paragraph,
    
        Section,
    
        Title,
        Toast
    
    } from "./components";
    
    import {
        Emulator,
        Feature,
        FREQUENCY_DELTA,
        PixelFormat,
        RomInfo
    } from "./structs";
    
    
    import "./app.css";
    
    
    type EmulatorAppProps = {
    
        emulator: Emulator;
    
        fullscreen?: boolean;
        debug?: boolean;
        keyboard?: boolean;
    
        palette?: string;
    
        backgrounds?: string[];
    };
    
    
    const isTouchDevice = () => {
        return "ontouchstart" in window || navigator.maxTouchPoints > 0;
    };
    
    
    export const EmulatorApp: FC<EmulatorAppProps> = ({
    
        emulator,
        fullscreen = false,
        debug = false,
        keyboard = false,
    
        backgrounds = ["264653"]
    }) => {
    
        const [paused, setPaused] = useState(false);
    
        const [fullscreenState, setFullscreenState] = useState(fullscreen);
    
        const [backgroundIndex, setBackgroundIndex] = useState(0);
    
        const [romInfo, setRomInfo] = useState<RomInfo>({});
        const [framerate, setFramerate] = useState(0);
    
        const [paletteName, setPaletteName] = useState(emulator.palette);
    
        const [keyaction, setKeyaction] = useState<string>();
        const [modalTitle, setModalTitle] = useState<string>();
        const [modalText, setModalText] = useState<string>();
    
        const [modalContents, setModalContents] = useState<ReactNode>();
    
        const [modalVisible, setModalVisible] = useState(false);
        const [toastText, setToastText] = useState<string>();
        const [toastError, setToastError] = useState(false);
        const [toastVisible, setToastVisible] = useState(false);
    
        const [keyboardVisible, setKeyboardVisible] = useState(
            isTouchDevice() || keyboard
        );
    
        const [infoVisible, setInfoVisible] = useState(true);
    
        const [debugVisible, setDebugVisible] = useState(debug);
    
        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) {
    
                    emulator.frequency +=
                        emulator.frequencyDelta ?? FREQUENCY_DELTA;
    
                    setKeyaction(undefined);
                    break;
                case "Minus":
    
                    emulator.frequency -=
                        emulator.frequencyDelta ?? FREQUENCY_DELTA;
    
                    setKeyaction(undefined);
                    break;
    
                case "Escape":
    
                    setFullscreenState(false);
    
                    setKeyaction(undefined);
                    break;
                case "Fullscreen":
    
                    setFullscreenState(!fullscreenState);
    
                    setKeyaction(undefined);
                    break;
    
                case "Keyboard":
                    setKeyboardVisible(!keyboardVisible);
                    setKeyaction(undefined);
                    break;
                case "Accelerate":
                    emulator.frequency *= 8;
                    break;
                case "Slowdown":
                    emulator.frequency /= 8;
                    break;
    
            }
        }, [keyaction]);
        useEffect(() => {
    
            if (palette) {
    
                emulator.palette = palette;
    
            const onFullChange = (event: Event) => {
    
                if (
                    !document.fullscreenElement &&
                    !(document as any).webkitFullscreenElement
                ) {
    
                    setFullscreenState(false);
                }
            };
    
            const onKeyDown = (event: KeyboardEvent) => {
    
                switch (event.key) {
                    case "+":
                        setKeyaction("Plus");
                        event.stopPropagation();
                        event.preventDefault();
                        break;
                    case "-":
                        setKeyaction("Minus");
                        event.stopPropagation();
                        event.preventDefault();
                        break;
                    case "Escape":
                        setKeyaction("Escape");
                        event.stopPropagation();
                        event.preventDefault();
                        break;
    
                if (event.ctrlKey === true) {
                    switch (event.key) {
                        case "f":
                            setKeyaction("Fullscreen");
                            event.stopPropagation();
                            event.preventDefault();
                            break;
                        case "k":
                            setKeyaction("Keyboard");
                            event.stopPropagation();
                            event.preventDefault();
                            break;
                        case "d":
                            setKeyaction("Accelerate");
                            event.stopPropagation();
                            event.preventDefault();
                            break;
                    }
                }
            };
            const onKeyUp = (event: KeyboardEvent) => {
                if (event.ctrlKey === true) {
                    switch (event.key) {
                        case "d":
                            setKeyaction("Slowdown");
                            event.stopPropagation();
                            event.preventDefault();
                            break;
                    }
    
            };
            const onBooted = () => {
    
                setRomInfo(emulator.romInfo);
    
                setPaused(false);
    
            };
            const onMessage = (
                emulator: Emulator,
                params: Record<string, any> = {}
            ) => {
    
                showToast(params.text, params.error, params.timeout);
    
            document.addEventListener("fullscreenchange", onFullChange);
    
            document.addEventListener("webkitfullscreenchange", onFullChange);
    
            document.addEventListener("keydown", onKeyDown);
    
            document.addEventListener("keyup", onKeyUp);
    
            emulator.bind("booted", onBooted);
            emulator.bind("message", onMessage);
            return () => {
    
                document.removeEventListener("fullscreenchange", onFullChange);
    
                document.removeEventListener(
                    "webkitfullscreenchange",
                    onFullChange
                );
    
                document.removeEventListener("keydown", onKeyDown);
    
                document.removeEventListener("keyup", onKeyUp);
    
                emulator.unbind("booted", onBooted);
                emulator.unbind("message", onMessage);
            };
    
        const getPauseText = () => (paused ? "Resume" : "Pause");
        const getPauseIcon = () =>
            paused ? require("../res/play.svg") : require("../res/pause.svg");
    
        const getBackground = () => backgrounds[backgroundIndex];
    
    
        const showModal = async (
    
            title = "Alert",
            text?: string,
            contents?: ReactNode
    
        ): Promise<boolean> => {
            setModalTitle(title);
    
            setModalText(text);
            setModalContents(contents);
    
            setModalVisible(true);
            const result = (await new Promise((resolve) => {
                modalCallbackRef.current = resolve;
            })) as boolean;
            return result;
        };
    
        const showHelp = async (title = "Help") => {
            await showModal(title, undefined, <Help />);
        };
    
        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 hasFeature = (feature: Feature) => {
            return emulator.features.includes(feature);
        };
    
        const onFile = async (file: File) => {
    
            const fileExtension = file.name.split(".").pop() ?? "";
            if (!emulator.romExts.includes(fileExtension)) {
    
                    `This is probably not a ${emulator.device.text} ROM file!`,
    
                return;
            }
    
            const arrayBuffer = await file.arrayBuffer();
            const romData = new Uint8Array(arrayBuffer);
    
            emulator.boot({ engine: null, romName: file.name, romData: romData });
    
            showToast(`Loaded ${file.name} ROM successfully!`);
        };
    
        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 () => {
    
            if (!emulator.benchmark) return;
    
            const result = await showModal(
    
                "Confirm",
                "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!"
    
            if (!result) return;
            const { delta, count, frequency_mhz } = emulator.benchmark();
    
            await showToast(
    
                `Took ${delta.toFixed(
                    2
                )} seconds to run ${count} ticks (${frequency_mhz.toFixed(
                    2
                )} Mhz)!`,
                undefined,
                7500
    
    João Magalhães's avatar
    João Magalhães committed
        const onFullscreenClick = () => {
    
            setFullscreenState(!fullscreenState);
    
        const onKeyboardClick = () => {
    
            setKeyboardVisible(!keyboardVisible);
    
    João Magalhães's avatar
    João Magalhães committed
        const onInformationClick = () => {
    
            setInfoVisible(!infoVisible);
        };
    
        const onHelpClick = () => {
            showHelp();
        };
    
        const onDebugClick = () => {
            setDebugVisible(!debugVisible);
    
    João Magalhães's avatar
    João Magalhães committed
        };
    
        const onThemeClick = () => {
            setBackgroundIndex((backgroundIndex + 1) % backgrounds.length);
        };
    
        const onPaletteClick = () => {
    
            const palette = emulator.changePalette?.();
            setPaletteName(palette);
    
        const onUploadFile = async (file: File) => {
            const arrayBuffer = await file.arrayBuffer();
            const romData = new Uint8Array(arrayBuffer);
            emulator.boot({ engine: null, romName: file.name, romData: romData });
            showToast(`Loaded ${file.name} ROM successfully!`);
        };
    
        const onEngineChange = (engine: string) => {
            emulator.boot({ engine: engine.toLowerCase() });
    
                `${emulator.device.text} running in engine "${engine}" from now on!`
    
        const onFrequencyChange = (value: number) => {
            emulator.frequency = value * 1000 * 1000;
        };
        const onFrequencyReady = (handler: (value: number) => void) => {
            emulator.bind("frequency", (emulator: Emulator, frequency: number) => {
                handler(frequency / 1000000);
            });
        };
    
        const onMinimize = () => {
    
            setFullscreenState(!fullscreenState);
    
        const onKeyDown = (key: string) => {
            emulator.keyPress(key);
        };
        const onKeyUp = (key: string) => {
            emulator.keyLift(key);
        };
    
        const onGamepad = (id: string, isValid: boolean, connected = true) => {
            if (connected) {
                if (isValid) showToast(`🕹️ Gamepad connect ${id}`);
                else showToast(`😥 Unsupported gamepad connect ${id}`, true);
            } else if (isValid) {
                showToast(`🕹️ Gamepad disconnected ${id}`, true);
            }
        };
    
    João Magalhães's avatar
    João Magalhães committed
        const onDrawHandler = (handler: DrawHandler) => {
    
            if (frameRef.current) return;
            frameRef.current = true;
    
            emulator.bind("frame", () => {
    
                handler(emulator.imageBuffer, PixelFormat.RGB);
                setFramerate(emulator.framerate);
    
        const onClearHandler = (handler: ClearHandler) => {
            if (errorRef.current) return;
            errorRef.current = true;
            emulator.bind("error", async () => {
                await handler(undefined, require("../res/storm.png"), 0.2);
            });
        };
    
            <div className="app">
    
                <Overlay text={"Drag to load ROM"} onFile={onFile} />
    
                <Modal
                    title={modalTitle}
                    text={modalText}
    
                    contents={modalContents}
    
                    visible={modalVisible}
    
                    onConfirm={onModalConfirm}
                    onCancel={onModalCancel}
                />
    
                <Toast
                    text={toastText}
                    error={toastError}
                    visible={toastVisible}
                    onCancel={onToastCancel}
                />
    
                <Footer color={getBackground()}>
                    Built with ❤️ by{" "}
    
    João Magalhães's avatar
    João Magalhães committed
                    <Link href="https://joao.me" target="_blank">
    
    João Magalhães's avatar
    João Magalhães committed
                    </Link>
    
                </Footer>
    
                <PanelSplit
                    left={
    
                        <div className="display-container">
    
                                fullscreen={fullscreenState}
    
                                onDrawHandler={onDrawHandler}
    
                                onClearHandler={onClearHandler}
    
                                onMinimize={onMinimize}
                            />
    
                    <Section visible={keyboardVisible} separatorBottom={true}>
    
                        {hasFeature(Feature.KeyboardChip8) && (
                            <KeyboardChip8
                                onKeyDown={onKeyDown}
                                onKeyUp={onKeyUp}
                            />
                        )}
                        {hasFeature(Feature.KeyboardGB) && (
                            <KeyboardGB
                                fullscreen={fullscreenState}
                                onKeyDown={onKeyDown}
                                onKeyUp={onKeyUp}
    
                                onGamepad={onGamepad}
    
                    </Section>
    
                        text={emulator.name}
    
                        version={emulator.version?.text}
                        versionUrl={emulator.version?.url}
    
                        iconSrc={require("../res/thunder.png")}
                    ></Title>
    
                    <Section>
                        <Paragraph>
                            This is a{" "}
    
                            {emulator.device.url ? (
                                <Link href={emulator.device.url} target="_blank">
                                    {emulator.device.text}
    
                                </Link>
                            ) : (
    
                                emulator.device.text
    
                            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>
    
                        {emulator.repository && (
                            <Paragraph>
                                You can check the source code of it at{" "}
    
                                {emulator.repository.url ? (
    
                                        href={emulator.repository.url}
    
                                        target="_blank"
                                    >
    
                                        {emulator.repository.text}
    
                                    </Link>
                                ) : (
    
                                    <>{emulator.repository.text}</>
    
                        <Paragraph>
                            TIP: Drag and Drop ROM files to the Browser to load the
                            ROM.
                        </Paragraph>
                    </Section>
    
                    {debugVisible && (
                        <Section>
    
                            <div
                                style={{
                                    display: "inline-block",
                                    verticalAlign: "top",
                                    marginRight: 32,
                                    width: 256
                                }}
                            >
                                <h3>VRAM Tiles</h3>
                                <Tiles
                                    getTile={(index) => emulator.getTile(index)}
                                    tileCount={384}
                                    width={"100%"}
                                    contentBox={false}
                                />
                            </div>
                            <div
                                style={{
                                    display: "inline-block",
                                    verticalAlign: "top"
                                }}
                            >
                                <h3>Registers</h3>
    
                                <RegistersGB
                                    getRegisters={() => emulator.registers}
                                />
    
                        </Section>
                    )}
                    {infoVisible && (
                        <Section>
    
                            <PanelTab
                                tabs={[
                                    <Info>
                                        <Pair
                                            key="button-engine"
                                            name={"Engine"}
                                            valueNode={
                                                <ButtonSwitch
                                                    options={emulator.engines.map(
                                                        (e) => e.toUpperCase()
                                                    )}
                                                    size={"large"}
                                                    style={["simple"]}
                                                    onChange={onEngineChange}
                                                />
                                            }
    
                                        <Pair
                                            key="rom"
                                            name={"ROM"}
                                            value={romInfo.name ?? "-"}
                                        />
                                        <Pair
                                            key="rom-size"
                                            name={"ROM Size"}
                                            value={
                                                romInfo.size
                                                    ? `${new Intl.NumberFormat().format(
                                                          romInfo.size
                                                      )} bytes`
                                                    : "-"
    
                                        <Pair
                                            key="button-frequency"
                                            name={"CPU Frequency"}
                                            valueNode={
                                                <ButtonIncrement
                                                    value={
                                                        emulator.frequency /
                                                        1000 /
                                                        1000
                                                    }
                                                    delta={
                                                        (emulator.frequencyDelta ??
                                                            FREQUENCY_DELTA) /
                                                        1000 /
                                                        1000
                                                    }
                                                    min={0}
                                                    suffix={"MHz"}
                                                    decimalPlaces={2}
                                                    onChange={onFrequencyChange}
                                                    onReady={onFrequencyReady}
                                                />
                                            }
                                        />
                                        <Pair
                                            key="rom-type"
                                            name={"ROM Type"}
                                            value={
                                                romInfo.extra?.romType
                                                    ? `${romInfo.extra?.romType}`
                                                    : "-"
                                            }
                                        />
                                        <Pair
                                            key="framerate"
                                            name={"Framerate"}
                                            value={`${framerate} fps`}
                                        />
                                    </Info>,
                                    <Info>
                                        <Pair
                                            key="palette"
                                            name={"Palette"}
                                            value={paletteName}
                                        />
                                    </Info>
                                ]}
                                tabNames={["General", "Detailed"]}
                            />
    
                        </Section>
                    )}
    
                    <Section>
    
                        <ButtonContainer>
                            <Button
    
                                text={getPauseText()}
                                image={getPauseIcon()}
                                imageAlt="pause"
    
                                enabled={paused}
    
                                style={["simple", "border", "padded"]}
    
                                onClick={onPauseClick}
    
                            />
                            <Button
                                text={"Reset"}
                                image={require("../res/reset.svg")}
                                imageAlt="reset"
    
                                style={["simple", "border", "padded"]}
    
                                onClick={onResetClick}
                            />
    
                            {hasFeature(Feature.Benchmark) && (
                                <Button
                                    text={"Benchmark"}
                                    image={require("../res/bolt.svg")}
                                    imageAlt="benchmark"
                                    style={["simple", "border", "padded"]}
                                    onClick={onBenchmarkClick}
                                />
                            )}
    
    João Magalhães's avatar
    João Magalhães committed
                            <Button
                                text={"Fullscreen"}
                                image={require("../res/maximise.svg")}
                                imageAlt="maximise"
    
                                style={["simple", "border", "padded"]}
    
    João Magalhães's avatar
    João Magalhães committed
                                onClick={onFullscreenClick}
                            />
    
                            {hasFeature(Feature.Keyboard) && (
                                <Button
                                    text={"Keyboard"}
                                    image={require("../res/dialpad.svg")}
                                    imageAlt="keyboard"
                                    enabled={keyboardVisible}
                                    style={["simple", "border", "padded"]}
                                    onClick={onKeyboardClick}
                                />
                            )}
    
    João Magalhães's avatar
    João Magalhães committed
                            <Button
                                text={"Information"}
                                image={require("../res/info.svg")}
    
                                imageAlt="information"
    
                                enabled={infoVisible}
                                style={["simple", "border", "padded"]}
    
    João Magalhães's avatar
    João Magalhães committed
                                onClick={onInformationClick}
                            />
    
                            <Button
                                text={"Help"}
                                image={require("../res/help.svg")}
                                imageAlt="help"
                                style={["simple", "border", "padded"]}
                                onClick={onHelpClick}
                            />
    
                            {hasFeature(Feature.Debug) && (
                                <Button
                                    text={"Debug"}
                                    image={require("../res/bug.svg")}
                                    imageAlt="debug"
                                    enabled={debugVisible}
                                    style={["simple", "border", "padded"]}
                                    onClick={onDebugClick}
                                />
                            )}
    
                            <Button
                                text={"Theme"}
                                image={require("../res/marker.svg")}
    
                                imageAlt="theme"
    
                                style={["simple", "border", "padded"]}
    
                                onClick={onThemeClick}
                            />
    
                            {hasFeature(Feature.Palettes) && (
                                <Button
                                    text={"Palette"}
                                    image={require("../res/brightness.svg")}
                                    imageAlt="palette"
                                    style={["simple", "border", "padded"]}
                                    onClick={onPaletteClick}
                                />
                            )}
    
    João Magalhães's avatar
    João Magalhães committed
                                text={"Load ROM"}
    
                                image={require("../res/upload.svg")}
                                imageAlt="upload"
                                file={true}
                                accept={".gb"}
    
                                style={["simple", "border", "padded"]}
    
                                onFile={onUploadFile}
                            />
    
                    </Section>
                </PanelSplit>
    
    export const startApp = (
        element: string,
    
        {
            emulator,
            fullscreen = false,
            debug = false,
            keyboard = false,
    
            backgrounds = []
        }: {
            emulator: Emulator;
            fullscreen?: boolean;
            debug?: boolean;
            keyboard?: boolean;
    
            palette?: string;
    
            backgrounds: string[];
        }
    
        const elementRef = document.getElementById(element);
    
        if (!elementRef) return;
    
    
        const root = ReactDOM.createRoot(elementRef);
    
            <EmulatorApp
    
                emulator={emulator}
                fullscreen={fullscreen}
                debug={debug}
                keyboard={keyboard}
    
                palette={palette}
    
                backgrounds={backgrounds}
            />
        );
    
    export default EmulatorApp;