1386 lines
37 KiB
JavaScript
1386 lines
37 KiB
JavaScript
/*
|
|
* Copyright (C) 2017 Ben Smith
|
|
*
|
|
* This software may be modified and distributed under the terms
|
|
* of the MIT license. See the LICENSE file for details.
|
|
*/
|
|
"use strict";
|
|
|
|
// User configurable.
|
|
const ROM_FILENAME = "rom/game.gb";
|
|
const ENABLE_REWIND = true;
|
|
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 = 83;
|
|
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,
|
|
];
|
|
|
|
const RESULT_OK = 0;
|
|
const RESULT_ERROR = 1;
|
|
const SCREEN_WIDTH = 160;
|
|
const SCREEN_HEIGHT = 144;
|
|
const SGB_SCREEN_WIDTH = 256;
|
|
const SGB_SCREEN_HEIGHT = 224;
|
|
const SGB_SCREEN_LEFT = (SGB_SCREEN_WIDTH - SCREEN_WIDTH) >> 1;
|
|
const SGB_SCREEN_RIGHT = (SGB_SCREEN_WIDTH + SCREEN_WIDTH) >> 1;
|
|
const SGB_SCREEN_TOP = (SGB_SCREEN_HEIGHT - SCREEN_HEIGHT) >> 1;
|
|
const SGB_SCREEN_BOTTOM = (SGB_SCREEN_HEIGHT + SCREEN_HEIGHT) >> 1;
|
|
const AUDIO_FRAMES = 4096;
|
|
const AUDIO_LATENCY_SEC = 0.1;
|
|
const MAX_UPDATE_SEC = 5 / 60;
|
|
const CPU_TICKS_PER_SECOND = 4194304;
|
|
const EVENT_NEW_FRAME = 1;
|
|
const EVENT_AUDIO_BUFFER_FULL = 2;
|
|
const EVENT_UNTIL_TICKS = 4;
|
|
const REWIND_FRAMES_PER_BASE_STATE = 45;
|
|
const REWIND_BUFFER_CAPACITY = 4 * 1024 * 1024;
|
|
const REWIND_FACTOR = 1.5;
|
|
const REWIND_UPDATE_MS = 16;
|
|
const GAMEPAD_POLLING_INTERVAL = 1000 / 60 / 4; // When activated, poll for gamepad input about ~4 times per gameboy frame (~240 times second)
|
|
const GAMEPAD_KEYMAP_STANDARD_STR = "standard"; // Try to use "standard" HTML5 mapping config if available
|
|
|
|
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();
|
|
|
|
const sgbEnabled = window.location.href.includes("sgb=true");
|
|
if (sgbEnabled) {
|
|
$("canvas").width = SGB_SCREEN_WIDTH;
|
|
$("canvas").height = SGB_SCREEN_HEIGHT;
|
|
} else {
|
|
$("canvas").width = SCREEN_WIDTH;
|
|
$("canvas").height = SCREEN_HEIGHT;
|
|
}
|
|
|
|
// 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.canvas = {
|
|
show: true,
|
|
useSgbBorder: sgbEnabled,
|
|
scale: 3,
|
|
};
|
|
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);
|
|
})();
|
|
|
|
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;
|
|
this.romDataPtr = this.module._malloc(romBuffer.byteLength);
|
|
makeWasmBuffer(this.module, this.romDataPtr, romBuffer.byteLength).set(
|
|
new Uint8Array(romBuffer)
|
|
);
|
|
this.e = this.module._emulator_new_simple(
|
|
this.romDataPtr,
|
|
romBuffer.byteLength,
|
|
Audio.ctx.sampleRate,
|
|
AUDIO_FRAMES,
|
|
CGB_COLOR_CURVE
|
|
);
|
|
if (this.e == 0) {
|
|
throw new Error("Invalid ROM.");
|
|
}
|
|
|
|
this.gamepad = new Gamepad(module, this.e);
|
|
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;
|
|
|
|
if (extRamBuffer) {
|
|
this.loadExtRam(extRamBuffer);
|
|
}
|
|
|
|
this.bindKeys();
|
|
this.bindTouch();
|
|
|
|
this.touchEnabled = "ontouchstart" in document.documentElement;
|
|
this.updateOnscreenGamepad();
|
|
|
|
this.gamepad.init();
|
|
}
|
|
|
|
destroy() {
|
|
this.gamepad.shutdown();
|
|
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 this.rewind.isRewinding;
|
|
}
|
|
|
|
beginRewind() {
|
|
this.rewind.beginRewind();
|
|
}
|
|
|
|
rewindToTicks(ticks) {
|
|
if (this.rewind.rewindToTicks(ticks)) {
|
|
this.runUntil(ticks);
|
|
this.video.renderTexture();
|
|
}
|
|
}
|
|
|
|
endRewind() {
|
|
this.rewind.endRewind();
|
|
this.lastRafSec = 0;
|
|
this.leftoverTicks = 0;
|
|
this.audio.startSec = 0;
|
|
}
|
|
|
|
set autoRewind(enabled) {
|
|
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 startTicks = this.ticks;
|
|
const deltaTicks =
|
|
Math.min(deltaSec, MAX_UPDATE_SEC) * CPU_TICKS_PER_SECOND;
|
|
const runUntilTicks = startTicks + deltaTicks - 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 = {
|
|
Backspace: this.keyRewind.bind(this),
|
|
" ": this.keyPause.bind(this),
|
|
"[": this.keyPrevPalette.bind(this),
|
|
"]": this.keyNextPalette.bind(this),
|
|
};
|
|
|
|
if (customControls.down && customControls.down.length > 0) {
|
|
customControls.down.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypDown.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["ArrowDown"] = this.setJoypDown.bind(this);
|
|
this.keyFuncs["s"] = this.setJoypDown.bind(this);
|
|
}
|
|
|
|
if (customControls.left && customControls.left.length > 0) {
|
|
customControls.left.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypLeft.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["ArrowLeft"] = this.setJoypLeft.bind(this);
|
|
this.keyFuncs["a"] = this.setJoypLeft.bind(this);
|
|
}
|
|
|
|
if (customControls.right && customControls.right.length > 0) {
|
|
customControls.right.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypRight.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["ArrowRight"] = this.setJoypRight.bind(this);
|
|
this.keyFuncs["d"] = this.setJoypRight.bind(this);
|
|
}
|
|
|
|
if (customControls.up && customControls.up.length > 0) {
|
|
customControls.up.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypUp.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["ArrowUp"] = this.setJoypUp.bind(this);
|
|
this.keyFuncs["w"] = this.setJoypUp.bind(this);
|
|
}
|
|
|
|
if (customControls.a && customControls.a.length > 0) {
|
|
customControls.a.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypA.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["z"] = this.setJoypA.bind(this);
|
|
this.keyFuncs["j"] = this.setJoypA.bind(this);
|
|
this.keyFuncs["Alt"] = this.setJoypA.bind(this);
|
|
}
|
|
|
|
if (customControls.b && customControls.b.length > 0) {
|
|
customControls.b.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypB.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["x"] = this.setJoypB.bind(this);
|
|
this.keyFuncs["k"] = this.setJoypB.bind(this);
|
|
this.keyFuncs["Control"] = this.setJoypB.bind(this);
|
|
}
|
|
|
|
if (customControls.start && customControls.start.length > 0) {
|
|
customControls.start.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypStart.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["Enter"] = this.setJoypStart.bind(this);
|
|
}
|
|
|
|
if (customControls.select && customControls.select.length > 0) {
|
|
customControls.select.forEach((k) => {
|
|
this.keyFuncs[k] = this.setJoypSelect.bind(this);
|
|
});
|
|
} else {
|
|
this.keyFuncs["Shift"] = this.setJoypSelect.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.key === "w" && (event.metaKey || event.ctrlKey)) {
|
|
return;
|
|
}
|
|
if (event.key in this.keyFuncs) {
|
|
if (this.touchEnabled) {
|
|
this.touchEnabled = false;
|
|
this.updateOnscreenGamepad();
|
|
}
|
|
this.keyFuncs[event.key](true);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
keyUp(event) {
|
|
if (event.key in this.keyFuncs) {
|
|
this.keyFuncs[event.key](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);
|
|
}
|
|
}
|
|
|
|
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 Gamepad {
|
|
constructor(module, e) {
|
|
this.module = module;
|
|
this.e = e;
|
|
}
|
|
|
|
// Load a key map for gamepad-to-gameboy buttons
|
|
bindKeys(strMapping) {
|
|
this.GAMEPAD_KEYMAP_STANDARD = [
|
|
{
|
|
gb_key: "b",
|
|
gp_button: 0,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_B.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "a",
|
|
gp_button: 1,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_A.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "select",
|
|
gp_button: 8,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_select.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "start",
|
|
gp_button: 9,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_start.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "up",
|
|
gp_button: 12,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_up.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "down",
|
|
gp_button: 13,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_down.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "left",
|
|
gp_button: 14,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_left.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "right",
|
|
gp_button: 15,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_right.bind(null, this.e),
|
|
},
|
|
];
|
|
|
|
this.GAMEPAD_KEYMAP_DEFAULT = [
|
|
{
|
|
gb_key: "a",
|
|
gp_button: 0,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_A.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "b",
|
|
gp_button: 1,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_B.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "select",
|
|
gp_button: 2,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_select.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "start",
|
|
gp_button: 3,
|
|
type: "button",
|
|
gp_bind: this.module._set_joyp_start.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "up",
|
|
gp_button: 2,
|
|
type: "axis",
|
|
gp_bind: this.module._set_joyp_up.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "down",
|
|
gp_button: 3,
|
|
type: "axis",
|
|
gp_bind: this.module._set_joyp_down.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "left",
|
|
gp_button: 0,
|
|
type: "axis",
|
|
gp_bind: this.module._set_joyp_left.bind(null, this.e),
|
|
},
|
|
{
|
|
gb_key: "right",
|
|
gp_button: 1,
|
|
type: "axis",
|
|
gp_bind: this.module._set_joyp_right.bind(null, this.e),
|
|
},
|
|
];
|
|
|
|
// Try to use the w3c "standard" gamepad mapping if available
|
|
// (Chrome/V8 seems to do that better than Firefox)
|
|
//
|
|
// Otherwise use a default mapping that assigns
|
|
// A/B/Select/Start to the first four buttons,
|
|
// and U/D/L/R to the first two axes.
|
|
if (strMapping === GAMEPAD_KEYMAP_STANDARD_STR) {
|
|
this.gp.keybinds = this.GAMEPAD_KEYMAP_STANDARD;
|
|
} else {
|
|
this.gp.keybinds = this.GAMEPAD_KEYMAP_DEFAULT;
|
|
}
|
|
}
|
|
|
|
cacheValues(gamepad) {
|
|
// Read Buttons
|
|
for (let k = 0; k < gamepad.buttons.length; k++) {
|
|
// .value is for analog, .pressed is for boolean buttons
|
|
this.gp.buttons.cur[k] =
|
|
gamepad.buttons[k].value > 0 || gamepad.buttons[k].pressed == true;
|
|
|
|
// Update state changed if not on first input pass
|
|
if (this.gp.buttons.last !== undefined) {
|
|
this.gp.buttons.changed[k] =
|
|
this.gp.buttons.cur[k] != this.gp.buttons.last[k];
|
|
}
|
|
}
|
|
|
|
// Read Axes
|
|
for (let k = 0; k < gamepad.axes.length; k++) {
|
|
// Decode each dpad axis into two buttons, one for each direction
|
|
this.gp.axes.cur[k * 2] = gamepad.axes[k] < 0;
|
|
this.gp.axes.cur[k * 2 + 1] = gamepad.axes[k] > 0;
|
|
|
|
// Update state changed if not on first input pass
|
|
if (this.gp.axes.last !== undefined) {
|
|
this.gp.axes.changed[k * 2] =
|
|
this.gp.axes.cur[k * 2] != this.gp.axes.last[k * 2];
|
|
this.gp.axes.changed[k * 2 + 1] =
|
|
this.gp.axes.cur[k * 2 + 1] != this.gp.axes.last[k * 2 + 1];
|
|
}
|
|
}
|
|
|
|
// Save current state for comparison on next input
|
|
this.gp.axes.last = this.gp.axes.cur.slice(0);
|
|
this.gp.buttons.last = this.gp.buttons.cur.slice(0);
|
|
}
|
|
|
|
handleButton(keyBind) {
|
|
let buttonCache;
|
|
|
|
// Select button / axis cache based on key bind type
|
|
if (keyBind.type === "button") {
|
|
buttonCache = this.gp.buttons;
|
|
} else if (keyBind.type === "axis") {
|
|
buttonCache = this.gp.axes;
|
|
}
|
|
|
|
// Make sure the button exists in the cache array
|
|
if (keyBind.gp_button < buttonCache.changed.length) {
|
|
// Send the button state if it's changed
|
|
if (buttonCache.changed[keyBind.gp_button]) {
|
|
if (buttonCache.cur[keyBind.gp_button]) {
|
|
// Gamepad Button Down
|
|
keyBind.gp_bind(true);
|
|
} else {
|
|
// Gamepad Button Up
|
|
keyBind.gp_bind(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
getCurrent() {
|
|
// Chrome requires retrieving a new gamepad object
|
|
// every time button state is queried (the existing object
|
|
// will have stale button state). Just do that for all browsers
|
|
let gamepad = navigator.getGamepads()[this.gp.apiID];
|
|
|
|
if (gamepad) {
|
|
if (gamepad.connected) {
|
|
return gamepad;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
update() {
|
|
let gamepad = this.getCurrent();
|
|
|
|
if (gamepad !== undefined) {
|
|
// Cache gamepad input values
|
|
this.cacheValues(gamepad);
|
|
|
|
// Loop through buttons and send changes if needed
|
|
for (let i = 0; i < this.gp.keybinds.length; i++) {
|
|
this.handleButton(this.gp.keybinds[i]);
|
|
}
|
|
} else {
|
|
// Gamepad is no longer present, disconnect
|
|
this.releaseGamepad();
|
|
}
|
|
}
|
|
|
|
startGamepad(gamepad) {
|
|
// Make sure it has enough buttons and axes
|
|
if (
|
|
gamepad.mapping === GAMEPAD_KEYMAP_STANDARD_STR ||
|
|
(gamepad.axes.length >= 2 && gamepad.buttons.length >= 4)
|
|
) {
|
|
// Save API index for polling (required by Chrome/V8)
|
|
this.gp.apiID = gamepad.index;
|
|
|
|
// Assign gameboy keys to the gamepad
|
|
this.bindKeys(gamepad.mapping);
|
|
|
|
// Start polling the gamepad for input
|
|
this.gp.timerID = setInterval(
|
|
() => this.update(),
|
|
GAMEPAD_POLLING_INTERVAL
|
|
);
|
|
}
|
|
}
|
|
|
|
releaseGamepad() {
|
|
// Stop polling the gamepad for input
|
|
if (this.gp.timerID !== undefined) {
|
|
clearInterval(this.gp.timerID);
|
|
}
|
|
|
|
// Clear previous button history and controller info
|
|
this.gp.axes.last = undefined;
|
|
this.gp.buttons.last = undefined;
|
|
this.gp.keybinds = undefined;
|
|
|
|
this.gp.apiID = undefined;
|
|
}
|
|
|
|
// If a gamepad was already connected on this page
|
|
// and released, it won't fire another connect event.
|
|
// So try to find any that might be present
|
|
checkAlreadyConnected() {
|
|
let gamepads = navigator.getGamepads();
|
|
|
|
// If any gamepads are already attached to the page,
|
|
// use the first one that is connected
|
|
for (let idx = 0; idx < gamepads.length; idx++) {
|
|
if (gamepads[idx] !== undefined && gamepads[idx] !== null) {
|
|
if (gamepads[idx].connected === true) {
|
|
this.startGamepad(gamepads[idx]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Event handler for when a gamepad is connected
|
|
eventConnected(event) {
|
|
this.startGamepad(navigator.getGamepads()[event.gamepad.index]);
|
|
}
|
|
|
|
// Event handler for when a gamepad is disconnected
|
|
eventDisconnected(event) {
|
|
this.releaseGamepad();
|
|
}
|
|
|
|
// Register event connection handlers for gamepads
|
|
init() {
|
|
// gamepad related vars
|
|
this.gp = {
|
|
apiID: undefined,
|
|
timerID: undefined,
|
|
keybinds: undefined,
|
|
axes: { last: undefined, cur: [], changed: [] },
|
|
buttons: { last: undefined, cur: [], changed: [] },
|
|
};
|
|
|
|
// Check for previously attached gamepads that might
|
|
// not emit a gamepadconnected() event
|
|
this.checkAlreadyConnected();
|
|
|
|
this.boundGamepadConnected = this.eventConnected.bind(this);
|
|
this.boundGamepadDisconnected = this.eventDisconnected.bind(this);
|
|
|
|
// When a gamepad connects, start polling it for input
|
|
window.addEventListener("gamepadconnected", this.boundGamepadConnected);
|
|
|
|
// When a gamepad disconnects, shut down polling for input
|
|
window.addEventListener(
|
|
"gamepaddisconnected",
|
|
this.boundGamepadDisconnected
|
|
);
|
|
}
|
|
|
|
// Release event connection handlers and settings
|
|
shutdown() {
|
|
this.releaseGamepad();
|
|
window.removeEventListener("gamepadconnected", this.boundGamepadConnected);
|
|
window.removeEventListener(
|
|
"gamepaddisconnected",
|
|
this.boundGamepadDisconnected
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
// Both iPhone and Desktop Safari dont'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|15.[0-9] Safari/)) {
|
|
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)
|
|
);
|
|
this.sgbBuffer = makeWasmBuffer(
|
|
this.module,
|
|
this.module._get_sgb_frame_buffer_ptr(e),
|
|
this.module._get_sgb_frame_buffer_size(e)
|
|
);
|
|
}
|
|
|
|
uploadTexture() {
|
|
this.renderer.uploadTextures(this.buffer, this.sgbBuffer);
|
|
}
|
|
|
|
renderTexture() {
|
|
this.renderer.renderTextures();
|
|
}
|
|
}
|
|
|
|
class Canvas2DRenderer {
|
|
constructor(el) {
|
|
this.ctx = el.getContext("2d");
|
|
this.imageData = this.ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
this.sgbImageData = this.ctx.createImageData(
|
|
SGB_SCREEN_WIDTH,
|
|
SGB_SCREEN_HEIGHT
|
|
);
|
|
|
|
this.overlayCanvas = document.createElement("canvas");
|
|
this.overlayCanvas.width = SGB_SCREEN_WIDTH;
|
|
this.overlayCanvas.height = SGB_SCREEN_HEIGHT;
|
|
this.overlayCtx = this.overlayCanvas.getContext("2d");
|
|
}
|
|
|
|
uploadTextures(buffer, sgbBuffer) {
|
|
this.imageData.data.set(buffer);
|
|
this.sgbImageData.data.set(sgbBuffer);
|
|
}
|
|
|
|
renderTextures() {
|
|
if (vm.canvas.useSgbBorder) {
|
|
this.ctx.putImageData(this.imageData, SGB_SCREEN_LEFT, SGB_SCREEN_TOP);
|
|
this.overlayCtx.putImageData(this.sgbImageData, 0, 0);
|
|
this.ctx.drawImage(this.overlayCanvas, 0, 0);
|
|
} else {
|
|
this.ctx.putImageData(this.imageData, 0, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
class WebGLRenderer {
|
|
constructor(el) {
|
|
const gl = (this.gl = el.getContext("webgl", {
|
|
preserveDrawingBuffer: true,
|
|
}));
|
|
if (gl === null) {
|
|
throw new Error("unable to create webgl context");
|
|
}
|
|
|
|
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);
|
|
|
|
this.aPos = gl.getAttribLocation(program, "aPos");
|
|
this.aTexCoord = gl.getAttribLocation(program, "aTexCoord");
|
|
this.uSampler = gl.getUniformLocation(program, "uSampler");
|
|
|
|
this.fbTexture = this.createTexture();
|
|
this.sgbFbTexture = this.createTexture();
|
|
|
|
const invLerpClipSpace = (x, max) => 2 * (x / max) - 1;
|
|
const l = invLerpClipSpace(SGB_SCREEN_LEFT, SGB_SCREEN_WIDTH);
|
|
const r = invLerpClipSpace(SGB_SCREEN_RIGHT, SGB_SCREEN_WIDTH);
|
|
const t = -invLerpClipSpace(SGB_SCREEN_TOP, SGB_SCREEN_HEIGHT);
|
|
const b = -invLerpClipSpace(SGB_SCREEN_BOTTOM, SGB_SCREEN_HEIGHT);
|
|
const w = SCREEN_WIDTH / 256,
|
|
sw = SGB_SCREEN_WIDTH / 256;
|
|
const h = SCREEN_HEIGHT / 256,
|
|
sh = SGB_SCREEN_HEIGHT / 256;
|
|
|
|
const verts = new Float32Array([
|
|
// fb only
|
|
-1,
|
|
-1,
|
|
0,
|
|
h,
|
|
+1,
|
|
-1,
|
|
w,
|
|
h,
|
|
-1,
|
|
+1,
|
|
0,
|
|
0,
|
|
+1,
|
|
+1,
|
|
w,
|
|
0,
|
|
|
|
// sgb fb
|
|
l,
|
|
b,
|
|
0,
|
|
h,
|
|
r,
|
|
b,
|
|
w,
|
|
h,
|
|
l,
|
|
t,
|
|
0,
|
|
0,
|
|
r,
|
|
t,
|
|
w,
|
|
0,
|
|
|
|
// sgb border
|
|
-1,
|
|
-1,
|
|
0,
|
|
sh,
|
|
+1,
|
|
-1,
|
|
sw,
|
|
sh,
|
|
-1,
|
|
+1,
|
|
0,
|
|
0,
|
|
+1,
|
|
+1,
|
|
sw,
|
|
0,
|
|
]);
|
|
|
|
const buffer = gl.createBuffer();
|
|
this.gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
|
|
|
|
gl.enableVertexAttribArray(this.aPos);
|
|
gl.enableVertexAttribArray(this.aTexCoord);
|
|
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, gl.FALSE, 16, 0);
|
|
gl.vertexAttribPointer(this.aTexCoord, 2, gl.FLOAT, gl.FALSE, 16, 8);
|
|
gl.uniform1i(this.uSampler, 0);
|
|
}
|
|
|
|
createTexture() {
|
|
const gl = this.gl;
|
|
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);
|
|
return texture;
|
|
}
|
|
|
|
uploadTextures(buffer, sgbBuffer) {
|
|
const gl = this.gl;
|
|
gl.bindTexture(gl.TEXTURE_2D, this.fbTexture);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
SCREEN_WIDTH,
|
|
SCREEN_HEIGHT,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
buffer
|
|
);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, this.sgbFbTexture);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
SGB_SCREEN_WIDTH,
|
|
SGB_SCREEN_HEIGHT,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
sgbBuffer
|
|
);
|
|
}
|
|
|
|
renderTextures() {
|
|
const gl = this.gl;
|
|
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
|
gl.clearColor(0.5, 0.5, 0.5, 1.0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
if (vm.canvas.useSgbBorder) {
|
|
gl.bindTexture(gl.TEXTURE_2D, this.fbTexture);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 4, 4);
|
|
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.sgbFbTexture);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 8, 4);
|
|
gl.disable(gl.BLEND);
|
|
} else {
|
|
gl.bindTexture(gl.TEXTURE_2D, this.fbTexture);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|