-
João Magalhães authoredJoão Magalhães authored
index.ts 40.49 KiB
import { BootRom, default as wasm, GameBoy } from "./lib/boytacean.js";
import info from "./package.json";
const PIXEL_UNSET_COLOR = 0x1b1a17ff;
const LOGIC_HZ = 600;
const VISUAL_HZ = 60;
const TIMER_HZ = 60;
const IDLE_HZ = 10;
const FREQUENCY_DELTA = 60;
const DISPLAY_WIDTH = 160;
const DISPLAY_HEIGHT = 144;
const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT;
const SAMPLE_RATE = 2;
const SOUND_DATA =
"data:audio/mpeg;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAA";
const BACKGROUNDS = [
"264653",
"1b1a17",
"023047",
"bc6c25",
"283618",
"2a9d8f",
"3a5a40"
];
const KEYS: Record<string, number> = {
"1": 0x01,
"2": 0x02,
"3": 0x03,
"4": 0x0c,
q: 0x04,
w: 0x05,
e: 0x06,
r: 0x0d,
a: 0x07,
s: 0x08,
d: 0x09,
f: 0x0e,
z: 0x0a,
x: 0x00,
c: 0x0b,
v: 0x0f
};
// @ts-ignore: ts(2580)
const ROM_PATH = require("../../res/roms/firstwhite.gb");
// Enumeration that describes the multiple pixel
// formats and the associated byte size.
enum PixelFormat {
RGB = 3,
RGBA = 4
}
type State = {
gameBoy: GameBoy;
engine: string;
logicFrequency: number;
visualFrequency: number;
timerFrequency: number;
idleFrequency: number;
canvas: HTMLCanvasElement;
canvasScaled: HTMLCanvasElement;
canvasCtx: CanvasRenderingContext2D;
canvasScaledCtx: CanvasRenderingContext2D;
image: ImageData;
videoBuff: DataView;
toastTimeout: number;
paused: boolean;
background_index: number;
nextTickTime: number;
fps: number;
frameStart: number;
frameCount: number;
romName: string;
romData: Uint8Array;
romSize: number;
};
type Global = {
modalCallback: Function;
};
const state: State = {
gameBoy: null,
engine: null,
logicFrequency: LOGIC_HZ,
visualFrequency: VISUAL_HZ,
timerFrequency: TIMER_HZ,
idleFrequency: IDLE_HZ,
canvas: null,
canvasScaled: null,
canvasCtx: null,
canvasScaledCtx: null,
image: null,
videoBuff: null,
toastTimeout: null,
paused: false,
background_index: 0,
nextTickTime: 0,
fps: 0,
frameStart: new Date().getTime(),
frameCount: 0,
romName: null,
romData: null,
romSize: 0
};
const global: Global = {
modalCallback: null
};
const sound = ((data = SOUND_DATA, volume = 0.2) => {
const sound = new Audio(data);
sound.volume = volume;
sound.muted = true;
return sound;
})();
const main = async () => {
// initializes the WASM module, this is required
// so that the global symbols become available
await wasm();
// initializes the complete set of sub-systems
// and registers the event handlers
await init();
await register();
// start the emulator subsystem with the initial
// ROM retrieved from a remote data source
await start({ loadRom: true });
// runs the sequence as an infinite loop, running
// the associated CPU cycles accordingly
while (true) {
// in case the machin is paused we must delay the execution
// a little bit until the paused state is recovered
if (state.paused) {
await new Promise((resolve) => {
setTimeout(resolve, 1000 / state.idleFrequency);
});
continue;
}
// obtains the current time, this value is going
// to be used to compute the need for tick computation
let currentTime = new Date().getTime();
try {
tick(currentTime);
} catch (err) {
// sets the default error message to be displayed
// to the user
let message = String(err);
// verifies if the current issue is a panic one
// and updates the message value if that's the case
const messageNormalized = (err as Error).message.toLowerCase();
const isPanic =
messageNormalized.startsWith("unreachable") ||
messageNormalized.startsWith("recursive use of an object");
if (isPanic) {
message = "Unrecoverable error, restarting Game Boy";
}
// displays the error information to both the end-user
// and the developer (for dianostics)
showToast(message, true, 5000);
console.error(err);
// pauses the machine, allowing the end-user to act
// on the error in a proper fashion
pause();
// if we're talking about a panic proper action must be taken
// which in this case it means restarting both the WASM sub
// system and the machine state (to be able to recover)
// also sets the default color on screen to indicate the issue
if (isPanic) {
await clearCanvas(undefined, {
// @ts-ignore: ts(2580)
image: require("./res/storm.png"),
imageScale: 0.2
});
await wasm();
await start({ restore: false });
}
}
// calculates the amount of time until the next draw operation
// this is the amount of time that is going to be pending
currentTime = new Date().getTime();
const pendingTime = Math.max(state.nextTickTime - currentTime, 0);
// waits a little bit for the next frame to be draw,
// this should control the flow of render
await new Promise((resolve) => {
setTimeout(resolve, pendingTime);
});
}
};
const tick = (currentTime: number) => {
// in case the time to draw the next frame has not been
// reached the flush of the "tick" logic is skiped
if (currentTime < state.nextTickTime) return;
// calculates the number of ticks that have elapsed since the
// last draw operation, this is critical to be able to properly
// operate the clock of the CPU in frame drop situations
if (state.nextTickTime === 0) state.nextTickTime = currentTime;
let ticks = Math.ceil(
(currentTime - state.nextTickTime) /
((1 / state.visualFrequency) * 1000)
);
ticks = Math.max(ticks, 1);
let counterTicks = 0;
while (true) {
// limits the number of ticks to the typical number
// of ticks required to do a complete PPU draw
if (counterTicks >= 70224) {
break;
}
// runs the Game Boy clock, this operations should
// include the advance of both the CPU and the PPU
counterTicks += state.gameBoy.clock();
}
// updates the canvas object with the new
// visual information coming in
updateCanvas(state.gameBoy.frame_buffer_eager(), PixelFormat.RGB);
// increments the number of frames rendered in the current
// section, this value is going to be used to calculate FPS
state.frameCount += 1;
// in case the target number of frames for FPS control
// has been reached calculates the number of FPS and
// flushes the value to the screen
if (state.frameCount === state.visualFrequency * SAMPLE_RATE) {
const currentTime = new Date().getTime();
const deltaTime = (currentTime - state.frameStart) / 1000;
const fps = Math.round(state.frameCount / deltaTime);
setFps(fps);
state.frameCount = 0;
state.frameStart = currentTime;
}
// updates the next update time reference to the, so that it
// can be used to control the game loop
state.nextTickTime += (1000 / state.visualFrequency) * ticks;
};
/**
* Starts the current machine, setting the internal structure in
* a proper state to start drwaing and receiving input.
*
* @param options The options that are going to be used in the
* starting of the machine.
*/
const start = async ({
engine = "neo",
restore = true,
loadRom = false,
romPath = ROM_PATH,
romName = null as string,
romData = null as Uint8Array
} = {}) => {
// in case a remote ROM loading operation has been
// requested then loads it from the remote origin
if (loadRom) {
[romName, romData] = await fetchRom(romPath);
} else if (romName === null || romData === null) {
[romName, romData] = [state.romName, state.romData];
}
// selects the proper engine for execution
// and builds a new instance of it
switch (engine) {
case "neo":
state.gameBoy = new GameBoy();
break;
default:
if (!state.gameBoy) {
throw new Error("No engine requested");
}
break;
}
// resets the Game Boy engine to restore it into
// a valid state ready to be used
state.gameBoy.reset();
state.gameBoy.load_boot_default();
state.gameBoy.load_rom(romData);
// updates the name of the currently selected engine
// to the one that has been provided (logic change)
if (engine) state.engine = engine;
// updates the complete set of global information that
// is going to be displayed
setEngine(state.engine);
setRom(romName, romData);
setLogicFrequency(state.logicFrequency);
setFps(state.fps);
// in case the restore (state) flag is set
// then resumes the machine execution
if (restore) resume();
};
const register = async () => {
await Promise.all([
registerDrop(),
registerKeys(),
registerButtons(),
registerKeyboard(),
registerCanvas(),
registerToast(),
registerModal()
]);
};
const init = async () => {
await Promise.all([initBase(), initCanvas()]);
};
const registerDrop = () => {
document.addEventListener("drop", async (event) => {
if (
!event.dataTransfer.files ||
event.dataTransfer.files.length === 0
) {
return;
}
event.preventDefault();
event.stopPropagation();
const overlay = document.getElementById("overlay");
overlay.classList.remove("visible");
const file = event.dataTransfer.files[0];
if (!file.name.endsWith(".gb")) {
showToast("This is probably not a Game Boy ROM file!", true);
return;
}
const arrayBuffer = await file.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
start({ engine: null, romName: file.name, romData: romData });
showToast(`Loaded ${file.name} ROM successfully!`);
});
document.addEventListener("dragover", async (event) => {
if (!event.dataTransfer.items || event.dataTransfer.items[0].type)
return;
event.preventDefault();
const overlay = document.getElementById("overlay");
overlay.classList.add("visible");
});
document.addEventListener("dragenter", async (event) => {
if (!event.dataTransfer.items || event.dataTransfer.items[0].type)
return;
const overlay = document.getElementById("overlay");
overlay.classList.add("visible");
});
document.addEventListener("dragleave", async (event) => {
if (!event.dataTransfer.items || event.dataTransfer.items[0].type)
return;
const overlay = document.getElementById("overlay");
overlay.classList.remove("visible");
});
};
const registerKeys = () => {
document.addEventListener("keydown", (event) => {
const keyCode = KEYS[event.key];
if (keyCode !== undefined) {
//state.gameBoy.key_press_ws(keyCode); @todo
return;
}
switch (event.key) {
case "+":
setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA);
break;
case "-":
setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA);
break;
case "Escape":
minimize();
break;
}
});
document.addEventListener("keyup", (event) => {
const keyCode = KEYS[event.key];
if (keyCode !== undefined) {
//state.gameBoy.key_lift_ws(keyCode); @todo
return;
}
});
};
const registerButtons = () => {
const engine = document.getElementById("engine");
engine.addEventListener("click", () => {
const name = state.engine == "neo" ? "classic" : "neo";
start({ engine: name });
showToast(
`Game Boy running in engine "${name.toUpperCase()}" from now on!`
);
});
const logicFrequencyPlus = document.getElementById("logic-frequency-plus");
logicFrequencyPlus.addEventListener("click", () => {
setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA);
});
const logicFrequencyMinus = document.getElementById(
"logic-frequency-minus"
);
logicFrequencyMinus.addEventListener("click", () => {
setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA);
});
const buttonPause = document.getElementById("button-pause");
buttonPause.addEventListener("click", () => {
toggleRunning();
});
const buttonReset = document.getElementById("button-reset");
buttonReset.addEventListener("click", () => {
reset();
});
const buttonBenchmark = document.getElementById("button-benchmark");
buttonBenchmark.addEventListener("click", async () => {
const result = await showModal(
"Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!",
"Confirm"
);
if (!result) return;
buttonBenchmark.classList.add("enabled");
pause();
try {
const initial = Date.now();
const count = 500000000;
for (let i = 0; i < count; i++) {
state.gameBoy.clock();
}
const delta = (Date.now() - initial) / 1000;
const frequency_mhz = count / delta / 1000 / 1000;
showToast(
`Took ${delta.toFixed(
2
)} seconds to run ${count} ticks (${frequency_mhz.toFixed(
2
)} Mhz)!`,
undefined,
7500
);
} finally {
resume();
buttonBenchmark.classList.remove("enabled");
}
});
const buttonFullscreen = document.getElementById("button-fullscreen");
buttonFullscreen.addEventListener("click", () => {
maximize();
});
const buttonKeyboard = document.getElementById("button-keyboard");
buttonKeyboard.addEventListener("click", () => {
const sectionKeyboard = document.getElementById("section-keyboard");
const separatorKeyboard = document.getElementById("separator-keyboard");
const sectionNarrative = document.getElementById("section-narrative");
const separatorNarrative = document.getElementById(
"separator-narrative"
);
if (buttonKeyboard.classList.contains("enabled")) {
sectionKeyboard.style.display = "none";
separatorKeyboard.style.display = "none";
sectionNarrative.style.display = "block";
separatorNarrative.style.display = "block";
buttonKeyboard.classList.remove("enabled");
} else {
sectionKeyboard.style.display = "block";
separatorKeyboard.style.display = "block";
sectionNarrative.style.display = "none";
separatorNarrative.style.display = "none";
buttonKeyboard.classList.add("enabled");
}
});
const buttonDebug = document.getElementById("button-debug");
buttonDebug.addEventListener("click", () => {
const sectionDebug = document.getElementById("section-debug");
const separatorDebug = document.getElementById("separator-debug");
const sectionNarrative = document.getElementById("section-narrative");
const separatorNarrative = document.getElementById(
"separator-narrative"
);
if (buttonDebug.classList.contains("enabled")) {
sectionDebug.style.display = "none";
separatorDebug.style.display = "none";
sectionNarrative.style.display = "block";
separatorNarrative.style.display = "block";
buttonDebug.classList.remove("enabled");
} else {
sectionDebug.style.display = "block";
separatorDebug.style.display = "block";
sectionNarrative.style.display = "none";
separatorNarrative.style.display = "none";
buttonDebug.classList.add("enabled");
const canvasTiles = document.getElementById(
"canvas-tiles"
) as HTMLCanvasElement;
const canvasTilesCtx = canvasTiles.getContext("2d");
const canvasImage = canvasTilesCtx.createImageData(
canvasTiles.width,
canvasTiles.height
);
const videoBuff = new DataView(canvasImage.data.buffer);
const drawSprite = (
index: number,
format: PixelFormat = PixelFormat.RGB
) => {
const pixels = state.gameBoy.get_tile_buffer(index);
const line = Math.floor(index / 16);
const column = index % 16;
let offset =
(line * canvasTiles.width * 8 + column * 8) *
PixelFormat.RGBA;
let counter = 0;
for (let index = 0; index < pixels.length; index += format) {
const color =
(pixels[index] << 24) |
(pixels[index + 1] << 16) |
(pixels[index + 2] << 8) |
(format == PixelFormat.RGBA ? pixels[index + 3] : 0xff);
videoBuff.setUint32(offset, color);
counter++;
if (counter == 8) {
counter = 0;
offset += (canvasTiles.width - 7) * PixelFormat.RGBA;
} else {
offset += PixelFormat.RGBA;
}
}
canvasTilesCtx.putImageData(canvasImage, 0, 0);
};
for (let index = 0; index < 256; index++) {
drawSprite(index);
}
const vram = state.gameBoy.vram_eager();
const step = 16;
for (let index = 0; index < vram.length; index += step) {
let line = `${(index + 0x8000).toString(16).padStart(4, "0")}`;
for (let j = 0; j < step; j++) {
line += ` ${vram[index + j].toString(16).padStart(2, "0")}`;
}
console.info(line);
}
}
});
const buttonInformation = document.getElementById("button-information");
buttonInformation.addEventListener("click", () => {
const sectionDiag = document.getElementById("section-diag");
const separatorDiag = document.getElementById("separator-diag");
if (buttonInformation.classList.contains("enabled")) {
sectionDiag.style.display = "none";
separatorDiag.style.display = "none";
buttonInformation.classList.remove("enabled");
} else {
sectionDiag.style.display = "block";
separatorDiag.style.display = "block";
buttonInformation.classList.add("enabled");
}
});
const buttonTheme = document.getElementById("button-theme");
buttonTheme.addEventListener("click", () => {
state.background_index =
(state.background_index + 1) % BACKGROUNDS.length;
const background = BACKGROUNDS[state.background_index];
setBackground(background);
});
const buttonUploadFile = document.getElementById(
"button-upload-file"
) as HTMLInputElement;
buttonUploadFile.addEventListener("change", async () => {
if (!buttonUploadFile.files || buttonUploadFile.files.length === 0) {
return;
}
const file = buttonUploadFile.files[0];
const arrayBuffer = await file.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
buttonUploadFile.value = "";
start({ engine: null, romName: file.name, romData: romData });
showToast(`Loaded ${file.name} ROM successfully!`);
});
};
const registerKeyboard = () => {
const keyboard = document.getElementById("keyboard");
const keys = keyboard.getElementsByClassName("key");
keyboard.addEventListener("touchstart", function (event) {
event.preventDefault();
event.stopPropagation();
});
keyboard.addEventListener("touchend", function (event) {
event.preventDefault();
event.stopPropagation();
});
Array.prototype.forEach.call(keys, (k: Element) => {
k.addEventListener("mousedown", function (event) {
const keyCode = KEYS[this.textContent.toLowerCase()];
//state.gameBoy.key_press_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
});
k.addEventListener("touchstart", function (event) {
const keyCode = KEYS[this.textContent.toLowerCase()];
//state.gameBoy.key_press_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
});
k.addEventListener("mouseup", function (event) {
const keyCode = KEYS[this.textContent.toLowerCase()];
//state.gameBoy.key_lift_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
});
k.addEventListener("touchend", function (event) {
const keyCode = KEYS[this.textContent.toLowerCase()];
//state.gameBoy.key_lift_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
});
});
};
const registerCanvas = () => {
const canvasClose = document.getElementById("canvas-close");
canvasClose.addEventListener("click", () => {
minimize();
});
};
const registerToast = () => {
const toast = document.getElementById("toast");
toast.addEventListener("click", () => {
toast.classList.remove("visible");
});
};
const registerModal = () => {
const modalClose = document.getElementById("modal-close");
modalClose.addEventListener("click", () => {
hideModal(false);
});
const modalCancel = document.getElementById("modal-cancel");
modalCancel.addEventListener("click", () => {
hideModal(false);
});
const modalConfirm = document.getElementById("modal-confirm");
modalConfirm.addEventListener("click", () => {
hideModal(true);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
hideModal(false);
}
});
};
const initBase = async () => {
const background = BACKGROUNDS[state.background_index];
setBackground(background);
setVersion(info.version);
};
const initCanvas = async () => {
// initializes the off-screen canvas that is going to be
// used in the drawing process
state.canvas = document.createElement("canvas");
state.canvas.width = DISPLAY_WIDTH;
state.canvas.height = DISPLAY_HEIGHT;
state.canvasCtx = state.canvas.getContext("2d");
state.canvasScaled = document.getElementById(
"engine-canvas"
) as HTMLCanvasElement;
state.canvasScaled.width =
state.canvasScaled.width * window.devicePixelRatio;
state.canvasScaled.height =
state.canvasScaled.height * window.devicePixelRatio;
state.canvasScaledCtx = state.canvasScaled.getContext("2d");
state.canvasScaledCtx.scale(
state.canvasScaled.width / state.canvas.width,
state.canvasScaled.height / state.canvas.height
);
state.canvasScaledCtx.imageSmoothingEnabled = false;
state.image = state.canvasCtx.createImageData(
state.canvas.width,
state.canvas.height
);
state.videoBuff = new DataView(state.image.data.buffer);
};
const updateCanvas = (
pixels: Uint8Array,
format: PixelFormat = PixelFormat.RGB
) => {
let offset = 0;
for (let index = 0; index < pixels.length; index += format) {
const color =
(pixels[index] << 24) |
(pixels[index + 1] << 16) |
(pixels[index + 2] << 8) |
(format == PixelFormat.RGBA ? pixels[index + 3] : 0xff);
state.videoBuff.setUint32(offset, color);
offset += PixelFormat.RGBA;
}
state.canvasCtx.putImageData(state.image, 0, 0);
state.canvasScaledCtx.drawImage(state.canvas, 0, 0);
};
const clearCanvas = async (
color = PIXEL_UNSET_COLOR,
{ image = null as string, imageScale = 1 } = {}
) => {
state.canvasScaledCtx.fillStyle = `#${color.toString(16).toUpperCase()}`;
state.canvasScaledCtx.fillRect(
0,
0,
state.canvasScaled.width,
state.canvasScaled.height
);
// in case an image was requested then uses that to load
// an image at the center of the screen properly scaled
if (image) {
const img = await new Promise<HTMLImageElement>((resolve) => {
const img = new Image();
img.onload = () => {
resolve(img);
};
img.src = image;
});
const [imgWidth, imgHeight] = [
img.width * imageScale * window.devicePixelRatio,
img.height * imageScale * window.devicePixelRatio
];
const [x0, y0] = [
state.canvasScaled.width / 2 - imgWidth / 2,
state.canvasScaled.height / 2 - imgHeight / 2
];
state.canvasScaledCtx.setTransform(1, 0, 0, 1, 0, 0);
try {
state.canvasScaledCtx.drawImage(img, x0, y0, imgWidth, imgHeight);
} finally {
state.canvasScaledCtx.scale(
state.canvasScaled.width / state.canvas.width,
state.canvasScaled.height / state.canvas.height
);
}
}
};
const showToast = async (message: string, error = false, timeout = 3500) => {
const toast = document.getElementById("toast");
toast.classList.remove("error");
if (error) toast.classList.add("error");
toast.classList.add("visible");
toast.textContent = message;
if (state.toastTimeout) clearTimeout(state.toastTimeout);
state.toastTimeout = setTimeout(() => {
toast.classList.remove("visible");
state.toastTimeout = null;
}, timeout);
};
const showModal = async (
message: string,
title = "Alert"
): Promise<boolean> => {
const modalContainer = document.getElementById("modal-container");
const modalTitle = document.getElementById("modal-title");
const modalText = document.getElementById("modal-text");
modalContainer.classList.add("visible");
modalTitle.textContent = title;
modalText.innerHTML = message.replace(/\n/g, "<br/>");
const result = (await new Promise((resolve) => {
global.modalCallback = resolve;
})) as boolean;
return result;
};
const hideModal = async (result = true) => {
const modalContainer = document.getElementById("modal-container");
modalContainer.classList.remove("visible");
if (global.modalCallback) global.modalCallback(result);
global.modalCallback = null;
};
const setVersion = (value: string) => {
document.getElementById("version").textContent = value;
};
const setEngine = (name: string, upper = true) => {
name = upper ? name.toUpperCase() : name;
document.getElementById("engine").textContent = name;
};
const setRom = (name: string, data: Uint8Array) => {
state.romName = name;
state.romData = data;
state.romSize = data.length;
document.getElementById("rom-name").textContent = name;
document.getElementById("rom-size").textContent = String(data.length);
};
const setLogicFrequency = (value: number) => {
if (value < 0) showToast("Invalid frequency value!", true);
value = Math.max(value, 0);
state.logicFrequency = value;
document.getElementById("logic-frequency").textContent = String(value);
};
const setFps = (value: number) => {
if (value < 0) showToast("Invalid FPS value!", true);
value = Math.max(value, 0);
state.fps = value;
document.getElementById("fps-count").textContent = String(value);
};
const setBackground = (value: string) => {
document.body.style.backgroundColor = `#${value}`;
document.getElementById(
"footer-background"
).style.backgroundColor = `#${value}f2`;
};
const toggleRunning = () => {
if (state.paused) {
resume();
} else {
pause();
}
};
const pause = () => {
state.paused = true;
const buttonPause = document.getElementById("button-pause");
const img = buttonPause.getElementsByTagName("img")[0];
const span = buttonPause.getElementsByTagName("span")[0];
buttonPause.classList.add("enabled");
// @ts-ignore: ts(2580)
img.src = require("./res/play.svg");
span.textContent = "Resume";
};
const resume = () => {
state.paused = false;
state.nextTickTime = new Date().getTime();
const buttonPause = document.getElementById("button-pause");
const img = buttonPause.getElementsByTagName("img")[0];
const span = buttonPause.getElementsByTagName("span")[0];
buttonPause.classList.remove("enabled");
// @ts-ignore: ts(2580)
img.src = require("./res/pause.svg");
span.textContent = "Pause";
};
const toggleWindow = () => {
maximize();
};
const maximize = () => {
const canvasContainer = document.getElementById("canvas-container");
canvasContainer.classList.add("fullscreen");
window.addEventListener("resize", crop);
crop();
};
const minimize = () => {
const canvasContainer = document.getElementById("canvas-container");
const engineCanvas = document.getElementById("engine-canvas");
canvasContainer.classList.remove("fullscreen");
engineCanvas.style.width = null;
engineCanvas.style.height = null;
window.removeEventListener("resize", crop);
};
const crop = () => {
const engineCanvas = document.getElementById("engine-canvas");
// calculates the window ratio as this is fundamental to
// determine the proper way to crop the fulscreen
const windowRatio = window.innerWidth / window.innerHeight;
// in case the window is wider (more horizontal than the base ratio)
// this means that we must crop horizontaly
if (windowRatio > DISPLAY_RATIO) {
engineCanvas.style.width = `${
window.innerWidth * (DISPLAY_RATIO / windowRatio)
}px`;
engineCanvas.style.height = `${window.innerHeight}px`;
} else {
engineCanvas.style.width = `${window.innerWidth}px`;
engineCanvas.style.height = `${
window.innerHeight * (windowRatio / DISPLAY_RATIO)
}px`;
}
};
const reset = () => {
start({ engine: null });
};
const fetchRom = async (romPath: string): Promise<[string, Uint8Array]> => {
// extracts the name of the ROM from the provided
// path by splitting its structure
const romPathS = romPath.split(/\//g);
let romName = romPathS[romPathS.length - 1].split("?")[0];
const romNameS = romName.split(/\./g);
romName = `${romNameS[0]}.${romNameS[romNameS.length - 1]}`;
// loads the ROM data and converts it into the
// target byte array buffer (to be used by WASM)
const response = await fetch(ROM_PATH);
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
// returns both the name of the ROM and the data
// contents as a byte array
return [romName, romData];
};
(async () => {
await main();
})();