Skip to content
Snippets Groups Projects
index.js 12.5 KiB
Newer Older
  • Learn to ignore specific revisions
  • import {
        default as wasm,
        Chip8Neo
    } from "./chip_ahoyto.js";
    
    const PIXEL_SET_COLOR = 0x50cb93ff;
    const PIXEL_UNSET_COLOR = 0x1b1a17ff;
    
    
    const LOGIC_HZ = 600;
    
    const VISUAL_HZ = 60;
    
    const TIMER_HZ = 60;
    
    const FREQUENCY_DELTA = 60
    
    
    const DISPLAY_WIDTH = 64;
    const DISPLAY_HEIGHT = 32;
    
    
    const BACKGROUNDS = [
        "1b1a17",
        "023047",
        "bc6c25",
        "264653",
        "283618"
    ]
    
    
    const KEYS = {
        "1": 0x01,
        "2": 0x02,
        "3": 0x03,
        "4": 0x0c,
        "q": 0x04,
        "w": 0x05,
        "e": 0x06,
        "r": 0x0d,
        "a": 0x07,
        "s": 0x08,
        "d": 0x09,
        "f": 0x0e,
        "z": 0x0a,
        "x": 0x00,
        "c": 0x0b,
        "v": 0x0f
    }
    
    
    const ROM_PATH = "res/roms/pong.ch8";
    
    const ROM_NAME = "pong.ch8";
    
    const state = {
    
        logicFrequency: LOGIC_HZ,
    
        visualFrequency: VISUAL_HZ,
        timerFrequency: TIMER_HZ,
    
        canvas: null,
        canvasScaled: null,
        canvasCtx: null,
        canvasScaledCtx: null,
        image: null,
    
        videoBuff: null,
    
        toastTimeout: null,
    
        paused: false,
    
        background_index: 0,
    
        nextTickTime: 0,
        fps: VISUAL_HZ,
    
        frameStart: new Date().getTime(),
    
        frameCount: 0
    
    
    (async () => {
        // initializes the WASM module, this is required
        // so that the global symbols become available
        await wasm();
    
    
        // initializes the complete set of sub-systems
        // and registers the event handlers
        init();
        register();
    
        // loads the ROM data and converts it into the
    
        // target u8 array buffer
    
        const response = await fetch(ROM_PATH);
    
        const blob = await response.blob();
        const arrayBuffer = await blob.arrayBuffer();
        const data = new Uint8Array(arrayBuffer);
    
    
        // updates the ROM information on display
        setRom(ROM_NAME, data.length);
    
        setLogicFrequency(state.logicFrequency);
    
        setFps(state.fps);
    
        // creates the CHIP-8 instance and resets it
    
        state.chip8 = new Chip8Neo();
        state.chip8.reset_hard_ws();
        state.chip8.load_rom_ws(data);
    
        // runs the sequence as an infinite loop, running
        // the associated CPU cycles accordingly
    
        while (true) {
    
            if (state.paused) {
                await new Promise((resolve) => {
                    setTimeout(resolve, 100);
                });
                continue;
            }
    
    
            let currentTime = new Date().getTime();
    
            // in case the time to draw the next frame has been
            // reached the flush of the logic and visuals is done
    
            if (currentTime >= state.nextTickTime) {
    
                const ratioLogic = state.logicFrequency / state.visualFrequency;
    
                for (let i = 0; i < ratioLogic; i++) {
                    state.chip8.clock_ws();
                }
    
                const ratioTimer = state.timerFrequency / state.visualFrequency;
    
                for (let i = 0; i < ratioTimer; i++) {
                    state.chip8.clock_dt_ws();
                    state.chip8.clock_st_ws();
                }
    
                // updates the canvas object with the new
                // visual information coming in
                updateCanvas(state.chip8.vram_ws());
    
    
                if (state.frameCount === state.visualFrequency * 3) {
                    const currentTime = new Date().getTime();
                    const deltaTime = (currentTime - state.frameStart) / 1000;
                    const fps = parseInt(Math.round(state.frameCount / deltaTime));
                    setFps(fps);
                    state.frameCount = 0;
                    state.frameStart = new Date().getTime();
                }
    
                // increments the number of frames rendered in the current
                // section, this value is going to be used to calculate FPS
                state.frameCount += 1;
    
                // updates the next update time reference to the, so that it
                // can be used to control the game loop
                state.nextTickTime = Math.max(
                    state.nextTickTime + 1000 / state.visualFrequency,
                    currentTime
                );
    
            // calculates the amount of time until the next draw operation
            // this is the amount of time that is going to be pending
            currentTime = new Date().getTime();
            const pendingTime = Math.max(state.nextTickTime - currentTime, 0);
    
    
            // waits a little bit for the next frame to be draw,
    
            // this should control the flow of render
    
            await new Promise((resolve) => {
    
                setTimeout(resolve, pendingTime);
    
    const register = () => {
        registerDrop();
        registerKeys();
    
        registerButtons();
    
        registerToast();
    };
    
    const registerDrop = () => {
    
        document.addEventListener("drop", async (event) => {
    
            if (!event.dataTransfer.files || event.dataTransfer.files.length === 0) return;
    
    
            event.preventDefault();
            event.stopPropagation();
    
            const overlay = document.getElementById("overlay");
            overlay.classList.remove("visible");
    
    
            const file = event.dataTransfer.files[0];
    
            if (!file.name.endsWith(".ch8")) {
    
                showToast("This is probably not a CHIP-8 ROM file!", true);
    
            const arrayBuffer = await file.arrayBuffer();
            const data = new Uint8Array(arrayBuffer);
    
            state.chip8.reset_hard_ws();
            state.chip8.load_rom_ws(data);
    
            setRom(file.name, file.size);
    
    
            showToast(`Loaded ${file.name} ROM successfully!`);
    
        document.addEventListener("dragover", async (event) => {
    
            if (!event.dataTransfer.items || event.dataTransfer.items[0].type) return;
    
    
            event.preventDefault();
    
            const overlay = document.getElementById("overlay");
            overlay.classList.add("visible");
        });
        document.addEventListener("dragenter", async (event) => {
    
            if (!event.dataTransfer.items || event.dataTransfer.items[0].type) return;
    
            const overlay = document.getElementById("overlay");
            overlay.classList.add("visible");
        });
        document.addEventListener("dragleave", async (event) => {
    
            if (!event.dataTransfer.items || event.dataTransfer.items[0].type) return;
    
            const overlay = document.getElementById("overlay");
            overlay.classList.remove("visible");
    
    };
    
    const registerKeys = () => {
        document.addEventListener("keydown", (event) => {
            const keyCode = KEYS[event.key];
    
    João Magalhães's avatar
    João Magalhães committed
            if (keyCode !== undefined) {
    
                state.chip8.key_press_ws(keyCode);
                return;
            }
    
    
            switch (event.key) {
    
                    setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA);
    
                    setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA);
    
    João Magalhães's avatar
    João Magalhães committed
    
                case "Escape":
                    const chipCanvas = document.getElementById("chip-canvas");
                    chipCanvas.classList.remove("fullscreen");
                    break;
    
            }
        });
    
        document.addEventListener("keyup", (event) => {
            const keyCode = KEYS[event.key];
    
    João Magalhães's avatar
    João Magalhães committed
            if (keyCode !== undefined) {
    
                state.chip8.key_lift_ws(keyCode);
                return;
            }
        });
    
    const registerButtons = () => {
        const logicFrequencyPlus = document.getElementById("logic-frequency-plus");
    
        logicFrequencyPlus.addEventListener("click", () => {
    
            setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA);
    
        });
    
        const logicFrequencyMinus = document.getElementById("logic-frequency-minus");
    
        logicFrequencyMinus.addEventListener("click", () => {
    
            setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA);
    
        const buttonPause = document.getElementById("button-pause");
    
        buttonPause.addEventListener("click", () => {
    
            toggleRunning();
        });
    
    
    João Magalhães's avatar
    João Magalhães committed
        const buttonBenchmark = document.getElementById("button-benchmark");
    
        buttonBenchmark.addEventListener("click", () => {
    
            buttonBenchmark.classList.add("enabled");
            pause();
            try {
                const initial = Date.now();
                const count = 500000000;
    
                for (let i = 0; i < count; i++) {
    
                    state.chip8.clock_ws();
                }
                const delta = (Date.now() - initial) / 1000;
                const frequency_mhz = count / delta / 1000 / 1000;
    
                showToast(`Took ${delta.toFixed(2)} seconds to run ${count} ticks (${frequency_mhz.toFixed(2)} Mhz)!`, undefined, 7500);
    
            } finally {
                resume();
                buttonBenchmark.classList.remove("enabled");
            }
    
    João Magalhães's avatar
    João Magalhães committed
        });
    
        const buttonFullscreen = document.getElementById("button-fullscreen");
    
        buttonFullscreen.addEventListener("click", () => {
    
    João Magalhães's avatar
    João Magalhães committed
            const chipCanvas = document.getElementById("chip-canvas");
            chipCanvas.classList.add("fullscreen");
        });
    
        const buttonInformation = document.getElementById("button-information");
    
        buttonInformation.addEventListener("click", () => {
    
    João Magalhães's avatar
    João Magalhães committed
            const sectionDiag = document.getElementById("section-diag");
            const separatorDiag = document.getElementById("separator-diag");
            if (buttonInformation.classList.contains("enabled")) {
                sectionDiag.style.display = "none";
                separatorDiag.style.display = "none";
                buttonInformation.classList.remove("enabled");
            } else {
                sectionDiag.style.display = "block";
                separatorDiag.style.display = "block";
                buttonInformation.classList.add("enabled");
            }
        });
    
    
        const buttonTheme = document.getElementById("button-theme");
        buttonTheme.addEventListener("click", () => {
            state.background_index = (state.background_index + 1) % BACKGROUNDS.length;
            const background = BACKGROUNDS[state.background_index];
            document.body.style.backgroundColor = `#${background}`;
            document.getElementById("footer").style.backgroundColor = `#${background}`;
        });
    
    };
    
    const registerToast = () => {
        const toast = document.getElementById("toast");
        toast.addEventListener("click", (event) => {
            toast.classList.remove("visible");
        });
    };
    
    const showToast = async (message, error = false, timeout = 3500) => {
        const toast = document.getElementById("toast");
        toast.classList.remove("error");
        if (error) toast.classList.add("error");
        toast.classList.add("visible");
        toast.textContent = message;
        if (state.toastTimeout) clearTimeout(state.toastTimeout);
        state.toastTimeout = setTimeout(() => {
            toast.classList.remove("visible");
            state.toastTimeout = null;
        }, timeout);
    
    const setRom = (name, size) => {
        state.romName = name;
        state.romSize = size;
        document.getElementById("rom-name").textContent = name;
        document.getElementById("rom-size").textContent = String(size);
    
    const setLogicFrequency = (value) => {
    
        if (value < 0) showToast("Invalid frequency value!", true);
    
        value = Math.max(value, 0);
    
        state.logicFrequency = value;
    
        document.getElementById("logic-frequency").textContent = value;
    
    const setFps = (value) => {
        if (value < 0) showToast("Invalid FPS value!", true);
        value = Math.max(value, 0);
        state.fps = value;
        document.getElementById("fps-count").textContent = value;
    };
    
    
    const toggleRunning = () => {
        const buttonPause = document.getElementById("button-pause");
        if (buttonPause.textContent === "Resume") {
            resume();
        } else {
            pause();
        }
    };
    
    const pause = () => {
        state.paused = true;
        const buttonPause = document.getElementById("button-pause");
    
        buttonPause.classList.add("enabled");
    
        buttonPause.textContent = "Resume";
    }
    
    const resume = () => {
        state.paused = false;
        const buttonPause = document.getElementById("button-pause");
    
        buttonPause.classList.remove("enabled");
    
        buttonPause.textContent = "Pause";
    }
    
    
    const init = () => {
        initCanvas();
    
    const initCanvas = () => {
        // initializes the off-screen canvas that is going to be
    
        // used in the drawing process
    
        state.canvas = document.createElement("canvas");
    
        state.canvas.width = DISPLAY_WIDTH;
        state.canvas.height = DISPLAY_HEIGHT;
    
        state.canvasCtx = state.canvas.getContext("2d");
    
        state.canvasScaled = document.getElementById("chip-canvas");
        state.canvasScaledCtx = state.canvasScaled.getContext("2d");
    
        state.canvasScaledCtx.scale(state.canvasScaled.width / state.canvas.width, state.canvasScaled.height / state.canvas.height);
        state.canvasScaledCtx.imageSmoothingEnabled = false;
    
        state.image = state.canvasCtx.createImageData(state.canvas.width, state.canvas.height);
        state.videoBuff = new DataView(state.image.data.buffer);
    
    
    const updateCanvas = (pixels) => {
        for (let i = 0; i < pixels.length; i++) {
            state.videoBuff.setUint32(i * 4, pixels[i] ? PIXEL_SET_COLOR : PIXEL_UNSET_COLOR);
        }
        state.canvasCtx.putImageData(state.image, 0, 0);
        state.canvasScaledCtx.drawImage(state.canvas, 0, 0);