import { startApp } from "./react/app"; import { default as _wasm, GameBoy, PadKey, PpuMode } from "./lib/boytacean.js"; import info from "./package.json"; const PIXEL_UNSET_COLOR = 0x1b1a17ff; const LOGIC_HZ = 600; const VISUAL_HZ = 60; const TIMER_HZ = 60; const IDLE_HZ = 10; const FREQUENCY_DELTA = 60; const DISPLAY_WIDTH = 160; const DISPLAY_HEIGHT = 144; const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT; const SAMPLE_RATE = 2; const BACKGROUNDS = [ "264653", "1b1a17", "023047", "bc6c25", "283618", "2a9d8f", "3a5a40" ]; const KEYS: Record<string, number> = { ArrowUp: PadKey.Up, ArrowDown: PadKey.Down, ArrowLeft: PadKey.Left, ArrowRight: PadKey.Right, Enter: PadKey.Start, " ": PadKey.Select, a: PadKey.A, s: PadKey.B }; const ROM_PATH = require("../../res/roms/20y.gb"); // Enumeration that describes the multiple pixel // formats and the associated size in bytes. enum PixelFormat { RGB = 3, RGBA = 4 } class Emulator { /** * The Game Boy engine (probably coming from WASM) that * is going to be used for the emulation. */ private gameBoy: GameBoy | null = null; /** * The descriptive name of the engine that is currently * in use to emulate the system. */ private engine: string | null = null; private logicFrequency: number = LOGIC_HZ; private visualFrequency: number = VISUAL_HZ; private timerFrequency: number = TIMER_HZ; private idleFrequency: number = IDLE_HZ; private canvas: HTMLCanvasElement | null = null; private canvasScaled: HTMLCanvasElement | null = null; private canvasCtx: CanvasRenderingContext2D | null = null; private canvasScaledCtx: CanvasRenderingContext2D | null = null; private image: ImageData | null = null; private videoBuff: DataView | null = null; private toastTimeout: number | null = null; private paused: boolean = false; private background_index: number = 0; private nextTickTime: number = 0; private fps: number = 0; private frameStart: number = new Date().getTime(); private frameCount: number = 0; private romName: string | null = null; private romData: Uint8Array | null = null; private romSize: number = 0; async main() { // 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 await this.buildVisuals(); await this.init(); await this.register(); // start the emulator subsystem with the initial // ROM retrieved from a remote data source await this.start({ loadRom: true }); // the counter that controls the overflowing cycles // from tick to tick operation let pending = 0; // runs the sequence as an infinite loop, running // the associated CPU cycles accordingly while (true) { // in case the machine is paused we must delay the execution // a little bit until the paused state is recovered if (this.paused) { await new Promise((resolve) => { setTimeout(resolve, 1000 / this.idleFrequency); }); continue; } // obtains the current time, this value is going // to be used to compute the need for tick computation let currentTime = new Date().getTime(); try { pending = this.tick(currentTime, pending); } catch (err) { // sets the default error message to be displayed // to the user, this value may be overridden in case // a better and more explicit message can be determined let message = String(err); // verifies if the current issue is a panic one // and updates the message value if that's the case const messageNormalized = (err as Error).message.toLowerCase(); const isPanic = messageNormalized.startsWith("unreachable") || messageNormalized.startsWith("recursive use of an object"); if (isPanic) { message = "Unrecoverable error, restarting Game Boy"; } // displays the error information to both the end-user // and the developer (for diagnostics) this.showToast(message, true, 5000); console.error(err); // pauses the machine, allowing the end-user to act // on the error in a proper fashion this.pause(); // if we're talking about a panic, proper action must be taken // which in this case it means restarting both the WASM sub // system and the machine state (to be able to recover) // also sets the default color on screen to indicate the issue if (isPanic) { await this.clearCanvas(undefined, { image: require("./res/storm.png"), imageScale: 0.2 }); await wasm(); await this.start({ restore: false }); } } // 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(this.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); }); } } tick(currentTime: number, pending: number, cycles: number = 70224) { // in case the time to draw the next frame has not been // reached the flush of the "tick" logic is skipped if (currentTime < this.nextTickTime) return pending; // calculates the number of ticks that have elapsed since the // last draw operation, this is critical to be able to properly // operate the clock of the CPU in frame drop situations if (this.nextTickTime === 0) this.nextTickTime = currentTime; let ticks = Math.ceil( (currentTime - this.nextTickTime) / ((1 / this.visualFrequency) * 1000) ); ticks = Math.max(ticks, 1); // initializes the counter of cycles with the pending number // of cycles coming from the previous tick let counterCycles = pending; let lastFrame = -1; while (true) { // limits the number of cycles to the provided // cycle value passed as a parameter if (counterCycles >= cycles) { break; } // runs the Game Boy clock, this operations should // include the advance of both the CPU and the PPU counterCycles += this.gameBoy!.clock(); // in case the current PPU mode is VBlank and the // frame is different from the previously rendered // one then it's time to update the canvas if ( this.gameBoy!.ppu_mode() == PpuMode.VBlank && this.gameBoy!.ppu_frame() != lastFrame ) { // updates the canvas object with the new // visual information coming in this.updateCanvas( this.gameBoy!.frame_buffer_eager(), PixelFormat.RGB ); lastFrame = this.gameBoy!.ppu_frame(); } } // increments the number of frames rendered in the current // section, this value is going to be used to calculate FPS this.frameCount += 1; // in case the target number of frames for FPS control // has been reached calculates the number of FPS and // flushes the value to the screen if (this.frameCount === this.visualFrequency * SAMPLE_RATE) { const currentTime = new Date().getTime(); const deltaTime = (currentTime - this.frameStart) / 1000; const fps = Math.round(this.frameCount / deltaTime); this.setFps(fps); this.frameCount = 0; this.frameStart = currentTime; } // updates the next update time reference to the, so that it // can be used to control the game loop this.nextTickTime += (1000 / this.visualFrequency) * ticks; // calculates the new number of pending (overflow) cycles // that are going to be added to the next iteration return counterCycles - cycles; } /** * Starts the current machine, setting the internal structure in * a proper state to start drawing and receiving input. * * @param options The options that are going to be used in the * starting of the machine. */ async start({ engine = "neo", restore = true, loadRom = false, romPath = ROM_PATH, romName = null, romData = null }: { engine?: string | null; restore?: boolean; loadRom?: boolean; romPath?: string; romName?: string | null; romData?: Uint8Array | null; } = {}) { // in case a remote ROM loading operation has been // requested then loads it from the remote origin if (loadRom) { [romName, romData] = await this.fetchRom(romPath); } else if (romName === null || romData === null) { [romName, romData] = [this.romName, this.romData]; } // selects the proper engine for execution // and builds a new instance of it switch (engine) { case "neo": this.gameBoy = new GameBoy(); break; default: if (!this.gameBoy) { throw new Error("No engine requested"); } break; } // resets the Game Boy engine to restore it into // a valid state ready to be used this.gameBoy.reset(); this.gameBoy.load_boot_default(); const cartridge = this.gameBoy.load_rom_ws(romData!); // updates the ROM name in case there's extra information // coming from the cartridge romName = cartridge.title() ? cartridge.title() : romName; // updates the name of the currently selected engine // to the one that has been provided (logic change) if (engine) this.engine = engine; // updates the complete set of global information that // is going to be displayed this.setEngine(this.engine!); this.setRom(romName!, romData!); this.setLogicFrequency(this.logicFrequency); this.setFps(this.fps); // in case the restore (state) flag is set // then resumes the machine execution if (restore) this.resume(); } async buildVisuals() { /* KeyValue.create("ROM", "-", { id: "diag:rom-name" }).mount(".diag"); KeyValue.create("ROM Size", "-", { id: "diag:rom-size" }).mount( ".diag" ); KeyValue.create("Framerate", "-", { id: "diag:framerate" }).mount( ".diag" ); KeyValue.create("ROM Type", "-", { id: "diag:rom-type" }).mount( ".diag" ); KeySwitch.create("Tobias", ["1", "2", "3"], { id: "diag:tobias" }).mount(".diag"); Button.create("Tobias", require("./res/close.svg")) .bind("click", () => alert("Hello World")) .mount(".button-area");*/ } // @todo remove this method, or at least most of it async register() { await Promise.all([ this.registerDrop(), this.registerKeys(), this.registerButtons(), this.registerKeyboard(), this.registerCanvas(), this.registerToast(), this.registerModal() ]); } async init() { await Promise.all([this.initBase(), this.initCanvas()]); } 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(".gb")) { this.showToast( "This is probably not a Game Boy ROM file!", true ); return; } const arrayBuffer = await file.arrayBuffer(); const romData = new Uint8Array(arrayBuffer); this.start({ engine: null, romName: file.name, romData: romData }); this.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"); }); } registerKeys() { document.addEventListener("keydown", (event) => { const keyCode = KEYS[event.key]; if (keyCode !== undefined) { this.gameBoy!.key_press(keyCode); return; } switch (event.key) { case "+": this.setLogicFrequency( this.logicFrequency + FREQUENCY_DELTA ); break; case "-": this.setLogicFrequency( this.logicFrequency - FREQUENCY_DELTA ); break; case "Escape": this.minimize(); break; } }); document.addEventListener("keyup", (event) => { const keyCode = KEYS[event.key]; if (keyCode !== undefined) { this.gameBoy!.key_lift(keyCode); return; } }); } registerButtons() { const engine = document.getElementById("engine")!; engine.addEventListener("click", () => { const name = this.engine == "neo" ? "classic" : "neo"; this.start({ engine: name }); this.showToast( `Game Boy running in engine "${name.toUpperCase()}" from now on!` ); }); const logicFrequencyPlus = document.getElementById( "logic-frequency-plus" )!; logicFrequencyPlus.addEventListener("click", () => { this.setLogicFrequency(this.logicFrequency + FREQUENCY_DELTA); }); const logicFrequencyMinus = document.getElementById( "logic-frequency-minus" )!; logicFrequencyMinus.addEventListener("click", () => { this.setLogicFrequency(this.logicFrequency - FREQUENCY_DELTA); }); const buttonPause = document.getElementById("button-pause")!; buttonPause.addEventListener("click", () => { this.toggleRunning(); }); const buttonReset = document.getElementById("button-reset")!; buttonReset.addEventListener("click", () => { this.reset(); }); const buttonBenchmark = document.getElementById("button-benchmark")!; buttonBenchmark.addEventListener("click", async () => { const result = await this.showModal( "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!", "Confirm" ); if (!result) return; buttonBenchmark.classList.add("enabled"); this.pause(); try { const initial = Date.now(); const count = 500000000; for (let i = 0; i < count; i++) { this.gameBoy!.clock(); } const delta = (Date.now() - initial) / 1000; const frequency_mhz = count / delta / 1000 / 1000; this.showToast( `Took ${delta.toFixed( 2 )} seconds to run ${count} ticks (${frequency_mhz.toFixed( 2 )} Mhz)!`, undefined, 7500 ); } finally { this.resume(); buttonBenchmark.classList.remove("enabled"); } }); const buttonFullscreen = document.getElementById("button-fullscreen")!; buttonFullscreen.addEventListener("click", () => { this.maximize(); }); const buttonKeyboard = document.getElementById("button-keyboard")!; buttonKeyboard.addEventListener("click", () => { const sectionKeyboard = document.getElementById("section-keyboard")!; const separatorKeyboard = document.getElementById("separator-keyboard")!; const sectionNarrative = document.getElementById("section-narrative")!; const separatorNarrative = document.getElementById( "separator-narrative" )!; if (buttonKeyboard.classList.contains("enabled")) { sectionKeyboard.style.display = "none"; separatorKeyboard.style.display = "none"; sectionNarrative.style.display = "block"; separatorNarrative.style.display = "block"; buttonKeyboard.classList.remove("enabled"); } else { sectionKeyboard.style.display = "block"; separatorKeyboard.style.display = "block"; sectionNarrative.style.display = "none"; separatorNarrative.style.display = "none"; buttonKeyboard.classList.add("enabled"); } }); const buttonDebug = document.getElementById("button-debug")!; buttonDebug.addEventListener("click", () => { const sectionDebug = document.getElementById("section-debug")!; const separatorDebug = document.getElementById("separator-debug")!; const sectionNarrative = document.getElementById("section-narrative")!; const separatorNarrative = document.getElementById( "separator-narrative" )!; if (buttonDebug.classList.contains("enabled")) { sectionDebug.style.display = "none"; separatorDebug.style.display = "none"; sectionNarrative.style.display = "block"; separatorNarrative.style.display = "block"; buttonDebug.classList.remove("enabled"); } else { sectionDebug.style.display = "block"; separatorDebug.style.display = "block"; sectionNarrative.style.display = "none"; separatorNarrative.style.display = "none"; buttonDebug.classList.add("enabled"); const canvasTiles = document.getElementById( "canvas-tiles" ) as HTMLCanvasElement; const canvasTilesCtx = canvasTiles.getContext("2d")!; canvasTilesCtx.imageSmoothingEnabled = false; const canvasImage = canvasTilesCtx.createImageData( canvasTiles.width, canvasTiles.height ); const videoBuff = new DataView(canvasImage.data.buffer); /** * Draws the tile at the given index to the proper * vertical offset in the given context and buffer. * * @param index The index of the sprite to be drawn. * @param format The pixel format of the sprite. */ const drawTile = ( index: number, context: CanvasRenderingContext2D, buffer: DataView, format: PixelFormat = PixelFormat.RGB ) => { const pixels = this.gameBoy!.get_tile_buffer(index); const line = Math.floor(index / 16); const column = index % 16; let offset = (line * canvasTiles.width * 8 + column * 8) * PixelFormat.RGBA; let counter = 0; for ( let index = 0; index < pixels.length; index += format ) { const color = (pixels[index] << 24) | (pixels[index + 1] << 16) | (pixels[index + 2] << 8) | (format == PixelFormat.RGBA ? pixels[index + 3] : 0xff); buffer.setUint32(offset, color); counter++; if (counter == 8) { counter = 0; offset += (canvasTiles.width - 7) * PixelFormat.RGBA; } else { offset += PixelFormat.RGBA; } } context.putImageData(canvasImage, 0, 0); }; for (let index = 0; index < 384; index++) { drawTile(index, canvasTilesCtx, videoBuff); } const vram = this.gameBoy!.vram_eager(); const step = 16; for (let index = 0; index < vram.length; index += step) { let line = `${(index + 0x8000) .toString(16) .padStart(4, "0")}`; for (let j = 0; j < step; j++) { line += ` ${vram[index + j] .toString(16) .padStart(2, "0")}`; } console.info(line); } } }); const buttonInformation = document.getElementById("button-information")!; buttonInformation.addEventListener("click", () => { 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", () => { this.background_index = (this.background_index + 1) % BACKGROUNDS.length; const background = BACKGROUNDS[this.background_index]; this.setBackground(background); }); const buttonUploadFile = document.getElementById( "button-upload-file" ) as HTMLInputElement; buttonUploadFile.addEventListener("change", async () => { if ( !buttonUploadFile.files || buttonUploadFile.files.length === 0 ) { return; } const file = buttonUploadFile.files[0]; const arrayBuffer = await file.arrayBuffer(); const romData = new Uint8Array(arrayBuffer); buttonUploadFile.value = ""; this.start({ engine: null, romName: file.name, romData: romData }); this.showToast(`Loaded ${file.name} ROM successfully!`); }); } // @todo this should be converted into a component registerKeyboard() { const keyboard = document.getElementById("keyboard")!; const keys = keyboard.getElementsByClassName("key"); keyboard.addEventListener("touchstart", function (event) { event.preventDefault(); event.stopPropagation(); }); keyboard.addEventListener("touchend", function (event) { event.preventDefault(); event.stopPropagation(); }); Array.prototype.forEach.call(keys, (k: Element) => { k.addEventListener( "mousedown", function (this: HTMLElement, event) { const keyCode = KEYS[this.textContent!.toLowerCase()]; //this.gameBoy.key_press_ws(keyCode); @todo event.preventDefault(); event.stopPropagation(); } ); k.addEventListener( "touchstart", function (this: HTMLElement, event) { const keyCode = KEYS[this.textContent!.toLowerCase()]; //this.gameBoy.key_press_ws(keyCode); @todo event.preventDefault(); event.stopPropagation(); } ); k.addEventListener("mouseup", function (this: HTMLElement, event) { const keyCode = KEYS[this.textContent!.toLowerCase()]; //this.gameBoy.key_lift_ws(keyCode); @todo event.preventDefault(); event.stopPropagation(); }); k.addEventListener("touchend", function (this: HTMLElement, event) { const keyCode = KEYS[this.textContent!.toLowerCase()]; //this.gameBoy.key_lift_ws(keyCode); @todo event.preventDefault(); event.stopPropagation(); }); }); } registerCanvas() { const canvasClose = document.getElementById("canvas-close")!; canvasClose.addEventListener("click", () => { this.minimize(); }); } registerToast() { const toast = document.getElementById("toast")!; toast.addEventListener("click", () => { toast.classList.remove("visible"); }); } registerModal() { const modalClose = document.getElementById("modal-close")!; modalClose.addEventListener("click", () => { this.hideModal(false); }); const modalCancel = document.getElementById("modal-cancel")!; modalCancel.addEventListener("click", () => { this.hideModal(false); }); const modalConfirm = document.getElementById("modal-confirm")!; modalConfirm.addEventListener("click", () => { this.hideModal(true); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { this.hideModal(false); } }); } async initBase() { const background = BACKGROUNDS[this.background_index]; this.setBackground(background); this.setVersion(info.version); } async initCanvas() { // initializes the off-screen canvas that is going to be // used in the drawing process this.canvas = document.createElement("canvas"); this.canvas.width = DISPLAY_WIDTH; this.canvas.height = DISPLAY_HEIGHT; this.canvasCtx = this.canvas.getContext("2d")!; this.canvasScaled = document.getElementById( "engine-canvas" ) as HTMLCanvasElement; this.canvasScaled.width = this.canvasScaled.width * window.devicePixelRatio; this.canvasScaled.height = this.canvasScaled.height * window.devicePixelRatio; this.canvasScaledCtx = this.canvasScaled.getContext("2d")!; this.canvasScaledCtx.scale( this.canvasScaled.width / this.canvas.width, this.canvasScaled.height / this.canvas.height ); this.canvasScaledCtx.imageSmoothingEnabled = false; this.image = this.canvasCtx.createImageData( this.canvas.width, this.canvas.height ); this.videoBuff = new DataView(this.image.data.buffer); } updateCanvas(pixels: Uint8Array, format: PixelFormat = PixelFormat.RGB) { let offset = 0; for (let index = 0; index < pixels.length; index += format) { const color = (pixels[index] << 24) | (pixels[index + 1] << 16) | (pixels[index + 2] << 8) | (format == PixelFormat.RGBA ? pixels[index + 3] : 0xff); this.videoBuff!.setUint32(offset, color); offset += PixelFormat.RGBA; } this.canvasCtx!.putImageData(this.image!, 0, 0); this.canvasScaledCtx!.drawImage(this.canvas!, 0, 0); } async clearCanvas( color = PIXEL_UNSET_COLOR, { image = null, imageScale = 1 }: { image?: string | null; imageScale?: number } = {} ) { this.canvasScaledCtx!.fillStyle = `#${color .toString(16) .toUpperCase()}`; this.canvasScaledCtx!.fillRect( 0, 0, this.canvasScaled!.width, this.canvasScaled!.height ); // in case an image was requested then uses that to load // an image at the center of the screen properly scaled if (image) { const img = await new Promise<HTMLImageElement>((resolve) => { const img = new Image(); img.onload = () => { resolve(img); }; img.src = image; }); const [imgWidth, imgHeight] = [ img.width * imageScale * window.devicePixelRatio, img.height * imageScale * window.devicePixelRatio ]; const [x0, y0] = [ this.canvasScaled!.width / 2 - imgWidth / 2, this.canvasScaled!.height / 2 - imgHeight / 2 ]; this.canvasScaledCtx!.setTransform(1, 0, 0, 1, 0, 0); try { this.canvasScaledCtx!.drawImage( img, x0, y0, imgWidth, imgHeight ); } finally { this.canvasScaledCtx!.scale( this.canvasScaled!.width / this.canvas!.width, this.canvasScaled!.height / this.canvas!.height ); } } } async showToast(message: string, 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 (this.toastTimeout) clearTimeout(this.toastTimeout); this.toastTimeout = setTimeout(() => { toast.classList.remove("visible"); this.toastTimeout = null; }, timeout); } async showModal(message: string, title = "Alert"): Promise<boolean> { const modalContainer = document.getElementById("modal-container")!; const modalTitle = document.getElementById("modal-title")!; const modalText = document.getElementById("modal-text")!; modalContainer.classList.add("visible"); modalTitle.textContent = title; modalText.innerHTML = message.replace(/\n/g, "<br/>"); const result = (await new Promise((resolve) => { global.modalCallback = resolve; })) as boolean; return result; } async hideModal(result = true) { const modalContainer = document.getElementById("modal-container")!; modalContainer.classList.remove("visible"); if (global.modalCallback) global.modalCallback(result); global.modalCallback = null; } setVersion(value: string) { document.getElementById("version")!.textContent = value; } setEngine(name: string, upper = true) { name = upper ? name.toUpperCase() : name; document.getElementById("engine")!.textContent = name; } setRom(name: string, data: Uint8Array) { this.romName = name; this.romData = data; this.romSize = data.length; //@todo update this one //Component.get<KeyValue>("diag:rom-name").value = name; //Component.get<KeyValue>("diag:rom-size").value = `${data.length} bytes`; } setLogicFrequency(value: number) { if (value < 0) this.showToast("Invalid frequency value!", true); value = Math.max(value, 0); this.logicFrequency = value; document.getElementById("logic-frequency")!.textContent = String(value); } setFps(value: number) { if (value < 0) this.showToast("Invalid FPS value!", true); value = Math.max(value, 0); this.fps = value; //@todo //Component.get<KeyValue>("diag:framerate").value = `${value} FPS`; } setBackground(value: string) { document.body.style.backgroundColor = `#${value}`; document.getElementById( "footer-background" )!.style.backgroundColor = `#${value}f2`; } toggleRunning() { if (this.paused) { this.resume(); } else { this.pause(); } } pause() { this.paused = true; const buttonPause = document.getElementById("button-pause")!; const img = buttonPause.getElementsByTagName("img")[0]; const span = buttonPause.getElementsByTagName("span")[0]; buttonPause.classList.add("enabled"); img.src = require("./res/play.svg"); span.textContent = "Resume"; } resume() { this.paused = false; this.nextTickTime = new Date().getTime(); const buttonPause = document.getElementById("button-pause")!; const img = buttonPause.getElementsByTagName("img")[0]; const span = buttonPause.getElementsByTagName("span")[0]; buttonPause.classList.remove("enabled"); img.src = require("./res/pause.svg"); span.textContent = "Pause"; } /** * Resets the emulator machine to the start state and loads * the ROM that is currently set in the emulator. */ reset() { this.start({ engine: null }); } toggleWindow() { this.maximize(); } /** * Maximizes the emulator's viewport taking up all the available * window space. This method is responsible for keeping the aspect * ratio of the emulator canvas according to the width/height ratio. */ maximize() { const canvasContainer = document.getElementById("canvas-container")!; canvasContainer.classList.add("fullscreen"); window.addEventListener("resize", this.crop); this.crop(); } /** * Restore the emulator's viewport to the minimal size, should make all * the other emulator's meta-information (info, buttons, etc.) visible. */ minimize() { const canvasContainer = document.getElementById("canvas-container")!; const engineCanvas = document.getElementById("engine-canvas")!; canvasContainer.classList.remove("fullscreen"); engineCanvas.style.width = ""; engineCanvas.style.height = ""; window.removeEventListener("resize", this.crop); } crop() { // @todo let's make this more flexible const engineCanvas = document.getElementById("engine-canvas")!; // calculates the window ratio as this is fundamental to // determine the proper way to crop the fullscreen const windowRatio = window.innerWidth / window.innerHeight; // in case the window is wider (more horizontal than the base ratio) // this means that we must crop horizontally if (windowRatio > DISPLAY_RATIO) { engineCanvas.style.width = `${ window.innerWidth * (DISPLAY_RATIO / windowRatio) }px`; engineCanvas.style.height = `${window.innerHeight}px`; } else { engineCanvas.style.width = `${window.innerWidth}px`; engineCanvas.style.height = `${ window.innerHeight * (windowRatio / DISPLAY_RATIO) }px`; } } async fetchRom(romPath: string): Promise<[string, Uint8Array]> { // extracts the name of the ROM from the provided // path by splitting its structure const romPathS = romPath.split(/\//g); let romName = romPathS[romPathS.length - 1].split("?")[0]; const romNameS = romName.split(/\./g); romName = `${romNameS[0]}.${romNameS[romNameS.length - 1]}`; // loads the ROM data and converts it into the // target byte array buffer (to be used by WASM) const response = await fetch(ROM_PATH); const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const romData = new Uint8Array(arrayBuffer); // returns both the name of the ROM and the data // contents as a byte array return [romName, romData]; } } type Global = { modalCallback: Function | null; }; //@todo check if this is really required const global: Global = { modalCallback: null }; declare global { interface Window { panic: (message: string) => void; } } window.panic = (message: string) => { console.error(message); }; const wasm = async () => { await _wasm(); GameBoy.set_panic_hook_ws(); }; (async () => { startApp("app"); const emulator = new Emulator(); await emulator.main(); })();