Newer
Older
import React, { FC, useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom/client";
ButtonIncrement,
ButtonSwitch,
export type Callback<T> = (owner: T, params?: Record<string, any>) => void;
/**
* Abstract class that implements the basic functionality
* part of the definition of the Observer pattern.
*
* @see {@link https://en.wikipedia.org/wiki/Observer_pattern}
*/
export class Observable {
private events: Record<string, [Callback<this>]> = {};
bind(event: string, callback: Callback<this>) {
const callbacks = this.events[event] ?? [];
if (callbacks.includes(callback)) return;
callbacks.push(callback);
this.events[event] = callbacks;
}
trigger(event: string, params?: Record<string, any>) {
const callbacks = this.events[event] ?? [];
export type RomInfo = {
name?: string;
data?: Uint8Array;
size?: number;
extra?: Record<string, string | undefined>;
};
export interface ObservableI {
bind(event: string, callback: Callback<this>): void;
trigger(event: string): void;
}
/**
* Top level interface that declares the main abstract
* interface of an emulator structured entity.
* Should allow typical hardware operations to be performed.
*/
export interface Emulator extends ObservableI {
/**
* Obtains the descriptive name of the emulator.
*
* @returns The descriptive name of the emulator.
*/
/**
* Obtains a semantic version string for the current
* version of the emulator.
*
* @returns The semantic version string.
* @see {@link https://semver.org}
*/
/**
* Obtains a URL to the page describing the current version
* of the emulator.
*
* @returns A URL to the page describing the current version
* of the emulator.
*/
/**
* Obtains the pixel format of the emulator's display
* image buffer (eg: RGB).
*
* @returns The pixel format used for the emulator's
* image buffer.
*/
/**
* Obtains the complete image buffer as a sequence of
* bytes that respects the current pixel format from
* `getPixelFormat()`. This method returns an in memory
* pointer to the heap and not a copy.
*
* @returns The byte based image buffer that respects
* the emulator's pixel format.
*/
/**
* Obtains information about the ROM that is currently
* loaded in the emulator.
*
* @returns Structure containing the information about
* the ROM that is currently loaded in the emulator.
*/
/**
* Returns the current logic framerate of the running
* emulator.
*
* @return The current logic framerate of the running
* emulator.
*/
/**
* Toggle the running state of the emulator between paused
* and running, prevents consumers from the need to access
* the current running state of the emulator to implement
* a logic toggle.
*/
toggleRunning(): void;
pause(): void;
resume(): void;
/**
* Resets the emulator machine to the start state and
* re-loads the ROM that is currently set in the emulator.
*/
}
/**
* Enumeration that describes the multiple pixel
* formats and the associated size in bytes.
*/
export enum PixelFormat {
RGB = 3,
RGBA = 4
backgrounds?: string[];
};
export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => {
const [paused, setPaused] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [backgroundIndex, setBackgroundIndex] = useState(0);
const [romInfo, setRomInfo] = useState<RomInfo>({});
const [framerate, setFramerate] = useState(0);
const [keyaction, setKeyaction] = useState<string>();
const [modalTitle, setModalTitle] = useState<string>();
const [modalText, setModalText] = useState<string>();
const [modalVisible, setModalVisible] = useState(false);
const [toastText, setToastText] = useState<string>();
const [toastError, setToastError] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
const frameRef = useRef<boolean>(false);
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
const modalCallbackRef =
useRef<(value: boolean | PromiseLike<boolean>) => void>();
useEffect(() => {
document.body.style.backgroundColor = `#${getBackground()}`;
}, [backgroundIndex]);
useEffect(() => {
switch (keyaction) {
case "Escape":
setFullscreen(false);
setKeyaction(undefined);
break;
case "Fullscreen":
setFullscreen(!fullscreen);
setKeyaction(undefined);
break;
}
}, [keyaction]);
useEffect(() => {
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
setKeyaction("Escape");
event.stopPropagation();
event.preventDefault();
}
if (event.key === "f" && event.ctrlKey === true) {
setKeyaction("Fullscreen");
event.stopPropagation();
event.preventDefault();
}
});
emulator.bind("booted", () => {
const romInfo = emulator.getRomInfo();
setRomInfo(romInfo);
});
emulator.bind("message", (_, params = {}) => {
showToast(params.text, params.error, params.timeout);
});
const getPauseText = () => (paused ? "Resume" : "Pause");
const getPauseIcon = () =>
paused ? require("../res/play.svg") : require("../res/pause.svg");
const getBackground = () => backgrounds[backgroundIndex];
const showModal = async (
text: string,
title = "Alert"
): Promise<boolean> => {
setModalText(text);
setModalTitle(title);
setModalVisible(true);
const result = (await new Promise((resolve) => {
modalCallbackRef.current = resolve;
})) as boolean;
return result;
};
const showToast = async (text: string, error = false, timeout = 3500) => {
setToastText(text);
setToastError(error);
setToastVisible(true);
toastCounterRef.current++;
const counter = toastCounterRef.current;
await new Promise((resolve) => {
setTimeout(() => {
if (counter !== toastCounterRef.current) return;
setToastVisible(false);
resolve(true);
}, timeout);
});
};
const onModalConfirm = () => {
if (modalCallbackRef.current) {
modalCallbackRef.current(true);
modalCallbackRef.current = undefined;
}
setModalVisible(false);
};
const onModalCancel = () => {
if (modalCallbackRef.current) {
modalCallbackRef.current(false);
modalCallbackRef.current = undefined;
}
setModalVisible(false);
};
const onToastCancel = () => {
setToastVisible(false);
};
const onPauseClick = () => {
emulator.toggleRunning();
setPaused(!paused);
const onResetClick = () => {
emulator.reset();
};
const onBenchmarkClick = async () => {
const result = await showModal(
"Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!",
"Confirm"
);
await showToast(
result
? "Will run the benchmark as fast as possible"
: "Will not run the benchmark",
!result
);
setFullscreen(!fullscreen);
const onKeyboardClick = () => {
showToast("Keyboard click");
};
const onThemeClick = () => {
setBackgroundIndex((backgroundIndex + 1) % backgrounds.length);
};
const onMinimize = () => {
setFullscreen(!fullscreen);
};
if (frameRef.current) return;
frameRef.current = true;
handler(emulator.getImageBuffer(), PixelFormat.RGB);
setFramerate(emulator.getFramerate());
const onClearHandler = (handler: ClearHandler) => {
if (errorRef.current) return;
errorRef.current = true;
emulator.bind("error", async () => {
await handler(undefined, require("../res/storm.png"), 0.2);
});
};
<Modal
title={modalTitle}
text={modalText}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
/>
<Toast
text={toastText}
error={toastError}
visible={toastVisible}
onCancel={onToastCancel}
/>
<Footer color={getBackground()}>
Built with ❤️ by{" "}
<Display
fullscreen={fullscreen}
onDrawHandler={onDrawHandler}
onMinimize={onMinimize}
/>
text={emulator.getName()}
version={emulator.getVersion()}
versionUrl={emulator.getVersionUrl()}
iconSrc={require("../res/thunder.png")}
></Title>
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
<Section>
<Paragraph>
This is a{" "}
<Link
href="https://en.wikipedia.org/wiki/Game_Boy"
target="_blank"
>
Game Boy
</Link>{" "}
emulator built using the{" "}
<Link href="https://www.rust-lang.org" target="_blank">
Rust Programming Language
</Link>{" "}
and is running inside this browser with the help of{" "}
<Link href="https://webassembly.org/" target="_blank">
WebAssembly
</Link>
.
</Paragraph>
<Paragraph>
You can check the source code of it at{" "}
<Link
href="https://gitlab.stage.hive.pt/joamag/boytacean"
target="_blank"
>
GitLab
</Link>
.
</Paragraph>
<Paragraph>
TIP: Drag and Drop ROM files to the Browser to load the
ROM.
</Paragraph>
</Section>
<ButtonContainer>
<Button
text={getPauseText()}
image={getPauseIcon()}
imageAlt="pause"
onClick={onPauseClick}
/>
<Button
text={"Reset"}
image={require("../res/reset.svg")}
imageAlt="reset"
onClick={onResetClick}
/>
<Button
text={"Benchmark"}
image={require("../res/bolt.svg")}
imageAlt="benchmark"
onClick={onBenchmarkClick}
/>
<Button
text={"Fullscreen"}
image={require("../res/maximise.svg")}
imageAlt="maximise"
onClick={onFullscreenClick}
/>
<Button
text={"Keyboard"}
image={require("../res/dialpad.svg")}
imageAlt="keyboard"
onClick={onKeyboardClick}
/>
<Button
text={"Theme"}
image={require("../res/marker.svg")}
onClick={onThemeClick}
/>
</ButtonContainer>
<Pair
key="rom"
name={"ROM"}
value={romInfo.name ?? "-"}
/>
<Pair
key="rom-size"
name={"ROM Size"}
value={romInfo.name ? `${romInfo.size} bytes` : "-"}
/>
<Pair
key="rom-type"
name={"ROM Type"}
value={
romInfo.extra?.romType
? `${romInfo.extra?.romType}`
: "-"
}
/>
<Pair
key="framerate"
name={"Framerate"}
value={`${framerate} fps`}
/>
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
<Pair
key="button-tobias"
name={"Button Increment"}
valueNode={
<ButtonIncrement
value={200}
delta={100}
min={0}
suffix={"Hz"}
/>
}
/>
<Pair
key="button-cpu"
name={"Button Switch"}
valueNode={
<ButtonSwitch
options={["NEO", "CLASSIC"]}
size={"large"}
style={["simple"]}
onChange={(v) => alert(v)}
/>
}
/>
</Info>
</Section>
</PanelSplit>
export const startApp = (
element: string,
emulator: Emulator,
backgrounds: string[]
) => {
const elementRef = document.getElementById(element);
if (!elementRef) {
return;
}
const root = ReactDOM.createRoot(elementRef);
root.render(<App emulator={emulator} backgrounds={backgrounds} />);