Newer
Older
import { startApp } from "./react/app";
import { default as _wasm, GameBoy, PadKey, PpuMode } from "./lib/boytacean.js";
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 BACKGROUNDS = [
"264653",
"1b1a17",
"023047",
"bc6c25",
"283618",
"2a9d8f",
"3a5a40"
];
const KEYS: Record<string, number> = {
ArrowUp: PadKey.Up,
ArrowDown: PadKey.Down,
ArrowLeft: PadKey.Left,
ArrowRight: PadKey.Right,
Enter: PadKey.Start,
" ": PadKey.Select,
a: PadKey.A,
s: PadKey.B
const ROM_PATH = require("../../res/roms/20y.gb");
// Enumeration that describes the multiple pixel
enum PixelFormat {
RGB = 3,
RGBA = 4
}
/**
* Top level class that controls the emulator behaviour
* and "joins" all the elements together to bring input/output
* of the associated machine.
*/
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
class Emulator {
/**
* The Game Boy engine (probably coming from WASM) that
* is going to be used for the emulation.
*/
private gameBoy: GameBoy | null = null;
/**
* The descriptive name of the engine that is currently
* in use to emulate the system.
*/
private engine: string | null = null;
private logicFrequency: number = LOGIC_HZ;
private visualFrequency: number = VISUAL_HZ;
private timerFrequency: number = TIMER_HZ;
private idleFrequency: number = IDLE_HZ;
private canvas: HTMLCanvasElement | null = null;
private canvasScaled: HTMLCanvasElement | null = null;
private canvasCtx: CanvasRenderingContext2D | null = null;
private canvasScaledCtx: CanvasRenderingContext2D | null = null;
private image: ImageData | null = null;
private videoBuff: DataView | null = null;
private toastTimeout: number | null = null;
private paused: boolean = false;
private nextTickTime: number = 0;
private fps: number = 0;
private frameStart: number = new Date().getTime();
private frameCount: number = 0;
private romName: string | null = null;
private romData: Uint8Array | null = null;
private romSize: number = 0;
async main() {
// 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 this.buildVisuals();
await this.init();
await this.register();
// start the emulator subsystem with the initial
// ROM retrieved from a remote data source
await this.start({ loadRom: true });
// the counter that controls the overflowing cycles
// from tick to tick operation
let pending = 0;
// runs the sequence as an infinite loop, running
// the associated CPU cycles accordingly
while (true) {
// in case the machine is paused we must delay the execution
// a little bit until the paused state is recovered
if (this.paused) {
await new Promise((resolve) => {
setTimeout(resolve, 1000 / this.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 {
pending = this.tick(currentTime, pending);
} catch (err) {
// sets the default error message to be displayed
// to the user, this value may be overridden in case
// a better and more explicit message can be determined
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 diagnostics)
this.showToast(message, true, 5000);
console.error(err);
// pauses the machine, allowing the end-user to act
// on the error in a proper fashion
this.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 this.clearCanvas(undefined, {
image: require("./res/storm.png"),
imageScale: 0.2
});
await wasm();
await this.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(this.nextTickTime - currentTime, 0);
// waits a little bit for the next frame to be draw,
// this should control the flow of render
tick(currentTime: number, pending: number, cycles: number = 70224) {
// in case the time to draw the next frame has not been
// reached the flush of the "tick" logic is skipped
if (currentTime < this.nextTickTime) return pending;
// 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 (this.nextTickTime === 0) this.nextTickTime = currentTime;
let ticks = Math.ceil(
(currentTime - this.nextTickTime) /
((1 / this.visualFrequency) * 1000)
);
ticks = Math.max(ticks, 1);
// initializes the counter of cycles with the pending number
// of cycles coming from the previous tick
let counterCycles = pending;
let lastFrame = -1;
while (true) {
// limits the number of cycles to the provided
// cycle value passed as a parameter
if (counterCycles >= cycles) {
break;
}
// runs the Game Boy clock, this operations should
// include the advance of both the CPU and the PPU
counterCycles += this.gameBoy!.clock();
// in case the current PPU mode is VBlank and the
// frame is different from the previously rendered
// one then it's time to update the canvas
if (
this.gameBoy!.ppu_mode() == PpuMode.VBlank &&
this.gameBoy!.ppu_frame() != lastFrame
) {
// updates the canvas object with the new
// visual information coming in
this.updateCanvas(
this.gameBoy!.frame_buffer_eager(),
PixelFormat.RGB
);
lastFrame = this.gameBoy!.ppu_frame();
// increments the number of frames rendered in the current
// section, this value is going to be used to calculate FPS
this.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 (this.frameCount === this.visualFrequency * SAMPLE_RATE) {
const currentTime = new Date().getTime();
const deltaTime = (currentTime - this.frameStart) / 1000;
const fps = Math.round(this.frameCount / deltaTime);
this.setFps(fps);
this.frameCount = 0;
this.frameStart = currentTime;
}
// updates the next update time reference to the, so that it
// can be used to control the game loop
this.nextTickTime += (1000 / this.visualFrequency) * ticks;
// calculates the new number of pending (overflow) cycles
// that are going to be added to the next iteration
return counterCycles - cycles;
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
/**
* Starts the current machine, setting the internal structure in
* a proper state to start drawing and receiving input.
*
* @param options The options that are going to be used in the
* starting of the machine.
*/
async start({
engine = "neo",
restore = true,
loadRom = false,
romPath = ROM_PATH,
romName = null,
romData = null
}: {
engine?: string | null;
restore?: boolean;
loadRom?: boolean;
romPath?: string;
romName?: string | null;
romData?: Uint8Array | null;
} = {}) {
// in case a remote ROM loading operation has been
// requested then loads it from the remote origin
if (loadRom) {
[romName, romData] = await this.fetchRom(romPath);
} else if (romName === null || romData === null) {
[romName, romData] = [this.romName, this.romData];
// selects the proper engine for execution
// and builds a new instance of it
switch (engine) {
case "neo":
this.gameBoy = new GameBoy();
break;
default:
if (!this.gameBoy) {
throw new Error("No engine requested");
}
break;
// resets the Game Boy engine to restore it into
// a valid state ready to be used
this.gameBoy.reset();
this.gameBoy.load_boot_default();
const cartridge = this.gameBoy.load_rom_ws(romData!);
// updates the ROM name in case there's extra information
// coming from the cartridge
romName = cartridge.title() ? cartridge.title() : romName;
// updates the name of the currently selected engine
// to the one that has been provided (logic change)
if (engine) this.engine = engine;
// updates the complete set of global information that
// is going to be displayed
this.setEngine(this.engine!);
this.setRom(romName!, romData!);
this.setLogicFrequency(this.logicFrequency);
this.setFps(this.fps);
// in case the restore (state) flag is set
// then resumes the machine execution
if (restore) this.resume();
async buildVisuals() {
/* KeyValue.create("ROM", "-", { id: "diag:rom-name" }).mount(".diag");
KeyValue.create("ROM Size", "-", { id: "diag:rom-size" }).mount(
".diag"
);
KeyValue.create("Framerate", "-", { id: "diag:framerate" }).mount(
".diag"
);
KeyValue.create("ROM Type", "-", { id: "diag:rom-type" }).mount(
".diag"
);
KeySwitch.create("Tobias", ["1", "2", "3"], {
id: "diag:tobias"
}).mount(".diag");
Button.create("Tobias", require("./res/close.svg"))
.bind("click", () => alert("Hello World"))
.mount(".button-area");*/
}
// @todo remove this method, or at least most of it
async register() {
await Promise.all([
this.registerDrop(),
this.registerKeys(),
this.registerButtons(),
this.registerKeyboard(),
this.registerCanvas(),
this.registerToast(),
this.registerModal()
]);
}
async init() {
await Promise.all([this.initBase(), this.initCanvas()]);
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")) {
this.showToast(
"This is probably not a Game Boy ROM file!",
true
);
return;
const arrayBuffer = await file.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
this.start({ engine: null, romName: file.name, romData: romData });
this.showToast(`Loaded ${file.name} ROM successfully!`);
});
document.addEventListener("dragover", async (event) => {
if (!event.dataTransfer!.items || event.dataTransfer!.items[0].type)
return;
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");
});
}
registerKeys() {
document.addEventListener("keydown", (event) => {
const keyCode = KEYS[event.key];
if (keyCode !== undefined) {
this.gameBoy!.key_press(keyCode);
return;
}
switch (event.key) {
case "+":
this.setLogicFrequency(
this.logicFrequency + FREQUENCY_DELTA
);
break;
case "-":
this.setLogicFrequency(
this.logicFrequency - FREQUENCY_DELTA
);
break;
case "Escape":
this.minimize();
break;
}
});
document.addEventListener("keyup", (event) => {
const keyCode = KEYS[event.key];
if (keyCode !== undefined) {
this.gameBoy!.key_lift(keyCode);
return;
}
});
}
registerButtons() {
const engine = document.getElementById("engine")!;
engine.addEventListener("click", () => {
const name = this.engine == "neo" ? "classic" : "neo";
this.start({ engine: name });
this.showToast(
`Game Boy running in engine "${name.toUpperCase()}" from now on!`
);
});
const logicFrequencyPlus = document.getElementById(
"logic-frequency-plus"
)!;
logicFrequencyPlus.addEventListener("click", () => {
this.setLogicFrequency(this.logicFrequency + FREQUENCY_DELTA);
});
const logicFrequencyMinus = document.getElementById(
"logic-frequency-minus"
)!;
logicFrequencyMinus.addEventListener("click", () => {
this.setLogicFrequency(this.logicFrequency - FREQUENCY_DELTA);
});
const buttonPause = document.getElementById("button-pause")!;
buttonPause.addEventListener("click", () => {
this.toggleRunning();
});
const buttonReset = document.getElementById("button-reset")!;
buttonReset.addEventListener("click", () => {
this.reset();
});
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
const buttonBenchmark = document.getElementById("button-benchmark")!;
buttonBenchmark.addEventListener("click", async () => {
const result = await this.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");
this.pause();
try {
const initial = Date.now();
const count = 500000000;
for (let i = 0; i < count; i++) {
this.gameBoy!.clock();
}
const delta = (Date.now() - initial) / 1000;
const frequency_mhz = count / delta / 1000 / 1000;
this.showToast(
`Took ${delta.toFixed(
2
)} seconds to run ${count} ticks (${frequency_mhz.toFixed(
2
)} Mhz)!`,
undefined,
7500
);
} finally {
this.resume();
buttonBenchmark.classList.remove("enabled");
}
});
const buttonFullscreen = document.getElementById("button-fullscreen")!;
buttonFullscreen.addEventListener("click", () => {
this.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");
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
});
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")!;
canvasTilesCtx.imageSmoothingEnabled = false;
const canvasImage = canvasTilesCtx.createImageData(
canvasTiles.width,
canvasTiles.height
);
const videoBuff = new DataView(canvasImage.data.buffer);
/**
* Draws the tile at the given index to the proper
* vertical offset in the given context and buffer.
*
* @param index The index of the sprite to be drawn.
* @param format The pixel format of the sprite.
*/
const drawTile = (
index: number,
context: CanvasRenderingContext2D,
buffer: DataView,
format: PixelFormat = PixelFormat.RGB
) => {
const pixels = this.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);
buffer.setUint32(offset, color);
counter++;
if (counter == 8) {
counter = 0;
offset +=
(canvasTiles.width - 7) * PixelFormat.RGBA;
} else {
offset += PixelFormat.RGBA;
}
context.putImageData(canvasImage, 0, 0);
};
for (let index = 0; index < 384; index++) {
drawTile(index, canvasTilesCtx, videoBuff);
const vram = this.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 buttonUploadFile = document.getElementById(
"button-upload-file"
) as HTMLInputElement;
buttonUploadFile.addEventListener("change", async () => {
if (
!buttonUploadFile.files ||
buttonUploadFile.files.length === 0
) {
return;
}
const arrayBuffer = await file.arrayBuffer();
const romData = new Uint8Array(arrayBuffer);
this.start({ engine: null, romName: file.name, romData: romData });
this.showToast(`Loaded ${file.name} ROM successfully!`);
});
}
// @todo this should be converted into a component
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();
});
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
Array.prototype.forEach.call(keys, (k: Element) => {
k.addEventListener(
"mousedown",
function (this: HTMLElement, event) {
const keyCode = KEYS[this.textContent!.toLowerCase()];
//this.gameBoy.key_press_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
}
);
k.addEventListener(
"touchstart",
function (this: HTMLElement, event) {
const keyCode = KEYS[this.textContent!.toLowerCase()];
//this.gameBoy.key_press_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
}
);
k.addEventListener("mouseup", function (this: HTMLElement, event) {
const keyCode = KEYS[this.textContent!.toLowerCase()];
//this.gameBoy.key_lift_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
});
k.addEventListener("touchend", function (this: HTMLElement, event) {
const keyCode = KEYS[this.textContent!.toLowerCase()];
//this.gameBoy.key_lift_ws(keyCode); @todo
event.preventDefault();
event.stopPropagation();
});
registerCanvas() {
const canvasClose = document.getElementById("canvas-close")!;
canvasClose.addEventListener("click", () => {
this.minimize();
registerToast() {
const toast = document.getElementById("toast")!;
toast.addEventListener("click", () => {
toast.classList.remove("visible");
});
}
registerModal() {
const modalClose = document.getElementById("modal-close")!;
modalClose.addEventListener("click", () => {
this.hideModal(false);
});
const modalCancel = document.getElementById("modal-cancel")!;
modalCancel.addEventListener("click", () => {
this.hideModal(false);
});
const modalConfirm = document.getElementById("modal-confirm")!;
modalConfirm.addEventListener("click", () => {
this.hideModal(true);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
this.hideModal(false);
}
});
}
async initBase() {
this.setVersion(info.version);
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
async initCanvas() {
// initializes the off-screen canvas that is going to be
// used in the drawing process
this.canvas = document.createElement("canvas");
this.canvas.width = DISPLAY_WIDTH;
this.canvas.height = DISPLAY_HEIGHT;
this.canvasCtx = this.canvas.getContext("2d")!;
this.canvasScaled = document.getElementById(
"engine-canvas"
) as HTMLCanvasElement;
this.canvasScaled.width =
this.canvasScaled.width * window.devicePixelRatio;
this.canvasScaled.height =
this.canvasScaled.height * window.devicePixelRatio;
this.canvasScaledCtx = this.canvasScaled.getContext("2d")!;
this.canvasScaledCtx.scale(
this.canvasScaled.width / this.canvas.width,
this.canvasScaled.height / this.canvas.height
);
this.canvasScaledCtx.imageSmoothingEnabled = false;
this.image = this.canvasCtx.createImageData(
this.canvas.width,
this.canvas.height
);
this.videoBuff = new DataView(this.image.data.buffer);
}
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);
this.videoBuff!.setUint32(offset, color);
offset += PixelFormat.RGBA;
this.canvasCtx!.putImageData(this.image!, 0, 0);
this.canvasScaledCtx!.drawImage(this.canvas!, 0, 0);
async clearCanvas(
color = PIXEL_UNSET_COLOR,
{
image = null,
imageScale = 1
}: { image?: string | null; imageScale?: number } = {}
) {
this.canvasScaledCtx!.fillStyle = `#${color
.toString(16)
.toUpperCase()}`;
this.canvasScaledCtx!.fillRect(
0,
0,
this.canvasScaled!.width,
this.canvasScaled!.height
);
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
// 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] = [
this.canvasScaled!.width / 2 - imgWidth / 2,
this.canvasScaled!.height / 2 - imgHeight / 2
];
this.canvasScaledCtx!.setTransform(1, 0, 0, 1, 0, 0);
try {
this.canvasScaledCtx!.drawImage(
img,
x0,
y0,
imgWidth,
imgHeight
);
} finally {
this.canvasScaledCtx!.scale(
this.canvasScaled!.width / this.canvas!.width,
this.canvasScaled!.height / this.canvas!.height
);
}
}
}
async showToast(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 (this.toastTimeout) clearTimeout(this.toastTimeout);
this.toastTimeout = setTimeout(() => {
toast.classList.remove("visible");
this.toastTimeout = null;
}, timeout);
}
async showModal(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;
}
async hideModal(result = true) {
const modalContainer = document.getElementById("modal-container")!;
modalContainer.classList.remove("visible");
if (global.modalCallback) global.modalCallback(result);
global.modalCallback = null;
}
setVersion(value: string) {
document.getElementById("version")!.textContent = value;
}
setEngine(name: string, upper = true) {
name = upper ? name.toUpperCase() : name;
document.getElementById("engine")!.textContent = name;
}
setRom(name: string, data: Uint8Array) {
this.romName = name;
this.romData = data;
this.romSize = data.length;
//@todo update this one
//Component.get<KeyValue>("diag:rom-name").value = name;
//Component.get<KeyValue>("diag:rom-size").value = `${data.length} bytes`;
}
setLogicFrequency(value: number) {
if (value < 0) this.showToast("Invalid frequency value!", true);
value = Math.max(value, 0);
this.logicFrequency = value;
document.getElementById("logic-frequency")!.textContent = String(value);
}
setFps(value: number) {
if (value < 0) this.showToast("Invalid FPS value!", true);
value = Math.max(value, 0);
this.fps = value;
//@todo
//Component.get<KeyValue>("diag:framerate").value = `${value} FPS`;
toggleRunning() {
if (this.paused) {
this.resume();
} else {
this.pause();
}
}
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
pause() {
this.paused = true;
const buttonPause = document.getElementById("button-pause")!;
const img = buttonPause.getElementsByTagName("img")[0];
const span = buttonPause.getElementsByTagName("span")[0];
buttonPause.classList.add("enabled");
img.src = require("./res/play.svg");
span.textContent = "Resume";
}
resume() {
this.paused = false;
this.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");
img.src = require("./res/pause.svg");
span.textContent = "Pause";
}
/**
* Resets the emulator machine to the start state and loads
* the ROM that is currently set in the emulator.
*/
reset() {
this.start({ engine: null });
}
toggleWindow() {
this.maximize();
}
/**
* Maximizes the emulator's viewport taking up all the available
* window space. This method is responsible for keeping the aspect
* ratio of the emulator canvas according to the width/height ratio.
*/
maximize() {
const canvasContainer = document.getElementById("canvas-container")!;
canvasContainer.classList.add("fullscreen");
window.addEventListener("resize", this.crop);