diff --git a/build.sh b/build.sh new file mode 100755 index 00000000000..3e0d8b89f85 --- /dev/null +++ b/build.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e +set -x + +#rm -rf build-wasm +#docker run --rm -t -v $PWD:/home/mgba/src mgba/wasm + +if [ ! -d "build-wasm" ] +then + # running cmake with this file already here consistently makes the build fail + rm -rf $HOME/emsdk/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten/libSDL2.a + mkdir build-wasm + cd build-wasm + source $HOME/emsdk/emsdk_env.sh + emcmake cmake .. +else + cd build-wasm +fi + +make -j4 install DESTDIR=install +cd .. + +mkdir -p src/platform/wasm/build +cp build-wasm/wasm/mgba.js src/platform/wasm/build +cp build-wasm/wasm/mgba.wasm src/platform/wasm/build + +SW_BUILD_PATH=src/platform/wasm/serviceworker.out.js +cp src/platform/wasm/serviceworker.js $SW_BUILD_PATH +COMMIT=$(git rev-parse --short HEAD) +if [ `uname` = 'Darwin' ]; then + sed -i '' -e s/GITCOMMIT/${COMMIT}/g $SW_BUILD_PATH +else + sed -i s/GITCOMMIT/${COMMIT}/g $SW_BUILD_PATH +fi + +#(cd src/platform/wasm && python3 -m http.server) diff --git a/src/platform/wasm/.gitignore b/src/platform/wasm/.gitignore new file mode 100644 index 00000000000..14617a563a9 --- /dev/null +++ b/src/platform/wasm/.gitignore @@ -0,0 +1,2 @@ +build/ +serviceworker.out.js diff --git a/src/platform/wasm/CMakeLists.txt b/src/platform/wasm/CMakeLists.txt index 97f4f03d4f6..6bb18ae138b 100644 --- a/src/platform/wasm/CMakeLists.txt +++ b/src/platform/wasm/CMakeLists.txt @@ -3,14 +3,12 @@ set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} \ -s USE_SDL=2 \ -s TOTAL_MEMORY=67108864 \ -s ALLOW_MEMORY_GROWTH=1 \ - -s EXTRA_EXPORTED_RUNTIME_METHODS=[\"FS\"] \ + -s EXTRA_EXPORTED_RUNTIME_METHODS=FS,ccall,cwrap \ -s MODULARIZE=1 \ - -s EXPORT_NAME=${CMAKE_PROJECT_NAME} \ - --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/pre.js") + -s EXPORT_NAME=${CMAKE_PROJECT_NAME}") add_executable(${BINARY_NAME}.js main.c memory.c ../sdl/sdl-audio.c ../sdl/sdl-events.c) set_target_properties(${BINARY_NAME}.js PROPERTIES COMPILE_DEFINITIONS "${OS_DEFINES};${FEATURE_DEFINES};${FUNCTION_DEFINES}" COMPILE_OPTIONS "-s;USE_SDL=2" - LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/pre.js" OUTPUT_NAME ${BINARY_NAME}) target_link_libraries(${BINARY_NAME}.js ${BINARY_NAME} ${OS_LIB}) diff --git a/src/platform/wasm/controls.js b/src/platform/wasm/controls.js new file mode 100644 index 00000000000..e597f0b5878 --- /dev/null +++ b/src/platform/wasm/controls.js @@ -0,0 +1,272 @@ +const buttonNameToId = new Map(); +buttonNameToId.set('a', 0); +buttonNameToId.set('b', 1); +buttonNameToId.set('select', 2); +buttonNameToId.set('start', 3); +buttonNameToId.set('right', 4); +buttonNameToId.set('left', 5); +buttonNameToId.set('up', 6); +buttonNameToId.set('down', 7); +buttonNameToId.set('r', 8); +buttonNameToId.set('l', 9); +const buttonIdToName = new Map(); +for (const [key, value] of buttonNameToId) { + buttonIdToName.set(value, key); +} + +// Use setTimeout with 16ms delays for 60fps. +// Ideally this would be 16.6666, but this has to be an integer... +const normalLoopTiming = 16; +const fastLoopTiming = 8; +const infinityLoopTiming = 1; +const slowLoopTiming = 64; + +export default class MgbaControls extends HTMLElement { + connectedCallback() { + // 0 is normalLoopTiming aka 1x + // 1 is fastLoopTiming aka 2x + // 2 is infinityLoopTiming + // 3 is slowLoopTiming aka 0.25x + this.loopTiming = 0; + + this.addButtons(); + } + + addButtons() { + const buttonContainer = document.createElement('div'); + buttonContainer.classList.add('button-container'); + this.appendChild(buttonContainer); + + this.addCanvasContainer(buttonContainer); + this.addShoulderRow(buttonContainer); + this.addControlsRow(buttonContainer); + this.addMenuRow(buttonContainer); + + const A = this.querySelector('.A'); + const B = this.querySelector('.B'); + const L = this.querySelector('.L'); + const R = this.querySelector('.R'); + const start = this.querySelector('.start'); + const select = this.querySelector('.select'); + + [[A, 'A'], [B, 'B'], [L, 'L'], [R, 'R'], [start, 'start'], [select, 'select']].forEach(([element, buttonName]) => { + ['mousedown', 'touchstart'].forEach(eventName => { + element.addEventListener(eventName, () => { + this.buttonPress(buttonName); + element.classList.add('pressed'); + }); + }); + ['mouseup', 'touchend', 'touchcancel'].forEach(eventName => { + element.addEventListener(eventName, () => { + this.buttonUnpress(buttonName); + element.classList.remove('pressed'); + }); + }); + }); + + const dpad = this.querySelector('.dpad'); + const clearDpad = () => { + for (const name of ['Up', 'Down', 'Left', 'Right']) + this.buttonUnpress(name); + }; + const updateDpad = event => { + const offsetLeft = event.target.offsetLeft - dpad.offsetLeft; + const offsetTop = event.target.offsetTop - dpad.offsetTop; + + const x = (offsetLeft + event.offsetX) / dpad.offsetWidth; + const y = (offsetTop + event.offsetY) / dpad.offsetHeight; + const threshold = 1 / 3; + const pressed = { + Left: false, + Right: false, + Up: false, + Down: false, + } + if (x < threshold) { + pressed.Left = true; + } else if (x > 1 - threshold) { + pressed.Right = true; + } + if (y < threshold) { + pressed.Up = true; + } else if (y > 1 - threshold) { + pressed.Down = true; + } + for (const name of ['Left', 'Right', 'Up', 'Down']) { + if (pressed[name]) { + this.buttonPress(name); + } else { + this.buttonUnpress(name); + } + } + }; + + dpad.onpointerdown = event => updateDpad(event); + dpad.onpointermove = event => { + if (!event.buttons && event.pointerType === 'mouse') + return; + updateDpad(event); + }; + dpad.onpointerup = () => clearDpad(); + dpad.onpointerleave = () => clearDpad(); + } + + buttonPress(name) { + window.Module._buttonPress(buttonNameToId.get(name.toLowerCase())); + } + + buttonUnpress(name) { + window.Module._buttonUnpress(buttonNameToId.get(name.toLowerCase())); + } + + addShoulderRow(container) { + const shoulderRow = document.createElement('div'); + container.appendChild(shoulderRow); + shoulderRow.classList.add('shoulder-row'); + + const L = document.createElement('div'); + L.classList.add('L'); + L.classList.add('button'); + shoulderRow.appendChild(L); + + const R = document.createElement('div'); + R.classList.add('R'); + R.classList.add('button'); + shoulderRow.appendChild(R); + } + + addControlsRow(container) { + const controlsRow = document.createElement('div'); + container.appendChild(controlsRow); + controlsRow.classList.add('controls-row'); + + const dpad = document.createElement('div'); + dpad.classList.add('dpad'); + controlsRow.appendChild(dpad); + for (let i = 0; i < 9; i++) { + const div = document.createElement('div'); + if (i == 0) { + div.classList.add('up-left'); + } else if (i == 1) { + div.classList.add('up'); + } else if (i == 2) { + div.classList.add('up-right'); + } else if (i == 3) { + div.classList.add('left'); + } else if (i == 4) { + div.classList.add('center'); + } else if (i == 5) { + div.classList.add('right'); + } else if (i == 6) { + div.classList.add('down-left'); + } else if (i == 7) { + div.classList.add('down'); + } else if (i == 8) { + div.classList.add('down-right'); + } + dpad.appendChild(div); + } + + const abContainer = document.createElement('div'); + abContainer.classList.add('ab-container'); + controlsRow.appendChild(abContainer); + + const placeholderOne = document.createElement('div'); + placeholderOne.classList.add('ab-placeholder'); + abContainer.appendChild(placeholderOne); + + const a = document.createElement('div'); + a.classList.add('A'); + a.classList.add('button'); + abContainer.appendChild(a); + + const b = document.createElement('div'); + b.classList.add('B'); + b.classList.add('button'); + abContainer.appendChild(b); + + const placeholderTwo = document.createElement('div'); + placeholderTwo.classList.add('ab-placeholder'); + abContainer.appendChild(placeholderTwo); + } + + addMenuRow(container) { + const menuRow = document.createElement('div'); + container.appendChild(menuRow); + menuRow.classList.add('menu-row'); + + const menuSpeedContainer = document.createElement('div'); + menuSpeedContainer.classList.add('menu-speed-container'); + menuRow.appendChild(menuSpeedContainer); + + const menu = document.createElement('div'); + menu.classList.add('menu'); + menu.classList.add('button'); + menuSpeedContainer.appendChild(menu); + menu.onclick = () => { + document.querySelector('mgba-game').appendChild(document.createElement('mgba-game-menu')); + }; + + const speed = document.createElement('div'); + speed.classList.add('speed'); + speed.classList.add('button'); + speed.textContent = '1x'; + menuSpeedContainer.appendChild(speed); + speed.onclick = () => { + if (this.loopTiming == 0) { + this.loopTiming = 1; + speed.textContent = '2x'; + // TODO consider using EM_TIMING_RAF instead of EM_TIMING_SETTIMEOUT: + // https://emscripten.org/docs/api_reference/emscripten.h.html#c.emscripten_set_main_loop_timing + window.Module._setMainLoopTiming(0, fastLoopTiming); + } else if (this.loopTiming == 1) { + this.loopTiming = 2; + speed.textContent = '♾️'; + window.Module._setMainLoopTiming(0, infinityLoopTiming); + } else if (this.loopTiming == 2) { + this.loopTiming = 3; + speed.textContent = '0.25x'; + window.Module._setMainLoopTiming(0, slowLoopTiming); + } else if (this.loopTiming == 3) { + this.loopTiming = 0; + speed.textContent = '1x'; + window.Module._setMainLoopTiming(0, normalLoopTiming); + } else { + console.error('unrecognized loopTiming: ' + this.loopTiming); + } + }; + + const selectStartContainer = document.createElement('div'); + selectStartContainer.classList.add('select-start-container'); + menuRow.appendChild(selectStartContainer); + + const select = document.createElement('div'); + select.classList.add('select'); + select.classList.add('button'); + selectStartContainer.appendChild(select); + + const start = document.createElement('div'); + start.classList.add('start'); + start.classList.add('button'); + selectStartContainer.appendChild(start); + + menuRow.appendChild(document.createElement('div')); + } + + addCanvasContainer(container) { + + const canvasContainer = document.createElement('div'); + canvasContainer.id = 'canvas-container'; + container.appendChild(canvasContainer); + + canvasContainer.appendChild(document.createElement('div')); + canvasContainer.appendChild(document.getElementById('canvas')); + canvasContainer.appendChild(document.createElement('div')); + } + + disconnectedCallback() { + document.body.appendChild(document.getElementById('canvas')); + } +} + +customElements.define('mgba-controls', MgbaControls); diff --git a/src/platform/wasm/deploy.sh b/src/platform/wasm/deploy.sh new file mode 100755 index 00000000000..3e5bc235066 --- /dev/null +++ b/src/platform/wasm/deploy.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e +set -x + +git init +echo mgba.jarhar.com > CNAME +git add -f . +git commit -m "github pages" +git checkout -b pages +git remote add origin git@github.com:josepharhar/mgba +git push -f origin pages +rm CNAME +rm -rf .git diff --git a/src/platform/wasm/fileloader.js b/src/platform/wasm/fileloader.js new file mode 100644 index 00000000000..0c17bba8158 --- /dev/null +++ b/src/platform/wasm/fileloader.js @@ -0,0 +1,81 @@ +export function loadGame(filepath) { + const cLoadGame = window.Module.cwrap('loadGame', 'number', ['string']); + if (!cLoadGame(filepath)) { + const message = 'Module._loadGame returned false! filepath: ' + filepath; + console.log(message); + throw new Error(message); + } +} + +export async function saveFile(filepath, file) { + return new Promise(resolve => { + const reader = new FileReader(); + reader.onload = async e => { + window.Module.FS.writeFile( + filepath, new Uint8Array(e.target.result)); + await writefs(); + resolve(); + } + reader.readAsArrayBuffer(file); + }); +} + +export function downloadFile(filepath, filename) { + const save = window.Module.FS.readFile(filepath); + const a = document.createElement('a'); + document.body.appendChild(a); + a.download = filename; + const blob = new Blob([save], { type: 'application/octet-stream' }); + a.href = URL.createObjectURL(blob); + + a.click(); + + URL.revokeObjectURL(blob); + a.remove(); +} + +export async function readfs() { + const err = await new Promise(resolve => { + window.Module.FS.syncfs(/*populate=*/true, resolve); + }); + if (err) + console.log('syncfs error: ', err); +} + +export async function writefs() { + const err = await new Promise(resolve => { + window.Module.FS.syncfs(/*populate=*/false, resolve); + }); + if (err) + console.log('syncfs error: ', err); +} + +export function readdirWithoutDotDirs(path) { + const files = window.Module.FS.readdir(path); + files.splice(files.indexOf('.'), 1); + files.splice(files.indexOf('..'), 1); + return files; +} + +// https://stackoverflow.com/a/14919494 +export function humanFileSize(bytes, si=false, dp=1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10**dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + + return bytes.toFixed(dp) + ' ' + units[u]; +} diff --git a/src/platform/wasm/fixsdl.sh b/src/platform/wasm/fixsdl.sh new file mode 100755 index 00000000000..837bd9d3c61 --- /dev/null +++ b/src/platform/wasm/fixsdl.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +set -x +rm -rf $HOME/emsdk/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten/libSDL2.a diff --git a/src/platform/wasm/game.css b/src/platform/wasm/game.css new file mode 100644 index 00000000000..5aed1f1fba8 --- /dev/null +++ b/src/platform/wasm/game.css @@ -0,0 +1,260 @@ +mgba-game { + display: block; + --controls-scale: min(13vw, 80px); + --half-width-control: 47vw; + --dpad-width: calc(1.15 * var(--half-width-control)); + --ab-width: calc(0.95 * var(--half-width-control)); +} + +body.in-game { + overflow: hidden; +} + +#canvas { + image-rendering: crisp-edges; /* TODO make this configurable */ + width: 100vw; + /* height: 100vh; */ + object-fit: contain; + display: block; + /*position: absolute;*/ + background-color: black; +} +#canvas.disabled { + display: none; +} + +mgba-game .placeholder { + display: flex; + justify-content: space-between; + align-items: center; +} + +mgba-game .button-container { + position: fixed; + top: 0; + /* TODO what does left and right 0 do? */ + left: 0; + right: 0; + + display: grid; + grid-template-rows: 1fr auto auto auto; + height: calc(100% - env(safe-area-inset-bottom)); + + z-index: 5; + opacity: 0.7; + font-family: sans-serif; + + /* TODO consider using padding-top for the canvas */ + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-top: env(safe-area-inset-top); +} + +mgba-game .button { + background-color: white; + color: black; + border-radius: calc(0.2*var(--controls-scale)); +} + +mgba-game .shoulder-row { + display: flex; + justify-content: space-between; +} +mgba-game .L, +mgba-game .R { + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + + width: calc(2*var(--controls-scale)); + height: calc(1*var(--controls-scale)); +} +mgba-game .L::before { + content: 'L'; +} +mgba-game .R::before { + content: 'R'; +} + +mgba-game .controls-row { + padding-top: 3px; + padding-bottom: 3px; + display: flex; + justify-content: space-between; +} + +/* TODO implement pressed state for dpad */ +mgba-game .dpad { + height: var(--dpad-width); + width: var(--dpad-width); + + display: grid; + grid-template-columns: auto auto auto; +} +mgba-game .dpad > div { + width: 100%; + height: 100%; + border: 1px solid black; + display: flex; + justify-content: center; + align-items: center; + font-size: 22px; + color: black; + background-color: white; + --dpad-border-radius: calc(0.25*var(--controls-scale)); +} +mgba-game .dpad .up-left { + border-top-left-radius: var(--dpad-border-radius); +} +mgba-game .dpad .up-right { + border-top-right-radius: var(--dpad-border-radius); +} +mgba-game .dpad .down-right { + border-bottom-right-radius: var(--dpad-border-radius); +} +mgba-game .dpad .down-left { + border-bottom-left-radius: var(--dpad-border-radius); +} +mgba-game .dpad > div::before { + content: '⬆'; +} +mgba-game .dpad .up-left::before { + transform: rotate(-45deg); +} +mgba-game .dpad .up-right::before { + transform: rotate(45deg); +} +mgba-game .dpad .right::before { + transform: rotate(90deg); +} +mgba-game .dpad .down-right::before { + transform: rotate(135deg); +} +mgba-game .dpad .down::before { + transform: rotate(180deg); +} +mgba-game .dpad .down-left::before { + transform: rotate(225deg); +} +mgba-game .dpad .left::before { + transform: rotate(-90deg); +} +mgba-game .dpad > .center::before { + content: ''; +} +mgba-game .ab-container { + display: grid; + grid-template-columns: auto auto; + height: 100%; + width: var(--ab-width); +} +mgba-game .ab-container > div { + width: 100%; + height: 100%; + + display: flex; + justify-content: center; + align-items: center; + font-size: 30px; +} +mgba-game .A::before { + content: 'A'; +} +mgba-game .B::before { + content: 'B'; +} + +mgba-game .menu-row { + /*display: flex; + justify-content: space-between;*/ + display: grid; + grid-template-columns: 1fr auto; +} +mgba-game .menu-speed-container, +mgba-game .select-start-container { + width: var(--half-width-control); + display: flex; +} +mgba-game .select, +mgba-game .start, +mgba-game .menu, +mgba-game .speed { + margin: 5px; + display: flex; + justify-content: center; + align-items: center; +} +mgba-game .select, +mgba-game .start, +mgba-game .menu, +mgba-game .speed { + width: calc(2*var(--controls-scale)); + height: calc(var(--controls-scale)); +} +mgba-game .select::before { + content: 'SELECT'; +} +mgba-game .start::before { + content: 'START'; +} +mgba-game .menu::before { + content: 'MENU'; +} + +/* this is intended for mobile devices */ +@media (min-width: 481px) { + mgba-game { + --controls-scale: min(75px, 15vh); + --half-width-control: calc(3*var(--controls-scale)); + } + /*#canvas { + height: 100vh; + }*/ +} + +@media (min-aspect-ratio: 1/1) { /* widescreen/landscape */ + /* shrink the canvas towards the middle to not overlay with the controls */ + /*#button-container::before { + content: ' '; + display: block; + }*/ + mgba-game .button-container { + /* for some reason safe-area-inset-top is always zero on ios. + * this makes the L and R buttons get chopped off by the top corners. */ + padding-left: 0; + padding-right: 0; + /* commenting out height so the buttons go all the way + * to the bottom of the screen. also padding-top for + * more vertical room. */ + /*padding-top: env(safe-area-inset-bottom);*/ + /*height: calc(100% - 2 * env(safe-area-inset-bottom));*/ + height: 100%; + width: 100%; + } + #canvas-container { + height: 100vh; + position: fixed; + display: grid; + grid-template-columns: var(--dpad-width) auto var(--ab-width); + width: 100vw; + /*display: flex;*/ + z-index: -1; /* this makes the buttons pressable */ + } + #canvas { + width: 100%; + max-height: 100vh; + } + + mgba-game .ab-container { + grid-template-columns: unset; + grid-template-rows: auto auto; + grid-gap: 6px; + } + mgba-game .ab-container > div { + } + mgba-game .ab-container > div.ab-placeholder { + display: none; + } +} diff --git a/src/platform/wasm/game.js b/src/platform/wasm/game.js new file mode 100644 index 00000000000..735ce986e9f --- /dev/null +++ b/src/platform/wasm/game.js @@ -0,0 +1,103 @@ +import * as FileLoader from './fileloader.js'; +import MgbaGameLoader from './gamemenu.js'; +import MgbaControls from './controls.js'; +import MgbaInit from './init.js'; + +export default class MgbaGame extends HTMLElement { + async connectedCallback() { + document.body.classList.add('in-game'); + + this.canvas = document.getElementById('canvas'); + + // TODO this is hacky but loading the game again always breaks the c program + if (!await MgbaInit.initMgba()) { + const div = document.createElement('div'); + div.textContent = 'mGBA initialization failed!'; + this.appendChild(div); + return; + } + + this.controls = document.createElement('mgba-controls'); + this.appendChild(this.controls); + + this.canvas.setAttribute('width', '240'); + this.canvas.setAttribute('height', '240'); + this.canvas.style = 'cursor: default;'; + + this.placeholder = document.createElement('div'); + this.appendChild(this.placeholder); + this.placeholder.classList.add('placeholder'); + const placeholderTitle = document.createElement('h1'); + placeholderTitle.textContent = 'Loading...'; + this.placeholder.appendChild(placeholderTitle); + + window.Module.canvas = this.canvas; + // TODO window.Module._setMainLoopTiming(0, MgbaGame.mainLoopTiming); + this.placeholder.remove(); + this.canvas.classList.remove('disabled'); + + if (!this.name) + throw new Error('this.namenot defined! this: ', this); + + const romFilepath = `/data/games/${this.name}`; + FileLoader.loadGame(romFilepath); + + // volume + try { + const volumeSetting = localStorage.getItem('volume'); + if (volumeSetting) + window.Module._setVolume(volumeSetting); + } catch (error) { + console.error('setting volume threw an error! ', error); + } + + const autosaveSlot = 0; + + // load save state if we have it + let filepath = this.name; + filepath = filepath.replace(/\.[^/.]+$/, ""); // remove file extension + filepath = `/data/states/${filepath}.ss${autosaveSlot}`; + try { + await FileLoader.writefs(); + if (window.Module.FS.stat(filepath) && window.confirm('Load save state?')) { + window.Module._loadState(autosaveSlot); + } + } catch (e) { + // FS.stat() will throw if the file doesn't exist. + } + + // auto save state every 2 seconds + // TODO tweak this interval + // TODO make this a setting in case people dont like autosaves + const autosaveMs = 2000; + const scheduleAutosave = () => { + this.timeout = setTimeout(async () => { + window.Module._saveState(autosaveSlot); + await FileLoader.writefs(); + scheduleAutosave(); + }, autosaveMs); + }; + scheduleAutosave(); + } + + disconnectedCallback() { + document.body.classList.remove('in-game'); + if (this.timeout) { + clearTimeout(this.timeout); + } + this.canvas.classList.add('disabled'); + } + + toggleTouchControls() { + if (this.controls) { + document.body.appendChild(document.getElementById('canvas')); + this.controls.remove(); + this.controls = null; + } else { + this.controls = document.createElement('mgba-controls'); + this.appendChild(this.controls); + } + } +} + +customElements.define('mgba-game', MgbaGame); diff --git a/src/platform/wasm/gamemenu.js b/src/platform/wasm/gamemenu.js new file mode 100644 index 00000000000..81eca7e3d34 --- /dev/null +++ b/src/platform/wasm/gamemenu.js @@ -0,0 +1,109 @@ +import MgbaSettingsDialog from './settings.js'; +import MgbaGame from './game.js'; +import * as FileLoader from './fileloader.js'; + +export default class MgbaGameMenu extends HTMLElement { + connectedCallback() { + const dialog = document.createElement('dialog'); + // TODO this dialog is a popover just for light dismiss. if light dismiss + // for dialogs ever gets implemented, then use that here instead. + dialog.setAttribute('popover', 'auto'); + dialog.onclose = () => dialog.remove(); + this.appendChild(dialog); + + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close menu'; + dialog.appendChild(closeButton); + closeButton.onclick = () => { + dialog.hidePopover(); + this.remove(); + }; + + // TODO call syncfs from C when the save is actually written internally + // so we can get rid of this button completely. + const saveButton = document.createElement('button'); + saveButton.textContent = 'Save'; + dialog.appendChild(saveButton); + saveButton.onclick = async () => { + saveButton.disabled = true; + await FileLoader.writefs(); + saveButton.disabled = false; + }; + + const saveStateButton = document.createElement('button'); + saveStateButton.textContent = 'Save State'; + dialog.appendChild(saveStateButton); + saveStateButton.onclick = async () => { + saveStateButton.disabled = true; + window.Module._saveState(1); + await FileLoader.writefs(); + saveStateButton.disabled = false; + }; + + const loadStateButton = document.createElement('button'); + loadStateButton.textContent = 'Load State'; + dialog.appendChild(loadStateButton); + loadStateButton.onclick = () => { + window.Module._loadState(1); + }; + + const controlsToggleButton = document.createElement('button'); + controlsToggleButton.textContent = 'Toggle Buttons'; + dialog.appendChild(controlsToggleButton); + controlsToggleButton.onclick = () => { + const game = document.querySelector('mgba-game'); + game.toggleTouchControls(); + }; + + /*const settingsButton = document.createElement('button'); + settingsButton.textContent = 'Settings'; + settingsButton.onclick = () => { + this.remove(); + document.body.appendChild(document.createElement('mgba-settings-dialog')); + }; + dialog.appendChild(settingsButton);*/ + + const quitButton = document.createElement('button'); + quitButton.textContent = 'Quit'; + dialog.appendChild(quitButton); + quitButton.onclick = () => { + try { + window.Module._quitGame(); + } catch (e) {} + dialog.hidePopover(); + this.remove(); + document.querySelector('mgba-game').remove(); + document.body.appendChild(document.createElement('mgba-menu')); + } + + const volumeContainer = document.createElement('div'); + volumeContainer.style = 'display: inline-block; border: 1px solid gray; padding: 3px;'; + dialog.appendChild(volumeContainer); + const volumeLabel = document.createElement('label'); + volumeLabel.textContent = 'Volume'; + volumeContainer.appendChild(volumeLabel); + const volumeInput = document.createElement('input'); + volumeLabel.appendChild(volumeInput); + volumeInput.type = 'range'; + volumeInput.min = 0; + volumeInput.max = 0x100; + volumeInput.defaultValue = window.Module._getVolume(); + volumeInput.oninput = () => { + window.Module._setVolume(volumeInput.value); + localStorage.setItem('volume', window.Module._getVolume()); + }; + + const muteButton = document.createElement('button'); + muteButton.textContent = 'Mute'; + dialog.appendChild(muteButton); + muteButton.onclick = () => { + window.Module._setVolume(0); + volumeInput.value = 0; + localStorage.setItem('volume', 0); + }; + + dialog.showPopover(); + } +}; + +customElements.define('mgba-game-menu', MgbaGameMenu); diff --git a/src/platform/wasm/icons/mgba-1024-square.png b/src/platform/wasm/icons/mgba-1024-square.png new file mode 100644 index 00000000000..c10d0661441 Binary files /dev/null and b/src/platform/wasm/icons/mgba-1024-square.png differ diff --git a/src/platform/wasm/icons/mgba-1024.png b/src/platform/wasm/icons/mgba-1024.png new file mode 100644 index 00000000000..5a2ffb3aeed Binary files /dev/null and b/src/platform/wasm/icons/mgba-1024.png differ diff --git a/src/platform/wasm/icons/mgba-128.png b/src/platform/wasm/icons/mgba-128.png new file mode 100644 index 00000000000..a515c42b615 Binary files /dev/null and b/src/platform/wasm/icons/mgba-128.png differ diff --git a/src/platform/wasm/icons/mgba-16.png b/src/platform/wasm/icons/mgba-16.png new file mode 100644 index 00000000000..a6fcd6491da Binary files /dev/null and b/src/platform/wasm/icons/mgba-16.png differ diff --git a/src/platform/wasm/icons/mgba-24.png b/src/platform/wasm/icons/mgba-24.png new file mode 100644 index 00000000000..def24d4d62a Binary files /dev/null and b/src/platform/wasm/icons/mgba-24.png differ diff --git a/src/platform/wasm/icons/mgba-256.png b/src/platform/wasm/icons/mgba-256.png new file mode 100644 index 00000000000..5ee8a7e66f5 Binary files /dev/null and b/src/platform/wasm/icons/mgba-256.png differ diff --git a/src/platform/wasm/icons/mgba-32.png b/src/platform/wasm/icons/mgba-32.png new file mode 100644 index 00000000000..67f9d9cf1b5 Binary files /dev/null and b/src/platform/wasm/icons/mgba-32.png differ diff --git a/src/platform/wasm/icons/mgba-48.png b/src/platform/wasm/icons/mgba-48.png new file mode 100644 index 00000000000..020e55eb0dc Binary files /dev/null and b/src/platform/wasm/icons/mgba-48.png differ diff --git a/src/platform/wasm/icons/mgba-512.png b/src/platform/wasm/icons/mgba-512.png new file mode 100644 index 00000000000..9ab87e5adf6 Binary files /dev/null and b/src/platform/wasm/icons/mgba-512.png differ diff --git a/src/platform/wasm/icons/mgba-64.png b/src/platform/wasm/icons/mgba-64.png new file mode 100644 index 00000000000..cce3a7016c1 Binary files /dev/null and b/src/platform/wasm/icons/mgba-64.png differ diff --git a/src/platform/wasm/icons/mgba-96.png b/src/platform/wasm/icons/mgba-96.png new file mode 100644 index 00000000000..659ea004cea Binary files /dev/null and b/src/platform/wasm/icons/mgba-96.png differ diff --git a/src/platform/wasm/icons/mgba-qt.desktop b/src/platform/wasm/icons/mgba-qt.desktop new file mode 100644 index 00000000000..8f87bb40d16 --- /dev/null +++ b/src/platform/wasm/icons/mgba-qt.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Version=1.0 +Icon=mgba +Exec=mgba-qt %f +Terminal=false +Type=Application +Name=mGBA +GenericName=Game Boy Advance Emulator +Comment=Nintendo Game Boy Advance Emulator +Categories=Game;Emulator; +MimeType=application/x-gameboy-advance-rom;application/x-agb-rom;application/x-gba-rom; +Keywords=emulator;Nintendo;advance;gba;Game Boy Advance; diff --git a/src/platform/wasm/icons/mgba.ico b/src/platform/wasm/icons/mgba.ico new file mode 100644 index 00000000000..49a8cc79b47 Binary files /dev/null and b/src/platform/wasm/icons/mgba.ico differ diff --git a/src/platform/wasm/index-old.html b/src/platform/wasm/index-old.html new file mode 100644 index 00000000000..344c9a964d2 --- /dev/null +++ b/src/platform/wasm/index-old.html @@ -0,0 +1,51 @@ + + + + + mGBA + + + + + + + + Download savedata + + + diff --git a/src/platform/wasm/index.html b/src/platform/wasm/index.html index c84aabc501b..012ce213873 100644 --- a/src/platform/wasm/index.html +++ b/src/platform/wasm/index.html @@ -1,51 +1,18 @@ - - - - mGBA - - - - - - - - Download savedata - - - + +mGBA + + + + + + + + + + + diff --git a/src/platform/wasm/init.js b/src/platform/wasm/init.js new file mode 100644 index 00000000000..19244cc4775 --- /dev/null +++ b/src/platform/wasm/init.js @@ -0,0 +1,69 @@ +import MgbaMenu from './menu.js'; +import * as FileLoader from './fileloader.js'; + +export default class MgbaInit extends HTMLElement { + static async initMgba() { + try { + if (window.Module && window.Module._quitMgba) { + try { + window.Module._quitMgba(); + } catch (error) { + // Qutting the program throws an exception i think + } + window.Module = {}; + } + if (!window.Module) + window.Module = {}; + + window.Module.canvas = document.getElementById('canvas'); + + try { + await mGBA(window.Module); + } catch (error) { + console.error('mGBA() failed! ', error); + return false; + } + + // set up filesystem, this was moved from main.c + window.Module.FS.mkdir('/data'); + window.Module.FS.mount(window.Module.FS.filesystems.IDBFS, {}, '/data'); + await FileLoader.readfs(); + // When we read from indexedb, these directories may or may not exist. + // If we mkdir and they already exist they throw, so just catch all of them. + try { + window.Module.FS.mkdir('/data/saves'); + } catch (e) {} + try { + window.Module.FS.mkdir('/data/states'); + } catch (e) {} + try { + window.Module.FS.mkdir('/data/games'); + } catch (e) {} + + return true; + } catch (error) { + console.error('initMgba caught an error: ', error); + return false; + } + } + + async connectedCallback() { + const loading = document.createElement('h2'); + loading.textContent = 'Loading mGBA...'; + this.appendChild(loading); + + const canvas = document.createElement('canvas'); + canvas.id = 'canvas'; + canvas.classList.add('disabled'); + document.body.appendChild(canvas); + + if (!await MgbaInit.initMgba()) { + loading.textContent = 'mGBA initialization failed!'; + } + + this.remove(); + document.body.appendChild(document.createElement('mgba-menu')); + } +} + +customElements.define('mgba-init', MgbaInit); diff --git a/src/platform/wasm/main.c b/src/platform/wasm/main.c index db3a5adbe9b..9b59f3d89fa 100644 --- a/src/platform/wasm/main.c +++ b/src/platform/wasm/main.c @@ -17,8 +17,8 @@ static struct mCore* core = NULL; static color_t* buffer = NULL; static struct mSDLAudio audio = { - .sampleRate = 48000, - .samples = 512 + .sampleRate = 48000, + .samples = 512 }; static SDL_Window* window = NULL; @@ -28,161 +28,226 @@ static SDL_Texture* tex = NULL; static void _log(struct mLogger*, int category, enum mLogLevel level, const char* format, va_list args); static struct mLogger logCtx = { .log = _log }; +EMSCRIPTEN_KEEPALIVE void buttonPress(int id) { + core->addKeys(core, 1 << id); +} +EMSCRIPTEN_KEEPALIVE void buttonUnpress(int id) { + core->clearKeys(core, 1 << id); +} + +EMSCRIPTEN_KEEPALIVE void setVolume(int volume) { + core->opts.volume = volume; + core->reloadConfigOption(core, NULL, NULL); +} +EMSCRIPTEN_KEEPALIVE int getVolume() { + // TODO if this is called before setVolume, it always returns zero! + return core->opts.volume; +} + +EMSCRIPTEN_KEEPALIVE void saveState(int slot) { + if (!mCoreSaveState(core, slot, /*flags=*/0)) + printf("mCoreSaveState returned false! slot: %d\n", slot); +} +EMSCRIPTEN_KEEPALIVE void loadState(int slot) { + if (!mCoreLoadState(core, slot, /*flags=*/0)) + printf("mCoreLoadState returned false! slot: %d\n", slot); +} + +EMSCRIPTEN_KEEPALIVE int getMainLoopTiming() { + int mode = -1; + int value = -1; + emscripten_get_main_loop_timing(&mode, &value); + return value; +} +EMSCRIPTEN_KEEPALIVE void setMainLoopTiming(int mode, int value) { + emscripten_set_main_loop_timing(mode, value); +} + static void handleKeypressCore(const struct SDL_KeyboardEvent* event) { - if (event->keysym.sym == SDLK_TAB) { - emscripten_set_main_loop_timing(event->type == SDL_KEYDOWN ? EM_TIMING_SETTIMEOUT : EM_TIMING_RAF, 0); - return; - } - int key = -1; - if (!(event->keysym.mod & ~(KMOD_NUM | KMOD_CAPS))) { - key = mInputMapKey(&core->inputMap, SDL_BINDING_KEY, event->keysym.sym); - } - if (key != -1) { - if (event->type == SDL_KEYDOWN) { - core->addKeys(core, 1 << key); - } else { - core->clearKeys(core, 1 << key); - } - } + /*if (event->keysym.sym == SDLK_TAB && event->type == SDL_KEYDOWN) { + int mode = -1; + int value = -1; + emscripten_get_main_loop_timing(&mode, &value); + if (value) { + value = 0; // unbounded + } else { + value = 60; // 60fps + } + emscripten_set_main_loop_timing(EM_TIMING_RAF, value); + return; + }*/ + int key = -1; + if (!(event->keysym.mod & ~(KMOD_NUM | KMOD_CAPS))) { + key = mInputMapKey(&core->inputMap, SDL_BINDING_KEY, event->keysym.sym); + } + printf("handleKeypressCore key: %d\n", key); + printf("handleKeypressCore event->keysym.sym: %d\n", event->keysym.sym); + if (key != -1) { + if (event->type == SDL_KEYDOWN) { + core->addKeys(core, 1 << key); + } else { + core->clearKeys(core, 1 << key); + } + } } static void handleKeypress(const struct SDL_KeyboardEvent* event) { - UNUSED(event); - // Nothing here yet + UNUSED(event); + // Nothing here yet } void testLoop() { - union SDL_Event event; - while (SDL_PollEvent(&event)) { - switch (event.type) { - case SDL_KEYDOWN: - case SDL_KEYUP: - if (core) { - handleKeypressCore(&event.key); - } - handleKeypress(&event.key); - break; - }; - } - if (core) { - core->runFrame(core); - - unsigned w, h; - core->currentVideoSize(core, &w, &h); - - SDL_Rect rect = { - .x = 0, - .y = 0, - .w = w, - .h = h - }; - SDL_UnlockTexture(tex); - SDL_RenderCopy(renderer, tex, &rect, &rect); - SDL_RenderPresent(renderer); - - int stride; - SDL_LockTexture(tex, 0, (void**) &buffer, &stride); - core->setVideoBuffer(core, buffer, stride / BYTES_PER_PIXEL); - return; - } + // TODO replace this input handling with buttonPress + union SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_KEYDOWN: + case SDL_KEYUP: + if (core) { + handleKeypressCore(&event.key); + } + handleKeypress(&event.key); + break; + }; + } + if (core) { + core->runFrame(core); + + unsigned w, h; + core->currentVideoSize(core, &w, &h); + + SDL_Rect rect = { + .x = 0, + .y = 0, + .w = w, + .h = h + }; + SDL_UnlockTexture(tex); + SDL_RenderCopy(renderer, tex, &rect, &rect); + SDL_RenderPresent(renderer); + + int stride; + SDL_LockTexture(tex, 0, (void**) &buffer, &stride); + core->setVideoBuffer(core, buffer, stride / BYTES_PER_PIXEL); + return; + } +} + +EMSCRIPTEN_KEEPALIVE void quitGame() { + if (core) { + core->deinit(core); + core = NULL; + } +} + +EMSCRIPTEN_KEEPALIVE void quitMgba() { + exit(0); } EMSCRIPTEN_KEEPALIVE bool loadGame(const char* name) { - if (core) { - core->deinit(core); - core = NULL; - } - core = mCoreFind(name); - if (!core) { - return false; - } - core->init(core); - core->opts.savegamePath = strdup("/data/saves"); - core->opts.savestatePath = strdup("/data/states"); - - mCoreLoadFile(core, name); - mCoreConfigInit(&core->config, "wasm"); - mInputMapInit(&core->inputMap, &GBAInputInfo); - mDirectorySetMapOptions(&core->dirs, &core->opts); - mCoreAutoloadSave(core); - mSDLInitBindingsGBA(&core->inputMap); - - unsigned w, h; - core->baseVideoSize(core, &w, &h); - if (tex) { - SDL_DestroyTexture(tex); - } - tex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ABGR8888, SDL_TEXTUREACCESS_STREAMING, w, h); - - int stride; - SDL_LockTexture(tex, 0, (void**) &buffer, &stride); - core->setVideoBuffer(core, buffer, stride / BYTES_PER_PIXEL); - - core->reset(core); - - core->currentVideoSize(core, &w, &h); - SDL_SetWindowSize(window, w, h); - audio.core = core; - mSDLResumeAudio(&audio); - return true; + if (core) { + core->deinit(core); + core = NULL; + } + core = mCoreFind(name); + if (!core) { + printf("loadGame mCoreFind returned null\n"); + return false; + } + core->init(core); + core->opts.savegamePath = strdup("/data/saves"); + core->opts.savestatePath = strdup("/data/states"); + + // 0x100 is max volume i think + // wait... changing this doesnt even affect the volume! + //core->opts.volume = 0; + + // TODO how does all of this window dimensions code interact with the actual page...? + unsigned w, h; + core->baseVideoSize(core, &w, &h); + if (tex) { + SDL_DestroyTexture(tex); + } + tex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ABGR8888, SDL_TEXTUREACCESS_STREAMING, w, h); + + // TODO also do savestates here somehow... + if (!mCoreLoadFile(core, name)) { + printf("mCoreLoadFile returned false!\n"); + return false; + } + mCoreConfigInit(&core->config, "wasm"); + mInputMapInit(&core->inputMap, &GBAInputInfo); + mDirectorySetMapOptions(&core->dirs, &core->opts); + if (!mCoreAutoloadSave(core)) { + printf("mCoreAutoloadSave returned false!\n"); + return false; + } + // TODO if this is for detecting keypresses in the webpage, I should probably try to remove it. + mSDLInitBindingsGBA(&core->inputMap); + + + int stride; + SDL_LockTexture(tex, 0, (void**) &buffer, &stride); + core->setVideoBuffer(core, buffer, stride / BYTES_PER_PIXEL); + + core->reset(core); // this is the one that gags. maybe it would be better to restart the whole wasm program. + + core->currentVideoSize(core, &w, &h); + SDL_SetWindowSize(window, w, h); + audio.core = core; + mSDLResumeAudio(&audio); + return true; } EMSCRIPTEN_KEEPALIVE bool saveStateSlot(int slot, int flags) { - if (!core) { - return false; - } - return mCoreSaveState(core, slot, flags); + if (!core) { + return false; + } + return mCoreSaveState(core, slot, flags); } EMSCRIPTEN_KEEPALIVE bool loadStateSlot(int slot, int flags) { - if (!core) { - return false; - } - return mCoreLoadState(core, slot, flags); + if (!core) { + return false; + } + return mCoreLoadState(core, slot, flags); } void _log(struct mLogger* logger, int category, enum mLogLevel level, const char* format, va_list args) { - UNUSED(logger); - UNUSED(category); - UNUSED(level); - UNUSED(format); - UNUSED(args); + UNUSED(logger); + UNUSED(category); + UNUSED(level); + UNUSED(format); + UNUSED(args); } EMSCRIPTEN_KEEPALIVE void setupConstants(void) { - EM_ASM(({ - Module.version = { - gitCommit: UTF8ToString($0), - gitShort: UTF8ToString($1), - gitBranch: UTF8ToString($2), - gitRevision: $3, - binaryName: UTF8ToString($4), - projectName: UTF8ToString($5), - projectVersion: UTF8ToString($6) - }; - }), gitCommit, gitCommitShort, gitBranch, gitRevision, binaryName, projectName, projectVersion); + EM_ASM(({ + Module.version = { + gitCommit: UTF8ToString($0), + gitShort: UTF8ToString($1), + gitBranch: UTF8ToString($2), + gitRevision: $3, + binaryName: UTF8ToString($4), + projectName: UTF8ToString($5), + projectVersion: UTF8ToString($6) + }; + }), gitCommit, gitCommitShort, gitBranch, gitRevision, binaryName, projectName, projectVersion); } CONSTRUCTOR(premain) { - setupConstants(); + setupConstants(); } int main() { - mLogSetDefaultLogger(&logCtx); - - SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_EVENTS); - window = SDL_CreateWindow(NULL, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 16, 16, SDL_WINDOW_OPENGL); - renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED); - mSDLInitAudio(&audio, NULL); - - EM_ASM( - FS.mkdir('/data'); - FS.mount(IDBFS, {}, '/data'); - FS.mkdir('/data/saves'); - FS.mkdir('/data/states'); - FS.syncfs(true, function (err) {}); - ); - emscripten_set_main_loop(testLoop, 0, 1); - return 0; + mLogSetDefaultLogger(&logCtx); + + SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_EVENTS); + window = SDL_CreateWindow(NULL, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 16, 16, SDL_WINDOW_OPENGL); + renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED); + mSDLInitAudio(&audio, NULL); + emscripten_set_main_loop(testLoop, 60, 1); + + return 0; } diff --git a/src/platform/wasm/manifest.json b/src/platform/wasm/manifest.json new file mode 100644 index 00000000000..6ca5da9f6cb --- /dev/null +++ b/src/platform/wasm/manifest.json @@ -0,0 +1,16 @@ +{ + "display": "standalone", + "name": "mGBA", + "short_name": "mGBA", + "description": "mGBA", + "color": "#0000FF", + "background_color": "#000022", + "start_url": "/", + "orientation": "any", + "icons": [ + { + "src": "/icons/mgba-1024-square.png", + "sizes": "1024x1024" + } + ] +} diff --git a/src/platform/wasm/menu.js b/src/platform/wasm/menu.js new file mode 100644 index 00000000000..54756e15aed --- /dev/null +++ b/src/platform/wasm/menu.js @@ -0,0 +1,110 @@ +import MgbaGame from './game.js'; +import MgbaSettingsDialog from './settings.js'; +import * as FileLoader from './fileloader.js'; +import MgbaStorage from './storage.js'; + +export default class MgbaMenu extends HTMLElement { + async connectedCallback() { + const filepicker = document.createElement('button'); + filepicker.textContent = 'pick a GBA ROM file...'; + this.appendChild(filepicker); + filepicker.onclick = () => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + fileInput.onchange = async () => { + const file = fileInput.files[0]; + const filepath = `/data/games/${file.name}`; + await FileLoader.saveFile(filepath, file); + + this.remove(); + const game = document.createElement('mgba-game'); + game.name = file.name; + document.body.appendChild(game); + }; + }; + + const games = FileLoader.readdirWithoutDotDirs('/data/games'); + + if (games.length) { + const title = document.createElement('h3'); + title.textContent = 'Saved ROMs:'; + this.appendChild(title); + } + for (const gameName of games) { + const button = document.createElement('button'); + button.textContent = gameName; + this.appendChild(button); + button.onclick = () => { + this.remove(); + const mgbaGame = document.createElement('mgba-game'); + mgbaGame.name = gameName; + document.body.appendChild(mgbaGame); + }; + } + + this.appendChild(document.createElement('br')); + + const storageButton = document.createElement('button'); + storageButton.textContent = 'Storage Manager'; + this.appendChild(storageButton); + storageButton.onclick = () => { + this.remove(); + document.body.appendChild(document.createElement('mgba-storage')); + }; + + this.appendChild(document.createElement('br')); + + const aboutButton = document.createElement('button'); + aboutButton.textContent = 'About'; + this.appendChild(aboutButton); + aboutButton.onclick = () => { + const dialog = document.createElement('dialog'); + dialog.onclose = () => dialog.remove(); + this.appendChild(dialog); + + const version = document.createElement('div'); + version.textContent = `${window.Module.version.projectName} ${window.Module.version.projectVersion}`; + dialog.appendChild(version); + + const commit = document.createElement('div'); + commit.textContent = `commit ${window.Module.version.gitShort}`; + dialog.appendChild(commit); + + const updateButton = document.createElement('button'); + updateButton.onclick = async () => { + updateButton.disabled = true; + try { + const serviceWorker = await navigator.serviceWorker.ready; + await serviceWorker.update(); + } catch (error) { + console.error('serviceWorker error: ', error); + window.alert('Encountered an error getting the service worker.'); + } + updateButton.disabled = false; + }; + updateButton.textContent = 'Attempt update'; + dialog.appendChild(updateButton); + + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close'; + closeButton.onclick = () => { + dialog.close(); + dialog.remove(); + }; + dialog.appendChild(closeButton); + + dialog.showModal(); + }; + + /*const settingsButton = document.createElement('button'); + settingsButton.classList.add('settings'); + settingsButton.textContent = 'Settings'; + this.appendChild(settingsButton); + settingsButton.onclick = () => { + document.body.appendChild(document.createElement('mgba-settings-dialog')); + };*/ + } +}; + +customElements.define('mgba-menu', MgbaMenu); diff --git a/src/platform/wasm/pre.js b/src/platform/wasm/pre.js deleted file mode 100644 index 606db616059..00000000000 --- a/src/platform/wasm/pre.js +++ /dev/null @@ -1,46 +0,0 @@ -/* Copyright (c) 2013-2023 Jeffrey Pfau - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -Module.loadFile = (name) => { - var loadGame = cwrap('loadGame', 'number', ['string']); - Module.loadFile = (name) => { - if (loadGame(name)) { - var arr = name.split('.'); - arr.pop(); - Module.gameName = name; - Module.saveName = arr.join('.') + '.sav'; - return true; - } - return false; - } - return Module.loadFile(name); -}; - -Module.getSave = () => { - return FS.readFile('/data/saves/' + Module.saveName); -} - -Module.saveStateSlot = (slot, flags) => { - var saveStateSlot = cwrap('saveStateSlot', 'number', ['number', 'number']); - Module.saveStateSlot = (slot, flags) => { - if (flags === undefined) { - flags = 0b111111; - } - return saveStateSlot(slot, flags); - } - return Module.saveStateSlot(slot, flags); -}; - -Module.loadStateSlot = (slot, flags) => { - var loadStateSlot = cwrap('loadStateSlot', 'number', ['number', 'number']); - Module.loadStateSlot = (slot, flags) => { - if (flags === undefined) { - flags = 0b111101; - } - return loadStateSlot(slot, flags); - } - return Module.loadStateSlot(slot, flags); -}; diff --git a/src/platform/wasm/serviceworker.js b/src/platform/wasm/serviceworker.js new file mode 100644 index 00000000000..0e055677de0 --- /dev/null +++ b/src/platform/wasm/serviceworker.js @@ -0,0 +1,68 @@ +// TODO why wont the new service worker ever install/activate?? + +const cacheName = 'mgba-GITCOMMIT'; + +self.addEventListener('fetch', event => { + event.respondWith((async () => { + const cachedResponse = await caches.match(event.request); + if (cachedResponse) + return cachedResponse; + const responseFromNetwork = await fetch(event.request); + const cache = await caches.open(cacheName); + await cache.put(event.request, responseFromNetwork.clone()); + return responseFromNetwork; + })()); +}); + +self.addEventListener('install', event => { + console.log(`sw ${cacheName} install`); + self.skipWaiting(); + event.waitUntil((async () => { + const cache = await caches.open(cacheName); + await cache.addAll([ + // TODO find a good way to maintain this list. + // maybe i could have cmake generate this as part of the build? + // and make a new cache/version based on the commit hash? + '/index.html', + '/manifest.json', + '/build/mgba.js', + '/build/mgba.wasm', + '/controls.js', + '/game.js', + '/game.css', + '/gamemenu.js', + '/menu.js', + '/settings.js', + '/style.css', + '/fileloader.js', + '/storage.js', + '/init.js', + '/icons/mgba.ico', + '/icons/mgba-1024-square.png', + ]); + })()); +}); + +self.addEventListener('activate', function (event) { + console.log(`sw ${cacheName} activate`); + event.waitUntil( + caches.keys().then(function (cacheNames) { + return Promise.all( + cacheNames + .filter(function (name) { + // Return true if you want to remove this cache, + // but remember that caches are shared across + // the whole origin + return name != cacheName; + if (name != cacheName) { + return true; + } + }) + .map(function (name) { + console.log(`sw ${cacheName} activate deleting cache: ${name}`); + return caches.delete(name); + }), + ); + }), + ); +}); diff --git a/src/platform/wasm/settings.js b/src/platform/wasm/settings.js new file mode 100644 index 00000000000..62116ac410a --- /dev/null +++ b/src/platform/wasm/settings.js @@ -0,0 +1,40 @@ +export default class MgbaSettingsDialog extends HTMLElement { + connectedCallback() { + const dialog = document.createElement('dialog'); + dialog.onclose = () => dialog.remove(); + this.appendChild(dialog); + + const title = document.createElement('h1'); + title.textContent = 'Settings'; + title.style = ` + text-align: center; + `; + dialog.appendChild(title); + + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close settings'; + dialog.appendChild(closeButton); + closeButton.onclick = () => { + dialog.close(); + this.remove(); + }; + + /*const volumeLabel = document.createElement('label'); + volumeLabel.textContent = 'Volume'; + dialog.appendChild(volumeLabel); + const volumeInput = document.createElement('input'); + volumeLabel.appendChild(volumeInput); + volumeInput.type = 'range'; + volumeInput.min = 0; + volumeInput.max = 0x100; + volumeInput.oninput = () => { + console.log('volumeInput.value: ' + volumeInput.value); + window.Module._setVolume(volumeInput.value); + localStorage.setItem('volume', window.Module._getVolume()); + };*/ + + dialog.showModal(); + } +}; + +customElements.define('mgba-settings-dialog', MgbaSettingsDialog); diff --git a/src/platform/wasm/storage.js b/src/platform/wasm/storage.js new file mode 100644 index 00000000000..007b8c6e578 --- /dev/null +++ b/src/platform/wasm/storage.js @@ -0,0 +1,174 @@ +import MgbaMenu from './menu.js'; +import * as FileLoader from './fileloader.js'; + +export default class MgbaStorage extends HTMLElement { + connectedCallback() { + const title = document.createElement('h2'); + title.textContent = 'Storage Manager'; + this.appendChild(title); + + const closeButton = document.createElement('button'); + closeButton.textContent = 'Main menu'; + this.appendChild(closeButton); + closeButton.onclick = () => { + this.remove(); + document.body.appendChild(document.createElement('mgba-menu')); + }; + + const uploadButton = document.createElement('button'); + uploadButton.textContent = 'Upload file...'; + this.appendChild(uploadButton); + uploadButton.onclick = () => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + fileInput.onchange = async () => { + const file = fileInput.files[0]; + const split = file.name.split('.'); + if (split.length < 2) { + window.alert('unrecognized file extension: ' + file.name); + return; + } + const extension = split[split.length - 1].toLowerCase(); + let dir = null; + if (extension === 'gba') { + dir = '/data/games/'; + } else if (extension == 'sav') { + dir = '/data/saves/'; + } else if (extension.startsWith('ss')) { + dir = '/data/states/'; + } else { + window.alert('unrecognized file extension: ' + extension); + return; + } + + const filepath = dir + file.name; + await FileLoader.saveFile(filepath, file); + this.refreshFiles(); + }; + }; + + const gamesTitle = document.createElement('h3'); + gamesTitle.textContent = 'ROM files:'; + this.appendChild(gamesTitle); + this.gamesContainer = document.createElement('div'); + this.appendChild(this.gamesContainer); + + const savesTitle = document.createElement('h3'); + savesTitle.textContent = 'Save files:'; + this.appendChild(savesTitle); + this.savesContainer = document.createElement('div'); + this.appendChild(this.savesContainer); + + const statesTitle = document.createElement('h3'); + statesTitle.textContent = 'Save state files:'; + this.appendChild(statesTitle); + this.statesContainer = document.createElement('div'); + this.appendChild(this.statesContainer); + + this.refreshFiles(); + } + + refreshFiles() { + removeAllChildren(this.gamesContainer); + const games = FileLoader.readdirWithoutDotDirs('/data/games'); + for (const gameName of games) { + this.appendFileButton(gameName, this.gamesContainer, `/data/games/${gameName}`); + } + + removeAllChildren(this.savesContainer); + const saves = FileLoader.readdirWithoutDotDirs('/data/saves'); + for (const saveName of saves) { + this.appendFileButton(saveName, this.savesContainer, `/data/saves/${saveName}`); + } + + removeAllChildren(this.statesContainer); + const states = FileLoader.readdirWithoutDotDirs('/data/states'); + for (const stateName of states) { + this.appendFileButton(stateName, this.statesContainer, `/data/states/${stateName}`); + } + } + + appendFileButton(filename, parent, filepath) { + const container = document.createElement('div'); + parent.appendChild(container); + + const filesize = document.createElement('span'); + container.appendChild(filesize); + container.textContent = FileLoader.humanFileSize( + window.Module.FS.stat(filepath).size) + ' '; + + const button = document.createElement('a'); + container.appendChild(button); + button.href = '#'; + button.textContent = filename; + button.onclick = () => { + const dialog = document.createElement('dialog'); + dialog.onclose = () => dialog.remove(); + this.appendChild(dialog); + + const closeButton = document.createElement('button'); + closeButton.textContent = 'Close'; + dialog.appendChild(closeButton); + closeButton.onclick = () => { + dialog.close(); + dialog.remove(); + }; + + const fileNameLabel = document.createElement('div'); + fileNameLabel.textContent = 'Filename:'; + dialog.appendChild(fileNameLabel); + const fileName = document.createElement('div'); + fileName.textContent = filename; + dialog.appendChild(fileName); + + const downloadButton = document.createElement('button'); + downloadButton.textContent = 'Download'; + dialog.appendChild(downloadButton); + downloadButton.onclick = () => { + FileLoader.downloadFile(filepath, filename); + }; + + dialog.appendChild(document.createElement('br')); + + const deleteButton = document.createElement('button'); + dialog.appendChild(deleteButton); + deleteButton.textContent = 'Delete'; + deleteButton.onclick = async () => { + if (window.confirm('Delete this file? ' + filename)) { + window.Module.FS.unlink(filepath); + await FileLoader.writefs(); + dialog.close(); + dialog.remove(); + this.refreshFiles(); + } + }; + + dialog.appendChild(document.createElement('br')); + + const renameButton = document.createElement('button'); + dialog.appendChild(renameButton); + renameButton.textContent = 'Rename'; + renameButton.onclick = async () => { + const newFilename = window.prompt('Enter new filename for ' + filename); + if (newFilename) { + window.Module.FS.rename(filepath, filepath.replace(filename, newFilename)); + await FileLoader.writefs(); + dialog.close(); + dialog.remove(); + this.refreshFiles(); + } + }; + + dialog.showModal(); + }; + } +}; + +function removeAllChildren(node) { + while (node.firstChild) { + node.lastChild.remove(); + } +} + +customElements.define('mgba-storage', MgbaStorage); diff --git a/src/platform/wasm/style.css b/src/platform/wasm/style.css new file mode 100644 index 00000000000..ea2d9c190c5 --- /dev/null +++ b/src/platform/wasm/style.css @@ -0,0 +1,21 @@ +:root { + /* TODO make this configurable */ + color-scheme: dark; +} + +body { + margin: 0; + user-select: none; + -webkit-user-select: none; +} + +mgba-storage dialog div { + user-select: all; + -webkit-user-select: all; +} + +button { + font-size: 24px; + margin: 10px; + padding: 10px; +}