diff --git a/CHANGELOG.md b/CHANGELOG.md index a70ff91541a4ca0081ab6c59bf37ab2c2328a209..0fd37ba9369e9a1610dd69695beab75c565ccb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * +## [0.4.4] - 2022-11-12 + +### Added + +* Support for responsive physical keyboard + +## [0.4.3] - 2022-11-11 + +### Added + +* Better debug panel support +* Support for some `GET` parameters +* Support for fullscreen on screen keyboard mode + ## [0.4.2] - 2022-11-09 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 39618c7d8cd69da2070da6ea6eb24d2b1a6363f5..bf0a16b2eb24642d7b55b347389ee3ab8e09d9ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "boytacean" description = "A Game Boy emulator that is written in Rust." -version = "0.4.2" +version = "0.4.4" authors = ["João Magalhães <joamag@gmail.com>"] license = "Apache-2.0" repository = "https://gitlab.stage.hive.pt/joamag/boytacean" diff --git a/README.md b/README.md index dd8077ba3b31b08c74609b59f3f9e7363703a131..aea440dc79767513863da6ba588d27be36f6f45a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,18 @@ npm install && npm run build cd dist && python3 -m http.server ``` +## Web version + +You can use some GET parameters to control the initial behaviour of the emulator. + +| Parameter | Type | Description | +| ------------ | ------- | ------------------------------------------------------------------------------ | +| `rom_url` | String | The URL from which the initial ROM is going to be loaded, should support CORS. | +| `url` | String | The same as `url`. | +| `fullscreen` | Boolean | If the emulator should start in fullscreen mode. | +| `debug` | Boolean | If the "debugger" should start visible. | +| `keyboard` | Boolean | If the on screen keyboard should start visible. | + ## Inspiration ### Documentation diff --git a/examples/sdl/Cargo.toml b/examples/sdl/Cargo.toml index 134f97f0c9105722571ba9e128fce4d4c4db1e3e..c558f5bf6dd2cbd251e9e9a9904182a9587b3487 100644 --- a/examples/sdl/Cargo.toml +++ b/examples/sdl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boytacean-sdl" -version = "0.4.2" +version = "0.4.4" authors = ["João Magalhães <joamag@gmail.com>"] description = "Game Boy Emulator SDL (Desktop) Application" license = "Apache-2.0" diff --git a/examples/web/index.ts b/examples/web/index.ts index 036d5c0d635101d3707f1a7c4fef99de1b9393ee..2d5409d696ddcca5328a783878415026f17debac 100644 --- a/examples/web/index.ts +++ b/examples/web/index.ts @@ -21,8 +21,6 @@ const LOGIC_HZ = 4194304; const VISUAL_HZ = 59.7275; const IDLE_HZ = 10; -const FREQUENCY_DELTA = 400000; - const SAMPLE_RATE = 2; const BACKGROUNDS = [ @@ -35,17 +33,6 @@ const BACKGROUNDS = [ "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 KEYS_NAME: Record<string, number> = { ArrowUp: PadKey.Up, ArrowDown: PadKey.Down, @@ -57,13 +44,6 @@ const KEYS_NAME: Record<string, number> = { B: PadKey.B }; -const ARROW_KEYS: Record<string, boolean> = { - ArrowUp: true, - ArrowDown: true, - ArrowLeft: true, - ArrowRight: true -}; - const ROM_PATH = require("../../res/roms/20y.gb"); /** @@ -99,20 +79,11 @@ class GameboyEmulator extends EmulatorBase implements Emulator { private romSize: number = 0; private cartridge: Cartridge | null = null; - async main() { - // parses the current location URL as retrieves - // some of the "relevant" GET parameters for logic - const params = new URLSearchParams(window.location.search); - const romUrl = params.get("url"); - + async main({ romUrl }: { romUrl?: string }) { // 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.register(); - // boots the emulator subsystem with the initial // ROM retrieved from a remote data source await this.boot({ loadRom: true, romPath: romUrl ?? undefined }); @@ -343,43 +314,6 @@ class GameboyEmulator extends EmulatorBase implements Emulator { this.trigger("booted"); } - // @todo remove this method, or at least most of it - async register() { - await Promise.all([this.registerKeys()]); - } - - registerKeys() { - document.addEventListener("keydown", (event) => { - const keyCode = KEYS[event.key]; - const isArrow = ARROW_KEYS[event.key] ?? false; - if (isArrow) event.preventDefault(); - if (keyCode !== undefined) { - this.gameBoy?.key_press(keyCode); - return; - } - - switch (event.key) { - case "+": - this.frequency += FREQUENCY_DELTA; - break; - - case "-": - this.frequency -= FREQUENCY_DELTA; - break; - } - }); - - document.addEventListener("keyup", (event) => { - const keyCode = KEYS[event.key]; - const isArrow = ARROW_KEYS[event.key] ?? false; - if (isArrow) event.preventDefault(); - if (keyCode !== undefined) { - this.gameBoy?.key_lift(keyCode); - return; - } - }); - } - setRom(name: string, data: Uint8Array, cartridge: Cartridge) { this.romName = name; this.romData = data; @@ -415,6 +349,10 @@ class GameboyEmulator extends EmulatorBase implements Emulator { return "https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md"; } + get romExts(): string[] { + return ["gb"]; + } + get pixelFormat(): PixelFormat { return PixelFormat.RGB; } @@ -426,7 +364,7 @@ class GameboyEmulator extends EmulatorBase implements Emulator { * @returns The current pixel data for the emulator display. */ get imageBuffer(): Uint8Array { - return this.gameBoy!.frame_buffer_eager(); + return this.gameBoy?.frame_buffer_eager() ?? new Uint8Array(); } get romInfo(): RomInfo { @@ -452,12 +390,38 @@ class GameboyEmulator extends EmulatorBase implements Emulator { this.trigger("frequency", value); } + get frequencyDelta(): number | null { + return 400000; + } + get framerate(): number { return this.fps; } + get registers(): Record<string, string | number> { + const registers = this.gameBoy?.registers(); + if (!registers) return {}; + return { + pc: registers.pc, + sp: registers.sp, + a: registers.a, + b: registers.b, + c: registers.c, + d: registers.d, + e: registers.e, + h: registers.h, + l: registers.l, + scy: registers.scy, + scx: registers.scx, + wy: registers.wy, + wx: registers.wx, + ly: registers.ly, + lyc: registers.lyc + }; + } + getTile(index: number): Uint8Array { - return this.gameBoy!.get_tile_buffer(index); + return this.gameBoy?.get_tile_buffer(index) ?? new Uint8Array(); } toggleRunning() { @@ -556,7 +520,25 @@ const wasm = async () => { }; (async () => { + // parses the current location URL as retrieves + // some of the "relevant" GET parameters for logic + const params = new URLSearchParams(window.location.search); + const romUrl = params.get("rom_url") ?? params.get("url") ?? undefined; + const fullscreen = ["1", "true", "True"].includes( + params.get("fullscreen") ?? "" + ); + const debug = ["1", "true", "True"].includes(params.get("debug") ?? ""); + const keyboard = ["1", "true", "True"].includes( + params.get("keyboard") ?? "" + ); + const emulator = new GameboyEmulator(); - startApp("app", emulator, BACKGROUNDS); - await emulator.main(); + startApp("app", { + emulator: emulator, + fullscreen: fullscreen, + debug: debug, + keyboard: keyboard, + backgrounds: BACKGROUNDS + }); + await emulator.main({ romUrl: romUrl }); })(); diff --git a/examples/web/package.json b/examples/web/package.json index a16a5149e41fd43e7033350794d4e0e2d65a7a48..31599354544a21fa32958115fe53f5438a42ca6d 100644 --- a/examples/web/package.json +++ b/examples/web/package.json @@ -1,6 +1,6 @@ { "name": "boytacean-web", - "version": "0.4.2", + "version": "0.4.4", "description": "The web version of Boytacean", "repository": { "type": "git", diff --git a/examples/web/react/app.css b/examples/web/react/app.css index d44f28a65d33844a5029ac0909c352465c2cb860..f21e17fbb362d937ec40c4108a3e96e2fd4fe010 100644 --- a/examples/web/react/app.css +++ b/examples/web/react/app.css @@ -1,11 +1,12 @@ .app { color: #ffffff; font-family: VT323, Roboto, Open Sans, Arial, Helvetica, sans-serif; - margin: 0px 0px 0px 0px; + margin: 0px auto 0px auto; + max-width: 1400px; } .app h3 { - margin: 10px 0px 10px 0px; + margin: 0px 0px 16px 0px; } .app .display-container { diff --git a/examples/web/react/app.tsx b/examples/web/react/app.tsx index ad01897b2abce06c63cb7597a93fab38b216e681..8ab6fd4b59128b01e3a5a33e62be28ad00bcf4dc 100644 --- a/examples/web/react/app.tsx +++ b/examples/web/react/app.tsx @@ -1,6 +1,8 @@ import React, { FC, useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom/client"; +const FREQUENCY_DELTA = 100000; + declare const require: any; import { @@ -20,6 +22,7 @@ import { Pair, PanelSplit, Paragraph, + RegistersGB, Section, Tiles, Title, @@ -97,6 +100,10 @@ export interface Emulator extends ObservableI { */ get device(): string; + /** + * A URL to a website that describes the device that is + * being emulated by the emulator (eg: Wikipedia link). + */ get deviceUrl(): string | undefined; /** @@ -125,6 +132,12 @@ export interface Emulator extends ObservableI { */ get engine(): string | null; + /** + * The complete set of file extensions that this emulator + * supports. + */ + get romExts(): string[]; + /** * The pixel format of the emulator's display * image buffer (eg: RGB). @@ -154,11 +167,30 @@ export interface Emulator extends ObservableI { get frequency(): number; set frequency(value: number); + /** + * The recommended frequency delta in hertz for scale up + * and scale down operations in the CPU frequency. + */ + get frequencyDelta(): number | null; + /** * The current logic framerate of the running emulator. */ get framerate(): number; + /** + * A dictionary that contains the register names associated + * with their value either as strings or numbers. + */ + get registers(): Record<string, string | number>; + + /** + * Obtains the pixel buffer for the VRAM tile at the given + * index. + * + * @param index The index of the tile to obtain pixel buffer. + * @returns The pixel buffer of the tile at the given index. + */ getTile(index: number): Uint8Array; /** @@ -210,6 +242,10 @@ export class EmulatorBase extends Observable { get versionUrl(): string | undefined { return undefined; } + + get frequencyDelta(): number | null { + return FREQUENCY_DELTA; + } } /** @@ -223,6 +259,9 @@ export enum PixelFormat { type AppProps = { emulator: Emulator; + fullscreen?: boolean; + debug?: boolean; + keyboard?: boolean; backgrounds?: string[]; }; @@ -230,9 +269,15 @@ const isTouchDevice = () => { return "ontouchstart" in window || navigator.maxTouchPoints > 0; }; -export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { +export const App: FC<AppProps> = ({ + emulator, + fullscreen = false, + debug = false, + keyboard = false, + backgrounds = ["264653"] +}) => { const [paused, setPaused] = useState(false); - const [fullscreen, setFullscreen] = useState(false); + const [fullscreenState, setFullscreenState] = useState(fullscreen); const [backgroundIndex, setBackgroundIndex] = useState(0); const [romInfo, setRomInfo] = useState<RomInfo>({}); const [framerate, setFramerate] = useState(0); @@ -243,9 +288,11 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { const [toastText, setToastText] = useState<string>(); const [toastError, setToastError] = useState(false); const [toastVisible, setToastVisible] = useState(false); - const [keyboardVisible, setKeyboardVisible] = useState(isTouchDevice()); + const [keyboardVisible, setKeyboardVisible] = useState( + isTouchDevice() || keyboard + ); const [infoVisible, setInfoVisible] = useState(true); - const [debugVisible, setDebugVisible] = useState(false); + const [debugVisible, setDebugVisible] = useState(debug); const toastCounterRef = useRef(0); const frameRef = useRef<boolean>(false); @@ -258,22 +305,46 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { }, [backgroundIndex]); useEffect(() => { switch (keyaction) { + case "Plus": + emulator.frequency += + emulator.frequencyDelta ?? FREQUENCY_DELTA; + setKeyaction(undefined); + break; + case "Minus": + emulator.frequency -= + emulator.frequencyDelta ?? FREQUENCY_DELTA; + setKeyaction(undefined); + break; case "Escape": - setFullscreen(false); + setFullscreenState(false); setKeyaction(undefined); break; case "Fullscreen": - setFullscreen(!fullscreen); + setFullscreenState(!fullscreenState); setKeyaction(undefined); break; } }, [keyaction]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setKeyaction("Escape"); - event.stopPropagation(); - event.preventDefault(); + switch (event.key) { + case "+": + setKeyaction("Plus"); + event.stopPropagation(); + event.preventDefault(); + break; + + case "-": + setKeyaction("Minus"); + event.stopPropagation(); + event.preventDefault(); + break; + + case "Escape": + setKeyaction("Escape"); + event.stopPropagation(); + event.preventDefault(); + break; } if (event.key === "f" && event.ctrlKey === true) { setKeyaction("Fullscreen"); @@ -334,9 +405,8 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { }; const onFile = async (file: File) => { - // @todo must make this more flexible and not just - // Game Boy only (using the emulator interface) - if (!file.name.endsWith(".gb")) { + const fileExtension = file.name.split(".").pop() ?? ""; + if (!emulator.romExts.includes(fileExtension)) { showToast( `This is probably not a ${emulator.device} ROM file!`, true @@ -393,7 +463,7 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { ); }; const onFullscreenClick = () => { - setFullscreen(!fullscreen); + setFullscreenState(!fullscreenState); }; const onKeyboardClick = () => { setKeyboardVisible(!keyboardVisible); @@ -428,7 +498,7 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { }); }; const onMinimize = () => { - setFullscreen(!fullscreen); + setFullscreenState(!fullscreenState); }; const onKeyDown = (key: string) => { emulator.keyPress(key); @@ -478,7 +548,7 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { left={ <div className="display-container"> <Display - fullscreen={fullscreen} + fullscreen={fullscreenState} onDrawHandler={onDrawHandler} onClearHandler={onClearHandler} onMinimize={onMinimize} @@ -488,7 +558,11 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { > {keyboardVisible && ( <Section separatorBottom={true}> - <KeyboardGB onKeyDown={onKeyDown} onKeyUp={onKeyUp} /> + <KeyboardGB + fullscreen={fullscreenState} + onKeyDown={onKeyDown} + onKeyUp={onKeyUp} + /> </Section> )} <Title @@ -536,11 +610,33 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { </Section> {debugVisible && ( <Section> - <h3>VRAM Tiles</h3> - <Tiles - getTile={(index) => emulator.getTile(index)} - tileCount={384} - /> + <div + style={{ + display: "inline-block", + verticalAlign: "top", + marginRight: 32, + width: 256 + }} + > + <h3>VRAM Tiles</h3> + <Tiles + getTile={(index) => emulator.getTile(index)} + tileCount={384} + width={"100%"} + contentBox={false} + /> + </div> + <div + style={{ + display: "inline-block", + verticalAlign: "top" + }} + > + <h3>Registers</h3> + <RegistersGB + getRegisters={() => emulator.registers} + /> + </div> </Section> )} {infoVisible && ( @@ -582,7 +678,12 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { valueNode={ <ButtonIncrement value={emulator.frequency / 1000 / 1000} - delta={0.4} + delta={ + (emulator.frequencyDelta ?? + FREQUENCY_DELTA) / + 1000 / + 1000 + } min={0} suffix={"MHz"} decimalPlaces={2} @@ -688,16 +789,33 @@ export const App: FC<AppProps> = ({ emulator, backgrounds = ["264653"] }) => { export const startApp = ( element: string, - emulator: Emulator, - backgrounds: string[] + { + emulator, + fullscreen = false, + debug = false, + keyboard = false, + backgrounds = [] + }: { + emulator: Emulator; + fullscreen?: boolean; + debug?: boolean; + keyboard?: boolean; + backgrounds: string[]; + } ) => { const elementRef = document.getElementById(element); - if (!elementRef) { - return; - } + if (!elementRef) return; const root = ReactDOM.createRoot(elementRef); - root.render(<App emulator={emulator} backgrounds={backgrounds} />); + root.render( + <App + emulator={emulator} + fullscreen={fullscreen} + debug={debug} + keyboard={keyboard} + backgrounds={backgrounds} + /> + ); }; export default App; diff --git a/examples/web/react/components/canvas/canvas.tsx b/examples/web/react/components/canvas/canvas.tsx index 24f7544a1c8e7f5de294f02b474cda82f3dda36e..5cad1d5c5d8f82c2073ebb210bce035523ad5d6b 100644 --- a/examples/web/react/components/canvas/canvas.tsx +++ b/examples/web/react/components/canvas/canvas.tsx @@ -12,6 +12,7 @@ export type CanvasStructure = { type CanvasProps = { width: number; height: number; + scaledWidth?: number | string; scale?: number; style?: string[]; onCanvas?: (structure: CanvasStructure) => void; @@ -20,6 +21,7 @@ type CanvasProps = { export const Canvas: FC<CanvasProps> = ({ width, height, + scaledWidth, scale = 1, style = [], onCanvas @@ -41,7 +43,7 @@ export const Canvas: FC<CanvasProps> = ({ <canvas ref={canvasRef} className={classes()} - style={{ width: width * scale }} + style={{ width: scaledWidth ?? width * scale }} width={width} height={height} /> diff --git a/examples/web/react/components/display/display.css b/examples/web/react/components/display/display.css index f0e987b779519ae3a66b0069e7596f97b81adfca..4c8bef99b089b0bdba70faa5dee74fd458739882 100644 --- a/examples/web/react/components/display/display.css +++ b/examples/web/react/components/display/display.css @@ -57,6 +57,13 @@ -webkit-user-select: none; } +@media only screen and (max-width: 1120px) { + .display > .display-minimize { + bottom: initial; + top: 22px; + } +} + .display > .display-minimize > img { height: 32px; width: 32px; diff --git a/examples/web/react/components/index.ts b/examples/web/react/components/index.ts index 838636b94e5c09949d1234242e97bcdccf67ca09..a2980dabb48825fb1403af2a17b42b20a0243bbf 100644 --- a/examples/web/react/components/index.ts +++ b/examples/web/react/components/index.ts @@ -14,6 +14,7 @@ export * from "./overlay/overlay"; export * from "./pair/pair"; export * from "./panel-split/panel-split"; export * from "./paragraph/paragraph"; +export * from "./registers-gb/registers-gb"; export * from "./section/section"; export * from "./tiles/tiles"; export * from "./title/title"; diff --git a/examples/web/react/components/keyboard-chip8/keyboard-chip8.css b/examples/web/react/components/keyboard-chip8/keyboard-chip8.css index 97507e3b920202c9458befbe75fdac9c13108c00..a9552b23a6e438d1965fd7d90e61af622f01ac30 100644 --- a/examples/web/react/components/keyboard-chip8/keyboard-chip8.css +++ b/examples/web/react/components/keyboard-chip8/keyboard-chip8.css @@ -1,4 +1,3 @@ - .keyboard-chip8 { font-size: 0px; text-align: center; diff --git a/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx b/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx index 99ddd4f8272700828680589bd53f23278e7593d9..2208c1c11b3d4c2a9800a7029a1c080693d1ce53 100644 --- a/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx +++ b/examples/web/react/components/keyboard-chip8/keyboard-chip8.tsx @@ -18,11 +18,10 @@ export const KeyboardChip8: FC<KeyboardChip8Props> = ({ const classes = () => ["keyboard", "keyboard-chip8", ...style].join(" "); const renderKey = (key: string, styles: string[] = []) => { const [pressed, setPressed] = useState(false); + const classes = ["key", pressed ? "pressed" : "", ...styles].join(" "); return ( <span - className={["key", pressed ? "pressed" : "", ...styles].join( - " " - )} + className={classes} key={key} tabIndex={focusable ? 0 : undefined} onKeyDown={(event) => { diff --git a/examples/web/react/components/keyboard-gb/keyboard-gb.css b/examples/web/react/components/keyboard-gb/keyboard-gb.css index fce8fc547108e455ee868cc71b224c423b69a75c..d26aaf967584bf6a9246396ffbd0e77d8e8f86a8 100644 --- a/examples/web/react/components/keyboard-gb/keyboard-gb.css +++ b/examples/web/react/components/keyboard-gb/keyboard-gb.css @@ -1,3 +1,17 @@ +.keyboard-container.fullscreen { + bottom: 30px; + left: 0px; + pointer-events: none; + position: fixed; + width: 100%; + z-index: 10; +} + +@media only screen and (max-width: 1120px) { + .keyboard-container.fullscreen { + bottom: 0px; + } +} .keyboard-gb { font-size: 0px; @@ -16,7 +30,39 @@ -webkit-user-select: none; } +.keyboard-gb.fullscreen { + background: rgba(0, 0, 0, 0.3); + border-radius: 24px 24px 24px 24px; + -o-border-radius: 24px 24px 24px 24px; + -ms-border-radius: 24px 24px 24px 24px; + -moz-border-radius: 24px 24px 24px 24px; + -khtml-border-radius: 24px 24px 24px 24px; + -webkit-border-radius: 24px 24px 24px 24px; + margin: 0px auto 0px auto; + max-width: 600px; + padding: 18px 0px 18px 0px; + pointer-events: initial; +} + +@media only screen and (max-width: 600px) { + .keyboard-gb.fullscreen { + border-radius: 0px 0px 0px 0px; + -o-border-radius: 0px 0px 0px 0px; + -ms-border-radius: 0px 0px 0px 0px; + -moz-border-radius: 0px 0px 0px 0px; + -khtml-border-radius: 0px 0px 0px 0px; + -webkit-border-radius: 0px 0px 0px 0px; + padding: 12px 0px 12px 0px; + } +} + .keyboard-gb > .keyboard-line { + border-radius: 24px 24px 24px 24px; + -o-border-radius: 24px 24px 24px 24px; + -ms-border-radius: 24px 24px 24px 24px; + -moz-border-radius: 24px 24px 24px 24px; + -khtml-border-radius: 24px 24px 24px 24px; + -webkit-border-radius: 24px 24px 24px 24px; margin-bottom: 12px; } @@ -54,7 +100,13 @@ .keyboard-gb > .dpad { float: left; text-align: center; - width: 180px; + width: 172px; +} + +@media only screen and (max-width: 600px) { + .keyboard-gb > .dpad { + width: 162px; + } } .keyboard-gb > .dpad > .dpad-top { @@ -146,6 +198,12 @@ margin-right: 18px; } +@media only screen and (max-width: 600px) { + .keyboard-gb > .action { + margin-right: 8px; + } +} + .keyboard-gb > .action > .key { border-radius: 32px 32px 32px 32px; -o-border-radius: 32px 32px 32px 32px; @@ -177,6 +235,12 @@ margin-top: 32px; } +@media only screen and (max-width: 600px) { + .keyboard-gb > .options { + margin-top: 22px; + } +} + .keyboard-gb > .options > .key { font-size: 14px; font-weight: 900; diff --git a/examples/web/react/components/keyboard-gb/keyboard-gb.tsx b/examples/web/react/components/keyboard-gb/keyboard-gb.tsx index 18672a13bc9713059756d6e97d3e5ed43c42d30d..96ed59c40db5a05919f1a5b103f4fc5a6b39adcb 100644 --- a/examples/web/react/components/keyboard-gb/keyboard-gb.tsx +++ b/examples/web/react/components/keyboard-gb/keyboard-gb.tsx @@ -1,11 +1,32 @@ -import React, { FC, useState } from "react"; +import React, { FC, useEffect, useRef, useState } from "react"; import "./keyboard-gb.css"; +const KEYS: Record<string, string> = { + ArrowUp: "ArrowUp", + ArrowDown: "ArrowDown", + ArrowLeft: "ArrowLeft", + ArrowRight: "ArrowRight", + Enter: "Start", + " ": "Select", + a: "A", + s: "B" +}; + +const PREVENT_KEYS: Record<string, boolean> = { + ArrowUp: true, + ArrowDown: true, + ArrowLeft: true, + ArrowRight: true, + " ": true +}; + declare const require: any; type KeyboardGBProps = { focusable?: boolean; + fullscreen?: boolean; + selectedKeys?: string[]; style?: string[]; onKeyDown?: (key: string) => void; onKeyUp?: (key: string) => void; @@ -13,22 +34,69 @@ type KeyboardGBProps = { export const KeyboardGB: FC<KeyboardGBProps> = ({ focusable = true, + fullscreen = false, + selectedKeys = [], style = [], onKeyDown, onKeyUp }) => { - const classes = () => ["keyboard", "keyboard-gb", ...style].join(" "); + const containerClasses = () => + ["keyboard-container", fullscreen ? "fullscreen" : ""].join(" "); + const recordRef = + useRef<Record<string, React.Dispatch<React.SetStateAction<boolean>>>>(); + const classes = () => + [ + "keyboard", + "keyboard-gb", + fullscreen ? "fullscreen" : "", + ...style + ].join(" "); + useEffect(() => { + const _onKeyDown = (event: KeyboardEvent) => { + const keyCode = KEYS[event.key]; + const isPrevent = PREVENT_KEYS[event.key] ?? false; + if (isPrevent) event.preventDefault(); + if (keyCode !== undefined) { + const records = recordRef.current ?? {}; + const setter = records[keyCode]; + setter(true); + onKeyDown && onKeyDown(keyCode); + return; + } + }; + const _onKeyUp = (event: KeyboardEvent) => { + const keyCode = KEYS[event.key]; + const isPrevent = PREVENT_KEYS[event.key] ?? false; + if (isPrevent) event.preventDefault(); + if (keyCode !== undefined) { + const records = recordRef.current ?? {}; + const setter = records[keyCode]; + setter(false); + onKeyUp && onKeyUp(keyCode); + return; + } + }; + document.addEventListener("keydown", _onKeyDown); + document.addEventListener("keyup", _onKeyUp); + return () => { + document.removeEventListener("keydown", _onKeyDown); + document.removeEventListener("keyup", _onKeyUp); + }; + }, []); const renderKey = ( key: string, keyName?: string, + selected = false, styles: string[] = [] ) => { - const [pressed, setPressed] = useState(false); + const [pressed, setPressed] = useState(selected); + const classes = ["key", pressed ? "pressed" : "", ...styles].join(" "); + const records = recordRef.current ?? {}; + records[keyName ?? key ?? "undefined"] = setPressed; + recordRef.current = records; return ( <span - className={["key", pressed ? "pressed" : "", ...styles].join( - " " - )} + className={classes} key={keyName ?? key} tabIndex={focusable ? 0 : undefined} onKeyDown={(event) => { @@ -86,32 +154,64 @@ export const KeyboardGB: FC<KeyboardGBProps> = ({ ); }; return ( - <div - className={classes()} - onTouchStart={(e) => e.preventDefault()} - onTouchEnd={(e) => e.preventDefault()} - > - <div className="dpad"> - <div className="dpad-top"> - {renderKey("▲", "ArrowUp", ["up"])} + <div className={containerClasses()}> + <div + className={classes()} + onTouchStart={(e) => e.preventDefault()} + onTouchEnd={(e) => e.preventDefault()} + > + <div className="dpad"> + <div className="dpad-top"> + {renderKey( + "▲", + "ArrowUp", + selectedKeys.includes("ArrowUp"), + ["up"] + )} + </div> + <div> + {renderKey( + "◄", + "ArrowLeft", + selectedKeys.includes("ArrowLeft"), + ["left"] + )} + {renderKey( + "►", + "ArrowRight", + selectedKeys.includes("ArrowRight"), + ["right"] + )} + </div> + <div className="dpad-bottom"> + {renderKey( + "▼", + "ArrowDown", + selectedKeys.includes("ArrowDown"), + ["down"] + )} + </div> </div> - <div> - {renderKey("◄", "ArrowLeft", ["left"])} - {renderKey("►", "ArrowRight", ["right"])} + <div className="action"> + {renderKey("B", "B", selectedKeys.includes("B"), ["b"])} + {renderKey("A", "A", selectedKeys.includes("A"), ["a"])} </div> - <div className="dpad-bottom"> - {renderKey("▼", "ArrowDown", ["down"])} + <div className="break"></div> + <div className="options"> + {renderKey( + "START", + "Start", + selectedKeys.includes("Start"), + ["start"] + )} + {renderKey( + "SELECT", + "Select", + selectedKeys.includes("Select"), + ["select"] + )} </div> </div> - <div className="action"> - {renderKey("B", "B", ["b"])} - {renderKey("A", "A", ["a"])} - </div> - <div className="break"></div> - <div className="options"> - {renderKey("START", "Start", ["start"])} - {renderKey("SELECT", "Select", ["select"])} - </div> </div> ); }; diff --git a/examples/web/react/components/registers-gb/registers-gb.css b/examples/web/react/components/registers-gb/registers-gb.css new file mode 100644 index 0000000000000000000000000000000000000000..0801939b049a3955f55675c88db3af75cb271045 --- /dev/null +++ b/examples/web/react/components/registers-gb/registers-gb.css @@ -0,0 +1,32 @@ +.registers-gb > .section { + display: inline-block; + margin-right: 32px; + vertical-align: top; +} + +.registers-gb > .section:last-child { + margin-right: 0px; +} + +.registers-gb > .section > h4 { + font-size: 22px; + margin: 0px 0px 8px 0px; +} + +.registers-gb > .section > .register { + font-size: 0px; + line-height: 22px; +} + +.registers-gb > .section > .register > .register-key { + display: inline-block; + font-size: 20px; + width: 40px; +} + +.registers-gb > .section > .register > .register-value { + display: inline-block; + font-size: 20px; + text-align: right; + width: 66px; +} diff --git a/examples/web/react/components/registers-gb/registers-gb.tsx b/examples/web/react/components/registers-gb/registers-gb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..988ba20ea37839ee3fa392000f66b1f6a532774f --- /dev/null +++ b/examples/web/react/components/registers-gb/registers-gb.tsx @@ -0,0 +1,79 @@ +import React, { FC, useEffect, useRef, useState } from "react"; + +import "./registers-gb.css"; + +type RegistersGBProps = { + getRegisters: () => Record<string, string | number>; + interval?: number; + style?: string[]; +}; + +export const RegistersGB: FC<RegistersGBProps> = ({ + getRegisters, + interval = 50, + style = [] +}) => { + const classes = () => ["registers-gb", ...style].join(" "); + const [registers, setRegisters] = useState<Record<string, string | number>>( + {} + ); + const intervalsRef = useRef<number>(); + useEffect(() => { + const updateRegisters = () => { + const registers = getRegisters(); + setRegisters(registers); + }; + setInterval(() => updateRegisters(), interval); + updateRegisters(); + return () => { + if (intervalsRef.current) { + clearInterval(intervalsRef.current); + } + }; + }, []); + const renderRegister = ( + key: string, + value?: number, + size = 2, + styles: string[] = [] + ) => { + const classes = ["register", ...styles].join(" "); + const valueS = + value?.toString(16).toUpperCase().padStart(size, "0") ?? value; + return ( + <div className={classes}> + <span className="register-key">{key}</span> + <span className="register-value"> + {valueS ? `0x${valueS}` : "-"} + </span> + </div> + ); + }; + return ( + <div className={classes()}> + <div className="section"> + <h4>CPU</h4> + {renderRegister("PC", registers.pc as number, 4)} + {renderRegister("SP", registers.sp as number, 4)} + {renderRegister("A", registers.a as number)} + {renderRegister("B", registers.b as number)} + {renderRegister("C", registers.c as number)} + {renderRegister("D", registers.d as number)} + {renderRegister("E", registers.e as number)} + {renderRegister("H", registers.h as number)} + {renderRegister("L", registers.l as number)} + </div> + <div className="section"> + <h4>PPU</h4> + {renderRegister("SCY", registers.scy as number)} + {renderRegister("SCX", registers.scx as number)} + {renderRegister("WY", registers.wy as number)} + {renderRegister("WX", registers.wx as number)} + {renderRegister("LY", registers.ly as number)} + {renderRegister("LYC", registers.lyc as number)} + </div> + </div> + ); +}; + +export default RegistersGB; diff --git a/examples/web/react/components/tiles/tiles.css b/examples/web/react/components/tiles/tiles.css index 59b7d18240ad1bc4ea361b700e70c75c911782de..030a9575febd3db6e663c0740256eb48c5a89e88 100644 --- a/examples/web/react/components/tiles/tiles.css +++ b/examples/web/react/components/tiles/tiles.css @@ -1,11 +1,14 @@ .tiles > .canvas { background-color: #1b1a17; border: 2px solid #50cb93; + padding: 8px 8px 8px 8px; +} + +.tiles > .canvas.content-box { box-sizing: content-box; -o-box-sizing: content-box; -ms-box-sizing: content-box; -moz-box-sizing: content-box; -khtml-box-sizing: content-box; -webkit-box-sizing: content-box; - padding: 8px 8px 8px 8px; } diff --git a/examples/web/react/components/tiles/tiles.tsx b/examples/web/react/components/tiles/tiles.tsx index e879f383585faaab2a29889295ec78793182cb5c..78b340e123425d43ee1d05b7cf13e799f7ea6c83 100644 --- a/examples/web/react/components/tiles/tiles.tsx +++ b/examples/web/react/components/tiles/tiles.tsx @@ -5,19 +5,24 @@ import Canvas, { CanvasStructure } from "../canvas/canvas"; import "./tiles.css"; type TilesProps = { - tileCount: number; getTile: (index: number) => Uint8Array; + tileCount: number; + width?: number | string; + contentBox?: boolean; interval?: number; style?: string[]; }; export const Tiles: FC<TilesProps> = ({ - tileCount, getTile, - interval = 500, + tileCount, + width, + contentBox = true, + interval = 1000, style = [] }) => { - const classes = () => ["tiles", ...style].join(" "); + const classes = () => + ["tiles", contentBox ? "content-box" : "", ...style].join(" "); const intervalsRef = useRef<number>(); useEffect(() => { return () => { @@ -38,7 +43,13 @@ export const Tiles: FC<TilesProps> = ({ }; return ( <div className={classes()}> - <Canvas width={128} height={192} scale={2} onCanvas={onCanvas} /> + <Canvas + width={128} + height={192} + scale={2} + scaledWidth={width} + onCanvas={onCanvas} + /> </div> ); }; diff --git a/src/cpu.rs b/src/cpu.rs index e253dba68cb622d24f5743b7e8646bdd63589b4e..a6632d0616a0d2bd8ca48d9e0914a2c8900fc3e7 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -21,12 +21,16 @@ pub struct Cpu { pub e: u8, pub h: u8, pub l: u8, + ime: bool, zero: bool, sub: bool, half_carry: bool, carry: bool, halted: bool, + + /// Reference to the MMU (Memory Management Unit) to be used + /// for memory bus access operations. pub mmu: Mmu, /// Temporary counter used to control the number of cycles @@ -52,7 +56,7 @@ impl Cpu { half_carry: false, carry: false, halted: false, - mmu: mmu, + mmu, cycles: 0, } } @@ -113,14 +117,13 @@ impl Cpu { // @todo this is so bad, need to improve this by an order // of magnitude, to be able to have better performance - if self.halted { - if ((self.mmu.ie & 0x01 == 0x01) && self.mmu.ppu().int_vblank()) + if self.halted + && (((self.mmu.ie & 0x01 == 0x01) && self.mmu.ppu().int_vblank()) || ((self.mmu.ie & 0x02 == 0x02) && self.mmu.ppu().int_stat()) || ((self.mmu.ie & 0x04 == 0x04) && self.mmu.timer().int_tima()) - || ((self.mmu.ie & 0x10 == 0x10) && self.mmu.pad().int_pad()) - { - self.halted = false; - } + || ((self.mmu.ie & 0x10 == 0x10) && self.mmu.pad().int_pad())) + { + self.halted = false; } if self.ime { diff --git a/src/gb.rs b/src/gb.rs index 3a72d3ff9efea0b3c76c963e58912eb729fffde7..77691658d0104aba62197896f07a597d25bb05ac 100644 --- a/src/gb.rs +++ b/src/gb.rs @@ -23,6 +23,25 @@ pub struct GameBoy { cpu: Cpu, } +#[cfg_attr(feature = "wasm", wasm_bindgen)] +pub struct Registers { + pub pc: u16, + pub sp: u16, + pub a: u8, + pub b: u8, + pub c: u8, + pub d: u8, + pub e: u8, + pub h: u8, + pub l: u8, + pub scy: u8, + pub scx: u8, + pub wy: u8, + pub wx: u8, + pub ly: u8, + pub lyc: u8, +} + #[cfg_attr(feature = "wasm", wasm_bindgen)] impl GameBoy { #[cfg_attr(feature = "wasm", wasm_bindgen(constructor))] @@ -32,7 +51,7 @@ impl GameBoy { let timer = Timer::new(); let mmu = Mmu::new(ppu, pad, timer); let cpu = Cpu::new(mmu); - Self { cpu: cpu } + Self { cpu } } pub fn reset(&mut self) { @@ -113,6 +132,27 @@ impl GameBoy { self.frame_buffer().to_vec() } + pub fn registers(&mut self) -> Registers { + let ppu_registers = self.ppu().registers(); + Registers { + pc: self.cpu.pc, + sp: self.cpu.sp, + a: self.cpu.a, + b: self.cpu.b, + c: self.cpu.c, + d: self.cpu.d, + e: self.cpu.e, + h: self.cpu.h, + l: self.cpu.l, + scy: ppu_registers.scy, + scx: ppu_registers.scx, + wy: ppu_registers.wy, + wx: ppu_registers.wx, + ly: ppu_registers.ly, + lyc: ppu_registers.lyc, + } + } + /// Obtains the tile structure for the tile at the /// given index, no conversion in the pixel buffer /// is done so that the color reference is the GB one. @@ -129,6 +169,8 @@ impl GameBoy { } } +/// Gameboy implementations that are meant with performance +/// in mind and that do not support WASM interface of copy. impl GameBoy { /// The logical frequency of the Game Boy /// CPU in hz. diff --git a/src/inst.rs b/src/inst.rs index 497523a1efe4a4222dbad1af862a21d3cf02025a..0b7ad53941a62f764162f7e500b0d3733508afb6 100644 --- a/src/inst.rs +++ b/src/inst.rs @@ -1,6 +1,6 @@ use crate::cpu::Cpu; -pub const INSTRUCTIONS: [(fn(&mut Cpu), u8, &'static str); 256] = [ +pub const INSTRUCTIONS: [(fn(&mut Cpu), u8, &str); 256] = [ // 0x0 opcodes (nop, 4, "NOP"), (ld_bc_u16, 12, "LD BC, u16"), @@ -275,7 +275,7 @@ pub const INSTRUCTIONS: [(fn(&mut Cpu), u8, &'static str); 256] = [ (rst_38h, 16, "RST 38h"), ]; -pub const EXTENDED: [(fn(&mut Cpu), u8, &'static str); 256] = [ +pub const EXTENDED: [(fn(&mut Cpu), u8, &str); 256] = [ // 0x0 opcodes (rlc_b, 8, "RLC B"), (rlc_c, 8, "RLC C"), diff --git a/src/mmu.rs b/src/mmu.rs index a8ce0a41af042ade35751fab8f416dcc09ce9a3d..78f9e36b53b8d755b7b44b821904735c95ca4321 100644 --- a/src/mmu.rs +++ b/src/mmu.rs @@ -37,9 +37,9 @@ pub struct Mmu { impl Mmu { pub fn new(ppu: Ppu, pad: Pad, timer: Timer) -> Self { Self { - ppu: ppu, - pad: pad, - timer: timer, + ppu, + pad, + timer, rom: Cartridge::new(), boot_active: true, boot: [0u8; BOOT_SIZE], @@ -119,11 +119,10 @@ impl Mmu { 0xe00 => self.ppu.oam[(addr & 0x009f) as usize], 0xf00 => match addr & 0x00ff { 0x0f => { - let value = if self.ppu.int_vblank() { 0x01 } else { 0x00 } + (if self.ppu.int_vblank() { 0x01 } else { 0x00 } | if self.ppu.int_stat() { 0x02 } else { 0x00 } | if self.timer.int_tima() { 0x04 } else { 0x00 } - | if self.pad.int_pad() { 0x10 } else { 0x00 }; - value + | if self.pad.int_pad() { 0x10 } else { 0x00 }) } 0x80..=0xfe => self.ppu.hram[(addr & 0x007f) as usize], 0xff => self.ie, @@ -245,7 +244,7 @@ impl Mmu { data.push(byte); } - return data; + data } pub fn write_boot(&mut self, addr: u16, buffer: &[u8]) { diff --git a/src/pad.rs b/src/pad.rs index ed15c9d8e34adf8655dee247166e5fff86a2efac..84c833a158465586a4b741bbd99b4e467afe186f 100644 --- a/src/pad.rs +++ b/src/pad.rs @@ -3,7 +3,7 @@ use wasm_bindgen::prelude::*; use crate::warnln; -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub enum PadSelection { Action, Direction, diff --git a/src/ppu.rs b/src/ppu.rs index df6b83acedf529b7428bfa5b86db1005bfd39d60..e2ef8060cff890837188f6f595b219849fb422c4 100644 --- a/src/ppu.rs +++ b/src/ppu.rs @@ -51,7 +51,7 @@ pub type Palette = [Pixel; PALETTE_SIZE]; /// Represents a tile within the Game Boy context, /// should contain the pixel buffer of the tile. #[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct Tile { buffer: [u8; 64], } @@ -93,14 +93,14 @@ impl Display for Tile { for x in 0..8 { buffer.push_str(format!("{}", self.get(x, y)).as_str()); } - buffer.push_str("\n"); + buffer.push('\n'); } write!(f, "{}", buffer) } } #[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct ObjectData { x: i16, y: i16, @@ -122,6 +122,15 @@ impl Display for ObjectData { } } +pub struct PpuRegisters { + pub scy: u8, + pub scx: u8, + pub wy: u8, + pub wx: u8, + pub ly: u8, + pub lyc: u8, +} + /// Represents the Game Boy PPU (Pixel Processing Unit) and controls /// all of the logic behind the graphics processing and presentation. /// Should store both the VRAM and HRAM together with the internal @@ -262,7 +271,7 @@ pub struct Ppu { } #[cfg_attr(feature = "wasm", wasm_bindgen)] -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub enum PpuMode { HBlank = 0, VBlank = 1, @@ -425,24 +434,22 @@ impl Ppu { pub fn read(&mut self, addr: u16) -> u8 { match addr & 0x00ff { 0x0040 => { - let value = if self.switch_bg { 0x01 } else { 0x00 } + (if self.switch_bg { 0x01 } else { 0x00 } | if self.switch_obj { 0x02 } else { 0x00 } | if self.obj_size { 0x04 } else { 0x00 } | if self.bg_map { 0x08 } else { 0x00 } | if self.bg_tile { 0x10 } else { 0x00 } | if self.switch_window { 0x20 } else { 0x00 } | if self.window_map { 0x40 } else { 0x00 } - | if self.switch_lcd { 0x80 } else { 0x00 }; - value + | if self.switch_lcd { 0x80 } else { 0x00 }) } 0x0041 => { - let value = if self.stat_hblank { 0x08 } else { 0x00 } + (if self.stat_hblank { 0x08 } else { 0x00 } | if self.stat_vblank { 0x10 } else { 0x00 } | if self.stat_oam { 0x20 } else { 0x00 } | if self.stat_lyc { 0x40 } else { 0x00 } | if self.lyc == self.ly { 0x04 } else { 0x00 } - | (self.mode as u8 & 0x03); - value + | (self.mode as u8 & 0x03)) } 0x0042 => self.scy, 0x0043 => self.scx, @@ -664,15 +671,26 @@ impl Ppu { 0x02 => obj.tile = value, 0x03 => { obj.palette = if value & 0x10 == 0x10 { 1 } else { 0 }; - obj.xflip = if value & 0x20 == 0x20 { true } else { false }; - obj.yflip = if value & 0x40 == 0x40 { true } else { false }; - obj.priority = if value & 0x80 == 0x80 { false } else { true }; + obj.xflip = value & 0x20 == 0x20; + obj.yflip = value & 0x40 == 0x40; + obj.priority = value & 0x80 != 0x80; obj.index = obj_index as u8; } _ => (), } } + pub fn registers(&self) -> PpuRegisters { + PpuRegisters { + scy: self.scy, + scx: self.scx, + wy: self.wy, + wx: self.wx, + ly: self.ly, + lyc: self.lyc, + } + } + fn render_line(&mut self) { if self.first_frame { return; @@ -846,7 +864,7 @@ impl Ppu { tile = &self.tiles[obj.tile as usize & 0xfe]; } else { tile = &self.tiles[obj.tile as usize | 0x01]; - tile_offset = tile_offset - 8; + tile_offset -= 8; } } // otherwise we're facing a 8x8 sprite and we should grab diff --git a/src/rom.rs b/src/rom.rs index 8cffef5b02cceae4530469ebae7b7d0f66caa2be..b01458eb489a74f8ef4a408fa2b3d6a1f114c9a9 100644 --- a/src/rom.rs +++ b/src/rom.rs @@ -475,7 +475,7 @@ pub static MBC1: Mbc = Mbc { // ROM bank selection 5 lower bits 0x2000 | 0x3000 => { let mut rom_bank = value & 0x1f; - rom_bank = rom_bank & (rom.rom_bank_count * 2 - 1) as u8; + rom_bank &= (rom.rom_bank_count * 2 - 1) as u8; if rom_bank == 0 { rom_bank = 1; } @@ -537,7 +537,7 @@ pub static MBC3: Mbc = Mbc { // ROM bank selection 0x2000 | 0x3000 => { let mut rom_bank = value & 0x7f; - rom_bank = rom_bank & (rom.rom_bank_count * 2 - 1) as u8; + rom_bank &= (rom.rom_bank_count * 2 - 1) as u8; if rom_bank == 0 { rom_bank = 1; } @@ -592,7 +592,7 @@ pub static MBC5: Mbc = Mbc { } // ROM bank selection 0x2000 => { - let rom_bank = value & 0xff; + let rom_bank = value; rom.set_rom_bank(rom_bank); } // RAM bank selection