用 JavaScript 自制 GameBoy 模拟器(上)
近期有些百无聊赖。因为工作,先前的个人项目全都不得不暂时搁置。为了找点乐子,我决定做一些与全栈开发不同的事情 (主要是最近玩宝可梦玩的有点多)。
这期的文章参考了 Imre Nazar 在 2010 年写的一系列关于用 JavaScript 实现 GameBoy 模拟器的教程。虽然这个教程有些年头了,但还是提供了一个很好的起点和思路。自然,这期文章不会直接去 Ctrl C + V 他的实现,而是会使用更现代的 ES6 语法。
前言
要模拟一台 GameBoy,我们至少需要模拟以下核心组件:
- CPU(Z80 兼容处理器),也就是“大脑”,负责执行游戏代码
- 内存管理单元(MMU),用于处理内存的读写,包括 ROM、RAM 和各种硬件寄存器
- 图形处理单元(PPU),负责渲染游戏画面
- 输入设备,处理用户按键操作
- 定时器,提供精确的时间控制
- 声音处理单元,生成游戏音效和音乐
1. 模拟 Z80 CPU
GameBoy 的 CPU 是一个修改过的 Zilog Z80 处理器。要模拟它,就得理解它的工作方式。
核心概念很简单:取指 → 解码 → 执行循环。
- 取指:从内存中获取下一条指令
- 解码:解析指令的含义
- 执行:执行指令指定的操作
这个循环在 GameBoy 上电后就开始运行,直到关机。为了跟踪程序执行到哪里,CPU 会使用一个特殊的寄存器——程序计数器。每当一条指令被取出后,程序计数器就会根据指令的长度向前移动,指向下一条要执行的指令。
Z80 CPU 是一个 8 位芯片,意味着它一次可以处理一个字节的数据。它也能访问多达 6,5536 字节的内存空间,程序代码和普通数据都被存储在同一个内存地址空间中,而一条指令的长度可以在 1 到 3 个字节之间。
除了程序计数器,Z80 CPU 还有一组内存寄存器,用于存储数据和执行计算:
- 8 位通用寄存器(A、B、C、D、E、H、L),每个可以存储一个字节(0 到 255)的值。大多数 Z80 指令都围绕着操作这些寄存器,例如将内存中的值加载到寄存器中,或者对寄存器中的值进行算术运算
- 标志寄存器(F)是一个特殊的 8 位寄存器,其中每个位都代表一个“标志”,用于存储上一次运算的结果状态
- 栈指针(SP)是一个 16 位寄存器,用于指向内存中的“栈顶”位置
- 栈是一种后进先出的数据结构
CPU 是什么?(用大白话说)
不要被“中央处理器”这个名字吓到。
把 CPU 想象成一个超快的计算器工人:
- 他有一张小纸条(寄存器),记录当前的数字
- 他有一本操作手册(指令集),告诉他怎么计算
- 他一次只能做一件事,但做得很快
假设有一个指令是“把 A 和 B 加起来”,那么对于这个工人而言就是:
- 看看 A 纸条(3)
- 看看 B 纸条(5)
- 算出结果(8)
- 写到 A 纸条上
基本上就是一个只会加减乘除但超级勤快的员工
基于以上理解,我们的 Z80 CPU 模拟器需要包含以下核心组件:
- 内部状态,需要保存所有寄存器的当前值、执行上一条指令所花费的时间,以及 CPU 总共运行了多长时间
- 指令模拟函数
- 指令映射表
- 内存接口
1.1. CPU 骨架
下面是我们 GameBoyCPU
类的初步骨架,包含了 CPU 的时钟系统和所有重要的寄存器:
1 | /** |
CPU 的时钟系统是用来精确跟踪模拟时间的,m
代表机器周期,t
代表时钟周期,两者之间存在固定关系。
1.2. 标志寄存器与基本指令
标志寄存器是 CPU 运算中一个非常关键的部分,它会根据上一条指令的执行结果自动设置某些位。在 GameBoy 的 Z80 CPU 中,有四个重要的标志位:
- 零标志(
0x80
):如果上一次运算的结果为 0,则设置此位 - 减法标志(
0x40
):如果上一次运算是减法操作,则设置此位 - 半进位标志(
0x20
):如果上一次运算在字节的低 4 位发生了溢出(即结果的第 3 位向第 4 位进位),则设置此位 - 进位标志(
0x10
):如果上一次加法运算结果超过 255(8 位最大值),或者减法运算结果低于 0(发生借位),则设置此位
看不懂的话……
想象你刚做完一道数学题,你的大脑会自动记住一些“状态”:
- “咦,答案是 0?” → 零标志
- “我刚才是在做减法吗?” → 减法标志
- “有没有进位?” → 进位标志
GameBoy 的 CPU 也是如此。每次计算完,它都会在标志寄存器里记下这些“感想”。
为什么需要这些标志?因为后面的指令可能会问:“上次计算结果是 0 吗?如果是的话,跳转到别的地方。”
为了更好地管理这些标志位,我们首先定义一个常量对象 CPU_FLAGS
:
1 | /** |
接着,我们为 GameBoyCPU
类添加 reset
、setFlag
和 getFlag
方法:
reset
方法用于将 CPU 的所有寄存器和时钟状态复位到初始值,这对于模拟器启动或重新加载游戏非常有用setFlag
和getFlag
则分别用于设置和检查标志位,方便我们根据运算结果来操作标志寄存器f
1 | // ... 之前的 constructor ... |
为了演示 CPU 如何执行指令并影响寄存器和标志位,我们来模拟几个基本的 Z80 指令:
-
ADD A, E
(加法指令):将寄存器e
的值加到寄存器a
中,结果存回a
。这个函数需要更新a
寄存器的值,并根据结果设置零标志和进位标志。
注意,我们将结果限制在 8 位范围内(&= 255
),并更新指令执行所花费的机器周期m
和时钟周期t
。cpu.jsjavascript1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/**
* 将 E 寄存器的值加到 A 寄存器
* 算术指令用:ADD A, E
*/
addRegisterE() {
this.registers.a += this.registers.e;
// 清除所有标志位并准备设置新的
this.registers.f = 0;
// 检查零标志和进位标志
this.setFlag(CPU_FLAGS.ZERO, !(this.registers.a & 255)); // 结果为0则设置零标志
this.setFlag(CPU_FLAGS.CARRY, this.registers.a > 255); // 结果溢出255则设置进位标志
// 将结果限制为 8 位
this.registers.a &= 255;
// 设置指令执行时间(1 机器周期,4 时钟周期)
this.registers.m = 1;
this.registers.t = 4;
} -
CP A, B
(比较指令):将寄存器a
的值与寄存器b
的值进行比较。这个指令实际上是执行a - b
的操作,但不保存结果,只根据结果设置标志位。我们需要设置减法标志,并根据比较结果设置零标志(如果a == b
)和进位标志(如果a < b
)。cpu.jsjavascript1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/**
* 比较 B 寄存器和 A 寄存器
* 用于条件判断和循环控制:CP A, B
*/
compareRegisterB() {
// 创建 A 的副本并模拟 A - B
let result = this.registers.a;
result -= this.registers.b;
// 设置减法标志
this.registers.f = CPU_FLAGS.OPERATION;
// 检查 A 是否等于 B(结果为 0)以及 A 是否小于 B(结果为负,产生借位)
this.setFlag(CPU_FLAGS.ZERO, !(result & 255)); // 结果为0则设置零标志
this.setFlag(CPU_FLAGS.CARRY, result < 0); // 结果为负(下溢)则设置进位标志
// 设置指令执行时间(1 机器周期,4 时钟周期)
this.registers.m = 1;
this.registers.t = 4;
} -
NOP(无操作指令):这个指令不做任何事情,仅仅消耗 CPU 周期,用来延时或指令对齐。
cpu.jsjavascript1
2
3
4
5
6
7
8/**
* 无操作指令,不执行任何操作只是浪费时间
* 延时或指令对齐用:NOP
*/
noOperation() {
this.registers.m = 1;
this.registers.t = 4;
} -
最后弄个调试工具,要打印出当前所有寄存器的十六进制值、标志位的状态以及时钟技术。
cpu.jsjavascript1
2
3
4
5
6
7
8
9
10
11
12/**
* 获取当前 CPU 状态的字符串表示,用于调试
*/
getStatusString() {
const r = this.registers;
return `CPU状态:
寄存器: A=${r.a.toString(16).padStart(2,'0')} B=${r.b.toString(16).padStart(2,'0')} C=${r.c.toString(16).padStart(2,'0')}
D=${r.d.toString(16).padStart(2,'0')} E=${r.e.toString(16).padStart(2,'0')} H=${r.h.toString(16).padStart(2,'0')} L=${r.l.toString(16).padStart(2,'0')}
标志位: F=${r.f.toString(16).padStart(2,'0')} [Z:${this.getFlag(CPU_FLAGS.ZERO)?1:0} N:${this.getFlag(CPU_FLAGS.OPERATION)?1:0} H:${this.getFlag(CPU_FLAGS.HALF_CARRY)?1:0} C:${this.getFlag(CPU_FLAGS.CARRY)?1:0}]
PC=${r.pc.toString(16).padStart(4,'0')} SP=${r.sp.toString(16).padStart(4,'0')}
时钟: M=${this.clock.m} T=${this.clock.t}`;
}
1.3. 完整指令集与执行循环
在前面我们实现了几个示例指令,但真正的 GameBoy CPU 需要支持完整的 Z80 指令集。现代模拟器的核心是建立一个高效的“取值 → 解码 → 执行”循环,这个循环每秒要执行数百万次。
1 | /** |
指令映射是现代模拟器的核心技术。我们创建了一个 256 元素的数组,每个元素对应一个操作码(opcode),直接指向对应的函数。这避免了复杂的 switch-case 语句,大幅提升执行效率:
1 | /** |
CPU 执行的核心是 step()
方法。每次调用它会执行一条指令,这个方法每秒会被调用数百万次:
1 | /** |
为了简化指令实现,我们提供了一组辅助方法来处理常见的寄存器操作:
1 | /** |
1.4. 导出
在文件的最下方、GameBoyCPU
类的外部添加导出代码:
1 | if (typeof module !== 'undefined' && module.exports) { |
2. 模拟内存管理单元
我们的 GameBoy CPU 骨架虽然能够执行指令并管理内存寄存器,但是一个没有内存的 CPU 就跟一个没有书的图书馆差不多,光有管理员也没啥用。CPU 必需能够与外部内存交互,才能读取程序代码、存取数据,并与各种硬件组件通信。
这就是内存管理单元(MMU)的作用。MMU 负责管理 GameBoy 的整个 64KB 的地址空间,并将 CPU 的内存访问请求路由到正确的物理内存区域。
为什么需要 MMU?(大白话+1)
想象你在管理一个大仓库,有多大呢?GameBoy 的 64KB 内存相当于有 65536 个格子,你的大仓库也有这 65536 个格子:
- 有些格子放游戏程序(ROM)
- 有些格子放临时数据(RAM)
- 有些格子放图片数据(VRAM)
- 有些格子是控制按钮(I/O 寄存器)
CPU 依然是那个工人。假设他想要
0x8000
地址的数据。如果没有 MMU 的话,CPU 就得自己跑到
0x8000
格子、搞清楚这个格子到底存的是什么、自行处理各种复杂情况。有了 MMU 的话,CPU 只需要说他要
0x8000
的数据,MMU 就会:
- “
0x8000
是图片数据区域。”- “这个区域的数据在 VRAM 里。”
- “给你!”
这样看,MMU 就像是一个很靠谱的仓库管理员。
2.1. GameBoy 内存映射
与现代计算机复杂的内存管理不同,GameBoy 的内存映射相对直观,但仍然包含了多个不同功能的区域。理解这个内存映射对于正确模拟 GameBoy 至关重要。
GameBoy 的 64KB 地址空间被划分为以下主要区域:
0x0000 - 0x3FFF
:卡带 ROM 银行 0
这是游戏卡带程序的第一个 16KB 区域,始终可访问。0x0000 - 0x00FF
:BIOS:GameBoy 启动时,CPU 从0x0000
地址开始执行 BIOS 代码。一旦 BIOS 运行完毕,这片区域就会被卡带 ROM 覆盖,不再可访问。0x0100 - 0x014F
:卡带头部:这部分包含游戏的名称、制造商、ROM/RAM 大小等关键信息。
0x4000 - 0x7FFF
:卡带 ROM 其他银行
对于大于 32KB 的游戏,卡带会包含多个 16KB 的 ROM 银行。MMU 需要通过内存银行控制器(MBC)来切换这些银行,以便 CPU 能够访问整个游戏程序。对于 32KB 或更小的游戏,这个区域也直接是 ROM 的一部分,无需银行切换。0x8000 - 0x9FFF
:视频 RAM
存储用于渲染游戏背景和精灵图形数据。这部分内存对 CPU 可读写,但主要由图形处理单元(GPU)使用。0xA000 - 0xBFFF
:卡带外部 RAM
部分游戏卡带会包含额外的可读写内存,用于保存游戏进度或临时数据。0xC000 - 0xDFFF
:工作 RAM
这是 GameBoy 内部的主工作内存,CPU 可以自由读写,用于存储程序变量、栈等。0xE000 - 0xFDFF
:工作 RAM 镜像
由于硬件接线的设计,工作 RAM 在内存映射中有一个完全相同的镜像区域。这意味着对0xE000-0xFDFF
的读写实际上是对0xC000-0xDFFF
的读写。0xFE00 - 0xFE9F
:精灵属性表
存储屏幕上所有精灵(例如角色、敌人)的位置、大小、图形数据索引等属性信息。0xFF00 - 0xFF7F
:内存映射 I/O 寄存器
这个区域包含了控制 GameBoy 各个硬件子系统(如图形、声音、定时器、输入)的寄存器。CPU 通过读写这些地址来控制硬件行为。0xFF80 - 0xFFFF
:零页 RAM
又称“高速 RAM”,这片区域是 GameBoy 最顶部的内存,CPU 访问它们的速度最快。虽然地址很高,但因其在汇编编程中的常用性,常被称为“零页”。
太复杂了看不懂、一图胜千言版本:
GameBoy 的 64KB 内存就像一栋 8 层楼的公寓:
🏢 GameBoy 内存公寓(64KB)
├── 8 楼(0xFF00-0xFFFF
)控制中心(按钮、音量调节等)
├── 7 楼(0xFE00-0xFEFF
)精灵属性(角色信息)
├── 6 楼(0xE000-0xFDFF
)工作区镜像(楼下的复印件)
├── 5 楼(0xC000-0xDFFF
)工作区(临时文件柜)
├── 4 楼(0xA000-0xBFFF
)卡带存档(游戏进度)
├── 3 楼(0x8000-0x9FFF
)图片仓库(所有图像数据)
├── 2 楼(0x4000-0x7FFF
)游戏程序(第 2 部分)
└── 1 楼(0x0000-0x3FFF
)游戏程序(第 1 部分)
└── 地下室(0x0000-0x00FF
)开机程序(BIOS)每次 CPU 说他想要
0x8000
的数据,MMU 就知道:“哦,你要 3 楼图片仓库的东西。”
为了在代码中清晰地表示这些区域,我们定义一个 MEMORY_REGIONS
常量对象:
1 | /** |
2.2. MMU 类结构与初始化
我们的 GameBoyMMU
类会负责创建和管理这些内存区域的实际存储(用 Unit8Array
),并提供读写内存的接口。
在构造函数里,首先要调用 initializeMemoryRegions
来分配各个内存区域的存储空间,然后调用 reset
方法将它们清空并设置初始状态。
1 | /** |
2.3. 内存读取
MMU 最核心的功能就是根据 CPU 请求的地址,将其路由到正确的内存区域并返回数据。readByte(address)
方法实现了这一逻辑:它根据 16 位地址的不同范围,返回对应 Uint8Array
中的字节。
这里需要特别注意 BIOS 区域的逻辑:当 CPU 程序计数器 PC
达到 0x0100
时,表明 BIOS 已经执行完毕,此时我们会禁用 BIOS 映射(this.biosEnabled = false;
),让该地址范围(0x0000-0x00FF
)切换到显示卡带 ROM。
1 | // ... 之前写的方法 ... |
2.4. 内存写入
内存写入的逻辑与读取类似,writeByte(address, value)
方法根据地址将数据写入对应的内存区域。需要注意的是,ROM 区域通常是只读的,任何写入操作都会被忽略(除了用于内存银行切换的特殊地址,这将在后面讨论)。
1 | // ... 之前写的方法 ... |
2.5. 加载 ROM 与 BIOS
为了让模拟器能够运行游戏,MMU 还需要加载 BIOS 和游戏 ROM 的功能:
loadBIOS(biosData)
: 加载 GameBoy 的 BIOS 文件- BIOS 是启动时运行的一小段程序,通常用于初始化硬件和显示任天堂的标志
loadROM(romData)
: 加载游戏卡带的 ROM 数据。这个方法会将 ROM 数据复制到 MMU 内部的this.rom
数组中loadROMFromURL(url)
: 直接从给定的 URL 下载 ROM 文件并加载
displayCartridgeInfo()
方法则会从 ROM 的卡带头部读取并打印出游戏的标题、类型、ROM 大小和 RAM 大小等信息,这对于验证 ROM 是否正确加载非常有用。
1 | // ... 之前写的方法 ... |
2.6. MMU 与 CPU 的连接
现在我们有了 GameBoyCPU
和 GameBoyMMU
两个类。CPU 需要一个方式来与 MMU 交互,才能真正实现“取指”和“读写内存”的功能。最简单的方式就是将 MMU 实例作为参数传递给 CPU,或者让 CPU 拥有一个 MMU 的引用。
不过也别忘了在 mmu.js
中导出 GameBoyMMU
类和常量:
1 | if (typeof module !== 'undefined' && module.exports) { |
然后在 GameBoyCPU
类中,我们可以添加一个对 MMU 的引用:
1 | class GameBoyCPU { |
这样,CPU 就可以通过 this.mmu.readByte()
、this.mmu.writeByte()
等方法来访问内存了。
3. GPU 时序:让 GameBoy 屏幕动起来
先前我们已经构建了 GameBoy 模拟器的 CPU 骨架和 MMU,让模拟器能够加载游戏 ROM 并开始执行指令。但是一个没有图像输出的模拟器是没有灵魂的!现在我们得引入 GameBoy 的主要输出设备——图形处理单元也就是我们经常说到的 GPU 了。
GameBoy 的官方内部名称是“点阵式游戏机”(Dot Matrix Game),这是因为它靠着一块 160x144 像素的单色 LCD 屏幕来显示内容。为了模拟这个屏幕,我们可以在 Web 页面中使用一个 HTML5 <canvas>
元素。这个 Canvas 将作为我们的“帧缓冲区”,其中每个像素的颜色都可以被直接操作。
3.1. 模拟屏幕与帧缓冲区
要将 GameBoy 的图形输出呈现在网页上,最直接的方法就是创建一个与 GameBoy 屏幕尺寸相同的 Canvas。我们可以通过 Canvas 的 2D 渲染上下文来操作它的像素数据。
GameBoy 的显示分辨率是 160 像素宽和 140 像素高。Canvas 的像素数据通常以 RGBA(红、绿、蓝、透明度)的 4 字节序列存储。这意味着每个像素需要 4 个字节来表示其颜色。我们可以通过 getImageData
或 createImageData
方法获取或创建这个帧缓冲区。
首先,在 HTML 文件中添加一个 Canvas 元素:
1 | <canvas id="gameboy-screen" width="160" height="144"></canvas> |
在 GameBoyGPU
类中,我们将负责初始化这个 Canvas 并创建帧缓冲区。我们还设置了 imageSmoothingEnabled = false
和 image-rendering: pixelated
样式,以确保图像在放大时保持像素艺术的清晰度,而不是变得模糊。
1 | // ... 其他常量定义,之后写 ... |
现在,我们有一个可以操作的帧缓冲区。通过修改 this.frameBuffer.data
数组中的 RGBA 值,我们可以改变屏幕上任何像素的颜色。修改完成后,调用 this.context.putImageData(this.frameBuffer, 0, 0)
就可以将更新后的帧缓冲区内容绘制到 Canvas 上。
3.2. 栅格图形与显示时序
GPU 工作原理:
我们可以看一下老式电视是如何显示画面的:
plaintext
1
2
3
4
5 电子枪从左到右扫描 →→→→→→→→→→→(一行画完)
然后跳到下一行 ↓
再从左到右扫描 →→→→→→→→→→→(下一行)
...重复 144次 ...
最后回到顶部 ↑↑↑↑↑↑↑↑↑(准备下一帧)GameBoy 的 GPU 完全模仿了这个过程。
1989 年的硬件很慢,一次只能处理一行像素,这样做也能省电省内存。当年的程序员很聪明,用最少的资源做最多的事。
GameBoy 的显示硬件模拟了阴极射线管(CRT)的工作方式。在 CRT 显示器中,电子束逐行扫描屏幕,并在扫描完成后返回到屏幕顶部。这个扫描过程不是瞬间完成的,它包含了消隐期:
- 水平消隐:电子束从一行的末尾移动到下一行的开头所需的时间
- 垂直消隐:在一帧结束后,电子束从屏幕底部回到屏幕左上角所需的时间。垂直消隐期通常比水平消隐期长得多,因为它需要移动更远的距离
GameBoy 的显示器也遵循类似的模式,并将其工作周期细分为四个不同的 GPU 模式。这对于精确模拟非常重要,因为某些硬件操作只能在特定的 GPU 模式下进行。
周期 | GPU 模式号 | 时间消耗(t 时钟周期) |
描述 |
---|---|---|---|
OAM 搜索 | 2 | 80 | GPU 扫描 OAM 获取当前扫描线上的精灵信息。 |
像素传输 | 3 | 172 | GPU 从 VRAM 读取图块和地图数据并渲染像素。 |
水平消隐 | 0 | 204 | 当前扫描线渲染完成后的等待期。 |
一条扫描线总时间 | 456 | (80 + 172 + 204) | |
垂直消隐 | 1 | 4560(10 行) | 所有可见扫描线渲染完成后的等待期。 |
一帧总时间 | 70224 | (456 * 154 行) |
为什么是这些数字呢?
GPU 像是一个画家,每天画一行像素。
- 准备阶段(80):看看要画什么角色(OAM 搜索)
- 绘画阶段(172):专心画这一行(像素传输)
- 休息阶段(204):喝口水,准备下一行(水平消隐)
- 长休息(4560):一张画完了,休息下(垂直消隐)
因此我们的游戏不能随时访问 GPU。
为了在模拟器中保持这些时序,我们需要一个 step
函数,它会在 CPU 每执行一条指令后被调用,并根据 CPU 消耗的时钟周期来推进 GPU 的内部时序状态。
我们定义 GPU 模式和时序常量:
1 | /** |
接下来,我们在 GameBoyGPU
类中初始化时序状态,并实现 step
方法:
1 | // ... 之前写的其他方法 ... |
4. 图形渲染:绘制背景与瓦片
与现代显卡动辄数 GB 的显存不同,早期游戏机(如 GameBoy)的内存极其有限,无法在内存中直接存储完整的屏幕像素(即帧缓冲区)。为了解决这个问题,工程师们采用了一种非常聪明的技术——瓦片系统。
想象一下你在用瓷砖来铺地板。你不需要为每一块地面都设计一个独一无二的图案,而是可以使用一组预先设计好的瓷砖(瓦片),通过不同的排列组合来创造出丰富的地面样式。GameBoy 的图形渲染就是基于这个原理。我们只需要存储一份“模板”,然后在需要的地方“引用”它即可。这个“模板”就是瓦片。
- 瓦片:一个 8x8 像素的基本图形单元。游戏中的所有背景和角色(精灵)都是由这些小小的瓦片拼接而成的
- 瓦片地图:一个 32x32 的二维数组,其中每个元素都是一个指向特定瓦片的索引。它就像一张设计图纸,规定了哪个位置应该使用哪个瓦片
- 视图:GameBoy 屏幕只有 160x144 像素,而整个背景地图却有 256x256 像素(32个瓦片 × 8像素/瓦片)。屏幕就像一个“摄像机”,我们只能通过这个 160x144 的窗口看到庞大背景地图的一部分
做过游戏开发的,尤其是做像素风格的应该对这个都很熟悉吧。
还是不太理解的,可以想一下拼图。
你要拼一幅大画,瓦片是你的拼图块(8x8 像素的小方块)、瓦片地图是拼图说明书、视图是拼图框。
游戏背景制作过程:
- 美术画了很多 8x8 的小图块
- 策划说:“草地用 1 号块、石头用 2 号块……”
- 程序:“我按照说明书拼。”(GPU 渲染)
- 玩家在游戏中看到森林
1989 年光是 1MB 内存就动不动要几千块钱,直接存储一张完整图片作为背景实在是过于奢侈。使用瓦片系统不仅可以节省内存,也可以节省空间。整个游戏卡带也才 32KB,必须得精打细算。
VRAM(视频内存)中,瓦片数据和瓦片地图的布局如下:
地址区域 | 用途 |
---|---|
0x8000 - 0x8FFF |
瓦片集 #1 |
0x9000 - 0x97FF |
瓦片集 #2 |
0x9800 - 0x9BFF |
瓦片地图 #0 |
0x9C00 - 0x9FFF |
瓦片地图 #1 |
有趣的是,瓦片集 #0 和 #1 有一部分是重叠的。游戏可以通过设置寄存器来选择使用哪个瓦片集和哪个瓦片地图,从而实现不同的显示效果。
4.1. 背景滚动与调色板
既然背景地图比屏幕大,我们自然就可以通过移动“摄像机”的位置来实现背景滚动的效果。这在平台跳跃或飞行射击游戏中非常常见。
GameBoy 提供了两个特殊的 GPU 寄存器来控制滚动:
SCY
(Scroll Y, 地址0xFF42
):定义了屏幕顶边对应在 256x256 背景地图上的垂直坐标SCX
(Scroll X, 地址0xFF43
):定义了屏幕左边对应在 256x256 背景地图上的水平坐标
通过在每一帧之间改变 SCX
和 SCY
的值,游戏就能让背景平滑地滚动起来,创造出动态的世界。
或许大家都会以为 GameBoy 是纯黑白的,但实际上它可以显示四种深浅不同的“灰色”(或者说是绿色,取决于屏幕材质)。这四种颜色是通过调色板系统实现的。
一个瓦片中的每个像素用 2 个比特来表示,可以表示四种值(00, 01, 10, 11)。但这四个值具体对应哪种颜色,是由背景调色板寄存器(BGP
, 地址 0xFF47
)决定的。
BGP
是一个 8 位寄存器,每 2 位定义一个颜色的映射关系:
- 位 1-0:映射像素值
00
- 位 3-2:映射像素值
01
- 位 5-4:映射像素值
10
- 位 7-6:映射像素值
11
例如,如果 BGP
的值是 0xE4
(二进制 11100100
),那么映射关系就是:
00
→00
(颜色 0)01
→01
(颜色 1)10
→10
(颜色 2)11
→11
(颜色 3)
这是最常见的默认调色板。但如果游戏将 BGP
的值改为 0x1E
(二进制 00011011
),映射就会变成:
00
→11
(颜色 3)01
→10
(颜色 2)10
→01
(颜色 1)11
→00
(颜色 0)
这样整个屏幕的颜色就瞬间反转了,能够很高效地实现特殊视觉效果(比方说闪烁、水下效果)的方式。
在我们的模拟器中,我们将这四种颜色定义为经典的 GameBoy 绿色调:
1 | const DEFAULT_PALETTE = [ |
4.2. 瓦片数据结构与缓存管理
在 GameBoy 中,每个瓦片的像素数据以一种特殊的方式存储。每个像素需要 2 位来表示(因为有 4 种颜色),但这 2 位并不是连续存储的。相反,一个瓦片行的 8 个像素的低位全部存储在一个字节中,高位存储在下一个字节中。
例如,如果一个瓦片行的像素值是 [3, 2, 1, 0, 0, 1, 2, 3]
,那么:
- 低位字节:
11100001
(每个像素值的最低位) - 高位字节:
11000011
(每个像素值的最高位)
这种存储方式虽然看起来复杂,但符合 GameBoy 硬件的读取方式。
这一部分不需要完全理解,只要知道“GameBoy 有自己的存储格式,我们需要转换一下”就够了。
为了提高模拟器的性能,我们需要将这种原始格式转换为更易于处理的格式,并建立缓存机制:
1 | /** |
这个缓存系统的核心思想是“按需更新”——只有当 VRAM 中的瓦片数据发生变化时,我们才重新计算对应的缓存。这样可以避免每次渲染时都进行耗时的位操作。
4.3. 调色板系统实现
调色板管理器负责处理颜色映射。它不仅存储当前的调色板设置,还提供了动态更新调色板的能力:
1 | /** |
例如,当游戏写入调色板寄存器 0xFF47
时,updatePaletteRegister
方法会被调用,自动重新映射所有颜色。这意味着游戏可以通过一个简单的寄存器写入操作瞬间改变整个屏幕的色调。
4.4. 背景滚动控制
滚动控制器管理背景的偏移位置,这是实现动态背景效果的关键:
1 | /** |
这个简单的类封装了 GameBoy 的 SCX
和 SCY
寄存器的功能。当游戏修改这些寄存器时,滚动位置会立即更新,下一帧的渲染就会反映出新的滚动位置。
4.5. 扫描线级背景渲染
背景渲染器是整个图形系统的核心,它负责将瓦片地图转换为实际的像素数据。渲染是按扫描线进行的,这模拟了 GameBoy 的真实渲染方式:
1 | /** |
这个渲染过程的关键在于理解坐标转换:
- 从屏幕坐标转换为背景地图坐标(考虑滚动偏移)
- 从背景地图坐标转换为瓦片坐标和瓦片内像素坐标
- 从瓦片地图读取瓦片索引
- 从瓦片缓存读取像素的调色板索引
- 通过调色板获取最终的 RGBA 颜色值
4.6. 图形系统集成
最后,图形系统主类将所有组件协调起来工作:
1 | /** |
这个集成系统的设计遵循了模块化原则——每个组件都有清晰的职责,通过明确的接口进行通信。当 GPU 需要渲染一条扫描线时,它只需调用 renderScanline
方法,图形系统会自动协调所有子组件完成复杂的渲染过程。
4.7. 图形系统集成与性能优化
在完成了瓦片管理、调色板和背景渲染等核心组件后,我们需要将图形系统与现有的 GPU 时序系统集成起来,并进行性能优化以确保流畅的渲染效果(当时测试的时候 FPS 发现连 1 都不到……)。
首先,我们需要在 GPU 类中添加图形系统的初始化和管理逻辑:
1 | /** |
接下来,我们需要建立 GPU 与图形系统之间的数据连接:
1 | /** |
为了提供更好的调试体验和性能,我们要实现渲染模式切换:
1 | /** |
最重要的性能优化是引入了简化的帧级别渲染,避免了复杂的周期级别计算:
1 | /** |
5. 系统集成:构建完整的模拟器架构
在前面的章节中,我们分别实现了 CPU 指令处理、MMU 内存管理、GPU 时序控制和图形渲染系统。虽然这些组件各自功能完善,但它们就跟分散的乐器差不多,需要一个指挥家来协调,不然是无法演奏出和谐的交响乐的。这一章的核心任务就是建立这样一个统一的系统架构,让所有硬件组件无缝协作。
5.1. 硬件抽象层与组件连接
在真实的 GameBoy 中,各个硬件组件通过物理总线连接。在我们的模拟器中,我们需要建立一个软件层面的“总线系统“,让组件间能够进行通信。MMU 作为内存管理的中心,天然地成为了这个连接中心。
我们为 MMU 添加硬件组件连接能力:
1 | /** |
这种设计的优势在于松耦合:每个组件都不需要知道其他组件的具体实现,只需要通过 MMU 这个“中介”进行通信。当我们要添加新的硬件组件时,只需要在 MMU 中注册即可。
5.2. I/O 寄存器路由系统
GameBoy 的硬件组件通过内存映射的 I/O 寄存器进行控制。这些寄存器位于 0xFF00-0xFF7F
地址范围内,不同的地址控制不同的硬件功能。我们需要建立一个路由系统,将对这些地址的读写操作转发给相应的硬件组件。
首先,定义一个完整的寄存器地址映射:
1 | /** |
MMU 中的寄存器读写方法会根据地址范围自动将请求路由到对应的硬件组件:
1 | /** |
不同的寄存器地址控制不同的硬件功能,MMU 需要将读写操作正确路由到对应的硬件组件:
1 | /** |
对应的写入路由逻辑确保了每个硬件组件都能及时收到控制指令:
1 | /** |
这样,当游戏代码写入 0xFF40
(LCD 控制寄存器)时,MMU 会自动将这个写入操作转发给 GPU,GPU 就能实时更新其内部状态。
GPU 是 I/O 寄存器最密集的硬件组件,拥有十多个不同功能的寄存器。这些寄存器不仅控制显示行为,还影响中断、DMA 传输等关键功能。
1 | /** |
GPU 内部维护所有寄存器的当前状态:
1 | /** |
LCD 控制寄存器(LCDC)是一个特殊的寄存器,它的每一位都控制不同的显示功能:
1 | /** |
GPU 提供完整的寄存器读写接口:
1 | /** |
DMA(直接内存访问)是 GameBoy 的重要功能,允许快速复制精灵数据到 OAM:
1 | /** |
1 | /** |
5.4. 系统调度器架构
现在我们有了能够相互通信的硬件组件,但还需要一个“指挥家”来协调它们的工作。这就是系统调度器 gameboy.js
的作用。
系统调度器作为顶层管理器,负责创建、连接和管理所有硬件组件:
1 | /** |
系统调度器的初始化过程展示了完整的组件连接流程:
1 | /** |
这个连接过程确保了:
- GPU 能够通过 MMU 接收寄存器访问
- GPU 能够直接访问 VRAM 进行渲染
- 图形系统能够接收 VRAM 更新通知
5.5. 帧级别时序调度
先前我们实现了精确的周期级别时序,但在实际运行中这种精确度往往会带来性能负担,因此我们当时引入了帧级别调度,在保持足够精度的同时大幅提升性能。现在我们完善一下当时只是简化版本的 stepFrame()
。
1 | /** |
系统调度器支持多种调度模式以适应不同的需求:
1 | /** |
这种设计的优势是:
- 性能优化:避免了复杂的周期级别计算
- 稳定的帧率:使用
requestAnimationFrame
确保流畅的 60 FPS - 灵活性:可以根据需要切换到其他调度模式
5.6. 错误处理与系统监控
一个健壮的模拟器必须能够优雅地处理错误并提供丰富的监控信息。系统调度器实现了多层次的错误处理:
1 | /** |
系统提供了丰富的性能统计信息:
1 | /** |
5.7. 现代化用户界面
展开以查看
1 |
|
效果(点击 初始化系统 → 切换渲染模式 → 测试图形渲染 → 加载演示程序 → 开始运行 ):
5.8. 全局系统控制接口
为了方便外部访问和调试,我们写一个统一的全局的系统控制接口:
1 | /** |
这个控制器接口使得前端可以通过简洁的 API 来操作整个模拟器系统,同时在浏览器控制台中也可以直接调用这些方法进行调试:
1 | // 在浏览器控制台中 |