前阵子整理 Game Boy 相关素材时,突然想到一个很有意思的问题:现在的开发工具、模拟器、社区资料都已经非常成熟,为什么我们还是很少看到有人真正从零做一款 GB 自制游戏?原因倒不是技术门槛太高,而是信息太散。今天这篇就以自制游戏《黑城堡 2》为例,完整拆解在 Game Boy 平台上从环境搭建、地图构建、人物移动、碰撞检测到最终编译 ROM 的全过程。哪怕你之前只写过一点 C 语言,也能跟着这篇文章跑通自己的第一个 GB 游戏原型。
1. 背景与核心概念
1.1 什么是 Game Boy 自制游戏
自制游戏,英文常叫 homebrew game,是指个人开发者或小团队在非商业目的下,为某个游戏主机平台开发的独立游戏。Game Boy 是其中非常受欢迎的目标平台,原因很直接:硬件资料公开透明、模拟器生态完善、编译工具链免费,而且主机性能有限,反而让开发者在强约束下更容易产出有风格的作品。
《黑城堡 2》是我给这个项目起的名字,玩法定位是“城堡探索 + 躲避巡逻敌人”。玩家控制一名骑士,在黑城堡内部移动,避开巡逻的骷髅卫兵,最终到达出口。听起来不复杂,但要真正把它跑在 Game Boy 上,背后需要处理背景地图、精灵、按键输入、碰撞检测等多个模块。这也是自制游戏最有意思的部分:看着自己写的像素角色在一个复古屏幕上动起来。
1.2 为什么选择 Game Boy 作为学习平台
很多初学者会问:现在做游戏不是应该选 PC、手机或者 Unity 吗?为什么还要研究一台 1989 年发售的掌机?
选择 Game Boy 有几个非常实际的好处。
第一,硬件规格完全公开。Game Boy 的 CPU、显存、内存映射、卡带协议等资料非常详细,社区里还有 Pan Docs 这样的经典文档持续维护,学习成本比逆向现代主机低很多。
第二,开发工具链成熟。目前最常用的 GBDK-2020 和 RGBDS 都是开源项目,前者可以用 C 语言开发,后者可以写汇编。配合各种模拟器,几乎能在任何操作系统上完成开发和调试。
第三,资源约束能培养设计能力。Game Boy 屏幕只有 160×144 像素,同屏精灵最多 40 个,BG 调色板和 OBJ 调色板各 4 色。在这种“什么都很有限”的环境里,开发者会本能地学会取舍:哪些效果必须做,哪些可以放弃。这种能力放在现代游戏开发中依然很有价值。
1.3 自制游戏的基本工作流
一个典型的 Game Boy 自制游戏开发流程大致是这样的:
- 确定游戏玩法,拆解成输入、移动、碰撞、渲染等模块。
- 准备 tile 素材,也就是 8×8 或 8×16 的像素单元。
- 设计地图,把 tile 按某种布局放到背景里。
- 编写代码,处理初始化、玩家逻辑、敌人逻辑和状态切换。
- 用 GBDK-2020 等工具链把代码编译成
.gb后缀的 ROM 文件。 - 在模拟器里运行测试。
- 如果有条件,烧录到烧录器并放到实体机里做真机验证。
本文会严格按这条主线展开,并且每一步都给出可复制的内容。
2. Game Boy 硬件要点与《黑城堡 2》设计思路
2.1 硬件规格速览
在写代码之前,先快速过一遍 Game Boy 的硬件规格,因为很多坑都来自硬件限制。
Game Boy 的 CPU 是 Sharp LR35902,指令集与 Z80 相似但不完全一样,主频约 4.19 MHz。屏幕分辨率 160×144,也就是说横向 160 个像素,纵向 144 个像素。显存只有 8KB VRAM,用于存放 tile、地图和精灵数据。背景地图本身是 32×32 个 tile,可见窗口是其中的 20×18 个 tile,每个 tile 是 8×8 像素,所以背景可见部分正好是 160×144。
精灵方面,硬件支持最多 40 个精灵,每个精灵默认 8×8,也可以设置为 8×16。但每扫描行最多只能显示 10 个精灵,超出部分会被硬件丢弃。
这些数字会直接影响游戏设计。比如《黑城堡 2》原型阶段就不能放太多敌人,因为精灵数量有限。一张 20×18 的完整地图就已经有 360 个 tile,如果再把地图做得很大,就需要考虑滚动和内存开销。正因为如此,理解了硬件参数再看代码,思路会清晰很多。
2.2 tile、地图与精灵三个核心概念
在 Game Boy 开发中,有三个词出现频率最高:tile、background map、sprite。
tile 是最小的图形单元,尺寸是 8×8 像素,使用 2bpp 编码,每个 tile 固定 16 字节。所谓 2bpp,是指每个像素用 2 个 bit 表示颜色索引,可以表示 0 到 3 共 4 种颜色。这 16 字节中,每行占用 2 个字节,前一个字节保存低位平面信息,后一个字节保存高位平面信息。两个 bit 组合起来决定这一行某个像素显示调色板中的第几种颜色。
background map 是背景地图,可以理解成一个 32×32 的二维数组,数组里每个值都表示一个 tile 索引。屏幕上只能看到其中 20×18 的部分。因此游戏里最常用的操作是:先用set_bkg_data把 tile 数据加载到显存,再用set_bkg_tiles把地图索引填进去。
sprite 是精灵,也就是独立于背景移动的图像单元。精灵数据来自同一块 VRAM,但精灵使用独立的 OBJ 调色板,可以自由移动。所有精灵的位置都存放在一片叫 OAM 的内存区域里,硬件会自动读取并绘制。
把这三个概念搞清楚,《黑城堡 2》的代码结构就很好理解了。
2.3 《黑城堡 2》玩法与模块设计
原型阶段的《黑城堡 2》玩法可以做成这样:
- 玩家用方向键控制骑士移动。
- 按住 A 键可以加速移动。
- 一个骷髅卫兵在地图中间的水平路线上来回巡逻。
- 碰到卫兵后,玩家会被传送回出发点,作为一个惩罚机制。
- 玩家走到地图右上角出口门处,触发胜利画面。
- 在胜利画面按 START 可以重新开始游戏。
这个玩法不算复杂,但足以覆盖 Game Boy 游戏开发的核心模块。我们把项目拆成几个部分:
- 数据模块:背景 tile、精灵 tile、地图数组。
- 初始化模块:设置显存、地图、精灵、初始坐标。
- 输入模块:读取方向键和 A 键状态。
- 移动模块:处理玩家和敌人的坐标变化。
- 碰撞模块:判断玩家与敌人是否发生碰撞,判断玩家是否到达出口。
- 状态模块:运行中、胜利等待重开。
有了模块划分,后面写代码就不会乱。
3. 开发环境准备
3.1 工具与角色分工
开发 Game Boy 自制游戏,最少需要这几类工具:
- 代码编辑器:VS Code、Vim、Notepad++ 都可以,任选。
- 编译器或汇编器:推荐 GBDK-2020,因为可以使用 C 语言开发,更适合新手快速上手。
- 模拟器:SameBoy、BGB、Emulicious 都支持运行普通
.gb文件。 - 构建工具:Windows 上可以直接用命令,也可以配合 Make。
- 绘图和地图工具:后续章节会单独介绍。
GBDK-2020 是目前社区最主流的 C 语言开发工具链,底层基于 SDCC。它提供了一批针对 Game Boy 硬件的库,比如操作背景、精灵、手柄输入、中断的函数,开发者不需要自己写大量寄存器操作。
3.2 安装 GBDK-2020
GBDK-2020 的安装以官方 GitHub 仓库发布的 release 包为准。不同操作系统步骤类似:
- 打开 GBDK-2020 的 GitHub 发布页面。
- 下载对应系统的压缩包,Windows 一般选择
.zip包,macOS 和 Linux 也有对应版本。 - 解压到某个目录,比如
D:\gbdk或~/gbdk。 - 把解压目录中的
bin子目录加入系统 PATH。
安装完成后,打开命令行工具,输入:
lcc -v如果能正常输出版本信息,说明工具链已经可用。
需要特别提醒:版本号会持续更新,不同版本之间的 API 差异不大,但如果你遇到编译报错,第一件事应该是检查是不是工具链版本和示例代码版本不一致。
3.3 准备模拟器
模拟器是开发阶段的“真机”,推荐准备一个自己用着顺手的。BGB 在 Windows 上很稳定,功能全;SameBoy 跨平台,精度很高,对新手友好;Emulicious 适合做深度的调试和可视化分析。
模拟器使用很简单,编译出.gb文件后,直接把文件拖进模拟器窗口即可。开发阶段我都会先用模拟器验证,最后有条件再上实体机。
3.4 真机烧录与合法边界
如果希望游戏在真实 Game Boy 上运行,需要把 ROM 烧录到空白卡带中,烧录器是常见方案,比如 GBxCart RW 系列。烧录器的正常用途是烧录自己开发的游戏、备份自己拥有合法权利的卡带,或者配合社区开源项目使用。这里要特别说明:不要用这类工具传播或下载盗版 ROM,也不要烧录没有授权的内容。做独立开发,保护原创版权是基本底线。
开发阶段完全可以用模拟器完成 99% 的验证,真机测试一般放在最后做兼容性和手感确认。
4. 项目结构与数据准备
4.1 项目目录
一个清晰的项目结构能让开发过程更可控。本文示例使用下面的目录:
black-castle-2/ ├── src/ │ └── main.c ├── Makefile └── README.md为了让第一篇教程足够直接,我把所有代码放在src/main.c中。实际项目变大后,建议再拆成map.c、player.c、enemy.c、game.c等模块,这些在后面的工程化建议中会展开。
4.2 background tile 数据解析
下面先定义背景 tile 数据。这里一共有 5 个 tile,索引 0 到 4,分别表示空白、墙、地板、深色地板和出口门。
每个 tile 是 16 字节的 2bpp 数据。第 1 行像素由第 1 个字节和第 2 个字节共同决定,第 2 行由第 3 个和第 4 个字节决定,依此类推。
const unsigned char bkg_tiles[] = { // tile 0:空白 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // tile 1:墙(上半浅色,下半深色) 0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00, 0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF, // tile 2:地板(斜纹) 0x55,0x00,0xAA,0x00,0x55,0x00,0xAA,0x00, 0x55,0x00,0xAA,0x00,0x55,0x00,0xAA,0x00, // tile 3:深色地板 0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF, 0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF, // tile 4:出口门(上面浅色,下面深色) 0xFF,0xFF,0xFF,0xFF,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0xFF,0xFF,0xFF,0xFF };你可以暂时不用理解每个字节的二进制细节,但需要知道一个原则:这些字节不是“一行一个像素”,而是“平面交错”存储。手动写 tile 数据容易出错,所以实际项目中更推荐用像素画工具生成。
4.3 地图生成策略
《黑城堡 2》的背景地图是 20×18 个 tile,也就是铺满一屏。如果要手写 360 个数字,代码会非常冗长。原型阶段我选择在程序启动时动态生成地图,这样代码更短,也更容易调整。
生成逻辑很简单:四周放墙,中间加一竖排墙作为分隔,剩余区域铺地板,右上角放出口门。
#define MAP_W 20 #define MAP_H 18 unsigned char castle_map[MAP_W * MAP_H]; void build_map(void) { uint8_t x, y; for (y = 0; y < MAP_H; y++) { for (x = 0; x < MAP_W; x++) { if (x == 0 || x == MAP_W - 1 || y == 0 || y == MAP_H - 1) { castle_map[y * MAP_W + x] = 1; } else if ((x == 9 || x == 10) && y >= 5 && y <= 12) { castle_map[y * MAP_W + x] = 1; } else { castle_map[y * MAP_W + x] = 2; } } } castle_map[1 * MAP_W + 19] = 4; }这种程序化地图的好处是逻辑简单、容易理解。正式做关卡时,推荐改用地图编辑器绘制,再通过脚本转换成 C 数组,后面会提到具体工具。
4.4 精灵 tile 设计
玩家和敌人各需要一个精灵 tile。这里依然用 2bpp 格式,精灵数据放在独立的数组中。
const unsigned char sprite_tiles[] = { // tile 0:玩家(骑士剪影) 0x00,0x00,0x3C,0x3C,0x66,0x66,0x3C,0x3C, 0x18,0x18,0x24,0x24,0x42,0x42,0x81,0x81, // tile 1:敌人(卫兵/骷髅) 0x00,0x00,0x7E,0x00,0x81,0x7E,0x7E,0x00, 0x18,0x00,0x24,0x18,0x00,0x7E,0x00,0x00 };玩家 tile 两个平面数据相同,所以绘制出来的非透明区域会显示为同样的深色。敌人 tile 的规划稍微复杂一点,利用了两个平面不同的 bit 值,最终会形成一种带描边感的图案。原型阶段精灵美术不追求完美,重点是让玩家和敌人能清楚区分。
5. 《黑城堡 2》核心代码实现
5.1 初始化游戏
初始化阶段要做的事情是把 tile 数据送入显存、填背景地图、设置玩家和敌人的初始位置,然后打开背景和精灵显示。
需要特别注意的是,初始化前后使用了DISPLAY_OFF和DISPLAY_ON这一对宏。在显存数据没有完全准备好之前,先把屏幕关掉可以避免花屏。
void init_game(void) { DISPLAY_OFF; build_map(); set_bkg_data(0, 5, bkg_tiles); set_bkg_tiles(0, 0, MAP_W, MAP_H, castle_map); set_sprite_data(0, 2, sprite_tiles); set_sprite_tile(0, 0); set_sprite_tile(1, 1); player_x = 76; player_y = 72; enemy_x = 120; enemy_y = 40; enemy_dir = 1; SHOW_SPRITES; SHOW_BKG; DISPLAY_ON; }set_bkg_data(0, 5, bkg_tiles)表示从 tile 编号 0 开始,连续加载 5 个 tile 到背景显存。set_bkg_tiles(0, 0, MAP_W, MAP_H, castle_map)表示从背景左上角开始,铺满 20×18 的地图。
5.2 玩家输入与移动
Game Boy 的按键状态通过joypad()读取,返回值是一个无符号整数,每一位对应一个按键。常用按键常量包括J_LEFT、J_RIGHT、J_UP、J_DOWN、J_A、J_B、J_START、J_SELECT。
玩家移动的核心逻辑是:读取当前按键,根据按键修改坐标,然后调用move_sprite更新精灵在屏幕上的位置。加速逻辑通过 A 键实现,按住 A 时每次移动 2 像素,否则移动 1 像素。
void update_player(void) { uint8_t keys = joypad(); uint8_t speed = (keys & J_A) ? 2 : 1; if (keys & J_LEFT) { if (player_x >= 8 + speed) player_x -= speed; else player_x = 8; } if (keys & J_RIGHT) { if (player_x <= 150 - speed) player_x += speed; else player_x = 150; } if (keys & J_UP) { if (player_y >= 16 + speed) player_y -= speed; else player_y = 16; } if (keys & J_DOWN) { if (player_y <= 136 - speed) player_y += speed; else player_y = 136; } move_sprite(0, player_x, player_y); }这里把玩家的移动边界限制在屏幕内。边界值并不是完全贴合屏幕边缘,而是预留了一些像素,避免精灵一半跑出屏幕造成视觉跳动。
5.3 敌人移动逻辑
敌人逻辑比玩家简单很多:在一个水平区间内来回移动。用一个enemy_dir变量记录方向,碰到右边界时改为向左,碰到左边界时改为向右。
void update_enemy(void) { enemy_x += enemy_dir; if (enemy_x > 150) { enemy_dir = -1; } if (enemy_x < 20) { enemy_dir = 1; } move_sprite(1, enemy_x, enemy_y); }这里enemy_dir是有符号数,而enemy_x是无符号数。在做加法时,C 语言会进行类型转换,所以我把判断边界放在更新坐标之后。这个逻辑在原型阶段够用,但正式项目中建议把敌人坐标统一改成有符号类型,避免很多隐式转换带来的隐患。
5.4 碰撞与出口判定
碰撞检测使用最简单的 AABB 粗判定。所谓 AABB,是指把两个物体都近似成一个矩形,然后判断两个矩形是否相交。Game Boy 上精灵尺寸很小,这种粗检测已经足够。
判断玩家与敌人碰撞时,先算出两个中心点的横纵距离,再分别和阈值比较。距离小于阈值就认为相撞,此时把玩家坐标重置回起点。
void check_collision(void) { int dx = (int)player_x - (int)enemy_x; int dy = (int)player_y - (int)enemy_y; if (dx > -12 && dx < 12 && dy > -12 && dy < 12) { player_x = 76; player_y = 72; move_sprite(0, player_x, player_y); } }出口判定放在主循环里。当玩家坐标进入右上角区域时,调用胜利函数。
5.5 完整 main.c
下面给出完整的src/main.c文件。为了方便第一次编译,我把所有代码集中在一个文件里。如果你使用的是 GBDK-2020 且版本比较新,这段代码可以作为原型直接参考。
// 文件路径:src/main.c // 《黑城堡 2》Game Boy 自制游戏原型 // 编译命令:lcc -o black-castle-2.gb src/main.c #include <gb/gb.h> #define MAP_W 20 #define MAP_H 18 const unsigned char bkg_tiles[] = { // tile 0:空白 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // tile 1:墙(上半浅色,下半深色) 0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00, 0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF, // tile 2:地板(斜纹) 0x55,0x00,0xAA,0x00,0x55,0x00,0xAA,0x00, 0x55,0x00,0xAA,0x00,0x55,0x00,0xAA,0x00, // tile 3:深色地板 0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF, 0x00,0xFF,0x00,0xFF,0x00,0xFF,0x00,0xFF, // tile 4:出口门(上浅下深) 0xFF,0xFF,0xFF,0xFF,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0xFF,0xFF,0xFF,0xFF }; const unsigned char sprite_tiles[] = { // tile 0:玩家(骑士剪影) 0x00,0x00,0x3C,0x3C,0x66,0x66,0x3C,0x3C, 0x18,0x18,0x24,0x24,0x42,0x42,0x81,0x81, // tile 1:敌人(卫兵) 0x00,0x00,0x7E,0x00,0x81,0x7E,0x7E,0x00, 0x18,0x00,0x24,0x18,0x00,0x7E,0x00,0x00 }; unsigned char castle_map[MAP_W * MAP_H]; uint8_t player_x = 76; uint8_t player_y = 72; uint8_t enemy_x = 120; uint8_t enemy_y = 40; signed char enemy_dir = 1; void build_map(void) { uint8_t x, y; for (y = 0; y < MAP_H; y++) { for (x = 0; x < MAP_W; x++) { if (x == 0 || x == MAP_W - 1 || y == 0 || y == MAP_H - 1) { castle_map[y * MAP_W + x] = 1; } else if ((x == 9 || x == 10) && y >= 5 && y <= 12) { castle_map[y * MAP_W + x] = 1; } else { castle_map[y * MAP_W + x] = 2; } } } castle_map[1 * MAP_W + 19] = 4; } void init_game(void) { DISPLAY_OFF; build_map(); set_bkg_data(0, 5, bkg_tiles); set_bkg_tiles(0, 0, MAP_W, MAP_H, castle_map); set_sprite_data(0, 2, sprite_tiles); set_sprite_tile(0, 0); set_sprite_tile(1, 1); player_x = 76; player_y = 72; enemy_x = 120; enemy_y = 40; enemy_dir = 1; SHOW_SPRITES; SHOW_BKG; DISPLAY_ON; } void update_player(void) { uint8_t keys = joypad(); uint8_t speed = (keys & J_A) ? 2 : 1; if (keys & J_LEFT) { if (player_x >= 8 + speed) player_x -= speed; else player_x = 8; } if (keys & J_RIGHT) { if (player_x <= 150 - speed) player_x += speed; else player_x = 150; } if (keys & J_UP) { if (player_y >= 16 + speed) player_y -= speed; else player_y = 16; } if (keys & J_DOWN) { if (player_y <= 136 - speed) player_y += speed; else player_y = 136; } move_sprite(0, player_x, player_y); } void update_enemy(void) { enemy_x += enemy_dir; if (enemy_x > 150) { enemy_dir = -1; } if (enemy_x < 20) { enemy_dir = 1; } move_sprite(1, enemy_x, enemy_y); } void check_collision(void) { int dx = (int)player_x - (int)enemy_x; int dy = (int)player_y - (int)enemy_y; if (dx > -12 && dx < 12 && dy > -12 && dy < 12) { player_x = 76; player_y = 72; move_sprite(0, player_x, player_y); } } void win_game(void) { uint8_t i; DISPLAY_OFF; for (i = 0; i < MAP_W * MAP_H; i++) { castle_map[i] = 2; } set_bkg_tiles(0, 0, MAP_W, MAP_H, castle_map); move_sprite(1, 0, 160); DISPLAY_ON; while (!(joypad() & J_START)) { wait_vbl_done(); } init_game(); } void main(void) { init_game(); while (1) { update_player(); update_enemy(); check_collision(); if (player_x > 140 && player_y < 20) { win_game(); } wait_vbl_done(); } }这段代码的核心思路是固定帧循环:每一帧读取输入,更新逻辑,最后调用wait_vbl_done()等待垂直同步。wait_vbl_done()是 Game Boy 开发中保证帧率稳定的关键函数,它让游戏逻辑与屏幕刷新保持同步,避免画面撕裂或者速度失控。
6. 编译、运行与验证
6.1 用 lcc 编译 ROM
在命令行中进入项目根目录,执行:
lcc -o black-castle-2.gb src/main.c如果你的 PATH 配置正确,会在当前目录生成black-castle-2.gb文件。这个文件就是可以在模拟器中运行的 ROM。
如果你使用 Make,也可以写一个最简单的 Makefile:
TARGET = black-castle-2.gb SRC = src/main.c $(TARGET): $(SRC) lcc -o $(TARGET) $(SRC) clean: rm -f $(TARGET)这样之后每次构建只需要执行make。实际项目文件多了之后,Makefile 的作用会非常明显。
6.2 在模拟器中运行
打开一个 GB 模拟器,把black-castle-2.gb文件拖进去。正常情况下,你应该看到:
- 背景是四周有围墙的城堡地图,中间有一道竖向墙。
- 右上角有一块明显的出口门。
- 一个骑士精灵出现在地图中央。
- 一个敌人精灵在水平方向来回移动。
- 用方向键可以控制骑士移动,按住 A 键移动速度会变快。
- 骑士碰到敌人后会被传送回中央起点。
- 骑士移动到右上角出口门区域后,背景会变成纯地板,此时按 START 可以重新开始游戏。
这些现象如果都符合,说明整个基础流程已经跑通。
6.3 验证清单
在继续扩展之前,建议先按下面的清单自查一遍:
| 验证项 | 预期结果 |
|---|---|
| 编译是否通过 | 生成.gb文件,没有报错 |
| 背景地图是否显示 | 围墙、地板、出口门正确显示 |
| 玩家移动是否正常 | 方向键控制骑士移动,不超出屏幕 |
| 加速是否正常 | 按住 A 键后移动速度变快 |
| 敌人巡逻是否正常 | 敌人在指定范围内来回移动 |
| 碰撞回归是否正常 | 碰到敌人后玩家回到起点 |
| 出口胜利是否正常 | 进入出口区域后触发胜利画面 |
| 重新开始是否正常 | 按 START 后重新初始化游戏 |
如果有一项不通过,可以先按后面“常见问题与排查思路”这一章的方法定位。
7. 图形、地图与音效的工程化建议
7.1 tile 素材工具
tile 数据手写太痛苦,而且很容易写错。实际开发中建议用像素画工具先画素材,再转换或导出为 C 数组。
Aseprite 是很多像素画作者常用的商业工具,支持分层、动画和调色板管理,可以把 8×8 的格子画得很舒服。Piskel 是免费在线工具,功能精简但够用。如果只想专注于 Game Boy 格式,可以试试 Game Boy Tile Designer 这类专用工具,它可以直接按 2bpp 格式导出 tile 数组。
在绘制 GB 像素画时,有几个实用小技巧:
- tile 边缘要留意接缝。两个 tile 放在一起后,如果边缘颜色不同,会出现明显的分割线,设计时要有意识地处理。
- 调色板颜色有限,不要尝试在一个 tile 里塞太多颜色。
- 先画黑白灰的明暗关系,再考虑用当前调色板呈现哪几种颜色。
7.2 地图编辑器与数据导出
当地图越来越复杂时,继续用代码里的build_map函数手工填数值就不现实了。推荐用 Tiled 这类地图编辑器先画出地图,导出为 CSV 或 JSON,再通过一个小脚本转换成 C 数组。
在 Tiled 里设置 tile 尺寸为 8×8,画布尺寸按 Game Boy 标准设为 160×144。地图数据中的每个数字都对应 tile 索引,脚本只需要把 CSV 里的数字直接输出成 C 数组即可。
这段转换逻辑不复杂,甚至可以保留build_map函数作为调试辅助,在开发初期先用程序生成地图快速测试玩法,后期再替换成 Tiled 导出的正式地图。
7.3 音乐音效方案
音乐和音效是 Game Boy 游戏氛围的核心。GB 的音频芯片有 4 个声道,分别是两个矩形波声道、一个波形声道和一个噪声声道。直接操作寄存器非常繁琐,社区一般使用工具链来做。
hUGETracker 是目前比较流行的 Game Boy 音乐工具,可以编写.uge格式的音乐,并在 GBDK 项目里用 hUGEDriver 播放。GBT Player 是另一个经典方案,很多自制游戏都在用。
原型阶段暂时不接音频,理由是先把游戏逻辑跑通。但当你准备打磨作品时,音乐和音效往往比想象中更重要。而且因为 GB 音频资源很小,一首音乐占用的空间也很低,非常适合用来练习“在资源约束下做设计”。
8. 常见问题与排查思路
8.1 问题汇总表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 编译时报找不到 lcc 命令 | GBDK-2020 的 bin 目录没有加入 PATH | 确认 PATH 配置,重启终端再试 |
| 模拟器打开后黑屏 | 忘记调用DISPLAY_ON,或者初始化前数据未就绪 | 检查初始化函数是否完整,先关闭显示再加载数据 |
| 背景显示花屏 | tile 索引越界,或者set_bkg_data数量参数不对 | 确认地图数组中的 tile 值都小于加载的 tile 数量 |
| 精灵不显示 | 忘了SHOW_SPRITES,或set_sprite_tile索引错误 | 检查初始化函数里的精灵相关配置 |
| 按键没有反应 | 没有在主循环中读取joypad(),或按键常量用错 | 在主循环中调用joypad(),确认用的是J_LEFT等常量 |
| 精灵移动闪烁 | 逻辑更新和 VBlank 不同步 | 确认每帧都调用了wait_vbl_done() |
| 实体机不兼容 | 使用了一些模拟器允许但硬件不支持的特性 | 多做真机测试,优先参考官方硬件文档 |
| 编译通过但 ROM 不能启动 | ROM 缺少正确的卡带头信息 | 检查连接脚本,或参考 GBDK 自带的示例工程 |
8.2 排查顺序
遇到问题不知道怎么下手时,可以按下面的顺序排查:
- 先确认编译有没有报错,语法问题先解决掉。
- 再看模拟器是否能正常打开 ROM,能打开说明 ROM 文件结构基本正确。
- 然后看背景是否显示,如果背景正常,说明 tile 和地图加载逻辑没问题。
- 接着看精灵是否显示,如果精灵没有出现,重点检查精灵 tile 加载和显示开关。
- 最后调试输入和碰撞,通常需要打印变量或临时修改逻辑来观察表现。
Game Boy 开发调试相对原始,没有现代 IDE 那么方便的断点工具,但可以用模拟器的调试器。Emulicious 和 BGB 都有比较强的调试功能,可以查看显存、OAM、寄存器状态,这对找 tile 和精灵问题非常有帮助。
9. 最佳实践与后续学习路线
9.1 写代码阶段的工程建议
虽然没有现代操作系统的资源,Game Boy 项目依然需要讲工程规范。
代码模块化要重视。原型可以用单文件,但一旦加入敌人类型、道具、Boss 和多个关卡,还是单文件就会非常痛苦。建议把数据、地图、玩家、敌人、UI 拆到独立文件。
命名要清晰。tile 数组可以叫bkg_tiles、sprite_tiles,地图函数叫build_map,敌人变量叫enemy_x、enemy_y、enemy_dir。名字能表达意思,比注释更重要。
异常处理要谨慎。Game Boy 没有标准异常机制,防御主要靠边界判断和状态机。例如玩家坐标不能超出屏幕,数组访问不能越界,这个写代码时要时刻想着。
9.2 性能与资源管理
Game Boy 性能有限,写代码时要注意几个常见陷阱。
精灵数量非常宝贵。原型里只用了两个精灵,所以很轻松。如果一个画面里需要大量可移动物体,就要提前规划 OAM 分配,必要时用“同屏最多显示 N 个敌人”的策略。
tile 显存也要珍惜。背景一次最多能用的 tile 数量是有限制的,加载过多 tile 会导致索引越界或显示异常。如果发现地图越来越大,就应该考虑使用 scroll 滚动地图,而不是把所有内容都塞进一屏。
碰撞检测建议用粗检测起步。先拿 AABB 跑通玩法,如果碰到特殊需求,再换成更精细的逐像素检测。过早优化不一定好。
9.3 后续学习路线
当原型跑通以后,可以从几个方向继续深入。
第一个方向是丰富游戏内容。给《黑城堡 2》加入多个敌人、Boss、道具、音效,把原型变成一个完整作品。这个过程会迫使你处理复杂的状态机、资源规划和手感调优。
第二个方向是学习汇编。GBDK 的 C 语言已经够用,但了解汇编可以帮你理解 C 代码到底怎么被翻译成机器指令。RGBDS 是学习 GB 汇编的首选工具链。
第三个方向是深入硬件特性。比如学习 MBC 卡带映射、SRAM 存档、双倍速模式、Game Boy Color 扩展特性。这个方向更偏底层,适合对硬件感兴趣的人。
第四个方向是参与社区。很多 GB 自制游戏开发者会开源自己的作品,阅读别人的源码是提升水平非常快的方法。可以找一些井井有条的工程,观察别人如何组织地图、如何管理精灵、如何控制游戏状态。
10. 写在最后
这篇从零开始的教程,把一个名为《黑城堡 2》的 Game Boy 自制游戏原型拆成了环境准备、硬件概念、tile 数据、地图生成、玩家移动、敌人巡逻、碰撞检测、胜利判定和编译运行几个部分。本质上,你得到的并不是一个多么华丽的成品游戏,而是一条清晰可复现的 GB 游戏开发路径。只要把这条路径走通一遍,后面做更复杂的作品就有了地基。
最建议你做的,是今天就把工具链装好,把这个原型跑起来,然后动手改一个参数、换一个 tile、加一个敌人。看着自己改的每一处都能在模拟器里马上体现出来,那种成就感是理解所有理论都换不来的。如果你在跑通流程时遇到具体问题,可以把报错内容或现象记录下来,按排查顺序逐步定位,多半都能在硬件约束和代码状态中找到答案。