diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 92bf46068bd64c858ea717c0cb9d6d719e321b88..28d50c7570bf528b34be105ebecbb858c36767a4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -55,6 +55,7 @@ deploy-netlify-preview: stage: deploy script: - cd examples/web/dist + - cp -rp ../static/* . - npm_config_yes=true npx --package=netlify-cli netlify deploy --dir=. dependencies: - build-wasm @@ -65,6 +66,7 @@ deploy-netlify-prod: stage: deploy script: - cd examples/web/dist + - cp -rp ../static/* . - npm_config_yes=true npx --package=netlify-cli netlify deploy --dir=. --prod dependencies: - build-wasm @@ -75,6 +77,7 @@ deploy-cloudfare-master: stage: deploy script: - cd examples/web/dist + - cp -rp ../static/* . - npm_config_yes=true npx wrangler pages publish . --project-name=boytacean --branch master dependencies: - build-wasm @@ -85,6 +88,7 @@ deploy-cloudfare-stable: stage: deploy script: - cd examples/web/dist + - cp -rp ../static/* . - npm_config_yes=true npx wrangler pages publish . --project-name=boytacean --branch stable dependencies: - build-wasm @@ -95,6 +99,7 @@ deploy-cloudfare-prod: stage: deploy script: - cd examples/web/dist + - cp -rp ../static/* . - npm_config_yes=true npx wrangler pages publish . --project-name=boytacean --branch prod dependencies: - build-wasm diff --git a/CHANGELOG.md b/CHANGELOG.md index e38f0beb70ff1904f7810c51ddcd4ca37eeb464a..4c3e07b9e26049c73f5bf89adbf83f93f334083e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* + +### Changed + +* + +### Fixed + +* + +## [0.4.0] - 2022-11-01 + +### Added + +* A whole new layout implemented using React.JS 🔥 * Instant boot support using the `GameBoy.boot()` method * Support for pending cycles in web version diff --git a/Cargo.toml b/Cargo.toml index 9523dbb9f5319699a67b859632cb1041bc3114e5..0d90a81bb287fdf92180c5d9a707f86bf3850f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "boytacean" description = "A Game Boy emulator that is written in Rust." -version = "0.3.0" +version = "0.4.0" authors = ["João Magalhães <joamag@gmail.com>"] license = "Apache-2.0" repository = "https://gitlab.stage.hive.pt/joamag/boytacean" diff --git a/examples/sdl/Cargo.toml b/examples/sdl/Cargo.toml index 97b7e3ea960e3bbc3f7a91d0d7323516286e673b..0c50371b7608b756ffeefdf6b45191188bd41829 100644 --- a/examples/sdl/Cargo.toml +++ b/examples/sdl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boytacean-sdl" -version = "0.3.0" +version = "0.4.0" authors = ["João Magalhães <joamag@gmail.com>"] description = "Game Boy Emulator SDL (Desktop) Application" license = "Apache-2.0" diff --git a/examples/sdl/src/main.rs b/examples/sdl/src/main.rs index 264a1cda21e30cabf8528e0c6a6c6854570b3850..e72b7a05e7dcd6b1c1d098e72dec6ccada22262a 100644 --- a/examples/sdl/src/main.rs +++ b/examples/sdl/src/main.rs @@ -142,6 +142,11 @@ impl Emulator { if self.system.ppu_mode() == PpuMode::VBlank && self.system.ppu_frame() != last_frame { + // clears the graphics canvas, making sure that no garbage + // pixel data remaining in the pixel buffer, not doing this would + // create visual glitches in OSs like Mac OS X + self.graphics.canvas.clear(); + // obtains the frame buffer of the Game Boy PPU and uses it // to update the stream texture, copying it then to the canvas let frame_buffer = self.system.frame_buffer().as_ref(); @@ -154,6 +159,8 @@ impl Emulator { // information presented to the user self.graphics.canvas.present(); + // obtains the index of the current PPU frame, this value + // is going to be used to detect for new frame presence last_frame = self.system.ppu_frame(); } } diff --git a/examples/web/index.css b/examples/web/index.css index 25a1c6283991884492ae2f74949af2719f93b5a7..d8dd1b1daf19c6ae8772aadec8ad2ac13a9123ab 100644 --- a/examples/web/index.css +++ b/examples/web/index.css @@ -56,78 +56,6 @@ p { text-align: center; } -.main > .side-left .canvas-container { - max-width: 100%; -} - -.main > .side-left .canvas-container.fullscreen { - align-items: center; - background-color: #2d2d2d; - display: flex; - height: 100%; - justify-content: center; - left: 0px; - position: fixed; - top: 0px; - width: 100%; - z-index: 6; -} - -.main > .side-left .canvas-container > .canvas-close { - bottom: 22px; - display: none; - position: absolute; - right: 22px; - user-select: none; - -o-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; -} - -.main > .side-left .canvas-container > .canvas-close > img { - height: 32px; - width: 32px; -} - -.main > .side-left .canvas-container.fullscreen > .canvas-close { - display: block; -} - -.main > .side-left .canvas-container > .canvas-frame { - background-color: #1b1a17; - border: 2px solid #50cb93; - font-size: 0px; - margin-top: 78px; - max-width: 320px; - padding: 8px 8px 8px 8px; -} - -@media only screen and (max-width: 1120px) { - .main > .side-left .canvas-container > .canvas-frame { - margin-top: 12px; - } -} - -.main > .side-left .canvas-container.fullscreen > .canvas-frame { - background-color: transparent; - border: none; - box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -o-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -ms-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -moz-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -khtml-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -webkit-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - margin: 0px 0px 0px 0px; - max-width: unset; - padding: 0px 0px 0px 0px; -} - -.main > .side-left .canvas-container > .canvas-frame > .canvas { - width: 100%; -} - .main > .side-right { flex: 0 1; max-width: 100%; @@ -182,343 +110,3 @@ p { content: ''; display: block; } - -.toast-container { - background-color: black; - height: 0px; - left: 0px; - padding: 0px 24px 0px 24px; - pointer-events: none; - position: fixed; - text-align: center; - top: 0px; - width: 100%; - z-index: 8; -} - -.toast-container > .toast { - background-color: #2a9d8f; - border-radius: 4px 4px 4px 4px; - -o-border-radius: 4px 4px 4px 4px; - -ms-border-radius: 4px 4px 4px 4px; - -moz-border-radius: 4px 4px 4px 4px; - -khtml-border-radius: 4px 4px 4px 4px; - -webkit-border-radius: 4px 4px 4px 4px; - cursor: pointer; - display: inline-block; - font-size: 20px; - line-height: 22px; - opacity: 0.0; - -o-opacity: 0.0; - -ms-opacity: 0.0; - -moz-opacity: 0.0; - -khtml-opacity: 0.0; - -webkit-opacity: 0.0; - padding: 12px 18px 12px 18px; - position: relative; - top: -46px; - transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - width: fit-content; -} - -.toast-container > .toast.error { - background-color: #e63946; -} - -.toast-container > .toast.visible { - opacity: 1.0; - -o-opacity: 1.0; - -ms-opacity: 1.0; - -moz-opacity: 1.0; - -khtml-opacity: 1.0; - -webkit-opacity: 1.0; - pointer-events: all; - top: 24px; -} - -.button-area { - user-select: none; - -o-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; -} - -.button-area > * { - margin-bottom: 12px; -} - -.magnify-button { - cursor: pointer; - display: inline-block; - transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); -} - -.magnify-button:hover { - transform: scale(1.3, 1.3); - -o-transform: scale(1.3, 1.3); - -ms-transform: scale(1.3, 1.3); - -moz-transform: scale(1.3, 1.3); - -khtml-transform: scale(1.3, 1.3); - -webkit-transform: scale(1.3, 1.3); -} - -.magnify-button:active { - transform: scale(1.0, 1.0); - -o-transform: scale(1.0, 1.0); - -ms-transform: scale(1.0, 1.0); - -moz-transform: scale(1.0, 1.0); - -khtml-transform: scale(1.0, 1.0); - -webkit-transform: scale(1.0, 1.0); -} - -.overlay { - align-items: center; - background-color: rgba(80, 203, 147, 0.95); - display: flex; - font-size: 48px; - height: 100%; - justify-content: center; - left: 0px; - opacity: 0.0; - -o-opacity: 0.0; - -ms-opacity: 0.0; - -moz-opacity: 0.0; - -khtml-opacity: 0.0; - -webkit-opacity: 0.0; - pointer-events: none; - position: fixed; - text-align: center; - top: 0px; - transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - width: 100%; - z-index: 10; -} - -.overlay.visible { - opacity: 1.0; - -o-opacity: 1.0; - -ms-opacity: 1.0; - -moz-opacity: 1.0; - -khtml-opacity: 1.0; - -webkit-opacity: 1.0; -} - -.overlay .overlay-image { - margin-top: 16px; -} - -.overlay .overlay-image > img { - width: 64px; -} - -.modal-container { - align-items: center; - background-color: rgba(20, 20, 20, 0.95); - display: flex; - height: 100%; - justify-content: center; - left: 0px; - opacity: 0; - -o-opacity: 0; - -ms-opacity: 0; - -moz-opacity: 0; - -khtml-opacity: 0; - -webkit-opacity: 0; - padding: 0px 12px 0px 12px; - pointer-events: none; - position: fixed; - text-align: center; - top: 0px; - transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); - width: 100%; - z-index: 10; -} - -.modal-container.visible { - opacity: 1.0; - -o-opacity: 1.0; - -ms-opacity: 1.0; - -moz-opacity: 1.0; - -khtml-opacity: 1.0; - -webkit-opacity: 1.0; - transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); -} - -.modal-container > .modal { - background-color: #264653; - border-radius: 6px 6px 6px 6px; - -o-border-radius: 6px 6px 6px 6px; - -ms-border-radius: 6px 6px 6px 6px; - -moz-border-radius: 6px 6px 6px 6px; - -khtml-border-radius: 6px 6px 6px 6px; - -webkit-border-radius: 6px 6px 6px 6px; - box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); - -o-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); - -ms-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); - -moz-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); - -khtml-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); - -webkit-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); - margin-top: 30px; - max-width: 100%; - padding: 24px 24px 24px 24px; - text-align: left; - transform: scale(0.96); - -o-transform: scale(0.96); - -ms-transform: scale(0.96); - -moz-transform: scale(0.96); - -khtml-transform: scale(0.96); - -webkit-transform: scale(0.96); - transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); - width: 480px; -} - -.modal-container.visible > .modal { - margin-top: 0px; - pointer-events: all; - transform: scale(1); - -o-transform: scale(1); - -ms-transform: scale(1); - -moz-transform: scale(1); - -khtml-transform: scale(1); - -webkit-transform: scale(1); - transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -o-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -ms-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -moz-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -khtml-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); - -webkit-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); -} - -.modal-container > .modal .modal-top-buttons { - float: right; - margin-right: -10px; - margin-top: -10px; -} - -.modal-container > .modal .modal-title { - font-size: 32px; - margin-top: 0px; - text-align: left; -} - -.modal-container > .modal .modal-text { - font-size: 20px; - line-height: 22px; -} - -.modal-container > .modal .modal-buttons { - font-size: 22px; - margin-top: 24px; - text-align: center; - user-select: none; - -o-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; -} - -.modal-container > .modal .modal-buttons > .button.simple { - display: inline-block; - margin-right: 12px; - min-width: 120px; -} - -.modal-container > .modal .modal-buttons > .button.simple:last-child { - margin-right: 0px; -} - -.keyboard { - font-size: 0px; - text-align: center; - touch-callout: none; - -o-touch-callout: none; - -ms-touch-callout: none; - -moz-touch-callout: none; - -khtml-touch-callout: none; - -webkit-touch-callout: none; - user-select: none; - -o-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; -} - -.keyboard > .keyboard-line { - margin-bottom: 12px; -} - -.keyboard > .keyboard-line:last-child { - margin-bottom: 0px; -} - -.keyboard .key { - border: 2px solid #ffffff; - border-radius: 5px 5px 5px 5px; - -o-border-radius: 5px 5px 5px 5px; - -ms-border-radius: 5px 5px 5px 5px; - -moz-border-radius: 5px 5px 5px 5px; - -khtml-border-radius: 5px 5px 5px 5px; - -webkit-border-radius: 5px 5px 5px 5px; - cursor: pointer; - display: inline-block; - font-size: 38px; - height: 48px; - line-height: 46px; - margin-right: 14px; - text-align: center; - width: 48px; -} - -.keyboard .key:last-child { - margin-right: 0px; -} - -.keyboard .key:hover { - background-color: #50cb93; -} - -.keyboard .key:active { - background-color: #2a9d8f; -} - -#section-diag { - display: none; -} - -#separator-diag { - display: none; -} \ No newline at end of file diff --git a/examples/web/index.html b/examples/web/index.html index 2ff402b47b20289271dc61d32e582a5032541034..6cc648c63fe4151b108528a7c421bc1e5a2afd1d 100644 --- a/examples/web/index.html +++ b/examples/web/index.html @@ -14,147 +14,7 @@ <body> <div id="app"></div> - <div class="main"> - <div class="side-left"> - <div id="canvas-container" class="canvas-container"> - <span id="canvas-close" class="magnify-button canvas-close"> - <img class="large" src="res/minimise.svg" alt="minimise" /> - </span> - <div class="canvas-frame"> - <canvas id="engine-canvas" class="canvas" width="320" height="288"></canvas> - </div> - </div> - </div> - <div class="side-right"> - <h1>Boytacean <a id="version" href="https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md" target="_blank"></a> <img class="logo-image" src="res/thunder.png" alt="thunder" /> - </h1> - <div class="separator"></div> - <div id="section-narrative" class="section"> - <p>This is a <a href="https://en.wikipedia.org/wiki/Game_Boy" target="_blank">Game Boy</a> emulator built using - the <a href="https://www.rust-lang.org" target="_blank">Rust Programming Language</a> and is running - inside this browser with the help of <a href="https://webassembly.org/" target="_blank">WebAssembly</a>. - </p> - <p>You can check the source code of it at <a href="https://gitlab.stage.hive.pt/joamag/boytacean" - target="_blank">GitLab</a>.</p> - <p>TIP: Drag and Drop ROM files to the Browser to load the ROM.</p> - </div> - <div id="separator-narrative" class="separator"></div> - <div id="section-keyboard" class="section" style="display: none;"> - <div id="keyboard" class="keyboard"> - <div class="keyboard-line"> - <span class="key">1</span> - <span class="key">2</span> - <span class="key">3</span> - <span class="key">4</span> - </div> - <div class="keyboard-line"> - <span class="key">Q</span> - <span class="key">W</span> - <span class="key">E</span> - <span class="key">R</span> - </div> - <div class="keyboard-line"> - <span class="key">A</span> - <span class="key">S</span> - <span class="key">D</span> - <span class="key">F</span> - </div> - <div class="keyboard-line"> - <span class="key">Z</span> - <span class="key">X</span> - <span class="key">C</span> - <span class="key">V</span> - </div> - </div> - </div> - <div id="separator-keyboard" class="separator" style="display: none;"></div> - <div id="section-debug" class="section" style="display: none;"> - <div id="debug" class="debug"> - <canvas id="canvas-tiles" class="canvas-tiles" width="128" height="192"></canvas> - </div> - </div> - <div id="separator-debug" class="separator" style="display: none;"></div> - <div id="section-diag" class="section"> - <dl class="diag"> - <dt>Engine</dt> - <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="button simple">-</span> - <span id="logic-frequency">-</span> Hz - <span id="logic-frequency-plus" class="button simple">+</span></dd> - <dt>Framerate</dt> - <dd><span id="fps-count">-</span> fps</dd> - </dl> - </div> - <div id="separator-diag" class="separator"></div> - <div class="section"> - <div class="button-area"> - <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="button simple border padded"> - <img src="res/reset.svg" alt="reset" /><span>Reset</span> - </span> - <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="button simple border padded"> - <img src="res/maximise.svg" alt="maximise" /><span>Fullscreen</span> - </span> - <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="button simple border padded"> - <img src="res/info.svg" alt="info" /><span>Information</span> - </span> - <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="button simple border padded"> - <img src="res/marker.svg" alt="marker" /><span>Theme</span> - </span> - <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> - </div> - </div> - </div> - </div> - <div class="toast-container"> - <div id="toast" class="toast"></div> - </div> </body> -<div id="modal-container" class="modal-container"> - <div id="modal" class="modal"> - <div class="modal-top-buttons"> - <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="button simple red border padded-large">Cancel</span> - <span id="modal-confirm" class="button simple border padded-large">Confirm</span> - </div> - </div> -</div> -<div id="overlay" class="overlay"> - <div class="overlay-container"> - <div class="overlay-text"> - Drag to load ROM <span id="rom-name"></span> - </div> - <div class="overlay-image"> - <img src="res/sunglasses.png" alt="sunglasses" /> - </div> - </div> -</div> <script type="module" src="index.ts"></script> </body> diff --git a/examples/web/index.ts b/examples/web/index.ts index 8631149265d051043e9a6d8b08ffaf58c327d143..cd8afa06d3e5d20999bd0bc491096e640098a657 100644 --- a/examples/web/index.ts +++ b/examples/web/index.ts @@ -1,23 +1,28 @@ -import { Emulator, startApp } from "./react/app"; - -import { default as _wasm, GameBoy, PadKey, PpuMode } from "./lib/boytacean.js"; +import { + Emulator, + Observable, + PixelFormat, + RomInfo, + startApp +} from "./react/app"; + +import { + Cartridge, + 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; const VISUAL_HZ = 60; -const TIMER_HZ = 60; const IDLE_HZ = 10; const FREQUENCY_DELTA = 60; -const DISPLAY_WIDTH = 160; -const DISPLAY_HEIGHT = 144; -const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT; - const SAMPLE_RATE = 2; const BACKGROUNDS = [ @@ -41,21 +46,21 @@ const KEYS: Record<string, number> = { s: PadKey.B }; -const ROM_PATH = require("../../res/roms/20y.gb"); +const ARROW_KEYS: Record<string, boolean> = { + ArrowUp: true, + ArrowDown: true, + ArrowLeft: true, + ArrowRight: true +}; -// Enumeration that describes the multiple pixel -// formats and the associated size in bytes. -enum PixelFormat { - RGB = 3, - RGBA = 4 -} +const ROM_PATH = require("../../res/roms/20y.gb"); /** * Top level class that controls the emulator behaviour * and "joins" all the elements together to bring input/output * of the associated machine. */ -class GameboyEmulator implements Emulator { +class GameboyEmulator extends Observable implements Emulator { /** * The Game Boy engine (probably coming from WASM) that * is going to be used for the emulation. @@ -70,16 +75,8 @@ class GameboyEmulator implements Emulator { 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 nextTickTime: number = 0; private fps: number = 0; @@ -89,6 +86,7 @@ class GameboyEmulator implements Emulator { private romName: string | null = null; private romData: Uint8Array | null = null; private romSize: number = 0; + private cartridge: Cartridge | null = null; async main() { // initializes the WASM module, this is required @@ -97,13 +95,11 @@ class GameboyEmulator implements Emulator { // 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 + // boots the emulator subsystem with the initial // ROM retrieved from a remote data source - await this.start({ loadRom: true }); + await this.boot({ loadRom: true }); // the counter that controls the overflowing cycles // from tick to tick operation @@ -145,7 +141,11 @@ class GameboyEmulator implements Emulator { // displays the error information to both the end-user // and the developer (for diagnostics) - this.showToast(message, true, 5000); + this.trigger("message", { + text: message, + error: true, + timeout: 5000 + }); console.error(err); // pauses the machine, allowing the end-user to act @@ -157,13 +157,10 @@ class GameboyEmulator implements Emulator { // 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 }); + await this.boot({ restore: false }); + + this.trigger("error"); } } @@ -216,16 +213,14 @@ class GameboyEmulator implements Emulator { // 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 + 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(); + + // triggers the frame event indicating that + // a new frame is now available for drawing + this.trigger("frame"); } } @@ -240,7 +235,7 @@ class GameboyEmulator implements Emulator { const currentTime = new Date().getTime(); const deltaTime = (currentTime - this.frameStart) / 1000; const fps = Math.round(this.frameCount / deltaTime); - this.setFps(fps); + this.fps = fps; this.frameCount = 0; this.frameStart = currentTime; } @@ -258,10 +253,13 @@ class GameboyEmulator implements Emulator { * Starts the current machine, setting the internal structure in * a proper state to start drawing and receiving input. * + * This method can also be used to load a new ROM into the machine. + * * @param options The options that are going to be used in the - * starting of the machine. + * starting of the machine, includes information on the ROM and + * the emulator engine to use. */ - async start({ + async boot({ engine = "neo", restore = true, loadRom = false, @@ -314,111 +312,27 @@ class GameboyEmulator implements Emulator { // 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); + this.setRom(romName!, romData!, cartridge); // in case the restore (state) flag is set // then resumes the machine execution if (restore) this.resume(); - } - - async buildVisuals() { - /* KeyValue.create("ROM", "-", { id: "diag:rom-name" }).mount(".diag"); - KeyValue.create("ROM Size", "-", { id: "diag:rom-size" }).mount( - ".diag" - ); - KeyValue.create("Framerate", "-", { id: "diag:framerate" }).mount( - ".diag" - ); - KeyValue.create("ROM Type", "-", { id: "diag:rom-type" }).mount( - ".diag" - ); - KeySwitch.create("Tobias", ["1", "2", "3"], { - id: "diag:tobias" - }).mount(".diag"); - Button.create("Tobias", require("./res/close.svg")) - .bind("click", () => alert("Hello World")) - .mount(".button-area");*/ + // triggers the booted event indicating that the + // emulator has finished the loading process + this.trigger("booted"); } // @todo remove this method, or at least most of it async register() { - await Promise.all([ - this.registerDrop(), - this.registerKeys(), - this.registerButtons(), - this.registerKeyboard(), - this.registerCanvas(), - this.registerToast(), - this.registerModal() - ]); - } - - async init() { - await Promise.all([this.initBase(), this.initCanvas()]); - } - - registerDrop() { - document.addEventListener("drop", async (event) => { - if ( - !event.dataTransfer!.files || - event.dataTransfer!.files.length === 0 - ) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const overlay = document.getElementById("overlay")!; - overlay.classList.remove("visible"); - - const file = event.dataTransfer!.files[0]; - - if (!file.name.endsWith(".gb")) { - this.showToast( - "This is probably not a Game Boy ROM file!", - true - ); - return; - } - - const arrayBuffer = await file.arrayBuffer(); - const romData = new Uint8Array(arrayBuffer); - - this.start({ engine: null, romName: file.name, romData: romData }); - - this.showToast(`Loaded ${file.name} ROM successfully!`); - }); - document.addEventListener("dragover", async (event) => { - if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type) - return; - - event.preventDefault(); - - const overlay = document.getElementById("overlay")!; - overlay.classList.add("visible"); - }); - document.addEventListener("dragenter", async (event) => { - if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type) - return; - const overlay = document.getElementById("overlay")!; - overlay.classList.add("visible"); - }); - document.addEventListener("dragleave", async (event) => { - if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type) - return; - const overlay = document.getElementById("overlay")!; - overlay.classList.remove("visible"); - }); + await Promise.all([this.registerKeys()]); } registerKeys() { document.addEventListener("keydown", (event) => { const keyCode = KEYS[event.key]; + const isArrow = ARROW_KEYS[event.key] ?? false; + if (isArrow) event.preventDefault(); if (keyCode !== undefined) { this.gameBoy!.key_press(keyCode); return; @@ -426,25 +340,19 @@ class GameboyEmulator implements Emulator { switch (event.key) { case "+": - this.setLogicFrequency( - this.logicFrequency + FREQUENCY_DELTA - ); + this.logicFrequency += FREQUENCY_DELTA; break; case "-": - this.setLogicFrequency( - this.logicFrequency - FREQUENCY_DELTA - ); - break; - - case "Escape": - this.minimize(); + this.logicFrequency -= FREQUENCY_DELTA; break; } }); document.addEventListener("keyup", (event) => { const keyCode = KEYS[event.key]; + const isArrow = ARROW_KEYS[event.key] ?? false; + if (isArrow) event.preventDefault(); if (keyCode !== undefined) { this.gameBoy!.key_lift(keyCode); return; @@ -452,510 +360,66 @@ class GameboyEmulator implements Emulator { }); } - registerButtons() { - const engine = document.getElementById("engine")!; - engine.addEventListener("click", () => { - const name = this.engine == "neo" ? "classic" : "neo"; - this.start({ engine: name }); - this.showToast( - `Game Boy running in engine "${name.toUpperCase()}" from now on!` - ); - }); - - const logicFrequencyPlus = document.getElementById( - "logic-frequency-plus" - )!; - logicFrequencyPlus.addEventListener("click", () => { - this.setLogicFrequency(this.logicFrequency + FREQUENCY_DELTA); - }); - - const logicFrequencyMinus = document.getElementById( - "logic-frequency-minus" - )!; - logicFrequencyMinus.addEventListener("click", () => { - this.setLogicFrequency(this.logicFrequency - FREQUENCY_DELTA); - }); - - const buttonPause = document.getElementById("button-pause")!; - buttonPause.addEventListener("click", () => { - this.toggleRunning(); - }); - - const buttonReset = document.getElementById("button-reset")!; - buttonReset.addEventListener("click", () => { - this.reset(); - }); - - const buttonBenchmark = document.getElementById("button-benchmark")!; - buttonBenchmark.addEventListener("click", async () => { - const result = await this.showModal( - "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!", - "Confirm" - ); - if (!result) return; - buttonBenchmark.classList.add("enabled"); - this.pause(); - try { - const initial = Date.now(); - const count = 500000000; - for (let i = 0; i < count; i++) { - this.gameBoy!.clock(); - } - const delta = (Date.now() - initial) / 1000; - const frequency_mhz = count / delta / 1000 / 1000; - this.showToast( - `Took ${delta.toFixed( - 2 - )} seconds to run ${count} ticks (${frequency_mhz.toFixed( - 2 - )} Mhz)!`, - undefined, - 7500 - ); - } finally { - this.resume(); - buttonBenchmark.classList.remove("enabled"); - } - }); - - const buttonFullscreen = document.getElementById("button-fullscreen")!; - buttonFullscreen.addEventListener("click", () => { - this.maximize(); - }); - - const buttonKeyboard = document.getElementById("button-keyboard")!; - buttonKeyboard.addEventListener("click", () => { - const sectionKeyboard = - document.getElementById("section-keyboard")!; - const separatorKeyboard = - document.getElementById("separator-keyboard")!; - const sectionNarrative = - document.getElementById("section-narrative")!; - const separatorNarrative = document.getElementById( - "separator-narrative" - )!; - if (buttonKeyboard.classList.contains("enabled")) { - sectionKeyboard.style.display = "none"; - separatorKeyboard.style.display = "none"; - sectionNarrative.style.display = "block"; - separatorNarrative.style.display = "block"; - buttonKeyboard.classList.remove("enabled"); - } else { - sectionKeyboard.style.display = "block"; - separatorKeyboard.style.display = "block"; - sectionNarrative.style.display = "none"; - separatorNarrative.style.display = "none"; - buttonKeyboard.classList.add("enabled"); - } - }); - - const buttonDebug = document.getElementById("button-debug")!; - buttonDebug.addEventListener("click", () => { - const sectionDebug = document.getElementById("section-debug")!; - const separatorDebug = document.getElementById("separator-debug")!; - const sectionNarrative = - document.getElementById("section-narrative")!; - const separatorNarrative = document.getElementById( - "separator-narrative" - )!; - if (buttonDebug.classList.contains("enabled")) { - sectionDebug.style.display = "none"; - separatorDebug.style.display = "none"; - sectionNarrative.style.display = "block"; - separatorNarrative.style.display = "block"; - buttonDebug.classList.remove("enabled"); - } else { - sectionDebug.style.display = "block"; - separatorDebug.style.display = "block"; - sectionNarrative.style.display = "none"; - separatorNarrative.style.display = "none"; - buttonDebug.classList.add("enabled"); - - const canvasTiles = document.getElementById( - "canvas-tiles" - ) as HTMLCanvasElement; - const canvasTilesCtx = canvasTiles.getContext("2d")!; - canvasTilesCtx.imageSmoothingEnabled = false; - - const canvasImage = canvasTilesCtx.createImageData( - canvasTiles.width, - canvasTiles.height - ); - const videoBuff = new DataView(canvasImage.data.buffer); - - /** - * Draws the tile at the given index to the proper - * vertical offset in the given context and buffer. - * - * @param index The index of the sprite to be drawn. - * @param format The pixel format of the sprite. - */ - const drawTile = ( - index: number, - context: CanvasRenderingContext2D, - buffer: DataView, - format: PixelFormat = PixelFormat.RGB - ) => { - const pixels = this.gameBoy!.get_tile_buffer(index); - const line = Math.floor(index / 16); - const column = index % 16; - let offset = - (line * canvasTiles.width * 8 + column * 8) * - PixelFormat.RGBA; - let counter = 0; - for ( - let index = 0; - index < pixels.length; - index += format - ) { - const color = - (pixels[index] << 24) | - (pixels[index + 1] << 16) | - (pixels[index + 2] << 8) | - (format == PixelFormat.RGBA - ? pixels[index + 3] - : 0xff); - buffer.setUint32(offset, color); - - counter++; - if (counter == 8) { - counter = 0; - offset += - (canvasTiles.width - 7) * PixelFormat.RGBA; - } else { - offset += PixelFormat.RGBA; - } - } - context.putImageData(canvasImage, 0, 0); - }; - - for (let index = 0; index < 384; index++) { - drawTile(index, canvasTilesCtx, videoBuff); - } - - const vram = this.gameBoy!.vram_eager(); - const step = 16; - for (let index = 0; index < vram.length; index += step) { - let line = `${(index + 0x8000) - .toString(16) - .padStart(4, "0")}`; - for (let j = 0; j < step; j++) { - line += ` ${vram[index + j] - .toString(16) - .padStart(2, "0")}`; - } - console.info(line); - } - } - }); - - const buttonInformation = - document.getElementById("button-information")!; - buttonInformation.addEventListener("click", () => { - const sectionDiag = document.getElementById("section-diag")!; - const separatorDiag = document.getElementById("separator-diag")!; - if (buttonInformation.classList.contains("enabled")) { - sectionDiag.style.display = "none"; - separatorDiag.style.display = "none"; - buttonInformation.classList.remove("enabled"); - } else { - sectionDiag.style.display = "block"; - separatorDiag.style.display = "block"; - buttonInformation.classList.add("enabled"); - } - }); - - const buttonUploadFile = document.getElementById( - "button-upload-file" - ) as HTMLInputElement; - buttonUploadFile.addEventListener("change", async () => { - if ( - !buttonUploadFile.files || - buttonUploadFile.files.length === 0 - ) { - return; - } - - const file = buttonUploadFile.files[0]; - - const arrayBuffer = await file.arrayBuffer(); - const romData = new Uint8Array(arrayBuffer); - - buttonUploadFile.value = ""; - - this.start({ engine: null, romName: file.name, romData: romData }); - - this.showToast(`Loaded ${file.name} ROM successfully!`); - }); - } - - // @todo this should be converted into a component - registerKeyboard() { - const keyboard = document.getElementById("keyboard")!; - const keys = keyboard.getElementsByClassName("key"); - - keyboard.addEventListener("touchstart", function (event) { - event.preventDefault(); - event.stopPropagation(); - }); - - keyboard.addEventListener("touchend", function (event) { - event.preventDefault(); - event.stopPropagation(); - }); - - Array.prototype.forEach.call(keys, (k: Element) => { - k.addEventListener( - "mousedown", - function (this: HTMLElement, event) { - const keyCode = KEYS[this.textContent!.toLowerCase()]; - //this.gameBoy.key_press_ws(keyCode); @todo - event.preventDefault(); - event.stopPropagation(); - } - ); - - k.addEventListener( - "touchstart", - function (this: HTMLElement, event) { - const keyCode = KEYS[this.textContent!.toLowerCase()]; - //this.gameBoy.key_press_ws(keyCode); @todo - event.preventDefault(); - event.stopPropagation(); - } - ); - - k.addEventListener("mouseup", function (this: HTMLElement, event) { - const keyCode = KEYS[this.textContent!.toLowerCase()]; - //this.gameBoy.key_lift_ws(keyCode); @todo - event.preventDefault(); - event.stopPropagation(); - }); - - k.addEventListener("touchend", function (this: HTMLElement, event) { - const keyCode = KEYS[this.textContent!.toLowerCase()]; - //this.gameBoy.key_lift_ws(keyCode); @todo - event.preventDefault(); - event.stopPropagation(); - }); - }); + setRom(name: string, data: Uint8Array, cartridge: Cartridge) { + this.romName = name; + this.romData = data; + this.romSize = data.length; + this.cartridge = cartridge; } - registerCanvas() { - const canvasClose = document.getElementById("canvas-close")!; - canvasClose.addEventListener("click", () => { - this.minimize(); - }); + getName() { + return "Boytacean"; } - registerToast() { - const toast = document.getElementById("toast")!; - toast.addEventListener("click", () => { - toast.classList.remove("visible"); - }); + getDevice(): string { + return "Game Boy"; } - registerModal() { - const modalClose = document.getElementById("modal-close")!; - modalClose.addEventListener("click", () => { - this.hideModal(false); - }); - - const modalCancel = document.getElementById("modal-cancel")!; - modalCancel.addEventListener("click", () => { - this.hideModal(false); - }); - - const modalConfirm = document.getElementById("modal-confirm")!; - modalConfirm.addEventListener("click", () => { - this.hideModal(true); - }); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - this.hideModal(false); - } - }); + getDeviceUrl(): string { + return "https://en.wikipedia.org/wiki/Game_Boy"; } - async initBase() { - this.setVersion(info.version); + getVersion() { + return info.version; } - async initCanvas() { - // initializes the off-screen canvas that is going to be - // used in the drawing process - this.canvas = document.createElement("canvas"); - this.canvas.width = DISPLAY_WIDTH; - this.canvas.height = DISPLAY_HEIGHT; - this.canvasCtx = this.canvas.getContext("2d")!; - - this.canvasScaled = document.getElementById( - "engine-canvas" - ) as HTMLCanvasElement; - this.canvasScaled.width = - this.canvasScaled.width * window.devicePixelRatio; - this.canvasScaled.height = - this.canvasScaled.height * window.devicePixelRatio; - this.canvasScaledCtx = this.canvasScaled.getContext("2d")!; - - this.canvasScaledCtx.scale( - this.canvasScaled.width / this.canvas.width, - this.canvasScaled.height / this.canvas.height - ); - this.canvasScaledCtx.imageSmoothingEnabled = false; - - this.image = this.canvasCtx.createImageData( - this.canvas.width, - this.canvas.height - ); - this.videoBuff = new DataView(this.image.data.buffer); + getVersionUrl() { + return "https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md"; } - 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); + getPixelFormat(): PixelFormat { + return PixelFormat.RGB; } - async clearCanvas( - color = PIXEL_UNSET_COLOR, - { - image = null, - imageScale = 1 - }: { image?: string | null; imageScale?: number } = {} - ) { - this.canvasScaledCtx!.fillStyle = `#${color - .toString(16) - .toUpperCase()}`; - this.canvasScaledCtx!.fillRect( - 0, - 0, - this.canvasScaled!.width, - this.canvasScaled!.height - ); - - // in case an image was requested then uses that to load - // an image at the center of the screen properly scaled - if (image) { - const img = await new Promise<HTMLImageElement>((resolve) => { - const img = new Image(); - img.onload = () => { - resolve(img); - }; - img.src = image; - }); - const [imgWidth, imgHeight] = [ - img.width * imageScale * window.devicePixelRatio, - img.height * imageScale * window.devicePixelRatio - ]; - const [x0, y0] = [ - this.canvasScaled!.width / 2 - imgWidth / 2, - this.canvasScaled!.height / 2 - imgHeight / 2 - ]; - this.canvasScaledCtx!.setTransform(1, 0, 0, 1, 0, 0); - try { - this.canvasScaledCtx!.drawImage( - img, - x0, - y0, - imgWidth, - imgHeight - ); - } finally { - this.canvasScaledCtx!.scale( - this.canvasScaled!.width / this.canvas!.width, - this.canvasScaled!.height / this.canvas!.height - ); + /** + * Returns the array buffer that contains the complete set of + * pixel data that is going to be drawn. + * + * @returns The current pixel data for the emulator display. + */ + getImageBuffer(): Uint8Array { + return this.gameBoy!.frame_buffer_eager(); + } + + getRomInfo(): RomInfo { + return { + name: this.romName || undefined, + data: this.romData || undefined, + size: this.romSize, + extra: { + romType: this.cartridge?.rom_type_s(), + romSize: this.cartridge?.rom_size_s(), + ramSize: this.cartridge?.ram_size_s() } - } - } - - async showToast(message: string, error = false, timeout = 3500) { - const toast = document.getElementById("toast")!; - toast.classList.remove("error"); - if (error) toast.classList.add("error"); - toast.classList.add("visible"); - toast.textContent = message; - if (this.toastTimeout) clearTimeout(this.toastTimeout); - this.toastTimeout = setTimeout(() => { - toast.classList.remove("visible"); - this.toastTimeout = null; - }, timeout); - } - - async showModal(message: string, title = "Alert"): Promise<boolean> { - const modalContainer = document.getElementById("modal-container")!; - const modalTitle = document.getElementById("modal-title")!; - const modalText = document.getElementById("modal-text")!; - modalContainer.classList.add("visible"); - modalTitle.textContent = title; - modalText.innerHTML = message.replace(/\n/g, "<br/>"); - const result = (await new Promise((resolve) => { - global.modalCallback = resolve; - })) as boolean; - return result; + }; } - async hideModal(result = true) { - const modalContainer = document.getElementById("modal-container")!; - modalContainer.classList.remove("visible"); - if (global.modalCallback) global.modalCallback(result); - global.modalCallback = null; + getFramerate(): number { + return this.fps; } - setVersion(value: string) { - document.getElementById("version")!.textContent = value; - } - - setEngine(name: string, upper = true) { - name = upper ? name.toUpperCase() : name; - document.getElementById("engine")!.textContent = name; - } - - setRom(name: string, data: Uint8Array) { - this.romName = name; - this.romData = data; - this.romSize = data.length; - //@todo update this one - //Component.get<KeyValue>("diag:rom-name").value = name; - //Component.get<KeyValue>("diag:rom-size").value = `${data.length} bytes`; - } - - setLogicFrequency(value: number) { - if (value < 0) this.showToast("Invalid frequency value!", true); - value = Math.max(value, 0); - this.logicFrequency = value; - document.getElementById("logic-frequency")!.textContent = String(value); - } - - setFps(value: number) { - if (value < 0) this.showToast("Invalid FPS value!", true); - value = Math.max(value, 0); - this.fps = value; - //@todo - //Component.get<KeyValue>("diag:framerate").value = `${value} FPS`; - } - - getName() { - return "Boytacean"; - } - - getVersion() { - return info.version; - } - - getVersionUrl() { - return "https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md"; + getTile(index: number): Uint8Array { + return this.gameBoy!.get_tile_buffer(index); } toggleRunning() { @@ -968,88 +432,39 @@ class GameboyEmulator implements Emulator { pause() { this.paused = true; - const buttonPause = document.getElementById("button-pause")!; - const img = buttonPause.getElementsByTagName("img")[0]; - const span = buttonPause.getElementsByTagName("span")[0]; - buttonPause.classList.add("enabled"); - img.src = require("./res/play.svg"); - span.textContent = "Resume"; } resume() { this.paused = false; this.nextTickTime = new Date().getTime(); - const buttonPause = document.getElementById("button-pause")!; - const img = buttonPause.getElementsByTagName("img")[0]; - const span = buttonPause.getElementsByTagName("span")[0]; - buttonPause.classList.remove("enabled"); - img.src = require("./res/pause.svg"); - span.textContent = "Pause"; } - /** - * Resets the emulator machine to the start state and loads - * the ROM that is currently set in the emulator. - */ reset() { - this.start({ engine: null }); - } - - toggleWindow() { - this.maximize(); - } - - /** - * Maximizes the emulator's viewport taking up all the available - * window space. This method is responsible for keeping the aspect - * ratio of the emulator canvas according to the width/height ratio. - */ - maximize() { - const canvasContainer = document.getElementById("canvas-container")!; - canvasContainer.classList.add("fullscreen"); - - window.addEventListener("resize", this.crop); - - this.crop(); + this.boot({ engine: null }); } - /** - * 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`; + benchmark(count = 50000000) { + let cycles = 0; + this.pause(); + try { + const initial = Date.now(); + for (let i = 0; i < count; i++) { + cycles += this.gameBoy!.clock(); + } + const delta = (Date.now() - initial) / 1000; + const frequency_mhz = cycles / delta / 1000 / 1000; + return { + delta: delta, + count: count, + cycles: cycles, + frequency_mhz: frequency_mhz + }; + } finally { + this.resume(); } } - async fetchRom(romPath: string): Promise<[string, Uint8Array]> { + private 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); @@ -1070,15 +485,6 @@ class GameboyEmulator implements Emulator { } } -type Global = { - modalCallback: Function | null; -}; - -//@todo check if this is really required -const global: Global = { - modalCallback: null -}; - declare global { interface Window { panic: (message: string) => void; diff --git a/examples/web/package.json b/examples/web/package.json index a956b883f06a58671732d1cff3f19fff1b0274ed..3f3fb0ce3f4a4c3b674407b58fddcf502b685f06 100644 --- a/examples/web/package.json +++ b/examples/web/package.json @@ -1,6 +1,6 @@ { "name": "boytacean-web", - "version": "0.3.0", + "version": "0.4.0", "description": "The web version of Boytacean", "repository": { "type": "git", diff --git a/examples/web/react/app.css b/examples/web/react/app.css index 849c49d05241bc10b7e35cf04c75a8eee7e99e67..4c1786700a5d9be248c7a518c125744177b3972e 100644 --- a/examples/web/react/app.css +++ b/examples/web/react/app.css @@ -2,5 +2,18 @@ color: #ffffff; font-family: VT323, Roboto, Open Sans, Arial, Helvetica, sans-serif; margin: 0px 0px 0px 0px; - display: none; +} + +.app h3 { + margin: 10px 0px 10px 0px; +} + +.app .display-container { + margin-top: 78px; +} + +@media only screen and (max-width: 1120px) { + .app .display-container { + margin-top: 0px; + } } diff --git a/examples/web/react/app.tsx b/examples/web/react/app.tsx index 3144dab2db6e410d8e628d5b2579f13519ac8c81..e8b7269c0320e20342cdce9b3617d380f92f7acd 100644 --- a/examples/web/react/app.tsx +++ b/examples/web/react/app.tsx @@ -1,31 +1,211 @@ -import React, { FC, useEffect, useState } from "react"; +import React, { FC, useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom/client"; +declare const require: any; + import { Button, ButtonContainer, ButtonIncrement, ButtonSwitch, + ClearHandler, + Display, + DrawHandler, Footer, Info, + KeyboardGB, Link, + Modal, + Overlay, Pair, PanelSplit, Paragraph, Section, - Title + Tiles, + Title, + Toast } from "./components"; import "./app.css"; -export interface Emulator { +export type Callback<T> = (owner: T, params?: Record<string, any>) => void; + +/** + * Abstract class that implements the basic functionality + * part of the definition of the Observer pattern. + * + * @see {@link https://en.wikipedia.org/wiki/Observer_pattern} + */ +export class Observable { + private events: Record<string, [Callback<this>]> = {}; + + bind(event: string, callback: Callback<this>) { + const callbacks = this.events[event] ?? []; + if (callbacks.includes(callback)) return; + callbacks.push(callback); + this.events[event] = callbacks; + } + + unbind(event: string, callback: Callback<this>) { + const callbacks = this.events[event] ?? []; + if (!callbacks.includes(callback)) return; + const index = callbacks.indexOf(callback); + callbacks.splice(index, 1); + this.events[event] = callbacks; + } + + trigger(event: string, params?: Record<string, any>) { + const callbacks = this.events[event] ?? []; + callbacks.forEach((c) => c(this, params)); + } +} + +export type RomInfo = { + name?: string; + data?: Uint8Array; + size?: number; + extra?: Record<string, string | undefined>; +}; + +export type BenchmarkResult = { + delta: number; + count: number; + cycles: number; + frequency_mhz: number; +}; + +export interface ObservableI { + bind(event: string, callback: Callback<this>): void; + unbind(event: string, callback: Callback<this>): void; + trigger(event: string): void; +} + +/** + * Top level interface that declares the main abstract + * interface of an emulator structured entity. + * Should allow typical hardware operations to be performed. + */ +export interface Emulator extends ObservableI { + /** + * Obtains the descriptive name of the emulator. + * + * @returns The descriptive name of the emulator. + */ getName(): string; + + /** + * Obtains the name of the name of the hardware that + * is being emulated by the emulator (eg: Super Nintendo). + * + * @returns The name of the hardware that is being + * emulated. + */ + getDevice(): string; + + getDeviceUrl?(): string; + + /** + * Obtains a semantic version string for the current + * version of the emulator. + * + * @returns The semantic version string. + * @see {@link https://semver.org} + */ getVersion(): string; - getVersionUrl(): string; + + /** + * Obtains a URL to the page describing the current version + * of the emulator. + * + * @returns A URL to the page describing the current version + * of the emulator. + */ + getVersionUrl?(): string; + + /** + * Obtains the pixel format of the emulator's display + * image buffer (eg: RGB). + * + * @returns The pixel format used for the emulator's + * image buffer. + */ + getPixelFormat(): PixelFormat; + + /** + * Obtains the complete image buffer as a sequence of + * bytes that respects the current pixel format from + * `getPixelFormat()`. This method returns an in memory + * pointer to the heap and not a copy. + * + * @returns The byte based image buffer that respects + * the emulator's pixel format. + */ + getImageBuffer(): Uint8Array; + + /** + * Obtains information about the ROM that is currently + * loaded in the emulator. + * + * @returns Structure containing the information about + * the ROM that is currently loaded in the emulator. + */ + getRomInfo(): RomInfo; + + /** + * Returns the current logic framerate of the running + * emulator. + * + * @returns The current logic framerate of the running + * emulator. + */ + getFramerate(): number; + + getTile(index: number): Uint8Array; + + /** + * Boot (or reboots) the emulator according to the provided + * set of options. + * + * @param options The options that are going to be used for + * the booting operation of the emulator. + */ + boot(options: any): void; + + /** + * Toggle the running state of the emulator between paused + * and running, prevents consumers from the need to access + * the current running state of the emulator to implement + * a logic toggle. + */ toggleRunning(): void; pause(): void; resume(): void; + + /** + * Resets the emulator machine to the start state and + * re-loads the ROM that is currently set in the emulator. + */ reset(): void; + + /** + * Runs a benchmark operation in the emulator, effectively + * measuring the performance of it. + * + * @param count The number of benchmark iterations to be + * run, increasing this value will make the benchmark take + * more time to be executed. + * @returns The result metrics from the benchmark run. + */ + benchmark(count?: number): BenchmarkResult; +} + +/** + * Enumeration that describes the multiple pixel + * formats and the associated size in bytes. + */ +export enum PixelFormat { + RGB = 3, + RGBA = 4 } type AppProps = { @@ -35,11 +215,143 @@ type AppProps = { export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { const [paused, setPaused] = useState(false); + const [fullscreen, setFullscreen] = useState(false); const [backgroundIndex, setBackgroundIndex] = useState(0); + const [romInfo, setRomInfo] = useState<RomInfo>({}); + const [framerate, setFramerate] = useState(0); + const [keyaction, setKeyaction] = useState<string>(); + const [modalTitle, setModalTitle] = useState<string>(); + const [modalText, setModalText] = useState<string>(); + const [modalVisible, setModalVisible] = useState(false); + const [toastText, setToastText] = useState<string>(); + const [toastError, setToastError] = useState(false); + const [toastVisible, setToastVisible] = useState(false); + const [keyboardVisible, setKeyboardVisible] = useState(false); + const [infoVisible, setInfoVisible] = useState(true); + const [debugVisible, setDebugVisible] = useState(false); + + const toastCounterRef = useRef(0); + const frameRef = useRef<boolean>(false); + const errorRef = useRef<boolean>(false); + const modalCallbackRef = + useRef<(value: boolean | PromiseLike<boolean>) => void>(); + + useEffect(() => { + document.body.style.backgroundColor = `#${getBackground()}`; + }, [backgroundIndex]); + useEffect(() => { + switch (keyaction) { + case "Escape": + setFullscreen(false); + setKeyaction(undefined); + break; + case "Fullscreen": + setFullscreen(!fullscreen); + setKeyaction(undefined); + break; + } + }, [keyaction]); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setKeyaction("Escape"); + event.stopPropagation(); + event.preventDefault(); + } + if (event.key === "f" && event.ctrlKey === true) { + setKeyaction("Fullscreen"); + event.stopPropagation(); + event.preventDefault(); + } + }; + const onBooted = () => { + const romInfo = emulator.getRomInfo(); + setRomInfo(romInfo); + setPaused(false); + }; + const onMessage = ( + emulator: Emulator, + params: Record<string, any> = {} + ) => { + showToast(params.text, params.error, params.timeout); + }; + document.addEventListener("keydown", onKeyDown); + emulator.bind("booted", onBooted); + emulator.bind("message", onMessage); + return () => { + document.removeEventListener("keydown", onKeyDown); + emulator.unbind("booted", onBooted); + emulator.unbind("message", onMessage); + }; + }, []); + const getPauseText = () => (paused ? "Resume" : "Pause"); const getPauseIcon = () => paused ? require("../res/play.svg") : require("../res/pause.svg"); const getBackground = () => backgrounds[backgroundIndex]; + + const showModal = async ( + text: string, + title = "Alert" + ): Promise<boolean> => { + setModalText(text); + setModalTitle(title); + setModalVisible(true); + const result = (await new Promise((resolve) => { + modalCallbackRef.current = resolve; + })) as boolean; + return result; + }; + const showToast = async (text: string, error = false, timeout = 3500) => { + setToastText(text); + setToastError(error); + setToastVisible(true); + toastCounterRef.current++; + const counter = toastCounterRef.current; + await new Promise((resolve) => { + setTimeout(() => { + if (counter !== toastCounterRef.current) return; + setToastVisible(false); + resolve(true); + }, timeout); + }); + }; + + const onFile = async (file: File) => { + // @todo must make this more flexible and not just + // Game Boy only (using the emulator interface) + if (!file.name.endsWith(".gb")) { + showToast( + `This is probably not a ${emulator.getDevice()} ROM file!`, + true + ); + return; + } + + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + emulator.boot({ engine: null, romName: file.name, romData: romData }); + + showToast(`Loaded ${file.name} ROM successfully!`); + }; + const onModalConfirm = () => { + if (modalCallbackRef.current) { + modalCallbackRef.current(true); + modalCallbackRef.current = undefined; + } + setModalVisible(false); + }; + const onModalCancel = () => { + if (modalCallbackRef.current) { + modalCallbackRef.current(false); + modalCallbackRef.current = undefined; + } + setModalVisible(false); + }; + const onToastCancel = () => { + setToastVisible(false); + }; const onPauseClick = () => { emulator.toggleRunning(); setPaused(!paused); @@ -47,36 +359,126 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { const onResetClick = () => { emulator.reset(); }; + const onBenchmarkClick = async () => { + const result = await showModal( + "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!", + "Confirm" + ); + if (!result) return; + const { delta, count, frequency_mhz } = emulator.benchmark(); + await showToast( + `Took ${delta.toFixed( + 2 + )} seconds to run ${count} ticks (${frequency_mhz.toFixed( + 2 + )} Mhz)!`, + undefined, + 7500 + ); + }; + const onFullscreenClick = () => { + setFullscreen(!fullscreen); + }; + const onKeyboardClick = () => { + setKeyboardVisible(!keyboardVisible); + }; + const onInformationClick = () => { + setInfoVisible(!infoVisible); + }; + const onDebugClick = () => { + setDebugVisible(!debugVisible); + }; const onThemeClick = () => { setBackgroundIndex((backgroundIndex + 1) % backgrounds.length); }; - useEffect(() => { - document.body.style.backgroundColor = `#${getBackground()}`; - }); + const onUploadFile = async (file: File) => { + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + emulator.boot({ engine: null, romName: file.name, romData: romData }); + showToast(`Loaded ${file.name} ROM successfully!`); + }; + const onEngineChange = (engine: string) => { + emulator.boot({ engine: engine.toLowerCase() }); + showToast( + `${emulator.getDevice()} running in engine "${engine}" from now on!` + ); + }; + const onMinimize = () => { + setFullscreen(!fullscreen); + }; + const onDrawHandler = (handler: DrawHandler) => { + if (frameRef.current) return; + frameRef.current = true; + emulator.bind("frame", () => { + handler(emulator.getImageBuffer(), PixelFormat.RGB); + setFramerate(emulator.getFramerate()); + }); + }; + const onClearHandler = (handler: ClearHandler) => { + if (errorRef.current) return; + errorRef.current = true; + emulator.bind("error", async () => { + await handler(undefined, require("../res/storm.png"), 0.2); + }); + }; + return ( <div className="app"> + <Overlay text={"Drag to load ROM"} onFile={onFile} /> + <Modal + title={modalTitle} + text={modalText} + visible={modalVisible} + onConfirm={onModalConfirm} + onCancel={onModalCancel} + /> + <Toast + text={toastText} + error={toastError} + visible={toastVisible} + onCancel={onToastCancel} + /> <Footer color={getBackground()}> Built with â¤ï¸ by{" "} <Link href="https://joao.me" target="_blank"> João Magalhães </Link> </Footer> - <PanelSplit left={<div>This is the left panel</div>}> + <PanelSplit + left={ + <div className="display-container"> + <Display + fullscreen={fullscreen} + onDrawHandler={onDrawHandler} + onClearHandler={onClearHandler} + onMinimize={onMinimize} + /> + </div> + } + > <Title text={emulator.getName()} version={emulator.getVersion()} - versionUrl={emulator.getVersionUrl()} + versionUrl={ + emulator.getVersionUrl + ? emulator.getVersionUrl() + : undefined + } 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.getDeviceUrl ? ( + <Link + href={emulator.getDeviceUrl()} + target="_blank" + > + {emulator.getDevice()} + </Link> + ) : ( + emulator.getDevice() + )}{" "} emulator built using the{" "} <Link href="https://www.rust-lang.org" target="_blank"> Rust Programming Language @@ -102,55 +504,149 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { ROM. </Paragraph> </Section> + {keyboardVisible && ( + <Section> + <KeyboardGB /> + </Section> + )} + {debugVisible && ( + <Section> + <h3>VRAM Tiles</h3> + <Tiles + getTile={(index) => emulator.getTile(index)} + tileCount={384} + /> + </Section> + )} + {infoVisible && ( + <Section> + <Info> + <Pair + key="button-engine" + name={"Engine"} + valueNode={ + <ButtonSwitch + options={["NEO", "CLASSIC"]} + size={"large"} + style={["simple"]} + onChange={onEngineChange} + /> + } + /> + <Pair + key="rom" + name={"ROM"} + value={romInfo.name ?? "-"} + /> + <Pair + key="rom-size" + name={"ROM Size"} + value={ + romInfo.name ? `${romInfo.size} bytes` : "-" + } + /> + <Pair + key="button-frequency" + name={"CPU Frequency"} + valueNode={ + <ButtonIncrement + value={4.19} + delta={0.1} + min={0} + suffix={"MHz"} + decimalPlaces={2} + /> + } + /> + <Pair + key="rom-type" + name={"ROM Type"} + value={ + romInfo.extra?.romType + ? `${romInfo.extra?.romType}` + : "-" + } + /> + <Pair + key="framerate" + name={"Framerate"} + value={`${framerate} fps`} + /> + </Info> + </Section> + )} <Section> <ButtonContainer> <Button text={getPauseText()} image={getPauseIcon()} imageAlt="pause" + enabled={paused} + style={["simple", "border", "padded"]} onClick={onPauseClick} /> <Button text={"Reset"} image={require("../res/reset.svg")} imageAlt="reset" + style={["simple", "border", "padded"]} onClick={onResetClick} /> + <Button + text={"Benchmark"} + image={require("../res/bolt.svg")} + imageAlt="benchmark" + style={["simple", "border", "padded"]} + onClick={onBenchmarkClick} + /> + <Button + text={"Fullscreen"} + image={require("../res/maximise.svg")} + imageAlt="maximise" + style={["simple", "border", "padded"]} + onClick={onFullscreenClick} + /> + <Button + text={"Keyboard"} + image={require("../res/dialpad.svg")} + imageAlt="keyboard" + enabled={keyboardVisible} + style={["simple", "border", "padded"]} + onClick={onKeyboardClick} + /> + <Button + text={"Information"} + image={require("../res/info.svg")} + imageAlt="information" + enabled={infoVisible} + style={["simple", "border", "padded"]} + onClick={onInformationClick} + /> + <Button + text={"Debug"} + image={require("../res/bug.svg")} + imageAlt="debug" + enabled={debugVisible} + style={["simple", "border", "padded"]} + onClick={onDebugClick} + /> <Button text={"Theme"} image={require("../res/marker.svg")} - imageAlt="marker" + imageAlt="theme" + style={["simple", "border", "padded"]} onClick={onThemeClick} /> - </ButtonContainer> - <Info> - <Pair key="tobias" name={"Tobias"} value={"Matias"} /> - <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)} - /> - } + <Button + text={"Load ROM"} + image={require("../res/upload.svg")} + imageAlt="upload" + file={true} + accept={".gb"} + style={["simple", "border", "padded"]} + onFile={onUploadFile} /> - </Info> + </ButtonContainer> </Section> </PanelSplit> </div> diff --git a/examples/web/react/components/button-container/button-container.css b/examples/web/react/components/button-container/button-container.css index eb4dc7f3a15f29db9fcb8a9d7ab5618353281f65..1d1df8f7b94bc330238cfcb7dbaa2ecea756599c 100644 --- a/examples/web/react/components/button-container/button-container.css +++ b/examples/web/react/components/button-container/button-container.css @@ -1,4 +1,9 @@ +.button-container { + margin-bottom: -12px; +} + .button-container > * { + margin-bottom: 12px; 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 index a4395b771baa97b2319bc081616abf6307b7845d..62a9071ad02633bc663095c388215871d660fc24 100644 --- a/examples/web/react/components/button-increment/button-increment.tsx +++ b/examples/web/react/components/button-increment/button-increment.tsx @@ -10,6 +10,7 @@ type ButtonIncrementProps = { max?: number; prefix?: string; suffix?: string; + decimalPlaces?: number; size?: string; style?: string[]; onClick?: () => void; @@ -24,6 +25,7 @@ export const ButtonIncrement: FC<ButtonIncrementProps> = ({ max, prefix, suffix, + decimalPlaces, size = "medium", style = ["simple", "border"], onClick, @@ -32,9 +34,6 @@ export const ButtonIncrement: FC<ButtonIncrementProps> = ({ }) => { 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) { @@ -54,7 +53,7 @@ export const ButtonIncrement: FC<ButtonIncrementProps> = ({ if (onChange) onChange(valueNew); }; return ( - <span className={classes()} onClick={_onClick}> + <span className={classes()} onClick={onClick}> <Button text={"-"} size={size} @@ -62,7 +61,9 @@ export const ButtonIncrement: FC<ButtonIncrementProps> = ({ onClick={_onMinusClick} /> {prefix && <span className="prefix">{prefix}</span>} - <span className="value">{valueState}</span> + <span className="value"> + {decimalPlaces ? valueState.toFixed(decimalPlaces) : valueState} + </span> {suffix && <span className="suffix">{suffix}</span>} <Button text={"+"} diff --git a/examples/web/react/components/button/button.css b/examples/web/react/components/button/button.css index 67b45263526fd7fb20cc9ac9d4cf02ae6628441c..b83e604f23ecda9ca9251c03ce2da6ca55b1c1de 100644 --- a/examples/web/react/components/button/button.css +++ b/examples/web/react/components/button/button.css @@ -30,7 +30,7 @@ } .button.simple.padded { - padding: 4px 10px 4px 10px; + padding: 0px 10px 0px 10px; } .button.simple.padded-large { @@ -71,15 +71,15 @@ width: 13px; } -.button.simple > img.medium { +.button.simple.medium > img { width: 20px; } -.button.simple > img.large { +.button.simple.large > img { width: 28px; } -.button.simple > img.very-large { +.button.simple.very-large > img { width: 38px; } diff --git a/examples/web/react/components/button/button.tsx b/examples/web/react/components/button/button.tsx index 7858e7ee1cb1a8d6a50c89201d6921cb7f38b947..8e26fb5d425d5113dfab94027feea8d205495d62 100644 --- a/examples/web/react/components/button/button.tsx +++ b/examples/web/react/components/button/button.tsx @@ -1,4 +1,4 @@ -import React, { FC } from "react"; +import React, { ChangeEvent, FC } from "react"; import "./button.css"; @@ -6,33 +6,58 @@ type ButtonProps = { text: string; image?: string; imageAlt?: string; + enabled?: boolean; + file?: boolean; + accept?: string; size?: string; style?: string[]; onClick?: () => void; + onFile?: (file: File) => void; }; export const Button: FC<ButtonProps> = ({ text, image, imageAlt, + enabled = false, + file = false, + accept = ".txt", size = "small", style = ["simple", "border"], - onClick + onClick, + onFile }) => { - const classes = () => ["button", size, ...style].join(" "); - const _onClick = () => (onClick ? onClick() : undefined); - const buttonSimple = () => ( - <span className={classes()} onClick={_onClick}> + const classes = () => + [ + "button", + size, + enabled ? "enabled" : "", + file ? "file" : "", + ...style + ].join(" "); + const onFileChange = (event: ChangeEvent<HTMLInputElement>) => { + if (!event.target.files || event.target.files.length === 0) { + return; + } + const file = event.target.files[0]; + onFile && onFile(file); + event.target.value = ""; + }; + const renderSimple = () => ( + <span className={classes()} onClick={onClick}> {text} </span> ); - const buttonImage = () => ( - <span className={classes()} onClick={_onClick}> - <img src={image} alt={imageAlt} /> + const renderComplex = () => ( + <span className={classes()} onClick={onClick}> + {image && <img src={image} alt={imageAlt || text || "button"} />} + {file && ( + <input type="file" accept={accept} onChange={onFileChange} /> + )} <span>{text}</span> </span> ); - return image ? buttonImage() : buttonSimple(); + return image ? renderComplex() : renderSimple(); }; export default Button; diff --git a/examples/web/react/components/canvas/canvas.css b/examples/web/react/components/canvas/canvas.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/examples/web/react/components/canvas/canvas.tsx b/examples/web/react/components/canvas/canvas.tsx new file mode 100644 index 0000000000000000000000000000000000000000..24f7544a1c8e7f5de294f02b474cda82f3dda36e --- /dev/null +++ b/examples/web/react/components/canvas/canvas.tsx @@ -0,0 +1,72 @@ +import React, { FC, useEffect, useRef } from "react"; + +import "./canvas.css"; + +export type CanvasStructure = { + canvas: HTMLCanvasElement; + canvasContext: CanvasRenderingContext2D; + canvasImage: ImageData; + canvasBuffer: DataView; +}; + +type CanvasProps = { + width: number; + height: number; + scale?: number; + style?: string[]; + onCanvas?: (structure: CanvasStructure) => void; +}; + +export const Canvas: FC<CanvasProps> = ({ + width, + height, + scale = 1, + style = [], + onCanvas +}) => { + const classes = () => ["canvas", ...style].join(" "); + const canvasRef = useRef<HTMLCanvasElement>(null); + useEffect(() => { + if (canvasRef.current) { + const structure = initCanvas( + width, + height, + scale, + canvasRef.current + ); + onCanvas && onCanvas(structure); + } + }, [canvasRef]); + return ( + <canvas + ref={canvasRef} + className={classes()} + style={{ width: width * scale }} + width={width} + height={height} + /> + ); +}; + +const initCanvas = ( + width: number, + height: number, + scale: number, + canvas: HTMLCanvasElement, + smoothing = false +): CanvasStructure => { + const canvasContext = canvas.getContext("2d")!; + canvasContext.imageSmoothingEnabled = smoothing; + + const canvasImage = canvasContext.createImageData(width, height); + const canvasBuffer = new DataView(canvasImage.data.buffer); + + return { + canvas: canvas, + canvasContext: canvasContext, + canvasImage: canvasImage, + canvasBuffer: canvasBuffer + }; +}; + +export default Canvas; diff --git a/examples/web/react/components/display/display.css b/examples/web/react/components/display/display.css new file mode 100644 index 0000000000000000000000000000000000000000..0781b767f944e7c3e73c50e0533e63bd6cd9f49c --- /dev/null +++ b/examples/web/react/components/display/display.css @@ -0,0 +1,104 @@ +.display { + max-width: 100%; +} + +.display.fullscreen { + align-items: center; + background-color: #2d2d2d; + display: flex; + height: 100%; + justify-content: center; + left: 0px; + position: fixed; + top: 0px; + width: 100%; + z-index: 6; +} + +.display > .magnify-button { + cursor: pointer; + display: inline-block; + transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.display > .magnify-button:hover { + transform: scale(1.3, 1.3); + -o-transform: scale(1.3, 1.3); + -ms-transform: scale(1.3, 1.3); + -moz-transform: scale(1.3, 1.3); + -khtml-transform: scale(1.3, 1.3); + -webkit-transform: scale(1.3, 1.3); +} + +.display > .magnify-button:active { + transform: scale(1.0, 1.0); + -o-transform: scale(1.0, 1.0); + -ms-transform: scale(1.0, 1.0); + -moz-transform: scale(1.0, 1.0); + -khtml-transform: scale(1.0, 1.0); + -webkit-transform: scale(1.0, 1.0); +} + +.display > .display-minimize { + bottom: 22px; + display: none; + position: absolute; + right: 22px; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.display > .display-minimize > img { + height: 32px; + width: 32px; +} + +.display.fullscreen > .display-minimize { + display: block; +} + +.display > .display-frame { + background-color: #1b1a17; + border: 2px solid #50cb93; + box-sizing: content-box; + -o-box-sizing: content-box; + -ms-box-sizing: content-box; + -moz-box-sizing: content-box; + -khtml-box-sizing: content-box; + -webkit-box-sizing: content-box; + font-size: 0px; + padding: 8px 8px 8px 8px; + transition: width 0.35s cubic-bezier(0.075, 0.82, 0.165, 1), height 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: width 0.35s cubic-bezier(0.075, 0.82, 0.165, 1), height 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: width 0.35s cubic-bezier(0.075, 0.82, 0.165, 1), height 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: width 0.35s cubic-bezier(0.075, 0.82, 0.165, 1), height 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: width 0.35s cubic-bezier(0.075, 0.82, 0.165, 1), height 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: width 0.35s cubic-bezier(0.075, 0.82, 0.165, 1), height 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.display.fullscreen > .display-frame { + background-color: transparent; + border: none; + box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -o-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -ms-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -moz-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -khtml-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -webkit-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + margin: 0px 0px 0px 0px; + max-width: unset; + padding: 0px 0px 0px 0px; +} + +.display > .display-frame > .display-canvas { + width: 100%; +} diff --git a/examples/web/react/components/display/display.tsx b/examples/web/react/components/display/display.tsx new file mode 100644 index 0000000000000000000000000000000000000000..821bb268bcf0c1cef87ebc21cc0d09f336d41e81 --- /dev/null +++ b/examples/web/react/components/display/display.tsx @@ -0,0 +1,280 @@ +import React, { FC, useState, useRef, useEffect } from "react"; +import { PixelFormat } from "../../app"; + +import "./display.css"; + +const PIXEL_UNSET_COLOR = 0x1b1a17ff; + +declare const require: any; + +/** + * Function that handles a draw operation into a + * certain drawing context. + */ +export type DrawHandler = (pixels: Uint8Array, format: PixelFormat) => void; + +export type ClearHandler = ( + color?: number, + image?: string, + imageScale?: number +) => Promise<void>; + +type DisplayOptions = { + width: number; + height: number; + logicWidth: number; + logicHeight: number; + scale?: number; +}; + +type DisplayProps = { + options?: DisplayOptions; + size?: string; + fullscreen?: boolean; + style?: string[]; + onDrawHandler?: (caller: DrawHandler) => void; + onClearHandler?: (caller: ClearHandler) => void; + onMinimize?: () => void; +}; + +type CanvasContents = { + canvas: HTMLCanvasElement; + canvasCtx: CanvasRenderingContext2D; + canvasBuffer: HTMLCanvasElement; + canvasBufferCtx: CanvasRenderingContext2D; + imageData: ImageData; + videoBuffer: DataView; +}; + +export const Display: FC<DisplayProps> = ({ + options = { width: 320, height: 288, logicWidth: 160, logicHeight: 144 }, + size = "small", + fullscreen = false, + style = [], + onDrawHandler, + onClearHandler, + onMinimize +}) => { + options = { + ...options, + ...{ width: 320, height: 288, logicWidth: 160, logicHeight: 144 } + }; + if (!options.scale) { + options.scale = window.devicePixelRatio ? window.devicePixelRatio : 1; + } + + const classes = () => + ["display", fullscreen ? "fullscreen" : null, size, ...style].join(" "); + + const [width, setWidth] = useState<number>(); + const [height, setHeight] = useState<number>(); + const canvasRef = useRef<HTMLCanvasElement>(null); + const canvasContentsRef = useRef<CanvasContents>(); + const resizeRef = useRef(() => { + const [fullWidth, fullHeight] = crop(options.width / options.height); + setWidth(fullWidth); + setHeight(fullHeight); + }); + + useEffect(() => { + if (canvasRef.current) { + canvasContentsRef.current = initCanvas( + options.logicWidth, + options.logicHeight, + canvasRef.current + ); + } + }, [canvasRef, options.scale]); + + useEffect(() => { + if (fullscreen) { + resizeRef.current(); + document.getElementsByTagName("body")[0].style.overflow = "hidden"; + window.addEventListener("resize", resizeRef.current); + } else { + setWidth(undefined); + setHeight(undefined); + document + .getElementsByTagName("body")[0] + .style.removeProperty("overflow"); + window.removeEventListener("resize", resizeRef.current); + } + return () => { + window.removeEventListener("resize", resizeRef.current); + }; + }, [fullscreen]); + + if (onDrawHandler) { + onDrawHandler((pixels, format) => { + if (!canvasContentsRef.current) return; + updateCanvas(canvasContentsRef.current, pixels, format); + }); + } + + if (onClearHandler) { + onClearHandler(async (color, image, imageScale) => { + if (!canvasContentsRef.current) return; + await clearCanvas(canvasContentsRef.current, color, { + image: image, + imageScale: imageScale + }); + }); + } + + return ( + <div className={classes()}> + <span + className="magnify-button display-minimize" + onClick={onMinimize} + > + <img + className="large" + src={require("./minimise.svg")} + alt="minimise" + /> + </span> + <div + className="display-frame" + style={{ width: width ?? options.width, height: height }} + > + <canvas + ref={canvasRef} + className="display-canvas" + width={options.width * options.scale} + height={options.height * options.scale} + ></canvas> + </div> + </div> + ); +}; + +const initCanvas = ( + width: number, + height: number, + canvas: HTMLCanvasElement +): CanvasContents => { + // initializes the off-screen canvas that is going to be + // used in the drawing process, this is used essentially for + // performance reasons as it provides a way to draw pixels + // in the original size instead of the target one + const canvasBuffer = document.createElement("canvas"); + canvasBuffer.width = width; + canvasBuffer.height = height; + const canvasBufferCtx = canvasBuffer.getContext("2d")!; + const imageData = canvasBufferCtx.createImageData( + canvasBuffer.width, + canvasBuffer.height + ); + const videoBuffer = new DataView(imageData.data.buffer); + + // initializes the visual canvas (where data is going to be written) + // with, resetting the transform vector to the identity and re-calculating + // the scale of the drawing properly + const canvasCtx = canvas.getContext("2d")!; + canvasCtx.setTransform(1, 0, 0, 1, 0, 0); + canvasCtx.scale( + canvas.width / canvasBuffer.width, + canvas.height / canvasBuffer.height + ); + canvasCtx.imageSmoothingEnabled = false; + + return { + canvas: canvas, + canvasCtx: canvasCtx, + canvasBuffer: canvasBuffer, + canvasBufferCtx: canvasBufferCtx, + imageData: imageData, + videoBuffer: videoBuffer + }; +}; + +const updateCanvas = ( + canvasContents: CanvasContents, + 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); + canvasContents.videoBuffer.setUint32(offset, color); + offset += PixelFormat.RGBA; + } + canvasContents.canvasBufferCtx.putImageData(canvasContents.imageData, 0, 0); + canvasContents.canvasCtx.drawImage(canvasContents.canvasBuffer, 0, 0); +}; + +const clearCanvas = async ( + canvasContents: CanvasContents, + color = PIXEL_UNSET_COLOR, + { + image = null, + imageScale = 1 + }: { image?: string | null; imageScale?: number } = {} +) => { + // uses the "clear" color to fill a rectangle with the complete + // size of the canvas contents + canvasContents.canvasCtx.fillStyle = `#${color.toString(16).toUpperCase()}`; + canvasContents.canvasCtx.fillRect( + 0, + 0, + canvasContents.canvas.width, + canvasContents.canvas.height + ); + + // in case an image was requested then uses that to load + // an image at the center of the screen properly scaled + if (image) { + await drawImageCanvas(canvasContents, image, imageScale); + } +}; + +const drawImageCanvas = async ( + canvasContents: CanvasContents, + image: string, + imageScale = 1.0 +) => { + 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] = [ + canvasContents.canvas.width / 2 - imgWidth / 2, + canvasContents.canvas.height / 2 - imgHeight / 2 + ]; + canvasContents.canvasCtx.setTransform(1, 0, 0, 1, 0, 0); + try { + canvasContents.canvasCtx.drawImage(img, x0, y0, imgWidth, imgHeight); + } finally { + canvasContents.canvasCtx.scale( + canvasContents.canvas.width / canvasContents.canvasBuffer.width, + canvasContents.canvas.height / canvasContents.canvasBuffer.height + ); + } +}; + +const crop = (ratio: number): [number, number] => { + // 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 > ratio) { + return [window.innerWidth * (ratio / windowRatio), window.innerHeight]; + } else { + return [window.innerWidth, window.innerHeight * (windowRatio / ratio)]; + } +}; + +export default Display; diff --git a/examples/web/react/components/display/minimise.svg b/examples/web/react/components/display/minimise.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d41ee133bf7a84a220f8649695c5fdc0a9fdb31 --- /dev/null +++ b/examples/web/react/components/display/minimise.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="minimiseIconTitle" stroke="#ffffff" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="minimiseIconTitle">Minimise View</title> <polyline points="8 3 8 8 3 8"/> <polyline points="21 8 16 8 16 3"/> <polyline points="3 16 8 16 8 21"/> <polyline points="16 21 16 16 21 16"/> </svg> \ No newline at end of file diff --git a/examples/web/react/components/index.ts b/examples/web/react/components/index.ts index 9f2a21f34b3fc6e70aabd5dcab19b492d09a0b7d..838636b94e5c09949d1234242e97bcdccf67ca09 100644 --- a/examples/web/react/components/index.ts +++ b/examples/web/react/components/index.ts @@ -2,11 +2,19 @@ export * from "./button/button"; export * from "./button-container/button-container"; export * from "./button-increment/button-increment"; export * from "./button-switch/button-switch"; +export * from "./canvas/canvas"; +export * from "./display/display"; export * from "./footer/footer"; export * from "./info/info"; +export * from "./keyboard-chip8/keyboard-chip8"; +export * from "./keyboard-gb/keyboard-gb"; export * from "./link/link"; +export * from "./modal/modal"; +export * from "./overlay/overlay"; export * from "./pair/pair"; export * from "./panel-split/panel-split"; export * from "./paragraph/paragraph"; export * from "./section/section"; +export * from "./tiles/tiles"; export * from "./title/title"; +export * from "./toast/toast"; diff --git a/examples/web/react/components/keyboard-chip8/keyboard-chip8.css b/examples/web/react/components/keyboard-chip8/keyboard-chip8.css new file mode 100644 index 0000000000000000000000000000000000000000..01f35fef038dc2cc39305784fdfde7dca61efb1d --- /dev/null +++ b/examples/web/react/components/keyboard-chip8/keyboard-chip8.css @@ -0,0 +1,55 @@ + +.keyboard-chip8 { + font-size: 0px; + text-align: center; + touch-callout: none; + -o-touch-callout: none; + -ms-touch-callout: none; + -moz-touch-callout: none; + -khtml-touch-callout: none; + -webkit-touch-callout: none; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.keyboard-chip8 > .keyboard-line { + margin-bottom: 12px; +} + +.keyboard-chip8 > .keyboard-line:last-child { + margin-bottom: 0px; +} + +.keyboard-chip8 .key { + border: 2px solid #ffffff; + border-radius: 5px 5px 5px 5px; + -o-border-radius: 5px 5px 5px 5px; + -ms-border-radius: 5px 5px 5px 5px; + -moz-border-radius: 5px 5px 5px 5px; + -khtml-border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + cursor: pointer; + display: inline-block; + font-size: 38px; + height: 48px; + line-height: 46px; + margin-right: 14px; + text-align: center; + width: 48px; +} + +.keyboard-chip8 .key:last-child { + margin-right: 0px; +} + +.keyboard-chip8 .key:hover { + background-color: #50cb93; +} + +.keyboard-chip8 .key:active { + background-color: #2a9d8f; +} diff --git a/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx b/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx new file mode 100644 index 0000000000000000000000000000000000000000..897cc353ee2d3b9bb20ce935a40c507a4ab68a84 --- /dev/null +++ b/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx @@ -0,0 +1,44 @@ +import React, { FC } from "react"; + +import "./keyboard-chip8.css"; + +type KeyboardChip8Props = { + style?: string[]; + onKeyDown?: (key: string) => void; +}; + +export const KeyboardChip8: FC<KeyboardChip8Props> = ({ + style = [], + onKeyDown +}) => { + const classes = () => ["keyboard", "keyboard-chip8", ...style].join(" "); + const renderKey = (key: string) => { + return ( + <span + className="key" + key={key} + onKeyDown={() => onKeyDown && onKeyDown(key)} + > + {key} + </span> + ); + }; + return ( + <div className={classes()}> + <div className="keyboard-line"> + {["1", "2", "3", "4"].map((k) => renderKey(k))} + </div> + <div className="keyboard-line"> + {["Q", "W", "E", "R"].map((k) => renderKey(k))} + </div> + <div className="keyboard-line"> + {["A", "S", "D", "F"].map((k) => renderKey(k))} + </div> + <div className="keyboard-line"> + {["Z", "X", "C", "V"].map((k) => renderKey(k))} + </div> + </div> + ); +}; + +export default KeyboardChip8; diff --git a/examples/web/react/components/keyboard-gb/dpad.svg b/examples/web/react/components/keyboard-gb/dpad.svg new file mode 100644 index 0000000000000000000000000000000000000000..8115b91717cc9d018b30805c4836162b78d9670d --- /dev/null +++ b/examples/web/react/components/keyboard-gb/dpad.svg @@ -0,0 +1 @@ +<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" fill="#ffffff" stroke="#ffffff" xmlns="http://www.w3.org/2000/svg"><path d="m338.45 240.62c3.1953 2.8047 7.3008 4.3516 11.551 4.3516s8.3555-1.5469 11.551-4.3516l78.75-70c3.7773-3.3164 5.9414-8.0977 5.9492-13.125v-105c0-4.6406-1.8438-9.0938-5.125-12.375s-7.7344-5.125-12.375-5.125h-157.5c-4.6406 0-9.0938 1.8438-12.375 5.125s-5.125 7.7344-5.125 12.375v105c0.007812 5.0273 2.1719 9.8086 5.9492 13.125zm-49.699-170.62h122.5v79.625l-61.25 54.426-61.25-54.426zm72.801 249.38c-3.1953-2.8047-7.3008-4.3516-11.551-4.3516s-8.3555 1.5469-11.551 4.3516l-78.75 70c-3.7773 3.3164-5.9414 8.0977-5.9492 13.125v105c0 4.6406 1.8438 9.0938 5.125 12.375s7.7344 5.125 12.375 5.125h157.5c4.6406 0 9.0938-1.8438 12.375-5.125s5.125-7.7344 5.125-12.375v-105c-0.007812-5.0273-2.1719-9.8086-5.9492-13.125zm49.699 170.62h-122.5v-79.625l61.25-54.426 61.25 54.426zm-100.62-221.55-70-78.75c-3.3164-3.7773-8.0977-5.9414-13.125-5.9492h-105c-4.6406 0-9.0938 1.8438-12.375 5.125s-5.125 7.7344-5.125 12.375v157.5c0 4.6406 1.8438 9.0938 5.125 12.375s7.7344 5.125 12.375 5.125h105c5.0273-0.007812 9.8086-2.1719 13.125-5.9492l70-78.75c2.8047-3.1953 4.3516-7.3008 4.3516-11.551s-1.5469-8.3555-4.3516-11.551zm-91 72.801h-79.625v-122.5h79.625l54.426 61.25zm357.88-157.5h-105c-5.0273 0.007812-9.8086 2.1719-13.125 5.9492l-70 78.75c-2.8047 3.1953-4.3516 7.3008-4.3516 11.551s1.5469 8.3555 4.3516 11.551l70 78.75c3.3164 3.7773 8.0977 5.9414 13.125 5.9492h105c4.6406 0 9.0938-1.8438 12.375-5.125s5.125-7.7344 5.125-12.375v-157.5c0-4.6406-1.8438-9.0938-5.125-12.375s-7.7344-5.125-12.375-5.125zm-17.5 157.5h-79.625l-54.426-61.25 54.426-61.25h79.625z"/></svg> diff --git a/examples/web/react/components/keyboard-gb/keyboard-gb.css b/examples/web/react/components/keyboard-gb/keyboard-gb.css new file mode 100644 index 0000000000000000000000000000000000000000..56f4b8dacbe21740773829853af511b26e7eb671 --- /dev/null +++ b/examples/web/react/components/keyboard-gb/keyboard-gb.css @@ -0,0 +1,59 @@ + +.keyboard-gb { + font-size: 0px; + text-align: center; + touch-callout: none; + -o-touch-callout: none; + -ms-touch-callout: none; + -moz-touch-callout: none; + -khtml-touch-callout: none; + -webkit-touch-callout: none; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.keyboard-gb > .keyboard-line { + margin-bottom: 12px; +} + +.keyboard-gb > .keyboard-line:last-child { + margin-bottom: 0px; +} + +.keyboard-gb .key { + border: 2px solid #ffffff; + border-radius: 5px 5px 5px 5px; + -o-border-radius: 5px 5px 5px 5px; + -ms-border-radius: 5px 5px 5px 5px; + -moz-border-radius: 5px 5px 5px 5px; + -khtml-border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + cursor: pointer; + display: inline-block; + font-size: 38px; + height: 48px; + line-height: 46px; + margin-right: 14px; + text-align: center; + width: 48px; +} + +.keyboard-gb .key:last-child { + margin-right: 0px; +} + +.keyboard-gb .key:hover { + background-color: #50cb93; +} + +.keyboard-gb .key:active { + background-color: #2a9d8f; +} + +.keyboard-gb .dpad { + width: 120px; +} diff --git a/examples/web/react/components/keyboard-gb/keyboard-gb.tsx b/examples/web/react/components/keyboard-gb/keyboard-gb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6358673061284ede95093ec10915277fa9c0681b --- /dev/null +++ b/examples/web/react/components/keyboard-gb/keyboard-gb.tsx @@ -0,0 +1,43 @@ +import React, { FC } from "react"; + +import "./keyboard-gb.css"; + +declare const require: any; + +type KeyboardGBProps = { + style?: string[]; + onKeyDown?: (key: string) => void; +}; + +export const KeyboardGB: FC<KeyboardGBProps> = ({ style = [], onKeyDown }) => { + const classes = () => ["keyboard", "keyboard-gb", ...style].join(" "); + const renderKey = (key: string) => { + return ( + <span + className="key" + key={key} + onKeyDown={() => onKeyDown && onKeyDown(key)} + > + {key} + </span> + ); + }; + return ( + <div className={classes()}> + <div className="keyboard-line"> + <img className="dpad" alt="dpad" src={require("./dpad.svg")} /> + </div> + <div className="keyboard-line"> + {["Q", "W", "E", "R"].map((k) => renderKey(k))} + </div> + <div className="keyboard-line"> + {["A", "S", "D", "F"].map((k) => renderKey(k))} + </div> + <div className="keyboard-line"> + {["Z", "X", "C", "V"].map((k) => renderKey(k))} + </div> + </div> + ); +}; + +export default KeyboardGB; diff --git a/examples/web/react/components/modal/close.svg b/examples/web/react/components/modal/close.svg new file mode 100644 index 0000000000000000000000000000000000000000..aeac9982d889f0194b3b55905d166541704b5bac --- /dev/null +++ b/examples/web/react/components/modal/close.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="closeIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="closeIconTitle">Close</title> <path d="M6.34314575 6.34314575L17.6568542 17.6568542M6.34314575 17.6568542L17.6568542 6.34314575"/> </svg> \ No newline at end of file diff --git a/examples/web/react/components/modal/modal.css b/examples/web/react/components/modal/modal.css new file mode 100644 index 0000000000000000000000000000000000000000..bcc2c27ca6885de37f72e0dcbeed5d466fae82ba --- /dev/null +++ b/examples/web/react/components/modal/modal.css @@ -0,0 +1,132 @@ +.modal { + align-items: center; + background-color: rgba(20, 20, 20, 0.95); + display: flex; + height: 100%; + justify-content: center; + left: 0px; + opacity: 0; + -o-opacity: 0; + -ms-opacity: 0; + -moz-opacity: 0; + -khtml-opacity: 0; + -webkit-opacity: 0; + padding: 0px 12px 0px 12px; + pointer-events: none; + position: fixed; + text-align: center; + top: 0px; + transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.3s cubic-bezier(0.075, 0.82, 0.165, 1); + width: 100%; + z-index: 10; +} + +.modal.visible { + opacity: 1.0; + -o-opacity: 1.0; + -ms-opacity: 1.0; + -moz-opacity: 1.0; + -khtml-opacity: 1.0; + -webkit-opacity: 1.0; + pointer-events: initial; + transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.modal > .modal-window { + background-color: #264653; + border-radius: 6px 6px 6px 6px; + -o-border-radius: 6px 6px 6px 6px; + -ms-border-radius: 6px 6px 6px 6px; + -moz-border-radius: 6px 6px 6px 6px; + -khtml-border-radius: 6px 6px 6px 6px; + -webkit-border-radius: 6px 6px 6px 6px; + box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.5); + -o-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.5); + -ms-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.5); + -moz-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.5); + -khtml-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.5); + margin-top: 30px; + max-width: 100%; + padding: 24px 24px 24px 24px; + text-align: left; + transform: scale(0.96); + -o-transform: scale(0.96); + -ms-transform: scale(0.96); + -moz-transform: scale(0.96); + -khtml-transform: scale(0.96); + -webkit-transform: scale(0.96); + transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + width: 480px; +} + +.modal.visible > .modal-window { + margin-top: 0px; + pointer-events: all; + transform: scale(1); + -o-transform: scale(1); + -ms-transform: scale(1); + -moz-transform: scale(1); + -khtml-transform: scale(1); + -webkit-transform: scale(1); + transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), margin-top 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.modal > .modal-window > .modal-top-buttons { + float: right; + margin-right: -10px; + margin-top: -10px; +} + +.modal > .modal-window > .modal-title { + font-size: 32px; + margin-top: 0px; + text-align: left; +} + +.modal > .modal-window > .modal-text { + font-size: 20px; + line-height: 22px; +} + +.modal > .modal-window > .modal-buttons { + font-size: 22px; + margin-top: 24px; + text-align: center; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.modal > .modal-window > .modal-buttons > .button.simple { + display: inline-block; + margin-right: 12px; + min-width: 120px; +} + +.modal > .modal-window > .modal-buttons > .button.simple:last-child { + margin-right: 0px; +} diff --git a/examples/web/react/components/modal/modal.tsx b/examples/web/react/components/modal/modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c72f676188cba2db6d04e100e553d47e4b4fbbe5 --- /dev/null +++ b/examples/web/react/components/modal/modal.tsx @@ -0,0 +1,86 @@ +import React, { FC, useEffect } from "react"; +import Button from "../button/button"; + +import "./modal.css"; + +declare const require: any; + +type ModalProps = { + title?: string; + text?: string; + visible?: boolean; + overlayClose?: boolean; + style?: string[]; + onConfirm?: () => void; + onCancel?: () => void; +}; + +export const Modal: FC<ModalProps> = ({ + title = "Alert", + text = "Do you confirm the following operation?", + visible = false, + overlayClose = true, + style = [], + onConfirm, + onCancel +}) => { + const classes = () => + ["modal", visible ? "visible" : "", ...style].join(" "); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCancel && onCancel(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, []); + const getTextHtml = (separator = /\n/g) => ({ + __html: text.replace(separator, "<br/>") + }); + const onWindowClick = ( + event: React.MouseEvent<HTMLDivElement, MouseEvent> + ) => { + if (!overlayClose) return; + event.stopPropagation(); + }; + return ( + <div className={classes()} onClick={onCancel}> + <div className="modal-window" onClick={onWindowClick}> + <div className="modal-top-buttons"> + <Button + text={""} + size={"medium"} + style={["simple", "rounded", "no-text"]} + image={require("./close.svg")} + imageAlt="close" + onClick={onCancel} + /> + </div> + <h2 className="modal-title">{title}</h2> + <p + className="modal-text" + dangerouslySetInnerHTML={getTextHtml()} + ></p> + <div className="modal-buttons"> + <Button + text={"Cancel"} + size={"medium"} + style={["simple", "red", "border", "padded-large"]} + onClick={onCancel} + /> + <Button + text={"Confirm"} + size={"medium"} + style={["simple", "border", "padded-large"]} + onClick={onConfirm} + /> + </div> + </div> + </div> + ); +}; + +export default Modal; diff --git a/examples/web/react/components/overlay/overlay.css b/examples/web/react/components/overlay/overlay.css new file mode 100644 index 0000000000000000000000000000000000000000..14ad1c9bda6f278b0b3980f4c6b7ed4ad95acf9f --- /dev/null +++ b/examples/web/react/components/overlay/overlay.css @@ -0,0 +1,44 @@ +.overlay { + align-items: center; + background-color: rgba(80, 203, 147, 0.95); + display: flex; + font-size: 48px; + height: 100%; + justify-content: center; + left: 0px; + opacity: 0.0; + -o-opacity: 0.0; + -ms-opacity: 0.0; + -moz-opacity: 0.0; + -khtml-opacity: 0.0; + -webkit-opacity: 0.0; + pointer-events: none; + position: fixed; + text-align: center; + top: 0px; + transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.4s cubic-bezier(0.075, 0.82, 0.165, 1); + width: 100%; + z-index: 10; +} + +.overlay.visible { + opacity: 1.0; + -o-opacity: 1.0; + -ms-opacity: 1.0; + -moz-opacity: 1.0; + -khtml-opacity: 1.0; + -webkit-opacity: 1.0; +} + +.overlay .overlay-image { + margin-top: 16px; +} + +.overlay .overlay-image > img { + width: 64px; +} diff --git a/examples/web/react/components/overlay/overlay.tsx b/examples/web/react/components/overlay/overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1762dcf2d15d33821e2f03cc0bb123eba41f52b0 --- /dev/null +++ b/examples/web/react/components/overlay/overlay.tsx @@ -0,0 +1,69 @@ +import React, { FC, useEffect, useState } from "react"; + +import "./overlay.css"; + +declare const require: any; + +type OverlayProps = { + text?: string; + style?: string[]; + onFile?: (file: File) => void; +}; + +export const Overlay: FC<OverlayProps> = ({ text, style = [], onFile }) => { + const [visible, setVisible] = useState(false); + const classes = () => + ["overlay", visible ? "visible" : "", ...style].join(" "); + useEffect(() => { + const onDrop = async (event: DragEvent) => { + if (!event.dataTransfer!.items) return; + if (event.dataTransfer!.items[0].type) return; + + setVisible(false); + + const file = event.dataTransfer!.files[0]; + onFile && onFile(file); + + event.preventDefault(); + event.stopPropagation(); + }; + const onDragOver = async (event: DragEvent) => { + if (!event.dataTransfer!.items) return; + if (event.dataTransfer!.items[0].type) return; + setVisible(true); + event.preventDefault(); + }; + const onDragEnter = async (event: DragEvent) => { + if (!event.dataTransfer!.items) return; + if (event.dataTransfer!.items[0].type) return; + setVisible(true); + }; + const onDragLeave = async (event: DragEvent) => { + if (!event.dataTransfer!.items) return; + if (event.dataTransfer!.items[0].type) return; + setVisible(false); + }; + document.addEventListener("drop", onDrop); + document.addEventListener("dragover", onDragOver); + document.addEventListener("dragenter", onDragEnter); + document.addEventListener("dragleave", onDragLeave); + return () => { + document.removeEventListener("drop", onDrop); + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("dragenter", onDragEnter); + document.removeEventListener("dragleave", onDragLeave); + }; + }, []); + return ( + <div className={classes()}> + <div className="overlay-container"> + {text && <div className="overlay-text">{text}</div>} + <div className="overlay-image"> + <img alt="sunglasses" src={require("./sunglasses.png")} /> + </div> + </div> + </div> + ); +}; + +export default Overlay; diff --git a/examples/web/react/components/overlay/sunglasses.png b/examples/web/react/components/overlay/sunglasses.png new file mode 100644 index 0000000000000000000000000000000000000000..98b0bba649db0699a170016e18bc4b7a9ce3b70c Binary files /dev/null and b/examples/web/react/components/overlay/sunglasses.png differ diff --git a/examples/web/react/components/tiles/tiles.css b/examples/web/react/components/tiles/tiles.css new file mode 100644 index 0000000000000000000000000000000000000000..59b7d18240ad1bc4ea361b700e70c75c911782de --- /dev/null +++ b/examples/web/react/components/tiles/tiles.css @@ -0,0 +1,11 @@ +.tiles > .canvas { + background-color: #1b1a17; + border: 2px solid #50cb93; + box-sizing: content-box; + -o-box-sizing: content-box; + -ms-box-sizing: content-box; + -moz-box-sizing: content-box; + -khtml-box-sizing: content-box; + -webkit-box-sizing: content-box; + padding: 8px 8px 8px 8px; +} diff --git a/examples/web/react/components/tiles/tiles.tsx b/examples/web/react/components/tiles/tiles.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ecc57d3b5442758914256df11f0074b0740aaee --- /dev/null +++ b/examples/web/react/components/tiles/tiles.tsx @@ -0,0 +1,79 @@ +import React, { FC } from "react"; +import { PixelFormat } from "../../app"; +import Canvas, { CanvasStructure } from "../canvas/canvas"; + +import "./tiles.css"; + +type TilesProps = { + tileCount: number; + getTile: (index: number) => Uint8Array; + interval?: number; + style?: string[]; +}; + +export const Tiles: FC<TilesProps> = ({ + tileCount, + getTile, + interval = 500, + style = [] +}) => { + const classes = () => ["tiles", ...style].join(" "); + const onCanvas = (structure: CanvasStructure) => { + const drawTiles = () => { + for (let index = 0; index < 384; index++) { + const pixels = getTile(index); + drawTile(index, pixels, structure); + } + }; + drawTiles(); + setInterval(() => drawTiles(), interval); + }; + return ( + <div className={classes()}> + <Canvas width={128} height={192} scale={2} onCanvas={onCanvas} /> + </div> + ); +}; + +/** + * 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 pixels Buffer of pixels that contains the RGB data + * that is going to be drawn. + * @param structure The canvas context to which the tile is + * growing to be drawn. + * @param format The pixel format of the sprite. + */ +const drawTile = ( + index: number, + pixels: Uint8Array, + structure: CanvasStructure, + format: PixelFormat = PixelFormat.RGB +) => { + const line = Math.floor(index / 16); + const column = index % 16; + let offset = + (line * structure.canvas.width * 8 + column * 8) * PixelFormat.RGBA; + let counter = 0; + for (let i = 0; i < pixels.length; i += format) { + const color = + (pixels[i] << 24) | + (pixels[i + 1] << 16) | + (pixels[i + 2] << 8) | + (format === PixelFormat.RGBA ? pixels[i + 3] : 0xff); + structure.canvasBuffer.setUint32(offset, color); + + counter++; + if (counter === 8) { + counter = 0; + offset += (structure.canvas.width - 7) * PixelFormat.RGBA; + } else { + offset += PixelFormat.RGBA; + } + } + structure.canvasContext.putImageData(structure.canvasImage, 0, 0); +}; + +export default Tiles; diff --git a/examples/web/react/components/title/title.css b/examples/web/react/components/title/title.css index b10f9b68cd6195975c92f801645d7c0e937e8448..7989a40d87bde43cd73a7ae846e67e6a2743d4f6 100644 --- a/examples/web/react/components/title/title.css +++ b/examples/web/react/components/title/title.css @@ -1,4 +1,5 @@ -.title > .link { +.title > .link, +.title > .label { margin-left: 14px; } diff --git a/examples/web/react/components/title/title.tsx b/examples/web/react/components/title/title.tsx index c390617521d3a422055466254620d13ef61373bd..ac312ce77c2e03291d7602046d4b858d76d3067d 100644 --- a/examples/web/react/components/title/title.tsx +++ b/examples/web/react/components/title/title.tsx @@ -22,11 +22,14 @@ export const Title: FC<TitleProps> = ({ return ( <h1 className={classes()}> {text} - {version && ( - <Link href={versionUrl} target="_blank"> - {version} - </Link> - )} + {version && + (versionUrl ? ( + <Link href={versionUrl} target="_blank"> + {version} + </Link> + ) : ( + <span className="label">{version}</span> + ))} {iconSrc && <img className="icon" src={iconSrc} alt="icon" />} </h1> ); diff --git a/examples/web/react/components/toast/toast.css b/examples/web/react/components/toast/toast.css new file mode 100644 index 0000000000000000000000000000000000000000..8bf5233215e4af7f340b94e48b55107dada71e4d --- /dev/null +++ b/examples/web/react/components/toast/toast.css @@ -0,0 +1,57 @@ +.toast { + background-color: black; + height: 0px; + left: 0px; + padding: 0px 24px 0px 24px; + pointer-events: none; + position: fixed; + text-align: center; + top: 0px; + width: 100%; + z-index: 8; +} + +.toast > .toast-text { + background-color: #2a9d8f; + border-radius: 4px 4px 4px 4px; + -o-border-radius: 4px 4px 4px 4px; + -ms-border-radius: 4px 4px 4px 4px; + -moz-border-radius: 4px 4px 4px 4px; + -khtml-border-radius: 4px 4px 4px 4px; + -webkit-border-radius: 4px 4px 4px 4px; + cursor: pointer; + display: inline-block; + font-size: 20px; + line-height: 22px; + opacity: 0.0; + -o-opacity: 0.0; + -ms-opacity: 0.0; + -moz-opacity: 0.0; + -khtml-opacity: 0.0; + -webkit-opacity: 0.0; + padding: 12px 18px 12px 18px; + position: relative; + top: -46px; + transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + width: fit-content; +} + +.toast.error > .toast-text { + background-color: #e63946; +} + +.toast.visible > .toast-text { + opacity: 1.0; + -o-opacity: 1.0; + -ms-opacity: 1.0; + -moz-opacity: 1.0; + -khtml-opacity: 1.0; + -webkit-opacity: 1.0; + pointer-events: all; + top: 24px; +} diff --git a/examples/web/react/components/toast/toast.tsx b/examples/web/react/components/toast/toast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c1f3831e86e5406420493c8d5a9afdc1b2f51a1 --- /dev/null +++ b/examples/web/react/components/toast/toast.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; + +import "./toast.css"; + +type ToastProps = { + text?: string; + error?: boolean; + visible?: boolean; + style?: string[]; + onCancel?: () => void; +}; + +export const Toast: FC<ToastProps> = ({ + text = "", + error = false, + visible = false, + style = [], + onCancel +}) => { + const classes = () => + [ + "toast", + error ? "error" : "", + visible ? "visible" : "", + ...style + ].join(" "); + return ( + <div className={classes()}> + <div className="toast-text" onClick={onCancel}> + {text} + </div> + </div> + ); +}; + +export default Toast; diff --git a/examples/web/static/_headers b/examples/web/static/_headers new file mode 100644 index 0000000000000000000000000000000000000000..7d54b8fcaf282650627bbf184ad85c9c36633d01 --- /dev/null +++ b/examples/web/static/_headers @@ -0,0 +1,3 @@ + +/* + X-Robots-Tag: all diff --git a/examples/web/static/robots.txt b/examples/web/static/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..a78466b3d66c7386c0ccc2293a67bccf4b6c85ff --- /dev/null +++ b/examples/web/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/examples/web/tsconfig.json b/examples/web/tsconfig.json index 8f83dd8e31c5ab0b458c75bafaedad1b74ca9698..d38b2b510692a48dc85856135ce5d04fd93c0faa 100644 --- a/examples/web/tsconfig.json +++ b/examples/web/tsconfig.json @@ -7,6 +7,7 @@ "target": "es6", "noImplicitAny": true, "noImplicitThis": true, + "noUnusedLocals": true, "alwaysStrict": true, "strictBindCallApply": true, "strictNullChecks": true, diff --git a/src/cpu.rs b/src/cpu.rs index b6b0a32fa314d83aa03b78afa852973366b13f52..e253dba68cb622d24f5743b7e8646bdd63589b4e 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -112,7 +112,7 @@ impl Cpu { } // @todo this is so bad, need to improve this by an order - // of magnitude + // of magnitude, to be able to have better performance if self.halted { if ((self.mmu.ie & 0x01 == 0x01) && self.mmu.ppu().int_vblank()) || ((self.mmu.ie & 0x02 == 0x02) && self.mmu.ppu().int_stat()) @@ -124,7 +124,8 @@ impl Cpu { } if self.ime { - // @todo aggregate all of this interrupts in the MMU + // @todo aggregate all of this interrupts in the MMU, as there's + // a lot of redundant code involved in here if (self.mmu.ie & 0x01 == 0x01) && self.mmu.ppu().int_vblank() { debugln!("Going to run V-Blank interrupt handler (0x40)"); diff --git a/src/gb.rs b/src/gb.rs index 2750414133440ad4878dbccefafdda3a04301b6b..3a72d3ff9efea0b3c76c963e58912eb729fffde7 100644 --- a/src/gb.rs +++ b/src/gb.rs @@ -113,10 +113,16 @@ impl GameBoy { self.frame_buffer().to_vec() } + /// Obtains the tile structure for the tile at the + /// given index, no conversion in the pixel buffer + /// is done so that the color reference is the GB one. pub fn get_tile(&mut self, index: usize) -> Tile { self.ppu().tiles()[index] } + /// Obtains the pixel buffer for the tile at the + /// provided index, converting the color buffer + /// using the currently loaded palette. pub fn get_tile_buffer(&mut self, index: usize) -> Vec<u8> { let tile = self.get_tile(index); tile.palette_buffer(self.ppu().palette()) diff --git a/src/inst.rs b/src/inst.rs index a7692a4bb92b6068ca712cc9bd64f9779af1783d..497523a1efe4a4222dbad1af862a21d3cf02025a 100644 --- a/src/inst.rs +++ b/src/inst.rs @@ -3129,6 +3129,7 @@ fn set_7_mhl(cpu: &mut Cpu) { fn set_7_a(cpu: &mut Cpu) { cpu.a = set(cpu.a, 7); } + /// Helper function to set one bit in a u8. fn set(value: u8, bit: u8) -> u8 { value | (1u8 << (bit as usize))