819 lines
24 KiB
JavaScript
819 lines
24 KiB
JavaScript
/*
|
|
* Copyright (C) 2020 Ben Smith
|
|
*
|
|
* This software may be modified and distributed under the terms
|
|
* of the MIT license. See the LICENSE file for details.
|
|
*
|
|
*
|
|
* Some code from GB-Studio, see LICENSE.gbstudio
|
|
*/
|
|
"use strict";
|
|
|
|
// User configurable.
|
|
const ROM_FILENAME = 'lcdt.gb';
|
|
const ENABLE_FAST_FORWARD = false;
|
|
const ENABLE_REWIND = false;
|
|
const ENABLE_PAUSE = false;
|
|
const ENABLE_SWITCH_PALETTES = true;
|
|
const OSGP_DEADZONE = 0.1; // On screen gamepad deadzone range
|
|
const CGB_COLOR_CURVE = 2; // 0: none, 1: Sameboy "Emulate Hardware" 2: Gambatte/Gameboy Online
|
|
|
|
// List of DMG palettes to switch between. By default it includes all 84
|
|
// built-in palettes. If you want to restrict this, change it to an array of
|
|
// the palettes you want to use and change DEFAULT_PALETTE_IDX to the index of the
|
|
// default palette in that list.
|
|
//
|
|
// Example: (only allow one palette with index 16):
|
|
// const DEFAULT_PALETTE_IDX = 0;
|
|
// const PALETTES = [16];
|
|
//
|
|
// Example: (allow three palettes, 16, 32, 64, with default 32):
|
|
// const DEFAULT_PALETTE_IDX = 1;
|
|
// const PALETTES = [16, 32, 64];
|
|
//
|
|
const DEFAULT_PALETTE_IDX = 79;
|
|
const PALETTES = [
|
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
|
|
34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
|
|
51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67,
|
|
68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83,
|
|
];
|
|
|
|
// It's probably OK to leave these alone. But you can tweak them to get better
|
|
// rewind performance.
|
|
const REWIND_FRAMES_PER_BASE_STATE = 45; // How many delta frames until keyframe
|
|
const REWIND_BUFFER_CAPACITY = 4 * 1024 * 1024; // Total rewind capacity
|
|
const REWIND_FACTOR = 1.5; // How fast is rewind compared to normal speed
|
|
const REWIND_UPDATE_MS = 16; // Rewind setInterval rate
|
|
|
|
// Probably OK to leave these alone too.
|
|
const AUDIO_FRAMES = 4096; // Number of audio frames pushed per buffer
|
|
const AUDIO_LATENCY_SEC = 0.1;
|
|
const MAX_UPDATE_SEC = 5 / 60; // Max. time to run emulator per step (== 5 frames)
|
|
|
|
// Constants
|
|
const RESULT_OK = 0;
|
|
const RESULT_ERROR = 1;
|
|
const SCREEN_WIDTH = 160;
|
|
const SCREEN_HEIGHT = 144;
|
|
const CPU_TICKS_PER_SECOND = 4194304;
|
|
const EVENT_NEW_FRAME = 1;
|
|
const EVENT_AUDIO_BUFFER_FULL = 2;
|
|
const EVENT_UNTIL_TICKS = 4;
|
|
|
|
const $ = document.querySelector.bind(document);
|
|
let emulator = null;
|
|
|
|
const controllerEl = $('#controller');
|
|
const dpadEl = $('#controller_dpad');
|
|
const selectEl = $('#controller_select');
|
|
const startEl = $('#controller_start');
|
|
const bEl = $('#controller_b');
|
|
const aEl = $('#controller_a');
|
|
|
|
const binjgbPromise = Binjgb();
|
|
|
|
// Extract stuff from the vue.js implementation in demo.js.
|
|
class VM {
|
|
constructor() {
|
|
this.ticks = 0;
|
|
this.extRamUpdated = false;
|
|
this.paused_ = false;
|
|
this.volume = 0.5;
|
|
this.palIdx = DEFAULT_PALETTE_IDX;
|
|
this.rewind = {
|
|
minTicks: 0,
|
|
maxTicks: 0,
|
|
};
|
|
setInterval(() => {
|
|
if (this.extRamUpdated) {
|
|
this.updateExtRam();
|
|
this.extRamUpdated = false;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
get paused() { return this.paused_; }
|
|
set paused(newPaused) {
|
|
let oldPaused = this.paused_;
|
|
this.paused_ = newPaused;
|
|
if (!emulator) return;
|
|
if (newPaused == oldPaused) return;
|
|
if (newPaused) {
|
|
emulator.pause();
|
|
this.ticks = emulator.ticks;
|
|
this.rewind.minTicks = emulator.rewind.oldestTicks;
|
|
this.rewind.maxTicks = emulator.rewind.newestTicks;
|
|
} else {
|
|
emulator.resume();
|
|
}
|
|
}
|
|
|
|
togglePause() {
|
|
this.paused = !this.paused;
|
|
}
|
|
|
|
updateExtRam() {
|
|
if (!emulator) return;
|
|
const extram = emulator.getExtRam();
|
|
localStorage.setItem('extram', JSON.stringify(Array.from(extram)));
|
|
}
|
|
};
|
|
|
|
const vm = new VM();
|
|
|
|
// Load a ROM.
|
|
(async function go() {
|
|
let response = await fetch(ROM_FILENAME);
|
|
let romBuffer = await response.arrayBuffer();
|
|
const extRam = new Uint8Array(JSON.parse(localStorage.getItem('extram')));
|
|
Emulator.start(await binjgbPromise, romBuffer, extRam);
|
|
emulator.setBuiltinPalette(vm.palIdx);
|
|
})();
|
|
|
|
|
|
// Copied from demo.js
|
|
function makeWasmBuffer(module, ptr, size) {
|
|
return new Uint8Array(module.HEAP8.buffer, ptr, size);
|
|
}
|
|
|
|
class Emulator {
|
|
static start(module, romBuffer, extRamBuffer) {
|
|
Emulator.stop();
|
|
emulator = new Emulator(module, romBuffer, extRamBuffer);
|
|
emulator.run();
|
|
}
|
|
|
|
static stop() {
|
|
if (emulator) {
|
|
emulator.destroy();
|
|
emulator = null;
|
|
}
|
|
}
|
|
|
|
constructor(module, romBuffer, extRamBuffer) {
|
|
this.module = module;
|
|
// Align size up to 32k.
|
|
const size = (romBuffer.byteLength + 0x7fff) & ~0x7fff;
|
|
this.romDataPtr = this.module._malloc(size);
|
|
makeWasmBuffer(this.module, this.romDataPtr, size)
|
|
.fill(0)
|
|
.set(new Uint8Array(romBuffer));
|
|
this.e = this.module._emulator_new_simple(
|
|
this.romDataPtr, size, Audio.ctx.sampleRate, AUDIO_FRAMES,
|
|
CGB_COLOR_CURVE);
|
|
if (this.e == 0) {
|
|
throw new Error('Invalid ROM.');
|
|
}
|
|
|
|
this.audio = new Audio(module, this.e);
|
|
this.video = new Video(module, this.e, $('canvas'));
|
|
this.rewind = new Rewind(module, this.e);
|
|
this.rewindIntervalId = 0;
|
|
|
|
this.lastRafSec = 0;
|
|
this.leftoverTicks = 0;
|
|
this.fps = 60;
|
|
this.fastForward = false;
|
|
|
|
if (extRamBuffer) {
|
|
this.loadExtRam(extRamBuffer);
|
|
}
|
|
|
|
this.bindKeys();
|
|
this.bindTouch();
|
|
|
|
this.touchEnabled = 'ontouchstart' in document.documentElement;
|
|
this.updateOnscreenGamepad();
|
|
}
|
|
|
|
destroy() {
|
|
this.unbindTouch();
|
|
this.unbindKeys();
|
|
this.cancelAnimationFrame();
|
|
clearInterval(this.rewindIntervalId);
|
|
this.rewind.destroy();
|
|
this.module._emulator_delete(this.e);
|
|
this.module._free(this.romDataPtr);
|
|
}
|
|
|
|
withNewFileData(cb) {
|
|
const fileDataPtr = this.module._ext_ram_file_data_new(this.e);
|
|
const buffer = makeWasmBuffer(
|
|
this.module, this.module._get_file_data_ptr(fileDataPtr),
|
|
this.module._get_file_data_size(fileDataPtr));
|
|
const result = cb(fileDataPtr, buffer);
|
|
this.module._file_data_delete(fileDataPtr);
|
|
return result;
|
|
}
|
|
|
|
loadExtRam(extRamBuffer) {
|
|
this.withNewFileData((fileDataPtr, buffer) => {
|
|
if (buffer.byteLength === extRamBuffer.byteLength) {
|
|
buffer.set(new Uint8Array(extRamBuffer));
|
|
this.module._emulator_read_ext_ram(this.e, fileDataPtr);
|
|
}
|
|
});
|
|
}
|
|
|
|
getExtRam() {
|
|
return this.withNewFileData((fileDataPtr, buffer) => {
|
|
this.module._emulator_write_ext_ram(this.e, fileDataPtr);
|
|
return new Uint8Array(buffer);
|
|
});
|
|
}
|
|
|
|
get isPaused() {
|
|
return this.rafCancelToken === null;
|
|
}
|
|
|
|
pause() {
|
|
if (!this.isPaused) {
|
|
this.cancelAnimationFrame();
|
|
this.audio.pause();
|
|
this.beginRewind();
|
|
}
|
|
}
|
|
|
|
resume() {
|
|
if (this.isPaused) {
|
|
this.endRewind();
|
|
this.requestAnimationFrame();
|
|
this.audio.resume();
|
|
}
|
|
}
|
|
|
|
setBuiltinPalette(palIdx) {
|
|
this.module._emulator_set_builtin_palette(this.e, PALETTES[palIdx]);
|
|
}
|
|
|
|
get isRewinding() {
|
|
return ENABLE_REWIND && this.rewind.isRewinding;
|
|
}
|
|
|
|
beginRewind() {
|
|
if (!ENABLE_REWIND) { return; }
|
|
this.rewind.beginRewind();
|
|
}
|
|
|
|
rewindToTicks(ticks) {
|
|
if (!ENABLE_REWIND) { return; }
|
|
if (this.rewind.rewindToTicks(ticks)) {
|
|
this.runUntil(ticks);
|
|
this.video.renderTexture();
|
|
}
|
|
}
|
|
|
|
endRewind() {
|
|
if (!ENABLE_REWIND) { return; }
|
|
this.rewind.endRewind();
|
|
this.lastRafSec = 0;
|
|
this.leftoverTicks = 0;
|
|
this.audio.startSec = 0;
|
|
}
|
|
|
|
set autoRewind(enabled) {
|
|
if (!ENABLE_REWIND) { return; }
|
|
if (enabled) {
|
|
this.rewindIntervalId = setInterval(() => {
|
|
const oldest = this.rewind.oldestTicks;
|
|
const start = this.ticks;
|
|
const delta =
|
|
REWIND_FACTOR * REWIND_UPDATE_MS / 1000 * CPU_TICKS_PER_SECOND;
|
|
const rewindTo = Math.max(oldest, start - delta);
|
|
this.rewindToTicks(rewindTo);
|
|
vm.ticks = emulator.ticks;
|
|
}, REWIND_UPDATE_MS);
|
|
} else {
|
|
clearInterval(this.rewindIntervalId);
|
|
this.rewindIntervalId = 0;
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame() {
|
|
this.rafCancelToken = requestAnimationFrame(this.rafCallback.bind(this));
|
|
}
|
|
|
|
cancelAnimationFrame() {
|
|
cancelAnimationFrame(this.rafCancelToken);
|
|
this.rafCancelToken = null;
|
|
}
|
|
|
|
run() {
|
|
this.requestAnimationFrame();
|
|
}
|
|
|
|
get ticks() {
|
|
return this.module._emulator_get_ticks_f64(this.e);
|
|
}
|
|
|
|
runUntil(ticks) {
|
|
while (true) {
|
|
const event = this.module._emulator_run_until_f64(this.e, ticks);
|
|
if (event & EVENT_NEW_FRAME) {
|
|
this.rewind.pushBuffer();
|
|
this.video.uploadTexture();
|
|
}
|
|
if ((event & EVENT_AUDIO_BUFFER_FULL) && !this.isRewinding) {
|
|
this.audio.pushBuffer();
|
|
}
|
|
if (event & EVENT_UNTIL_TICKS) {
|
|
break;
|
|
}
|
|
}
|
|
if (this.module._emulator_was_ext_ram_updated(this.e)) {
|
|
vm.extRamUpdated = true;
|
|
}
|
|
}
|
|
|
|
rafCallback(startMs) {
|
|
this.requestAnimationFrame();
|
|
let deltaSec = 0;
|
|
if (!this.isRewinding) {
|
|
const startSec = startMs / 1000;
|
|
deltaSec = Math.max(startSec - (this.lastRafSec || startSec), 0);
|
|
|
|
const startTimeMs = performance.now();
|
|
const deltaTicks =
|
|
Math.min(deltaSec, MAX_UPDATE_SEC) * CPU_TICKS_PER_SECOND;
|
|
let runUntilTicks = this.ticks + deltaTicks - this.leftoverTicks;
|
|
this.runUntil(runUntilTicks);
|
|
const deltaTimeMs = performance.now() - startTimeMs;
|
|
const deltaTimeSec = deltaTimeMs / 1000;
|
|
|
|
if (this.fastForward) {
|
|
// Estimate how much faster we can run in fast-forward, keeping the
|
|
// same rAF update rate.
|
|
const speedUp = (deltaTicks / CPU_TICKS_PER_SECOND) / deltaTimeSec;
|
|
const extraFrames = Math.floor(speedUp - deltaTimeSec);
|
|
const extraTicks = extraFrames * deltaTicks;
|
|
runUntilTicks = this.ticks + extraTicks - this.leftoverTicks;
|
|
this.runUntil(runUntilTicks);
|
|
}
|
|
|
|
this.leftoverTicks = (this.ticks - runUntilTicks) | 0;
|
|
this.lastRafSec = startSec;
|
|
}
|
|
const lerp = (from, to, alpha) => (alpha * from) + (1 - alpha) * to;
|
|
this.fps = lerp(this.fps, Math.min(1 / deltaSec, 10000), 0.3);
|
|
this.video.renderTexture();
|
|
}
|
|
|
|
updateOnscreenGamepad() {
|
|
$('#controller').style.display = this.touchEnabled ? 'block' : 'none';
|
|
}
|
|
|
|
bindTouch() {
|
|
this.touchFuncs = {
|
|
'controller_b': this.setJoypB.bind(this),
|
|
'controller_a': this.setJoypA.bind(this),
|
|
'controller_start': this.setJoypStart.bind(this),
|
|
'controller_select': this.setJoypSelect.bind(this),
|
|
};
|
|
|
|
this.boundButtonTouchStart = this.buttonTouchStart.bind(this);
|
|
this.boundButtonTouchEnd = this.buttonTouchEnd.bind(this);
|
|
selectEl.addEventListener('touchstart', this.boundButtonTouchStart);
|
|
selectEl.addEventListener('touchend', this.boundButtonTouchEnd);
|
|
startEl.addEventListener('touchstart', this.boundButtonTouchStart);
|
|
startEl.addEventListener('touchend', this.boundButtonTouchEnd);
|
|
bEl.addEventListener('touchstart', this.boundButtonTouchStart);
|
|
bEl.addEventListener('touchend', this.boundButtonTouchEnd);
|
|
aEl.addEventListener('touchstart', this.boundButtonTouchStart);
|
|
aEl.addEventListener('touchend', this.boundButtonTouchEnd);
|
|
|
|
this.boundDpadTouchStartMove = this.dpadTouchStartMove.bind(this);
|
|
this.boundDpadTouchEnd = this.dpadTouchEnd.bind(this);
|
|
dpadEl.addEventListener('touchstart', this.boundDpadTouchStartMove);
|
|
dpadEl.addEventListener('touchmove', this.boundDpadTouchStartMove);
|
|
dpadEl.addEventListener('touchend', this.boundDpadTouchEnd);
|
|
|
|
this.boundTouchRestore = this.touchRestore.bind(this);
|
|
window.addEventListener('touchstart', this.boundTouchRestore);
|
|
}
|
|
|
|
unbindTouch() {
|
|
selectEl.removeEventListener('touchstart', this.boundButtonTouchStart);
|
|
selectEl.removeEventListener('touchend', this.boundButtonTouchEnd);
|
|
startEl.removeEventListener('touchstart', this.boundButtonTouchStart);
|
|
startEl.removeEventListener('touchend', this.boundButtonTouchEnd);
|
|
bEl.removeEventListener('touchstart', this.boundButtonTouchStart);
|
|
bEl.removeEventListener('touchend', this.boundButtonTouchEnd);
|
|
aEl.removeEventListener('touchstart', this.boundButtonTouchStart);
|
|
aEl.removeEventListener('touchend', this.boundButtonTouchEnd);
|
|
|
|
dpadEl.removeEventListener('touchstart', this.boundDpadTouchStartMove);
|
|
dpadEl.removeEventListener('touchmove', this.boundDpadTouchStartMove);
|
|
dpadEl.removeEventListener('touchend', this.boundDpadTouchEnd);
|
|
|
|
window.removeEventListener('touchstart', this.boundTouchRestore);
|
|
}
|
|
|
|
buttonTouchStart(event) {
|
|
if (event.currentTarget.id in this.touchFuncs) {
|
|
this.touchFuncs[event.currentTarget.id](true);
|
|
event.currentTarget.classList.add('btnPressed');
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
buttonTouchEnd(event) {
|
|
if (event.currentTarget.id in this.touchFuncs) {
|
|
this.touchFuncs[event.currentTarget.id](false);
|
|
event.currentTarget.classList.remove('btnPressed');
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
dpadTouchStartMove(event) {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const x = (2 * (event.targetTouches[0].clientX - rect.left)) / rect.width - 1;
|
|
const y = (2 * (event.targetTouches[0].clientY - rect.top)) / rect.height - 1;
|
|
|
|
if (Math.abs(x) > OSGP_DEADZONE) {
|
|
if (y > x && y < -x) {
|
|
this.setJoypLeft(true);
|
|
this.setJoypRight(false);
|
|
} else if (y < x && y > -x) {
|
|
this.setJoypLeft(false);
|
|
this.setJoypRight(true);
|
|
}
|
|
} else {
|
|
this.setJoypLeft(false);
|
|
this.setJoypRight(false);
|
|
}
|
|
|
|
if (Math.abs(y) > OSGP_DEADZONE) {
|
|
if (x > y && x < -y) {
|
|
this.setJoypUp(true);
|
|
this.setJoypDown(false);
|
|
} else if (x < y && x > -y) {
|
|
this.setJoypUp(false);
|
|
this.setJoypDown(true);
|
|
}
|
|
} else {
|
|
this.setJoypUp(false);
|
|
this.setJoypDown(false);
|
|
}
|
|
event.preventDefault();
|
|
}
|
|
|
|
dpadTouchEnd(event) {
|
|
this.setJoypLeft(false);
|
|
this.setJoypRight(false);
|
|
this.setJoypUp(false);
|
|
this.setJoypDown(false);
|
|
event.preventDefault();
|
|
}
|
|
|
|
touchRestore() {
|
|
this.touchEnabled = true;
|
|
this.updateOnscreenGamepad();
|
|
}
|
|
|
|
bindKeys() {
|
|
this.keyFuncs = {
|
|
'ArrowDown': this.setJoypDown.bind(this),
|
|
'ArrowLeft': this.setJoypLeft.bind(this),
|
|
'ArrowRight': this.setJoypRight.bind(this),
|
|
'ArrowUp': this.setJoypUp.bind(this),
|
|
'KeyZ': this.setJoypB.bind(this),
|
|
'KeyX': this.setJoypA.bind(this),
|
|
'Enter': this.setJoypStart.bind(this),
|
|
'Tab': this.setJoypSelect.bind(this),
|
|
'Backspace': this.keyRewind.bind(this),
|
|
'Space': this.keyPause.bind(this),
|
|
'BracketLeft': this.keyPrevPalette.bind(this),
|
|
'BracketRight': this.keyNextPalette.bind(this),
|
|
'ShiftLeft': this.setFastForward.bind(this),
|
|
};
|
|
this.boundKeyDown = this.keyDown.bind(this);
|
|
this.boundKeyUp = this.keyUp.bind(this);
|
|
|
|
window.addEventListener('keydown', this.boundKeyDown);
|
|
window.addEventListener('keyup', this.boundKeyUp);
|
|
}
|
|
|
|
unbindKeys() {
|
|
window.removeEventListener('keydown', this.boundKeyDown);
|
|
window.removeEventListener('keyup', this.boundKeyUp);
|
|
}
|
|
|
|
keyDown(event) {
|
|
if (event.code in this.keyFuncs) {
|
|
if (this.touchEnabled) {
|
|
this.touchEnabled = false;
|
|
this.updateOnscreenGamepad();
|
|
}
|
|
this.keyFuncs[event.code](true);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
keyUp(event) {
|
|
if (event.code in this.keyFuncs) {
|
|
this.keyFuncs[event.code](false);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
keyRewind(isKeyDown) {
|
|
if (!ENABLE_REWIND) { return; }
|
|
if (this.isRewinding !== isKeyDown) {
|
|
if (isKeyDown) {
|
|
vm.paused = true;
|
|
this.autoRewind = true;
|
|
} else {
|
|
this.autoRewind = false;
|
|
vm.paused = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
keyPause(isKeyDown) {
|
|
if (!ENABLE_PAUSE) { return; }
|
|
if (isKeyDown) vm.togglePause();
|
|
}
|
|
|
|
keyPrevPalette(isKeyDown) {
|
|
if (!ENABLE_SWITCH_PALETTES) { return; }
|
|
if (isKeyDown) {
|
|
vm.palIdx = (vm.palIdx + PALETTES.length - 1) % PALETTES.length;
|
|
emulator.setBuiltinPalette(vm.palIdx);
|
|
}
|
|
}
|
|
|
|
keyNextPalette(isKeyDown) {
|
|
if (!ENABLE_SWITCH_PALETTES) { return; }
|
|
if (isKeyDown) {
|
|
vm.palIdx = (vm.palIdx + 1) % PALETTES.length;
|
|
emulator.setBuiltinPalette(vm.palIdx);
|
|
}
|
|
}
|
|
|
|
setFastForward(isKeyDown) {
|
|
if (!ENABLE_FAST_FORWARD) { return; }
|
|
this.fastForward = isKeyDown;
|
|
}
|
|
|
|
setJoypDown(set) { this.module._set_joyp_down(this.e, set); }
|
|
setJoypUp(set) { this.module._set_joyp_up(this.e, set); }
|
|
setJoypLeft(set) { this.module._set_joyp_left(this.e, set); }
|
|
setJoypRight(set) { this.module._set_joyp_right(this.e, set); }
|
|
setJoypSelect(set) { this.module._set_joyp_select(this.e, set); }
|
|
setJoypStart(set) { this.module._set_joyp_start(this.e, set); }
|
|
setJoypB(set) { this.module._set_joyp_B(this.e, set); }
|
|
setJoypA(set) { this.module._set_joyp_A(this.e, set); }
|
|
}
|
|
|
|
class Audio {
|
|
constructor(module, e) {
|
|
this.started = false;
|
|
this.module = module;
|
|
this.buffer = makeWasmBuffer(
|
|
this.module, this.module._get_audio_buffer_ptr(e),
|
|
this.module._get_audio_buffer_capacity(e));
|
|
this.startSec = 0;
|
|
this.resume();
|
|
|
|
this.boundStartPlayback = this.startPlayback.bind(this);
|
|
window.addEventListener('keydown', this.boundStartPlayback, true);
|
|
window.addEventListener('click', this.boundStartPlayback, true);
|
|
window.addEventListener('touchend', this.boundStartPlayback, true);
|
|
}
|
|
|
|
startPlayback() {
|
|
window.removeEventListener('touchend', this.boundStartPlayback, true);
|
|
window.removeEventListener('keydown', this.boundStartPlayback, true);
|
|
window.removeEventListener('click', this.boundStartPlayback, true);
|
|
this.started = true;
|
|
this.resume();
|
|
}
|
|
|
|
get sampleRate() { return Audio.ctx.sampleRate; }
|
|
|
|
pushBuffer() {
|
|
if (!this.started) { return; }
|
|
const nowSec = Audio.ctx.currentTime;
|
|
const nowPlusLatency = nowSec + AUDIO_LATENCY_SEC;
|
|
const volume = vm.volume;
|
|
this.startSec = (this.startSec || nowPlusLatency);
|
|
if (this.startSec >= nowSec) {
|
|
const buffer = Audio.ctx.createBuffer(2, AUDIO_FRAMES, this.sampleRate);
|
|
const channel0 = buffer.getChannelData(0);
|
|
const channel1 = buffer.getChannelData(1);
|
|
for (let i = 0; i < AUDIO_FRAMES; i++) {
|
|
channel0[i] = this.buffer[2 * i] * volume / 255;
|
|
channel1[i] = this.buffer[2 * i + 1] * volume / 255;
|
|
}
|
|
const bufferSource = Audio.ctx.createBufferSource();
|
|
bufferSource.buffer = buffer;
|
|
bufferSource.connect(Audio.ctx.destination);
|
|
bufferSource.start(this.startSec);
|
|
const bufferSec = AUDIO_FRAMES / this.sampleRate;
|
|
this.startSec += bufferSec;
|
|
} else {
|
|
console.log(
|
|
'Resetting audio (' + this.startSec.toFixed(2) + ' < ' +
|
|
nowSec.toFixed(2) + ')');
|
|
this.startSec = nowPlusLatency;
|
|
}
|
|
}
|
|
|
|
pause() {
|
|
if (!this.started) { return; }
|
|
Audio.ctx.suspend();
|
|
}
|
|
|
|
resume() {
|
|
if (!this.started) { return; }
|
|
Audio.ctx.resume();
|
|
}
|
|
}
|
|
|
|
Audio.ctx = new AudioContext;
|
|
|
|
class Video {
|
|
constructor(module, e, el) {
|
|
this.module = module;
|
|
// iPhone Safari doesn't upscale using image-rendering: pixelated on webgl
|
|
// canvases. See https://bugs.webkit.org/show_bug.cgi?id=193895.
|
|
// For now, default to Canvas2D.
|
|
if (window.navigator.userAgent.match(/iPhone|iPad/)) {
|
|
this.renderer = new Canvas2DRenderer(el);
|
|
} else {
|
|
try {
|
|
this.renderer = new WebGLRenderer(el);
|
|
} catch (error) {
|
|
console.log(`Error creating WebGLRenderer: ${error}`);
|
|
this.renderer = new Canvas2DRenderer(el);
|
|
}
|
|
}
|
|
this.buffer = makeWasmBuffer(
|
|
this.module, this.module._get_frame_buffer_ptr(e),
|
|
this.module._get_frame_buffer_size(e));
|
|
}
|
|
|
|
uploadTexture() {
|
|
this.renderer.uploadTexture(this.buffer);
|
|
}
|
|
|
|
renderTexture() {
|
|
this.renderer.renderTexture();
|
|
}
|
|
}
|
|
|
|
class Canvas2DRenderer {
|
|
constructor(el) {
|
|
this.ctx = el.getContext('2d');
|
|
this.imageData = this.ctx.createImageData(el.width, el.height);
|
|
}
|
|
|
|
renderTexture() {
|
|
this.ctx.putImageData(this.imageData, 0, 0);
|
|
}
|
|
|
|
uploadTexture(buffer) {
|
|
this.imageData.data.set(buffer);
|
|
}
|
|
}
|
|
|
|
class WebGLRenderer {
|
|
constructor(el) {
|
|
const gl = this.gl = el.getContext('webgl', {preserveDrawingBuffer: true});
|
|
if (gl === null) {
|
|
throw new Error('unable to create webgl context');
|
|
}
|
|
|
|
const w = SCREEN_WIDTH / 256;
|
|
const h = SCREEN_HEIGHT / 256;
|
|
const buffer = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
|
-1, -1, 0, h,
|
|
+1, -1, w, h,
|
|
-1, +1, 0, 0,
|
|
+1, +1, w, 0,
|
|
]), gl.STATIC_DRAW);
|
|
|
|
const texture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
gl.texImage2D(
|
|
gl.TEXTURE_2D, 0, gl.RGBA, 256, 256, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
|
|
function compileShader(type, source) {
|
|
const shader = gl.createShader(type);
|
|
gl.shaderSource(shader, source);
|
|
gl.compileShader(shader);
|
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
throw new Error(`compileShader failed: ${gl.getShaderInfoLog(shader)}`);
|
|
}
|
|
return shader;
|
|
}
|
|
|
|
const vertexShader = compileShader(gl.VERTEX_SHADER,
|
|
`attribute vec2 aPos;
|
|
attribute vec2 aTexCoord;
|
|
varying highp vec2 vTexCoord;
|
|
void main(void) {
|
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
vTexCoord = aTexCoord;
|
|
}`);
|
|
const fragmentShader = compileShader(gl.FRAGMENT_SHADER,
|
|
`varying highp vec2 vTexCoord;
|
|
uniform sampler2D uSampler;
|
|
void main(void) {
|
|
gl_FragColor = texture2D(uSampler, vTexCoord);
|
|
}`);
|
|
|
|
const program = gl.createProgram();
|
|
gl.attachShader(program, vertexShader);
|
|
gl.attachShader(program, fragmentShader);
|
|
gl.linkProgram(program);
|
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
throw new Error(`program link failed: ${gl.getProgramInfoLog(program)}`);
|
|
}
|
|
gl.useProgram(program);
|
|
|
|
const aPos = gl.getAttribLocation(program, 'aPos');
|
|
const aTexCoord = gl.getAttribLocation(program, 'aTexCoord');
|
|
const uSampler = gl.getUniformLocation(program, 'uSampler');
|
|
|
|
gl.enableVertexAttribArray(aPos);
|
|
gl.enableVertexAttribArray(aTexCoord);
|
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, gl.FALSE, 16, 0);
|
|
gl.vertexAttribPointer(aTexCoord, 2, gl.FLOAT, gl.FALSE, 16, 8);
|
|
gl.uniform1i(uSampler, 0);
|
|
}
|
|
|
|
renderTexture() {
|
|
this.gl.clearColor(0.5, 0.5, 0.5, 1.0);
|
|
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
|
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
|
}
|
|
|
|
uploadTexture(buffer) {
|
|
this.gl.texSubImage2D(
|
|
this.gl.TEXTURE_2D, 0, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, this.gl.RGBA,
|
|
this.gl.UNSIGNED_BYTE, buffer);
|
|
}
|
|
}
|
|
|
|
class Rewind {
|
|
constructor(module, e) {
|
|
this.module = module;
|
|
this.e = e;
|
|
this.joypadBufferPtr = this.module._joypad_new();
|
|
this.statePtr = 0;
|
|
this.bufferPtr = this.module._rewind_new_simple(
|
|
e, REWIND_FRAMES_PER_BASE_STATE, REWIND_BUFFER_CAPACITY);
|
|
this.module._emulator_set_default_joypad_callback(e, this.joypadBufferPtr);
|
|
}
|
|
|
|
destroy() {
|
|
this.module._rewind_delete(this.bufferPtr);
|
|
this.module._joypad_delete(this.joypadBufferPtr);
|
|
}
|
|
|
|
get oldestTicks() {
|
|
return this.module._rewind_get_oldest_ticks_f64(this.bufferPtr);
|
|
}
|
|
|
|
get newestTicks() {
|
|
return this.module._rewind_get_newest_ticks_f64(this.bufferPtr);
|
|
}
|
|
|
|
pushBuffer() {
|
|
if (!this.isRewinding) {
|
|
this.module._rewind_append(this.bufferPtr, this.e);
|
|
}
|
|
}
|
|
|
|
get isRewinding() {
|
|
return this.statePtr !== 0;
|
|
}
|
|
|
|
beginRewind() {
|
|
if (this.isRewinding) return;
|
|
this.statePtr =
|
|
this.module._rewind_begin(this.e, this.bufferPtr, this.joypadBufferPtr);
|
|
}
|
|
|
|
rewindToTicks(ticks) {
|
|
if (!this.isRewinding) return;
|
|
return this.module._rewind_to_ticks_wrapper(this.statePtr, ticks) ===
|
|
RESULT_OK;
|
|
}
|
|
|
|
endRewind() {
|
|
if (!this.isRewinding) return;
|
|
this.module._emulator_set_default_joypad_callback(
|
|
this.e, this.joypadBufferPtr);
|
|
this.module._rewind_end(this.statePtr);
|
|
this.statePtr = 0;
|
|
}
|
|
}
|