Created
October 6, 2020 21:51
-
-
Save alex-red/1221a55e79eeea9762695d0639c7493b to your computer and use it in GitHub Desktop.
wasmboy headless
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Wasmboy init | |
import { WasmBoy } from 'wasmboy'; | |
import * as fs from 'fs'; | |
import * as PNGImage from 'pngjs-image'; | |
import * as sharp from 'sharp'; | |
const pkmn_rom = new Uint8Array(fs.readFileSync('./src/wasmboy/pkmn.gbc')); | |
const pkmn_rom_save_path = './src/wasmboy/pkmn.save'; | |
const GAMEBOY_CAMERA_WIDTH = 160; | |
const GAMEBOY_CAMERA_HEIGHT = 144; | |
const IMAGE_SAVE_PATH = './public/current.png'; | |
const WasmBoyOptionsSchema = { | |
headless: true, | |
gameboySpeed: 100.0, | |
useGbcWhenOptional: true, | |
isAudioEnabled: false, | |
frameSkip: 1, | |
audioBatchProcessing: false, | |
timersBatchProcessing: false, | |
audioAccumulateSamples: false, | |
graphicsBatchProcessing: false, | |
graphicsDisableScanlineRendering: false, | |
tileRendering: true, | |
tileCaching: true, | |
gameboyFPSCap: 60, | |
updateGraphicsCallback: false, | |
updateAudioCallback: false, | |
saveStateCallback: false, | |
onReady: false, | |
onPlay: false, | |
onPause: false, | |
onLoadedAndStarted: false, | |
}; | |
const getImageDataFromFrame = async () => { | |
// Get our output frame | |
const frameInProgressVideoOutputLocation = await WasmBoy._getWasmConstant( | |
'FRAME_LOCATION', | |
); | |
const frameInProgressMemory = await WasmBoy._getWasmMemorySection( | |
frameInProgressVideoOutputLocation, | |
frameInProgressVideoOutputLocation + | |
GAMEBOY_CAMERA_HEIGHT * GAMEBOY_CAMERA_WIDTH * 3 + | |
1, | |
); | |
// Going to compare pixel values from the VRAM to confirm tests | |
const imageDataArray = []; | |
const rgbColor = []; | |
for (let y = 0; y < GAMEBOY_CAMERA_HEIGHT; y++) { | |
for (let x = 0; x < GAMEBOY_CAMERA_WIDTH; x++) { | |
// Each color has an R G B component | |
const pixelStart = (y * GAMEBOY_CAMERA_WIDTH + x) * 3; | |
for (let color = 0; color < 3; color++) { | |
rgbColor[color] = frameInProgressMemory[pixelStart + color]; | |
} | |
// Doing graphics using second answer on: | |
// https://stackoverflow.com/questions/4899799/whats-the-best-way-to-set-a-single-pixel-in-an-html5-canvas | |
// Image Data mapping | |
const imageDataIndex = (x + y * GAMEBOY_CAMERA_WIDTH) * 4; | |
imageDataArray[imageDataIndex] = rgbColor[0]; | |
imageDataArray[imageDataIndex + 1] = rgbColor[1]; | |
imageDataArray[imageDataIndex + 2] = rgbColor[2]; | |
// Alpha, no transparency | |
imageDataArray[imageDataIndex + 3] = 255; | |
} | |
} | |
return imageDataArray; | |
}; | |
// Function to create an image from output | |
const createImageFromFrame = (imageDataArray, outputPath) => { | |
return new Promise((resolve, reject) => { | |
// https://www.npmjs.com/package/pngjs-image | |
const image = PNGImage.createImage( | |
GAMEBOY_CAMERA_WIDTH, | |
GAMEBOY_CAMERA_HEIGHT, | |
); | |
// Write our pixel values | |
for (let i = 0; i < imageDataArray.length - 4; i = i + 4) { | |
// Since 4 indexes represent 1 pixels. divide i by 4 | |
const pixelIndex = i / 4; | |
// Get our y value from i | |
const y = Math.floor(pixelIndex / GAMEBOY_CAMERA_WIDTH); | |
// Get our x value from i | |
const x = pixelIndex % GAMEBOY_CAMERA_WIDTH; | |
image.setAt(x, y, { | |
red: imageDataArray[i], | |
green: imageDataArray[i + 1], | |
blue: imageDataArray[i + 2], | |
alpha: imageDataArray[i + 3], | |
}); | |
} | |
image.writeImage('tmp.png', function (err) { | |
if (err) { | |
reject(err); | |
} | |
sharp('tmp.png') | |
.resize(320, 288) | |
.toFile(outputPath) | |
.then(() => { | |
resolve(); | |
}) | |
.catch((err) => { | |
reject(err); | |
}); | |
}); | |
}); | |
}; | |
export const getScreenshot = async () => { | |
const imageFrames = await getImageDataFromFrame(); | |
const image = await createImageFromFrame(imageFrames, IMAGE_SAVE_PATH); | |
return image; | |
}; | |
export const getSaveFile = async () => { | |
try { | |
const state = JSON.parse( | |
(await fs.promises.readFile(pkmn_rom_save_path)).toString(), | |
); | |
const nwIS = Uint8Array.from(state.wasmboyMemory.wasmBoyInternalState); | |
state.wasmboyMemory.wasmBoyInternalState = nwIS; | |
const nwPM = Uint8Array.from(state.wasmboyMemory.wasmBoyPaletteMemory); | |
state.wasmboyMemory.wasmBoyPaletteMemory = nwPM; | |
const ngBM = Uint8Array.from(state.wasmboyMemory.gameBoyMemory); | |
state.wasmboyMemory.gameBoyMemory = ngBM; | |
const ncR = Uint8Array.from(state.wasmboyMemory.cartridgeRam); | |
state.wasmboyMemory.cartridgeRam = ncR; | |
return state; | |
} catch (error) { | |
return null; | |
} | |
}; | |
export const saveState = async () => { | |
try { | |
const state = await WasmBoy.saveState(); | |
const nwIS = Array.from(state.wasmboyMemory.wasmBoyInternalState); | |
state.wasmboyMemory.wasmBoyInternalState = nwIS; | |
const nwPM = Array.from(state.wasmboyMemory.wasmBoyPaletteMemory); | |
state.wasmboyMemory.wasmBoyPaletteMemory = nwPM; | |
const ngBM = Array.from(state.wasmboyMemory.gameBoyMemory); | |
state.wasmboyMemory.gameBoyMemory = ngBM; | |
const ncR = Array.from(state.wasmboyMemory.cartridgeRam); | |
state.wasmboyMemory.cartridgeRam = ncR; | |
const saveFile = await fs.promises.writeFile( | |
pkmn_rom_save_path, | |
JSON.stringify(state), | |
); | |
return saveFile; | |
} catch (error) { | |
return null; | |
} | |
}; | |
export const initWasmboy = async () => { | |
await WasmBoy.config(WasmBoyOptionsSchema); | |
await WasmBoy.loadROM(pkmn_rom); | |
const saveFile = await getSaveFile(); | |
if (saveFile) { | |
await WasmBoy.loadState(saveFile); | |
} | |
WasmBoy.play(); | |
return WasmBoy.isReady(); | |
}; | |
// server call | |
@Post('input') | |
async handleInput(@Query('command') command: string) { | |
const newState = Object.assign( | |
{ | |
...WasmBoyJoypadState, | |
}, | |
{ | |
[command.toLocaleUpperCase()]: true, | |
}, | |
); | |
// console.log(newState, WasmBoyJoypadState) | |
// actual input | |
WasmBoy.setJoypadState(newState); | |
await WasmBoy._runWasmExport('executeMultipleFrames', [60]); | |
//reset input - looks like it holds the button down otherwise | |
WasmBoy.setJoypadState(WasmBoyJoypadState); | |
const image = await getScreenshot(); | |
await saveState(); | |
return { | |
url: 'http://localhost:40404/current.png', | |
image, | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment