用 JavaScript 編寫模擬器(Chip-8)
我童年的大部分時間都在我的電腦上玩模擬的 NES 和 SNES 遊戲,但我從未想過有一天我會自己編寫一個模擬器。然而,Vanya Sergeev 要求我編寫一個 Chip-8 解釋器來學習一些低級編程語言的基本概念以及 CPU 的工作原理,最終結果是我在他的指導下編寫的 JavaScript 中的 Chip-8 模擬器.
儘管在每一種可以想像到的編程語言中都有無數種 Chip-8 解釋器的實現,但這個有點獨特。我的 Chip8.js 代碼不僅與一種環境,而且與三種環境進行交互,以 Web 應用程序、CLI 應用程序和本機應用程序的形式存在。
您可以在此處查看 Web 應用演示和源代碼:
- 演示
- 源代碼
有很多關於如何製作 Chip-8 仿真器的指南,例如 Mastering Chip8,如何編寫仿真器,最重要的是,Cowgod 的 Chip-8 技術參考,我自己的仿真器使用的主要資源,還有一個網站,所以舊的它以 .HTM
結尾 .因此,這不是一個操作指南,而是我如何構建模擬器的概述,我學到了哪些主要概念,以及一些用於製作瀏覽器、CLI 或本機應用程序的 JavaScript 細節。
內容
- 什麼是 Chip-8
- Chip-8 解釋器包含什麼?
- 解碼 Chip-8 指令
- 讀取 ROM
- 指令周期 - 獲取、解碼、執行
- 為 I/O 創建 CPU 接口
- CLI 應用 - 與終端交互
- Web 應用程序 - 與瀏覽器交互
- 原生應用 - 與原生平台交互
什麼是 Chip-8?
在開始這個項目之前,我從未聽說過 Chip-8,所以我認為大多數人都沒有聽說過,除非他們已經進入了仿真器。 Chip-8 是一種非常簡單的解釋性編程語言,它是在 1970 年代為計算機愛好者開發的。人們編寫了基本的 Chip-8 程序來模仿當時流行的遊戲,例如 Pong、Tetris、Space Invaders,以及可能其他因時間流逝而消失的獨特遊戲。
玩這些遊戲的虛擬機實際上是一個 Chip-8 解釋器 ,在技術上不是一個模擬器 ,因為模擬器是模擬特定機器硬件的軟件,而 Chip-8 程序不特定於任何硬件。通常,Chip-8 解釋器用於圖形計算器。
儘管如此,它已經足夠接近於成為一個模擬器,它通常是任何想要學習如何構建模擬器的人的起始項目,因為它比創建 NES 模擬器或其他任何東西要簡單得多。它也是很多 CPU 概念(例如內存、堆棧和 I/O)的一個很好的起點,這些都是我在無限複雜的 JavaScript 運行時世界中每天處理的事情。
Chip-8 解釋器包含什麼?
因為我之前從未了解過計算機科學基礎知識,所以我必須做很多預習才能開始了解我正在使用的東西。所以我寫了Understanding Bits, Bytes, Bases, and Writing a Hex Dump in JavaScript,其中涵蓋了大部分內容。
總而言之,那篇文章有兩個主要內容:
- 位和字節 - 位是二進制數字 -
0
或1
,true
或false
,打開或關閉。八位是一個字節,是計算機處理信息的基本單位。 - 數基 - 十進制是我們最習慣處理的基數係統,但計算機通常使用二進制(基數 2)或十六進制(基數 16)。
1111
二進制,15
十進制,f
在十六進制中都是相同的數字。 - 小食 - 另外,4 bits 是一個 nibble,很可愛,在這個項目中我不得不稍微處理一下。
- 前綴 - 在 JavaScript 中,
0x
是十六進制數的前綴,0b
是二進制數的前綴。
我還編寫了一個 CLI 蛇遊戲,以準備了解如何在該項目的終端中使用像素。
一個CPU 是執行程序指令的計算機的主處理器。在這種情況下,它由各種狀態位(如下所述)和一個帶有獲取、解碼和執行的指令周期組成 步驟。
- 記憶
- 程序計數器
- 註冊
- 索引寄存器
- 堆棧
- 堆棧指針
- 按鍵輸入
- 圖形輸出
- 計時器
內存
Chip-8 最多可以訪問 4 KB 的內存 (內存)。 (即 0.002%
軟盤上的存儲空間。)CPU中的絕大多數數據都存儲在內存中。
4kb 是 4096
字節,並且 JavaScript 有一些有用的類型化數組,比如 Uint8Array,它是某個元素的固定大小的數組——在本例中是 8 位。
let memory = new Uint8Array(4096)
您可以從 memory[0]
像常規數組一樣訪問和使用此數組 到 memory[4095]
並將每個元素設置為不超過 255
的值 .任何高於該值的內容都會退回到該值(例如,memory[0] = 300
將導致 memory[0] === 255
)。
程序計數器
程序計數器將當前指令的地址存儲為 16 位整數 . Chip-8 中的每條指令都會在執行完後更新程序計數器(PC)以繼續執行下一條指令,以 PC 為索引訪問內存。
在 Chip-8 內存佈局中,0x000
到 0x1FF
in memory 是保留的,所以它從 0x200
開始 .
let PC = 0x200 // memory[PC] will access the address of the current instruvtion
*你會注意到內存數組是 8 位的,而 PC 是一個 16 位的整數,所以兩個程序代碼將組合成一個大端操作碼。 小>
寄存器
內存通常用於長期存儲和程序數據,因此寄存器作為一種“短期內存”存在,用於即時數據和計算。 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-bit
指向堆棧中某個位置的整數。即使堆棧是 16 位的,也只需 8 位,因為它只引用堆棧的索引,因此只需 0
徹底的15
.
let SP = -1
// stack[SP] will access the current return address in the stack
計時器
就聲音而言,Chip-8 能夠發出美妙的單聲嗶嗶聲。老實說,我並沒有費心實現“音樂”的實際輸出,儘管 CPU 本身都設置為與它正確接口。有兩個定時器,都是8位寄存器 - 用於決定何時發出嗶聲的聲音計時器 (ST) 和用於在整個遊戲中計時某些事件的延遲計時器 (DT)。它們以 60 Hz 的頻率倒計時 .
let DT = 0
let ST = 0
按鍵輸入
Chip-8 被設置為與驚人的十六進制鍵盤接口。它看起來像這樣:
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ C │
│ 4 │ 5 │ 6 │ D │
│ 7 │ 8 │ 9 │ E │
│ A │ 0 │ B │ F │
└───┴───┴───┴───┘
實際上,似乎只使用了幾個鍵,您可以將它們映射到您想要的任何 4x4 網格,但它們在遊戲之間非常不一致。
圖形輸出
Chip-8 使用單色 64x32
分辨率顯示。每個像素要么開啟,要么關閉。
可以保存在內存中的精靈是8x15
- 寬 8 像素,高 15 像素。 Chip-8 也自帶字體集,但它只包含十六進制鍵盤中的字符,所以不是總體上最有用的字體集。
CPU
把它們放在一起,你就得到了 CPU 狀態。
CPUclass 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
是 opcode
和 x
, y
是操作數。這種類型的語言稱為彙編語言。該指令將映射到:
x = x + y
使用這個指令集,我必須將這些數據存儲在 16 位中,所以每個指令最終都是來自 0x0000
的數字 到 0xffff
.這些集合中的每個數字位置都是一個半字節(4 位)。
那麼我怎樣才能從 nnnn
類似於 ADD x, y
,這樣比較好理解?好,我先看Chip-8的其中一條指令,和上面的例子基本一樣:
說明 | 說明 |
---|---|
8xy4 | ADD Vx, Vy |
那麼我們在這里處理什麼?有一個關鍵字,ADD
,和兩個參數,Vx
和 Vy
,我們上面建立的就是寄存器。
操作碼助記詞有幾種(類似於關鍵字),如:
ADD
(添加)SUB
(減)JP
(跳)SKP
(跳過)RET
(返回)LD
(加載)
並且有幾種類型的操作數值,例如:
- 地址(
I
) - 註冊(
Vx
,Vy
) - 常數(
N
或NN
半字節或字節)
下一步是想辦法將 16 位操作碼解釋為這些更易於理解的指令。
位掩碼
每條指令都包含一個始終相同的模式和可以更改的變量。對於 8xy4
,模式為 8__4
.中間的兩個半字節是變量。通過為該模式創建位掩碼,我可以確定指令。
要屏蔽,請使用按位與 (&
) 帶有掩碼並將其與模式匹配。所以如果指令 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
// right shifting by 8 (>> 8) will remove 8 zeroes from the right
// This leaves us with 1
const y = (0x8124 & 0x00f0) >> 4 // 2
// (0x8124 & 0x00f0) is 100000 in binary
// right shifting by 4 (>> 4) will remove 4 zeroes from the right
// This leaves us with 10, the binary equivalent of 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) {
// Find the instruction from the opcode
const instruction = INSTRUCTION_SET.find(
(instruction) => (opcode & instruction.mask) === instruction.pattern
)
// Find the argument(s)
const args = instruction.arguments.map((arg) => (opcode & arg.mask) >> arg.shift)
// Return an object containing the instruction data and arguments
return { instruction, args }
}
讀取ROM
由於我們將此項目視為仿真器,因此每個 Chip-8 程序文件都可以視為一個 ROM。 ROM 只是二進制數據,我們正在編寫程序來解釋它。我們可以把Chip8 CPU想像成一個虛擬控制台,把Chip-8 ROM想像成一個虛擬遊戲卡。
ROM 緩衝區將獲取原始二進製文件並將其轉換為 16 位大端字(字是由一定數量的位組成的數據單元)。這是十六進制轉儲文章派上用場的地方。我正在收集二進制數據並將其轉換為我可以使用的塊,在本例中為 16 位操作碼。大端意味著最高有效字節將在緩衝區的第一個,所以當它遇到兩個字節時 12 34
,它將創建一個 1234
16 位代碼。小端代碼看起來像 3412
.
class RomBuffer {
/**
* @param {binary} fileContents ROM binary
*/
constructor(fileContents) {
this.data = []
// Read the raw data buffer from the file
const buffer = fileContents
// Create 16-bit big endian opcodes from the buffer
for (let i = 0; i < buffer.length; i += 2) {
this.data.push((buffer[i] << 8) | (buffer[i + 1] << 0))
}
}
}
從這個緩衝區返回的數據就是“遊戲”。
CPU 將有一個 load()
方法 - 就像將磁帶加載到控制台中 - 將從該緩衝區中獲取數據並將其放入內存中。緩衝區和內存在 JavaScript 中都充當數組,因此加載內存只是循環緩衝區並將字節放入內存數組的問題。
指令周期——獲取、解碼、執行
現在我已經準備好解釋指令集和遊戲數據了。 CPU 只需要對它做點什麼。指令周期由三個步驟組成——獲取、解碼和執行。
- 獲取 - 使用程序計數器獲取存儲在內存中的數據
- 解碼 - 反彙編 16 位操作碼,得到解碼後的指令和參數值
- 執行 - 根據解碼指令執行操作並更新程序計數器
這是代碼中如何加載、獲取、解碼和執行工作的精簡和簡化版本。這些 CPU 週期方法是私有的,沒有公開。
第一步,fetch
, 將從內存中訪問當前的操作碼。
// Get address value from memory
function fetch() {
return memory[PC]
}
下一步,decode
,會將操作碼反彙編成我之前創建的更易於理解的指令集。
// Decode instruction
function decode(opcode) {
return disassemble(opcode)
}
最後一步,execute
,將包含一個以所有 36 條指令為案例的開關,並對找到的指令執行相關操作,然後更新程序計數器,以便下一個取指周期找到下一個操作碼。任何錯誤處理也會在這裡進行,這將停止 CPU。
// Execute instruction
function execute(instruction) {
const { id, args } = instruction
switch (id) {
case 'ADD_VX_VY':
// Perform the instruction operation
registers[args[0]] += registers[args[1]]
// Update program counter to next instruction
PC = PC + 2
break
case 'SUB_VX_VY':
// etc...
}
}
我最終得到的是 CPU,具有所有狀態和指令周期。 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 buffer into memory
load(romBuffer) {
this.reset()
romBuffer.forEach((opcode, i) => {
this.memory[i] = opcode
})
}
// Step through each instruction
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
}
}
}
現在只缺少該項目的一個方面,並且非常重要的一個方面 - 實際玩和觀看遊戲的能力。
為 I/O 創建 CPU 接口
所以現在我有這個 CPU 可以解釋和執行指令並更新它自己的所有狀態,但我還不能用它做任何事情。為了玩遊戲,您必須看到它並能夠與之互動。
這就是輸入/輸出或 I/O 的用武之地。I/O 是 CPU 與外部世界之間的通信。
- 輸入 是CPU接收到的數據
- 輸出 是從 CPU 發出的數據
所以對我來說,輸入是通過鍵盤,輸出是圖形到屏幕上。
我可以直接將 I/O 代碼與 CPU 混合,但隨後我將被綁定到一個環境。通過創建通用 CPU 接口來連接 I/O 和 CPU,我可以與任何系統進行交互。
首先要做的是查看說明並找到與 I/O 相關的任何內容。這些指令的幾個例子:
CLS
- 清除屏幕LD Vx, K
- 等待按鍵,將按鍵的值存儲在 Vx 中。DRW Vx, Vy, nibble
- 從內存位置 I 開始顯示 n 字節精靈
基於此,我們希望接口具有如下方法:
clearDisplay()
waitKey()
drawPixel()
(drawSprite
本來應該是 1:1,但最終會更容易從界面逐個像素地進行操作)
據我所知,JavaScript 並沒有真正的抽像類的概念,但我通過創建一個本身無法實例化的類來創建一個抽像類,其方法只能從擴展它的類中使用。以下是該類的所有接口方法:
CpuInterface.js// Abstract CPU interface class
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 {
// Initialize the interface
constructor(cpuInterface) {
this.interface = cpuInterface
}
_execute(instruction) {
const { id, args } = instruction
switch (id) {
case 'CLS':
// Use the interface while executing an instruction
this.interface.clearDisplay()
}
}
在使用任何真實環境(Web、終端或本機)設置接口之前,我創建了一個模擬接口用於測試。它實際上並沒有連接到任何 I/O,但它幫助我設置了接口的狀態並為真實數據做好了準備。我會忽略聲音的,因為這從未通過實際的揚聲器輸出實現,所以只剩下鍵盤和屏幕了。
屏幕
屏幕的分辨率為 64 像素寬 x 32 像素高。因此,就 CPU 和接口而言,它是一個 64x32 的位網格,可以打開或關閉。要設置一個空屏幕,我可以製作一個 3D 零數組來表示所有像素都關閉。幀緩衝區是內存的一部分,其中包含將被渲染到顯示器的位圖圖像。
MockCpuInterface.js// Interface for testing
class MockCpuInterface extends CpuInterface {
constructor() {
super()
// Store the screen data in the frame buffer
this.frameBuffer = this.createFrameBuffer()
}
// Create 3D array of zeroes
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
}
// Update a single pixel with a value (0 or 1)
drawPixel(x, y, value) {
this.frameBuffer[y][x] ^= value
}
}
所以我最終得到了這樣的東西來表示屏幕(將其打印為換行符分隔的字符串時):
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
...etc...
在 DRW
函數,CPU 將循環遍歷它從內存中提取的精靈並更新精靈中的每個像素(為簡潔起見,省略了一些細節)。
case 'DRW_VX_VY_N':
// The interpreter reads n bytes from memory, starting at the address stored in I
for (let i = 0; i < args[2]; i++) {
let line = this.memory[this.I + i]
// Each byte is a line of eight pixels
for (let position = 0; position < 8; position++) {
// ...Get value, x, and y...
this.interface.drawPixel(x, y, value)
}
}
clearDisplay()
函數是用於與屏幕交互的唯一其他方法。這就是與屏幕交互所需的所有 CPU 接口。
鍵
對於按鍵,我將原來的十六進制鍵盤映射到以下 4x4 按鍵網格:
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │
│ Q │ W │ E │ R │
│ A │ S │ D │ F │
│ Z │ X │ C │ V │
└───┴───┴───┴───┘
我把鍵放在一個數組中。
// prettier-ignore
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 只想隨時知道 16 個按鍵中按下了哪些按鍵,並據此做出決定。下面舉幾個例子:
0b1000000000000000 // V is pressed (keyMap[15], or index 15)
0b0000000000000011 // 1 and 2 are pressed (index 0, 1)
0b0000000000110000 // Q and W are pressed (index 4, 5)
現在,例如,V
被按下 (keyMap[15]
) 並且操作數是 0xf
(十進制15
),按鍵被按下。左移(<<
) 與 1
將使用 1
創建一個二進制數 後跟與左移一樣多的零。
case 'SKP_VX':
// Skip next instruction if key with the value of Vx is pressed
if (this.interface.getKeys() & (1 << this.registers[args[0]])) {
// Skip instruction
} else {
// Go to next instruction
}
還有另一種關鍵方法,waitKey
,其中的指令是等待按鍵並在按鍵後返回。
CLI 應用 - 與終端交互
我製作的第一個界面是用於終端的。這對我來說不像使用 DOM 那樣熟悉,因為我從未在終端中製作過任何類型的圖形應用程序,但這並不太難。
Curses 是一個用於在終端中創建文本用戶界面的庫。 Blessed 是一個為 Node.js 包裝 curses 的庫。
屏幕
包含屏幕數據位圖的幀緩衝區對於所有實現都是相同的,但屏幕與每個環境的接口方式會有所不同。
使用 blessed
,我剛剛定義了一個屏幕對象:
this.screen = blessed.screen({ smartCSR: true })
並使用了 fillRegion
或 clearRegion
在像素上用一個完整的 unicode 塊來填充它,使用 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 的期望沒有太大不同。如果按下某個鍵,處理程序會傳遞該鍵,然後我可以使用它來查找索引並使用已按下的任何新的附加鍵更新鍵對象。
this.screen.on('keypress', (_, key) => {
const keyIndex = keyMap.indexOf(key.full)
if (keyIndex) {
this._setKeys(keyIndex)
}
})
唯一特別奇怪的是 blessed
沒有任何我可以使用的 keyup 事件,所以我只能通過設置一個定期清除鍵的間隔來模擬一個。
setInterval(() => {
// Emulate a keyup event to clear all pressed keys
this._resetKeys()
}, 100)
入口點
現在一切都設置好了 - 將二進制數據轉換為操作碼的 rom 緩衝區、連接 I/O 的接口、包含狀態的 CPU、指令周期和兩種公開的方法 - 一種用於加載遊戲,一種用於單步執行一個循環。所以我創建了一個 cycle
函數將在無限循環中運行 CPU 指令。
const fs = require('fs')
const { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const { TerminalCpuInterface } = require('../classes/interfaces/TerminalCpuInterface')
// Retrieve the ROM file
const fileContents = fs.readFileSync(process.argv.slice(2)[0])
// Initialize the terminal interface
const cpuInterface = new TerminalCpuInterface()
// Initialize the CPU with the interface
const cpu = new CPU(cpuInterface)
// Convert the binary code into opcodes
const romBuffer = new RomBuffer(fileContents)
// Load the game
cpu.load(romBuffer)
function cycle() {
cpu.step()
setTimeout(cycle, 3)
}
cycle()
循環函數中還有一個延遲計時器,但為了清楚起見,我從示例中刪除了它。 小>
現在我可以運行終端入口點文件的腳本並傳遞一個 ROM 作為參數來玩遊戲。
npm run play:terminal roms/PONG
Web App - 與瀏覽器交互
我製作的下一個界面是用於 Web 的,與瀏覽器和 DOM 進行通信。我讓這個版本的模擬器更加花哨,因為瀏覽器更像是我熟悉的環境,我無法抗拒製作復古網站的衝動。這個還可以讓你在遊戲之間切換。
屏幕
對於屏幕,我使用了 Canvas API,它使用 CanvasRenderingContext2D 作為繪圖表面。使用 fillRect
with canvas 與 fillRegion
基本相同 祝福。
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 事件而無需任何黑客攻擊。
// Set keys on key down
document.addEventListener('keydown', event => {
const keyIndex = keyMap.indexOf(event.key)
if (keyIndex) {
this._setKeys(keyIndex)
}
})
// Reset keys on keyup
document.addEventListener('keyup', event => {
this._resetKeys()
})
}
入口點
我通過導入所有模塊並將它們設置為全局對象來處理模塊的工作,然後使用 Browserify 在瀏覽器中使用它們。將它們設置為全局使它們在窗口中可用,因此我可以在瀏覽器腳本中使用代碼輸出。現在我可能會為此使用 Webpack 或其他東西,但它既快速又簡單。
web.jsconst { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const { WebCpuInterface } = require('../classes/interfaces/WebCpuInterface')
const cpuInterface = new WebCpuInterface()
const cpu = new CPU(cpuInterface)
// Set CPU and Rom Buffer to the global object, which will become window in the
// browser after bundling.
global.cpu = cpu
global.RomBuffer = RomBuffer
Web 入口點使用相同的 cycle
功能作為終端實現,但具有獲取每個ROM並在每次選擇新ROM時重置顯示的功能。我習慣於使用 json 數據和獲取,但在這種情況下,我獲取了原始 arrayBuffer
來自響應。
// Fetch the ROM and load the game
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)
}
// Add the ability to select a game
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 頁面上,因為它是靜態的。
Native App - 與 Native Platform 交互
我還做了一個實驗性的原生 UI 實現。我使用了 Raylib,這是一個編程庫,用於編寫具有 Node.js 綁定的簡單遊戲。
我認為這個版本是實驗性的,因為它與其他版本相比真的很慢,所以它的可用性較低,但所有的按鍵和屏幕都可以正常工作。
入口點
Raylib 的工作方式與其他實現有點不同,因為 Raylib 本身在循環中運行,這意味著我最終不會使用 cycle
功能。
const r = require('raylib')
// As long as the window shouldn't close...
while (!r.WindowShouldClose()) {
// Fetch, decode, execute
cpu.step()
r.BeginDrawing()
// Paint screen with amy changes
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
// Run through all possible keys
for (let i = 0; i < nativeKeyMap.length; i++) {
const currentKey = nativeKeyMap[i]
// If key is already down, add index to key down map
// This will also lift up any keys that aren't pressed
if (r.IsKeyDown(currentKey)) {
keyDownIndices |= 1 << i
}
}
// Set all pressed keys
cpu.interface.setKeys(keyDownIndices)
這就是本機實現。這比其他挑戰更具挑戰性,但我很高興我這樣做是為了完善界面,看看它在截然不同的平台上的表現如何。
結論
這就是我的 Chip-8 項目!再次,您可以在 GitHub 上查看源代碼。我學到了很多關於低級編程概念和 CPU 運行方式的知識,以及瀏覽器應用程序或 REST API 服務器之外的 JavaScript 功能。在這個項目中我還有一些事情要做,比如嘗試製作一個(非常)簡單的遊戲,但是模擬器已經完成了,我很自豪能夠完成它。