Автор провёл детство за играми в эмуляторах 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
? Начну с инструкции, аналогичной примеру выше:
Инструкция | Описание |
---|---|
8xy4 |
ADD Vx, Vy |
Есть одно ключевое слово, 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
.
<canvas></canvas> <select> <option disabled selected>Load ROM...</option> <option value="CONNECT4">Connect4</option> <option value="PONG">Pong</option> </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 вы сможете на наших курсах:
Узнайте подробности здесь.
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также
ссылка на оригинал статьи https://habr.com/ru/articles/593525/
Добавить комментарий