在上篇文章中,我们已经构建了 GameBoy 模拟器的基础架构,包括 CPU 指令处理、内存管理、GPU 时序和图形渲染系统。虽然我们的模拟器能够显示图像,但还无法与用户进行交互。这篇文章将继续完善该模拟器。
用 JavaScript 自制 GameBoy 模拟器(下)
在 上篇文章 中,我们已经构建了 GameBoy 模拟器的基础架构,包括 CPU 指令处理、内存管理、GPU 时序和图形渲染系统。虽然我们的模拟器能够显示图像,但还无法与用户进行交互。这篇文章将继续完善该模拟器。
6. 输入系统:让玩家与游戏交互
与现代游戏手柄复杂的按键布局不同,GameBoy 的输入系统相对简单但非常巧妙。它只有 8 个按键,但通过矩阵式的硬件设计,能够有效地检测任意组合的按键状态。
6.1. GameBoy 输入硬件原理
GameBoy 的 8 个按键被组织为一个 2 列 × 4 行的矩阵:
| 列 1(功能键) | 列 2(方向键) |
|---|---|
| A 按键 | 右方向键 |
| B 按键 | 左方向键 |
| Select | 上方向键 |
| Start | 下方向键 |
这种矩阵设计的工作原理类似于键盘扫描:
-
列选择:CPU 通过写入
0xFF00寄存器的第 4、5 位来选择要扫描的列- 写入
0x10选择列 1(功能键) - 写入
0x20选择列 2(方向键)
- 写入
-
行读取:选择列后,CPU 读取
0xFF00寄存器的低 4 位,获取该列中各行的按键状态 -
状态反转:硬件使用反转逻辑,即 未按下 = 1,按下 = 0
为什么用矩阵?
如果直接为每个按键分配一位,需要 8 位。但用 2×4 矩阵只需要 6 根线(2 列 + 4 行),节省了硬件成本。
这在 1989 年是很重要的优化,每减少一根线都能降低成本。
6.2. 输入控制器实现
在实现输入控制器之前,我们需要先设计完整的常量体系和架构。GameBoy 的输入系统虽然简单,但需要精确的位操作和状态管理。
输入系统的所有魔法数字都需要明确的常量定义:
1 | /** |
首先创建输入控制器的核心类:
1 | /** |
按键映射定义了 JavaScript 键码到 GameBoy 按键的对应关系:
1 | /** |
这个映射表的设计考虑了几个重要因素:
- 人体工程学:A/B 键放在左手位置(Z/X),方向键用标准箭头
- 避免冲突:避开常用的网页快捷键(如 Ctrl + C 等)
- 易于记忆:功能键集中,方向键直观
输入控制器需要将浏览器的键盘事件转换为 GameBoy 格式:
1 | /** |
键盘事件处理是输入系统的核心,需要正确实现状态反转逻辑:
1 | /** |
6.2.1. 位操作详解:状态反转的数值示例
GameBoy 的状态反转逻辑是整个输入系统的核心。让我们通过具体的数值示例来理解这个过程:
初始状态(所有按键未按下):
1
2
3
4
5 列1(功能键): keyMatrix[0] = 0x0F = 0b00001111
列2(方向键): keyMatrix[1] = 0x0F = 0b00001111
位含义:[bit3=Start, bit2=Select, bit1=B, bit0=A]
[bit3=Down, bit2=Up, bit1=Left, bit0=Right]示例 1:按下 A 键(列 0 行 0)
1
2
3
4
5
6 1. 按键信息:A_BUTTON = { column: 0, row: 0, ... }
2. 创建行掩码:rowMask = ~(1 << 0) = ~0b00000001 = 0b11111110 = 0xFE
3. 应用掩码:keyMatrix[0] &= 0xFE
原值:0x0F = 0b00001111
掩码:0xFE = 0b11111110
结果:0x0E = 0b00001110 ← A 键现在为 0(按下状态)示例 2:按下方向键 Up(列 1 行 2)
1
2
3
4
5
6 1. 按键信息:UP = { column: 1, row: 2, ... }
2. 创建行掩码:rowMask = ~(1 << 2) = ~0b00000100 = 0b11111011 = 0xFB
3. 应用掩码:keyMatrix[1] &= 0xFB
原值:0x0F = 0b00001111
掩码:0xFB = 0b11111011
结果:0x0B = 0b00001011 ← Up 键现在为 0(按下状态)示例 3:同时按下 A 键和 B 键
1
2
3
4
5
6
7
8
9
10
11 初始:keyMatrix[0] = 0x0F = 0b00001111
按下A键:
rowMask = ~(1 << 0) = 0xFE = 0b11111110
keyMatrix[0] = 0x0F & 0xFE = 0x0E = 0b00001110
按下B键:
rowMask = ~(1 << 1) = 0xFD = 0b11111101
keyMatrix[0] = 0x0E & 0xFD = 0x0C = 0b00001100
最终状态:0b00001100 ← A 和 B 键都为 0(都被按下)释放按键的过程(以释放 A 键为例):
1
2
3
4
5
6 1. 当前状态:keyMatrix[0] = 0x0C = 0b00001100 (A和B都按下)
2. 创建恢复掩码:rowMask = 1 << 0 = 0b00000001 = 0x01
3. 应用或操作:keyMatrix[0] |= 0x01
当前:0x0C = 0b00001100
掩码:0x01 = 0b00000001
结果:0x0D = 0b00001101 ← A 键恢复为 1(未按下),B 键保持 0(仍按下)
关键理解点:
-
按下操作:使用
&=和反转掩码将指定位设为 0~(1 << row)创建只有目标位为 0 的掩码- 与运算确保只有目标位被清零
-
释放操作:使用
|=和正常掩码将指定位设为 1(1 << row)创建只有目标位为 1 的掩码- 或运算确保只有目标位被置位
-
组合按键:多个按键可以同时处于按下状态,每个按键独立管理其对应的位
这种设计让 GameBoy 能够准确检测任意按键组合,为复杂的游戏操作提供了基础。
为什么是反转的?
GameBoy 使用 "低电平有效" 的硬件设计:
- 按键未按下时,该位为高电平(1)
- 按键按下时,该位被拉低(0)
6.3. 寄存器接口实现
输入控制器的最终目的是为 CPU 提供符合 GameBoy 硬件规范的寄存器接口。CPU 通过读写 0xFF00 地址来获取按键状态和控制扫描模式。
输入控制器必须提供符合 GameBoy 硬件规范的寄存器接口:
1 | /** |
这些函数是整个输入系统的核心,实现了完整的矩阵扫描逻辑:
- 高位设置:
0xC0设置第 6、7 位为 1(硬件规范要求) - 列选择回显:将当前选择的列值加入结果
- 行状态返回:根据选择的列返回对应的 4 位行状态
矩阵扫描的工作流程:
6.4. MMU 集成
在 MMU 中完善输入寄存器的处理逻辑:
1 | /** |
6.5. 系统集成
在主系统中创建和连接输入控制器:
1 | /** |
6.6. 调试和测试工具
输入系统包含了完整的调试功能:
1 | /** |
自动化测试功能可以验证所有按键:
1 | /** |
6.7. 用户界面完善
为了提高用户体验,需要实现一个可视化的虚拟手柄。虚拟手柄的核心在于将触摸 / 鼠标事件转换为 GameBoy 按键事件::
1 | <!-- 输入控制 --> |
样式:
1 | <style> |
虚拟手柄支持鼠标和触摸操作:
1 | <script> |
在 index.html 中添加用于测试和调试的板块:
1 | <!-- 输入控制 --> |
页面最底部添加按键映射弹窗:
1 | <!-- 按键映射弹窗 --> |
1 | <script> |