NodeJSでCHIP-8 Emulatorを作る

common lispで作ろうと思ったが思ったよりもわからなかったので、得意なnodejsで作ってみた。


chip8 image

repo: takeokunn/chip8js

chip8 emulatorについては他の有志が山程作って記事を書いてると思うので詳しいことは省く。

良かった記事やrepo

command line arguments

minimist でcommand line argumentsを取得してみた。

argumentにspeedやcolorやromをもたせられるので、reduceで引数のvalidationを綺麗に書いた。

src/argument.js抜粋:

const minimist = require('minimist');

const validate = () => {
    const argv = minimist(process.argv.slice(2));

    if (argv.help) {
        utility.print(help);
        process.exit(0);
    }

    const validators = [
        { message: missing_rom_arg_warning, cond: !argv.rom },
        { message: speed_arg_warning,       cond: argv.speed != undefined && !Number.isInteger(argv.speed) },
        { message: color_arg_warning,       cond: argv.color != undefined && Object.keys(config.colors).indexOf(argv.color) < 0 },
        { message: rom_path_warning,        cond: !fs.existsSync(argv.rom) }
    ];
    const errors = validators.reduce((accum, value) => value['cond']? [...accum, ...value['message']] : [...accum], []);

    if (errors.length > 0) {
        utility.print(errors);
        process.exit(1);
    }

    return {
        rom: argv.rom,
        speed: argv.speed,
        color: argv.color
    };
};

game loop

browser javascriptならwindow.requestAnimationFrameを使えば楽勝なのだが、nodejsなので自前で書く必要がある。

Node.js のタイマーと仕組みあたりを参考に作ってみた。

src/index.js抜粋:

let game_state = config.state.prep;
const initial_time = {
    now: 0,
    elapsed: 0,
    then: Date.now()
};

const handleGameEvent = () => {
    cpu.executeCycle();

    if (!cpu.draw_flag) return;

    utility.clearScreen();
    utility.printOutput(cpu.video, rendering_color, rendering_char);

    cpu.draw_flag = false;
};

const mainLoop = time => {
    const cond = time.elapsed > fps_interval;
    const now = Date.now();
    const new_time = {
        now: now,
        elapsed: now - time.then,
        then: cond? now - (time.elapsed % fps_interval) : time.then
    };

    if (cond) handleGameEvent();
    if (game_state === config.state.loop) setImmediate(mainLoop.bind(this, new_time));
};

cpu

参考にしたrepo(obsfx/console8)とCHIP-8 wikiの仕様通りに作った。

一番悩んだのはdispatcherの部分。読み易いように書きなおした。

src/cpu.js抜粋:

class Chip8 {
    dispatchInstruction() {
        switch (this.opcode & 0xF000) {
        case 0x0000:
            {
                switch(this.opcode & 0x000F) {
                case 0x0000: this.op_00e0(); break;
                case 0x000E: this.op_00ee(); break;
                default: this.op_error("0x0000"); break;
                }
            }
            break;
        case 0x1000: this.op_1nnn(); break;
        case 0x2000: this.op_2nnn(); break;
        case 0x3000: this.op_3xnn(); break;
        case 0x4000: this.op_4xnn(); break;
        case 0x5000: this.op_5xy0(); break;
        case 0x6000: this.op_6xnn(); break;
        case 0x7000: this.op_7xnn(); break;
        case 0x8000:
            {
                switch (this.opcode & 0x000F) {
                case 0x0000: this.op_8xy0(); break;
                case 0x0001: this.op_8xy1(); break;
                case 0x0002: this.op_8xy2(); break;
                case 0x0003: this.op_8xy3(); break;
                case 0x0004: this.op_9xy4(); break;
                case 0x0005: this.op_8xy5(); break;
                case 0x0006: this.op_8xy6(); break;
                case 0x0007: this.op_8xy7(); break;
                case 0x000E: this.op_8xye(); break;
                default: this.op_error("0x8000"); break;
                }
            }
            break;
        case 0x9000: this.op_9xy0(); break;
        case 0xA000: this.op_annn(); break;
        case 0xB000: this.op_bnnn(); break;
        case 0xC000: this.op_cxnn(); break;
        case 0xD000: this.op_dxyn(); break;
        case 0xE000:
            {
                switch (this.opcode & 0x000F) {
                case 0x000E: this.op_ex9e(); break;
                case 0x0001: this.op_exa1(); break;
                default: this.op_error("0xE000"); break;
                }
            }
            break;
        case 0xF000:
            {
                switch (this.opcode & 0x000F) {
                case 0x0007: this.op_fx07(); break;
                case 0x000A: this.op_fx0a(); break;
                case 0x0005:
                    {
                        switch(this.opcode & 0x00F0) {
                        case 0x0010: this.op_fx15(); break;
                        case 0x0050: this.op_fx55(); break;
                        case 0x0060: this.op_fx65(); break;
                        default: this.op_error("0xF000 > 0x0060"); break;
                        }
                    }
                    break;
                case 0x0008: break;
                case 0x000E: this.op_fx1e(); break;
                case 0x0009: this.op_fx29();break;
                case 0x0003: this.op_fx33(); break;
                default: this.op_error("0xF000"); break;
                }
            }
            break;
        default: this.op_error("error"); break;
        }
    }
}

keyboard

iohookを使った。サクっとkey eventを取得できて便利だった。gameloop内でeventを取得する方法がよくわからなく現在調査中。

src/index.js抜粋:

// keyboard events
const handleKeyup = e => {
    config.keys.forEach((key, index) => {
        if (e.keycode === key) cpu.keypad[index] = 0;
    });
};

const handleKeydown = e => {
    if (e.keycode === 19 && e.ctrlKey && game_state === config.state.prep) {
        game_state = config.state.prep;
        cpu.reset();
        utility.clearScreen();
        game_state = config.state.loop;
        mainLoop(initial_time);
    }

    if (e.keycode === 28 && game_state === config.state.prep) {
        game_state = config.state.loop;
        utility.clearScreen();
        mainLoop(initial_time);
    }

    config.keys.forEach((key, index) => {
        if (e.keycode === key) cpu.keypad[index] = 1;
    });
};

iohook.on('keyup', handleKeyup);
iohook.on('keydown', handleKeydown);
iohook.start();

今後の課題

  • keyinputがいまいち動かないので修正
  • ブラウザで動くようにする

結局慣れてる言語で作るのが一番理解が速い。