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 @@ + + +
+ +