diff --git a/web/src/main.js b/web/src/main.js index 423d076..536951d 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -1,5 +1,4 @@ -import loaderUrl from "data-url:../../platform/bin/loader.wasm"; -import platformUrl from "data-url:../../platform/bin/platform.uw8"; +import MicroW8 from './microw8.js'; function setMessage(size, error) { let html = size ? `${size} bytes` : 'Insert cart'; @@ -9,314 +8,27 @@ function setMessage(size, error) { document.getElementById('message').innerHTML = html; } -let screen = document.getElementById('screen'); -let canvasCtx = screen.getContext('2d'); -let imageData = canvasCtx.createImageData(320, 240); - -let devkitMode; - -let cancelFunction; - -let currentData; - -let U8 = (d) => new Uint8Array(d); -let U32 = (d) => new Uint32Array(d); - -let pad = 0; -let keyHandler = (e) => { - let isKeyDown = e.type == 'keydown'; - let mask; - switch (e.code) { - case 'ArrowUp': - mask = 1; - break; - case 'ArrowDown': - mask = 2; - break; - case 'ArrowLeft': - mask = 4; - break; - case 'ArrowRight': - mask = 8; - break; - case 'KeyZ': - mask = 16; - break; - case 'KeyX': - mask = 32; - break; - case 'KeyA': - mask = 64; - break; - case 'KeyS': - mask = 128; - break; - case 'KeyR': - if (isKeyDown) { - runModule(currentData, true); - } - break; - case 'F9': - if(isKeyDown) { - screen.toBlob(blob => { - downloadBlob(blob, '.png'); - }); - } - e.preventDefault(); - break; - case 'F10': - if(isKeyDown) { - recordVideo(); - } - e.preventDefault(); - break; - } - - if (isKeyDown) { - pad |= mask; - } else { - pad &= ~mask; - } -}; -window.onkeydown = keyHandler; -window.onkeyup = keyHandler; - -async function runModule(data, keepUrl) { - if (cancelFunction) { - cancelFunction(); - cancelFunction = null; - } - - let cartridgeSize = data.byteLength; - - setMessage(cartridgeSize); - if (cartridgeSize == 0) { - return; - } - - currentData = data; - - let newURL = window.location.pathname; - if (cartridgeSize <= 1024 && !keepUrl) { - let dataString = ''; - for (let byte of U8(data)) { - dataString += String.fromCharCode(byte); - } - newURL += '#' + btoa(dataString); - - if (newURL != window.location.pathname + window.location.hash) { - history.pushState(null, null, newURL); - } - } - - screen.width = screen.width; - - try { - let memSize = { initial: 4 }; - if(!devkitMode) { - memSize.maximum = 4; - } - let memory = new WebAssembly.Memory({ initial: 4, maximum: devkitMode ? 16 : 4 }); - let memU8 = U8(memory.buffer); - - let importObject = { - env: { - memory - }, - }; - - let loader; - - let loadModuleData = (data) => { - if (loader && (!devkitMode || U8(data)[0] != 0)) { - memU8.set(U8(data)); - let length = loader.exports.load_uw8(data.byteLength); - data = new ArrayBuffer(length); - U8(data).set(memU8.slice(0, length)); - } - return data; - } - - let instantiate = async (data) => (await WebAssembly.instantiate(data, importObject)).instance; - - let loadModuleURL = async (url) => instantiate(loadModuleData(await (await fetch(url)).arrayBuffer())); - - loader = await loadModuleURL(loaderUrl); - - for (let n of ['acos', 'asin', 'atan', 'atan2', 'cos', 'exp', 'log', 'sin', 'tan', 'pow']) { - importObject.env[n] = Math[n]; - } - - for (let i = 9; i < 64; ++i) { - importObject.env['reserved' + i] = () => { }; - } - - for (let i = 0; i < 16; ++i) { - importObject.env['g_reserved' + i] = 0; - } - - data = loadModuleData(data); - - let platform_instance = await loadModuleURL(platformUrl); - - for (let name in platform_instance.exports) { - importObject.env[name] = platform_instance.exports[name] - } - - let instance = await instantiate(data); - - let buffer = U32(imageData.data.buffer); - - let startTime = Date.now(); - - let keepRunning = true; - cancelFunction = () => keepRunning = false; - - const timePerFrame = 1000 / 60; - let nextFrame = startTime; - - function mainloop() { - if (!keepRunning) { - return; - } - - try { - let now = Date.now(); - let restart = false; - if (now >= nextFrame) { - let gamepads = navigator.getGamepads(); - let gamepad = 0; - for (let i = 0; i < 4; ++i) { - let pad = gamepads[i]; - if (!pad) { - continue; - } - for (let j = 0; j < 8; ++j) { - let buttonIdx = (j + 12) % 16; - if (pad.buttons.length > buttonIdx && pad.buttons[buttonIdx].pressed) { - gamepad |= 1 << (i * 8 + j); - } - } - if (pad.axes.length > 1) { - for (let j = 0; j < 4; ++j) { - let v = pad.axes[1 - (j >> 1)]; - if (((j & 1) ? v : -v) > 0.5) { - gamepad |= 1 << (i * 8 + j); - } - } - } - if (pad.buttons.length > 9 && pad.buttons[9].pressed) { - restart = true; - } - } - - let u32Mem = U32(memory.buffer); - u32Mem[16] = now - startTime; - u32Mem[17] = pad | gamepad; - instance.exports.upd(); - platform_instance.exports.endFrame(); - - let palette = U32(memory.buffer.slice(0x13000, 0x13000 + 1024)); - for (let i = 0; i < 320 * 240; ++i) { - buffer[i] = palette[memU8[i + 120]] | 0xff000000; - } - canvasCtx.putImageData(imageData, 0, 0); - nextFrame = Math.max(nextFrame + timePerFrame, now); - } - - if (restart) { - runModule(currentData); - } else { - window.requestAnimationFrame(mainloop); - } - } catch (err) { - setMessage(cartridgeSize, err.toString()); - } - } - - mainloop(); - } catch (err) { - setMessage(cartridgeSize, err.toString()); - } -} - -function downloadBlob(blob, ext) { - let a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'microw8_' + new Date().toISOString() + ext; - a.click(); - URL.revokeObjectURL(a.href); -} - -let videoRecorder; -let videoStartTime; -function recordVideo() { - if(videoRecorder) { - videoRecorder.stop(); - videoRecorder = null; - return; - } - - videoRecorder = new MediaRecorder(screen.captureStream(), { - mimeType: 'video/webm', - videoBitsPerSecond: 25000000 - }); - - let chunks = []; - videoRecorder.ondataavailable = e => { - chunks.push(e.data); - }; - - let timer = document.getElementById("timer"); - timer.hidden = false; - timer.innerText = "00:00"; - - videoRecorder.onstop = () => { - timer.hidden = true; - downloadBlob(new Blob(chunks, {type: 'video/webm'}), '.webm'); - }; - - videoRecorder.start(); - videoStartTime = Date.now(); - - function updateTimer() { - if(!videoStartTime) { - return; - } - - let duration = Math.floor((Date.now() - videoStartTime) / 1000); - timer.innerText = Math.floor(duration / 60).toString().padStart(2, '0') + ':' + (duration % 60).toString().padStart(2, '0'); - - setTimeout(updateTimer, 1000); - } - - setTimeout(updateTimer, 1000); -} - -async function runModuleFromURL(url, keepUrl) { - let response = await fetch(url); - let type = response.headers.get('Content-Type'); - if(type && type.includes('html')) { - throw false; - } - runModule(await response.arrayBuffer(), keepUrl || devkitMode); -} +let uw8 = MicroW8(document.getElementById('screen'), { + setMessage, + keyboardElement: window, + timerElement: document.getElementById("timer"), +}); function runModuleFromHash() { let hash = window.location.hash.slice(1); if(hash == 'devkit') { - devkitMode = true; + uw8.setDevkitMode(true); return; } - devkitMode = false; + uw8.setDevkitMode(false); if (hash.length > 0) { if (hash.startsWith("url=")) { - runModuleFromURL(hash.slice(4), true); + uw8.runModuleFromURL(hash.slice(4), true); } else { - runModuleFromURL('data:;base64,' + hash); + uw8.runModuleFromURL('data:;base64,' + hash); } } else { - runModule(new ArrayBuffer(0)); + uw8.runModule(new ArrayBuffer(0)); } } @@ -331,7 +43,7 @@ let setupLoad = () => { fileInput.accept = '.wasm,.uw8,application/wasm'; fileInput.onchange = () => { if (fileInput.files.length > 0) { - runModuleFromURL(URL.createObjectURL(fileInput.files[0])); + uw8.runModuleFromURL(URL.createObjectURL(fileInput.files[0])); } }; fileInput.click(); @@ -345,7 +57,7 @@ let setupLoad = () => { let files = e.dataTransfer && e.dataTransfer.files; if(files && files.length == 1) { e.preventDefault(); - runModuleFromURL(URL.createObjectURL(e.dataTransfer.files[0])); + uw8.runModuleFromURL(URL.createObjectURL(e.dataTransfer.files[0])); } } @@ -367,7 +79,7 @@ if(location.hash.length != 0) { url += 'cart.uw8'; } try { - await runModuleFromURL(url, true); + await uw8.runModuleFromURL(url, true); } catch(e) { setupLoad(); } diff --git a/web/src/microw8.js b/web/src/microw8.js new file mode 100644 index 0000000..801367d --- /dev/null +++ b/web/src/microw8.js @@ -0,0 +1,314 @@ +import loaderUrl from "data-url:../../platform/bin/loader.wasm"; +import platformUrl from "data-url:../../platform/bin/platform.uw8"; + +export default function MicroW8(screen, config) { + let canvasCtx = screen.getContext('2d'); + let imageData = canvasCtx.createImageData(320, 240); + + let devkitMode = config.devkitMode; + + let cancelFunction; + + let currentData; + + let U8 = (d) => new Uint8Array(d); + let U32 = (d) => new Uint32Array(d); + + let pad = 0; + let keyboardElement = config.keyboardElement == undefined ? screen : config.keyboardElement; + if(keyboardElement) { + let keyHandler = (e) => { + let isKeyDown = e.type == 'keydown'; + let mask; + switch (e.code) { + case 'ArrowUp': + mask = 1; + break; + case 'ArrowDown': + mask = 2; + break; + case 'ArrowLeft': + mask = 4; + break; + case 'ArrowRight': + mask = 8; + break; + case 'KeyZ': + mask = 16; + break; + case 'KeyX': + mask = 32; + break; + case 'KeyA': + mask = 64; + break; + case 'KeyS': + mask = 128; + break; + case 'KeyR': + if (isKeyDown) { + runModule(currentData, true); + } + break; + case 'F9': + if(isKeyDown) { + screen.toBlob(blob => { + downloadBlob(blob, '.png'); + }); + } + e.preventDefault(); + break; + case 'F10': + if(isKeyDown) { + recordVideo(); + } + e.preventDefault(); + break; + } + + if (isKeyDown) { + pad |= mask; + } else { + pad &= ~mask; + } + }; + + keyboardElement.onkeydown = keyHandler; + keyboardElement.onkeyup = keyHandler; + } + + async function runModule(data, keepUrl) { + if (cancelFunction) { + cancelFunction(); + cancelFunction = null; + } + + let cartridgeSize = data.byteLength; + + config.setMessage(cartridgeSize); + if (cartridgeSize == 0) { + return; + } + + currentData = data; + + let newURL = window.location.pathname; + if (cartridgeSize <= 1024 && !keepUrl) { + let dataString = ''; + for (let byte of U8(data)) { + dataString += String.fromCharCode(byte); + } + newURL += '#' + btoa(dataString); + + if (newURL != window.location.pathname + window.location.hash) { + history.pushState(null, null, newURL); + } + } + + screen.width = screen.width; + + try { + let memSize = { initial: 4 }; + if(!devkitMode) { + memSize.maximum = 4; + } + let memory = new WebAssembly.Memory({ initial: 4, maximum: devkitMode ? 16 : 4 }); + let memU8 = U8(memory.buffer); + + let importObject = { + env: { + memory + }, + }; + + let loader; + + let loadModuleData = (data) => { + if (loader && (!devkitMode || U8(data)[0] != 0)) { + memU8.set(U8(data)); + let length = loader.exports.load_uw8(data.byteLength); + data = new ArrayBuffer(length); + U8(data).set(memU8.slice(0, length)); + } + return data; + } + + let instantiate = async (data) => (await WebAssembly.instantiate(data, importObject)).instance; + + let loadModuleURL = async (url) => instantiate(loadModuleData(await (await fetch(url)).arrayBuffer())); + + loader = await loadModuleURL(loaderUrl); + + for (let n of ['acos', 'asin', 'atan', 'atan2', 'cos', 'exp', 'log', 'sin', 'tan', 'pow']) { + importObject.env[n] = Math[n]; + } + + for (let i = 9; i < 64; ++i) { + importObject.env['reserved' + i] = () => { }; + } + + for (let i = 0; i < 16; ++i) { + importObject.env['g_reserved' + i] = 0; + } + + data = loadModuleData(data); + + let platform_instance = await loadModuleURL(platformUrl); + + for (let name in platform_instance.exports) { + importObject.env[name] = platform_instance.exports[name] + } + + let instance = await instantiate(data); + + let buffer = U32(imageData.data.buffer); + + let startTime = Date.now(); + + let keepRunning = true; + cancelFunction = () => keepRunning = false; + + const timePerFrame = 1000 / 60; + let nextFrame = startTime; + + function mainloop() { + if (!keepRunning) { + return; + } + + try { + let now = Date.now(); + let restart = false; + if (now >= nextFrame) { + let gamepads = navigator.getGamepads(); + let gamepad = 0; + for (let i = 0; i < 4; ++i) { + let pad = gamepads[i]; + if (!pad) { + continue; + } + for (let j = 0; j < 8; ++j) { + let buttonIdx = (j + 12) % 16; + if (pad.buttons.length > buttonIdx && pad.buttons[buttonIdx].pressed) { + gamepad |= 1 << (i * 8 + j); + } + } + if (pad.axes.length > 1) { + for (let j = 0; j < 4; ++j) { + let v = pad.axes[1 - (j >> 1)]; + if (((j & 1) ? v : -v) > 0.5) { + gamepad |= 1 << (i * 8 + j); + } + } + } + if (pad.buttons.length > 9 && pad.buttons[9].pressed) { + restart = true; + } + } + + let u32Mem = U32(memory.buffer); + u32Mem[16] = now - startTime; + u32Mem[17] = pad | gamepad; + instance.exports.upd(); + platform_instance.exports.endFrame(); + + let palette = U32(memory.buffer.slice(0x13000, 0x13000 + 1024)); + for (let i = 0; i < 320 * 240; ++i) { + buffer[i] = palette[memU8[i + 120]] | 0xff000000; + } + canvasCtx.putImageData(imageData, 0, 0); + nextFrame = Math.max(nextFrame + timePerFrame, now); + } + + if (restart) { + runModule(currentData); + } else { + window.requestAnimationFrame(mainloop); + } + } catch (err) { + config.setMessage(cartridgeSize, err.toString()); + } + } + + mainloop(); + } catch (err) { + config.setMessage(cartridgeSize, err.toString()); + } + } + + function downloadBlob(blob, ext) { + let a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'microw8_' + new Date().toISOString() + ext; + a.click(); + URL.revokeObjectURL(a.href); + } + + let videoRecorder; + let videoStartTime; + function recordVideo() { + if(videoRecorder) { + videoRecorder.stop(); + videoRecorder = null; + return; + } + + videoRecorder = new MediaRecorder(screen.captureStream(), { + mimeType: 'video/webm', + videoBitsPerSecond: 25000000 + }); + + let chunks = []; + videoRecorder.ondataavailable = e => { + chunks.push(e.data); + }; + + let timer = config.timerElement; + if(timer) { + timer.hidden = false; + timer.innerText = "00:00"; + } + + videoRecorder.onstop = () => { + if(timer) { + timer.hidden = true; + } + downloadBlob(new Blob(chunks, {type: 'video/webm'}), '.webm'); + }; + + videoRecorder.start(); + videoStartTime = Date.now(); + + function updateTimer() { + if(!videoStartTime) { + return; + } + + if(timer) { + let duration = Math.floor((Date.now() - videoStartTime) / 1000); + timer.innerText = Math.floor(duration / 60).toString().padStart(2, '0') + ':' + (duration % 60).toString().padStart(2, '0'); + } + + setTimeout(updateTimer, 1000); + } + + setTimeout(updateTimer, 1000); + } + + async function runModuleFromURL(url, keepUrl) { + let response = await fetch(url); + let type = response.headers.get('Content-Type'); + if(type && type.includes('html')) { + throw false; + } + runModule(await response.arrayBuffer(), keepUrl || devkitMode); + } + + return { + runModule, + runModuleFromURL, + setDevkitMode: (m) => devkitMode = m, + }; +} + +this.uw8 = MicroW8; \ No newline at end of file