diff --git a/README.md b/README.md index c1230f2..f23c52d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ network multiplayer feature, so itโ€™s not completely useless. Screenshots +## WebAssembly Build + +WebAssembly version for modern browsers is available at https://maxpoletaev.github.io/dendy/. +It runs smoothly in modern browsers, though it does not support netplay in its +current form. (there was an [experimental][wasm-netplay] implementation of +netplay over WebRTC, but it was too slow and unreliable to be usable). + +[wasm-netplay]: https://drive.google.com/file/d/1r3ZY20L168u3djRMWA_KLMrY0eIr1ify/view?usp=sharing + ## Download You can download the latest pre-built binaries for Windows, macOS, and Linux diff --git a/apu/apu.go b/apu/apu.go index 25871c1..8d77fe5 100644 --- a/apu/apu.go +++ b/apu/apu.go @@ -134,7 +134,7 @@ func (a *APU) Output() float32 { out = f.do(out) } - return out * 5.0 + return clamp(out, -1, 1) } func (a *APU) Tick() { @@ -230,3 +230,12 @@ func (a *APU) LoadState(r *binario.Reader) error { r.ReadBoolTo(&a.frameIRQ), ) } + +func clamp(v float32, min, max float32) float32 { + if v < min { + return min + } else if v > max { + return max + } + return v +} diff --git a/cmd/dendy-wasm/main.go b/cmd/dendy-wasm/main.go index 2389bbe..17d6162 100644 --- a/cmd/dendy-wasm/main.go +++ b/cmd/dendy-wasm/main.go @@ -4,22 +4,21 @@ import ( _ "embed" "fmt" "log" - "runtime" "syscall/js" - "time" "unsafe" + "github.com/maxpoletaev/dendy/consts" "github.com/maxpoletaev/dendy/ines" "github.com/maxpoletaev/dendy/input" "github.com/maxpoletaev/dendy/system" ) const ( - debugFrameTime = false + audioBufferSize = 512 ) //go:embed nestest.nes -var bootROM []byte +var nestestROM []byte func create(joy *input.Joystick, romData []byte) (*system.System, error) { rom, err := ines.NewFromBuffer(romData) @@ -40,51 +39,60 @@ func create(joy *input.Joystick, romData []byte) (*system.System, error) { func main() { log.SetFlags(0) // disable timestamps joystick := input.NewJoystick() + audioBuf := make([]float32, audioBufferSize) - nes, err := create(joystick, bootROM) + nes, err := create(joystick, nestestROM) if err != nil { log.Fatalf("[ERROR] failed to initialize: %v", err) } + global := js.Global() + jsapi := global.Get("go") + jsapi.Set("AudioBufferSize", audioBufferSize) + jsapi.Set("AudioSampleRate", consts.AudioSamplesPerSecond) + var ( - mem runtime.MemStats - frameTimeSum time.Duration - frameCount uint + ticksCount int + sampleCount int ) - js.Global().Set("runFrame", js.FuncOf(func(this js.Value, args []js.Value) any { + jsapi.Set("RunFrame", js.FuncOf(func(this js.Value, args []js.Value) any { buttons := args[0].Int() - start := time.Now() - var framePtr uintptr - - for { - nes.Tick() - if nes.FrameReady() { - frame := nes.Frame() - joystick.SetButtons(uint8(buttons)) - framePtr = uintptr(unsafe.Pointer(&frame[0])) - break + frameReady := false + + for sampleCount < len(audioBuf) { + for ticksCount < consts.TicksPerAudioSample { + nes.Tick() + ticksCount++ + + if nes.FrameReady() { + joystick.SetButtons(uint8(buttons)) + frameReady = true + } } - } - if debugFrameTime { - frameTime := time.Since(start) - frameTimeSum += frameTime - frameCount++ - - if frameCount%120 == 0 { - runtime.ReadMemStats(&mem) - avgFrameTime := frameTimeSum / time.Duration(frameCount) - log.Printf("[INFO] frame time: %v, memory: %d", avgFrameTime, mem.HeapAlloc) - frameTimeSum = 0 - frameCount = 0 + audioBuf[sampleCount] = nes.AudioSample() + sampleCount++ + ticksCount = 0 + + if frameReady { + return true } } - return framePtr + sampleCount = 0 + return false + })) + + jsapi.Set("GetFrameBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any { + return uintptr(unsafe.Pointer(&nes.Frame()[0])) + })) + + jsapi.Set("GetAudioBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any { + return uintptr(unsafe.Pointer(&audioBuf[0])) })) - js.Global().Set("uploadROM", js.FuncOf(func(this js.Value, args []js.Value) any { + jsapi.Set("LoadROM", js.FuncOf(func(this js.Value, args []js.Value) any { data := js.Global().Get("Uint8Array").New(args[0]) romData := make([]byte, data.Length()) js.CopyBytesToGo(romData, data) diff --git a/web/audio.js b/web/audio.js new file mode 100644 index 0000000..3d9a106 --- /dev/null +++ b/web/audio.js @@ -0,0 +1,40 @@ +class AudioProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.position = 0; + this.buffers = []; + this.current = null; + + this.port.onmessage = (e) => { + this.buffers.push(e.data); + if (this.buffers.length > 3) { + this.buffers.shift(); + } + }; + } + + process(inputs, outputs, parameters) { + let output = outputs[0]; + let channel = output[0]; + + if (!this.current || this.position >= this.current.length) { + this.current = this.buffers.shift(); + this.position = 0; + + if (!this.current) { + for (let i = 0; i < channel.length; i++) { + channel[i] = 0; + } + return true; + } + } + + for (let i = 0; i < channel.length; i++) { + channel[i] = this.current[this.position++]; + } + + return true; + } +} + +registerProcessor("audio-processor", AudioProcessor); \ No newline at end of file diff --git a/web/index.html b/web/index.html index e15954a..789a1f2 100644 --- a/web/index.html +++ b/web/index.html @@ -13,13 +13,15 @@
+
- Select ROM (.nes): + Select ROM (.nes): +
diff --git a/web/main.js b/web/main.js index 6a0448c..95cf1d9 100644 --- a/web/main.js +++ b/web/main.js @@ -6,27 +6,67 @@ const documentReady = new Promise((resolve) => { } }); -const go = new Go(); +window.go = new Go(); const wasmReady = WebAssembly.instantiateStreaming(fetch("dendy.wasm"), go.importObject).then((result) => { go.run(result.instance); }); -Promise.all([wasmReady, documentReady]).then(() => { - const width = 256; - const height = 240; - const targetFPS = 60; - const scale = 2; +Promise.all([wasmReady, documentReady]).then(async () => { + const WIDTH = 256; + const HEIGHT = 240; + const TARGET_FPS = 60; + const SCALE = 2; + + const audioBufferSize = go.AudioBufferSize; + const audioSampleRate = go.AudioSampleRate; + + // ======================== + // Canvas setup + // ======================== let canvas = document.getElementById("canvas"); - canvas.width = width; - canvas.height = height; - canvas.style.width = width*scale + "px"; - canvas.style.height = height*scale + "px"; + canvas.width = WIDTH; + canvas.height = HEIGHT; + canvas.style.width = WIDTH * SCALE + "px"; + canvas.style.height = HEIGHT * SCALE + "px"; canvas.style.imageRendering = "pixelated"; let ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; - let buttonsPressed = 0; + + // ======================== + // Audio setup + // ======================== + + console.log(`[INFO] audio sample rate: ${audioSampleRate}, buffer size: ${audioBufferSize}`); + let audioCtx = new AudioContext({ + sampleRate: audioSampleRate, + }); + + await audioCtx.audioWorklet.addModule("audio.js"); + let audioNode = new AudioWorkletNode(audioCtx, "audio-processor"); + audioNode.connect(audioCtx.destination); + + // ======================== + // Mute/unmute button + // ======================== + + let unmuteButton = document.getElementById("unmute-button"); + if (audioCtx.state === "suspended") { + unmuteButton.style.display = "block"; + } + + document.addEventListener("click", function() { + if (audioCtx.state === "suspended") { + unmuteButton.style.display = "none"; + audioCtx.resume(); + } + }, {once: true}); + + + // ======================== + // Input handling + // ======================== const BUTTON_A = 1 << 0; const BUTTON_B = 1 << 1; @@ -48,6 +88,8 @@ Promise.all([wasmReady, documentReady]).then(() => { "KeyK": BUTTON_A, }; + let buttonsPressed = 0; + document.addEventListener("keydown", (event) => { if (keyMap[event.code]) { event.preventDefault(); @@ -62,52 +104,80 @@ Promise.all([wasmReady, documentReady]).then(() => { } }); + // ======================== + // ROM loading + // ======================== + let fileInput = document.getElementById("file-input"); - fileInput.addEventListener("input", function() { + fileInput.addEventListener("input", function () { this.files[0].arrayBuffer().then((buffer) => { let rom = new Uint8Array(buffer); - let ok = uploadROM(rom); + let ok = go.LoadROM(rom); if (!ok) { - alert("Invalid ROM file"); + alert("Invalid or unsupported ROM file"); this.value = ""; } }); - this.blur(); + this.blur(); // Avoid re-opening file dialog when pressing Enter }); if (fileInput.files.length > 0) { fileInput.files[0].arrayBuffer().then((buffer) => { let rom = new Uint8Array(buffer); - let ok = uploadROM(rom); + let ok = go.LoadROM(rom); if (!ok) { fileInput.value = ""; } }); } + // ======================== + // Game loop + // ======================== + function isInFocus() { return document.hasFocus() && document.visibilityState === "visible"; } - function gameLoop() { - let nextFrame = () => { - let start = performance.now(); + function getMemoryBuffer() { + return go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo + } + + function executeFrame() { + while (true) { + let frameReady = go.RunFrame(buttonsPressed); - if (isInFocus()) { - let framePtr = runFrame(buttonsPressed); - let memPtr = go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo - let image = new ImageData(new Uint8ClampedArray(memPtr, framePtr, width * height * 4), width, height); + if (frameReady) { + let framePtr = go.GetFrameBufferPtr(); + let image = new ImageData(new Uint8ClampedArray(getMemoryBuffer(), framePtr, WIDTH * HEIGHT * 4), WIDTH, HEIGHT); ctx.putImageData(image, 0, 0); + return } - let elapsed = performance.now() - start; - let nextTimeout = Math.max(0, (1000 / targetFPS) - elapsed); - setTimeout(nextFrame, nextTimeout); - }; + let audioBufPtr = go.GetAudioBufferPtr(); + let audioBuf = new Float32Array(getMemoryBuffer(), audioBufPtr, go.AudioBufferSize); + audioNode.port.postMessage(audioBuf.slice()); + } + } + + let lastFrameTime = performance.now(); + const frameTime = 1000 / TARGET_FPS; + + function loop() { + requestAnimationFrame(loop) - nextFrame(); + const now = performance.now() + const elapsed = now - lastFrameTime + if (elapsed < frameTime) return + + const excessTime = elapsed % frameTime + lastFrameTime = now - excessTime + + if (isInFocus()) { + executeFrame(); + } } - gameLoop(); + requestAnimationFrame(loop); }); diff --git a/web/style.css b/web/style.css index 37de3c9..e63b7c3 100644 --- a/web/style.css +++ b/web/style.css @@ -16,7 +16,7 @@ body { width: 512px; border-radius: 12px; overflow: hidden; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); + box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3); } .header { @@ -41,7 +41,7 @@ body { .game-window { height: 480px; - border-bottom: 2px solid #2d3748; + border-bottom: 1px solid #2d3748; } .game-window canvas { @@ -136,3 +136,17 @@ body { .source-link a { color: #4a5568; } + +.unmute { + position: absolute; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(5px); + border-radius: 20px; + padding: 10px; + color: white; + left: 50%; + transform: translateX(-50%); + bottom: 40px; + font-size: 1.1em; + white-space: nowrap; +} \ No newline at end of file