diff --git a/examples/sdl/src/main.rs b/examples/sdl/src/main.rs index 73969689f8a5d8e9089bf14fd57cdf4c02ef6853..531febeac65b21655f45fca6bd10c655d3a346a9 100644 --- a/examples/sdl/src/main.rs +++ b/examples/sdl/src/main.rs @@ -80,8 +80,8 @@ fn main() { let mut game_boy = GameBoy::new(); game_boy.load_boot_default(); - game_boy.load_rom("../../res/roms/ld_r_r.gb"); - //game_boy.load_rom("../../res/roms/opus5.gb"); + game_boy.load_rom_file("../../res/roms/ld_r_r.gb"); + //game_boy.load_rom_file("../../res/roms/opus5.gb"); let mut counter = 0; diff --git a/examples/web/.gitignore b/examples/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5263f1b298966afe55b0447f940353959f789c55 --- /dev/null +++ b/examples/web/.gitignore @@ -0,0 +1,7 @@ +yarn.lock +package-lock.json + +/.parcel-cache + +/dist +/node_modules diff --git a/examples/web/.parcelrc b/examples/web/.parcelrc new file mode 100644 index 0000000000000000000000000000000000000000..50fbdcdb8d58cfcf093cd82645bea9d2e088c0c2 --- /dev/null +++ b/examples/web/.parcelrc @@ -0,0 +1,7 @@ +{ + "extends": "@parcel/config-default", + "transformers": { + "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"], + "*.ch8": ["@parcel/transformer-raw"] + } +} diff --git a/examples/web/.prettierrc b/examples/web/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..6afb03785f6c8c93401f7b35905b6d8f8065e499 --- /dev/null +++ b/examples/web/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "none", + "singleQuote": false, + "tabWidth": 4, + "endOfLine": "crlf" +} diff --git a/examples/web/index.css b/examples/web/index.css new file mode 100644 index 0000000000000000000000000000000000000000..31f6533f372fd16b64435dfc0c076ff4d6a0a3c5 --- /dev/null +++ b/examples/web/index.css @@ -0,0 +1,632 @@ +@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap"); + +* { + box-sizing: border-box; + -o-box-sizing: border-box; + -ms-box-sizing: border-box; + -moz-box-sizing: border-box; + -khtml-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +a { + border-bottom: 2px dotted #ffffff; + color: #ffffff; + text-decoration: none; +} + +a:hover { + border-bottom-style: solid; +} + +html { + margin: 0px 0px 0px 0px; + padding: 0px 0px 0px 0px; +} + +body { + color: #ffffff; + font-family: "VT323", "Robot", "Open Sans", Arial, Helvetica, sans-serif; + margin: 0px 0px 0px 0px; + padding: 12px 12px 52px 12px; +} + +p { + font-size: 18px; + line-height: 24px; + margin: 12px 0px 12px 0px; +} + +.main { + display: flex; +} + +@media only screen and (max-width: 1120px) { + .main { + flex-direction: column; + } +} + +.main > .side-left { + display: flex; + flex: 1 0; + justify-content: center; + 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; +} + +.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: 660px; + 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%; + min-width: 580px; + padding: 0px 24px 0px 24px; +} + +@media only screen and (max-width: 1120px) { + .main > .side-right { + min-width: unset; + padding: 0px 0px 0px 0px; + } +} + +.main > .side-right .logo-image { + vertical-align: middle; + width: 32px; +} + +.main > .side-right .separator { + background: #ffffff; + height: 2px; + margin: 22px 0px 22px 0px; +} + +.main > .side-right .diag { + font-size: 24px; + vertical-align: top; +} + +.main > .side-right .diag > dt { + clear: both; + float: left; + margin-top: 12px; +} + +.main > .side-right .diag > dt:first-of-type { + margin-top: 0px; +} + +.main > .side-right .diag > dd { + float: right; + margin-top: 12px; +} + +.main > .side-right .diag > dd:first-of-type { + margin-top: 0px; +} + +.main > .side-right .diag::after { + clear: both; + content: ''; + display: block; +} + +.footer { + bottom: 0px; + height: 40px; + left: 0px; + line-height: 40px; + padding: 0px 0px 0px 0px; + position: fixed; + text-align: center; + width: 100%; +} + +.footer-background { + bottom: 0px; + filter: blur(1.0rem); + -o-filter: blur(1.0rem); + -ms-filter: blur(1.0rem); + -moz-filter: blur(1.0rem); + -khtml-filter: blur(1.0rem); + -webkit-filter: blur(1.0rem); + height: 40px; + left: 0px; + position: fixed; + width: 100%; +} + +.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); +} + +.tiny-button { + border-radius: 96px 96px 96px 96px; + -o-border-radius: 96px 96px 96px 96px; + -ms-border-radius: 96px 96px 96px 96px; + -moz-border-radius: 96px 96px 96px 96px; + -khtml-border-radius: 96px 96px 96px 96px; + -webkit-border-radius: 96px 96px 96px 96px; + cursor: pointer; + display: inline-block; + padding: 0px 8px 0px 8px; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.tiny-button.border { + border: 1px solid #ffffff; +} + +.tiny-button.padded { + padding: 4px 10px 4px 10px; +} + +.tiny-button.padded-large { + padding: 4px 14px 4px 14px; +} + +.tiny-button.rounded { + padding: 6px 6px 6px 6px; +} + +.tiny-button.enabled { + background-color: #50cb93; +} + +.tiny-button.file { + position: relative; +} + +.tiny-button:hover { + background-color: #50cb93; +} + +.tiny-button.red:hover { + background-color: #e63946; +} + +.tiny-button:active { + background-color: #2a9d8f; +} + +.tiny-button.red:active { + background-color: #bf2a37; +} + +.tiny-button > img { + margin-right: 6px; + margin-top: 2px; + vertical-align: top; + width: 13px; +} + +.tiny-button > img.medium { + width: 20px; +} + +.tiny-button > img.large { + width: 28px; +} + +.tiny-button > img.very-large { + width: 38px; +} + +.tiny-button.no-text > img { + margin-right: 0px; + margin-top: 0px; +} + +.tiny-button.file > input[type="file"] { + cursor: pointer; + height: 100%; + left: 0px; + opacity: 0; + -o-opacity: 0; + -ms-opacity: 0; + -moz-opacity: 0; + -khtml-opacity: 0; + -webkit-opacity: 0; + position: absolute; + top: 0px; + vertical-align: top; + width: 100%; +} + +.tiny-button.file > input[type="file"]::-webkit-file-upload-button { + cursor: pointer; +} + +.overlay { + align-items: center; + background-color: rgba(80, 203, 147, 0.95); + 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.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.35s 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.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.35s 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); + 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.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); + width: 480px; +} + +.modal-container.visible > .modal { + 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); + -o-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.5s 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 > .tiny-button { + margin-right: 12px; + min-width: 120px; +} + +.modal-container > .modal .modal-buttons > .tiny-button: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; +} diff --git a/examples/web/index.html b/examples/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..c66238ada7f1a561e7c6a3809b98c21e99d4866c --- /dev/null +++ b/examples/web/index.html @@ -0,0 +1,158 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <title>Boytacean</title> + <meta charset="utf-8"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta name="description" content="Game Boy emulator written in Rust 🦀." /> + <meta name="viewport" + content="width=device-width, user-scalable=yes, initial-scale=1, minimum-scale=1, maximum-scale=5" /> + <link rel="icon" href="res/icon.png" /> + <link rel="stylesheet" href="index.css" /> +</head> + +<body> + <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="chip-canvas" class="canvas" width="640" height="320"></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-diag" class="section"> + <dl class="diag"> + <dt>Engine</dt> + <dd id="engine" class="tiny-button">-</dd> + <dt>ROM</dt> + <dd id="rom-name">-</dd> + <dt>ROM Size</dt> + <dd><span id="rom-size">-</span> bytes</dd> + <dt>CPU Frequency</dt> + <dd> + <span id="logic-frequency-minus" class="tiny-button">-</span> + <span id="logic-frequency">-</span> Hz + <span id="logic-frequency-plus" class="tiny-button">+</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="tiny-button border padded"> + <img src="res/pause.svg" alt="pause" /><span>Pause</span> + </span> + <span id="button-reset" class="tiny-button border padded"> + <img src="res/reset.svg" alt="reset" /><span>Reset</span> + </span> + <span id="button-benchmark" class="tiny-button border padded"> + <img src="res/bolt.svg" alt="bolt" /><span>Benchmark</span> + </span> + <span id="button-fullscreen" class="tiny-button border padded"> + <img src="res/maximise.svg" alt="maximise" /><span>Fullscreen</span> + </span> + <span id="button-keyboard" class="tiny-button border padded"> + <img src="res/dialpad.svg" alt="info" /><span>Keyboard</span> + </span> + <span id="button-information" class="tiny-button border padded enabled"> + <img src="res/info.svg" alt="info" /><span>Information</span> + </span> + <span id="button-debug" class="tiny-button border padded"> + <img src="res/bug.svg" alt="bug" /><span>Debug</span> + </span> + <span id="button-theme" class="tiny-button border padded"> + <img src="res/marker.svg" alt="marker" /><span>Theme</span> + </span> + <span id="button-upload" class="tiny-button border padded file"> + <img src="res/upload.svg" alt="upload" /><span>Upload ROM</span> + <input type="file" id="button-upload-file" name="button-upload-file" accept=".ch8"> + </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="tiny-button rounded no-text"> + <img class="medium" src="res/close.svg" alt="close" /> + </span> + </div> + <h2 id="modal-title" class="modal-title"></h2> + <p id="modal-text" class="modal-text"></p> + <div class="modal-buttons"> + <span id="modal-cancel" class="tiny-button red border padded-large">Cancel</span> + <span id="modal-confirm" class="tiny-button border padded-large">Confirm</span> + </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> +<div id="footer-background" class="footer-background"></div> +<div id="footer" class="footer"> + Built with ❤️ by <a href="https://joao.me" target="_blank">João Magalhães</a> +</div> +<script type="module" src="index.ts"></script> +</body> + +</html> diff --git a/examples/web/index.ts b/examples/web/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fda080e3d4e3ae6b6bf39bae74210b153e23f37c --- /dev/null +++ b/examples/web/index.ts @@ -0,0 +1,897 @@ +import { default as wasm, GameBoy } from "./lib/boytacean.js"; +import info from "./package.json"; + +const PIXEL_SET_COLOR = 0x50cb93ff; +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 = 64; +const DISPLAY_HEIGHT = 32; +const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT; + +const SAMPLE_RATE = 2; + +const SOUND_DATA = + "data:audio/mpeg;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAA"; + +const BACKGROUNDS = [ + "264653", + "1b1a17", + "023047", + "bc6c25", + "283618", + "2a9d8f", + "3a5a40" +]; + +const KEYS: Record<string, number> = { + "1": 0x01, + "2": 0x02, + "3": 0x03, + "4": 0x0c, + q: 0x04, + w: 0x05, + e: 0x06, + r: 0x0d, + a: 0x07, + s: 0x08, + d: 0x09, + f: 0x0e, + z: 0x0a, + x: 0x00, + c: 0x0b, + v: 0x0f +}; + +// @ts-ignore: ts(2580) +const ROM_PATH = require("../../res/roms/pong.ch8"); + +type State = { + gameBoy: GameBoy; + engine: string; + logicFrequency: number; + visualFrequency: number; + timerFrequency: number; + idleFrequency: number; + canvas: HTMLCanvasElement; + canvasScaled: HTMLCanvasElement; + canvasCtx: CanvasRenderingContext2D; + canvasScaledCtx: CanvasRenderingContext2D; + image: ImageData; + videoBuff: DataView; + toastTimeout: number; + paused: boolean; + background_index: number; + nextTickTime: number; + fps: number; + frameStart: number; + frameCount: number; + romName: string; + romData: Uint8Array; + romSize: number; +}; + +type Global = { + modalCallback: Function; +}; + +const state: State = { + gameBoy: null, + engine: null, + logicFrequency: LOGIC_HZ, + visualFrequency: VISUAL_HZ, + timerFrequency: TIMER_HZ, + idleFrequency: IDLE_HZ, + canvas: null, + canvasScaled: null, + canvasCtx: null, + canvasScaledCtx: null, + image: null, + videoBuff: null, + toastTimeout: null, + paused: false, + background_index: 0, + nextTickTime: 0, + fps: 0, + frameStart: new Date().getTime(), + frameCount: 0, + romName: null, + romData: null, + romSize: 0 +}; + +const global: Global = { + modalCallback: null +}; + +const sound = ((data = SOUND_DATA, volume = 0.2) => { + const sound = new Audio(data); + sound.volume = volume; + sound.muted = true; + return sound; +})(); + +const main = async () => { + // initializes the WASM module, this is required + // so that the global symbols become available + await wasm(); + + // initializes the complete set of sub-systems + // and registers the event handlers + await init(); + await register(); + + // start the emulator subsystem with the initial + // ROM retrieved from a remote data source + await start({ loadRom: true }); + + // runs the sequence as an infinite loop, running + // the associated CPU cycles accordingly + while (true) { + // in case the machin is paused we must delay the execution + // a little bit until the paused state is recovered + if (state.paused) { + await new Promise((resolve) => { + setTimeout(resolve, 1000 / state.idleFrequency); + }); + continue; + } + + // obtains the current time, this value is going + // to be used to compute the need for tick computation + let currentTime = new Date().getTime(); + + try { + tick(currentTime); + } catch (err) { + // sets the default error message to be displayed + // to the user + let message = String(err); + + // verifies if the current issue is a panic one + // and updates the message value if that's the case + const messageNormalized = (err as Error).message.toLowerCase(); + const isPanic = + messageNormalized.startsWith("unreachable") || + messageNormalized.startsWith("recursive use of an object"); + if (isPanic) { + message = "Unrecoverable error, restarting Game Boy"; + } + + // displays the error information to both the end-user + // and the developer (for dianostics) + showToast(message, true, 5000); + console.error(err); + + // pauses the machine, allowing the end-user to act + // on the error in a proper fashion + pause(); + + // if we're talking about a panic proper action must be taken + // which in this case it means restarting both the WASM sub + // system and the machine state (to be able to recover) + // also sets the default color on screen to indicate the issue + if (isPanic) { + await clearCanvas(undefined, { + // @ts-ignore: ts(2580) + image: require("./res/storm.png"), + imageScale: 0.4 + }); + + await wasm(); + await start({ restore: false }); + } + } + + // calculates the amount of time until the next draw operation + // this is the amount of time that is going to be pending + currentTime = new Date().getTime(); + const pendingTime = Math.max(state.nextTickTime - currentTime, 0); + + // waits a little bit for the next frame to be draw, + // this should control the flow of render + await new Promise((resolve) => { + setTimeout(resolve, pendingTime); + }); + } +}; + +const tick = (currentTime: number) => { + // in case the time to draw the next frame has not been + // reached the flush of the "tick" logic is skiped + if (currentTime < state.nextTickTime) return; + + // initializes the flag that is going to control is a beep + // is going to be issued + let beepFlag = false; + + // calculates the number of ticks that have elapsed since the + // last draw operation, this is critical to be able to properly + // operate the clock of the CPU in frame drop situations + if (state.nextTickTime === 0) state.nextTickTime = currentTime; + let ticks = Math.ceil( + (currentTime - state.nextTickTime) / + ((1 / state.visualFrequency) * 1000) + ); + ticks = Math.max(ticks, 1); + + const ratioLogic = (state.logicFrequency / state.visualFrequency) * ticks; + for (let i = 0; i < ratioLogic; i++) { + state.gameBoy.clock(); + } + + // in case the beep flag is active issue a sound during a bried + // period, to notify the user about a certain event + if (beepFlag) beep(); + + // updates the canvas object with the new + // visual information coming in + updateCanvas(state.gameBoy.frame_buffer_eager()); + + // increments the number of frames rendered in the current + // section, this value is going to be used to calculate FPS + state.frameCount += 1; + + // in case the target number of frames for FPS control + // has been reached calculates the number of FPS and + // flushes the value to the screen + if (state.frameCount === state.visualFrequency * SAMPLE_RATE) { + const currentTime = new Date().getTime(); + const deltaTime = (currentTime - state.frameStart) / 1000; + const fps = Math.round(state.frameCount / deltaTime); + setFps(fps); + state.frameCount = 0; + state.frameStart = currentTime; + } + + // updates the next update time reference to the, so that it + // can be used to control the game loop + state.nextTickTime += (1000 / state.visualFrequency) * ticks; +}; + +const start = async ({ + engine = "neo", + restore = true, + loadRom = false, + romPath = ROM_PATH, + romName = null as string, + romData = null as Uint8Array +} = {}) => { + // in case a remote ROM loading operation has been + // requested then loads it from the remote origin + if (loadRom) { + [romName, romData] = await fetchRom(romPath); + } else if (romName === null || romData === null) { + [romName, romData] = [state.romName, state.romData]; + } + + // selects the proper engine for execution + // and builds a new instance of it + switch (engine) { + case "neo": + state.gameBoy = new GameBoy(); + break; + + default: + if (!state.gameBoy) { + throw new Error("No engine requested"); + } + break; + } + + // resets the Game Boy engine to restore it into + // a valid state ready to be used + //state.gameBoy.reset_hard(); @todo + state.gameBoy.load_boot_default(); + state.gameBoy.load_rom(romData); + + // updates the name of the currently selected engine + // to the one that has been provided (logic change) + if (engine) state.engine = engine; + + // updates the complete set of global information that + // is going to be displayed + setEngine(state.engine); + setRom(romName, romData); + setLogicFrequency(state.logicFrequency); + setFps(state.fps); + + // in case the restore (state) flag is set + // then resumes the machine execution + if (restore) resume(); +}; + +const register = async () => { + await Promise.all([ + registerDrop(), + registerKeys(), + registerButtons(), + registerKeyboard(), + registerCanvas(), + registerToast(), + registerModal() + ]); +}; + +const init = async () => { + await Promise.all([initBase(), initCanvas()]); +}; + +const registerDrop = () => { + document.addEventListener("drop", async (event) => { + if ( + !event.dataTransfer.files || + event.dataTransfer.files.length === 0 + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const overlay = document.getElementById("overlay"); + overlay.classList.remove("visible"); + + const file = event.dataTransfer.files[0]; + + if (!file.name.endsWith(".gb")) { + showToast("This is probably not a Game Boy ROM file!", true); + return; + } + + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + start({ engine: null, romName: file.name, romData: romData }); + + showToast(`Loaded ${file.name} ROM successfully!`); + }); + document.addEventListener("dragover", async (event) => { + if (!event.dataTransfer.items || event.dataTransfer.items[0].type) + return; + + event.preventDefault(); + + const overlay = document.getElementById("overlay"); + overlay.classList.add("visible"); + }); + document.addEventListener("dragenter", async (event) => { + if (!event.dataTransfer.items || event.dataTransfer.items[0].type) + return; + const overlay = document.getElementById("overlay"); + overlay.classList.add("visible"); + }); + document.addEventListener("dragleave", async (event) => { + if (!event.dataTransfer.items || event.dataTransfer.items[0].type) + return; + const overlay = document.getElementById("overlay"); + overlay.classList.remove("visible"); + }); +}; + +const registerKeys = () => { + document.addEventListener("keydown", (event) => { + const keyCode = KEYS[event.key]; + if (keyCode !== undefined) { + //state.gameBoy.key_press_ws(keyCode); @todo + return; + } + + switch (event.key) { + case "+": + setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA); + break; + + case "-": + setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA); + break; + + case "Escape": + minimize(); + break; + } + }); + + document.addEventListener("keyup", (event) => { + const keyCode = KEYS[event.key]; + if (keyCode !== undefined) { + //state.gameBoy.key_lift_ws(keyCode); @todo + return; + } + }); +}; + +const registerButtons = () => { + const engine = document.getElementById("engine"); + engine.addEventListener("click", () => { + const name = state.engine == "neo" ? "classic" : "neo"; + start({ engine: name }); + showToast( + `Game Boy running in engine "${name.toUpperCase()}" from now on!` + ); + }); + + const logicFrequencyPlus = document.getElementById("logic-frequency-plus"); + logicFrequencyPlus.addEventListener("click", () => { + setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA); + }); + + const logicFrequencyMinus = document.getElementById( + "logic-frequency-minus" + ); + logicFrequencyMinus.addEventListener("click", () => { + setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA); + }); + + const buttonPause = document.getElementById("button-pause"); + buttonPause.addEventListener("click", () => { + toggleRunning(); + }); + + const buttonReset = document.getElementById("button-reset"); + buttonReset.addEventListener("click", () => { + reset(); + }); + + const buttonBenchmark = document.getElementById("button-benchmark"); + buttonBenchmark.addEventListener("click", async () => { + const result = await showModal( + "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!", + "Confirm" + ); + if (!result) return; + buttonBenchmark.classList.add("enabled"); + pause(); + try { + const initial = Date.now(); + const count = 500000000; + for (let i = 0; i < count; i++) { + state.gameBoy.clock(); + } + const delta = (Date.now() - initial) / 1000; + const frequency_mhz = count / delta / 1000 / 1000; + showToast( + `Took ${delta.toFixed( + 2 + )} seconds to run ${count} ticks (${frequency_mhz.toFixed( + 2 + )} Mhz)!`, + undefined, + 7500 + ); + } finally { + resume(); + buttonBenchmark.classList.remove("enabled"); + } + }); + + const buttonFullscreen = document.getElementById("button-fullscreen"); + buttonFullscreen.addEventListener("click", () => { + maximize(); + }); + + const buttonKeyboard = document.getElementById("button-keyboard"); + buttonKeyboard.addEventListener("click", () => { + const sectionKeyboard = document.getElementById("section-keyboard"); + const separatorKeyboard = document.getElementById("separator-keyboard"); + const sectionNarrative = document.getElementById("section-narrative"); + const separatorNarrative = document.getElementById( + "separator-narrative" + ); + if (buttonKeyboard.classList.contains("enabled")) { + sectionKeyboard.style.display = "none"; + separatorKeyboard.style.display = "none"; + sectionNarrative.style.display = "block"; + separatorNarrative.style.display = "block"; + buttonKeyboard.classList.remove("enabled"); + } else { + sectionKeyboard.style.display = "block"; + separatorKeyboard.style.display = "block"; + sectionNarrative.style.display = "none"; + separatorNarrative.style.display = "none"; + buttonKeyboard.classList.add("enabled"); + } + }); + + const buttonInformation = document.getElementById("button-information"); + buttonInformation.addEventListener("click", () => { + const sectionDiag = document.getElementById("section-diag"); + const separatorDiag = document.getElementById("separator-diag"); + if (buttonInformation.classList.contains("enabled")) { + sectionDiag.style.display = "none"; + separatorDiag.style.display = "none"; + buttonInformation.classList.remove("enabled"); + } else { + sectionDiag.style.display = "block"; + separatorDiag.style.display = "block"; + buttonInformation.classList.add("enabled"); + } + }); + + const buttonTheme = document.getElementById("button-theme"); + buttonTheme.addEventListener("click", () => { + state.background_index = + (state.background_index + 1) % BACKGROUNDS.length; + const background = BACKGROUNDS[state.background_index]; + setBackground(background); + }); + + const buttonUploadFile = document.getElementById( + "button-upload-file" + ) as HTMLInputElement; + buttonUploadFile.addEventListener("change", async () => { + if (!buttonUploadFile.files || buttonUploadFile.files.length === 0) { + return; + } + + const file = buttonUploadFile.files[0]; + + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + buttonUploadFile.value = ""; + + start({ engine: null, romName: file.name, romData: romData }); + + showToast(`Loaded ${file.name} ROM successfully!`); + }); +}; + +const 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 (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_press_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + + k.addEventListener("touchstart", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_press_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + + k.addEventListener("mouseup", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_lift_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + + k.addEventListener("touchend", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_lift_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + }); +}; + +const registerCanvas = () => { + const canvasClose = document.getElementById("canvas-close"); + canvasClose.addEventListener("click", () => { + minimize(); + }); +}; + +const registerToast = () => { + const toast = document.getElementById("toast"); + toast.addEventListener("click", () => { + toast.classList.remove("visible"); + }); +}; + +const registerModal = () => { + const modalClose = document.getElementById("modal-close"); + modalClose.addEventListener("click", () => { + hideModal(false); + }); + + const modalCancel = document.getElementById("modal-cancel"); + modalCancel.addEventListener("click", () => { + hideModal(false); + }); + + const modalConfirm = document.getElementById("modal-confirm"); + modalConfirm.addEventListener("click", () => { + hideModal(true); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideModal(false); + } + }); +}; + +const initBase = async () => { + const background = BACKGROUNDS[state.background_index]; + setBackground(background); + setVersion(info.version); +}; + +const initCanvas = async () => { + // initializes the off-screen canvas that is going to be + // used in the drawing process + state.canvas = document.createElement("canvas"); + state.canvas.width = DISPLAY_WIDTH; + state.canvas.height = DISPLAY_HEIGHT; + state.canvasCtx = state.canvas.getContext("2d"); + + state.canvasScaled = document.getElementById( + "chip-canvas" + ) as HTMLCanvasElement; + state.canvasScaled.width = + state.canvasScaled.width * window.devicePixelRatio; + state.canvasScaled.height = + state.canvasScaled.height * window.devicePixelRatio; + state.canvasScaledCtx = state.canvasScaled.getContext("2d"); + + state.canvasScaledCtx.scale( + state.canvasScaled.width / state.canvas.width, + state.canvasScaled.height / state.canvas.height + ); + state.canvasScaledCtx.imageSmoothingEnabled = false; + + state.image = state.canvasCtx.createImageData( + state.canvas.width, + state.canvas.height + ); + state.videoBuff = new DataView(state.image.data.buffer); +}; + +const updateCanvas = (pixels: Uint8Array) => { + for (let i = 0; i < pixels.length; i++) { + state.videoBuff.setUint32( + i * 4, + pixels[i] ? PIXEL_SET_COLOR : PIXEL_UNSET_COLOR + ); //@todo must take into consideration that these are RGB pixels + } + state.canvasCtx.putImageData(state.image, 0, 0); + state.canvasScaledCtx.drawImage(state.canvas, 0, 0); +}; + +const clearCanvas = async ( + color = PIXEL_UNSET_COLOR, + { image = null as string, imageScale = 1 } = {} +) => { + state.canvasScaledCtx.fillStyle = `#${color.toString(16).toUpperCase()}`; + state.canvasScaledCtx.fillRect( + 0, + 0, + state.canvasScaled.width, + state.canvasScaled.height + ); + + // in case an image was requested then uses that to load + // an image at the center of the screen + 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, + img.height * imageScale + ]; + const [x0, y0] = [ + state.canvasScaled.width / 2 - imgWidth / 2, + state.canvasScaled.height / 2 - imgHeight / 2 + ]; + state.canvasScaledCtx.setTransform(1, 0, 0, 1, 0, 0); + try { + state.canvasScaledCtx.drawImage(img, x0, y0, imgWidth, imgHeight); + } finally { + state.canvasScaledCtx.scale( + state.canvasScaled.width / state.canvas.width, + state.canvasScaled.height / state.canvas.height + ); + } + } +}; + +const showToast = async (message: string, error = false, timeout = 3500) => { + const toast = document.getElementById("toast"); + toast.classList.remove("error"); + if (error) toast.classList.add("error"); + toast.classList.add("visible"); + toast.textContent = message; + if (state.toastTimeout) clearTimeout(state.toastTimeout); + state.toastTimeout = setTimeout(() => { + toast.classList.remove("visible"); + state.toastTimeout = null; + }, timeout); +}; + +const showModal = async ( + message: string, + title = "Alert" +): Promise<boolean> => { + const modalContainer = document.getElementById("modal-container"); + const modalTitle = document.getElementById("modal-title"); + const modalText = document.getElementById("modal-text"); + modalContainer.classList.add("visible"); + modalTitle.textContent = title; + modalText.innerHTML = message.replace(/\n/g, "<br/>"); + const result = (await new Promise((resolve) => { + global.modalCallback = resolve; + })) as boolean; + return result; +}; + +const hideModal = async (result = true) => { + const modalContainer = document.getElementById("modal-container"); + modalContainer.classList.remove("visible"); + if (global.modalCallback) global.modalCallback(result); + global.modalCallback = null; +}; + +const setVersion = (value: string) => { + document.getElementById("version").textContent = value; +}; + +const setEngine = (name: string, upper = true) => { + name = upper ? name.toUpperCase() : name; + document.getElementById("engine").textContent = name; +}; + +const setRom = (name: string, data: Uint8Array) => { + state.romName = name; + state.romData = data; + state.romSize = data.length; + document.getElementById("rom-name").textContent = name; + document.getElementById("rom-size").textContent = String(data.length); +}; + +const setLogicFrequency = (value: number) => { + if (value < 0) showToast("Invalid frequency value!", true); + value = Math.max(value, 0); + state.logicFrequency = value; + document.getElementById("logic-frequency").textContent = String(value); +}; + +const setFps = (value: number) => { + if (value < 0) showToast("Invalid FPS value!", true); + value = Math.max(value, 0); + state.fps = value; + document.getElementById("fps-count").textContent = String(value); +}; + +const setBackground = (value: string) => { + document.body.style.backgroundColor = `#${value}`; + document.getElementById( + "footer-background" + ).style.backgroundColor = `#${value}f2`; +}; + +const toggleRunning = () => { + if (state.paused) { + resume(); + } else { + pause(); + } +}; + +const pause = () => { + state.paused = true; + const buttonPause = document.getElementById("button-pause"); + const img = buttonPause.getElementsByTagName("img")[0]; + const span = buttonPause.getElementsByTagName("span")[0]; + buttonPause.classList.add("enabled"); + // @ts-ignore: ts(2580) + img.src = require("./res/play.svg"); + span.textContent = "Resume"; +}; + +const resume = () => { + state.paused = false; + state.nextTickTime = new Date().getTime(); + const buttonPause = document.getElementById("button-pause"); + const img = buttonPause.getElementsByTagName("img")[0]; + const span = buttonPause.getElementsByTagName("span")[0]; + buttonPause.classList.remove("enabled"); + // @ts-ignore: ts(2580) + img.src = require("./res/pause.svg"); + span.textContent = "Pause"; +}; + +const toggleWindow = () => { + maximize(); +}; + +const maximize = () => { + const canvasContainer = document.getElementById("canvas-container"); + canvasContainer.classList.add("fullscreen"); + + window.addEventListener("resize", crop); + + crop(); +}; + +const minimize = () => { + const canvasContainer = document.getElementById("canvas-container"); + const chipCanvas = document.getElementById("chip-canvas"); + canvasContainer.classList.remove("fullscreen"); + chipCanvas.style.width = null; + chipCanvas.style.height = null; + window.removeEventListener("resize", crop); +}; + +const crop = () => { + const chipCanvas = document.getElementById("chip-canvas"); + + // calculates the window ratio as this is fundamental to + // determine the proper way to crop the fulscreen + const windowRatio = window.innerWidth / window.innerHeight; + + // in case the window is wider (more horizontal than the base ratio) + // this means that we must crop horizontaly + if (windowRatio > DISPLAY_RATIO) { + chipCanvas.style.width = `${ + window.innerWidth * (DISPLAY_RATIO / windowRatio) + }px`; + chipCanvas.style.height = `${window.innerHeight}px`; + } else { + chipCanvas.style.width = `${window.innerWidth}px`; + chipCanvas.style.height = `${ + window.innerHeight * (windowRatio / DISPLAY_RATIO) + }px`; + } +}; + +const reset = () => { + start({ engine: null }); +}; + +const fetchRom = async (romPath: string): Promise<[string, Uint8Array]> => { + // extracts the name of the ROM from the provided + // path by splitting its structure + const romPathS = romPath.split(/\//g); + let romName = romPathS[romPathS.length - 1].split("?")[0]; + const romNameS = romName.split(/\./g); + romName = `${romNameS[0]}.${romNameS[romNameS.length - 1]}`; + + // loads the ROM data and converts it into the + // target byte array buffer (to be used by WASM) + const response = await fetch(ROM_PATH); + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + // returns both the name of the ROM and the data + // contents as a byte array + return [romName, romData]; +}; + +const beep = async () => { + sound.muted = false; + await sound.play(); +}; + +(async () => { + await main(); +})(); diff --git a/examples/web/package.json b/examples/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6130b967919cbfd162bb51f0a20e95f588e63216 --- /dev/null +++ b/examples/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "boytacean-web", + "version": "0.1.0", + "description": "The web version of Boytacean", + "repository": { + "type": "git", + "url": "git+https://gitlab.stage.hive.pt/joamag/boytacean.git" + }, + "license": "Apache-2.0", + "scripts": { + "build": "parcel build index.html", + "dev": "parcel index.html", + "pretty": "prettier --config .prettierrc \"./**/*.{ts,json}\" --write", + "start": "npm run build", + "watch": "parcel watch index.html" + }, + "source": "index.ts", + "devDependencies": { + "@parcel/transformer-typescript-tsc": "^2.6.1", + "parcel": "^2.6.1", + "prettier": "^2.7.1", + "typescript": "^4.5.5" + } +} diff --git a/examples/web/tsconfig.json b/examples/web/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..0bc8cec0b5a82f53019127e9adb2821060aaa7dd --- /dev/null +++ b/examples/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "es2015", + "moduleResolution": "node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "target": "es6", + "noImplicitAny": true, + "sourceMap": true, + "outDir": ".", + "baseUrl": ".", + "lib": ["es2015", "dom"], + "paths": { + "*": ["node_modules/*", "src/types/*"] + } + }, + "include": ["**/*"], + "exclude": [] +} diff --git a/src/gb.rs b/src/gb.rs index e918ba68414c40cf8a7aba37695b63136b5a7f81..59bd189810236babc92ccc7d25ee20f8355671ef 100644 --- a/src/gb.rs +++ b/src/gb.rs @@ -37,18 +37,30 @@ impl GameBoy { self.ppu().clock(cycles) } - pub fn load_rom(&mut self, path: &str) { + pub fn load_rom(&mut self, data: &[u8]) { + self.cpu.mmu().write_rom(0x0000, data); + } + + pub fn load_rom_file(&mut self, path: &str) { let data = read_file(path); - self.cpu.mmu().write_rom(0x0000, &data); + self.load_rom(&data); + } + + pub fn load_boot(&mut self, data: &[u8]) { + self.cpu.mmu().write_boot(0x0000, data); } - pub fn load_boot(&mut self, path: &str) { + pub fn load_boot_file(&mut self, path: &str) { let data = read_file(path); - self.cpu.mmu().write_boot(0x0000, &data); + self.load_boot(&data); } pub fn load_boot_default(&mut self) { - self.load_boot("./res/dmg_rom.bin"); + self.load_boot_file("./res/dmg_rom.bin"); + } + + pub fn frame_buffer_eager(&mut self) -> Vec<u8> { + self.frame_buffer().to_vec() } }