diff --git a/CHANGELOG.md b/CHANGELOG.md index fae2c540704e0658ad36668682172e4b5998c6d9..4ec17b2c38e3f31b7dc53c87b8c18d785c796444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * Issue related to STAT interrupt and H-Blank +* Issue related to overflow in sprite drawing ## [0.3.0] - 2022-07-11 diff --git a/README.md b/README.md index 007c7aae9d066c15e2d08dec70dd7d603dc8bbce..5462e74fae95515934303cb891a312f6bab9d023 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ A Game Boy emulator that is written in Rust 🦀. +**This emulator has been written for educational purposes and shouldn't be taken to seriously.** But yeahh it plays games, which is cool... 🎮 + +## Deployments + +| Provider | Stable | URL | +| --------- | ------- | ---------------------------------------------------------------- | +| Cloudfare | `True` | [boytacean.pages.dev](https://boytacean.pages.dev) | +| Cloudfare | `True` | [prod.boytacean.pages.dev](https://prod.boytacean.pages.dev) | +| Cloudfare | `False` | [master.boytacean.pages.dev](https://master.boytacean.pages.dev) | + ## Build ### WASM for Node.js diff --git a/examples/sdl/src/util.rs b/examples/sdl/src/util.rs index 2f613a9def6b695b28513f082ddb3c58318333ad..a2adbb8cc3e15c503d081203bb6ae5b967bba174 100644 --- a/examples/sdl/src/util.rs +++ b/examples/sdl/src/util.rs @@ -3,6 +3,9 @@ use sdl2::{ AudioSubsystem, EventPump, TimerSubsystem, VideoSubsystem, }; +/// Structure that provide the complete set of Graphics +/// and Sound syb-system ready to be used by the overall +/// emulator infrastructure. pub struct Graphics { pub canvas: Canvas<Window>, pub video_subsystem: VideoSubsystem, diff --git a/examples/web/index.css b/examples/web/index.css index 850d93f7baf978e698ca0ee83e8f34f6d0722cdd..eb47a643bd0915fff9d2c3982c59cd2b1de03a1a 100644 --- a/examples/web/index.css +++ b/examples/web/index.css @@ -308,108 +308,6 @@ p { -webkit-transform: scale(1.0, 1.0); } -.tiny-button { - border-radius: 96px 96px 96px 96px; - -o-border-radius: 96px 96px 96px 96px; - -ms-border-radius: 96px 96px 96px 96px; - -moz-border-radius: 96px 96px 96px 96px; - -khtml-border-radius: 96px 96px 96px 96px; - -webkit-border-radius: 96px 96px 96px 96px; - cursor: pointer; - display: inline-block; - padding: 0px 8px 0px 8px; - user-select: none; - -o-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; -} - -.tiny-button.border { - border: 1px solid #ffffff; -} - -.tiny-button.padded { - padding: 4px 10px 4px 10px; -} - -.tiny-button.padded-large { - padding: 4px 14px 4px 14px; -} - -.tiny-button.rounded { - padding: 6px 6px 6px 6px; -} - -.tiny-button.enabled { - background-color: #50cb93; -} - -.tiny-button.file { - position: relative; -} - -.tiny-button:hover { - background-color: #50cb93; -} - -.tiny-button.red:hover { - background-color: #e63946; -} - -.tiny-button:active { - background-color: #2a9d8f; -} - -.tiny-button.red:active { - background-color: #bf2a37; -} - -.tiny-button > img { - margin-right: 6px; - margin-top: 2px; - vertical-align: top; - width: 13px; -} - -.tiny-button > img.medium { - width: 20px; -} - -.tiny-button > img.large { - width: 28px; -} - -.tiny-button > img.very-large { - width: 38px; -} - -.tiny-button.no-text > img { - margin-right: 0px; - margin-top: 0px; -} - -.tiny-button.file > input[type="file"] { - cursor: pointer; - height: 100%; - left: 0px; - opacity: 0; - -o-opacity: 0; - -ms-opacity: 0; - -moz-opacity: 0; - -khtml-opacity: 0; - -webkit-opacity: 0; - position: absolute; - top: 0px; - vertical-align: top; - width: 100%; -} - -.tiny-button.file > input[type="file"]::-webkit-file-upload-button { - cursor: pointer; -} - .overlay { align-items: center; background-color: rgba(80, 203, 147, 0.95); @@ -575,12 +473,12 @@ p { -webkit-user-select: none; } -.modal-container > .modal .modal-buttons > .tiny-button { +.modal-container > .modal .modal-buttons > .button.simple { margin-right: 12px; min-width: 120px; } -.modal-container > .modal .modal-buttons > .tiny-button:last-child { +.modal-container > .modal .modal-buttons > .button.simple:last-child { margin-right: 0px; } diff --git a/examples/web/index.html b/examples/web/index.html index 3000b3a8a9e36d5376f86edaa2bd2b1334d4387c..a192760d3f63b79adf38a41e71517cf6c45ad721 100644 --- a/examples/web/index.html +++ b/examples/web/index.html @@ -13,6 +13,7 @@ </head> <body> + <div id="app"></div> <div class="main"> <div class="side-left"> <div id="canvas-container" class="canvas-container"> @@ -76,16 +77,16 @@ <div id="section-diag" class="section"> <dl class="diag"> <dt>Engine</dt> - <dd id="engine" class="tiny-button">-</dd> + <dd id="engine" class="button simple">-</dd> <dt>ROM</dt> <dd id="rom-name">-</dd> <dt>ROM Size</dt> <dd><span id="rom-size">-</span> bytes</dd> <dt>CPU Frequency</dt> <dd> - <span id="logic-frequency-minus" class="tiny-button">-</span> + <span id="logic-frequency-minus" class="button simple">-</span> <span id="logic-frequency">-</span> Hz - <span id="logic-frequency-plus" class="tiny-button">+</span></dd> + <span id="logic-frequency-plus" class="button simple">+</span></dd> <dt>Framerate</dt> <dd><span id="fps-count">-</span> fps</dd> </dl> @@ -93,31 +94,31 @@ <div id="separator-diag" class="separator"></div> <div class="section"> <div class="button-area"> - <span id="button-pause" class="tiny-button border padded"> + <span id="button-pause" class="button simple border padded"> <img src="res/pause.svg" alt="pause" /><span>Pause</span> </span> - <span id="button-reset" class="tiny-button border padded"> + <span id="button-reset" class="button simple border padded"> <img src="res/reset.svg" alt="reset" /><span>Reset</span> </span> - <span id="button-benchmark" class="tiny-button border padded"> + <span id="button-benchmark" class="button simple border padded"> <img src="res/bolt.svg" alt="bolt" /><span>Benchmark</span> </span> - <span id="button-fullscreen" class="tiny-button border padded"> + <span id="button-fullscreen" class="button simple border padded"> <img src="res/maximise.svg" alt="maximise" /><span>Fullscreen</span> </span> - <span id="button-keyboard" class="tiny-button border padded"> + <span id="button-keyboard" class="button simple border padded"> <img src="res/dialpad.svg" alt="info" /><span>Keyboard</span> </span> - <span id="button-information" class="tiny-button border padded enabled"> + <span id="button-information" class="button simple border padded enabled"> <img src="res/info.svg" alt="info" /><span>Information</span> </span> - <span id="button-debug" class="tiny-button border padded"> + <span id="button-debug" class="button simple border padded"> <img src="res/bug.svg" alt="bug" /><span>Debug</span> </span> - <span id="button-theme" class="tiny-button border padded"> + <span id="button-theme" class="button simple border padded"> <img src="res/marker.svg" alt="marker" /><span>Theme</span> </span> - <span id="button-upload" class="tiny-button border padded file"> + <span id="button-upload" class="button simple border padded file"> <img src="res/upload.svg" alt="upload" /><span>Load ROM</span> <input type="file" id="button-upload-file" name="button-upload-file" accept=".gb"> </span> @@ -132,15 +133,15 @@ <div id="modal-container" class="modal-container"> <div id="modal" class="modal"> <div class="modal-top-buttons"> - <span id="modal-close" class="tiny-button rounded no-text"> + <span id="modal-close" class="button simple rounded no-text"> <img class="medium" src="res/close.svg" alt="close" /> </span> </div> <h2 id="modal-title" class="modal-title"></h2> <p id="modal-text" class="modal-text"></p> <div class="modal-buttons"> - <span id="modal-cancel" class="tiny-button red border padded-large">Cancel</span> - <span id="modal-confirm" class="tiny-button border padded-large">Confirm</span> + <span id="modal-cancel" class="button simple red border padded-large">Cancel</span> + <span id="modal-confirm" class="button simple border padded-large">Confirm</span> </div> </div> </div> diff --git a/examples/web/index.ts b/examples/web/index.ts index 3784a69fb6c5d5aa451a19da4cf8bb0e89be84ac..dc36af2ccd5d4a0329c69de529cb84c047c236b6 100644 --- a/examples/web/index.ts +++ b/examples/web/index.ts @@ -1,6 +1,10 @@ +import { startApp } from "./react/app"; + import { default as _wasm, GameBoy, PadKey, PpuMode } from "./lib/boytacean.js"; import info from "./package.json"; +declare const require: any; + const PIXEL_UNSET_COLOR = 0x1b1a17ff; const LOGIC_HZ = 600; @@ -16,9 +20,6 @@ const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT; const SAMPLE_RATE = 2; -const SOUND_DATA = - "data:audio/mpeg;base64,SUQzAwAAAAAAJlRQRTEAAAAcAAAAU291bmRKYXkuY29tIFNvdW5kIEVmZmVjdHMA//uSwAAAAAABLBQAAAL6QWkrN1ADDCBAACAQBAQECQD//2c7OmpoX/btmzIxt4R/7tmdKRqBVldEDICeA2szOT5E0ANLDoERvAwYDvXUwGPgUBhQVAiIAGFQb9toDBQAwSGwMLgECIPAUE/7v4YoAwyHQMSh8BgNl0r//5ofWmt///4swTaBg0CgSAgNoClQMSAwCgBAwiA//t9/GRFBlcXORYXAN8ZQggBgCACBH////4WYFjpmaRcLZcYggswUoBgEEgYPBf////////+VwfOBAwA7llUiIABQAAAgAAAEBgUARBzKEVmNPo26GUFGinz0RnZcAARtaVqlvTwGDx8BvHbgkEQMtcYIQgBjzkgaETYGFhuAEeRQ5m4ZcMEAsmKArYXE7qZFkXGOGkI5L4yqTIqRZNK45ociBkoKE6brSDUgMNi8mkJqHfAwaMBz11/t23+yEgox4FicKWLheWtJMWkAYIGpvvKwpgAQBJxVki+QFZOmhfJkQWCICACENuqdNB1Ba39WSI1wxkIsPSalHkFsZloPyHLBoEwssSa3Xf/7ksBnABz9nUn5qoACZTMov7FQAGsyLZRDwG7X+vJcfAjUzWVJMUz/DadX/DPVVPTwxgAAYggAShABbnnd5DQOPbj70zVpiaxayfheoOiDfgbrAYWXYHf90BlMZAYvDQUAYhKOIfxmTyebVJ71qsPaSBSPnR4NTPoOShOniyMyQEMSAScgXMjmnkkTJ71ob1q2rei1TUOy0Ss5w4QYIA0HbOG3Pf//3+j8i6LMiQ0CAFFXbU9Xf//+/mJHJOsyLwYXJ1mr16/1AJZ4ZlMAACAAADEFHpoLU2ytFsJ1sql3c1hG7r4LivRJ06AgAMwNgSDQUFJMGgAAOAXR8a+/8op8Ln/Z5+X/z+4/yc+vLe5V+QXz/52DO8uxhuYWBWA9SESgTZOJpmtaG2rbR2u29NqluNQrUjU4EoAfZG1SNfVX/928+3ccDzJEmgCCQc41Szj/V9S/r+o29Qn1qrhQY9Wg/rb/9fzku8RCoAABQAABKjQCK1VNcqoJHKmjjRanrzeKUiQHJyu63xb0wtDo+TRcFFkPAS68UpPuY2f+v/4/+///+5LAbIATtdU/7HqNwlm0aD2O0bDv9q3qS1nq12Z9yUSRRMBjQF4wHfMidi6aVlt2PVI7a6n11d7ashxpscCbQWBa2qP1tnq22q7VatDVj01aygAkcI0TXnHr1tX2/W+qrqmQ03rwUBNXnK7dvTeRh2VkYwAAKAAANmkNuUCQrNCopStlXHuCRUS6Xmb1FJdyyQKCxhEZZ3xiBiIE5ZJ45VZj9nK/39d7n/5////b0Sx1MW7zwd/89STW8J+EAoCwJcYM2OAvmjE5VzayGr+nvpash5arY4EJIBQOJrNaZL1tUtS9v9uqd08Zl2RSIaASHQ402MXko1etvr+632qPbKLI3F1YDQRecybarX+3qq+o+upVkRCAAAgAAAZGbDPFHmW35hRX4JfLKULFfuWuey1yVKB0FwsZRmlgZgIFCHdUjlw/BVq9h3Cxnzv4Y5659JYr7ortvLj4fn/eR6xq5K3oC4vgc9EKDIAQdSBMspPTXT3+m/tOp1oR0qQtBCwCiw3RPTpb+qvtV6mbzJqGMtZSBTAMIhsaBxUyNXV0GV0l//uSwJkAFGnXPex2rcKXuuf9jtG4L9f0z2nQFK1JqQAUDM681f7/Zf1e82WAioiGUwAAMAAAKBrafL7Ku+qidGFD4nVyacggTALkCEoYIANAGBgXCWBiVFyBp/PgBhGCEAMFAMVk+dH2TBoYrm9BHTe8nCjIANs3I8ixWIx9JAjDVNA6IXAeEUDDEBoBQCAuBTqPtesy39Nt61bVKrZRgnRMDwIQGA4EBFC0aIHUG/9/1P/pUBjTdzhgOgBwDBF1qQrb1Nv/v+tfWok07GBcC4En3VljsdIclUMYgIgAAAAAAAAAAAASAeJK1eXElURk3DcGCI9jsylQ8LhANGAxQ48DSKDgORA0gBiAYAwXjYCQG0TUCwHBzEUHUy2WsrkHMi4kpqDJuxmVE5bNC+GOAYPAailFSeFzgYZQCCf1rIiJtAwuASGAkyNqtKt9Zmmo0NE1npbEqCAAZga6aaQ5YDQMiJm+VzQqiugHAgLRxk7b6x6FDBZX75ZUM+BYBydBk7okIKFC+iTM9m1zp8pB4zfVX1uU2H2I2agtPQdZuiWhqv/7ksC6gBV1o0P1iwADaDro+x9gAEEdFvX///mZ/eT/6Dx8wAyYoAUAAAADAEAFAAAAAAPVTzyO6U2P8w8nM8P6bv+PBRjw07pfb/AciANoiwLBCM1LAysBAFCABgMGhMABswkysR0CIHAMAAMBiAo5JOE9XhikQ4LmBQgtKRMlgyJ74xQblBiMCQEEeCOyis1IcTRb/IEKMJ0FbiyRtCUCGmKBskYnP43B0i4xpidRkB2DlmSRsUTE8ZGTl3/juHAOeOaSQzA/ENHPGXE+oqeicUbFExb/5UKhAzhEiIEXIqViCEoQ0i46x2GSTooqeipSRii3//YliLmBPE4RcmSsQQjP//mQ0nLjQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LAvgAcldNN2bqASAAAJYOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAA"; - const BACKGROUNDS = [ "264653", "1b1a17", @@ -40,987 +41,1068 @@ const KEYS: Record<string, number> = { s: PadKey.B }; -// @ts-ignore: ts(2580) const ROM_PATH = require("../../res/roms/20y.gb"); // Enumeration that describes the multiple pixel -// formats and the associated byte size. +// formats and the associated size in bytes. enum PixelFormat { RGB = 3, RGBA = 4 } -type State = { - gameBoy: GameBoy; - engine: string; - logicFrequency: number; - visualFrequency: number; - timerFrequency: number; - idleFrequency: number; - canvas: HTMLCanvasElement; - canvasScaled: HTMLCanvasElement; - canvasCtx: CanvasRenderingContext2D; - canvasScaledCtx: CanvasRenderingContext2D; - image: ImageData; - videoBuff: DataView; - toastTimeout: number; - paused: boolean; - background_index: number; - nextTickTime: number; - fps: number; - frameStart: number; - frameCount: number; - romName: string; - romData: Uint8Array; - romSize: number; -}; - -type Global = { - modalCallback: Function; -}; - -const state: State = { - gameBoy: null, - engine: null, - logicFrequency: LOGIC_HZ, - visualFrequency: VISUAL_HZ, - timerFrequency: TIMER_HZ, - idleFrequency: IDLE_HZ, - canvas: null, - canvasScaled: null, - canvasCtx: null, - canvasScaledCtx: null, - image: null, - videoBuff: null, - toastTimeout: null, - paused: false, - background_index: 0, - nextTickTime: 0, - fps: 0, - frameStart: new Date().getTime(), - frameCount: 0, - romName: null, - romData: null, - romSize: 0 -}; - -const global: Global = { - modalCallback: null -}; +/** + * Top level class that controls the emulator behaviour + * and "joins" all the elements together to bring input/output + * of the associated machine. + */ +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; + } -const sound = ((data = SOUND_DATA, volume = 0.2) => { - const sound = new Audio(data); - sound.volume = volume; - sound.muted = true; - return sound; -})(); + // 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"; + } -const wasm = async () => { - await _wasm(); - GameBoy.set_panic_hook_ws(); -}; + // 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 }); + } + } -(window as any).panic = (message: string) => { - console.error(message); -}; + // 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); -const main = 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 - await init(); - await register(); - - // start the emulator subsystem with the initial - // ROM retrieved from a remote data source - await 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 machin is paused we must delay the execution - // a little bit until the paused state is recovered - if (state.paused) { + // waits a little bit for the next frame to be draw, + // this should control the flow of render await new Promise((resolve) => { - setTimeout(resolve, 1000 / state.idleFrequency); + setTimeout(resolve, pendingTime); }); - 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 = tick(currentTime, pending); - } catch (err) { - // sets the default error message to be displayed - // to the user - 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"; - } + 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); - // displays the error information to both the end-user - // and the developer (for dianostics) - showToast(message, true, 5000); - console.error(err); - - // pauses the machine, allowing the end-user to act - // on the error in a proper fashion - 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 clearCanvas(undefined, { - // @ts-ignore: ts(2580) - image: require("./res/storm.png"), - imageScale: 0.2 - }); + // 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; + } - await wasm(); - await start({ restore: false }); + // 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(); } } - // 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); + // 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; + } - // 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); - }); + // 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; } -}; -const 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 skiped - if (currentTime < state.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 (state.nextTickTime === 0) state.nextTickTime = currentTime; - let ticks = Math.ceil( - (currentTime - state.nextTickTime) / - ((1 / state.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; + /** + * 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]; } - // runs the Game Boy clock, this operations should - // include the advance of both the CPU and the PPU - counterCycles += state.gameBoy.clock(); - - // in case the current PPU mode is VBlank and the - // fram is different from the previously rendered - // one then it's time to update the canvas - if ( - state.gameBoy.ppu_mode() == PpuMode.VBlank && - state.gameBoy.ppu_frame() != lastFrame - ) { - // updates the canvas object with the new - // visual information coming in - updateCanvas(state.gameBoy.frame_buffer_eager(), PixelFormat.RGB); - lastFrame = state.gameBoy.ppu_frame(); + // 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; } - } - // increments the number of frames rendered in the current - // section, this value is going to be used to calculate FPS - state.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 (state.frameCount === state.visualFrequency * SAMPLE_RATE) { - const currentTime = new Date().getTime(); - const deltaTime = (currentTime - state.frameStart) / 1000; - const fps = Math.round(state.frameCount / deltaTime); - setFps(fps); - state.frameCount = 0; - state.frameStart = currentTime; + // 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(); } - // updates the next update time reference to the, so that it - // can be used to control the game loop - state.nextTickTime += (1000 / state.visualFrequency) * ticks; + 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"); - // calculates the new number of pending (overflow) cycles - // that are going to be added to the next iteration - return counterCycles - cycles; -}; + Button.create("Tobias", require("./res/close.svg")) + .bind("click", () => alert("Hello World")) + .mount(".button-area");*/ + } -/** - * Starts the current machine, setting the internal structure in - * a proper state to start drwaing and receiving input. - * - * @param options The options that are going to be used in the - * starting of the machine. - */ -const start = async ({ - engine = "neo", - restore = true, - loadRom = false, - romPath = ROM_PATH, - romName = null as string, - romData = null as Uint8Array -} = {}) => { - // in case a remote ROM loading operation has been - // requested then loads it from the remote origin - if (loadRom) { - [romName, romData] = await fetchRom(romPath); - } else if (romName === null || romData === null) { - [romName, romData] = [state.romName, state.romData]; + // @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() + ]); } - // selects the proper engine for execution - // and builds a new instance of it - switch (engine) { - case "neo": - state.gameBoy = new GameBoy(); - break; + async init() { + await Promise.all([this.initBase(), this.initCanvas()]); + } - default: - if (!state.gameBoy) { - throw new Error("No engine requested"); + registerDrop() { + document.addEventListener("drop", async (event) => { + if ( + !event.dataTransfer!.files || + event.dataTransfer!.files.length === 0 + ) { + return; } - break; - } - // resets the Game Boy engine to restore it into - // a valid state ready to be used - state.gameBoy.reset(); - state.gameBoy.load_boot_default(); - const cartridge = state.gameBoy.load_rom_ws(romData); - - // updates the ROM name in case there's extra information - // comming 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) state.engine = engine; - - // updates the complete set of global information that - // is going to be displayed - setEngine(state.engine); - setRom(romName, romData); - setLogicFrequency(state.logicFrequency); - setFps(state.fps); - - // in case the restore (state) flag is set - // then resumes the machine execution - if (restore) resume(); -}; + event.preventDefault(); + event.stopPropagation(); -const register = async () => { - await Promise.all([ - registerDrop(), - registerKeys(), - registerButtons(), - registerKeyboard(), - registerCanvas(), - registerToast(), - registerModal() - ]); -}; + const overlay = document.getElementById("overlay")!; + overlay.classList.remove("visible"); -const init = async () => { - await Promise.all([initBase(), initCanvas()]); -}; + const file = event.dataTransfer!.files[0]; -const registerDrop = () => { - document.addEventListener("drop", async (event) => { - if ( - !event.dataTransfer.files || - event.dataTransfer.files.length === 0 - ) { - return; - } + if (!file.name.endsWith(".gb")) { + this.showToast( + "This is probably not a Game Boy ROM file!", + true + ); + return; + } - event.preventDefault(); - event.stopPropagation(); + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); - const overlay = document.getElementById("overlay"); - overlay.classList.remove("visible"); + this.start({ engine: null, romName: file.name, romData: romData }); - const file = event.dataTransfer.files[0]; + this.showToast(`Loaded ${file.name} ROM successfully!`); + }); + document.addEventListener("dragover", async (event) => { + if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type) + return; - if (!file.name.endsWith(".gb")) { - showToast("This is probably not a Game Boy ROM file!", true); - return; - } + event.preventDefault(); - const arrayBuffer = await file.arrayBuffer(); - const romData = new Uint8Array(arrayBuffer); + 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"); + }); + } - start({ engine: null, romName: file.name, romData: romData }); - - 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; + } -const registerKeys = () => { - document.addEventListener("keydown", (event) => { - const keyCode = KEYS[event.key]; - if (keyCode !== undefined) { - state.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; + } + }); - switch (event.key) { - case "+": - setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA); - break; + document.addEventListener("keyup", (event) => { + const keyCode = KEYS[event.key]; + if (keyCode !== undefined) { + this.gameBoy!.key_lift(keyCode); + return; + } + }); + } - case "-": - setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA); - break; + 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!` + ); + }); - case "Escape": - minimize(); - break; - } - }); + const logicFrequencyPlus = document.getElementById( + "logic-frequency-plus" + )!; + logicFrequencyPlus.addEventListener("click", () => { + this.setLogicFrequency(this.logicFrequency + FREQUENCY_DELTA); + }); - document.addEventListener("keyup", (event) => { - const keyCode = KEYS[event.key]; - if (keyCode !== undefined) { - state.gameBoy.key_lift(keyCode); - return; - } - }); -}; + const logicFrequencyMinus = document.getElementById( + "logic-frequency-minus" + )!; + logicFrequencyMinus.addEventListener("click", () => { + this.setLogicFrequency(this.logicFrequency - FREQUENCY_DELTA); + }); -const registerButtons = () => { - const engine = document.getElementById("engine"); - engine.addEventListener("click", () => { - const name = state.engine == "neo" ? "classic" : "neo"; - start({ engine: name }); - showToast( - `Game Boy running in engine "${name.toUpperCase()}" from now on!` - ); - }); - - 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(); - }); - - const buttonReset = document.getElementById("button-reset"); - buttonReset.addEventListener("click", () => { - reset(); - }); - - const buttonBenchmark = document.getElementById("button-benchmark"); - buttonBenchmark.addEventListener("click", async () => { - const result = await 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"); - pause(); - try { - const initial = Date.now(); - const count = 500000000; - for (let i = 0; i < count; i++) { - state.gameBoy.clock(); - } - 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"); - } - }); - - const buttonFullscreen = document.getElementById("button-fullscreen"); - buttonFullscreen.addEventListener("click", () => { - 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"); - - const canvasImage = canvasTilesCtx.createImageData( - canvasTiles.width, - canvasTiles.height + 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" ); - 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 = state.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; - } + 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(); } - context.putImageData(canvasImage, 0, 0); - }; + 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(); + }); - for (let index = 0; index < 384; index++) { - drawTile(index, canvasTilesCtx, videoBuff); + 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); + }; - const vram = state.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")}`; + 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); } - 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", () => { - state.background_index = - (state.background_index + 1) % BACKGROUNDS.length; - const background = BACKGROUNDS[state.background_index]; - 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 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 arrayBuffer = await file.arrayBuffer(); - const romData = new Uint8Array(arrayBuffer); + 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); + }); - buttonUploadFile.value = ""; + const buttonUploadFile = document.getElementById( + "button-upload-file" + ) as HTMLInputElement; + buttonUploadFile.addEventListener("change", async () => { + if ( + !buttonUploadFile.files || + buttonUploadFile.files.length === 0 + ) { + return; + } - start({ engine: null, romName: file.name, romData: romData }); + const file = buttonUploadFile.files[0]; - showToast(`Loaded ${file.name} ROM successfully!`); - }); -}; + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); -const registerKeyboard = () => { - const keyboard = document.getElementById("keyboard"); - const keys = keyboard.getElementsByClassName("key"); + buttonUploadFile.value = ""; - keyboard.addEventListener("touchstart", function (event) { - event.preventDefault(); - event.stopPropagation(); - }); + this.start({ engine: null, romName: file.name, romData: romData }); - keyboard.addEventListener("touchend", function (event) { - event.preventDefault(); - event.stopPropagation(); - }); + 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"); - Array.prototype.forEach.call(keys, (k: Element) => { - k.addEventListener("mousedown", function (event) { - const keyCode = KEYS[this.textContent.toLowerCase()]; - //state.gameBoy.key_press_ws(keyCode); @todo + keyboard.addEventListener("touchstart", function (event) { event.preventDefault(); event.stopPropagation(); }); - k.addEventListener("touchstart", function (event) { - const keyCode = KEYS[this.textContent.toLowerCase()]; - //state.gameBoy.key_press_ws(keyCode); @todo + keyboard.addEventListener("touchend", function (event) { event.preventDefault(); event.stopPropagation(); }); - k.addEventListener("mouseup", function (event) { - const keyCode = KEYS[this.textContent.toLowerCase()]; - //state.gameBoy.key_lift_ws(keyCode); @todo - 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(); + }); }); + } - k.addEventListener("touchend", function (event) { - const keyCode = KEYS[this.textContent.toLowerCase()]; - //state.gameBoy.key_lift_ws(keyCode); @todo - event.preventDefault(); - event.stopPropagation(); + registerCanvas() { + const canvasClose = document.getElementById("canvas-close")!; + canvasClose.addEventListener("click", () => { + this.minimize(); }); - }); -}; + } -const registerCanvas = () => { - const canvasClose = document.getElementById("canvas-close"); - canvasClose.addEventListener("click", () => { - minimize(); - }); -}; + registerToast() { + const toast = document.getElementById("toast")!; + toast.addEventListener("click", () => { + toast.classList.remove("visible"); + }); + } -const 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 registerModal = () => { - const modalClose = document.getElementById("modal-close"); - modalClose.addEventListener("click", () => { - hideModal(false); - }); - - const modalCancel = document.getElementById("modal-cancel"); - modalCancel.addEventListener("click", () => { - hideModal(false); - }); - - const modalConfirm = document.getElementById("modal-confirm"); - modalConfirm.addEventListener("click", () => { - hideModal(true); - }); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - hideModal(false); - } - }); -}; + const modalCancel = document.getElementById("modal-cancel")!; + modalCancel.addEventListener("click", () => { + this.hideModal(false); + }); -const initBase = async () => { - const background = BACKGROUNDS[state.background_index]; - setBackground(background); - setVersion(info.version); -}; + const modalConfirm = document.getElementById("modal-confirm")!; + modalConfirm.addEventListener("click", () => { + this.hideModal(true); + }); -const initCanvas = async () => { - // 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( - "engine-canvas" - ) as HTMLCanvasElement; - state.canvasScaled.width = - state.canvasScaled.width * window.devicePixelRatio; - state.canvasScaled.height = - state.canvasScaled.height * window.devicePixelRatio; - 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); -}; + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + this.hideModal(false); + } + }); + } -const 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); - state.videoBuff.setUint32(offset, color); - offset += PixelFormat.RGBA; + async initBase() { + const background = BACKGROUNDS[this.background_index]; + this.setBackground(background); + this.setVersion(info.version); } - state.canvasCtx.putImageData(state.image, 0, 0); - state.canvasScaledCtx.drawImage(state.canvas, 0, 0); -}; -const clearCanvas = async ( - color = PIXEL_UNSET_COLOR, - { image = null as string, imageScale = 1 } = {} -) => { - state.canvasScaledCtx.fillStyle = `#${color.toString(16).toUpperCase()}`; - state.canvasScaledCtx.fillRect( - 0, - 0, - state.canvasScaled.width, - state.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] = [ - state.canvasScaled.width / 2 - imgWidth / 2, - state.canvasScaled.height / 2 - imgHeight / 2 - ]; - state.canvasScaledCtx.setTransform(1, 0, 0, 1, 0, 0); - try { - state.canvasScaledCtx.drawImage(img, x0, y0, imgWidth, imgHeight); - } finally { - state.canvasScaledCtx.scale( - state.canvasScaled.width / state.canvas.width, - state.canvasScaled.height / state.canvas.height - ); + 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); } -}; -const showToast = async (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 (state.toastTimeout) clearTimeout(state.toastTimeout); - state.toastTimeout = setTimeout(() => { - toast.classList.remove("visible"); - state.toastTimeout = null; - }, timeout); -}; + 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 + ); -const showModal = async ( - 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; -}; + // 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 + ); + } + } + } -const hideModal = async (result = true) => { - const modalContainer = document.getElementById("modal-container"); - modalContainer.classList.remove("visible"); - if (global.modalCallback) global.modalCallback(result); - global.modalCallback = null; -}; + 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); + } -const setVersion = (value: string) => { - document.getElementById("version").textContent = value; -}; + 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; + } -const setEngine = (name: string, upper = true) => { - name = upper ? name.toUpperCase() : name; - document.getElementById("engine").textContent = name; -}; + async hideModal(result = true) { + const modalContainer = document.getElementById("modal-container")!; + modalContainer.classList.remove("visible"); + if (global.modalCallback) global.modalCallback(result); + global.modalCallback = null; + } -const setRom = (name: string, data: Uint8Array) => { - state.romName = name; - state.romData = data; - state.romSize = data.length; - document.getElementById("rom-name").textContent = name; - document.getElementById("rom-size").textContent = String(data.length); -}; + setVersion(value: string) { + document.getElementById("version")!.textContent = value; + } -const setLogicFrequency = (value: number) => { - if (value < 0) showToast("Invalid frequency value!", true); - value = Math.max(value, 0); - state.logicFrequency = value; - document.getElementById("logic-frequency").textContent = String(value); -}; + setEngine(name: string, upper = true) { + name = upper ? name.toUpperCase() : name; + document.getElementById("engine")!.textContent = name; + } -const setFps = (value: number) => { - if (value < 0) showToast("Invalid FPS value!", true); - value = Math.max(value, 0); - state.fps = value; - document.getElementById("fps-count").textContent = String(value); -}; + 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`; + } -const setBackground = (value: string) => { - document.body.style.backgroundColor = `#${value}`; - document.getElementById( - "footer-background" - ).style.backgroundColor = `#${value}f2`; -}; + 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); + } -const toggleRunning = () => { - if (state.paused) { - resume(); - } else { - pause(); + 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`; } -}; -const pause = () => { - state.paused = true; - const buttonPause = document.getElementById("button-pause"); - const img = buttonPause.getElementsByTagName("img")[0]; - const span = buttonPause.getElementsByTagName("span")[0]; - buttonPause.classList.add("enabled"); - // @ts-ignore: ts(2580) - img.src = require("./res/play.svg"); - span.textContent = "Resume"; -}; + setBackground(value: string) { + document.body.style.backgroundColor = `#${value}`; + document.getElementById( + "footer-background" + )!.style.backgroundColor = `#${value}f2`; + } -const resume = () => { - state.paused = false; - state.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"); - // @ts-ignore: ts(2580) - img.src = require("./res/pause.svg"); - span.textContent = "Pause"; -}; + toggleRunning() { + if (this.paused) { + this.resume(); + } else { + this.pause(); + } + } -const toggleWindow = () => { - maximize(); -}; + 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"; + } -const maximize = () => { - const canvasContainer = document.getElementById("canvas-container"); - canvasContainer.classList.add("fullscreen"); + 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"; + } - window.addEventListener("resize", crop); + /** + * Resets the emulator machine to the start state and loads + * the ROM that is currently set in the emulator. + */ + reset() { + this.start({ engine: null }); + } - crop(); + 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; }; -const minimize = () => { - const canvasContainer = document.getElementById("canvas-container"); - const engineCanvas = document.getElementById("engine-canvas"); - canvasContainer.classList.remove("fullscreen"); - engineCanvas.style.width = null; - engineCanvas.style.height = null; - window.removeEventListener("resize", crop); +//@todo check if this is really required +const global: Global = { + modalCallback: null }; -const crop = () => { - const engineCanvas = document.getElementById("engine-canvas"); - - // calculates the window ratio as this is fundamental to - // determine the proper way to crop the fulscreen - const windowRatio = window.innerWidth / window.innerHeight; - - // in case the window is wider (more horizontal than the base ratio) - // this means that we must crop horizontaly - 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`; +declare global { + interface Window { + panic: (message: string) => void; } -}; +} -const reset = () => { - start({ engine: null }); +window.panic = (message: string) => { + console.error(message); }; -const fetchRom = async (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]; +const wasm = async () => { + await _wasm(); + GameBoy.set_panic_hook_ws(); }; (async () => { - await main(); + startApp("app"); + + const emulator = new Emulator(); + await emulator.main(); })(); diff --git a/examples/web/package.json b/examples/web/package.json index 3b25a29a46c25f7a5e97cc0e436f3f5220333503..a956b883f06a58671732d1cff3f19fff1b0274ed 100644 --- a/examples/web/package.json +++ b/examples/web/package.json @@ -10,15 +10,20 @@ "scripts": { "build": "parcel build index.html", "dev": "parcel index.html", - "pretty": "prettier --config .prettierrc \"./**/*.{ts,json}\" --write", + "pretty": "prettier --config .prettierrc \"./**/*.{ts,tsx,json}\" --write", "start": "npm run build", "watch": "parcel watch index.html" }, "source": "index.ts", "devDependencies": { - "@parcel/transformer-typescript-tsc": "^2.6.2", - "parcel": "^2.6.2", + "@parcel/transformer-typescript-tsc": "^2.7.0", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "parcel": "^2.7.0", "prettier": "^2.7.1", - "typescript": "^4.7.4" + "process": "^0.11.10", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^4.8.4" } } diff --git a/examples/web/react/app.css b/examples/web/react/app.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/examples/web/react/app.tsx b/examples/web/react/app.tsx new file mode 100644 index 0000000000000000000000000000000000000000..27f27484a5077017473cd211b8881337841374fd --- /dev/null +++ b/examples/web/react/app.tsx @@ -0,0 +1,122 @@ +import React, { useState } from "react"; +import ReactDOM from "react-dom/client"; + +import { + Button, + ButtonIncrement, + ButtonSwitch, + Info, + Link, + Pair, + PanelSplit, + Paragraph, + Section, + Title +} from "./components"; + +import "./app.css"; + +export const App = () => { + const [count, setCount] = useState(0); + const getText = () => `Hello World ${count}`; + const onClick = () => setCount(count + 1); + return ( + <> + <PanelSplit left={<div>This is the left panel</div>}> + <Title + text="Boytacean" + version="0.3.0" + versionUrl="https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md" + 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> + <Button text={getText()} onClick={onClick} /> + <Button + text={getText()} + image={require("../res/pause.svg")} + imageAlt="tobias" + onClick={onClick} + /> + <Info> + <Pair + key="tobias" + name={"Tobias"} + value={`Count ${count}`} + /> + <Pair key="matias" name={"Matias"} value={"3"} /> + <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> + </> + ); +}; + +export const startApp = (element: string) => { + const elementRef = document.getElementById(element); + if (!elementRef) { + return; + } + + const root = ReactDOM.createRoot(elementRef); + root.render(<App />); +}; + +export default App; diff --git a/examples/web/react/components/button-increment/button-increment.css b/examples/web/react/components/button-increment/button-increment.css new file mode 100644 index 0000000000000000000000000000000000000000..57f94577dd1a79df31fcae63964153ab1de88fcc --- /dev/null +++ b/examples/web/react/components/button-increment/button-increment.css @@ -0,0 +1,15 @@ +.button-increment { + display: inline-block; +} + +.button-increment > .value { + margin: 0px 8px 0px 8px; +} + +.button-increment > .prefix { + margin-left: 8px; +} + +.button-increment > .suffix { + margin-right: 8px; +} diff --git a/examples/web/react/components/button-increment/button-increment.tsx b/examples/web/react/components/button-increment/button-increment.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a4395b771baa97b2319bc081616abf6307b7845d --- /dev/null +++ b/examples/web/react/components/button-increment/button-increment.tsx @@ -0,0 +1,77 @@ +import React, { FC, useState } from "react"; +import Button from "../button/button"; + +import "./button-increment.css"; + +type ButtonIncrementProps = { + value: number; + delta?: number; + min?: number; + max?: number; + prefix?: string; + suffix?: string; + size?: string; + style?: string[]; + onClick?: () => void; + onBeforeChange?: (value: number) => boolean; + onChange?: (value: number) => void; +}; + +export const ButtonIncrement: FC<ButtonIncrementProps> = ({ + value, + delta = 1, + min, + max, + prefix, + suffix, + size = "medium", + style = ["simple", "border"], + onClick, + onBeforeChange, + onChange +}) => { + const [valueState, setValue] = useState(value); + const classes = () => ["button-increment", size, ...style].join(" "); + const _onClick = () => { + if (onClick) onClick(); + }; + const _onMinusClick = () => { + const valueNew = valueState - delta; + if (onBeforeChange) { + if (!onBeforeChange(valueNew)) return; + } + if (min !== undefined && valueNew < min) return; + setValue(valueNew); + if (onChange) onChange(valueNew); + }; + const _onPlusClick = () => { + const valueNew = valueState + delta; + if (onBeforeChange) { + if (!onBeforeChange(valueNew)) return; + } + if (max !== undefined && valueNew > max) return; + setValue(valueNew); + if (onChange) onChange(valueNew); + }; + return ( + <span className={classes()} onClick={_onClick}> + <Button + text={"-"} + size={size} + style={["simple"]} + onClick={_onMinusClick} + /> + {prefix && <span className="prefix">{prefix}</span>} + <span className="value">{valueState}</span> + {suffix && <span className="suffix">{suffix}</span>} + <Button + text={"+"} + size={size} + style={["simple"]} + onClick={_onPlusClick} + /> + </span> + ); +}; + +export default ButtonIncrement; diff --git a/examples/web/react/components/button-switch/button-switch.tsx b/examples/web/react/components/button-switch/button-switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..54c5bd272985ad8eb809eeb0aafdcca91de30e85 --- /dev/null +++ b/examples/web/react/components/button-switch/button-switch.tsx @@ -0,0 +1,33 @@ +import React, { FC, useState } from "react"; +import Button from "../button/button"; + +type ButtonSwitchProps = { + options: string[]; + size?: string; + style?: string[]; + onClick?: () => void; + onChange?: (value: string, index: number) => void; +}; + +export const ButtonSwitch: FC<ButtonSwitchProps> = ({ + options, + size = "small", + style = ["simple", "border"], + onClick, + onChange +}) => { + const [index, setIndex] = useState(0); + const text = () => options[index]; + const _onClick = () => { + const indexNew = (index + 1) % options.length; + const option = options[indexNew]; + setIndex(indexNew); + if (onClick) onClick(); + if (onChange) onChange(option, indexNew); + }; + return ( + <Button text={text()} size={size} style={style} onClick={_onClick} /> + ); +}; + +export default ButtonSwitch; diff --git a/examples/web/react/components/button/button.css b/examples/web/react/components/button/button.css new file mode 100644 index 0000000000000000000000000000000000000000..67b45263526fd7fb20cc9ac9d4cf02ae6628441c --- /dev/null +++ b/examples/web/react/components/button/button.css @@ -0,0 +1,114 @@ +.button { + cursor: pointer; + display: inline-flex; + vertical-align: middle; +} + +.button.small { + font-size: 16px; + line-height: 24px; +} + +.button.simple { + border-radius: 96px 96px 96px 96px; + -o-border-radius: 96px 96px 96px 96px; + -ms-border-radius: 96px 96px 96px 96px; + -moz-border-radius: 96px 96px 96px 96px; + -khtml-border-radius: 96px 96px 96px 96px; + -webkit-border-radius: 96px 96px 96px 96px; + padding: 0px 8px 0px 8px; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.button.simple.border { + border: 1px solid #ffffff; +} + +.button.simple.padded { + padding: 4px 10px 4px 10px; +} + +.button.simple.padded-large { + padding: 4px 14px 4px 14px; +} + +.button.simple.rounded { + padding: 6px 6px 6px 6px; +} + +.button.simple.enabled { + background-color: #50cb93; +} + +.button.simple.file { + position: relative; +} + +.button.simple:hover { + background-color: #50cb93; +} + +.button.simple.red:hover { + background-color: #e63946; +} + +.button.simple:active { + background-color: #2a9d8f; +} + +.button.simple.red:active { + background-color: #bf2a37; +} + +.button.simple > img { + margin-right: 6px; + vertical-align: middle; + width: 13px; +} + +.button.simple > img.medium { + width: 20px; +} + +.button.simple > img.large { + width: 28px; +} + +.button.simple > img.very-large { + width: 38px; +} + +.button.simple > span { + display: inline-block; + vertical-align: middle; +} + +.button.simple.no-text > img { + margin-right: 0px; + margin-top: 0px; +} + +.button.simple.file > input[type="file"] { + cursor: pointer; + height: 100%; + left: 0px; + opacity: 0; + -o-opacity: 0; + -ms-opacity: 0; + -moz-opacity: 0; + -khtml-opacity: 0; + -webkit-opacity: 0; + position: absolute; + top: 0px; + vertical-align: top; + width: 100%; +} + +.button.simple.file > input[type="file"]::-webkit-file-upload-button { + cursor: pointer; +} diff --git a/examples/web/react/components/button/button.tsx b/examples/web/react/components/button/button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7858e7ee1cb1a8d6a50c89201d6921cb7f38b947 --- /dev/null +++ b/examples/web/react/components/button/button.tsx @@ -0,0 +1,38 @@ +import React, { FC } from "react"; + +import "./button.css"; + +type ButtonProps = { + text: string; + image?: string; + imageAlt?: string; + size?: string; + style?: string[]; + onClick?: () => void; +}; + +export const Button: FC<ButtonProps> = ({ + text, + image, + imageAlt, + size = "small", + style = ["simple", "border"], + onClick +}) => { + const classes = () => ["button", size, ...style].join(" "); + const _onClick = () => (onClick ? onClick() : undefined); + const buttonSimple = () => ( + <span className={classes()} onClick={_onClick}> + {text} + </span> + ); + const buttonImage = () => ( + <span className={classes()} onClick={_onClick}> + <img src={image} alt={imageAlt} /> + <span>{text}</span> + </span> + ); + return image ? buttonImage() : buttonSimple(); +}; + +export default Button; diff --git a/examples/web/react/components/index.ts b/examples/web/react/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9e3046a2fe02117875e4641023a1cc4693a6320 --- /dev/null +++ b/examples/web/react/components/index.ts @@ -0,0 +1,10 @@ +export * from "./button/button"; +export * from "./button-increment/button-increment"; +export * from "./button-switch/button-switch"; +export * from "./info/info"; +export * from "./link/link"; +export * from "./pair/pair"; +export * from "./panel-split/panel-split"; +export * from "./paragraph/paragraph"; +export * from "./section/section"; +export * from "./title/title"; diff --git a/examples/web/react/components/info/info.css b/examples/web/react/components/info/info.css new file mode 100644 index 0000000000000000000000000000000000000000..cf1924233a9aa1055ff043584ced54dac9431830 --- /dev/null +++ b/examples/web/react/components/info/info.css @@ -0,0 +1,29 @@ +.info { + font-size: 24px; + vertical-align: top; +} + +.info > dt { + clear: both; + float: left; + margin-top: 12px; +} + +.info > dt:first-of-type { + margin-top: 0px; +} + +.info > dd { + float: right; + margin-top: 12px; +} + +.info > dd:first-of-type { + margin-top: 0px; +} + +.info::after { + clear: both; + content: ''; + display: block; +} diff --git a/examples/web/react/components/info/info.tsx b/examples/web/react/components/info/info.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a899b91c8e89571c6e58e71819a46ae6ebb20ee0 --- /dev/null +++ b/examples/web/react/components/info/info.tsx @@ -0,0 +1,26 @@ +import React, { FC, ReactNode } from "react"; + +import "./info.css"; + +type InfoProps = { + children: ReactNode; + style?: string[]; +}; + +/** + * Builds a new info component with the provided pairs components + * setting the style in accordance with the provided list of strings. + * + * An info component is responsible for the management of multiple + * key to "value" pairs. + * + * @param options The multiple options that are going to be used + * to build the info pairs. + * @returns The info component with the associated pairs. + */ +export const Info: FC<InfoProps> = ({ children, style = [] }) => { + const classes = () => ["info", ...style].join(" "); + return <dl className={classes()}>{children}</dl>; +}; + +export default Info; diff --git a/examples/web/react/components/link/link.css b/examples/web/react/components/link/link.css new file mode 100644 index 0000000000000000000000000000000000000000..f04c472e5ad124f21555b1d948ad0350d697573a --- /dev/null +++ b/examples/web/react/components/link/link.css @@ -0,0 +1,5 @@ +.link { + border-bottom: 2px dotted #ffffff; + color: #ffffff; + text-decoration: none; +} diff --git a/examples/web/react/components/link/link.tsx b/examples/web/react/components/link/link.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65046383f1471abf35720c8211e5b8f0019d04d2 --- /dev/null +++ b/examples/web/react/components/link/link.tsx @@ -0,0 +1,28 @@ +import React, { ReactNode, FC } from "react"; + +import "./link.css"; + +type LinkProps = { + children?: ReactNode; + text?: string; + href?: string; + target?: string; + style?: string[]; +}; + +export const Link: FC<LinkProps> = ({ + children, + text, + href, + target, + style = [] +}) => { + const classes = () => ["link", ...style].join(" "); + return ( + <a className={classes()} href={href} target={target}> + {children || text} + </a> + ); +}; + +export default Link; diff --git a/examples/web/react/components/pair/pair.css b/examples/web/react/components/pair/pair.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/examples/web/react/components/pair/pair.tsx b/examples/web/react/components/pair/pair.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9e032e85bd60d460f2284b28b3b0496a95eb6951 --- /dev/null +++ b/examples/web/react/components/pair/pair.tsx @@ -0,0 +1,37 @@ +import React, { FC, ReactNode } from "react"; + +import "./pair.css"; + +type PairProps = { + name: string; + value?: string; + valueNode?: ReactNode; + style?: string[]; + onNameClick?: () => void; + onValueClick?: () => void; +}; + +export const Pair: FC<PairProps> = ({ + name, + value, + valueNode, + style = [], + onNameClick, + onValueClick +}) => { + const classes = () => ["pair", ...style].join(" "); + const _onNameClick = () => (onNameClick ? onNameClick() : undefined); + const _onValueClick = () => (onValueClick ? onValueClick() : undefined); + return ( + <> + <dt className={classes()} onClick={_onNameClick}> + {name} + </dt> + <dd className={classes()} onClick={_onValueClick}> + {valueNode ?? value ?? ""} + </dd> + </> + ); +}; + +export default Pair; diff --git a/examples/web/react/components/panel-split/panel-split.css b/examples/web/react/components/panel-split/panel-split.css new file mode 100644 index 0000000000000000000000000000000000000000..88edf704fcd6e49a1661e1496a2aa8cb9f632066 --- /dev/null +++ b/examples/web/react/components/panel-split/panel-split.css @@ -0,0 +1,17 @@ +.panel-split { + display: flex; +} + +.panel-split > .side-left { + display: flex; + flex: 1 0; + justify-content: center; + text-align: center; +} + +.panel-split > .side-right { + flex: 0; + max-width: 100%; + min-width: 580px; + padding: 0px 24px 0px 24px; +} diff --git a/examples/web/react/components/panel-split/panel-split.tsx b/examples/web/react/components/panel-split/panel-split.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f394bbbda51f08f37be90134222bbe77ec4d9004 --- /dev/null +++ b/examples/web/react/components/panel-split/panel-split.tsx @@ -0,0 +1,27 @@ +import React, { FC, ReactNode } from "react"; + +import "./panel-split.css"; + +type PanelSplitProps = { + children?: ReactNode; + left?: ReactNode; + right?: ReactNode; + style?: string[]; +}; + +export const PanelSplit: FC<PanelSplitProps> = ({ + children, + left, + right, + style = [] +}) => { + const classes = () => ["panel-split", ...style].join(" "); + return ( + <div className={classes()}> + <div className="side-left">{left}</div> + <div className="side-right">{children || right}</div> + </div> + ); +}; + +export default PanelSplit; diff --git a/examples/web/react/components/paragraph/paragraph.css b/examples/web/react/components/paragraph/paragraph.css new file mode 100644 index 0000000000000000000000000000000000000000..4d6c5b875a58e6544470c7c904adec5dfac3797a --- /dev/null +++ b/examples/web/react/components/paragraph/paragraph.css @@ -0,0 +1,5 @@ +.paragraph { + font-size: 18px; + line-height: 24px; + margin: 12px 0px 12px 0px; +} diff --git a/examples/web/react/components/paragraph/paragraph.tsx b/examples/web/react/components/paragraph/paragraph.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aeaa51dce2bb318940410a8f276ecd4814618fdc --- /dev/null +++ b/examples/web/react/components/paragraph/paragraph.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode, FC } from "react"; + +import "./paragraph.css"; + +type ParagraphProps = { + children?: ReactNode; + text?: string; + style?: string[]; +}; + +export const Paragraph: FC<ParagraphProps> = ({ + children, + text, + style = [] +}) => { + const classes = () => ["paragraph", ...style].join(" "); + return <p className={classes()}>{children || text}</p>; +}; + +export default Paragraph; diff --git a/examples/web/react/components/section/section.css b/examples/web/react/components/section/section.css new file mode 100644 index 0000000000000000000000000000000000000000..c5ce8fe250b7b3ba2bc339ae4b90f812200bbb0f --- /dev/null +++ b/examples/web/react/components/section/section.css @@ -0,0 +1,5 @@ +.section > .separator { + background: #ffffff; + height: 2px; + margin: 22px 0px 22px 0px; +} diff --git a/examples/web/react/components/section/section.tsx b/examples/web/react/components/section/section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..91a84184a041858745c50f06805b13b4521d6c25 --- /dev/null +++ b/examples/web/react/components/section/section.tsx @@ -0,0 +1,25 @@ +import React, { FC, ReactNode } from "react"; + +import "./section.css"; + +type SectionProps = { + children: ReactNode; + separator?: boolean; + style?: string[]; +}; + +export const Section: FC<SectionProps> = ({ + children, + separator = true, + style = [] +}) => { + const classes = () => ["section", ...style].join(" "); + return ( + <div className={classes()}> + {separator && <div className="separator"></div>} + <div className="section-contents">{children}</div> + </div> + ); +}; + +export default Section; diff --git a/examples/web/react/components/title/title.css b/examples/web/react/components/title/title.css new file mode 100644 index 0000000000000000000000000000000000000000..b10f9b68cd6195975c92f801645d7c0e937e8448 --- /dev/null +++ b/examples/web/react/components/title/title.css @@ -0,0 +1,9 @@ +.title > .link { + margin-left: 14px; +} + +.title > .icon { + margin-left: 14px; + vertical-align: middle; + width: 32px; +} diff --git a/examples/web/react/components/title/title.tsx b/examples/web/react/components/title/title.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c390617521d3a422055466254620d13ef61373bd --- /dev/null +++ b/examples/web/react/components/title/title.tsx @@ -0,0 +1,35 @@ +import React, { FC } from "react"; +import { Link } from "../link/link"; + +import "./title.css"; + +type TitleProps = { + text: string; + version?: string; + versionUrl?: string; + iconSrc?: string; + style?: string[]; +}; + +export const Title: FC<TitleProps> = ({ + text, + version, + versionUrl, + iconSrc, + style = [] +}) => { + const classes = () => ["title", ...style].join(" "); + return ( + <h1 className={classes()}> + {text} + {version && ( + <Link href={versionUrl} target="_blank"> + {version} + </Link> + )} + {iconSrc && <img className="icon" src={iconSrc} alt="icon" />} + </h1> + ); +}; + +export default Title; diff --git a/examples/web/tsconfig.json b/examples/web/tsconfig.json index 54b8f23f78f97da010f950ac602328134bcce54a..8f83dd8e31c5ab0b458c75bafaedad1b74ca9698 100644 --- a/examples/web/tsconfig.json +++ b/examples/web/tsconfig.json @@ -6,7 +6,14 @@ "allowSyntheticDefaultImports": true, "target": "es6", "noImplicitAny": true, + "noImplicitThis": true, + "alwaysStrict": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, "sourceMap": true, + "jsx": "react", "outDir": ".", "baseUrl": ".", "lib": ["es2017", "dom"], diff --git a/src/gb.rs b/src/gb.rs index 0785ea67cd02b915a0de35f04cb2641b3b03ff8e..2750414133440ad4878dbccefafdda3a04301b6b 100644 --- a/src/gb.rs +++ b/src/gb.rs @@ -205,11 +205,11 @@ impl GameBoy { #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = window)] - fn panic(s: &str); + fn panic(message: &str); } #[cfg(feature = "wasm")] pub fn hook_impl(info: &PanicInfo) { - let msg = info.to_string(); - panic(msg.as_str()); + let message = info.to_string(); + panic(message.as_str()); } diff --git a/src/ppu.rs b/src/ppu.rs index 1b051ed176221667729f900eed3a2641e25aed7d..df6b83acedf529b7428bfa5b86db1005bfd39d60 100644 --- a/src/ppu.rs +++ b/src/ppu.rs @@ -704,7 +704,7 @@ impl Ppu { // index and the DY (scroll Y) divided by 8 (as the tiles are 8x8 pixels), // on top of that ensures that the result is modulus 32 meaning that the // drawing wraps around the Y axis - let row_offset = (((ld + scy) & 0xff) >> 3) % 32; + let row_offset = (((ld as usize + scy as usize) & 0xff) >> 3) % 32; // obtains the base address of the background map using the bg map flag // that control which background map is going to be used @@ -736,7 +736,7 @@ impl Ppu { // calculates both the current Y and X positions within the tiles // using the bitwise and operation as an effective modulus 8 - let y = ((ld + scy) & 0x07) as usize; + let y = (ld as usize + scy as usize) & 0x07; let mut x = (scx & 0x07) as usize; for index in 0..DISPLAY_WIDTH { @@ -749,7 +749,8 @@ impl Ppu { let pixel = self.tiles[tile_index].get(x, y); let color = self.palette[pixel as usize]; - // updates the pixel in the color buffer + // updates the pixel in the color buffer, which stores + // the raw pixel color information (unmapped) self.color_buffer[color_offset] = pixel; // set the color pixel in the frame buffer @@ -817,12 +818,16 @@ impl Ppu { self.palette_obj_1 }; - let mut color_offset = self.ly as usize * DISPLAY_WIDTH + obj.x as usize; + // calculates the offset in the color buffer (raw color information + // from 0 to 3) for the sprit that is going to be drawn, this value + // is kept as a signed integer to allow proper negative number math + let mut color_offset = self.ly as i32 * DISPLAY_WIDTH as i32 + obj.x as i32; // calculates the offset in the frame buffer for the sprite // that is going to be drawn, this is going to be the starting // point for the draw operation to be performed - let mut frame_offset = (self.ly as usize * DISPLAY_WIDTH + obj.x as usize) * RGB_SIZE; + let mut frame_offset = + (self.ly as i32 * DISPLAY_WIDTH as i32 + obj.x as i32) * RGB_SIZE as i32; // the relative title offset should range from 0 to 7 in 8x8 // objects and from 0 to 15 in 8x16 objects @@ -863,7 +868,7 @@ impl Ppu { if is_contained { // the object is only considered visible if it's a priority // or if the underlying pixel is transparent (zero value) - let is_visible = obj.priority || self.color_buffer[color_offset] == 0; + let is_visible = obj.priority || self.color_buffer[color_offset as usize] == 0; let pixel = tile_row[if obj.xflip { 7 - x } else { x }]; if is_visible && pixel != 0 { // obtains the current pixel data from the tile row and @@ -871,9 +876,9 @@ impl Ppu { let color = palette[pixel as usize]; // sets the color pixel in the frame buffer - self.frame_buffer[frame_offset] = color[0]; - self.frame_buffer[frame_offset + 1] = color[1]; - self.frame_buffer[frame_offset + 2] = color[2]; + self.frame_buffer[frame_offset as usize] = color[0]; + self.frame_buffer[frame_offset as usize + 1] = color[1]; + self.frame_buffer[frame_offset as usize + 2] = color[2]; } } @@ -883,7 +888,7 @@ impl Ppu { // increments the offset of the frame buffer by the // size of an RGB pixel (which is 3 bytes) - frame_offset += RGB_SIZE; + frame_offset += RGB_SIZE as i32; } } } diff --git a/src/rom.rs b/src/rom.rs index b791a353a2fa3446284313d4873141f74d6512b1..42947b9ba6b0df83dd8397924725326103c1c6a3 100644 --- a/src/rom.rs +++ b/src/rom.rs @@ -45,9 +45,9 @@ pub enum RomType { Unknown = 0xef, } -impl Display for RomType { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let str = match self { +impl RomType { + pub fn description(&self) -> &'static str { + match self { RomType::RomOnly => "ROM Only", RomType::Mbc1 => "MBC1", RomType::Mbc1Ram => "MBC1 + RAM", @@ -77,8 +77,13 @@ impl Display for RomType { RomType::HuC3 => "HuC3", RomType::HuC1RamBattery => "HuC1 + RAM + BATTERY", RomType::Unknown => "Unknown", - }; - write!(f, "{}", str) + } + } +} + +impl Display for RomType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.description()) } } @@ -97,6 +102,21 @@ pub enum RomSize { } impl RomSize { + pub fn description(&self) -> &'static str { + match self { + RomSize::Size32K => "32 KB", + RomSize::Size64K => "64 KB", + RomSize::Size128K => "128 KB", + RomSize::Size256K => "256 KB", + RomSize::Size512K => "512 KB", + RomSize::Size1M => "1 MB", + RomSize::Size2M => "2 MB", + RomSize::Size4M => "4 MB", + RomSize::Size8M => "8 MB", + RomSize::SizeUnknown => "Unknown", + } + } + pub fn rom_banks(&self) -> u16 { match self { RomSize::Size32K => 2, @@ -115,19 +135,7 @@ impl RomSize { impl Display for RomSize { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let str = match self { - RomSize::Size32K => "32 KB", - RomSize::Size64K => "64 KB", - RomSize::Size128K => "128 KB", - RomSize::Size256K => "256 KB", - RomSize::Size512K => "512 KB", - RomSize::Size1M => "1 MB", - RomSize::Size2M => "2 MB", - RomSize::Size4M => "4 MB", - RomSize::Size8M => "8 MB", - RomSize::SizeUnknown => "Unknown", - }; - write!(f, "{}", str) + write!(f, "{}", self.description()) } } @@ -143,6 +151,18 @@ pub enum RamSize { } impl RamSize { + pub fn description(&self) -> &'static str { + match self { + RamSize::NoRam => "No RAM", + RamSize::Unused => "Unused", + RamSize::Size8K => "8 KB", + RamSize::Size32K => "32 KB", + RamSize::Size128K => "128 KB", + RamSize::Size64K => "64 KB", + RamSize::SizeUnknown => "Unknown", + } + } + pub fn ram_banks(&self) -> u16 { match self { RamSize::NoRam => 0, @@ -158,16 +178,7 @@ impl RamSize { impl Display for RamSize { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let str = match self { - RamSize::NoRam => "No RAM", - RamSize::Unused => "Unused", - RamSize::Size8K => "8 KB", - RamSize::Size32K => "32 KB", - RamSize::Size128K => "128 KB", - RamSize::Size64K => "64 KB", - RamSize::SizeUnknown => "Unknown", - }; - write!(f, "{}", str) + write!(f, "{}", self.description()) } } @@ -376,6 +387,18 @@ impl Cartridge { _ => RamSize::SizeUnknown, } } + + pub fn rom_type_s(&self) -> String { + String::from(self.rom_type().description()) + } + + pub fn rom_size_s(&self) -> String { + String::from(self.rom_size().description()) + } + + pub fn ram_size_s(&self) -> String { + String::from(self.ram_size().description()) + } } impl Display for Cartridge {