diff --git a/examples/web/index.css b/examples/web/index.css index fc4edd1ecb12f0696f93872660a92dae70f8547f..49b3eb97227dd4bcc3afd1bb7aaca88909fea02d 100644 --- a/examples/web/index.css +++ b/examples/web/index.css @@ -56,78 +56,6 @@ p { text-align: center; } -.main > .side-left .canvas-container { - max-width: 100%; -} - -.main > .side-left .canvas-container.fullscreen { - align-items: center; - background-color: #2d2d2d; - display: flex; - height: 100%; - justify-content: center; - left: 0px; - position: fixed; - top: 0px; - width: 100%; - z-index: 6; -} - -.main > .side-left .canvas-container > .canvas-close { - bottom: 22px; - display: none; - position: absolute; - right: 22px; - user-select: none; - -o-user-select: none; - -ms-user-select: none; - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; -} - -.main > .side-left .canvas-container > .canvas-close > img { - height: 32px; - width: 32px; -} - -.main > .side-left .canvas-container.fullscreen > .canvas-close { - display: block; -} - -.main > .side-left .canvas-container > .canvas-frame { - background-color: #1b1a17; - border: 2px solid #50cb93; - font-size: 0px; - margin-top: 78px; - max-width: 320px; - padding: 8px 8px 8px 8px; -} - -@media only screen and (max-width: 1120px) { - .main > .side-left .canvas-container > .canvas-frame { - margin-top: 12px; - } -} - -.main > .side-left .canvas-container.fullscreen > .canvas-frame { - background-color: transparent; - border: none; - box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -o-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -ms-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -moz-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -khtml-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - -webkit-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); - margin: 0px 0px 0px 0px; - max-width: unset; - padding: 0px 0px 0px 0px; -} - -.main > .side-left .canvas-container > .canvas-frame > .canvas { - width: 100%; -} - .main > .side-right { flex: 0 1; max-width: 100%; diff --git a/examples/web/index.html b/examples/web/index.html index 3455761548a038a1d338e988d6df3ec6828f6314..5deae36ea921969bc31867fd5aa7fe78fe09685a 100644 --- a/examples/web/index.html +++ b/examples/web/index.html @@ -16,14 +16,6 @@ <div id="app"></div> <div class="main"> <div class="side-left"> - <div id="canvas-container" class="canvas-container"> - <span id="canvas-close" class="magnify-button canvas-close"> - <img class="large" src="res/minimise.svg" alt="minimise" /> - </span> - <div class="canvas-frame"> - <canvas id="engine-canvas" class="canvas" width="320" height="288"></canvas> - </div> - </div> </div> <div class="side-right"> <h1>Boytacean <a id="version" href="https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md" target="_blank"></a> <img class="logo-image" src="res/thunder.png" alt="thunder" /> diff --git a/examples/web/index.ts b/examples/web/index.ts index b680317e63984e8b343a270f32bd0fd06132d9ae..921ce62943a05334228894cf77d5180efa68cf72 100644 --- a/examples/web/index.ts +++ b/examples/web/index.ts @@ -78,12 +78,6 @@ class GameboyEmulator extends Observable implements Emulator { 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; @@ -162,13 +156,10 @@ class GameboyEmulator extends Observable implements Emulator { // 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 }); + + this.trigger("error"); } } @@ -224,12 +215,6 @@ class GameboyEmulator extends Observable implements Emulator { 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(); // triggers the frame event indicating that @@ -344,14 +329,13 @@ class GameboyEmulator extends Observable implements Emulator { this.registerKeys(), this.registerButtons(), this.registerKeyboard(), - this.registerCanvas(), this.registerToast(), this.registerModal() ]); } async init() { - await Promise.all([this.initBase(), this.initCanvas()]); + await Promise.all([this.initBase()]); } registerDrop() { @@ -429,10 +413,6 @@ class GameboyEmulator extends Observable implements Emulator { this.logicFrequency - FREQUENCY_DELTA ); break; - - case "Escape": - this.minimize(); - break; } }); @@ -511,11 +491,6 @@ class GameboyEmulator extends Observable implements Emulator { } }); - const buttonFullscreen = document.getElementById("button-fullscreen")!; - buttonFullscreen.addEventListener("click", () => { - this.maximize(); - }); - const buttonKeyboard = document.getElementById("button-keyboard")!; buttonKeyboard.addEventListener("click", () => { const sectionKeyboard = @@ -734,13 +709,6 @@ class GameboyEmulator extends Observable implements Emulator { }); } - registerCanvas() { - const canvasClose = document.getElementById("canvas-close")!; - canvasClose.addEventListener("click", () => { - this.minimize(); - }); - } - registerToast() { const toast = document.getElementById("toast")!; toast.addEventListener("click", () => { @@ -775,104 +743,6 @@ class GameboyEmulator extends Observable implements Emulator { this.setVersion(info.version); } - 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 - ); - - // 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"); @@ -1015,60 +885,6 @@ class GameboyEmulator extends Observable implements Emulator { 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); - - this.crop(); - } - - /** - * Restore the emulator's viewport to the minimal size, should make all - * the other emulator's meta-information (info, buttons, etc.) visible. - */ - minimize() { - const canvasContainer = document.getElementById("canvas-container")!; - const engineCanvas = document.getElementById("engine-canvas")!; - canvasContainer.classList.remove("fullscreen"); - engineCanvas.style.width = ""; - engineCanvas.style.height = ""; - window.removeEventListener("resize", this.crop); - } - - crop() { - // @todo let's make this more flexible - const engineCanvas = document.getElementById("engine-canvas")!; - - // calculates the window ratio as this is fundamental to - // determine the proper way to crop the fullscreen - const windowRatio = window.innerWidth / window.innerHeight; - - // in case the window is wider (more horizontal than the base ratio) - // this means that we must crop horizontally - 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`; - } - } - async fetchRom(romPath: string): Promise<[string, Uint8Array]> { // extracts the name of the ROM from the provided // path by splitting its structure diff --git a/examples/web/react/app.tsx b/examples/web/react/app.tsx index 8bc482f37cae54eb4d05e9bbbfae7f2c9b037fe6..bd91cb759d0a78e710f198215864e22178087f48 100644 --- a/examples/web/react/app.tsx +++ b/examples/web/react/app.tsx @@ -8,6 +8,7 @@ import { ButtonContainer, ButtonIncrement, ButtonSwitch, + ClearHandler, Display, DrawHandler, Footer, @@ -98,6 +99,7 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { const [romInfo, setRomInfo] = useState<RomInfo>({}); const [framerate, setFramerate] = useState(0); const frameRef = useRef<boolean>(false); + const errorRef = useRef<boolean>(false); const getPauseText = () => (paused ? "Resume" : "Pause"); const getPauseIcon = () => paused ? require("../res/play.svg") : require("../res/pause.svg"); @@ -126,6 +128,13 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { 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); + }); + }; useEffect(() => { document.body.style.backgroundColor = `#${getBackground()}`; }); @@ -133,6 +142,13 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { document.addEventListener("keydown", (event) => { if (event.key === "Escape") { setFullscreen(false); + event.stopPropagation(); + event.preventDefault(); + } + if (event.key === "f" && event.ctrlKey === true) { + setFullscreen(true); + event.stopPropagation(); + event.preventDefault(); } }); emulator.bind("loaded", () => { @@ -154,6 +170,7 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { <Display fullscreen={fullscreen} onDrawHandler={onDrawHandler} + onClearHandler={onClearHandler} onMinimize={onMinimize} /> </div> diff --git a/examples/web/react/components/display/display.tsx b/examples/web/react/components/display/display.tsx index aac3b51ec94e9468b13ae7fb382cdf9ff319b569..f8457f296f2a2ed86e5fe5767963faa03aa8638f 100644 --- a/examples/web/react/components/display/display.tsx +++ b/examples/web/react/components/display/display.tsx @@ -3,6 +3,8 @@ import { PixelFormat } from "../../app"; import "./display.css"; +const PIXEL_UNSET_COLOR = 0x1b1a17ff; + declare const require: any; /** @@ -11,6 +13,12 @@ declare const require: any; */ export type DrawHandler = (pixels: Uint8Array, format: PixelFormat) => void; +export type ClearHandler = ( + color?: number, + image?: string, + imageScale?: number +) => Promise<void>; + type DisplayOptions = { width: number; height: number; @@ -25,10 +33,12 @@ type DisplayProps = { fullscreen?: boolean; style?: string[]; onDrawHandler?: (caller: DrawHandler) => void; + onClearHandler?: (caller: ClearHandler) => void; onMinimize?: () => void; }; type CanvasContents = { + canvas: HTMLCanvasElement; canvasCtx: CanvasRenderingContext2D; canvasBuffer: HTMLCanvasElement; canvasBufferCtx: CanvasRenderingContext2D; @@ -42,6 +52,7 @@ export const Display: FC<DisplayProps> = ({ fullscreen = false, style = [], onDrawHandler, + onClearHandler, onMinimize }) => { options = { @@ -88,12 +99,22 @@ export const Display: FC<DisplayProps> = ({ }, [canvasRef, fullscreen]); if (onDrawHandler) { - onDrawHandler((pixels: Uint8Array, format: PixelFormat) => { + onDrawHandler((pixels, format) => { if (!canvasContentsRef.current) return; updateCanvas(canvasContentsRef.current, pixels, format); }); } + if (onClearHandler) { + onClearHandler(async (color, image, imageScale) => { + if (!canvasContentsRef.current) return; + await clearCanvas(canvasContentsRef.current, color, { + image: image, + imageScale: imageScale + }); + }); + } + return ( <div id="display" className={classes()}> <span @@ -151,6 +172,7 @@ const initCanvas = ( canvasCtx.imageSmoothingEnabled = false; return { + canvas: canvas, canvasCtx: canvasCtx, canvasBuffer: canvasBuffer, canvasBufferCtx: canvasBufferCtx, @@ -178,6 +200,62 @@ const updateCanvas = ( canvasContents.canvasCtx.drawImage(canvasContents.canvasBuffer, 0, 0); }; +const clearCanvas = async ( + canvasContents: CanvasContents, + color = PIXEL_UNSET_COLOR, + { + image = null, + imageScale = 1 + }: { image?: string | null; imageScale?: number } = {} +) => { + // uses the "clear" color to fill a rectangle with the complete + // size of the canvas contents + canvasContents.canvasCtx.fillStyle = `#${color.toString(16).toUpperCase()}`; + canvasContents.canvasCtx.fillRect( + 0, + 0, + canvasContents.canvas.width, + canvasContents.canvas.height + ); + + // in case an image was requested then uses that to load + // an image at the center of the screen properly scaled + if (image) { + await drawImageCanvas(canvasContents, image, imageScale); + } +}; + +const drawImageCanvas = async ( + canvasContents: CanvasContents, + image: string, + imageScale = 1.0 +) => { + 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] = [ + canvasContents.canvas.width / 2 - imgWidth / 2, + canvasContents.canvas.height / 2 - imgHeight / 2 + ]; + canvasContents.canvasCtx.setTransform(1, 0, 0, 1, 0, 0); + try { + canvasContents.canvasCtx.drawImage(img, x0, y0, imgWidth, imgHeight); + } finally { + canvasContents.canvasCtx.scale( + canvasContents.canvas.width / canvasContents.canvasBuffer.width, + canvasContents.canvas.height / canvasContents.canvasBuffer.height + ); + } +}; + const crop = (ratio: number): [number, number] => { // calculates the window ratio as this is fundamental to // determine the proper way to crop the fullscreen