Skip to content

Commit

Permalink
Support loading ROM files through the UI
Browse files Browse the repository at this point in the history
  • Loading branch information
nathsou committed Jul 4, 2023
1 parent 4d00fcd commit 5faa561
Show file tree
Hide file tree
Showing 14 changed files with 365 additions and 163 deletions.
8 changes: 8 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@
"sideEffects": false,
"devDependencies": {
"vite": "^4.3.9"
},
"dependencies": {
"browser-fs-access": "^0.34.1"
}
}
}
73 changes: 38 additions & 35 deletions web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import init, {
Nes, createConsole, nextFrame, setJoypad1, saveState, loadState,
resetConsole,
} from '../public/pkg/nessy';
import { store } from './ui/store';
import { createUI } from './ui/ui';
const WIDTH = 256; // px
const HEIGHT = 240; // px
Expand Down Expand Up @@ -42,10 +43,10 @@ const roms = {

const game = roms.SuperMarioBros;

enum Joypad {
export enum Joypad {
A = 0b0000_0001,
B = 0b0000_0010,
Select = 0b0000_0100,
SELECT = 0b0000_0100,
START = 0b0000_1000,
UP = 0b0001_0000,
DOWN = 0b0010_0000,
Expand All @@ -61,17 +62,7 @@ const joypad1Mapping: Record<KeyboardEvent['key'], Joypad> = {
'k': Joypad.B,
'l': Joypad.A,
'Enter': Joypad.START,
' ': Joypad.Select,
};

type Mode = {
type: 'play',
} | {
type: 'replay',
inputs: number[],
} | {
type: 'loadSave',
inputs: number[],
' ': Joypad.SELECT,
};

const createController = (nes: Nes) => {
Expand Down Expand Up @@ -210,39 +201,51 @@ async function setup() {

const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(WIDTH, HEIGHT);
const inputs = await (await fetch(`inputs/zelda2.json`)).json();
const mode: Mode = {
type: 'play',
// inputs,
};

await init();
const rom = await (await fetch(`roms/${game}.nes`)).arrayBuffer();
const nes = createConsole(new Uint8Array(rom));
let nes: Nes;
let controller: ReturnType<typeof createController>;
const frame: Uint8Array = imageData.data as any;
const controller = createController(nes);
const replay = createReplay(nes, inputs);
const ui = createUI();

window.addEventListener('resize', resize);
window.addEventListener('keyup', controller.onKeyUp);
window.addEventListener('keydown', controller.onKeyDown);
window.addEventListener('beforeunload', controller.save);
const updateROM = (rom: Uint8Array): void => {
ui.showUI.ref = false;
nes = createConsole(rom);

const ui = createUI();
if (controller) {
window.removeEventListener('resize', resize);
window.removeEventListener('keyup', controller.onKeyUp);
window.removeEventListener('keydown', controller.onKeyDown);
window.removeEventListener('beforeunload', controller.save);
}

controller = createController(nes);

window.addEventListener('resize', resize);
window.addEventListener('keyup', controller.onKeyUp);
window.addEventListener('keydown', controller.onKeyDown);
window.addEventListener('beforeunload', controller.save);

frame.fill(0);
};

if (store.ref.rom != null) {
updateROM(new Uint8Array(store.ref.rom));
}

store.subscribe('rom', async (rom) => {
if (rom != null) {
const bytes = new Uint8Array(rom);
updateROM(bytes);
}
});

function run(): void {
requestAnimationFrame(run);

if (!ui.showUI.ref) {
nextFrame(nes, frame);

if (mode.type === 'play' || mode.type === 'loadSave') {
controller.tick();
}

if (mode.type === 'replay') {
replay.tick();
}
controller.tick();
} else {
ui.render(imageData);
}
Expand Down
71 changes: 71 additions & 0 deletions web/src/ui/components/ControllerMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Joypad } from "../../main";
import { Screen } from "../screen";
import { Store, store } from "../store";
import { Text } from "./text";

export const ControllerMapping = (button: Joypad) => {
let buttonName: keyof Store['controls'];
let isListening = false;

switch (button) {
case Joypad.UP:
buttonName = 'up';
break;
case Joypad.LEFT:
buttonName = 'left';
break;
case Joypad.DOWN:
buttonName = 'down';
break;
case Joypad.RIGHT:
buttonName = 'right';
break;
case Joypad.A:
buttonName = 'a';
break;
case Joypad.B:
buttonName = 'b';
break;
case Joypad.START:
buttonName = 'start';
break;
case Joypad.SELECT:
buttonName = 'select';
break;
}

const getText = () => {
const btnName = `${Joypad[button].padEnd(6, ' ')}`;

if (isListening) {
return `${btnName} > ...`;
}

let keyName = store.ref.controls[buttonName];
if (keyName === ' ') {
keyName = 'space';
}


return `${btnName} > ${keyName.toUpperCase()}`;
};

const text = Text(getText());

return {
...text,
render(x: number, y: number, screen: Screen): void {
text.update(getText());
text.render(x, y, screen);
},
onKeyDown(key: string) {
if (isListening) {
store.ref.controls[buttonName] = key;
isListening = false;
text.update(getText());
} else if (key === 'Enter') {
isListening = true;
}
},
};
};
29 changes: 29 additions & 0 deletions web/src/ui/components/Controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Joypad } from "../../main";
import { ControllerMapping } from "./ControllerMapping";
import { VMenu } from "./VMenu";

export const Controls = () => {
const ctrls = [
Joypad.UP,
Joypad.LEFT,
Joypad.DOWN,
Joypad.RIGHT,
Joypad.A,
Joypad.B,
Joypad.START,
Joypad.SELECT,
].map(ControllerMapping);

const controlsList = VMenu(ctrls);

const onKeyDown = (key: string) => {
if (controlsList.state.activeIndex !== -1) {
ctrls[controlsList.state.activeIndex].onKeyDown(key);
}
};

return {
...controlsList,
onKeyDown,
};
};
5 changes: 5 additions & 0 deletions web/src/ui/components/Flex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Component } from "./component";

const HStack = (x: number, y: number, items: Component[]): Component => {

};
64 changes: 64 additions & 0 deletions web/src/ui/components/HMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Component, WIDTH } from "./component";
import { drawText } from "./text";

export const HMenu = (
items: Component<{ active: boolean }>[],
initialIndex = 0,
): Component<{ active: boolean, activeIndex: number }> & {
next(): void,
prev(): void,
} => {
const state = { active: true, activeIndex: initialIndex };
items[initialIndex].state.active = true;
// compute the x offset for each item so that they are centered
// on the screen
const xOffsets: number[] = [];
let maxLength = 0;
let x0 = 0;

for (let i = 0; i < items.length; i++) {
const item = items[i];
xOffsets.push(Math.round(x0 + item.width / 2));
x0 += item.width + 1;
maxLength = Math.max(maxLength, item.width);
}

const setActiveIndex = (index: number): void => {
if (state.active && index !== state.activeIndex) {
items[state.activeIndex].state.active = false;
items[index].state.active = true;
state.activeIndex = index;
}
};

setActiveIndex(initialIndex);

return {
state,
width: WIDTH,
height: 1,
render(_x, y, screen): void {
let x0 = WIDTH / 2 - xOffsets[state.activeIndex];

for (let i = 0; i < items.length; i++) {
const item = items[i];
item.render(x0, y, screen);
x0 += item.width + 1;
}

if (state.activeIndex > 0) {
drawText(0, y, '< ', screen);
}

if (state.activeIndex < items.length - 1) {
drawText(WIDTH - 2, y, ' >', screen);
}
},
next(): void {
setActiveIndex(Math.min(state.activeIndex + 1, items.length - 1));
},
prev(): void {
setActiveIndex(Math.max(state.activeIndex - 1, 0));
},
};
};
50 changes: 50 additions & 0 deletions web/src/ui/components/Library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { directoryOpen, fileOpen } from "browser-fs-access";
import { VMenu } from "./VMenu";
import { Text } from "./text";
import { store } from "../store";

export const Library = () => {
const list = VMenu([
Text('Load ROM file'),
Text('Load ROM dir.'),
]);

const loadRomFile = async () => {
const file = await fileOpen({
description: 'NES ROM file',
extensions: ['.nes'],
mimeTypes: ['application/octet-stream'],
multiple: false,
startIn: 'downloads',
});

const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
store.set('rom', bytes);
};

const loadRomDir = async () => {
const dir = await directoryOpen({
recursive: false,
mode: 'read',
startIn: 'downloads',
});

console.log(dir);
};

const onClickMappings = [
loadRomFile,
loadRomDir,
];

const onKeyDown = (key: string): void => {
if (list.state.activeIndex !== -1 && key === 'Enter') {
onClickMappings[list.state.activeIndex]();
}
};

return {
...list,
onKeyDown,
};
};
Loading

0 comments on commit 5faa561

Please sign in to comment.