├── .github └── FUNDING.yml ├── .gitignore ├── Cargo.toml ├── LICENSE.md ├── README.md ├── build.sh ├── concept └── pixel_buttons.ase ├── public ├── audio_processor.js ├── cc-by-nc-sa-logo.png ├── embed.css ├── embed.html ├── emu_worker.js ├── favicon.png ├── github-logo-48px-black.png ├── grid.png ├── index.html ├── input.js ├── main.js ├── patreon-logo-48px-black.png ├── style.css ├── touch.js ├── touch_playground.html ├── wavy_grid.png └── wavy_grid_gradient.png ├── rusticnes-itch.io-embed ├── audio_processor.js ├── embed.css ├── emu_worker.js ├── index.html ├── input.js ├── main.js ├── style.css └── touch.js └── src ├── assets └── overlay.png └── lib.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: "zeta0134" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | **/*.rs.bk 3 | Cargo.lock 4 | *.wasm 5 | *.nes 6 | *.zip 7 | rusticnes_wasm.d.ts 8 | rusticnes_wasm_bg.wasm.d.ts 9 | rusticnes_wasm.js 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rusticnes-wasm" 3 | version = "0.1.0" 4 | authors = ["Nicholas Flynt "] 5 | 6 | [profile.release] 7 | lto = "fat" 8 | codegen-units = 1 9 | panic = "abort" 10 | 11 | [lib] 12 | crate-type = ["cdylib"] 13 | 14 | [dependencies] 15 | lazy_static = "1.0" 16 | wasm-bindgen = "0.2.92" 17 | rusticnes-core = { git = "https://github.com/zeta0134/rusticnes-core", rev = "be6ef2c7cd3d654846e67888aa3091aa8d310a0a" } 18 | rusticnes-ui-common = { git = "https://github.com/zeta0134/rusticnes-ui-common", rev="88f533743d55fabe860e6a91c9b18a309d5e3cac" } 19 | 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 Nicholas Flynt, aka "zeta0134" 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archival Notice 2 | 3 | This project has been renamed to Rustico, and moved to a shiny new monorepo over here: https://github.com/zeta0134/rustico 4 | 5 | Please update your bookmarks! All new development will proceed in the monorepo, and I'll eventually remove these individual repositories. 6 | 7 | # RusticNES-wasm 8 | 9 | A web based interface for [RusticNES](https://github.com/zeta0134/rusticnes-core), running on Web Assembly for sweet retro action in the browser. Includes a basic web shell to operate the emulator, and relies on [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) to simplify the interface between the emulator core and the JavaScript UI. This is a reasonably early work in progress, expect bugs! 10 | 11 | A live demo can be found [here](http://rusticnes.reploid.cafe/wasm/?cartridge=super-bat-puncher.nes), running the AWESOME homebrew [Super Bat Puncher](http://morphcat.de/superbatpuncher/), hosted with permission from [Morphcat Games](http://morphcat.de/) whom you should totally check out. 12 | 13 | ## Building 14 | 15 | First, install the wasm32-unknown-unknown target. As of this writing, this is only available in rust nightly, so install that too as the build script expects it: 16 | 17 | ``` 18 | rustup toolchain install nightly 19 | rustup target add wasm32-unknown-unknown --toolchain nightly 20 | ``` 21 | 22 | Next, install wasm-bindgen, and add your local cargo bin directory to your $PATH if you haven't done so already: 23 | 24 | ``` 25 | cargo install wasm-bindgen-cli 26 | export PATH=$PATH:~/.cargo/bin 27 | ``` 28 | 29 | Finally, run the `./build.sh` script in the main folder. Afterwards, for Firefox you should be able to open `public/index.html` and run the emulator. For Chrome, you'll need to host the "public" folder on a (possibly local) webserver first, as Chrome will not permit the project to load the .wasm files from the file:// protocol. 30 | 31 | ## Usage 32 | 33 | Use the "Load" button to open up a `.nes` file from your computer. Alternately, you can pass in a query string: `?cartridge=game.nes`. This is read as a standard URL, so paths relative to the index page work, as do fully qualified URLs that point to a valid `.nes` file. Note that `.zip` and other archives are not supported. 34 | 35 | The emulator will try to maintain 60 FPS, the success of which depends on how powerful your computer, tablet, or phone is. You may enter fullscreen mode by double-clicking on the game screen. If your game supports SRAM (not many NES games do), it will be persisted to local storage in your browser; note that the filename / URL is used to determine which save file is loaded. Gamepad controls default to the following, and may be remapped: 36 | 37 | ``` 38 | D-Pad: Arrow Keys 39 | A: Z 40 | B: X 41 | Start: Enter 42 | Select: Shift 43 | ``` 44 | 45 | ## Planned Features 46 | 47 | - Speed Improvements 48 | - Better support for touchscreens 49 | 50 | Needs rusticnes-core support: 51 | 52 | - NES Zapper 53 | - Save States 54 | 55 | ## General Notes 56 | 57 | All shells to RusticNES are limited by features present in the [core emulator](https://github.com/zeta0134/rusticnes-core), so some features need to be implemented upstream first. This applies to emulation bugs as well, especially with regards to missing mapper support. Bug reports are welcome! I don't have every cartridge out there to test with, so I may not be aware that your favorite game doesn't boot, but I love a good challenge. 58 | 59 | Several technologies involved in this project are moving targets, not the least of which being WebAssembly itself. Web Audio is particularly new, and might run into strange issues; I've heard reports of it deciding to mix audio at 192 KHz on some Windows systems, which should technically work but will definitely make the emulator work a lot harder. As of this writing, the emulator is known to work in both Firefox and Chrome, and has faster performance on Firefox. It should in theory be able to run on Microsoft Edge, but wasm-bindgen fails to load due to a missing (planned) feature. I'll try to correct for this later, but might need to wait on Microsoft for a proper fix. I'd like to make it as standards compliant as possible, so if it's not working in your favorite browser, (and you're reasonably confident that browser at least supports WASM), bug reports are welcome! 60 | 61 | If you'd like to test the emulator in more detail, right now the [RusticNES-SDL](https://github.com/zeta0134/rusticnes-sdl) frontend is much more mature, and supports many debug features. You should give it a try, as both shells use the same [rusticnes-core](https://github.com/zeta0134/rusticnes-core) backend. Emulation accuracy should be identical between the various frontends. 62 | 63 | Feel free to distribute this emulator! I mostly ask for common courtesy and respect. Fork, modify, extend, and have fun with it! However, please do be respectful of copyright laws. The emulator will play almost any game, even commercial games, but this does not give you the legal right to distribute those game files on your personal website. Please obey the local laws in your country / state, and if you can, be sure to give Nintendo and other publishers your support by buying their current titles and Virtual Console releases. Unless you are distributing a homebrew game that you wrote, I would not recommend hosting game files alongside the emulator if you choose make your build public. 64 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p ./public 4 | cargo +nightly build --target wasm32-unknown-unknown --release 5 | wasm-bindgen target/wasm32-unknown-unknown/release/rusticnes_wasm.wasm --out-dir ./public --no-modules 6 | -------------------------------------------------------------------------------- /concept/pixel_buttons.ase: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/concept/pixel_buttons.ase -------------------------------------------------------------------------------- /public/audio_processor.js: -------------------------------------------------------------------------------- 1 | // An example thing, obviously fix this 2 | class NesAudioProcessor extends AudioWorkletProcessor { 3 | constructor (...args) { 4 | super(...args) 5 | this.sampleBuffer = new Int16Array(0); 6 | this.lastPlayedSample = 0; 7 | this.port.onmessage = (e) => { 8 | if (e.data.type == "samples") { 9 | let mergedBuffer = new Int16Array(this.sampleBuffer.length + e.data.samples.length); 10 | mergedBuffer.set(this.sampleBuffer); 11 | mergedBuffer.set(e.data.samples, this.sampleBuffer.length); 12 | this.sampleBuffer = mergedBuffer; 13 | } 14 | } 15 | } 16 | process (inputs, outputs, parameters) { 17 | const output = outputs[0] 18 | const desired_length = output[0].length; 19 | //console.log("Want to play: ", desired_length); 20 | //console.log("Actual size: ", this.sampleBuffer.length); 21 | if (desired_length <= this.sampleBuffer.length) { 22 | // Queue up the buffer contents. Note that NES audio is in mono, so we'll replicate that 23 | // to every channel on the output. (I'm guessing usually 2?) 24 | output.forEach(channel => { 25 | for (let i = 0; i < channel.length; i++) { 26 | // Convert from i16 to float, ranging from -1 to 1 27 | channel[i] = (this.sampleBuffer[i] / 32768); 28 | } 29 | }) 30 | // Set the new last played sample, this will be our hold value if we have an underrun 31 | this.lastPlayedSample = this.sampleBuffer[desired_length - 1]; 32 | // Remove those contents from the buffer 33 | this.sampleBuffer = this.sampleBuffer.slice(desired_length); 34 | // Finally, tell the main thread so it can adjust its totals 35 | this.port.postMessage({"type": "samplesPlayed", "count": desired_length}); 36 | } else { 37 | // Queue up nothing! Specifically, *repeat* the last sample, to hold the level; this won't 38 | // avoid a break in the audio, but it avoids ugly pops 39 | output.forEach(channel => { 40 | for (let i = 0; i < channel.length; i++) { 41 | channel[i] = (this.lastPlayedSample / 37268); 42 | } 43 | }) 44 | // Tell the main thread that we've run behind 45 | this.port.postMessage({"type": "audioUnderrun", "count": output[0].length}); 46 | } 47 | 48 | return true 49 | } 50 | } 51 | 52 | registerProcessor('nes-audio-processor', NesAudioProcessor) 53 | -------------------------------------------------------------------------------- /public/cc-by-nc-sa-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/cc-by-nc-sa-logo.png -------------------------------------------------------------------------------- /public/embed.css: -------------------------------------------------------------------------------- 1 | /* note: minimal new styling here, mostly this just hides elements on the main interface 2 | that we don't want to display in the embedded one. */ 3 | 4 | #navbar { 5 | display: none; 6 | } 7 | 8 | body { 9 | width: 100%; 10 | height: 100%; 11 | box-shadow: none; 12 | } 13 | 14 | #content_area { 15 | width: 100%; 16 | height: 100%; 17 | overflow: hidden; 18 | } 19 | 20 | #playfield div.canvas_container { 21 | width: 100%; 22 | height: 100%; 23 | } 24 | -------------------------------------------------------------------------------- /public/embed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |

P1 - Standard

47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
A
B
Select
Start
Up
Down
Left
Right
57 |
58 |
59 |

P2 - Standard

60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
A
B
Select
Start
Up
Down
Left
Right
70 |
71 |
72 |
73 |
74 | 75 | 76 | -------------------------------------------------------------------------------- /public/emu_worker.js: -------------------------------------------------------------------------------- 1 | importScripts('./rusticnes_wasm.js'); 2 | 3 | const { 4 | wasm_init, 5 | load_rom, 6 | run_until_vblank, 7 | set_p1_input, 8 | set_p2_input, 9 | set_audio_samplerate, 10 | set_audio_buffersize, 11 | audio_buffer_full, 12 | get_audio_buffer, 13 | get_sram, set_sram, 14 | has_sram, update_windows, 15 | draw_piano_roll_window, 16 | draw_screen_pixels, 17 | piano_roll_window_click, 18 | consume_audio_samples, 19 | } = wasm_bindgen; 20 | 21 | let initialized = false; 22 | let profiling = { 23 | run_one_frame: {accumulated_time: 0, count: 0}, 24 | render_screen: {accumulated_time: 0, count: 0}, 25 | render_piano_roll: {accumulated_time: 0, count: 0}, 26 | idle: {accumulated_time: 0, count: 0}, 27 | render_all_panels: {accumulated_time: 0, count: 0}, 28 | } 29 | let idle_start = 0; 30 | let idle_accumulator = 0; 31 | 32 | function collect_profiling(event_name, measured_time) { 33 | let profile = profiling[event_name] 34 | profile.accumulated_time += measured_time; 35 | profile.count += 1; 36 | // do an average over 10 frames or so 37 | if (profile.count >= 60) { 38 | let average = profile.accumulated_time / 60; 39 | profile.count = 0; 40 | profile.accumulated_time = 0; 41 | postMessage({"type": "reportPerformance", "event": event_name, "average_milliseconds": average}); 42 | } 43 | } 44 | 45 | // TODO: The rust side of this *should* be generating appropriate error 46 | // messages. Can we catch those and propogate that error to the UI? That 47 | // would be excellent for users, right now they're just getting silent 48 | // failure. 49 | function load_cartridge(cart_data) { 50 | load_rom(cart_data); 51 | set_audio_samplerate(44100); 52 | } 53 | 54 | function run_one_frame() { 55 | let start_time = performance.now(); 56 | run_until_vblank(); 57 | update_windows(); 58 | collect_profiling("run_one_frame", performance.now() - start_time) 59 | } 60 | 61 | function get_screen_pixels(dest_array_buffer) { 62 | let start_time = performance.now(); 63 | //let raw_buffer = new ArrayBuffer(256*240*4); 64 | //let screen_pixels = new Uint8ClampedArray(raw_buffer); 65 | let screen_pixels = new Uint8ClampedArray(dest_array_buffer); 66 | draw_screen_pixels(screen_pixels); 67 | collect_profiling("render_screen", performance.now() - start_time) 68 | return dest_array_buffer; 69 | } 70 | 71 | function get_piano_roll_pixels(dest_array_buffer) { 72 | let start_time = performance.now(); 73 | //let raw_buffer = new ArrayBuffer(480*270*4); 74 | //let screen_pixels = new Uint8ClampedArray(raw_buffer); 75 | let screen_pixels = new Uint8ClampedArray(dest_array_buffer); 76 | draw_piano_roll_window(screen_pixels); 77 | collect_profiling("render_piano_roll", performance.now() - start_time) 78 | return dest_array_buffer; 79 | } 80 | 81 | function handle_piano_roll_window_click(mx, my) { 82 | piano_roll_window_click(mx, my); 83 | } 84 | 85 | const rpc_functions = { 86 | "load_cartridge": load_cartridge, 87 | "run_one_frame": run_one_frame, 88 | "get_screen_pixels": get_screen_pixels, 89 | "get_piano_roll_pixels": get_piano_roll_pixels, 90 | "handle_piano_roll_window_click": handle_piano_roll_window_click, 91 | "has_sram": has_sram, 92 | "get_sram": get_sram, 93 | "set_sram": set_sram, 94 | }; 95 | 96 | function rpc(task, args, reply_channel) { 97 | if (rpc_functions.hasOwnProperty(task)) { 98 | const result = rpc_functions[task].apply(this, args); 99 | reply_channel.postMessage({"result": result}); 100 | } 101 | } 102 | 103 | function handle_message(e) { 104 | idle_accumulator += performance.now() - idle_start; 105 | if (e.data.type == "rpc") { 106 | rpc(e.data.func, e.data.args, e.ports[0]) 107 | } 108 | if (e.data.type == "requestFrame") { 109 | // Measure the idle time between each frame, for profiling purposes 110 | collect_profiling("idle", idle_accumulator); 111 | idle_accumulator = 0; 112 | 113 | // Run one step of the emulator 114 | set_p1_input(e.data.p1); 115 | set_p2_input(e.data.p2); 116 | run_one_frame(); 117 | 118 | let outputPanels = []; 119 | let transferrableBuffers = []; 120 | let panel_start_time = performance.now(); 121 | for (let panel of e.data.panels) { 122 | if (panel.id == "screen") { 123 | let image_buffer = get_screen_pixels(panel.dest_buffer); 124 | outputPanels.push({ 125 | id: "screen", 126 | target_element: panel.target_element, 127 | image_buffer: image_buffer, 128 | width: 256, 129 | height: 240 130 | }); 131 | transferrableBuffers.push(image_buffer); 132 | } 133 | if (panel.id == "piano_roll_window") { 134 | let image_buffer = get_piano_roll_pixels(panel.dest_buffer); 135 | outputPanels.push({ 136 | id: "piano_roll_window", 137 | target_element: panel.target_element, 138 | image_buffer: image_buffer, 139 | width: 480, 140 | height: 270 141 | }); 142 | transferrableBuffers.push(image_buffer); 143 | } 144 | } 145 | // Only profile a render if we actually drew something 146 | if (e.data.panels.length > 0) { 147 | collect_profiling("render_all_panels", performance.now() - panel_start_time) 148 | } 149 | // TODO: this isn't an ArrayBuffer. It probably should be? 150 | let audio_buffer = consume_audio_samples(); 151 | postMessage({"type": "deliverFrame", "panels": outputPanels, "audio_buffer": audio_buffer}, transferrableBuffers); 152 | } 153 | idle_start = performance.now(); 154 | } 155 | 156 | worker_init = function() { 157 | wasm_init(); 158 | // We are ready to go! Tell the main thread it can kick off execution 159 | initialized = true; 160 | postMessage({"type": "init"}); 161 | self.onmessage = handle_message; 162 | } 163 | 164 | wasm_bindgen('./rusticnes_wasm_bg.wasm').then(worker_init); 165 | 166 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/favicon.png -------------------------------------------------------------------------------- /public/github-logo-48px-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/github-logo-48px-black.png -------------------------------------------------------------------------------- /public/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/grid.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | RusticNES 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 32 | 35 | 39 | 45 | 51 | 55 | 64 | 68 | 72 | 76 | 80 | 84 |
85 | === Profiling Results ===
86 |
would go here
87 |
88 | 89 |
90 |
91 |
92 | 93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |

P1 - Standard

114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 |
A
B
Select
Start
Up
Down
Left
Right
124 |
125 |
126 |

P2 - Standard

127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 |
A
B
Select
Start
Up
Down
Left
Right
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | 146 |
147 |
148 |
149 |
150 | 151 |
152 |
153 |
154 |
155 |
156 |
157 | 158 | 159 | -------------------------------------------------------------------------------- /public/input.js: -------------------------------------------------------------------------------- 1 | // Note: The following variable is global, and represents our live button state for the emulator: 2 | // var keys = [0,0]; 3 | 4 | var keys = [0,0,0]; 5 | var touch_keys = [0,0,0]; 6 | var remap_key = false; 7 | var remap_index = 0; 8 | var remap_slot = 1; 9 | 10 | var controller_keymaps = []; 11 | 12 | controller_keymaps[1] = [ 13 | "x", 14 | "z", 15 | "Shift", 16 | "Enter", 17 | "ArrowUp", 18 | "ArrowDown", 19 | "ArrowLeft", 20 | "ArrowRight"]; 21 | 22 | controller_keymaps[2] = ["-","-","-","-","-","-","-","-"]; 23 | 24 | window.addEventListener('keydown', function(event) { 25 | if (remap_key) { 26 | if (event.key != "Escape") { 27 | controller_keymaps[remap_slot][remap_index] = event.key; 28 | } else { 29 | controller_keymaps[remap_slot][remap_index] = "-"; 30 | } 31 | remap_key = false; 32 | displayButtonMappings(); 33 | saveInputConfig(); 34 | return; 35 | } 36 | for (var c = 1; c <= 2; c++) { 37 | for (var i = 0; i < 8; i++) { 38 | if (event.key == controller_keymaps[c][i]) { 39 | keys[c] = keys[c] | (0x1 << i); 40 | } 41 | } 42 | } 43 | if (event.key == "p") { 44 | var debug_box = document.querySelector("#debug-box"); 45 | debug_box.classList.toggle("active"); 46 | } 47 | }); 48 | 49 | window.addEventListener('keyup', function(event) { 50 | for (var c = 1; c <= 2; c++) { 51 | for (var i = 0; i < 8; i++) { 52 | if (event.key == controller_keymaps[c][i]) { 53 | keys[c] = keys[c] & ~(0x1 << i); 54 | } 55 | } 56 | } 57 | }); 58 | 59 | var controller_padmaps = []; 60 | controller_padmaps[1] = ["-","-","-","-","-","-","-","-"]; 61 | controller_padmaps[2] = ["-","-","-","-","-","-","-","-"]; 62 | 63 | var gamepads = []; 64 | 65 | var idle_interval = setInterval(updateGamepads, 500); 66 | 67 | window.addEventListener("gamepadconnected", function(e) { 68 | var gp = navigator.getGamepads()[e.gamepad.index]; 69 | console.log("Recognized new gamepad! Index: ", e.gamepad.index, " Buttons: ", gp.buttons.length, " Axis: ", gp.axes.length); 70 | gamepads[e.gamepad.index] = gamepadState(gp); 71 | }); 72 | 73 | function gamepadState(gamepad) { 74 | var state = {buttons: [], axes: []}; 75 | for (var b = 0; b < gamepad.buttons.length; b++) { 76 | state.buttons[b] = gamepad.buttons[b].pressed; 77 | } 78 | for (var a = 0; a < gamepad.axes.length; a++) { 79 | state.axes[a] = gamepad.axes[a]; 80 | } 81 | return state; 82 | } 83 | 84 | function updateGamepads() { 85 | for (var i = 0; i < gamepads.length; i++) { 86 | var old_state = gamepads[i]; 87 | if (old_state) { 88 | gp = navigator.getGamepads()[i]; 89 | if (gp) { 90 | var new_state = gamepadState(gp); 91 | for (var b = 0; b < old_state.buttons.length; b++) { 92 | if (old_state.buttons[b] == false && new_state.buttons[b] == true) { 93 | gamepadDown("PAD("+i+"): BUTTON("+b+")"); 94 | } 95 | if (old_state.buttons[b] == true && new_state.buttons[b] == false) { 96 | gamepadUp("PAD("+i+"): BUTTON("+b+")"); 97 | } 98 | } 99 | for (var a = 0; a < old_state.axes.length; a++) { 100 | if (old_state.axes[a] < 0.5 && new_state.axes[a] >= 0.5) { 101 | gamepadDown("PAD("+i+"): AXIS("+a+")+"); 102 | } 103 | if (old_state.axes[a] > -0.5 && new_state.axes[a] <= -0.5) { 104 | gamepadDown("PAD("+i+"): AXIS("+a+")-"); 105 | } 106 | 107 | if (old_state.axes[a] >= 0.5 && new_state.axes[a] < 0.5) { 108 | gamepadUp("PAD("+i+"): AXIS("+a+")+"); 109 | } 110 | if (old_state.axes[a] <= -0.5 && new_state.axes[a] > -0.5) { 111 | gamepadUp("PAD("+i+"): AXIS("+a+")-"); 112 | } 113 | } 114 | gamepads[i] = new_state; 115 | } 116 | } 117 | } 118 | } 119 | 120 | function gamepadDown(button_name) { 121 | if (remap_key) { 122 | controller_padmaps[remap_slot][remap_index] = button_name; 123 | remap_key = false; 124 | displayButtonMappings(); 125 | saveInputConfig(); 126 | return; 127 | } 128 | for (var c = 1; c <= 2; c++) { 129 | for (var i = 0; i < 8; i++) { 130 | if (button_name == controller_padmaps[c][i]) { 131 | keys[c] = keys[c] | (0x1 << i); 132 | } 133 | } 134 | } 135 | } 136 | 137 | function gamepadUp(button_name) { 138 | if (remap_key) { 139 | controller_padmaps[remap_slot][remap_index] = button_name; 140 | remap_key = false; 141 | displayButtonMappings(); 142 | return; 143 | } 144 | for (var c = 1; c <= 2; c++) { 145 | for (var i = 0; i < 8; i++) { 146 | if (button_name == controller_padmaps[c][i]) { 147 | keys[c] = keys[c] & ~(0x1 << i); 148 | } 149 | } 150 | } 151 | } 152 | 153 | function displayButtonMappings() { 154 | var buttons = document.querySelectorAll("#configure_input button"); 155 | buttons.forEach(function(button) { 156 | var key_index = button.getAttribute("data-key"); 157 | var key_slot = button.getAttribute("data-slot"); 158 | button.innerHTML = controller_keymaps[key_slot][key_index] + " / " + controller_padmaps[key_slot][key_index]; 159 | button.classList.remove("remapping"); 160 | }); 161 | } 162 | 163 | function remapButton() { 164 | displayButtonMappings(); 165 | this.classList.add("remapping"); 166 | this.innerHTML = "..." 167 | remap_key = true; 168 | remap_index = this.getAttribute("data-key"); 169 | remap_slot = this.getAttribute("data-slot"); 170 | this.blur(); 171 | } 172 | 173 | function initializeButtonMappings() { 174 | displayButtonMappings(); 175 | var buttons = document.querySelectorAll("#configure_input button"); 176 | buttons.forEach(function(button) { 177 | button.addEventListener("click", remapButton); 178 | }); 179 | } 180 | 181 | function saveInputConfig() { 182 | try { 183 | window.localStorage.setItem("keyboard_1", JSON.stringify(controller_keymaps[1])); 184 | window.localStorage.setItem("keyboard_2", JSON.stringify(controller_keymaps[2])); 185 | window.localStorage.setItem("gamepad_1", JSON.stringify(controller_padmaps[1])); 186 | window.localStorage.setItem("gamepad_2", JSON.stringify(controller_padmaps[2])); 187 | console.log("Input Config Saved!"); 188 | } catch(e) { 189 | console.log("Local Storage is probably unavailable! Input configuration will not persist."); 190 | } 191 | } 192 | 193 | function loadInputConfig() { 194 | try { 195 | var keyboard_1 = window.localStorage.getItem("keyboard_1"); 196 | if (keyboard_1) { controller_keymaps[1] = JSON.parse(keyboard_1); } 197 | var keyboard_2 = window.localStorage.getItem("keyboard_2"); 198 | if (keyboard_2) { controller_keymaps[2] = JSON.parse(keyboard_2); } 199 | var gamepad_1 = window.localStorage.getItem("gamepad_1"); 200 | if (gamepad_1) { controller_padmaps[1] = JSON.parse(gamepad_1); } 201 | var gamepad_2 = window.localStorage.getItem("gamepad_2"); 202 | if (gamepad_2) { controller_padmaps[2] = JSON.parse(gamepad_2); } 203 | console.log("Input Config Loaded!"); 204 | displayButtonMappings(); 205 | } catch(e) { 206 | console.log("Local Storage is probably unavailable! Input configuration will not persist."); 207 | } 208 | } 209 | 210 | KEY_A = 1 211 | KEY_B = 2 212 | KEY_SELECT = 4 213 | KEY_START = 8 214 | KEY_UP = 16 215 | KEY_DOWN = 32 216 | KEY_LEFT = 64 217 | KEY_RIGHT = 128 218 | 219 | BUTTON_MAPPING = { 220 | "button_a": KEY_A, 221 | "button_b": KEY_B, 222 | "button_ab": (KEY_A | KEY_B), 223 | "button_start": KEY_START, 224 | "button_select": KEY_SELECT 225 | } 226 | 227 | DIRECTION_MAPPING = { 228 | "up": KEY_UP, 229 | "down": KEY_DOWN, 230 | "left": KEY_LEFT, 231 | "right": KEY_RIGHT 232 | } 233 | 234 | function updateTouchKeys() { 235 | p1_keys = 0; 236 | // Iterate over the button and d-pad states and collect the key status in a byte 237 | // Note: only implemented for P1 at the moment 238 | for (let touch_identifier in active_touches) { 239 | active_touch = active_touches[touch_identifier]; 240 | if (BUTTON_MAPPING.hasOwnProperty(active_touch.button)) { 241 | p1_keys = p1_keys | BUTTON_MAPPING[active_touch.button]; 242 | } 243 | if (active_touch.dpad != null) { 244 | for (let direction of active_touch.directions) { 245 | if (DIRECTION_MAPPING.hasOwnProperty(direction)) { 246 | p1_keys = p1_keys | DIRECTION_MAPPING[direction]; 247 | } 248 | } 249 | } 250 | } 251 | // Because we allow multiple touch points to activate the same D-pad input, we might accidentally produce a directional combination 252 | // that should be disallowed on real hardware. Let's sanity check this and make sure we disallow U+D and L+R 253 | p1_up_left = p1_keys & (KEY_UP | KEY_LEFT); 254 | p1_down_right = p1_up_left << 1; 255 | p1_down_right_mask = p1_down_right ^ 0xFF; 256 | 257 | touch_keys[1] = p1_keys & p1_down_right_mask; 258 | } -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | // ========== Global Application State ========== 2 | 3 | let g_pending_frames = 0; 4 | let g_frames_since_last_fps_count = 0; 5 | let g_rendered_frames = []; 6 | 7 | let g_last_frame_sample_count = 44100 / 60; // Close-ish enough 8 | let g_audio_samples_buffered = 0; 9 | let g_new_frame_sample_threshold = 4096; // under which we request a new frame 10 | let g_audio_overrun_sample_threshold = 8192; // over which we *drop* samples 11 | 12 | let g_game_checksum = -1; 13 | 14 | let g_screen_buffers = []; 15 | let g_piano_roll_buffers = []; 16 | let g_next_free_buffer_index = 0; 17 | let g_last_rendered_buffer_index = 0; 18 | let g_total_buffers = 16; 19 | 20 | let g_frameskip = 0; 21 | let g_frame_delay = 0; 22 | 23 | let g_audio_confirmed_working = false; 24 | let g_profiling_results = {}; 25 | 26 | let g_trouble_detector = { 27 | successful_samples: 0, 28 | failed_samples: 0, 29 | frames_requested: 0, 30 | trouble_count: 0, 31 | got_better_count: 0, 32 | } 33 | 34 | let g_increase_frameskip_threshold = 0.01; // percent of missed samples 35 | let g_decrease_frameskip_headroom = 1.5 // percent of the time taken to render one frame 36 | 37 | let embed_autostart_url = null; 38 | 39 | // ========== Init which does not depend on DOM ======== 40 | 41 | for (let i = 0; i < g_total_buffers; i++) { 42 | // Allocate a good number of screen buffers 43 | g_screen_buffers[i] = new ArrayBuffer(256*240*4); 44 | g_piano_roll_buffers[i] = new ArrayBuffer(480*270*4); 45 | } 46 | 47 | // ========== Worker Setup and Utility ========== 48 | 49 | var worker = new Worker('emu_worker.js'); 50 | 51 | function rpc(task, args) { 52 | return new Promise((resolve, reject) => { 53 | const channel = new MessageChannel(); 54 | channel.port1.onmessage = ({data}) => { 55 | if (data.error) { 56 | reject(data.error); 57 | } else { 58 | resolve(data.result); 59 | } 60 | }; 61 | worker.postMessage({"type": "rpc", "func": task, "args": args}, [channel.port2]); 62 | }); 63 | } 64 | 65 | worker.onmessage = function(e) { 66 | if (e.data.type == "init") { 67 | onready(); 68 | } 69 | if (e.data.type == "deliverFrame") { 70 | if (e.data.panels.length > 0) { 71 | g_rendered_frames.push(e.data.panels); 72 | for (let panel of e.data.panels) { 73 | if (panel.id == "screen") { 74 | g_screen_buffers[g_last_rendered_buffer_index] = panel.image_buffer; 75 | } 76 | if (panel.id == "piano_roll_window") { 77 | g_piano_roll_buffers[g_last_rendered_buffer_index] = panel.image_buffer; 78 | } 79 | } 80 | g_last_rendered_buffer_index += 1; 81 | if (g_last_rendered_buffer_index >= g_total_buffers) { 82 | g_last_rendered_buffer_index = 0; 83 | } 84 | g_frames_since_last_fps_count += 1; 85 | } 86 | g_pending_frames -= 1; 87 | if (g_audio_samples_buffered < g_audio_overrun_sample_threshold) { 88 | g_nes_audio_node.port.postMessage({"type": "samples", "samples": e.data.audio_buffer}); 89 | g_audio_samples_buffered += e.data.audio_buffer.length; 90 | g_last_frame_sample_count = e.data.audio_buffer.length; 91 | } else { 92 | // Audio overrun, we're running too fast! Drop these samples on the floor and bail. 93 | // (This can happen in fastforward mode.) 94 | } 95 | if (g_rendered_frames.length > 3) { 96 | // Frame rendering running behing, dropping one frame 97 | g_rendered_frames.shift(); // and throw it away 98 | } 99 | } 100 | if (e.data.type == "reportPerformance") { 101 | g_profiling_results[e.data.event] = e.data.average_milliseconds; 102 | } 103 | } 104 | 105 | function render_profiling_results() { 106 | let results = ""; 107 | for (let event_name in g_profiling_results) { 108 | let time = g_profiling_results[event_name].toFixed(2); 109 | results += `${event_name}: ${time}\n` 110 | } 111 | var results_box = document.querySelector("#profiling-results"); 112 | if (results_box != null) { 113 | results_box.innerHTML = results; 114 | } 115 | } 116 | 117 | function automatic_frameskip() { 118 | // first off, do we have enough profiling data collected? 119 | if (g_trouble_detector.frames_requested >= 60) { 120 | let audio_fail_percent = g_trouble_detector.failed_samples / g_trouble_detector.successful_samples; 121 | if (g_frameskip < 2) { 122 | // if our audio context is running behind, let's try 123 | // rendering fewer frames to compensate 124 | if (audio_fail_percent > g_increase_frameskip_threshold) { 125 | g_trouble_detector.trouble_count += 1; 126 | g_trouble_detector.got_better_count = 0; 127 | console.log("Audio failure percentage: ", audio_fail_percent); 128 | console.log("Trouble count incremented to: ", g_trouble_detector.trouble_count); 129 | if (g_trouble_detector.trouble_count > 3) { 130 | // that's quite enough of that 131 | g_frameskip += 1; 132 | g_trouble_detector.trouble_count = 0; 133 | console.log("Frameskip increased to: ", g_frameskip); 134 | console.log("Trouble reset") 135 | } 136 | } else { 137 | // Slowly recover from brief trouble spikes 138 | // without taking action 139 | if (g_trouble_detector.trouble_count > 0) { 140 | g_trouble_detector.trouble_count -= 1; 141 | console.log("Trouble count relaxed to: ", g_trouble_detector.trouble_count); 142 | } 143 | } 144 | } 145 | if (g_frameskip > 0) { 146 | // Perform a bunch of sanity checks to see if it looks safe to 147 | // decrease frameskip. 148 | if (audio_fail_percent < g_increase_frameskip_threshold) { 149 | // how long would it take to render one frame right now? 150 | let frame_render_cost = g_profiling_results.render_all_panels; 151 | let cost_with_headroom = frame_render_cost * g_decrease_frameskip_headroom; 152 | // Would a full render reliably fit in our idle time? 153 | if (cost_with_headroom < g_profiling_results.idle) { 154 | console.log("Frame render costs: ", frame_render_cost); 155 | console.log("With headroom: ", cost_with_headroom); 156 | console.log("Idle time currently: ", g_profiling_results.idle); 157 | g_trouble_detector.got_better_count += 1; 158 | console.log("Recovery count increased to: ", g_trouble_detector.got_better_count); 159 | } 160 | if (cost_with_headroom > g_profiling_results.idle) { 161 | if (g_trouble_detector.got_better_count > 0) { 162 | g_trouble_detector.got_better_count -= 1; 163 | console.log("Recovery count decreased to: ", g_trouble_detector.got_better_count); 164 | } 165 | } 166 | if (g_trouble_detector.got_better_count >= 10) { 167 | g_frameskip -= 1; 168 | console.log("Performance recovered! Lowering frameskip by 1 to: "); 169 | g_trouble_detector.got_better_count = 0; 170 | } 171 | } 172 | } 173 | 174 | // now reset the counters for the next run 175 | g_trouble_detector.frames_requested = 0; 176 | g_trouble_detector.failed_samples = 0; 177 | g_trouble_detector.successful_samples = 0; 178 | } 179 | } 180 | 181 | // ========== Audio Setup ========== 182 | 183 | let g_audio_context = null; 184 | let g_nes_audio_node = null; 185 | 186 | async function init_audio_context() { 187 | g_audio_context = new AudioContext({ 188 | latencyHint: 'interactive', 189 | sampleRate: 44100, 190 | }); 191 | await g_audio_context.audioWorklet.addModule('audio_processor.js'); 192 | g_nes_audio_node = new AudioWorkletNode(g_audio_context, 'nes-audio-processor'); 193 | g_nes_audio_node.connect(g_audio_context.destination); 194 | g_nes_audio_node.port.onmessage = handle_audio_message; 195 | } 196 | 197 | function handle_audio_message(e) { 198 | if (e.data.type == "samplesPlayed") { 199 | g_audio_samples_buffered -= e.data.count; 200 | g_trouble_detector.successful_samples += e.data.count; 201 | if (!g_audio_confirmed_working && g_trouble_detector.successful_samples > 44100) { 202 | let audio_context_banner = document.querySelector("#audio-context-warning"); 203 | if (audio_context_banner != null) { 204 | audio_context_banner.classList.remove("active"); 205 | } 206 | g_audio_confirmed_working = true; 207 | } 208 | } 209 | if (e.data.type == "audioUnderrun") { 210 | g_trouble_detector.failed_samples += e.data.count; 211 | } 212 | } 213 | 214 | // ========== Main ========== 215 | 216 | async function onready() { 217 | // Initialize audio context, this will also begin audio playback 218 | await init_audio_context(); 219 | 220 | // Initialize everything else 221 | init_ui_events(); 222 | initializeButtonMappings(); 223 | 224 | // Kick off the events that will drive emulation 225 | requestAnimationFrame(renderLoop); 226 | // run the scheduler as often as we can. It will frequently decide not to schedule things, this is fine. 227 | //window.setInterval(schedule_frames_at_top_speed, 1); 228 | window.setTimeout(sync_to_audio, 1); 229 | window.setInterval(compute_fps, 1000); 230 | window.setInterval(render_profiling_results, 1000); 231 | window.setInterval(automatic_frameskip, 1000); 232 | window.setInterval(save_sram_periodically, 10000); 233 | 234 | // Attempt to load a cartridge by URL, if one is provided 235 | let params = new URLSearchParams(location.search.slice(1)); 236 | if (params.get("cartridge")) { 237 | load_cartridge_by_url(params.get("cartridge")); 238 | display_banner(params.get("cartridge")); 239 | } 240 | if (params.get("tab")) { 241 | switchToTab(params.get("tab")); 242 | } 243 | if (embed_autostart_url != null) { 244 | load_cartridge_by_url(embed_autostart_url); 245 | } 246 | } 247 | 248 | function init_ui_events() { 249 | // Setup UI events 250 | document.getElementById('file-loader').addEventListener('change', load_cartridge_by_file, false); 251 | 252 | var buttons = document.querySelectorAll("#main_menu button"); 253 | buttons.forEach(function(button) { 254 | button.addEventListener("click", clickTab); 255 | }); 256 | 257 | window.addEventListener("click", function() { 258 | // Needed to play audio in certain browsers, notably Chrome, which restricts playback until user action. 259 | g_audio_context.resume(); 260 | }); 261 | 262 | document.querySelector("#playfield").addEventListener("dblclick", enterFullscreen); 263 | document.addEventListener("fullscreenchange", handleFullscreenSwitch); 264 | document.addEventListener("webkitfullscreenchange", handleFullscreenSwitch); 265 | document.addEventListener("mozfullscreenchange", handleFullscreenSwitch); 266 | document.addEventListener("MSFullscreenChange", handleFullscreenSwitch); 267 | 268 | if (document.querySelector("#piano_roll_window")) { 269 | document.querySelector("#piano_roll_window").addEventListener("click", handle_piano_roll_window_click); 270 | } 271 | 272 | register_touch_button("#button_a"); 273 | register_touch_button("#button_b"); 274 | register_touch_button("#button_ab"); 275 | register_touch_button("#button_select"); 276 | register_touch_button("#button_start"); 277 | register_d_pad("#d_pad"); 278 | initialize_touch("#playfield"); 279 | } 280 | 281 | // ========== Cartridge Management ========== 282 | 283 | async function load_cartridge(cart_data) { 284 | console.log("Attempting to load cart with length: ", cart_data.length); 285 | await rpc("load_cartridge", [cart_data]); 286 | console.log("Cart data loaded?"); 287 | 288 | g_game_checksum = crc32(cart_data); 289 | load_sram(); 290 | let power_light = document.querySelector("#power_light #led"); 291 | power_light.classList.add("powered"); 292 | } 293 | 294 | function load_cartridge_by_file(e) { 295 | if (g_game_checksum != -1) { 296 | save_sram(); 297 | } 298 | var file = e.target.files[0]; 299 | if (!file) { 300 | return; 301 | } 302 | var reader = new FileReader(); 303 | reader.onload = function(e) { 304 | cart_data = new Uint8Array(e.target.result); 305 | load_cartridge(cart_data); 306 | hide_banners(); 307 | } 308 | reader.readAsArrayBuffer(file); 309 | 310 | // we're done with the file loader; unfocus it, so keystrokes are captured 311 | // by the game instead 312 | this.blur(); 313 | } 314 | 315 | function load_cartridge_by_url(url) { 316 | if (g_game_checksum != -1) { 317 | save_sram(); 318 | } 319 | var rawFile = new XMLHttpRequest(); 320 | rawFile.overrideMimeType("application/octet-stream"); 321 | rawFile.open("GET", url, true); 322 | rawFile.responseType = "arraybuffer"; 323 | rawFile.onreadystatechange = function() { 324 | if (rawFile.readyState === 4 && rawFile.status == "200") { 325 | console.log(rawFile.responseType); 326 | cart_data = new Uint8Array(rawFile.response); 327 | load_cartridge(cart_data); 328 | } 329 | } 330 | rawFile.send(null); 331 | } 332 | 333 | // ========== Emulator Runtime ========== 334 | 335 | function schedule_frames_at_top_speed() { 336 | if (g_pending_frames < 10) { 337 | requestFrame(); 338 | } 339 | window.setTimeout(schedule_frames_at_top_speed, 1); 340 | } 341 | 342 | function sync_to_audio() { 343 | // On mobile browsers, sometimes window.setTimeout isn't called often enough to reliably 344 | // queue up single frames; try to catch up by up to 4 of them at once. 345 | for (i = 0; i < 4; i++) { 346 | // Never, for any reason, request more than 10 frames at a time. This prevents 347 | // the message queue from getting flooded if the emulator can't keep up. 348 | if (g_pending_frames < 10) { 349 | const actual_samples = g_audio_samples_buffered; 350 | const pending_samples = g_pending_frames * g_last_frame_sample_count; 351 | if (actual_samples + pending_samples < g_new_frame_sample_threshold) { 352 | requestFrame(); 353 | } 354 | } 355 | } 356 | window.setTimeout(sync_to_audio, 1); 357 | } 358 | 359 | function requestFrame() { 360 | updateTouchKeys(); 361 | g_trouble_detector.frames_requested += 1; 362 | let active_tab = document.querySelector(".tab_content.active").id; 363 | if (g_frame_delay > 0) { 364 | // frameskip: advance the emulation, but do not populate or render 365 | // any panels this time around 366 | worker.postMessage({"type": "requestFrame", "p1": keys[1] | touch_keys[1], "p2": keys[2] | touch_keys[2], "panels": []}); 367 | g_frame_delay -= 1; 368 | g_pending_frames += 1; 369 | return; 370 | } 371 | if (active_tab == "jam") { 372 | worker.postMessage( 373 | {"type": "requestFrame", "p1": keys[1] | touch_keys[1], "p2": keys[2] | touch_keys[2], "panels": [ 374 | { 375 | "id": "screen", 376 | "target_element": "#jam_pixels", 377 | "dest_buffer": g_screen_buffers[g_next_free_buffer_index], 378 | }, 379 | { 380 | "id": "piano_roll_window", 381 | "target_element": "#piano_roll_window", 382 | "dest_buffer": g_piano_roll_buffers[g_next_free_buffer_index], 383 | }, 384 | ]}, 385 | [ 386 | g_screen_buffers[g_next_free_buffer_index], 387 | g_piano_roll_buffers[g_next_free_buffer_index] 388 | ] 389 | ); 390 | } else { 391 | worker.postMessage( 392 | {"type": "requestFrame", "p1": keys[1] | touch_keys[1], "p2": keys[2] | touch_keys[2], "panels": [ 393 | { 394 | "id": "screen", 395 | "target_element": "#pixels", 396 | "dest_buffer": g_screen_buffers[g_next_free_buffer_index], 397 | } 398 | ]}, 399 | [g_screen_buffers[g_next_free_buffer_index]] 400 | ); 401 | } 402 | g_pending_frames += 1; 403 | g_next_free_buffer_index += 1; 404 | if (g_next_free_buffer_index >= g_total_buffers) { 405 | g_next_free_buffer_index = 0; 406 | } 407 | g_frame_delay = g_frameskip; 408 | } 409 | 410 | function renderLoop() { 411 | if (g_rendered_frames.length > 0) { 412 | for (let panel of g_rendered_frames.shift()) { 413 | const typed_pixels = new Uint8ClampedArray(panel.image_buffer); 414 | // TODO: don't hard-code the panel size here 415 | let rendered_frame = new ImageData(typed_pixels, panel.width, panel.height); 416 | canvas = document.querySelector(panel.target_element); 417 | ctx = canvas.getContext("2d", { alpha: false }); 418 | ctx.putImageData(rendered_frame, 0, 0); 419 | ctx.imageSmoothingEnabled = false; 420 | } 421 | } 422 | 423 | requestAnimationFrame(renderLoop); 424 | } 425 | 426 | // ========== SRAM Management ========== 427 | 428 | // CRC32 checksum generating functions, yanked from this handy stackoverflow post and modified to work with arrays: 429 | // https://stackoverflow.com/questions/18638900/javascript-crc32 430 | // Used to identify .nes files semi-uniquely, for the purpose of saving SRAM 431 | var makeCRCTable = function(){ 432 | var c; 433 | var crcTable = []; 434 | for(var n =0; n < 256; n++){ 435 | c = n; 436 | for(var k =0; k < 8; k++){ 437 | c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)); 438 | } 439 | crcTable[n] = c; 440 | } 441 | return crcTable; 442 | } 443 | 444 | var crc32 = function(byte_array) { 445 | var crcTable = window.crcTable || (window.crcTable = makeCRCTable()); 446 | var crc = 0 ^ (-1); 447 | 448 | for (var i = 0; i < byte_array.length; i++ ) { 449 | crc = (crc >>> 8) ^ crcTable[(crc ^ byte_array[i]) & 0xFF]; 450 | } 451 | 452 | return (crc ^ (-1)) >>> 0; 453 | }; 454 | 455 | async function load_sram() { 456 | if (await rpc("has_sram")) { 457 | try { 458 | var sram_str = window.localStorage.getItem(g_game_checksum); 459 | if (sram_str) { 460 | var sram = JSON.parse(sram_str); 461 | await rpc("set_sram", [sram]); 462 | console.log("SRAM Loaded!", g_game_checksum); 463 | } 464 | } catch(e) { 465 | console.log("Local Storage is probably unavailable! SRAM saving and loading will not work."); 466 | } 467 | } 468 | } 469 | 470 | async function save_sram() { 471 | if (await rpc("has_sram")) { 472 | try { 473 | var sram_uint8 = await rpc("get_sram", [sram]); 474 | // Make it a normal array 475 | var sram = []; 476 | for (var i = 0; i < sram_uint8.length; i++) { 477 | sram[i] = sram_uint8[i]; 478 | } 479 | window.localStorage.setItem(g_game_checksum, JSON.stringify(sram)); 480 | console.log("SRAM Saved!", g_game_checksum); 481 | } catch(e) { 482 | console.log("Local Storage is probably unavailable! SRAM saving and loading will not work."); 483 | } 484 | } 485 | } 486 | 487 | function save_sram_periodically() { 488 | save_sram(); 489 | } 490 | 491 | // ========== User Interface ========== 492 | 493 | // This runs *around* once per second, ish. It's fine. 494 | function compute_fps() { 495 | let counter_element = document.querySelector("#fps-counter"); 496 | if (counter_element != null) { 497 | counter_element.innerText = "FPS: " + g_frames_since_last_fps_count; 498 | } 499 | g_frames_since_last_fps_count = 0; 500 | } 501 | 502 | function clearTabs() { 503 | var buttons = document.querySelectorAll("#main_menu button"); 504 | buttons.forEach(function(button) { 505 | button.classList.remove("active"); 506 | }); 507 | 508 | var tabs = document.querySelectorAll("div.tab_content"); 509 | tabs.forEach(function(tab) { 510 | tab.classList.remove("active"); 511 | }); 512 | } 513 | 514 | function switchToTab(tab_name) { 515 | tab_elements = document.getElementsByName(tab_name); 516 | if (tab_elements.length == 1) { 517 | clearTabs(); 518 | tab_elements[0].classList.add("active"); 519 | content_element = document.getElementById(tab_name); 520 | content_element.classList.add("active"); 521 | } 522 | } 523 | 524 | function clickTab() { 525 | let tabName = this.getAttribute("name"); 526 | if (tabName == "fullscreen") { 527 | switchToTab("playfield"); 528 | enterFullscreen(); 529 | } else { 530 | switchToTab(tabName); 531 | } 532 | } 533 | 534 | function enterFullscreen() { 535 | var viewport = document.querySelector("#playfield"); 536 | if (viewport.requestFullscreen) { 537 | viewport.requestFullscreen(); 538 | } else if (viewport.mozRequestFullScreen) { 539 | viewport.mozRequestFullScreen(); 540 | } else if (viewport.webkitRequestFullscreen) { 541 | viewport.webkitRequestFullscreen(); 542 | } 543 | } 544 | 545 | function handleFullscreenSwitch() { 546 | if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) { 547 | console.log("Entering fullscreen..."); 548 | // Entering fullscreen 549 | var viewport = document.querySelector("#playfield"); 550 | viewport.classList.add("fullscreen"); 551 | viewport.classList.remove("horizontal"); 552 | viewport.classList.remove("vertical"); 553 | if (is_touch_detected) { 554 | viewport.classList.add("touchscreen"); 555 | } else { 556 | viewport.classList.remove("touchscreen"); 557 | } 558 | 559 | setTimeout(function() { 560 | var viewport = document.querySelector("#playfield"); 561 | 562 | var viewport_width = viewport.clientWidth; 563 | var viewport_height = viewport.clientHeight; 564 | 565 | var canvas_container = document.querySelector("#playfield div.canvas_container"); 566 | if ((viewport_width / 256) * 240 > viewport_height) { 567 | var target_height = viewport_height; 568 | var target_width = target_height / 240 * 256; 569 | canvas_container.style.width = target_width + "px"; 570 | canvas_container.style.height = target_height + "px"; 571 | viewport.classList.add("horizontal"); 572 | } else { 573 | var target_width = viewport_width; 574 | var target_height = target_width / 256 * 240; 575 | canvas_container.style.width = target_width + "px"; 576 | canvas_container.style.height = target_height + "px"; 577 | viewport.classList.add("vertical"); 578 | } 579 | }, 100); 580 | } else { 581 | // Exiting fullscreen 582 | console.log("Exiting fullscreen..."); 583 | var viewport = document.querySelector("#playfield"); 584 | var canvas_container = document.querySelector("#playfield div.canvas_container"); 585 | viewport.classList.remove("fullscreen"); 586 | canvas_container.style.width = ""; 587 | canvas_container.style.height = ""; 588 | } 589 | } 590 | 591 | function hide_banners() { 592 | banner_elements = document.querySelectorAll(".banner"); 593 | banner_elements.forEach(function(banner) { 594 | banner.classList.remove("active"); 595 | }); 596 | } 597 | 598 | function display_banner(cartridge_name) { 599 | hide_banners(); 600 | banner_elements = document.getElementsByName(cartridge_name); 601 | if (banner_elements.length == 1) { 602 | banner_elements[0].classList.add("active"); 603 | } 604 | } 605 | 606 | function handle_piano_roll_window_click(e) { 607 | rpc("handle_piano_roll_window_click", [e.offsetX / 2, e.offsetY / 2]).then(); 608 | } -------------------------------------------------------------------------------- /public/patreon-logo-48px-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/patreon-logo-48px-black.png -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | width: 100%; 3 | margin: 0px; 4 | padding: 0px; 5 | font-family: sans-serif; 6 | } 7 | 8 | html { 9 | background-color: #080808; 10 | height: 100%; 11 | } 12 | 13 | body { 14 | background-color: #030303; 15 | color: #FDF0F1; 16 | box-shadow: 0 0 16px #040404; 17 | max-width: 1200px; 18 | min-width: 800px; 19 | height: 100%; 20 | margin-left: auto; 21 | margin-right: auto; 22 | position: relative; 23 | } 24 | 25 | #header { 26 | position: relative; 27 | padding-left: 16px; 28 | padding-right: 16px; 29 | padding-top: 31px; 30 | } 31 | 32 | #header h1 { 33 | color: #666; 34 | font-size: 25px; 35 | margin: 0px; 36 | padding: 0px; 37 | line-height: 25px; 38 | flex-grow: 0; 39 | } 40 | 41 | #navbar { 42 | height: 64px; 43 | display: flex; 44 | flex-direction: row; 45 | position: relative; 46 | background-color: #040404; 47 | margin-top: 0px; 48 | border-bottom: 2px solid #222; 49 | background-image: url("wavy_grid_gradient.png"); 50 | } 51 | 52 | #main_menu { 53 | display: flex; 54 | flex-direction: row; 55 | width: 100%; 56 | margin: 0px; 57 | padding: 0px; 58 | flex-grow: 1; 59 | } 60 | 61 | #credits { 62 | height: 48px; 63 | padding-top: 8px; 64 | font-size: 0.6em; 65 | width: 450px; 66 | text-align: right; 67 | padding-right: 12px; 68 | font-weight: bold; 69 | color: #444; 70 | } 71 | 72 | #credits a { 73 | color: #444; 74 | text-decoration: none; 75 | } 76 | 77 | #credits a:hover { 78 | color: #666; 79 | } 80 | 81 | #credits img { 82 | display: inline; 83 | margin-left: 4px; 84 | margin-right: 4px; 85 | } 86 | 87 | #main_menu li { 88 | padding-top: 16px; 89 | height: 32px; 90 | display: block; 91 | margin: 6px; 92 | text-align: right; 93 | text-transform: uppercase; 94 | font-weight: bold; 95 | } 96 | 97 | #main_menu li button, #main_menu li label { 98 | display: block; 99 | padding-top: 10px; 100 | padding-left: 15px; 101 | padding-right: 5px; 102 | padding-bottom: 5px; 103 | font-size: 12px; 104 | font-weight: bold; 105 | border-top: none; 106 | border-left: none; 107 | border-right: 3px solid #181818; 108 | border-bottom: 3px solid #111; 109 | background-color: #222; 110 | color: #888; 111 | text-decoration: none; 112 | text-transform: uppercase; 113 | cursor: pointer; 114 | } 115 | 116 | #main_menu li button:hover, #main_menu li label:hover { 117 | background-color: #333; 118 | } 119 | 120 | #main_menu li button.active { 121 | background-color: #181818; 122 | border-right: 1px solid #0C0C0C; 123 | border-bottom: 1px solid #080808; 124 | color: #666; 125 | /* Transparent top-left, for offset purposes */ 126 | border-top: 2px solid rgba(0, 0, 0, 0); 127 | border-left: 2px solid rgba(0, 0, 0, 0); 128 | } 129 | 130 | #main_menu li button.active:hover { 131 | background-color: #222; 132 | } 133 | 134 | #main_menu #power_light { 135 | display: flex; 136 | align-items: center; 137 | border: none; 138 | box-shadow: none; 139 | } 140 | 141 | #main_menu #power_light #led { 142 | width: 10px; 143 | height: 10px; 144 | margin-left: 10px; 145 | border: 1px solid #1B1917; 146 | background-color: #353233; 147 | } 148 | 149 | #main_menu #power_light #led.powered { 150 | background-color: #84B; 151 | box-shadow: 0px 0px 4px #629; 152 | } 153 | 154 | #content_area { 155 | position: relative; 156 | height: calc(100% - 66px); 157 | overflow: auto; 158 | } 159 | 160 | .tab_content { 161 | display: none; 162 | color: #dddddd; 163 | } 164 | 165 | .tab_content.active { 166 | display: block; 167 | } 168 | 169 | #playfield.active { 170 | display: flex; 171 | height: 100%; 172 | flex-direction: column; 173 | justify-content: top; 174 | position: relative; 175 | } 176 | 177 | #playfield.fullscreen { 178 | width: 100%; 179 | height: 100%; 180 | padding-top: 0px; 181 | background-color: black; 182 | } 183 | 184 | #playfield div.canvas_container { 185 | position: relative; 186 | width: 768px; 187 | height: 720px; 188 | margin-left: auto; 189 | margin-right: auto; 190 | overflow: hidden; 191 | } 192 | 193 | #jam.active { 194 | display: flex; 195 | height: 100%; 196 | flex-direction: column; 197 | justify-content: center; 198 | } 199 | 200 | #jam div.canvas_container { 201 | position: relative; 202 | overflow: hidden; 203 | } 204 | 205 | #jam div.jam_pixels_container { 206 | width: 256px; 207 | height: 240px; 208 | overflow: hidden; 209 | } 210 | 211 | #jam div.jam_apu_container { 212 | width: 240px; 213 | height: 500px; 214 | overflow: hidden; 215 | } 216 | 217 | #jam div.piano_roll_container { 218 | width: 960px; 219 | height: 540px; 220 | overflow: hidden; 221 | } 222 | 223 | div.canvas_container img.overlay { 224 | display: block; 225 | position: absolute; 226 | top: 0px; 227 | left: 0px; 228 | width: 100%; 229 | height: 100%; 230 | 231 | image-rendering: -moz-crisp-edges; 232 | image-rendering: -o-crisp-edges; 233 | image-rendering: -webkit-optimize-contrast; 234 | -ms-interpolation-mode: nearest-neighbor; 235 | image-rendering: pixelated; 236 | } 237 | 238 | canvas { 239 | width: 100%; 240 | height: 100%; 241 | 242 | image-rendering: -moz-crisp-edges; 243 | image-rendering: -o-crisp-edges; 244 | image-rendering: -webkit-optimize-contrast; 245 | -ms-interpolation-mode: nearest-neighbor; 246 | image-rendering: pixelated; 247 | } 248 | 249 | input#file-loader { 250 | width: 0.1px; 251 | height: 0.1px; 252 | opacity: 0; 253 | overflow: hidden; 254 | position: absolute; 255 | z-index: -1; 256 | } 257 | 258 | .flex_columns { 259 | display: flex; 260 | flex-direction: row; 261 | } 262 | 263 | .flex_rows { 264 | display: flex; 265 | flex-direction: column; 266 | } 267 | 268 | #jam .flex_columns { 269 | justify-content: center; 270 | } 271 | 272 | #jam .flex_rows { 273 | justify-content: center; 274 | } 275 | 276 | #jam .flex_columns div { 277 | margin: 8px; 278 | } 279 | 280 | #configure_input .flex_columns div { 281 | margin: auto; 282 | } 283 | 284 | #configure_input { 285 | padding-left: 10px; 286 | padding-right: 10px; 287 | } 288 | 289 | #configure_input td { 290 | text-align: right; 291 | padding: 3px; 292 | padding-left: 10px; 293 | } 294 | 295 | #configure_input button { 296 | width: 300px; 297 | border: none; 298 | border-radius: 9px; 299 | padding: 2px; 300 | background-color: #727272; 301 | color: #77221A; 302 | font-weight: bold; 303 | text-transform: uppercase; 304 | } 305 | 306 | .banner { 307 | display: none; 308 | } 309 | 310 | .banner.active { 311 | display: block; 312 | height: 30px; 313 | line-height: 30px; 314 | text-align: center; 315 | background-color: #111; 316 | border-bottom: 2px solid #222; 317 | color: #555; 318 | font-size: 11px; 319 | font-weight: bold; 320 | } 321 | 322 | 323 | .banner a { 324 | color: #777; 325 | text-decoration: none; 326 | } 327 | 328 | .banner a:hover { 329 | color: #999; 330 | } 331 | 332 | .banner.active.error { 333 | background-color: #311; 334 | border-bottom: 2px solid #622; 335 | color: #755; 336 | } 337 | 338 | .debug-box { 339 | display: none; 340 | } 341 | 342 | .debug-box.active { 343 | display: block; 344 | text-align: center; 345 | background-color: #441; 346 | border-bottom: 2px solid #552; 347 | color: #885; 348 | font-size: 11px; 349 | font-weight: bold; 350 | } 351 | 352 | .debug-box pre { 353 | text-align: left; 354 | width: 200px; 355 | margin-left: 500px; 356 | } 357 | 358 | /* Simple shapes for drawing the touch controls. 359 | Later we should either spruce these up or replace the 360 | CSS art with better looking images 361 | */ 362 | .circle { 363 | background-color: #FF0000; 364 | border: 5px solid #551111; 365 | border-radius: 50%; 366 | width: 100px; 367 | height: 100px; 368 | } 369 | 370 | .circle.small { 371 | height: 50px; 372 | width: 50px; 373 | background-color: #EE2200; 374 | } 375 | 376 | .circle.active { 377 | background-color: #FF8888; 378 | } 379 | 380 | .rectangle { 381 | background-color: #555555; 382 | border: 5px solid #222222; 383 | border-radius: 10%; 384 | width: 80px; 385 | height: 30px; 386 | } 387 | 388 | .rectangle.active { 389 | background-color: #cccccc; 390 | } 391 | 392 | .d-pad-button { 393 | background-color: #222222; 394 | border: 5px solid #111111; 395 | border-radius: 10%; 396 | width: 60px; 397 | height: 60px; 398 | } 399 | 400 | .d-pad-button.active { 401 | background-color: #666666; 402 | } 403 | 404 | /* Containers to PUT the button bits in */ 405 | /* TODO: guard these on touch being detected */ 406 | 407 | .round-button-area { 408 | display: none; 409 | position: absolute; 410 | bottom: 40px; 411 | right: 40px; 412 | width: 210px; 413 | height: 150px; 414 | } 415 | 416 | .fullscreen.touchscreen .round-button-area { 417 | display: block; 418 | } 419 | 420 | .pill-button-area { 421 | display: none; 422 | position: absolute; 423 | width: 200px; 424 | height: 30px; 425 | } 426 | 427 | .horizontal .pill-button-area { 428 | top: 40px; 429 | left: 40px; 430 | } 431 | 432 | .vertical .pill-button-area { 433 | bottom: 40px; 434 | left: 50%; 435 | margin-left: -100px; 436 | } 437 | 438 | .fullscreen.touchscreen .pill-button-area { 439 | display: block; 440 | } 441 | 442 | .dpad-area { 443 | display: none; 444 | position: absolute; 445 | width: 180px; 446 | height: 180px; 447 | left: 40px; 448 | bottom: 40px; 449 | } 450 | 451 | .fullscreen.touchscreen .dpad-area { 452 | display: block; 453 | } -------------------------------------------------------------------------------- /public/touch.js: -------------------------------------------------------------------------------- 1 | is_touch_detected = false; 2 | touch_button_elements = []; 3 | dpad_elements = []; 4 | active_touches = {}; 5 | 6 | stickiness_radius = 5; // pixels, ish 7 | 8 | dpad_inner_deadzone_percent = 0.25; 9 | dpad_extra_radius_percent = 0.10; 10 | dpad_sticky_degrees = 5; 11 | dpad_cardinal_priority_degrees = 10; 12 | 13 | function register_touch_button(querystring) { 14 | var button_element = document.querySelector(querystring); 15 | if (button_element) { 16 | touch_button_elements.push(button_element); 17 | } else { 18 | console.log("Could not find element ", querystring); 19 | } 20 | } 21 | 22 | function register_d_pad(querystring) { 23 | var dpad_element = document.querySelector(querystring); 24 | if (dpad_element) { 25 | dpad_elements.push(dpad_element); 26 | } else { 27 | console.log("Could not find element ", querystring); 28 | } 29 | } 30 | 31 | // Relative to the viewport 32 | function element_centerpoint(element) { 33 | let rect = element.getBoundingClientRect(); 34 | let cx = rect.left + (rect.width / 2); 35 | let cy = rect.top + (rect.height / 2); 36 | return [cx, cy] 37 | } 38 | 39 | function element_radius(element) { 40 | let rect = element.getBoundingClientRect(); 41 | let longest_side = Math.max(rect.width, rect.height) 42 | return longest_side / 2; 43 | } 44 | 45 | function angle_to_element(touch, element) { 46 | let [cx, cy] = element_centerpoint(element); 47 | let tx = touch.clientX; 48 | let ty = touch.clientY; 49 | let dx = tx - cx; 50 | let dy = ty - cy; 51 | let angle_radians = Math.atan2(dy * -1, dx); 52 | let angle_degrees = angle_radians * 180 / Math.PI; 53 | if (angle_degrees < 0) { 54 | angle_degrees += 360.0; 55 | } 56 | return angle_degrees; 57 | } 58 | 59 | function is_inside_button(touch, element) { 60 | let [cx, cy] = element_centerpoint(element); 61 | let radius = element_radius(element); 62 | let tx = touch.clientX; 63 | let ty = touch.clientY; 64 | let dx = tx - cx; 65 | let dy = ty - cy; 66 | let distance_squared = (dx * dx) + (dy * dy) 67 | let radius_squared = radius * radius; 68 | return distance_squared < radius_squared; 69 | } 70 | 71 | function is_stuck_to_button(touch, element) { 72 | // Very similar to is_inside_element, but with the stickiness radius applied 73 | let [cx, cy] = element_centerpoint(element); 74 | let radius = element_radius(element) + stickiness_radius; 75 | let tx = touch.clientX; 76 | let ty = touch.clientY; 77 | let dx = tx - cx; 78 | let dy = ty - cy; 79 | let distance_squared = (dx * dx) + (dy * dy) 80 | let radius_squared = radius * radius; 81 | return distance_squared < radius_squared; 82 | } 83 | 84 | function is_inside_dpad(touch, element) { 85 | let [cx, cy] = element_centerpoint(element); 86 | let base_radius = element_radius(element) + stickiness_radius; 87 | let outer_radius = base_radius + (base_radius * dpad_extra_radius_percent); 88 | let deadzone_radius = base_radius * dpad_inner_deadzone_percent; 89 | let tx = touch.clientX; 90 | let ty = touch.clientY; 91 | let dx = tx - cx; 92 | let dy = ty - cy; 93 | let distance_squared = (dx * dx) + (dy * dy) 94 | let outer_radius_squared = outer_radius * outer_radius; 95 | let deadzone_radius_squared = deadzone_radius * deadzone_radius; 96 | return ((distance_squared > deadzone_radius_squared) && (distance_squared < outer_radius_squared)); 97 | } 98 | 99 | function is_sticky_dpad(angle) { 100 | // (ordered counter-clockwise, starting with 0 degrees "east") 101 | 102 | // East is split along the X axis 103 | if (angle < (22.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 104 | return false; 105 | } 106 | if (angle > (337.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees)) { 107 | return false; 108 | } 109 | 110 | // North East 111 | if (angle > (22.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (67.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 112 | return false; 113 | } 114 | 115 | // North 116 | if (angle > (67.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (112.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 117 | return false; 118 | } 119 | 120 | // North West 121 | if (angle > (112.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (157.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 122 | return false; 123 | } 124 | 125 | // West 126 | if (angle > (157.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (202.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 127 | return false; 128 | } 129 | 130 | // South West 131 | if (angle > (202.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (247.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 132 | return false; 133 | } 134 | 135 | // South 136 | if (angle > (247.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (292.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 137 | return false; 138 | } 139 | 140 | // South East 141 | if (angle > (292.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (337.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 142 | return false; 143 | } 144 | 145 | return true; 146 | } 147 | 148 | function dpad_directions(touch, element, old_directions) { 149 | let has_previous_directions = old_directions.length > 0; 150 | let dpad_angle = angle_to_element(touch, element); 151 | if (has_previous_directions && is_sticky_dpad(dpad_angle)) { 152 | return old_directions; 153 | } 154 | 155 | // East is split along the X axis 156 | if (dpad_angle < (22.5 + dpad_cardinal_priority_degrees)) { 157 | return ["right"]; 158 | } 159 | if (dpad_angle > (337.5 - dpad_cardinal_priority_degrees)) { 160 | return ["right"]; 161 | } 162 | 163 | // North East 164 | if (dpad_angle > (22.5 + dpad_cardinal_priority_degrees) && dpad_angle < (67.5 - dpad_cardinal_priority_degrees)) { 165 | return ["up", "right"]; 166 | } 167 | 168 | // North 169 | if (dpad_angle > (67.5 - dpad_cardinal_priority_degrees) && dpad_angle < (112.5 + dpad_cardinal_priority_degrees)) { 170 | return ["up"]; 171 | } 172 | 173 | // North West 174 | if (dpad_angle > (112.5 + dpad_cardinal_priority_degrees) && dpad_angle < (157.5 - dpad_cardinal_priority_degrees)) { 175 | return ["up", "left"]; 176 | } 177 | 178 | // West 179 | if (dpad_angle > (157.5 - dpad_cardinal_priority_degrees) && dpad_angle < (202.5 + dpad_cardinal_priority_degrees)) { 180 | return ["left"]; 181 | } 182 | 183 | // South West 184 | if (dpad_angle > (202.5 + dpad_cardinal_priority_degrees) && dpad_angle < (247.5 - dpad_cardinal_priority_degrees)) { 185 | return ["down", "left"]; 186 | } 187 | 188 | // South 189 | if (dpad_angle > (247.5 - dpad_cardinal_priority_degrees) && dpad_angle < (292.5 + dpad_cardinal_priority_degrees)) { 190 | return ["down"]; 191 | } 192 | 193 | // South East 194 | if (dpad_angle > (292.5 + dpad_cardinal_priority_degrees) && dpad_angle < (337.5 - dpad_cardinal_priority_degrees)) { 195 | return ["down", "right"]; 196 | } 197 | 198 | // We really *shouldn't* get here, but... in case floating point gnargles, return old directions, 199 | // just so we don't have glitchy dropped inputs on boundaries 200 | return old_directions; 201 | 202 | } 203 | 204 | function initialize_touch(querystring) { 205 | var touch_root_element = document.querySelector(querystring); 206 | touch_root_element.addEventListener('touchstart', handleTouchEvent) 207 | touch_root_element.addEventListener('touchend', handleTouchEvent) 208 | touch_root_element.addEventListener('touchmove', handleTouchEvent) 209 | touch_root_element.addEventListener('touchcancel', handleTouchEvent) 210 | } 211 | 212 | function handleTouches(touches, event) { 213 | // First, prune any touches that got released, and add (empty) touches for 214 | // new identifiers 215 | pruned_touches = {} 216 | for (let touch of touches) { 217 | if (active_touches.hasOwnProperty(touch.identifier)) { 218 | // If this touch is previously tracked, copy that info 219 | pruned_touches[touch.identifier] = active_touches[touch.identifier]; 220 | } else { 221 | // Otherwise this is a new touch; initialize it accordingly 222 | pruned_touches[touch.identifier] = {"button": null, "dpad": null, "directions": []}; 223 | } 224 | 225 | // For buttons, first check for and handle the sticky radius. If we're still inside this, 226 | // do not attempt to switch to a new button 227 | if (pruned_touches[touch.identifier].button != null) { 228 | let button_element = document.getElementById(pruned_touches[touch.identifier].button); 229 | if (!(is_stuck_to_button(touch, button_element))) { 230 | pruned_touches[touch.identifier].button = null; 231 | } 232 | } 233 | 234 | // If we have no active button for this touch, check all buttons and see if we can't find 235 | // a new one. If so, activate it 236 | if (pruned_touches[touch.identifier].button == null) { 237 | for (let button_element of touch_button_elements) { 238 | if (is_inside_button(touch, button_element)) { 239 | pruned_touches[touch.identifier].button = button_element.id; 240 | event.preventDefault(); 241 | } 242 | } 243 | } 244 | 245 | // D-pads are slightly more complicated. First, if we have an active D-Pad but we've left 246 | // its area, deactivate it 247 | if (pruned_touches[touch.identifier].dpad != null) { 248 | let dpad_element = document.getElementById(pruned_touches[touch.identifier].dpad); 249 | if (!(is_inside_dpad(touch, dpad_element))) { 250 | pruned_touches[touch.identifier].dpad = null; 251 | } 252 | } 253 | 254 | // If we do *not* have an active D-pad, check to see if we are inside one and, if so, 255 | // activate it with no direction 256 | if (pruned_touches[touch.identifier].dpad == null) { 257 | for (let dpad_element of dpad_elements) { 258 | if (is_inside_dpad(touch, dpad_element)) { 259 | pruned_touches[touch.identifier].dpad = dpad_element.id; 260 | event.preventDefault(); 261 | } 262 | } 263 | } 264 | 265 | // Finally, with our active D-pad, collect and set the directions 266 | if (pruned_touches[touch.identifier].dpad != null) { 267 | let dpad_element = document.getElementById(pruned_touches[touch.identifier].dpad); 268 | let old_directions = pruned_touches[touch.identifier].directions; 269 | pruned_touches[touch.identifier].directions = dpad_directions(touch, dpad_element, old_directions); 270 | event.preventDefault(); 271 | } 272 | 273 | } 274 | // At this point any released touch points should not have been copied to the list, 275 | // so swapping lists will prune them 276 | active_touches = pruned_touches; 277 | 278 | process_active_touch_regions(); 279 | } 280 | 281 | function clear_active_classes() { 282 | for (let el of touch_button_elements) { 283 | el.classList.remove("active"); 284 | } 285 | for (let el of dpad_elements) { 286 | for (let direction of ["up", "down", "left", "right"]) { 287 | let pad_element = document.getElementById(el.id + "_" + direction); 288 | pad_element.classList.remove("active"); 289 | } 290 | } 291 | } 292 | 293 | function process_active_touch_regions() { 294 | clear_active_classes(); 295 | for (let touch_identifier in active_touches) { 296 | active_touch = active_touches[touch_identifier]; 297 | if (active_touch.button != null) { 298 | let button_element = document.getElementById(active_touch.button); 299 | button_element.classList.add("active"); 300 | } 301 | if (active_touch.dpad != null) { 302 | for (let direction of active_touch.directions) { 303 | let pad_element = document.getElementById(active_touch.dpad + "_" + direction); 304 | pad_element.classList.add("active"); 305 | } 306 | } 307 | } 308 | } 309 | 310 | function handleTouchEvent(event) { 311 | is_touch_detected = true; 312 | handleTouches(event.touches, event); 313 | } 314 | 315 | -------------------------------------------------------------------------------- /public/touch_playground.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 63 | 64 | 65 | 66 |
67 | 68 |
69 |
70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 | 83 | 91 | 92 | -------------------------------------------------------------------------------- /public/wavy_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/wavy_grid.png -------------------------------------------------------------------------------- /public/wavy_grid_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/public/wavy_grid_gradient.png -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/audio_processor.js: -------------------------------------------------------------------------------- 1 | // An example thing, obviously fix this 2 | class NesAudioProcessor extends AudioWorkletProcessor { 3 | constructor (...args) { 4 | super(...args) 5 | this.sampleBuffer = new Int16Array(0); 6 | this.lastPlayedSample = 0; 7 | this.port.onmessage = (e) => { 8 | if (e.data.type == "samples") { 9 | let mergedBuffer = new Int16Array(this.sampleBuffer.length + e.data.samples.length); 10 | mergedBuffer.set(this.sampleBuffer); 11 | mergedBuffer.set(e.data.samples, this.sampleBuffer.length); 12 | this.sampleBuffer = mergedBuffer; 13 | } 14 | } 15 | } 16 | process (inputs, outputs, parameters) { 17 | const output = outputs[0] 18 | const desired_length = output[0].length; 19 | //console.log("Want to play: ", desired_length); 20 | //console.log("Actual size: ", this.sampleBuffer.length); 21 | if (desired_length <= this.sampleBuffer.length) { 22 | // Queue up the buffer contents. Note that NES audio is in mono, so we'll replicate that 23 | // to every channel on the output. (I'm guessing usually 2?) 24 | output.forEach(channel => { 25 | for (let i = 0; i < channel.length; i++) { 26 | // Convert from i16 to float, ranging from -1 to 1 27 | channel[i] = (this.sampleBuffer[i] / 32768); 28 | } 29 | }) 30 | // Set the new last played sample, this will be our hold value if we have an underrun 31 | this.lastPlayedSample = this.sampleBuffer[desired_length - 1]; 32 | // Remove those contents from the buffer 33 | this.sampleBuffer = this.sampleBuffer.slice(desired_length); 34 | // Finally, tell the main thread so it can adjust its totals 35 | this.port.postMessage({"type": "samplesPlayed", "count": desired_length}); 36 | } else { 37 | // Queue up nothing! Specifically, *repeat* the last sample, to hold the level; this won't 38 | // avoid a break in the audio, but it avoids ugly pops 39 | output.forEach(channel => { 40 | for (let i = 0; i < channel.length; i++) { 41 | channel[i] = (this.lastPlayedSample / 37268); 42 | } 43 | }) 44 | // Tell the main thread that we've run behind 45 | this.port.postMessage({"type": "audioUnderrun", "count": output[0].length}); 46 | } 47 | 48 | return true 49 | } 50 | } 51 | 52 | registerProcessor('nes-audio-processor', NesAudioProcessor) 53 | -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/embed.css: -------------------------------------------------------------------------------- 1 | /* note: minimal new styling here, mostly this just hides elements on the main interface 2 | that we don't want to display in the embedded one. */ 3 | 4 | #navbar { 5 | display: none; 6 | } 7 | 8 | body { 9 | width: 100%; 10 | height: 100%; 11 | box-shadow: none; 12 | } 13 | 14 | #content_area { 15 | width: 100%; 16 | height: 100%; 17 | overflow: hidden; 18 | } 19 | 20 | #playfield div.canvas_container { 21 | width: 100%; 22 | height: 100%; 23 | } 24 | -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/emu_worker.js: -------------------------------------------------------------------------------- 1 | importScripts('./rusticnes_wasm.js'); 2 | 3 | const { 4 | wasm_init, 5 | load_rom, 6 | run_until_vblank, 7 | set_p1_input, 8 | set_p2_input, 9 | set_audio_samplerate, 10 | set_audio_buffersize, 11 | audio_buffer_full, 12 | get_audio_buffer, 13 | get_sram, set_sram, 14 | has_sram, update_windows, 15 | draw_piano_roll_window, 16 | draw_screen_pixels, 17 | piano_roll_window_click, 18 | consume_audio_samples, 19 | } = wasm_bindgen; 20 | 21 | let initialized = false; 22 | let profiling = { 23 | run_one_frame: {accumulated_time: 0, count: 0}, 24 | render_screen: {accumulated_time: 0, count: 0}, 25 | render_piano_roll: {accumulated_time: 0, count: 0}, 26 | idle: {accumulated_time: 0, count: 0}, 27 | render_all_panels: {accumulated_time: 0, count: 0}, 28 | } 29 | let idle_start = 0; 30 | let idle_accumulator = 0; 31 | 32 | function collect_profiling(event_name, measured_time) { 33 | let profile = profiling[event_name] 34 | profile.accumulated_time += measured_time; 35 | profile.count += 1; 36 | // do an average over 10 frames or so 37 | if (profile.count >= 60) { 38 | let average = profile.accumulated_time / 60; 39 | profile.count = 0; 40 | profile.accumulated_time = 0; 41 | postMessage({"type": "reportPerformance", "event": event_name, "average_milliseconds": average}); 42 | } 43 | } 44 | 45 | // TODO: The rust side of this *should* be generating appropriate error 46 | // messages. Can we catch those and propogate that error to the UI? That 47 | // would be excellent for users, right now they're just getting silent 48 | // failure. 49 | function load_cartridge(cart_data) { 50 | load_rom(cart_data); 51 | set_audio_samplerate(44100); 52 | } 53 | 54 | function run_one_frame() { 55 | let start_time = performance.now(); 56 | run_until_vblank(); 57 | update_windows(); 58 | collect_profiling("run_one_frame", performance.now() - start_time) 59 | } 60 | 61 | function get_screen_pixels(dest_array_buffer) { 62 | let start_time = performance.now(); 63 | //let raw_buffer = new ArrayBuffer(256*240*4); 64 | //let screen_pixels = new Uint8ClampedArray(raw_buffer); 65 | let screen_pixels = new Uint8ClampedArray(dest_array_buffer); 66 | draw_screen_pixels(screen_pixels); 67 | collect_profiling("render_screen", performance.now() - start_time) 68 | return dest_array_buffer; 69 | } 70 | 71 | function get_piano_roll_pixels(dest_array_buffer) { 72 | let start_time = performance.now(); 73 | //let raw_buffer = new ArrayBuffer(480*270*4); 74 | //let screen_pixels = new Uint8ClampedArray(raw_buffer); 75 | let screen_pixels = new Uint8ClampedArray(dest_array_buffer); 76 | draw_piano_roll_window(screen_pixels); 77 | collect_profiling("render_piano_roll", performance.now() - start_time) 78 | return dest_array_buffer; 79 | } 80 | 81 | function handle_piano_roll_window_click(mx, my) { 82 | piano_roll_window_click(mx, my); 83 | } 84 | 85 | const rpc_functions = { 86 | "load_cartridge": load_cartridge, 87 | "run_one_frame": run_one_frame, 88 | "get_screen_pixels": get_screen_pixels, 89 | "get_piano_roll_pixels": get_piano_roll_pixels, 90 | "handle_piano_roll_window_click": handle_piano_roll_window_click, 91 | "has_sram": has_sram, 92 | "get_sram": get_sram, 93 | "set_sram": set_sram, 94 | }; 95 | 96 | function rpc(task, args, reply_channel) { 97 | if (rpc_functions.hasOwnProperty(task)) { 98 | const result = rpc_functions[task].apply(this, args); 99 | reply_channel.postMessage({"result": result}); 100 | } 101 | } 102 | 103 | function handle_message(e) { 104 | idle_accumulator += performance.now() - idle_start; 105 | if (e.data.type == "rpc") { 106 | rpc(e.data.func, e.data.args, e.ports[0]) 107 | } 108 | if (e.data.type == "requestFrame") { 109 | // Measure the idle time between each frame, for profiling purposes 110 | collect_profiling("idle", idle_accumulator); 111 | idle_accumulator = 0; 112 | 113 | // Run one step of the emulator 114 | set_p1_input(e.data.p1); 115 | set_p2_input(e.data.p2); 116 | run_one_frame(); 117 | 118 | let outputPanels = []; 119 | let transferrableBuffers = []; 120 | let panel_start_time = performance.now(); 121 | for (let panel of e.data.panels) { 122 | if (panel.id == "screen") { 123 | let image_buffer = get_screen_pixels(panel.dest_buffer); 124 | outputPanels.push({ 125 | id: "screen", 126 | target_element: panel.target_element, 127 | image_buffer: image_buffer, 128 | width: 256, 129 | height: 240 130 | }); 131 | transferrableBuffers.push(image_buffer); 132 | } 133 | if (panel.id == "piano_roll_window") { 134 | let image_buffer = get_piano_roll_pixels(panel.dest_buffer); 135 | outputPanels.push({ 136 | id: "piano_roll_window", 137 | target_element: panel.target_element, 138 | image_buffer: image_buffer, 139 | width: 480, 140 | height: 270 141 | }); 142 | transferrableBuffers.push(image_buffer); 143 | } 144 | } 145 | // Only profile a render if we actually drew something 146 | if (e.data.panels.length > 0) { 147 | collect_profiling("render_all_panels", performance.now() - panel_start_time) 148 | } 149 | // TODO: this isn't an ArrayBuffer. It probably should be? 150 | let audio_buffer = consume_audio_samples(); 151 | postMessage({"type": "deliverFrame", "panels": outputPanels, "audio_buffer": audio_buffer}, transferrableBuffers); 152 | } 153 | idle_start = performance.now(); 154 | } 155 | 156 | worker_init = function() { 157 | wasm_init(); 158 | // We are ready to go! Tell the main thread it can kick off execution 159 | initialized = true; 160 | postMessage({"type": "init"}); 161 | self.onmessage = handle_message; 162 | } 163 | 164 | wasm_bindgen('./rusticnes_wasm_bg.wasm').then(worker_init); 165 | 166 | -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 |

P1 - Standard

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
A
B
Select
Start
Up
Down
Left
Right
44 |
45 |
46 |

P2 - Standard

47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
A
B
Select
Start
Up
Down
Left
Right
57 |
58 |
59 |
60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/input.js: -------------------------------------------------------------------------------- 1 | // Note: The following variable is global, and represents our live button state for the emulator: 2 | // var keys = [0,0]; 3 | 4 | var keys = [0,0,0]; 5 | var touch_keys = [0,0,0]; 6 | var remap_key = false; 7 | var remap_index = 0; 8 | var remap_slot = 1; 9 | 10 | var controller_keymaps = []; 11 | 12 | controller_keymaps[1] = [ 13 | "x", 14 | "z", 15 | "Shift", 16 | "Enter", 17 | "ArrowUp", 18 | "ArrowDown", 19 | "ArrowLeft", 20 | "ArrowRight"]; 21 | 22 | controller_keymaps[2] = ["-","-","-","-","-","-","-","-"]; 23 | 24 | window.addEventListener('keydown', function(event) { 25 | if (remap_key) { 26 | if (event.key != "Escape") { 27 | controller_keymaps[remap_slot][remap_index] = event.key; 28 | } else { 29 | controller_keymaps[remap_slot][remap_index] = "-"; 30 | } 31 | remap_key = false; 32 | displayButtonMappings(); 33 | saveInputConfig(); 34 | return; 35 | } 36 | for (var c = 1; c <= 2; c++) { 37 | for (var i = 0; i < 8; i++) { 38 | if (event.key == controller_keymaps[c][i]) { 39 | keys[c] = keys[c] | (0x1 << i); 40 | } 41 | } 42 | } 43 | if (event.key == "p") { 44 | var debug_box = document.querySelector("#debug-box"); 45 | debug_box.classList.toggle("active"); 46 | } 47 | }); 48 | 49 | window.addEventListener('keyup', function(event) { 50 | for (var c = 1; c <= 2; c++) { 51 | for (var i = 0; i < 8; i++) { 52 | if (event.key == controller_keymaps[c][i]) { 53 | keys[c] = keys[c] & ~(0x1 << i); 54 | } 55 | } 56 | } 57 | }); 58 | 59 | var controller_padmaps = []; 60 | controller_padmaps[1] = ["-","-","-","-","-","-","-","-"]; 61 | controller_padmaps[2] = ["-","-","-","-","-","-","-","-"]; 62 | 63 | var gamepads = []; 64 | 65 | var idle_interval = setInterval(updateGamepads, 500); 66 | 67 | window.addEventListener("gamepadconnected", function(e) { 68 | var gp = navigator.getGamepads()[e.gamepad.index]; 69 | console.log("Recognized new gamepad! Index: ", e.gamepad.index, " Buttons: ", gp.buttons.length, " Axis: ", gp.axes.length); 70 | gamepads[e.gamepad.index] = gamepadState(gp); 71 | }); 72 | 73 | function gamepadState(gamepad) { 74 | var state = {buttons: [], axes: []}; 75 | for (var b = 0; b < gamepad.buttons.length; b++) { 76 | state.buttons[b] = gamepad.buttons[b].pressed; 77 | } 78 | for (var a = 0; a < gamepad.axes.length; a++) { 79 | state.axes[a] = gamepad.axes[a]; 80 | } 81 | return state; 82 | } 83 | 84 | function updateGamepads() { 85 | for (var i = 0; i < gamepads.length; i++) { 86 | var old_state = gamepads[i]; 87 | if (old_state) { 88 | gp = navigator.getGamepads()[i]; 89 | if (gp) { 90 | var new_state = gamepadState(gp); 91 | for (var b = 0; b < old_state.buttons.length; b++) { 92 | if (old_state.buttons[b] == false && new_state.buttons[b] == true) { 93 | gamepadDown("PAD("+i+"): BUTTON("+b+")"); 94 | } 95 | if (old_state.buttons[b] == true && new_state.buttons[b] == false) { 96 | gamepadUp("PAD("+i+"): BUTTON("+b+")"); 97 | } 98 | } 99 | for (var a = 0; a < old_state.axes.length; a++) { 100 | if (old_state.axes[a] < 0.5 && new_state.axes[a] >= 0.5) { 101 | gamepadDown("PAD("+i+"): AXIS("+a+")+"); 102 | } 103 | if (old_state.axes[a] > -0.5 && new_state.axes[a] <= -0.5) { 104 | gamepadDown("PAD("+i+"): AXIS("+a+")-"); 105 | } 106 | 107 | if (old_state.axes[a] >= 0.5 && new_state.axes[a] < 0.5) { 108 | gamepadUp("PAD("+i+"): AXIS("+a+")+"); 109 | } 110 | if (old_state.axes[a] <= -0.5 && new_state.axes[a] > -0.5) { 111 | gamepadUp("PAD("+i+"): AXIS("+a+")-"); 112 | } 113 | } 114 | gamepads[i] = new_state; 115 | } 116 | } 117 | } 118 | } 119 | 120 | function gamepadDown(button_name) { 121 | if (remap_key) { 122 | controller_padmaps[remap_slot][remap_index] = button_name; 123 | remap_key = false; 124 | displayButtonMappings(); 125 | saveInputConfig(); 126 | return; 127 | } 128 | for (var c = 1; c <= 2; c++) { 129 | for (var i = 0; i < 8; i++) { 130 | if (button_name == controller_padmaps[c][i]) { 131 | keys[c] = keys[c] | (0x1 << i); 132 | } 133 | } 134 | } 135 | } 136 | 137 | function gamepadUp(button_name) { 138 | if (remap_key) { 139 | controller_padmaps[remap_slot][remap_index] = button_name; 140 | remap_key = false; 141 | displayButtonMappings(); 142 | return; 143 | } 144 | for (var c = 1; c <= 2; c++) { 145 | for (var i = 0; i < 8; i++) { 146 | if (button_name == controller_padmaps[c][i]) { 147 | keys[c] = keys[c] & ~(0x1 << i); 148 | } 149 | } 150 | } 151 | } 152 | 153 | function displayButtonMappings() { 154 | var buttons = document.querySelectorAll("#configure_input button"); 155 | buttons.forEach(function(button) { 156 | var key_index = button.getAttribute("data-key"); 157 | var key_slot = button.getAttribute("data-slot"); 158 | button.innerHTML = controller_keymaps[key_slot][key_index] + " / " + controller_padmaps[key_slot][key_index]; 159 | button.classList.remove("remapping"); 160 | }); 161 | } 162 | 163 | function remapButton() { 164 | displayButtonMappings(); 165 | this.classList.add("remapping"); 166 | this.innerHTML = "..." 167 | remap_key = true; 168 | remap_index = this.getAttribute("data-key"); 169 | remap_slot = this.getAttribute("data-slot"); 170 | this.blur(); 171 | } 172 | 173 | function initializeButtonMappings() { 174 | displayButtonMappings(); 175 | var buttons = document.querySelectorAll("#configure_input button"); 176 | buttons.forEach(function(button) { 177 | button.addEventListener("click", remapButton); 178 | }); 179 | } 180 | 181 | function saveInputConfig() { 182 | try { 183 | window.localStorage.setItem("keyboard_1", JSON.stringify(controller_keymaps[1])); 184 | window.localStorage.setItem("keyboard_2", JSON.stringify(controller_keymaps[2])); 185 | window.localStorage.setItem("gamepad_1", JSON.stringify(controller_padmaps[1])); 186 | window.localStorage.setItem("gamepad_2", JSON.stringify(controller_padmaps[2])); 187 | console.log("Input Config Saved!"); 188 | } catch(e) { 189 | console.log("Local Storage is probably unavailable! Input configuration will not persist."); 190 | } 191 | } 192 | 193 | function loadInputConfig() { 194 | try { 195 | var keyboard_1 = window.localStorage.getItem("keyboard_1"); 196 | if (keyboard_1) { controller_keymaps[1] = JSON.parse(keyboard_1); } 197 | var keyboard_2 = window.localStorage.getItem("keyboard_2"); 198 | if (keyboard_2) { controller_keymaps[2] = JSON.parse(keyboard_2); } 199 | var gamepad_1 = window.localStorage.getItem("gamepad_1"); 200 | if (gamepad_1) { controller_padmaps[1] = JSON.parse(gamepad_1); } 201 | var gamepad_2 = window.localStorage.getItem("gamepad_2"); 202 | if (gamepad_2) { controller_padmaps[2] = JSON.parse(gamepad_2); } 203 | console.log("Input Config Loaded!"); 204 | displayButtonMappings(); 205 | } catch(e) { 206 | console.log("Local Storage is probably unavailable! Input configuration will not persist."); 207 | } 208 | } 209 | 210 | KEY_A = 1 211 | KEY_B = 2 212 | KEY_SELECT = 4 213 | KEY_START = 8 214 | KEY_UP = 16 215 | KEY_DOWN = 32 216 | KEY_LEFT = 64 217 | KEY_RIGHT = 128 218 | 219 | BUTTON_MAPPING = { 220 | "button_a": KEY_A, 221 | "button_b": KEY_B, 222 | "button_ab": (KEY_A | KEY_B), 223 | "button_start": KEY_START, 224 | "button_select": KEY_SELECT 225 | } 226 | 227 | DIRECTION_MAPPING = { 228 | "up": KEY_UP, 229 | "down": KEY_DOWN, 230 | "left": KEY_LEFT, 231 | "right": KEY_RIGHT 232 | } 233 | 234 | function updateTouchKeys() { 235 | p1_keys = 0; 236 | // Iterate over the button and d-pad states and collect the key status in a byte 237 | // Note: only implemented for P1 at the moment 238 | for (let touch_identifier in active_touches) { 239 | active_touch = active_touches[touch_identifier]; 240 | if (BUTTON_MAPPING.hasOwnProperty(active_touch.button)) { 241 | p1_keys = p1_keys | BUTTON_MAPPING[active_touch.button]; 242 | } 243 | if (active_touch.dpad != null) { 244 | for (let direction of active_touch.directions) { 245 | if (DIRECTION_MAPPING.hasOwnProperty(direction)) { 246 | p1_keys = p1_keys | DIRECTION_MAPPING[direction]; 247 | } 248 | } 249 | } 250 | } 251 | // Because we allow multiple touch points to activate the same D-pad input, we might accidentally produce a directional combination 252 | // that should be disallowed on real hardware. Let's sanity check this and make sure we disallow U+D and L+R 253 | p1_up_left = p1_keys & (KEY_UP | KEY_LEFT); 254 | p1_down_right = p1_up_left << 1; 255 | p1_down_right_mask = p1_down_right ^ 0xFF; 256 | 257 | touch_keys[1] = p1_keys & p1_down_right_mask; 258 | } -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/main.js: -------------------------------------------------------------------------------- 1 | // ========== Global Application State ========== 2 | 3 | let g_pending_frames = 0; 4 | let g_frames_since_last_fps_count = 0; 5 | let g_rendered_frames = []; 6 | 7 | let g_last_frame_sample_count = 44100 / 60; // Close-ish enough 8 | let g_audio_samples_buffered = 0; 9 | let g_new_frame_sample_threshold = 4096; // under which we request a new frame 10 | let g_audio_overrun_sample_threshold = 8192; // over which we *drop* samples 11 | 12 | let g_game_checksum = -1; 13 | 14 | let g_screen_buffers = []; 15 | let g_piano_roll_buffers = []; 16 | let g_next_free_buffer_index = 0; 17 | let g_last_rendered_buffer_index = 0; 18 | let g_total_buffers = 16; 19 | 20 | let g_frameskip = 0; 21 | let g_frame_delay = 0; 22 | 23 | let g_audio_confirmed_working = false; 24 | let g_profiling_results = {}; 25 | 26 | let g_trouble_detector = { 27 | successful_samples: 0, 28 | failed_samples: 0, 29 | frames_requested: 0, 30 | trouble_count: 0, 31 | got_better_count: 0, 32 | } 33 | 34 | let g_increase_frameskip_threshold = 0.01; // percent of missed samples 35 | let g_decrease_frameskip_headroom = 1.5 // percent of the time taken to render one frame 36 | 37 | let embed_autostart_url = null; 38 | 39 | // ========== Init which does not depend on DOM ======== 40 | 41 | for (let i = 0; i < g_total_buffers; i++) { 42 | // Allocate a good number of screen buffers 43 | g_screen_buffers[i] = new ArrayBuffer(256*240*4); 44 | g_piano_roll_buffers[i] = new ArrayBuffer(480*270*4); 45 | } 46 | 47 | // ========== Worker Setup and Utility ========== 48 | 49 | var worker = new Worker('emu_worker.js'); 50 | 51 | function rpc(task, args) { 52 | return new Promise((resolve, reject) => { 53 | const channel = new MessageChannel(); 54 | channel.port1.onmessage = ({data}) => { 55 | if (data.error) { 56 | reject(data.error); 57 | } else { 58 | resolve(data.result); 59 | } 60 | }; 61 | worker.postMessage({"type": "rpc", "func": task, "args": args}, [channel.port2]); 62 | }); 63 | } 64 | 65 | worker.onmessage = function(e) { 66 | if (e.data.type == "init") { 67 | onready(); 68 | } 69 | if (e.data.type == "deliverFrame") { 70 | if (e.data.panels.length > 0) { 71 | g_rendered_frames.push(e.data.panels); 72 | for (let panel of e.data.panels) { 73 | if (panel.id == "screen") { 74 | g_screen_buffers[g_last_rendered_buffer_index] = panel.image_buffer; 75 | } 76 | if (panel.id == "piano_roll_window") { 77 | g_piano_roll_buffers[g_last_rendered_buffer_index] = panel.image_buffer; 78 | } 79 | } 80 | g_last_rendered_buffer_index += 1; 81 | if (g_last_rendered_buffer_index >= g_total_buffers) { 82 | g_last_rendered_buffer_index = 0; 83 | } 84 | g_frames_since_last_fps_count += 1; 85 | } 86 | g_pending_frames -= 1; 87 | if (g_audio_samples_buffered < g_audio_overrun_sample_threshold) { 88 | g_nes_audio_node.port.postMessage({"type": "samples", "samples": e.data.audio_buffer}); 89 | g_audio_samples_buffered += e.data.audio_buffer.length; 90 | g_last_frame_sample_count = e.data.audio_buffer.length; 91 | } else { 92 | // Audio overrun, we're running too fast! Drop these samples on the floor and bail. 93 | // (This can happen in fastforward mode.) 94 | } 95 | if (g_rendered_frames.length > 3) { 96 | // Frame rendering running behing, dropping one frame 97 | g_rendered_frames.shift(); // and throw it away 98 | } 99 | } 100 | if (e.data.type == "reportPerformance") { 101 | g_profiling_results[e.data.event] = e.data.average_milliseconds; 102 | } 103 | } 104 | 105 | function render_profiling_results() { 106 | let results = ""; 107 | for (let event_name in g_profiling_results) { 108 | let time = g_profiling_results[event_name].toFixed(2); 109 | results += `${event_name}: ${time}\n` 110 | } 111 | var results_box = document.querySelector("#profiling-results"); 112 | if (results_box != null) { 113 | results_box.innerHTML = results; 114 | } 115 | } 116 | 117 | function automatic_frameskip() { 118 | // first off, do we have enough profiling data collected? 119 | if (g_trouble_detector.frames_requested >= 60) { 120 | let audio_fail_percent = g_trouble_detector.failed_samples / g_trouble_detector.successful_samples; 121 | if (g_frameskip < 2) { 122 | // if our audio context is running behind, let's try 123 | // rendering fewer frames to compensate 124 | if (audio_fail_percent > g_increase_frameskip_threshold) { 125 | g_trouble_detector.trouble_count += 1; 126 | g_trouble_detector.got_better_count = 0; 127 | console.log("Audio failure percentage: ", audio_fail_percent); 128 | console.log("Trouble count incremented to: ", g_trouble_detector.trouble_count); 129 | if (g_trouble_detector.trouble_count > 3) { 130 | // that's quite enough of that 131 | g_frameskip += 1; 132 | g_trouble_detector.trouble_count = 0; 133 | console.log("Frameskip increased to: ", g_frameskip); 134 | console.log("Trouble reset") 135 | } 136 | } else { 137 | // Slowly recover from brief trouble spikes 138 | // without taking action 139 | if (g_trouble_detector.trouble_count > 0) { 140 | g_trouble_detector.trouble_count -= 1; 141 | console.log("Trouble count relaxed to: ", g_trouble_detector.trouble_count); 142 | } 143 | } 144 | } 145 | if (g_frameskip > 0) { 146 | // Perform a bunch of sanity checks to see if it looks safe to 147 | // decrease frameskip. 148 | if (audio_fail_percent < g_increase_frameskip_threshold) { 149 | // how long would it take to render one frame right now? 150 | let frame_render_cost = g_profiling_results.render_all_panels; 151 | let cost_with_headroom = frame_render_cost * g_decrease_frameskip_headroom; 152 | // Would a full render reliably fit in our idle time? 153 | if (cost_with_headroom < g_profiling_results.idle) { 154 | console.log("Frame render costs: ", frame_render_cost); 155 | console.log("With headroom: ", cost_with_headroom); 156 | console.log("Idle time currently: ", g_profiling_results.idle); 157 | g_trouble_detector.got_better_count += 1; 158 | console.log("Recovery count increased to: ", g_trouble_detector.got_better_count); 159 | } 160 | if (cost_with_headroom > g_profiling_results.idle) { 161 | if (g_trouble_detector.got_better_count > 0) { 162 | g_trouble_detector.got_better_count -= 1; 163 | console.log("Recovery count decreased to: ", g_trouble_detector.got_better_count); 164 | } 165 | } 166 | if (g_trouble_detector.got_better_count >= 10) { 167 | g_frameskip -= 1; 168 | console.log("Performance recovered! Lowering frameskip by 1 to: "); 169 | g_trouble_detector.got_better_count = 0; 170 | } 171 | } 172 | } 173 | 174 | // now reset the counters for the next run 175 | g_trouble_detector.frames_requested = 0; 176 | g_trouble_detector.failed_samples = 0; 177 | g_trouble_detector.successful_samples = 0; 178 | } 179 | } 180 | 181 | // ========== Audio Setup ========== 182 | 183 | let g_audio_context = null; 184 | let g_nes_audio_node = null; 185 | 186 | async function init_audio_context() { 187 | g_audio_context = new AudioContext({ 188 | latencyHint: 'interactive', 189 | sampleRate: 44100, 190 | }); 191 | await g_audio_context.audioWorklet.addModule('audio_processor.js'); 192 | g_nes_audio_node = new AudioWorkletNode(g_audio_context, 'nes-audio-processor'); 193 | g_nes_audio_node.connect(g_audio_context.destination); 194 | g_nes_audio_node.port.onmessage = handle_audio_message; 195 | } 196 | 197 | function handle_audio_message(e) { 198 | if (e.data.type == "samplesPlayed") { 199 | g_audio_samples_buffered -= e.data.count; 200 | g_trouble_detector.successful_samples += e.data.count; 201 | if (!g_audio_confirmed_working && g_trouble_detector.successful_samples > 44100) { 202 | let audio_context_banner = document.querySelector("#audio-context-warning"); 203 | if (audio_context_banner != null) { 204 | audio_context_banner.classList.remove("active"); 205 | } 206 | g_audio_confirmed_working = true; 207 | } 208 | } 209 | if (e.data.type == "audioUnderrun") { 210 | g_trouble_detector.failed_samples += e.data.count; 211 | } 212 | } 213 | 214 | // ========== Main ========== 215 | 216 | async function onready() { 217 | // Initialize audio context, this will also begin audio playback 218 | await init_audio_context(); 219 | 220 | // Initialize everything else 221 | init_ui_events(); 222 | initializeButtonMappings(); 223 | 224 | // Kick off the events that will drive emulation 225 | requestAnimationFrame(renderLoop); 226 | // run the scheduler as often as we can. It will frequently decide not to schedule things, this is fine. 227 | //window.setInterval(schedule_frames_at_top_speed, 1); 228 | window.setTimeout(sync_to_audio, 1); 229 | window.setInterval(compute_fps, 1000); 230 | window.setInterval(render_profiling_results, 1000); 231 | window.setInterval(automatic_frameskip, 1000); 232 | window.setInterval(save_sram_periodically, 10000); 233 | 234 | // Attempt to load a cartridge by URL, if one is provided 235 | let params = new URLSearchParams(location.search.slice(1)); 236 | if (params.get("cartridge")) { 237 | load_cartridge_by_url(params.get("cartridge")); 238 | display_banner(params.get("cartridge")); 239 | } 240 | if (params.get("tab")) { 241 | switchToTab(params.get("tab")); 242 | } 243 | if (embed_autostart_url != null) { 244 | load_cartridge_by_url(embed_autostart_url); 245 | } 246 | } 247 | 248 | function init_ui_events() { 249 | // Setup UI events 250 | document.getElementById('file-loader').addEventListener('change', load_cartridge_by_file, false); 251 | 252 | var buttons = document.querySelectorAll("#main_menu button"); 253 | buttons.forEach(function(button) { 254 | button.addEventListener("click", clickTab); 255 | }); 256 | 257 | window.addEventListener("click", function() { 258 | // Needed to play audio in certain browsers, notably Chrome, which restricts playback until user action. 259 | g_audio_context.resume(); 260 | }); 261 | 262 | document.querySelector("#playfield").addEventListener("dblclick", enterFullscreen); 263 | document.addEventListener("fullscreenchange", handleFullscreenSwitch); 264 | document.addEventListener("webkitfullscreenchange", handleFullscreenSwitch); 265 | document.addEventListener("mozfullscreenchange", handleFullscreenSwitch); 266 | document.addEventListener("MSFullscreenChange", handleFullscreenSwitch); 267 | 268 | if (document.querySelector("#piano_roll_window")) { 269 | document.querySelector("#piano_roll_window").addEventListener("click", handle_piano_roll_window_click); 270 | } 271 | 272 | register_touch_button("#button_a"); 273 | register_touch_button("#button_b"); 274 | register_touch_button("#button_ab"); 275 | register_touch_button("#button_select"); 276 | register_touch_button("#button_start"); 277 | register_d_pad("#d_pad"); 278 | initialize_touch("#playfield"); 279 | } 280 | 281 | // ========== Cartridge Management ========== 282 | 283 | async function load_cartridge(cart_data) { 284 | console.log("Attempting to load cart with length: ", cart_data.length); 285 | await rpc("load_cartridge", [cart_data]); 286 | console.log("Cart data loaded?"); 287 | 288 | g_game_checksum = crc32(cart_data); 289 | load_sram(); 290 | let power_light = document.querySelector("#power_light #led"); 291 | power_light.classList.add("powered"); 292 | } 293 | 294 | function load_cartridge_by_file(e) { 295 | if (g_game_checksum != -1) { 296 | save_sram(); 297 | } 298 | var file = e.target.files[0]; 299 | if (!file) { 300 | return; 301 | } 302 | var reader = new FileReader(); 303 | reader.onload = function(e) { 304 | cart_data = new Uint8Array(e.target.result); 305 | load_cartridge(cart_data); 306 | hide_banners(); 307 | } 308 | reader.readAsArrayBuffer(file); 309 | 310 | // we're done with the file loader; unfocus it, so keystrokes are captured 311 | // by the game instead 312 | this.blur(); 313 | } 314 | 315 | function load_cartridge_by_url(url) { 316 | if (g_game_checksum != -1) { 317 | save_sram(); 318 | } 319 | var rawFile = new XMLHttpRequest(); 320 | rawFile.overrideMimeType("application/octet-stream"); 321 | rawFile.open("GET", url, true); 322 | rawFile.responseType = "arraybuffer"; 323 | rawFile.onreadystatechange = function() { 324 | if (rawFile.readyState === 4 && rawFile.status == "200") { 325 | console.log(rawFile.responseType); 326 | cart_data = new Uint8Array(rawFile.response); 327 | load_cartridge(cart_data); 328 | } 329 | } 330 | rawFile.send(null); 331 | } 332 | 333 | // ========== Emulator Runtime ========== 334 | 335 | function schedule_frames_at_top_speed() { 336 | if (g_pending_frames < 10) { 337 | requestFrame(); 338 | } 339 | window.setTimeout(schedule_frames_at_top_speed, 1); 340 | } 341 | 342 | function sync_to_audio() { 343 | // On mobile browsers, sometimes window.setTimeout isn't called often enough to reliably 344 | // queue up single frames; try to catch up by up to 4 of them at once. 345 | for (i = 0; i < 4; i++) { 346 | // Never, for any reason, request more than 10 frames at a time. This prevents 347 | // the message queue from getting flooded if the emulator can't keep up. 348 | if (g_pending_frames < 10) { 349 | const actual_samples = g_audio_samples_buffered; 350 | const pending_samples = g_pending_frames * g_last_frame_sample_count; 351 | if (actual_samples + pending_samples < g_new_frame_sample_threshold) { 352 | requestFrame(); 353 | } 354 | } 355 | } 356 | window.setTimeout(sync_to_audio, 1); 357 | } 358 | 359 | function requestFrame() { 360 | updateTouchKeys(); 361 | g_trouble_detector.frames_requested += 1; 362 | let active_tab = document.querySelector(".tab_content.active").id; 363 | if (g_frame_delay > 0) { 364 | // frameskip: advance the emulation, but do not populate or render 365 | // any panels this time around 366 | worker.postMessage({"type": "requestFrame", "p1": keys[1] | touch_keys[1], "p2": keys[2] | touch_keys[2], "panels": []}); 367 | g_frame_delay -= 1; 368 | g_pending_frames += 1; 369 | return; 370 | } 371 | if (active_tab == "jam") { 372 | worker.postMessage( 373 | {"type": "requestFrame", "p1": keys[1] | touch_keys[1], "p2": keys[2] | touch_keys[2], "panels": [ 374 | { 375 | "id": "screen", 376 | "target_element": "#jam_pixels", 377 | "dest_buffer": g_screen_buffers[g_next_free_buffer_index], 378 | }, 379 | { 380 | "id": "piano_roll_window", 381 | "target_element": "#piano_roll_window", 382 | "dest_buffer": g_piano_roll_buffers[g_next_free_buffer_index], 383 | }, 384 | ]}, 385 | [ 386 | g_screen_buffers[g_next_free_buffer_index], 387 | g_piano_roll_buffers[g_next_free_buffer_index] 388 | ] 389 | ); 390 | } else { 391 | worker.postMessage( 392 | {"type": "requestFrame", "p1": keys[1] | touch_keys[1], "p2": keys[2] | touch_keys[2], "panels": [ 393 | { 394 | "id": "screen", 395 | "target_element": "#pixels", 396 | "dest_buffer": g_screen_buffers[g_next_free_buffer_index], 397 | } 398 | ]}, 399 | [g_screen_buffers[g_next_free_buffer_index]] 400 | ); 401 | } 402 | g_pending_frames += 1; 403 | g_next_free_buffer_index += 1; 404 | if (g_next_free_buffer_index >= g_total_buffers) { 405 | g_next_free_buffer_index = 0; 406 | } 407 | g_frame_delay = g_frameskip; 408 | } 409 | 410 | function renderLoop() { 411 | if (g_rendered_frames.length > 0) { 412 | for (let panel of g_rendered_frames.shift()) { 413 | const typed_pixels = new Uint8ClampedArray(panel.image_buffer); 414 | // TODO: don't hard-code the panel size here 415 | let rendered_frame = new ImageData(typed_pixels, panel.width, panel.height); 416 | canvas = document.querySelector(panel.target_element); 417 | ctx = canvas.getContext("2d", { alpha: false }); 418 | ctx.putImageData(rendered_frame, 0, 0); 419 | ctx.imageSmoothingEnabled = false; 420 | } 421 | } 422 | 423 | requestAnimationFrame(renderLoop); 424 | } 425 | 426 | // ========== SRAM Management ========== 427 | 428 | // CRC32 checksum generating functions, yanked from this handy stackoverflow post and modified to work with arrays: 429 | // https://stackoverflow.com/questions/18638900/javascript-crc32 430 | // Used to identify .nes files semi-uniquely, for the purpose of saving SRAM 431 | var makeCRCTable = function(){ 432 | var c; 433 | var crcTable = []; 434 | for(var n =0; n < 256; n++){ 435 | c = n; 436 | for(var k =0; k < 8; k++){ 437 | c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)); 438 | } 439 | crcTable[n] = c; 440 | } 441 | return crcTable; 442 | } 443 | 444 | var crc32 = function(byte_array) { 445 | var crcTable = window.crcTable || (window.crcTable = makeCRCTable()); 446 | var crc = 0 ^ (-1); 447 | 448 | for (var i = 0; i < byte_array.length; i++ ) { 449 | crc = (crc >>> 8) ^ crcTable[(crc ^ byte_array[i]) & 0xFF]; 450 | } 451 | 452 | return (crc ^ (-1)) >>> 0; 453 | }; 454 | 455 | async function load_sram() { 456 | if (await rpc("has_sram")) { 457 | try { 458 | var sram_str = window.localStorage.getItem(g_game_checksum); 459 | if (sram_str) { 460 | var sram = JSON.parse(sram_str); 461 | await rpc("set_sram", [sram]); 462 | console.log("SRAM Loaded!", g_game_checksum); 463 | } 464 | } catch(e) { 465 | console.log("Local Storage is probably unavailable! SRAM saving and loading will not work."); 466 | } 467 | } 468 | } 469 | 470 | async function save_sram() { 471 | if (await rpc("has_sram")) { 472 | try { 473 | var sram_uint8 = await rpc("get_sram", [sram]); 474 | // Make it a normal array 475 | var sram = []; 476 | for (var i = 0; i < sram_uint8.length; i++) { 477 | sram[i] = sram_uint8[i]; 478 | } 479 | window.localStorage.setItem(g_game_checksum, JSON.stringify(sram)); 480 | console.log("SRAM Saved!", g_game_checksum); 481 | } catch(e) { 482 | console.log("Local Storage is probably unavailable! SRAM saving and loading will not work."); 483 | } 484 | } 485 | } 486 | 487 | function save_sram_periodically() { 488 | save_sram(); 489 | } 490 | 491 | // ========== User Interface ========== 492 | 493 | // This runs *around* once per second, ish. It's fine. 494 | function compute_fps() { 495 | let counter_element = document.querySelector("#fps-counter"); 496 | if (counter_element != null) { 497 | counter_element.innerText = "FPS: " + g_frames_since_last_fps_count; 498 | } 499 | g_frames_since_last_fps_count = 0; 500 | } 501 | 502 | function clearTabs() { 503 | var buttons = document.querySelectorAll("#main_menu button"); 504 | buttons.forEach(function(button) { 505 | button.classList.remove("active"); 506 | }); 507 | 508 | var tabs = document.querySelectorAll("div.tab_content"); 509 | tabs.forEach(function(tab) { 510 | tab.classList.remove("active"); 511 | }); 512 | } 513 | 514 | function switchToTab(tab_name) { 515 | tab_elements = document.getElementsByName(tab_name); 516 | if (tab_elements.length == 1) { 517 | clearTabs(); 518 | tab_elements[0].classList.add("active"); 519 | content_element = document.getElementById(tab_name); 520 | content_element.classList.add("active"); 521 | } 522 | } 523 | 524 | function clickTab() { 525 | let tabName = this.getAttribute("name"); 526 | if (tabName == "fullscreen") { 527 | switchToTab("playfield"); 528 | enterFullscreen(); 529 | } else { 530 | switchToTab(tabName); 531 | } 532 | } 533 | 534 | function enterFullscreen() { 535 | var viewport = document.querySelector("#playfield"); 536 | if (viewport.requestFullscreen) { 537 | viewport.requestFullscreen(); 538 | } else if (viewport.mozRequestFullScreen) { 539 | viewport.mozRequestFullScreen(); 540 | } else if (viewport.webkitRequestFullscreen) { 541 | viewport.webkitRequestFullscreen(); 542 | } 543 | } 544 | 545 | function handleFullscreenSwitch() { 546 | if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) { 547 | console.log("Entering fullscreen..."); 548 | // Entering fullscreen 549 | var viewport = document.querySelector("#playfield"); 550 | viewport.classList.add("fullscreen"); 551 | viewport.classList.remove("horizontal"); 552 | viewport.classList.remove("vertical"); 553 | if (is_touch_detected) { 554 | viewport.classList.add("touchscreen"); 555 | } else { 556 | viewport.classList.remove("touchscreen"); 557 | } 558 | 559 | setTimeout(function() { 560 | var viewport = document.querySelector("#playfield"); 561 | 562 | var viewport_width = viewport.clientWidth; 563 | var viewport_height = viewport.clientHeight; 564 | 565 | var canvas_container = document.querySelector("#playfield div.canvas_container"); 566 | if ((viewport_width / 256) * 240 > viewport_height) { 567 | var target_height = viewport_height; 568 | var target_width = target_height / 240 * 256; 569 | canvas_container.style.width = target_width + "px"; 570 | canvas_container.style.height = target_height + "px"; 571 | viewport.classList.add("horizontal"); 572 | } else { 573 | var target_width = viewport_width; 574 | var target_height = target_width / 256 * 240; 575 | canvas_container.style.width = target_width + "px"; 576 | canvas_container.style.height = target_height + "px"; 577 | viewport.classList.add("vertical"); 578 | } 579 | }, 100); 580 | } else { 581 | // Exiting fullscreen 582 | console.log("Exiting fullscreen..."); 583 | var viewport = document.querySelector("#playfield"); 584 | var canvas_container = document.querySelector("#playfield div.canvas_container"); 585 | viewport.classList.remove("fullscreen"); 586 | canvas_container.style.width = ""; 587 | canvas_container.style.height = ""; 588 | } 589 | } 590 | 591 | function hide_banners() { 592 | banner_elements = document.querySelectorAll(".banner"); 593 | banner_elements.forEach(function(banner) { 594 | banner.classList.remove("active"); 595 | }); 596 | } 597 | 598 | function display_banner(cartridge_name) { 599 | hide_banners(); 600 | banner_elements = document.getElementsByName(cartridge_name); 601 | if (banner_elements.length == 1) { 602 | banner_elements[0].classList.add("active"); 603 | } 604 | } 605 | 606 | function handle_piano_roll_window_click(e) { 607 | rpc("handle_piano_roll_window_click", [e.offsetX / 2, e.offsetY / 2]).then(); 608 | } -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/style.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | width: 100%; 3 | margin: 0px; 4 | padding: 0px; 5 | font-family: sans-serif; 6 | } 7 | 8 | html { 9 | background-color: #080808; 10 | height: 100%; 11 | } 12 | 13 | body { 14 | background-color: #030303; 15 | color: #FDF0F1; 16 | box-shadow: 0 0 16px #040404; 17 | width: 1200px; 18 | height: 100%; 19 | margin-left: auto; 20 | margin-right: auto; 21 | position: relative; 22 | } 23 | 24 | #header { 25 | position: relative; 26 | padding-left: 16px; 27 | padding-right: 16px; 28 | padding-top: 31px; 29 | } 30 | 31 | #header h1 { 32 | color: #666; 33 | font-size: 25px; 34 | margin: 0px; 35 | padding: 0px; 36 | line-height: 25px; 37 | flex-grow: 0; 38 | } 39 | 40 | #navbar { 41 | height: 64px; 42 | display: flex; 43 | flex-direction: row; 44 | position: relative; 45 | background-color: #040404; 46 | margin-top: 0px; 47 | border-bottom: 2px solid #222; 48 | background-image: url("wavy_grid_gradient.png"); 49 | } 50 | 51 | #main_menu { 52 | display: flex; 53 | flex-direction: row; 54 | width: 100%; 55 | margin: 0px; 56 | padding: 0px; 57 | flex-grow: 1; 58 | } 59 | 60 | #credits { 61 | height: 48px; 62 | padding-top: 8px; 63 | font-size: 0.6em; 64 | width: 450px; 65 | text-align: right; 66 | padding-right: 12px; 67 | font-weight: bold; 68 | color: #444; 69 | } 70 | 71 | #credits a { 72 | color: #444; 73 | text-decoration: none; 74 | } 75 | 76 | #credits a:hover { 77 | color: #666; 78 | } 79 | 80 | #credits img { 81 | display: inline; 82 | margin-left: 4px; 83 | margin-right: 4px; 84 | } 85 | 86 | #main_menu li { 87 | padding-top: 16px; 88 | height: 32px; 89 | display: block; 90 | margin: 6px; 91 | text-align: right; 92 | text-transform: uppercase; 93 | font-weight: bold; 94 | } 95 | 96 | #main_menu li button, #main_menu li label { 97 | display: block; 98 | padding-top: 10px; 99 | padding-left: 15px; 100 | padding-right: 5px; 101 | padding-bottom: 5px; 102 | font-size: 12px; 103 | font-weight: bold; 104 | border-top: none; 105 | border-left: none; 106 | border-right: 3px solid #181818; 107 | border-bottom: 3px solid #111; 108 | background-color: #222; 109 | color: #888; 110 | text-decoration: none; 111 | text-transform: uppercase; 112 | cursor: pointer; 113 | } 114 | 115 | #main_menu li button:hover, #main_menu li label:hover { 116 | background-color: #333; 117 | } 118 | 119 | #main_menu li button.active { 120 | background-color: #181818; 121 | border-right: 1px solid #0C0C0C; 122 | border-bottom: 1px solid #080808; 123 | color: #666; 124 | /* Transparent top-left, for offset purposes */ 125 | border-top: 2px solid rgba(0, 0, 0, 0); 126 | border-left: 2px solid rgba(0, 0, 0, 0); 127 | } 128 | 129 | #main_menu li button.active:hover { 130 | background-color: #222; 131 | } 132 | 133 | #main_menu #power_light { 134 | display: flex; 135 | align-items: center; 136 | border: none; 137 | box-shadow: none; 138 | } 139 | 140 | #main_menu #power_light #led { 141 | width: 10px; 142 | height: 10px; 143 | margin-left: 10px; 144 | border: 1px solid #1B1917; 145 | background-color: #353233; 146 | } 147 | 148 | #main_menu #power_light #led.powered { 149 | background-color: #84B; 150 | box-shadow: 0px 0px 4px #629; 151 | } 152 | 153 | #content_area { 154 | position: relative; 155 | height: calc(100% - 66px); 156 | overflow: auto; 157 | } 158 | 159 | .tab_content { 160 | display: none; 161 | color: #dddddd; 162 | } 163 | 164 | .tab_content.active { 165 | display: block; 166 | } 167 | 168 | #playfield.active { 169 | display: flex; 170 | height: 100%; 171 | flex-direction: column; 172 | justify-content: center; 173 | } 174 | 175 | #playfield.fullscreen { 176 | width: 100%; 177 | height: 100%; 178 | padding-top: 0px; 179 | background-color: black; 180 | } 181 | 182 | #playfield div.canvas_container { 183 | position: relative; 184 | width: 768px; 185 | height: 720px; 186 | margin-left: auto; 187 | margin-right: auto; 188 | overflow: hidden; 189 | } 190 | 191 | #jam.active { 192 | display: flex; 193 | height: 100%; 194 | flex-direction: column; 195 | justify-content: center; 196 | } 197 | 198 | #jam div.canvas_container { 199 | position: relative; 200 | overflow: hidden; 201 | } 202 | 203 | #jam div.jam_pixels_container { 204 | width: 256px; 205 | height: 240px; 206 | overflow: hidden; 207 | } 208 | 209 | #jam div.jam_apu_container { 210 | width: 240px; 211 | height: 500px; 212 | overflow: hidden; 213 | } 214 | 215 | #jam div.piano_roll_container { 216 | width: 960px; 217 | height: 540px; 218 | overflow: hidden; 219 | } 220 | 221 | div.canvas_container img.overlay { 222 | display: block; 223 | position: absolute; 224 | top: 0px; 225 | left: 0px; 226 | width: 100%; 227 | height: 100%; 228 | 229 | image-rendering: -moz-crisp-edges; 230 | image-rendering: -o-crisp-edges; 231 | image-rendering: -webkit-optimize-contrast; 232 | -ms-interpolation-mode: nearest-neighbor; 233 | image-rendering: pixelated; 234 | } 235 | 236 | canvas { 237 | width: 100%; 238 | height: 100%; 239 | 240 | image-rendering: -moz-crisp-edges; 241 | image-rendering: -o-crisp-edges; 242 | image-rendering: -webkit-optimize-contrast; 243 | -ms-interpolation-mode: nearest-neighbor; 244 | image-rendering: pixelated; 245 | } 246 | 247 | input#file-loader { 248 | width: 0.1px; 249 | height: 0.1px; 250 | opacity: 0; 251 | overflow: hidden; 252 | position: absolute; 253 | z-index: -1; 254 | } 255 | 256 | .flex_columns { 257 | display: flex; 258 | flex-direction: row; 259 | } 260 | 261 | .flex_rows { 262 | display: flex; 263 | flex-direction: column; 264 | } 265 | 266 | #jam .flex_columns { 267 | justify-content: center; 268 | } 269 | 270 | #jam .flex_rows { 271 | justify-content: center; 272 | } 273 | 274 | #jam .flex_columns div { 275 | margin: 8px; 276 | } 277 | 278 | #configure_input .flex_columns div { 279 | margin: auto; 280 | } 281 | 282 | #configure_input { 283 | padding-left: 10px; 284 | padding-right: 10px; 285 | } 286 | 287 | #configure_input td { 288 | text-align: right; 289 | padding: 3px; 290 | padding-left: 10px; 291 | } 292 | 293 | #configure_input button { 294 | width: 300px; 295 | border: none; 296 | border-radius: 9px; 297 | padding: 2px; 298 | background-color: #727272; 299 | color: #77221A; 300 | font-weight: bold; 301 | text-transform: uppercase; 302 | } 303 | 304 | .banner { 305 | display: none; 306 | } 307 | 308 | .banner.active { 309 | display: block; 310 | height: 30px; 311 | line-height: 30px; 312 | text-align: center; 313 | background-color: #111; 314 | border-bottom: 2px solid #222; 315 | color: #555; 316 | font-size: 11px; 317 | font-weight: bold; 318 | } 319 | 320 | 321 | .banner a { 322 | color: #777; 323 | text-decoration: none; 324 | } 325 | 326 | .banner a:hover { 327 | color: #999; 328 | } 329 | 330 | .banner.active.error { 331 | background-color: #311; 332 | border-bottom: 2px solid #622; 333 | color: #755; 334 | } 335 | 336 | .debug-box { 337 | display: none; 338 | } 339 | 340 | .debug-box.active { 341 | display: block; 342 | text-align: center; 343 | background-color: #441; 344 | border-bottom: 2px solid #552; 345 | color: #885; 346 | font-size: 11px; 347 | font-weight: bold; 348 | } 349 | 350 | .debug-box pre { 351 | text-align: left; 352 | width: 200px; 353 | margin-left: 500px; 354 | } -------------------------------------------------------------------------------- /rusticnes-itch.io-embed/touch.js: -------------------------------------------------------------------------------- 1 | is_touch_detected = false; 2 | touch_button_elements = []; 3 | dpad_elements = []; 4 | active_touches = {}; 5 | 6 | stickiness_radius = 5; // pixels, ish 7 | 8 | dpad_inner_deadzone_percent = 0.25; 9 | dpad_extra_radius_percent = 0.10; 10 | dpad_sticky_degrees = 5; 11 | dpad_cardinal_priority_degrees = 10; 12 | 13 | function register_touch_button(querystring) { 14 | var button_element = document.querySelector(querystring); 15 | if (button_element) { 16 | touch_button_elements.push(button_element); 17 | } else { 18 | console.log("Could not find element ", querystring); 19 | } 20 | } 21 | 22 | function register_d_pad(querystring) { 23 | var dpad_element = document.querySelector(querystring); 24 | if (dpad_element) { 25 | dpad_elements.push(dpad_element); 26 | } else { 27 | console.log("Could not find element ", querystring); 28 | } 29 | } 30 | 31 | // Relative to the viewport 32 | function element_centerpoint(element) { 33 | let rect = element.getBoundingClientRect(); 34 | let cx = rect.left + (rect.width / 2); 35 | let cy = rect.top + (rect.height / 2); 36 | return [cx, cy] 37 | } 38 | 39 | function element_radius(element) { 40 | let rect = element.getBoundingClientRect(); 41 | let longest_side = Math.max(rect.width, rect.height) 42 | return longest_side / 2; 43 | } 44 | 45 | function angle_to_element(touch, element) { 46 | let [cx, cy] = element_centerpoint(element); 47 | let tx = touch.clientX; 48 | let ty = touch.clientY; 49 | let dx = tx - cx; 50 | let dy = ty - cy; 51 | let angle_radians = Math.atan2(dy * -1, dx); 52 | let angle_degrees = angle_radians * 180 / Math.PI; 53 | if (angle_degrees < 0) { 54 | angle_degrees += 360.0; 55 | } 56 | return angle_degrees; 57 | } 58 | 59 | function is_inside_button(touch, element) { 60 | let [cx, cy] = element_centerpoint(element); 61 | let radius = element_radius(element); 62 | let tx = touch.clientX; 63 | let ty = touch.clientY; 64 | let dx = tx - cx; 65 | let dy = ty - cy; 66 | let distance_squared = (dx * dx) + (dy * dy) 67 | let radius_squared = radius * radius; 68 | return distance_squared < radius_squared; 69 | } 70 | 71 | function is_stuck_to_button(touch, element) { 72 | // Very similar to is_inside_element, but with the stickiness radius applied 73 | let [cx, cy] = element_centerpoint(element); 74 | let radius = element_radius(element) + stickiness_radius; 75 | let tx = touch.clientX; 76 | let ty = touch.clientY; 77 | let dx = tx - cx; 78 | let dy = ty - cy; 79 | let distance_squared = (dx * dx) + (dy * dy) 80 | let radius_squared = radius * radius; 81 | return distance_squared < radius_squared; 82 | } 83 | 84 | function is_inside_dpad(touch, element) { 85 | let [cx, cy] = element_centerpoint(element); 86 | let base_radius = element_radius(element) + stickiness_radius; 87 | let outer_radius = base_radius + (base_radius * dpad_extra_radius_percent); 88 | let deadzone_radius = base_radius * dpad_inner_deadzone_percent; 89 | let tx = touch.clientX; 90 | let ty = touch.clientY; 91 | let dx = tx - cx; 92 | let dy = ty - cy; 93 | let distance_squared = (dx * dx) + (dy * dy) 94 | let outer_radius_squared = outer_radius * outer_radius; 95 | let deadzone_radius_squared = deadzone_radius * deadzone_radius; 96 | return ((distance_squared > deadzone_radius_squared) && (distance_squared < outer_radius_squared)); 97 | } 98 | 99 | function is_sticky_dpad(angle) { 100 | // (ordered counter-clockwise, starting with 0 degrees "east") 101 | 102 | // East is split along the X axis 103 | if (angle < (22.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 104 | return false; 105 | } 106 | if (angle > (337.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees)) { 107 | return false; 108 | } 109 | 110 | // North East 111 | if (angle > (22.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (67.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 112 | return false; 113 | } 114 | 115 | // North 116 | if (angle > (67.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (112.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 117 | return false; 118 | } 119 | 120 | // North West 121 | if (angle > (112.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (157.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 122 | return false; 123 | } 124 | 125 | // West 126 | if (angle > (157.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (202.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 127 | return false; 128 | } 129 | 130 | // South West 131 | if (angle > (202.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (247.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 132 | return false; 133 | } 134 | 135 | // South 136 | if (angle > (247.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (292.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 137 | return false; 138 | } 139 | 140 | // South East 141 | if (angle > (292.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees) && angle < (337.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees)) { 142 | return false; 143 | } 144 | 145 | return true; 146 | } 147 | 148 | function dpad_directions(touch, element, old_directions) { 149 | let has_previous_directions = old_directions.length > 0; 150 | let dpad_angle = angle_to_element(touch, element); 151 | if (has_previous_directions && is_sticky_dpad(dpad_angle)) { 152 | return old_directions; 153 | } 154 | 155 | // East is split along the X axis 156 | if (dpad_angle < (22.5 + dpad_cardinal_priority_degrees)) { 157 | return ["right"]; 158 | } 159 | if (dpad_angle > (337.5 - dpad_cardinal_priority_degrees)) { 160 | return ["right"]; 161 | } 162 | 163 | // North East 164 | if (dpad_angle > (22.5 + dpad_cardinal_priority_degrees) && dpad_angle < (67.5 - dpad_cardinal_priority_degrees)) { 165 | return ["up", "right"]; 166 | } 167 | 168 | // North 169 | if (dpad_angle > (67.5 - dpad_cardinal_priority_degrees) && dpad_angle < (112.5 + dpad_cardinal_priority_degrees)) { 170 | return ["up"]; 171 | } 172 | 173 | // North West 174 | if (dpad_angle > (112.5 + dpad_cardinal_priority_degrees) && dpad_angle < (157.5 - dpad_cardinal_priority_degrees)) { 175 | return ["up", "left"]; 176 | } 177 | 178 | // West 179 | if (dpad_angle > (157.5 - dpad_cardinal_priority_degrees) && dpad_angle < (202.5 + dpad_cardinal_priority_degrees)) { 180 | return ["left"]; 181 | } 182 | 183 | // South West 184 | if (dpad_angle > (202.5 + dpad_cardinal_priority_degrees) && dpad_angle < (247.5 - dpad_cardinal_priority_degrees)) { 185 | return ["down", "left"]; 186 | } 187 | 188 | // South 189 | if (dpad_angle > (247.5 - dpad_cardinal_priority_degrees) && dpad_angle < (292.5 + dpad_cardinal_priority_degrees)) { 190 | return ["down"]; 191 | } 192 | 193 | // South East 194 | if (dpad_angle > (292.5 + dpad_cardinal_priority_degrees) && dpad_angle < (337.5 - dpad_cardinal_priority_degrees)) { 195 | return ["down", "right"]; 196 | } 197 | 198 | // We really *shouldn't* get here, but... in case floating point gnargles, return old directions, 199 | // just so we don't have glitchy dropped inputs on boundaries 200 | return old_directions; 201 | 202 | } 203 | 204 | function initialize_touch(querystring) { 205 | var touch_root_element = document.querySelector(querystring); 206 | touch_root_element.addEventListener('touchstart', handleTouchEvent) 207 | touch_root_element.addEventListener('touchend', handleTouchEvent) 208 | touch_root_element.addEventListener('touchmove', handleTouchEvent) 209 | touch_root_element.addEventListener('touchcancel', handleTouchEvent) 210 | } 211 | 212 | function handleTouches(touches, event) { 213 | // First, prune any touches that got released, and add (empty) touches for 214 | // new identifiers 215 | pruned_touches = {} 216 | for (let touch of touches) { 217 | if (active_touches.hasOwnProperty(touch.identifier)) { 218 | // If this touch is previously tracked, copy that info 219 | pruned_touches[touch.identifier] = active_touches[touch.identifier]; 220 | } else { 221 | // Otherwise this is a new touch; initialize it accordingly 222 | pruned_touches[touch.identifier] = {"button": null, "dpad": null, "directions": []}; 223 | } 224 | 225 | // For buttons, first check for and handle the sticky radius. If we're still inside this, 226 | // do not attempt to switch to a new button 227 | if (pruned_touches[touch.identifier].button != null) { 228 | let button_element = document.getElementById(pruned_touches[touch.identifier].button); 229 | if (!(is_stuck_to_button(touch, button_element))) { 230 | pruned_touches[touch.identifier].button = null; 231 | } 232 | } 233 | 234 | // If we have no active button for this touch, check all buttons and see if we can't find 235 | // a new one. If so, activate it 236 | if (pruned_touches[touch.identifier].button == null) { 237 | for (let button_element of touch_button_elements) { 238 | if (is_inside_button(touch, button_element)) { 239 | pruned_touches[touch.identifier].button = button_element.id; 240 | event.preventDefault(); 241 | } 242 | } 243 | } 244 | 245 | // D-pads are slightly more complicated. First, if we have an active D-Pad but we've left 246 | // its area, deactivate it 247 | if (pruned_touches[touch.identifier].dpad != null) { 248 | let dpad_element = document.getElementById(pruned_touches[touch.identifier].dpad); 249 | if (!(is_inside_dpad(touch, dpad_element))) { 250 | pruned_touches[touch.identifier].dpad = null; 251 | } 252 | } 253 | 254 | // If we do *not* have an active D-pad, check to see if we are inside one and, if so, 255 | // activate it with no direction 256 | if (pruned_touches[touch.identifier].dpad == null) { 257 | for (let dpad_element of dpad_elements) { 258 | if (is_inside_dpad(touch, dpad_element)) { 259 | pruned_touches[touch.identifier].dpad = dpad_element.id; 260 | event.preventDefault(); 261 | } 262 | } 263 | } 264 | 265 | // Finally, with our active D-pad, collect and set the directions 266 | if (pruned_touches[touch.identifier].dpad != null) { 267 | let dpad_element = document.getElementById(pruned_touches[touch.identifier].dpad); 268 | let old_directions = pruned_touches[touch.identifier].directions; 269 | pruned_touches[touch.identifier].directions = dpad_directions(touch, dpad_element, old_directions); 270 | event.preventDefault(); 271 | } 272 | 273 | } 274 | // At this point any released touch points should not have been copied to the list, 275 | // so swapping lists will prune them 276 | active_touches = pruned_touches; 277 | 278 | process_active_touch_regions(); 279 | } 280 | 281 | function clear_active_classes() { 282 | for (let el of touch_button_elements) { 283 | el.classList.remove("active"); 284 | } 285 | for (let el of dpad_elements) { 286 | for (let direction of ["up", "down", "left", "right"]) { 287 | let pad_element = document.getElementById(el.id + "_" + direction); 288 | pad_element.classList.remove("active"); 289 | } 290 | } 291 | } 292 | 293 | function process_active_touch_regions() { 294 | clear_active_classes(); 295 | for (let touch_identifier in active_touches) { 296 | active_touch = active_touches[touch_identifier]; 297 | if (active_touch.button != null) { 298 | let button_element = document.getElementById(active_touch.button); 299 | button_element.classList.add("active"); 300 | } 301 | if (active_touch.dpad != null) { 302 | for (let direction of active_touch.directions) { 303 | let pad_element = document.getElementById(active_touch.dpad + "_" + direction); 304 | pad_element.classList.add("active"); 305 | } 306 | } 307 | } 308 | } 309 | 310 | function handleTouchEvent(event) { 311 | is_touch_detected = true; 312 | handleTouches(event.touches, event); 313 | } 314 | 315 | -------------------------------------------------------------------------------- /src/assets/overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zeta0134/rusticnes-wasm/54d5e6ea3d319ea6609d61c1807197692e2230c4/src/assets/overlay.png -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate lazy_static; 3 | extern crate rusticnes_core; 4 | extern crate rusticnes_ui_common; 5 | extern crate wasm_bindgen; 6 | 7 | use std::sync::Mutex; 8 | use std::rc::Rc; 9 | 10 | use rusticnes_core::palettes::NTSC_PAL; 11 | use rusticnes_core::apu::FilterType; 12 | use wasm_bindgen::prelude::*; 13 | 14 | use rusticnes_ui_common::application::RuntimeState; 15 | use rusticnes_ui_common::settings::SettingsState; 16 | use rusticnes_ui_common::events::Event; 17 | use rusticnes_ui_common::apu_window::ApuWindow; 18 | use rusticnes_ui_common::piano_roll_window::PianoRollWindow; 19 | 20 | use rusticnes_ui_common::panel::Panel; 21 | use rusticnes_ui_common::drawing::SimpleBuffer; 22 | 23 | const WASM_CONFIG: &str = r###" 24 | [piano_roll] 25 | canvas_width = 480 26 | canvas_height = 270 27 | key_length = 24 28 | key_thickness = 4 29 | octave_count = 9 30 | scale_factor = 1 31 | speed_multiplier = 1 32 | starting_octave = 0 33 | waveform_height = 32 34 | oscilloscope_glow_thickness = 2.5 35 | oscilloscope_line_thickness = 0.5 36 | divider_width = 2 37 | draw_text_labels = false 38 | draw_piano_strings = false 39 | "###; 40 | 41 | /* There is no "main" scope, so our application globals need to be actual globals. 42 | These will be resolved any time a JS event needs to use them; JavaScript will only 43 | ever process one event in a single thread, but Rust doesn't know that, so we get to 44 | use Mutexes for no particular reason. */ 45 | lazy_static! { 46 | static ref RUNTIME: Mutex = Mutex::new(RuntimeState::new()); 47 | static ref APU_WINDOW: Mutex = Mutex::new(ApuWindow::new()); 48 | static ref PIANO_ROLL_WINDOW: Mutex = Mutex::new(PianoRollWindow::new()); 49 | 50 | /* used for blitting the game window */ 51 | static ref CRT_OVERLAY: Mutex = Mutex::new(SimpleBuffer::from_raw(include_bytes!("assets/overlay.png"))); 52 | static ref GAME_RENDER: Mutex = Mutex::new(SimpleBuffer::new(256, 240)); 53 | } 54 | 55 | pub fn dispatch_event(event: Event, runtime: &mut RuntimeState) -> Vec { 56 | let mut responses: Vec = Vec::new(); 57 | 58 | let mut apu_window = APU_WINDOW.lock().expect("wat"); 59 | let mut piano_roll_window = PIANO_ROLL_WINDOW.lock().expect("wat"); 60 | 61 | // windows get an immutable reference to the runtime 62 | responses.extend(apu_window.handle_event(&runtime, event.clone())); 63 | responses.extend(piano_roll_window.handle_event(&runtime, event.clone())); 64 | 65 | // ... but RuntimeState needs a mutable reference to itself 66 | responses.extend(runtime.handle_event(event.clone())); 67 | 68 | return responses; 69 | } 70 | 71 | pub fn resolve_events(mut events: Vec, runtime: &mut RuntimeState) { 72 | while events.len() > 0 { 73 | let event = events.remove(0); 74 | let responses = dispatch_event(event, runtime); 75 | events.extend(responses); 76 | } 77 | } 78 | 79 | #[wasm_bindgen] 80 | pub fn wasm_init() { 81 | let mut runtime = RUNTIME.lock().expect("wat"); 82 | 83 | runtime.settings = SettingsState::new(); 84 | let settings_events = runtime.settings.apply_settings(); 85 | resolve_events(settings_events, &mut runtime); 86 | 87 | runtime.settings.load_str(WASM_CONFIG); 88 | let settings_events = runtime.settings.apply_settings(); 89 | resolve_events(settings_events, &mut runtime); 90 | } 91 | 92 | #[wasm_bindgen] 93 | pub fn load_rom(cart_data: &[u8]) { 94 | let mut runtime = RUNTIME.lock().expect("wat"); 95 | let mut events: Vec = Vec::new(); 96 | let bucket_of_nothing: Vec = Vec::new(); 97 | let cartridge_data = cart_data.to_vec(); 98 | events.push(Event::LoadCartridge("cartridge".to_string(), Rc::new(cartridge_data), Rc::new(bucket_of_nothing))); 99 | resolve_events(events, &mut runtime); 100 | } 101 | 102 | #[wasm_bindgen] 103 | pub fn run_until_vblank() { 104 | let mut runtime = RUNTIME.lock().expect("wat"); 105 | while runtime.nes.ppu.current_scanline == 242 { 106 | let mut events: Vec = Vec::new(); 107 | events.push(Event::NesRunScanline); 108 | resolve_events(events, &mut runtime); 109 | } 110 | while runtime.nes.ppu.current_scanline != 242 { 111 | let mut events: Vec = Vec::new(); 112 | events.push(Event::NesRunScanline); 113 | resolve_events(events, &mut runtime); 114 | } 115 | } 116 | 117 | #[wasm_bindgen] 118 | pub fn update_windows() { 119 | let mut runtime = RUNTIME.lock().expect("wat"); 120 | let mut events: Vec = Vec::new(); 121 | events.push(Event::Update); 122 | resolve_events(events, &mut runtime); 123 | } 124 | 125 | pub fn render_screen_pixels() { 126 | let runtime = RUNTIME.lock().expect("wat"); 127 | let nes = &runtime.nes; 128 | 129 | let overlay = CRT_OVERLAY.lock().expect("wat"); 130 | let mut render_canvas = GAME_RENDER.lock().expect("wat"); 131 | let pixels = &mut render_canvas.buffer; 132 | 133 | for x in 0 .. 256 { 134 | for y in 0 .. 240 { 135 | let palette_index = ((nes.ppu.screen[y * 256 + x]) as usize) * 3; 136 | let pixel_offset = (y * 256 + x) * 4; 137 | // overlay with direct buffer reading 138 | let alpha = overlay.buffer[pixel_offset] as u16; 139 | let background_color = [3, 3, 3]; 140 | let r = (((NTSC_PAL[palette_index + 0] as u16 * alpha) + (background_color[0] * (256 - alpha))) / 256) as u8; 141 | let g = (((NTSC_PAL[palette_index + 1] as u16 * alpha) + (background_color[1] * (256 - alpha))) / 256) as u8; 142 | let b = (((NTSC_PAL[palette_index + 2] as u16 * alpha) + (background_color[2] * (256 - alpha))) / 256) as u8; 143 | pixels[pixel_offset + 0] = r; 144 | pixels[pixel_offset + 1] = g; 145 | pixels[pixel_offset + 2] = b; 146 | pixels[((y * 256 + x) * 4) + 3] = 255; 147 | } 148 | } 149 | } 150 | 151 | #[wasm_bindgen] 152 | pub fn draw_screen_pixels(pixels: &mut [u8]) { 153 | render_screen_pixels(); 154 | let render_canvas = GAME_RENDER.lock().expect("wat"); 155 | pixels.copy_from_slice(&render_canvas.buffer[0..(256*240*4)]); 156 | } 157 | 158 | #[wasm_bindgen] 159 | pub fn draw_apu_window(dest: &mut [u8]) { 160 | let mut runtime = RUNTIME.lock().expect("wat"); 161 | let mut apu_window = APU_WINDOW.lock().expect("wat"); 162 | resolve_events(apu_window.handle_event(&runtime, Event::RequestFrame), &mut runtime); 163 | dest.copy_from_slice(&apu_window.active_canvas().buffer[0..(256*500*4)]); 164 | } 165 | 166 | #[wasm_bindgen] 167 | pub fn draw_piano_roll_window(dest: &mut [u8]) { 168 | let mut runtime = RUNTIME.lock().expect("wat"); 169 | let mut piano_roll_window = PIANO_ROLL_WINDOW.lock().expect("wat"); 170 | resolve_events(piano_roll_window.handle_event(&runtime, Event::RequestFrame), &mut runtime); 171 | dest.copy_from_slice(&piano_roll_window.active_canvas().buffer); 172 | } 173 | 174 | #[wasm_bindgen] 175 | pub fn set_p1_input(keystate: u8) { 176 | let mut runtime = RUNTIME.lock().expect("wat"); 177 | let nes = &mut runtime.nes; 178 | nes.p1_input = keystate; 179 | } 180 | 181 | #[wasm_bindgen] 182 | pub fn set_p2_input(keystate: u8) { 183 | let mut runtime = RUNTIME.lock().expect("wat"); 184 | let nes = &mut runtime.nes; 185 | nes.p2_input = keystate; 186 | } 187 | 188 | #[wasm_bindgen] 189 | pub fn set_audio_samplerate(sample_rate: u32) { 190 | let mut runtime = RUNTIME.lock().expect("wat"); 191 | let nes = &mut runtime.nes; 192 | 193 | nes.apu.set_sample_rate(sample_rate as u64); 194 | // while we're here, set the filter to low quality 195 | nes.apu.set_filter(FilterType::FamiCom, false); 196 | // and if this happens to be an N163 ROM, tell it not 197 | // to use multiplexing, as this sounds *awful* when passed 198 | // through the LQ filtering chain 199 | nes.mapper.audio_multiplexing(false); 200 | } 201 | 202 | #[wasm_bindgen] 203 | pub fn set_audio_buffersize(buffer_size: u32) { 204 | let mut runtime = RUNTIME.lock().expect("wat"); 205 | let nes = &mut runtime.nes; 206 | 207 | nes.apu.set_buffer_size(buffer_size as usize); 208 | } 209 | 210 | #[wasm_bindgen] 211 | pub fn audio_buffer_full() -> bool { 212 | let runtime = RUNTIME.lock().expect("wat"); 213 | let nes = &runtime.nes; 214 | 215 | return nes.apu.buffer_full; 216 | } 217 | 218 | #[wasm_bindgen] 219 | pub fn get_audio_buffer() -> Vec { 220 | let mut runtime = RUNTIME.lock().expect("wat"); 221 | let nes = &mut runtime.nes; 222 | 223 | nes.apu.buffer_full = false; 224 | return nes.apu.output_buffer.to_owned(); 225 | } 226 | 227 | #[wasm_bindgen] 228 | pub fn consume_audio_samples() -> Vec { 229 | let mut runtime = RUNTIME.lock().expect("wat"); 230 | let nes = &mut runtime.nes; 231 | 232 | return nes.apu.consume_samples(); 233 | } 234 | 235 | #[wasm_bindgen] 236 | pub fn get_sram() -> Vec { 237 | let runtime = RUNTIME.lock().expect("wat"); 238 | let nes = &runtime.nes; 239 | 240 | return nes.sram().to_owned(); 241 | } 242 | 243 | #[wasm_bindgen] 244 | pub fn set_sram(sram: Vec) { 245 | let mut runtime = RUNTIME.lock().expect("wat"); 246 | let nes = &mut runtime.nes; 247 | 248 | nes.set_sram(sram); 249 | } 250 | 251 | #[wasm_bindgen] 252 | pub fn has_sram() -> bool { 253 | let runtime = RUNTIME.lock().expect("wat"); 254 | let nes = &runtime.nes; 255 | 256 | return nes.mapper.has_sram(); 257 | } 258 | 259 | #[wasm_bindgen] 260 | pub fn piano_roll_window_click(mx: i32, my: i32) { 261 | let mut runtime = RUNTIME.lock().expect("wat"); 262 | let mut events: Vec = Vec::new(); 263 | let mut piano_roll_window = PIANO_ROLL_WINDOW.lock().expect("wat"); 264 | events.extend(piano_roll_window.handle_event(&runtime, Event::MouseClick(mx, my))); 265 | drop(piano_roll_window); 266 | resolve_events(events, &mut runtime); 267 | } --------------------------------------------------------------------------------