[Перевод] Как написать эмулятор CHIP-8 на JS
Автор провёл детство за играми в эмуляторах NES и SNES на своём компьютере, но никогда не думал, что однажды сам напишет эмулятор. Иван Сергеев поставил перед автором задачу написать интерпретатор Chip-8, чтобы изучить основные понятия низкоуровневых языков программирования и то, как работает процессор.
Результат — эмулятор Chip-8 на JavaScript, который я написал под его руководством. Подробности рассказываем, пока у нас начинается курс по Fullstack-разработке на Python.
Хотя есть множество реализаций интерпретатора Chip-8 на всевозможных языках программирования, мой Chip8.js работает с тремя средами: это веб-приложение, приложение CLI и нативное приложение. Исходный код и демо:
Есть и множество руководств о том, как сделать эмулятор Chip-8, таких как Mastering Chip8, How to Write an Emulator и, самое главное — Cowgod«s Chip-8 Technical Reference, основной ресурс для моего собственного эмулятора, а ещё веб-сайт, настолько старый, что его адрес заканчивается .HTM
.
Таким образом, это не руководство, но обзор того, как я создала эмулятор, какие основные концепции я изучила, и о кое-какой специфике JavaScript в смысле создания браузерного, нативного и CLI-приложения.
Что такое Chip-8
Я не слышала о Chip-8 до начала этого проекта, поэтому предполагаю, что большинство людей тоже не слышали, если не ладят с эмуляторами. Chip-8 — это очень простой интерпретируемый язык программирования, разработанный в 1970-х годах для любителей компьютеров.
Люди писали простые программы Chip-8, которые имитировали популярные игры того времени: Pong, Tetris, Space Invaders и, вероятно, другие игры, потерянные в анналах истории.
Виртуальная машина, которая играет в них, на самом деле технически является интерпретатором Chip-8, а не эмулятором, поскольку эмулятор — это программное обеспечение, эмулирующее аппаратное обеспечение конкретной машины, а программы Chip-8 не привязаны к какому-либо конкретному оборудованию. Интерпретаторы Chip-8 часто использовались на графических калькуляторах.
Тем не менее этот интерпретатор достаточно близок к эмулятору, поэтому с него обычно начинают те, кто хочет научиться создавать эмуляторы; это значительно проще, чем создавать эмулятор NES или чего-либо ещё. А ещё это хорошая отправная точка для изучения многих концепций процессора в целом, таких как память, стеки и ввод-вывод, с которыми я ежедневно имею дело в бесконечно более сложном мире среды выполнения JavaScript.
Что входит в интерпретатор Chip-8?
Мне пришлось пройти много предварительной подготовки, чтобы хотя бы начать понимать, с чем я работаю; я никогда раньше не изучала основы информатики. Поэтому написала статью Понимание битов, байтов, оснований и запись шестнадцатеричного дампа в JavaScript, где подробно рассказала об этом.Из неё можно сделать два основных вывода:
- Биты и байты. Бит — это двоичная цифра — 0 или 1, истина или ложь, включено или выключено. Восемь бит — это байт, основная единица информации, с которой работают компьютеры.
- Основания чисел. Десятичная система счисления является наиболее привычной для нас, но компьютеры обычно работают с двоичной (основание 2) или шестнадцатеричной (основание 16).
1111
в двоичной системе,15
в десятичной иf
в шестнадцатеричной — это одно и то же число. - Полубайты. Кроме того, 4 бита — это полубайт, что очень мило. Мне пришлось немного повозиться с ними.
- Префиксы. В JS
0x
— это префикс шестнадцатеричных чисел,0b
— это префикс двоичных чисел.
Я также написала Змейку в CLI, чтобы понять, как здесь работать с пикселями в терминале.
Процессор (CPU) — это основной процессор компьютера, который выполняет инструкции программы. Здесь он состоит из различных битов состояния, описанных ниже, а также цикла инструкций с шагами: извлечением, декодированием и выполнением.
Память
Chip-8 может получить доступ к 4 Кб памяти ОЗУ. Это 0,002%
от объёма памяти на дискете. Большая часть данных процессора хранится в памяти.
4 Кb — это 4096 байт, и JavaScript поддерживает полезные типизированные массивы, к примеру, Uint8Array с фиксированным размером элементов — здесь это 8 бит.
let memory = new Uint8Array(4096)
Вы можете получить доступ и использовать этот массив как обычный массив, от memory[0]
до memory[4095]
, устанавливая элементы массива в значения до 255
. Значения выше 255 преобразуются в 255.
Счётчик команд (PC)
Этот счётчик хранит адрес текущей инструкции в виде 16-битного целого числа. Каждая инструкция в Chip-8 обновляет PC, когда она завершена, чтобы перейти к следующей инструкции, обращаясь к инструкции по адресу, который записан в PC.
Что касается схемы размещения ячеек памяти Chip-8, 0x000
to 0x1FF
зарезервировано, так что память начинается с адреса 0x200
.
let PC = 0x200 // memory[PC] будет обращаться к адресу текущей инструкции
Вы заметите, что массив памяти 8-битный, а PC — 16-битное целое число, поэтому, чтобы получился опкод big endian, объединяются два программных кода.
Регистры
Память обычно используется для долгосрочного хранения и программирования данных, поэтому регистры существуют как своего рода «кратковременная память» для немедленного получения данных и вычислений. Chip-8 имеет 16 8-битных регистров, от V0
до VF
.
let registers = new Uint8Array(16)
Индексный регистр
Существует специальный 16-битный регистр, который обращается к определённой точке в памяти, так называемый I
. Регистр I
существует в основном для чтения и записи в память, поскольку адресуемая память также 16-битная.
let I = 0
Стек
Chip-8 имеет возможность переходить в подпрограммы, а также в стек для отслеживания того, куда возвращаться. Стек имеет размер 16 16-битных значений: до «переполнение стека» программа может перейти в 16 вложенных подпрограмм.
let stack = new Uint16Array(16)
Указатель стека
Указатель стека (SP) — это 8-битное
целое число, которое указывает на место в стеке. Он должен быть только 8-битным, хотя стек 16-битный. Поскольку указатель ссылается только на индекс стека, он должен иметь значения только от 0
до 15
.
let SP = -1
// stack[SP] получит доступ к текущему адресу возврата в стеке.
Таймеры
Chip-8 способен издавать великолепный одиночный звуковой сигнал. Честно говоря, я не потрудилась реализовать реальный вывод «музыки», хотя сам процессор может с ней работать.
Есть два таймера, оба — 8-битные регистры: звуковой таймер (ST) для определения времени звукового сигнала и таймер задержки (DT) для определения времени некоторых событий в игре. Они отсчитывают время с частотой 60 Гц.
let DT = 0
let ST = 0
Ввод с клавиатуры
Chip-8 поставлялся вот с такой удивительной шестнадцатеричной клавиатурой:
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ C │
│ 4 │ 5 │ 6 │ D │
│ 7 │ 8 │ 9 │ E │
│ A │ 0 │ B │ F │
└───┴───┴───┴───┘
На практике, похоже, используются лишь несколько клавиш, и вы можете сопоставить их с любой сеткой 4×4, но в разных играх они довольно непоследовательны.
Графический вывод
В Chip-8 используется монохромный дисплей с разрешением 64x32
. Каждый пиксель либо включён, либо выключен.
Спрайты, которые можно сохранить в памяти, имеют размер 8x15
— восемь пикселей в ширину и пятнадцать — в высоту. Chip-8 также поставляется с набором шрифтов, но он содержит только символы шестнадцатеричной клавиатуры.
CPU
Сложите всё это вместе, и вы получите состояние процессора. Вот его класс:
class CPU {
constructor() {
this.memory = new Uint8Array(4096)
this.registers = new Uint8Array(16)
this.stack = new Uint16Array(16)
this.ST = 0
this.DT = 0
this.I = 0
this.SP = -1
this.PC = 0x200
}
}
Декодирование инструкций Chip-8
Chip-8 имеет 36 инструкций. Все инструкции перечислены здесь. Все инструкции имеют длину 2 байта (16 бит). Каждая инструкция кодируется опкодом (кодом операции) и операндом — данными, над которыми производится операция. Примером инструкции может быть такая операция:
x = 1.
y = 2.
ADD x, y,
где ADD
— опкод
и x
, y
— операнды. Этот тип языка известен как язык ассемблера. Эта инструкция будет отображаться на:
x = x + y
При таком наборе инструкций мне придётся хранить эти данные в 16 битах, так что каждая инструкция будет представлять собой число от 0x0000
до 0xffffff
. Каждая позиция разряда в этих наборах состоит из 4 битов.
Как же мне перейти от nnnn
к чему-то вроде ADD x, y
? Начну с инструкции, аналогичной примеру выше:
Есть одно ключевое слово, ADD
, а ещё два установленных в регистрах аргумента, Vx
и Vy
. Также есть несколько мнемоник опкодов, похожих на ключевые слова:
ADD
(сложение).SUB
(вычитание).JP
(переход).SKP
(пропуск).RET
(возврат).LD
(загрузка).
И несколько типов значений операндов, таких как:
- Адрес (
I
). - Регистр (
Vx
,Vy
). - Константа
(N
илиNN
для полубайта или байта).
Теперь нужно найти способ интерпретации 16-битного опкода как более понятных инструкций.
Битовые маски
Каждая инструкция содержит шаблон, и он всегда будет одним и тем же, и переменные, которые могут меняться. Для 8xy4
паттерном является 8__4
. Два полубайта в середине — это переменные. Создав битовую маску для этого шаблона, я могу определить инструкцию.
Для маскирования используется побитовое AND (&
) с маской и сопоставляется с шаблоном. Таким образом, если появится команда 8124
, захочется гарантировать, что полубайт в позиции 1 и 4 включён (пропущен), а полубайт в позиции 2 и 3 выключен (замаскирован). И вот маска: f00f
.
const opcode = 0x8124
const mask = 0xf00f
const pattern = 0x8004
const isMatch = (opcode & mask) === pattern // true
8124
& f00f
====
8004
Аналогично 0f00
м 00f0
будет маскировать переменные, а сдвигом вправо (>>
) они получат доступ к нужному полубайту.
const x = (0x8124 & 0x0f00) >> 8 // 1
// (0x8124 & 0x0f00) is 100000000 in binary
// правый сдвиг на 8 (>> 8) удалит 8 нулей справа
// Останется 1
const y = (0x8124 & 0x00f0) >> 4 // 2
// (0x8124 & 0x00f0) — это 100000 в двоичном коде
// правый сдвиг на 4 (>> 4) удалит четыре нуля справа
// Останется 10, то есть двоичный эквивалент 2
Поэтому для каждой из 36 инструкций я создала объект с уникальным идентификатором, маской, шаблоном и аргументами.
const instruction = {
id: 'ADD_VX_VY',
name: 'ADD',
mask: 0xf00f,
pattern: 0x8004,
arguments: [
{ mask: 0x0f00, shift: 8, type: 'R' },
{ mask: 0x00f0, shift: 4, type: 'R' },
],
}
Теперь, когда у меня есть эти объекты, каждый опкод может быть разобран на уникальный идентификатор, и значения аргументов могут быть определены.
Я создала массив INSTRUCTION_SET
, содержащий все эти инструкции, а также написала дизассемблер и тесты, чтобы гарантировать, что все они работают правильно. Вот дизассемблер:
function disassemble(opcode) {
// Ищем инструкцию исходя из байт-кода
const instruction = INSTRUCTION_SET.find(
(instruction) => (opcode & instruction.mask) === instruction.pattern
)
// Ищем аргументы
const args = instruction.arguments.map(
(arg) => (opcode & arg.mask) >> arg.shift
)
// Возвращает объект, содержащий инструкции и аргументы
return { instruction, args }
}
Чтение ПЗУ
Поскольку мы рассматриваем этот проект как эмулятор, каждый программный файл Chip-8 можно считать ПЗУ. ПЗУ — это просто двоичные данные, а мы пишем программу для их интерпретации. Мы можем представить процессор Chip8 как виртуальную консоль, а ПЗУ Chip-8 как виртуальный картридж.
Буфер ПЗУ примет необработанный двоичный файл и преобразует его в 16-битные слова big endian (слово — это единица данных, состоящая из определённого количества битов). Вот где пригодится статья о шестнадцатеричном дампе. Я собираю двоичные данные и преобразую их в блоки, которые я могу использовать, в нашем случае 16-битные опкоды. Big endian означает, что старший байт будет первым в буфере, поэтому, когда он встретит два байта 12 34
, он создаст 1234
16-битный код. Код с little endian выглядел бы так: 3412
.
class RomBuffer {
/**
* @param {binary} fileContents ROM binary
*/
constructor(fileContents) {
this.data = []
// Читаем сырые данные буфера из файла
const buffer = fileContents
// Создаём 16-битные опкоды big endian из буфера
for (let i = 0; i < buffer.length; i += 2) {
this.data.push((buffer[i] << 8) | (buffer[i + 1] << 0))
}
}
}
Возвращаемые из этого буфера данные — это и есть «игра».
У процессора будет метод load()
— как при загрузке картриджа в консоль, — который будет брать данные из этого буфера и помещать их в память. И буфер, и память работают в JavaScript как массивы, поэтому загрузка памяти сводится к циклическому просмотру буфера и помещению байтов в массив памяти.
Цикл выполнения инструкций — извлечение, декодирование, выполнение
Теперь у меня есть набор инструкций и игровые данные, готовые к интерпретации. Процессор просто должен что-то с ним сделать. Цикл инструкции состоит из трёх этапов — извлечения, декодирования и выполнения.
- Извлечение (fetch) — получение данных, хранящихся в памяти, при помощи счётчика программы.
- Декодирование — разбор 16-битного опкода для получения декодированной инструкции и значений аргументов.
- Выполнение — выполнение операции на основе декодированной инструкции и обновление счётчика программы.
Вот сжатая и упрощённая версия того, как работает цикл. Эти методы цикла ЦП являются частными и не раскрываются.
Первый шаг, fetch
, обращается к текущему опкоду из памяти.
// Берём адрес из памяти
function fetch() {
return memory[PC]
}
decode разберёт опкод на понятный набор команд:
// Декодируем инструкцию
function decode(opcode) {
return disassemble(opcode)
}
Execute состоит из switch
со всеми 36 инструкциями в качестве case, и выполнит для найденной инструкции соответствующую операцию, обновив затем счётчик программы, чтобы следующий цикл извлечения нашёл следующий опкод. Любая обработка ошибок будет проходить здесь же, что приведёт к остановке процессора.
// Выполняем инструкцию
function execute(instruction) {
const { id, args } = instruction
switch (id) {
case 'ADD_VX_VY':
// Выполняем операцию инструкции
registers[args[0]] += registers[args[1]]
// Обновляем счётчик
PC = PC + 2
break
case 'SUB_VX_VY':
// и т д.
}
}
В итоге я получаю процессор со всеми состояниями и циклом команд. Есть два метода, открытые на CPU, — load
— эквивалент загрузки картриджа в консоль с romBuffer
в качестве игры, и step
, который представляет собой три функции цикла инструкций (извлечение, декодирование, выполнение). step
будет работать в бесконечном цикле.
class CPU {
constructor() {
this.memory = new Uint8Array(4096)
this.registers = new Uint8Array(16)
this.stack = new Uint16Array(16)
this.ST = 0
this.DT = 0
this.I = 0
this.SP = -1
this.PC = 0x200
}
// Загружаем буфер в память
load(romBuffer) {
this.reset()
romBuffer.forEach((opcode, i) => {
this.memory[i] = opcode
})
}
// Шаг по инструкциям
step() {
const opcode = this._fetch()
const instruction = this._decode(opcode)
this._execute(instruction)
}
_fetch() {
return this.memory[this.PC]
}
_decode(opcode) {
return disassemble(opcode)
}
_execute(instruction) {
const { id, args } = instruction
switch (id) {
case 'ADD_VX_VY':
this.registers[args[0]] += this.registers[args[1]]
this.PC = this.PC + 2
break
}
}
}
Сейчас не хватает только одного — возможности поиграть.
Создание интерфейса ЦП для ввода-вывода
Итак, у меня есть процессор, который интерпретирует и выполняет инструкции и обновляет все свои состояния, но я ещё ничего не могу с ним сделать.
Именно здесь в дело вступает ввод/вывод — связь между центральным процессором и внешним миром.
- Ввод — это данные, полученные центральным процессором.
- Вывод — это данные, отправленные центральным процессором.
Ввод будет с клавиатуры, вывод — в виде графики.
Я могла просто смешать код ввода-вывода с процессором напрямую, но тогда я была бы привязан к одной среде. Создав общий интерфейс CPU для соединения ввода/вывода и CPU, я могу взаимодействовать с любой системой.
Первое, что нужно было сделать, — это просмотреть инструкции и найти те, что имеют отношение к вводу/выводу. Несколько примеров таких инструкций:
CLS
— очистить экран.LD Vx, K
— ожидание нажатия клавиши, сохранение значения клавиши в Vx.DRW Vx, Vy, nibble
— отображение n-байтового спрайта, начинающегося в ячейке памяти I.
Исходя из этого, мы хотим, чтобы интерфейс имел такие методы:
clearDisplay()
.waitKey()
.drawPixel()
(drawSprite
было бы 1:1, но в итоге оказалось, что проще делать это попиксельно из интерфейса).
В JavaScript нет понятия абстрактного класса, но я создала класс, который не может быть инстанцирован, с методами, работающими только из классов, которые расширяют этот класс. Вот все методы интерфейса этого класса:
// Абстрактный класс интерфейса CPU
class CpuInterface {
constructor() {
if (new.target === CpuInterface) {
throw new TypeError('Cannot instantiate abstract class')
}
}
clearDisplay() {
throw new TypeError('Must be implemented on the inherited class.')
}
waitKey() {
throw new TypeError('Must be implemented on the inherited class.')
}
getKeys() {
throw new TypeError('Must be implemented on the inherited class.')
}
drawPixel() {
throw new TypeError('Must be implemented on the inherited class.')
}
enableSound() {
throw new TypeError('Must be implemented on the inherited class.')
}
disableSound() {
throw new TypeError('Must be implemented on the inherited class.')
}
}
Вот как это будет работать: интерфейс будет загружен в CPU при инициализации, и CPU сможет получить доступ к его методам.
class CPU {
// Инстанцируем интерфейс
constructor(cpuInterface) {
this.interface = cpuInterface
}
_execute(instruction) {
const { id, args } = instruction
switch (id) {
case 'CLS':
// Используем интерфейс при выполнении инструкции
this.interface.clearDisplay()
}
}
Перед установкой интерфейса в реальной среде (веб, терминал или нативная среда) я создала его макет для тестов. На самом деле он не подключён ни к какому вводу/выводу, но он помог мне настроить состояние интерфейса и подготовить его к работе с реальными данными. Я проигнорирую звуковые данные, потому что у них нет выхода на динамики. Остаются клавиатура и экран.
Экран
Экран имеет разрешение 64 пикселя в ширину на 32 пикселя в высоту. Итак, что касается процессора и интерфейса, то это 64×32 сетка битов, которые либо включены, либо выключены. Чтобы создать пустой экран, я могу просто создать 3D-массив нулей, представляя все пиксели выключенными. Буфер кадра — это часть памяти, содержащая растровое изображение, которое будет выведено на дисплей.
// Интерфейс для тестирования
class MockCpuInterface extends CpuInterface {
constructor() {
super()
// Храним данные экрана в буфере кадров
this.frameBuffer = this.createFrameBuffer()
}
// Создаём 3D массив нулей
createFrameBuffer() {
let frameBuffer = []
for (let i = 0; i < 32; i++) {
frameBuffer.push([])
for (let j = 0; j < 64; j++) {
frameBuffer[i].push(0)
}
}
return frameBuffer
}
// Обновляем пиксель (0 или 1)
drawPixel(x, y, value) {
this.frameBuffer[y][x] ^= value
}
}
В итоге в смысле представления экрана я получаю что-то вроде этого:
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
и т.д.
В функции DRW
процессор пройдётся по извлечённому из памяти спрайту и обновит каждый пиксель в нём. Детали опущены для краткости.
case 'DRW_VX_VY_N':
// Интерпретатор считывает n байт из памяти, начиная с адреса в I
for (let i = 0; i < args[2]; i++) {
let line = this.memory[this.I + i]
// Каждый байт представляет собой строку из восьми пикселей
for (let position = 0; position < 8; position++) {
// ...Получаем значение, x, и y...
this.interface.drawPixel(x, y, value)
}
}
Функция clearDisplay()
— единственный метод, который будет использоваться для взаимодействия с экраном. Это всё, что нужно интерфейсу процессора для такого взаимодействия.
Клавиши
Я сопоставила оригинальную клавиатуру со следующей сеткой клавиш:
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │
│ Q │ W │ E │ R │
│ A │ S │ D │ F │
│ Z │ X │ C │ V │
└───┴───┴───┴───┘
И поместила ключи в массив.
// лучше игнорировать
const keyMap = [
'1', '2', '3', '4',
'q', 'w', 'e', 'r',
'a', 's', 'd', 'f',
'z', 'x', 'c', 'v'
]
Для хранения текущих нажатых клавиш опишите состояние:
this.keys = 0
В интерфейсе keys
— это двоичное число из 16 цифр, индекс представляет клавишу. Chip-8 просто хочет знать, какие клавиши нажаты, и на основе этого принимает решение:
0b1000000000000000 // V нажата (keyMap[15], или индекс 15)
0b0000000000000011 // 1 и 2 нажаты (index 0, 1)
0b0000000000110000 // Q и W нажаты (index 4, 5)
Теперь, если, например, нажата V
(keyMap[15]
) и операнд — 0xf
(десятичное 15
), то клавиша нажата. Левый сдвиг (<<
) и 1
создаст двоичное число с 1
, за которым следует столько нулей, сколько находится в левом сдвиге.
case 'SKP_VX':
// Пропустить следующую инструкцию, если нажата клавиша со значением VX
if (this.interface.getKeys() & (1 << this.registers[args[0]])) {
// Пропускаем инструкцию
} else {
// Идём к следующей инструкции
}
Есть ещё один метод клавиш, waitKey
, где инструкция заключается в ожидании нажатия клавиши и возврате этой нажатой клавиши.
Приложение CLI — взаимодействие с терминалом
Первый интерфейс, который я сделала, был для терминала. Это было мне не так знакомо, как работа с DOM: я никогда не создавала графических приложений в терминале, но это не слишком сложно.
Curses — это библиотека, используемая для создания текстовых пользовательских интерфейсов в терминале. Blessed — это библиотека, оборачивающая curses для Node.js.
Экран
Буфер кадра, содержащий битовую карту данных экрана, одинаков для всех реализаций, но способы взаимодействия экрана с каждой средой будут различаться.
С помощью blessed
я определила объект экрана:
this.screen = blessed.screen({ smartCSR: true })
И использовала fillRegion
или clearRegion
на пикселе с полным блоком юникода, чтобы заполнить его c frameBuffer
в качестве источника данных.
drawPixel(x, y, value) {
this.frameBuffer[y][x] ^= value
if (this.frameBuffer[y][x]) {
this.screen.fillRegion(this.color, '█', x, x + 1, y, y + 1)
} else {
this.screen.clearRegion(x, x + 1, y, y + 1)
}
this.screen.render()
}
Клавиши
Обработчик клавиш не слишком отличался от того, что в DOM. Если клавиша нажата, обработчик передаёт ключ, затем я могу использовать его для поиска индекса и обновления объекта keys с любыми дополнительными клавишами, которые были нажаты.
this.screen.on('keypress', (_, key) => {
const keyIndex = keyMap.indexOf(key.full)
if (keyIndex) {
this._setKeys(keyIndex)
}
})
Особенно странной вещью было то, что у blessed
не было никакого keyup
, которое я могла бы использовать, поэтому мне пришлось просто имитировать его, задав интервал периодической очистки клавиш.
setInterval(() => {
// Эмулируем keyup, чтобы очистить все нажатые клавиши
this._resetKeys()
}, 100)
Точка входа
Всё готово — буфер rom для преобразования двоичных данных в опкоды, интерфейс для подключения ввода/вывода, процессор, содержащий состояние, цикл команд и два открытых метода — один для загрузки игры, другой для выполнения цикла. Поэтому я создаю функцию cycle
, которая будет запускать инструкции процессора в бесконечном цикле.
const fs = require('fs')
const { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const {
TerminalCpuInterface,
} = require('../classes/interfaces/TerminalCpuInterface')
// Извлекаем файл ПЗУ
const fileContents = fs.readFileSync(process.argv.slice(2)[0])
// Инициализируем интерфейс терминала
const cpuInterface = new TerminalCpuInterface()
// Инициализируем CPU с интерфейсом
const cpu = new CPU(cpuInterface)
// Преобразуем двоичные данные в опкоды
const romBuffer = new RomBuffer(fileContents)
// Загружаем игру
cpu.load(romBuffer)
function cycle() {
cpu.step()
setTimeout(cycle, 3)
}
cycle()
В функции цикла также есть таймер задержки, но я удалила его из примера для наглядности.
Теперь, чтобы играть, я могу запустить файл точки входа терминала и передать ROM как аргумент.
npm run play:terminal roms/PONG
Веб-приложение — взаимодействие с браузером
Следующий интерфейс, который я создала, предназначен для веба. Я сделала эту версию эмулятора немного более причудливой, поскольку браузер — привычная для меня среда, и я не могу устоять перед желанием сделать сайты в стиле ретро. Он также позволяет переключаться между играми.
Экран
Для экрана я использовала Canvas API и его CanvasRenderingContext2D для поверхности рисования. fillRect
и canvas в основном то же, что fillRegion
в blessed
.
this.screen = document.querySelector('canvas')
this.context = this.screen.getContext('2d')
this.context.fillStyle = 'black'
this.context.fillRect(0, 0, this.screen.width, this.screen.height)
Одно небольшое отличие: я умножила все пиксели на 10, чтобы экран стал заметнее.
this.multiplier = 10
this.screen.width = DISPLAY_WIDTH * this.multiplier
this.screen.height = DISPLAY_HEIGHT * this.multiplier
Это сделало команду drawPixel
более многословной, но в остальном концепция осталась прежней.
drawPixel(x, y, value) {
this.frameBuffer[y][x] ^= value
if (this.frameBuffer[y][x]) {
this.context.fillStyle = COLOR
this.context.fillRect(
x * this.multiplier,
y * this.multiplier,
this.multiplier,
this.multiplier
)
} else {
this.context.fillStyle = 'black'
this.context.fillRect(
x * this.multiplier,
y * this.multiplier,
this.multiplier,
this.multiplier
)
}
}
Клавиши
У меня был доступ к гораздо большему количеству обработчиков событий клавиш в DOM, поэтому я смогла легко обрабатывать события keyup
и keydown
.
// Устанавливаем клавиши ненажатыми
document.addEventListener('keydown', event => {
const keyIndex = keyMap.indexOf(event.key)
if (keyIndex) {
this._setKeys(keyIndex)
}
})
// Сбрасываем клавиши по нажатию
document.addEventListener('keyup', event => {
this._resetKeys()
})
}
Точка входа
Для работы с модулями я импортировала все модули и установила их в глобальный объект, а затем использовала Browserify для работы в браузере. Установка их в глобальные делает их доступными в окне, чтобы я могла использовать вывод кода в сценарии браузера. Сегодня для этого можно использовать Webpack или что-то другое, но это было быстро и просто.
const { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const { WebCpuInterface } = require('../classes/interfaces/WebCpuInterface')
const cpuInterface = new WebCpuInterface()
const cpu = new CPU(cpuInterface)
// Устанавливаем буфер CPU и ROM в глобальный объект, который станет окном в браузере.
global.cpu = cpu
global.RomBuffer = RomBuffer
Точка входа в веб использует ту же функцию cycle
, что и реализация терминала, но имеет функцию для получения каждого ПЗУ и сброса данных дисплея каждый раз, когда выбирается новое ПЗУ. Я привыкла работать с json данными и fetch, но в этом случае извлекла необработанный arrayBuffer
из ответа.
// Извлекаем ПЗУ и загружаем игру
async function loadRom() {
const rom = event.target.value
const response = await fetch(`./roms/${rom}`)
const arrayBuffer = await response.arrayBuffer()
const uint8View = new Uint8Array(arrayBuffer)
const romBuffer = new RomBuffer(uint8View)
cpu.interface.clearDisplay()
cpu.load(romBuffer)
}
// Добавляем возможность выбирать игру
document.querySelector('select').addEventListener('change', loadRom)
HTML содержит canvas
и select
.
Затем я просто развернула код на страницах GitHub, потому что он статический.
Нативное приложение — взаимодействие с нативной платформой
Я также сделала экспериментальную реализацию нативного UI. Я использовала Raylib для программирования простых игр, которая имела биндинг для Node.js.
Я считаю эту версию экспериментальной только потому, что она очень медленная по сравнению с другими, поэтому она менее удобна в использовании, но с клавишами и экраном всё работает правильно.
Точка входа
Raylib работает немного иначе, чем другие реализации, поскольку сама работает в цикле, а это значит, что я не буду использовать функцию cycle
.
const r = require('raylib')
// Пока окно не закроется...
while (!r.WindowShouldClose()) {
// Извлекаем, декодируем, выполняем
cpu.step()
r.BeginDrawing()
// Отрисовываем экран с изменениями
r.EndDrawing()
}
r.CloseWindow()
Экран
В рамках методов beginDrawing()
и endDrawing()
экран будет обновляться. Для реализации Raylib, вместо того чтобы держать всё в интерфейсе, я обращалась к интерфейсу прямо из скрипта. Это работает.
r.BeginDrawing()
cpu.interface.frameBuffer.forEach((y, i) => {
y.forEach((x, j) => {
if (x) {
r.DrawRectangleRec({ x, y, width, height }, r.GREEN)
} else {
r.DrawRectangleRec({ x, y, width, height }, r.BLACK)
}
})
})
r.EndDrawing()
Клавиши
Заставить ключи работать на Raylib — это последнее, над чем я работала. Мне приходилось делать всё в методе IsKeyDown
— существовал метод GetKeyPressed
, но он имел побочные эффекты и вызывал проблемы. Поэтому, вместо того чтобы просто ждать нажатия клавиши, как в других реализациях, я должна была перебирать все клавиши и проверять, нажаты ли они, и, если так, добавлять их в битовую маску клавиши.
let keyDownIndices = 0
// Выполняем для всех клавиш
for (let i = 0; i < nativeKeyMap.length; i++) {
const currentKey = nativeKeyMap[i]
// Если клавиша нажата, добавляем индекс в отображение нажатых клавиш
// Также отожмёт все клавиши, которые не были нажаты
if (r.IsKeyDown(currentKey)) {
keyDownIndices |= 1 << i
}
}
// Устанавливаем нажатые клавиши
cpu.interface.setKeys(keyDownIndices)
Вот и всё. Эта задача сложнее остальных, но я рада, что сделала это, чтобы завершить интерфейс и посмотреть, насколько хорошо этот интерфейс работает на разных платформах.
Заключение
И вот мой проект «Chip-8». Вы можете посмотреть исходники на GitHub. Я узнала много нового о концепциях низкоуровневого программирования и о том, как работает процессор, а также о возможностях JavaScript за пределами браузерного приложения или сервера REST API. Мне ещё предстоит сделать несколько вещей, например попытаться написать простую игру, но эмулятор завершён, и я горжусь этим.
Продолжить изучение JS вы сможете на наших курсах:
Узнайте подробности здесь.
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также