From 324e16dc42e29de20e8ff571ba8de173a11e7907 Mon Sep 17 00:00:00 2001 From: Max Poletaev Date: Sat, 11 Jan 2025 20:49:15 +0300 Subject: [PATCH] WASM: UI update --- README.md | 48 ++----- cmd/dendy-wasm/main.go | 2 +- web/index.html | 88 ++++++------ web/main.js | 53 ++++++-- web/style.css | 300 +++++++++++++++++++++++++++++------------ 5 files changed, 315 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index f23c52d..ea67779 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ network multiplayer feature, so it’s not completely useless. Screenshots -## WebAssembly Build +## WebAssembly -WebAssembly version for modern browsers is available at https://maxpoletaev.github.io/dendy/. -It runs smoothly in modern browsers, though it does not support netplay in its -current form. (there was an [experimental][wasm-netplay] implementation of -netplay over WebRTC, but it was too slow and unreliable to be usable). +The WASM-compiled version for modern browsers is available at +https://maxpoletaev.github.io/dendy/. It runs surprisingly smooth, though it does +not support netplay in its current form (there was an [experimental][wasm-netplay] +implementation of it over WebRTC, but it was too slow and unreliable to be +usable). [wasm-netplay]: https://drive.google.com/file/d/1r3ZY20L168u3djRMWA_KLMrY0eIr1ify/view?usp=sharing @@ -262,42 +263,21 @@ nescartdb.com. ## Dependencies - * https://github.com/gen2brain/raylib-go/raylib - Go bindings for raylib (graphics/audio) + * https://github.com/gen2brain/raylib-go/raylib - Go bindings for Raylib (graphics/audio) * https://github.com/xtaci/kcp-go - TCP-over-UDP for netplay ## Resources -Although NES emulation is a pretty well-covered topic, It is still a very -interesting and challenging project to work on. Here are some of the resources -that I found particularly useful while writing this emulator. Big thanks to -everyone who made them! - -### Documentation - * [NESDev Wiki](https://www.nesdev.org/wiki/Nesdev_Wiki) -* [MOS 6502 CPU Reference](https://web.archive.org/web/20210429110213/http://obelisk.me.uk/6502/) by Andrew Jabobs, 2009 -* [Extra Instructions of the 65xx Series CPU](http://www.ffd2.com/fridge/docs/6502-NMOS.extra.opcodes) by Adam Vardy, 1996 -* [NES Rendering Overview](https://austinmorlan.com/posts/nes_rendering_overview/) by Austin Morlan, 2019 -* [Making NES Games in Assembly](https://famicom.party/book/) by Kevin Zurawel, 2021 +* [MOS 6502 CPU Reference](https://web.archive.org/web/20210429110213/http://obelisk.me.uk/6502/) by Andrew Jabobs +* [Extra Instructions of the 65xx Series CPU](http://www.ffd2.com/fridge/docs/6502-NMOS.extra.opcodes) by Adam Vardy +* [NES Rendering Overview](https://austinmorlan.com/posts/nes_rendering_overview/) by Austin Morlan +* [Making NES Games in Assembly](https://famicom.party/book/) by Kevin Zurawel +* [NES Emulator from Scratch](https://www.youtube.com/playlist?list=PLrOv9FMX8xJHqMvSGB_9G9nZZ_4IgteYf) series by javidx9 +* [Audio pseudo-code](https://forums.nesdev.org/viewtopic.php?t=13767) by oRBIT2002 * [Retroarch Netplay README](https://github.com/libretro/RetroArch/blob/master/network/netplay/README) -* [Audio pseudo-code](https://forums.nesdev.org/viewtopic.php?t=13767) - -### Videos - -* The [NES Emulator from Scratch][nesemu] series covers most of the topics from - the CPU to the sound, but I found the two videos about the PPU to be the most - useful for understanding the obscure details of the NES rendering pipeline: - [[1]][ppu1], [[2]][ppu2]. - -[nesemu]: https://www.youtube.com/playlist?list=PLrOv9FMX8xJHqMvSGB_9G9nZZ_4IgteYf -[ppu1]: https://www.youtube.com/watch?v=-THeUXqR3zY&list=PLrOv9FMX8xJHqMvSGB_9G9nZZ_4IgteYf&index=5 -[ppu2]: https://www.youtube.com/watch?v=cksywUTZxlY&list=PLrOv9FMX8xJHqMvSGB_9G9nZZ_4IgteYf&index=6 - -### Code -During bad times, it’s always nice to look at other people’s code to see how -they solved the same problems. Here are some of the emulators written by other -people that I often referred to when I was stuck: +## Referenced Projects * [github.com/OneLoneCoder/olcNES](https://github.com/OneLoneCoder/olcNES) * [github.com/ad-sho-loko/goones](https://github.com/ad-sho-loko/goones) diff --git a/cmd/dendy-wasm/main.go b/cmd/dendy-wasm/main.go index 17d6162..1581ca1 100644 --- a/cmd/dendy-wasm/main.go +++ b/cmd/dendy-wasm/main.go @@ -14,7 +14,7 @@ import ( ) const ( - audioBufferSize = 512 + audioBufferSize = 1024 // must be multiple 128 as JS consumes samples in 128 chunks ) //go:embed nestest.nes diff --git a/web/index.html b/web/index.html index 789a1f2..a8e8471 100644 --- a/web/index.html +++ b/web/index.html @@ -2,56 +2,68 @@ - + Dendy Emulator (WASM) -
-
-
- - +
+
+
+ + +
-
- -
-
- Select ROM (.nes): - +
+
- -
-
- WASD - D-Pad -
-
- J - B Button -
-
- K - A Button -
-
- Enter - Start -
-
- Right Shift - Select -
-
- ⌘+R - Reset +
+
+
+
+
+
W
+
S
+
+
+
A
+
D
+
+
+
+
+
+
+
+
+
+
RShift
+
+
+
Enter
+
+
+
+
+
+
+
J
+
+
+
K
+
+
+
- diff --git a/web/main.js b/web/main.js index 95cf1d9..b1156c1 100644 --- a/web/main.js +++ b/web/main.js @@ -17,9 +17,6 @@ Promise.all([wasmReady, documentReady]).then(async () => { const TARGET_FPS = 60; const SCALE = 2; - const audioBufferSize = go.AudioBufferSize; - const audioSampleRate = go.AudioSampleRate; - // ======================== // Canvas setup // ======================== @@ -38,10 +35,10 @@ Promise.all([wasmReady, documentReady]).then(async () => { // Audio setup // ======================== + const audioBufferSize = go.AudioBufferSize; + const audioSampleRate = go.AudioSampleRate; console.log(`[INFO] audio sample rate: ${audioSampleRate}, buffer size: ${audioBufferSize}`); - let audioCtx = new AudioContext({ - sampleRate: audioSampleRate, - }); + let audioCtx = new AudioContext({sampleRate: audioSampleRate}); await audioCtx.audioWorklet.addModule("audio.js"); let audioNode = new AudioWorkletNode(audioCtx, "audio-processor"); @@ -63,7 +60,6 @@ Promise.all([wasmReady, documentReady]).then(async () => { } }, {once: true}); - // ======================== // Input handling // ======================== @@ -104,6 +100,25 @@ Promise.all([wasmReady, documentReady]).then(async () => { } }); + const elementKeyMap = { + "dpad-up": BUTTON_UP, + "dpad-down": BUTTON_DOWN, + "dpad-left": BUTTON_LEFT, + "dpad-right": BUTTON_RIGHT, + "button-start": BUTTON_START, + "button-select": BUTTON_SELECT, + "button-b": BUTTON_B, + "button-a": BUTTON_A, + }; + + for (let [id, mask] of Object.entries(elementKeyMap)) { + let element = document.getElementById(id); + element.addEventListener("mousedown", () => { buttonsPressed |= mask; }); + element.addEventListener("touchstart", () => { buttonsPressed |= mask; }); + element.addEventListener("mouseup", () => { buttonsPressed &= ~mask; }); + element.addEventListener("touchend", () => { buttonsPressed &= ~mask; }); + } + // ======================== // ROM loading // ======================== @@ -132,6 +147,16 @@ Promise.all([wasmReady, documentReady]).then(async () => { }); } + document.addEventListener('dragover', (e) => { + e.preventDefault(); + }); + + document.addEventListener('drop', (e) => { + e.preventDefault(); + fileInput.files = e.dataTransfer.files; + fileInput.dispatchEvent(new Event('input')); + }); + // ======================== // Game loop // ======================== @@ -152,7 +177,7 @@ Promise.all([wasmReady, documentReady]).then(async () => { let framePtr = go.GetFrameBufferPtr(); let image = new ImageData(new Uint8ClampedArray(getMemoryBuffer(), framePtr, WIDTH * HEIGHT * 4), WIDTH, HEIGHT); ctx.putImageData(image, 0, 0); - return + return; } let audioBufPtr = go.GetAudioBufferPtr(); @@ -165,14 +190,14 @@ Promise.all([wasmReady, documentReady]).then(async () => { const frameTime = 1000 / TARGET_FPS; function loop() { - requestAnimationFrame(loop) + requestAnimationFrame(loop); - const now = performance.now() - const elapsed = now - lastFrameTime - if (elapsed < frameTime) return + const now = performance.now(); + const elapsed = now - lastFrameTime; + if (elapsed < frameTime) return; - const excessTime = elapsed % frameTime - lastFrameTime = now - excessTime + const excessTime = elapsed % frameTime; + lastFrameTime = now - excessTime; if (isInFocus()) { executeFrame(); diff --git a/web/style.css b/web/style.css index e63b7c3..bee821e 100644 --- a/web/style.css +++ b/web/style.css @@ -11,142 +11,264 @@ body { font-family: ui-sans-serif, system-ui, sans-serif; } -.container { - background-color: #ffffff; - width: 512px; +.console { border-radius: 12px; + background: #959595; + padding: 20px; +} + +.console__screen { overflow: hidden; - box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3); + border-radius: 10px; + background: #101010; + margin-bottom: 15px; +} + +.console__controls { + /*margin-bottom: 10px;*/ +} + +.console__rom { + margin-bottom: 15px; +} + +.screen { + position: relative; +} + +.screen__canvas { + display: block; } -.header { - background-color: #e53e3e; +.screen__unmute { + position: absolute; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(5px); + border-radius: 20px; + padding: 10px; color: white; - padding: 15px; - text-align: center; + left: 50%; + transform: translateX(-50%); + bottom: 40px; + font-size: 1.1em; + white-space: nowrap; +} + +/* Controls styles are inspired by + https://codepen.io/injectilo/pen/MYJrmm */ + +.controls { + padding: 30px; + background: #686868; + display: flex; position: relative; + flex-direction: row; + justify-content: space-between; + border: 1px solid #4e4e4e; + border-radius: 10px; } -.header h1 { - margin: 0; - font-size: 1.5em; - letter-spacing: 1px; +.controls__dpad { + position: relative; } -.game-section { +.controls__center { + margin-top: 60px; +} + +.controls__ab { + margin-top: 50px; +} + +.dpad { position: relative; - background: #000; + height: 110px; + width: 110px; + color: #000000; +} + +.dpad__horizontal { + position: absolute; width: 100%; + height: 38px; + border-radius: 4px; + top: 50%; + transform: translateY(-50%); + background: #252725; + z-index: 1; } -.game-window { - height: 480px; - border-bottom: 1px solid #2d3748; +.dpad__vertical { + position: absolute; + width: 38px; + height: 100%; + left: 50%; + border-radius: 4px; + transform: translateX(-50%); + background: #252725; + z-index: 2; } -.game-window canvas { - display: block; +.dpad__button { + color: rgba(255, 255, 255, 0.6); + font-weight: bold; + position: absolute; + text-align: center; + line-height: 35px; + cursor: pointer; + user-select: none; + width: 38px; + height: 38px; + z-index: 1; } -.upload-section { - padding: 20px; +.dpad__button.-left { + box-shadow: inset 0 1px rgba(255, 255, 255, 0.5); + z-index: 0; + left: 0; } -.rom-upload { - font-size: 0.9em; - padding: 10px; - display: flex; - gap: 10px; - align-items: center; - border: 1px solid #edf2f7; - border-radius: 8px; - background: #f7fafc; - margin-bottom: 20px; +.dpad__button.-right { + box-shadow: inset 0 1px rgba(255, 255, 255, 0.5); + z-index: 0; + right: 0; } -.info-section { - padding: 20px; +.dpad__button.-up { + box-shadow: inset 0 1px rgba(255, 255, 255, 0.5) ; + z-index: 1; + top: 0; } -.controls-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; +.dpad__button.-down { + bottom: 0; } -.control-item { - background: #f7fafc; - padding: 10px; - border-radius: 8px; - text-align: center; - border: 1px solid #edf2f7; +.dpad__border { + position: absolute; + background: #edece7; } -.control-key { - font-weight: bold; - color: #e53e3e; - display: block; - margin-bottom: 4px; +.dpad__border.-vert { + width: 46px; + top: -3px; + bottom: -3px; + z-index: 0; + left: 50%; + transform: translateX(-50%); + border-radius: 4px; } -.control-action { - font-size: 0.9em; - color: #4a5568; +.dpad__border.-horiz { + height: 46px; + left: -3px; + right: -3px; + z-index: 0; + top: 50%; + transform: translateY(-50%); + border-radius: 4px; } -.project-info { - background: #f7fafc; - padding: 15px; - border-radius: 8px; - font-size: 0.9em; - line-height: 1.6; +.dpad__circle { + position: absolute; + width: 20px; + height: 20px; + left: 0; + right: 0; + bottom: 0; + top: 0; + margin: auto; + z-index: 3; + border-radius: 50%; + background: linear-gradient(180deg, rgb(22, 22, 22) 30%, rgb(58, 58, 58) 100%); + box-shadow: inset 0 -2px 0 0 rgba(255, 255, 255, 0.1); } -.project-info h2 { - color: #e53e3e; - margin: 0 0 10px 0; - font-size: 1.2em; +.center-buttons { + margin: auto; + width: 142px; + height: 40px; + border-radius: 10px; + background-color: #edece7; + border: 4px solid #e0ded4; + box-shadow: inset 0 0 2px 2px rgba(0, 0, 0, 0.3); + z-index: 3; + display: flex; } -.features { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; - margin-top: 10px; +.center-buttons__button { + position: relative; + top: 3px; + width: 42px; + height: 18px; + cursor: pointer; + user-select: none; + background-color: #464646; + border-radius: 10px; + border: 1px solid #272723; + margin: 9px 13px; + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.51); +} + +.center-buttons__label { + color: rgba(255, 255, 255, 0.6); + position: absolute; + font-weight: bold; + text-align: center; + left: 50%; + transform: translateX(-50%); + top: -40px; } -.feature-item { +.ab { + position: relative; display: flex; - align-items: center; - gap: 8px; } -.feature-item::before { - content: "•"; - color: #e53e3e; +.ab__border { + position: relative; + border-radius: 4px; + background-color: #edece7; + margin: 6px; + padding: 6px; } -.source-link { +.ab__button { + width: 40px; + height: 40px; + border-radius: 50%; + float: left; + text-align: center; + line-height: 40px; + font-weight: bold; + cursor: pointer; + user-select: none; + color: rgba(255, 255, 255, 0.6); + border: 1px rgba(0, 0, 0, 0.51) solid; + background: linear-gradient(to bottom, #df2015 0%, #f84936 100%); + box-shadow: inset 0 1px 2px 0 #fbfbfb, 0 1px 1px 0 rgba(0, 0, 0, 0.71); +} + +.rom-select { + border-radius: 12px; + border: 1px dashed #4e4e4e; + text-align: center; + cursor: pointer; + display: block; + padding: 15px; +} + +.rom-select svg { position: relative; + top: 3px; +} + +.source-link { + padding: 15px 0; text-align: center; - margin-bottom: 20px; font-size: 0.9em; } .source-link a { - color: #4a5568; + color: #a9a9a9; } - -.unmute { - position: absolute; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(5px); - border-radius: 20px; - padding: 10px; - color: white; - left: 50%; - transform: translateX(-50%); - bottom: 40px; - font-size: 1.1em; - white-space: nowrap; -} \ No newline at end of file