import loaderUrl from "data-url:../../platform/bin/loader.wasm"; import platformUrl from "data-url:../../platform/bin/platform.uw8"; import audioWorkletUrl from "data-url:./audiolet.js"; class AudioNode extends AudioWorkletNode { constructor(context) { super(context, 'apu', {outputChannelCount: [2]}); } } let U8 = (...a) => new Uint8Array(...a); let U32 = (...a) => new Uint32Array(...a); export default function MicroW8(screen, config = {}) { if(!config.setMessage) { config.setMessage = (s, e) => { if(e) { console.log('error: ' + e); } } } let canvasCtx = screen.getContext('2d'); let imageData = canvasCtx.createImageData(320, 240); let devkitMode = config.devkitMode; let cancelFunction; let currentData; 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; } let audioContext; let audioNode; async function runModule(data, keepUrl) { if (cancelFunction) { cancelFunction(); cancelFunction = null; } audioContext = new AudioContext({sampleRate: 44100}); let keepRunning = true; let abortController = new AbortController(); cancelFunction = () => { audioContext.close(); keepRunning = false; abortController.abort(); }; let cartridgeSize = data.byteLength; config.setMessage(cartridgeSize); if (cartridgeSize == 0) { return; } await audioContext.audioWorklet.addModule(audioWorkletUrl); audioNode = new AudioNode(audioContext); let audioReadyFlags = 0; let audioReadyResolve; let audioReadyPromise = new Promise(resolve => audioReadyResolve = resolve); let updateAudioReady = (f) => { audioReadyFlags |= f; if(audioReadyFlags == 3 && audioReadyResolve) { audioReadyResolve(true); audioReadyResolve = null; } }; let audioStateChange = () => { if(audioContext.state == 'suspended') { if(config.startButton) { config.startButton.style = ''; screen.style = 'display:none'; } (config.startButton || screen).onclick = () => { audioContext.resume(); }; } else { if(config.startButton) { config.startButton.style = 'display:none'; screen.style = ''; } updateAudioReady(1); } }; audioContext.onstatechange = audioStateChange; audioStateChange(); 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(memSize); 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) => loadModuleData(await (await fetch(url)).arrayBuffer()); loader = await instantiate(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] = () => { }; } let logLine = ''; importObject.env['logChar'] = (c) => { if(c == 10) { console.log(logLine); logLine = ''; } else { logLine += String.fromCharCode(c); } }; for (let i = 0; i < 16; ++i) { importObject.env['g_reserved' + i] = 0; } data = loadModuleData(data); let platform_data = await loadModuleURL(platformUrl); audioNode.port.onmessage = (e) => updateAudioReady(e.data); audioNode.port.postMessage([platform_data, data]); let platform_instance = await instantiate(platform_data); 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); await audioReadyPromise; let startTime = Date.now(); let frameCounter = 0; const timePerFrame = 1000 / 60; audioNode.connect(audioContext.destination); let isPaused = false; let pauseTime = startTime; let updateVisibility = isVisible => { let now = Date.now(); if(isVisible) { isPaused = false; audioContext.resume(); startTime += now - pauseTime; } else { isPaused = true; audioContext.suspend(); pauseTime = now; } }; window.addEventListener('focus', () => updateVisibility(true), { signal: abortController.signal }); window.addEventListener('blur', () => updateVisibility(false), { signal: abortController.signal }); updateVisibility(document.hasFocus()); if (instance.exports.start) { instance.exports.start(); } function mainloop() { if (!keepRunning) { return; } try { let restart = false; let thisFrame; if (!isPaused) { 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); let time = Date.now() - startTime; u32Mem[16] = time; u32Mem[17] = pad | gamepad; u32Mem[18] = frameCounter++; if(instance.exports.upd) { instance.exports.upd(); } platform_instance.exports.endFrame(); let soundRegisters = new ArrayBuffer(32); U8(soundRegisters).set(U8(memory.buffer, 80, 32)); audioNode.port.postMessage({t: time, r: soundRegisters}, [soundRegisters]); let palette = U32(memory.buffer, 0x13000, 1024); for (let i = 0; i < 320 * 240; ++i) { buffer[i] = palette[memU8[i + 120]] | 0xff000000; } canvasCtx.putImageData(imageData, 0, 0); let timeOffset = ((time * 6) % 100 - 50) / 6; thisFrame = startTime + time - timeOffset / 8; } else { thisFrame = Date.now(); } let now = Date.now(); let nextFrame = Math.max(thisFrame + timePerFrame, now); if (restart) { runModule(currentData); } else { window.setTimeout(mainloop, nextFrame - now) } } 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; let videoAudioSourceNode; let videoAudioStreamNode; function recordVideo() { if(videoRecorder) { videoRecorder.stop(); videoRecorder = null; videoAudioSourceNode.disconnect(videoAudioStreamNode); videoAudioSourceNode = null; videoAudioStreamNode = null; return; } let stream = screen.captureStream(); videoAudioStreamNode = audioContext.createMediaStreamDestination(); videoAudioSourceNode = audioNode; audioNode.connect(videoAudioStreamNode); stream.addTrack(videoAudioStreamNode.stream.getAudioTracks()[0]); videoRecorder = new MediaRecorder(stream, { 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')) || response.status != 200) { return false; } runModule(await response.arrayBuffer(), keepUrl || devkitMode); return true; } return { runModule, runModuleFromURL, setDevkitMode: (m) => devkitMode = m, }; }