Game Boy自制游戏开发实战:从零实现《黑城堡 2》完整流程 📅 发布时间:2026/9/8 13:24:45 👁 浏览次数: 之前想做一款 Game Boy 平台的自制游戏翻了不少资料发现大多只讲环境搭建或者只贴几个小 demo真正把“从设计到编译成 ROM、再到模拟器运行”串起来的教程很少。这次借《黑城堡 2》这个自制项目完整记录一套可以跟着走的 Game Boy 游戏开发流程从硬件限制、开发环境、C 语言代码到最后的编译排错和工程建议都会覆盖。如果你对复古游戏开发感兴趣或者想用 C 语言做点能跑在真实游戏机硬件上的东西这篇文章会非常适合你。就算你完全没有 Game Boy 开发经验只要懂一点 C 语言基础也能跟着做出一个可以运行的《黑城堡 2》原型。1. 为什么要做 Game Boy 自制游戏1.1 什么是 Game Boy 自制游戏Game Boy 自制游戏指的是不使用任天堂官方开发套件由独立开发者使用第三方工具链为 Game Boy 硬件编写游戏 ROM 的玩法。由于 Game Boy 早已停产这类自制游戏大多数运行在模拟器上也有部分玩家使用烧录卡在真实硬件上体验。《黑城堡 2》就是这样一个自制项目。它的名字看起来像某个商业游戏的续作但本质上是我们自己定义的玩法、地图、角色和关卡规则。Game Boy 平台上这类“续作式”自制游戏非常多开发者的自由度其实很高。从技术角度看Game Boy 自制游戏开发可以拆成三件事画面输出背景、精灵、调色板。输入处理方向键、A/B 键、Start/Select。游戏逻辑状态机、碰撞检测、敌人 AI、关卡流程。这三件事在 PC 上看起来很简单但放到只有 8KB 工作内存、8KB 显存、4 种颜色可用的 Game Boy 硬件上就需要设计者动很多脑筋。1.2 Game Boy 适合作为练手平台的原因很多开发者入门嵌入式或复古游戏开发时会先在 PC 上用 Pygame、Unity 做 2D 游戏。但 PC 平台的资源过于丰富程序写起来往往不太需要考虑内存也不太需要关心 CPU 频率。Game Boy 完全不同。一台 Game Boy 的硬件规格大致如下CPU8 位主频约 4.19 MHz。工作内存8KB。显存VRAM8KB。屏幕分辨率160 x 144 像素。最多同时显示 40 个精灵但每行扫描线最多只能显示 10 个。画面使用 4 色调色板。声音为 4 个通道的方波、噪声等合成音源。这些数字放到今天连最普通的单片机都不如但正是这种限制逼着开发者思考“怎么做才高效”。你在 Game Boy 上学到的内存管理、帧同步、状态机、贴图优化放到其他嵌入式平台或者游戏开发中依然有效。1.3 自制游戏绕不开的现实问题做 Game Boy 自制游戏之前要先认识两个现实问题。第一Game Boy 的 ROM 可以做得很大但程序能直接访问的内存非常有限。普通地址空间只有 64KB要扩展容量就得用卡带切换 bank 的机制。入门阶段不需要关心 bank但如果做《黑城堡 2》这样有多张地图的游戏迟早会接触到。第二不要试图把 PC 游戏思维直接搬过来。Game Boy 屏幕小、颜色少、声音合成方式特殊强行塞入复杂美术资源只会让程序变得臃肿。好的 Game Boy 自制游戏通常围绕“单一核心玩法”展开比如移动、跳跃、解谜、收集而不是模仿 3A 游戏。《黑城堡 2》的定位就是一个偏探索和解谜的俯视角小游戏先把移动、地图、碰撞这些基本功做好再慢慢加入敌人和道具系统。2. 开发环境准备2.1 工具链选择GBDK 还是 RGBDSGame Boy 自制游戏的主流工具链有两个工具链开发语言适合人群GBDKC 语言入门快代码可读性好适合大多数自制游戏RGBDS汇编语言控制精度最高但开发效率较低本文选择 GBDK。原因是《黑城堡 2》需要快速迭代地图和逻辑C 语言的开发效率明显更高。GBDK 底层其实还是编译成汇编最终生成的 ROM 同样可以在模拟器和真实硬件上运行。GBDK 的核心命令是lcc它封装了编译、汇编、链接等步骤。一个main.c文件经过lcc处理后可以直接生成.gb格式的 ROM 文件。2.2 安装 GBDK 和模拟器GBDK 目前以 4.x 系列为主不同操作系统的安装方式不同。Windows从 GBDK 官方发布页下载对应版本的压缩包。解压到本地目录例如D:\gbdk。把D:\gbdk\bin添加到系统 PATH 环境变量。macOS可以使用 Homebrew 安装也可以直接下载预编译版本。如果使用 Homebrew注意安装后确认lcc命令是否在 PATH 中。Linux可以下载预编译包也可以从源码自行编译。不同发行版包管理器里的版本可能较旧建议优先使用官方发布包。模拟器方面常见的选择有BGB老牌 Windows 模拟器调试功能强。Emulicious精确度高适合排查硬件行为差异。SameBoy支持 macOS、Linux文档完善。网页模拟器适合快速演示但不适合调试。安装完成后打开模拟器能正常加载官方示例 ROM 说明环境没问题。2.3 项目目录结构《黑城堡 2》的项目目录建议这样组织blackcastle2/ ├── src/ │ ├── main.c │ ├── player.c │ ├── player.h │ ├── map.c │ ├── map.h │ └── game_state.h ├── res/ │ ├── tiles.c │ ├── tiles.h │ └── map_data.c ├── Makefile └── README.mdsrc放源码res放瓦片、地图数据Makefile 负责编译。入门阶段可以先把所有 C 代码写在一个main.c里等逻辑变多再拆分。本文为了演示清晰会先用单个文件跑通再介绍如何模块化。3. Game Boy 的画面与内存模型3.1 背景、瓦片与精灵Game Boy 的画面由两层组成背景层和精灵层。背景层使用“瓦片地图”的方式绘制。整个背景地图逻辑上是 32x32 个瓦片每个瓦片是 8x8 像素。屏幕只能显示其中 20x18 个瓦片也就是 160x144 像素。当角色移动或镜头滚动时程序通过修改背景卷轴坐标来显示地图的不同区域。精灵是独立于背景绘制的小图像同样使用 8x8 瓦片也可以组合成 8x16 大小。玩家角色、敌人、道具通常都是精灵。精灵可以移动、翻转、隐藏但数量有限超出数量的精灵会被硬件忽略。一个常见的误区是“我直接往屏幕上画一个角色”。Game Boy 没有这种 API你需要先把自己设计的像素图变成瓦片数据再把瓦片放入精灵或背景然后告诉硬件在哪个位置显示。3.2 调色板与颜色限制Game Boy 的屏幕理论上只有 4 种颜色默认是白、浅灰、深灰、黑。实际在真实硬件上颜色会有偏色但程序里始终只有 4 个索引值。背景和精灵可以分别设置不同的调色板但同一个背景或同一个精灵的调色板在单色模式下是一致的。RGB 颜色、Alpha 通道这些概念在 Game Boy 上都不存在。这意味着美术资源设计时要主动把颜色减少到 4 级灰度。对《黑城堡 2》这种地牢主题这个限制反而让画面显得统一。3.3 按键输入Game Boy 有方向键、A、B、Start、Select 两组按键。GBDK 中通过joypad()函数读取当前按键状态返回一个 8 位整数。常用按键常量宏对应按键J_LEFT左J_RIGHT右J_UP上J_DOWN下J_AAJ_BBJ_STARTStartJ_SELECTSelect在while循环中不断读取joypad()响应按键变化是 Game Boy 游戏处理输入的基本方式。4. 《黑城堡 2》玩法设计与程序结构4.1 玩法设定《黑城堡 2》的构思是一个俯视角地牢探索游戏。玩家控制一名骑士在黑城堡中穿行收集钥匙、打开门、躲避巡逻的敌人最终到达下一层出口。本节的示例原型不会把完整玩法全部实现而是重点完成三个核心机制。角色在地图上移动。地图四周有墙壁角色不能穿墙。画面稳定运行不闪烁。把这些基础做扎实后敌人 AI、道具、音效都可以在这个骨架上继续加。4.2 游戏状态机游戏无论多复杂都可以抽象成几种状态。例如标题状态。游戏中状态。暂停状态。过关状态。每个状态内部处理自己的输入和更新逻辑。状态切换时可能需要清空当前画面、重置变量或加载新地图。《黑城堡 2》的示例原型先只实现“游戏中”状态但代码会保留状态机的雏形方便后续扩展。4.3 数据结构设计一个简单的玩家结构可以这样设计typedef struct { UINT8 x; UINT8 y; UINT8 direction; UINT8 moving; } Player;由于 Game Boy 内存有限结构体字段用 8 位类型能省则省。坐标用像素还是瓦片坐标取决于单位。示例中玩家按像素移动墙壁碰撞时再按瓦片坐标判断。地图方面最简单的方式是用一个unsigned char数组保存瓦片索引。数字 0 表示地板数字 1 表示墙壁数字 2 表示其他障碍。这样地图读取和碰撞判断都比较直观。5. 完整实战从零实现《黑城堡 2》可运行原型接下来进入完整代码阶段。这一节会给出一个可编译、可运行的 GBDK 项目。为降低上手成本我们把瓦片、地图、玩家逻辑统一放在同一个main.c中。5.1 创建工程目录打开命令行创建项目目录。mkdir blackcastle2 cd blackcastle2 mkdir src5.2 编写瓦片数据Game Boy 瓦片是 2bpp 格式每个像素用 2 bit 表示一个 8x8 瓦片总共占用 16 字节。每行有两个字节第一个字节保存所有像素的低位第二个字节保存所有像素的高位。为了演示我们定义 3 个瓦片瓦片 0空白地板。瓦片 1黑色实心方块作为墙壁。瓦片 2灰色实心方块作为装饰或可交互物体。文件路径src/main.c#include gb/gb.h // 地板全白16 个 0x00 const unsigned char tile_floor[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; // 墙壁全黑每行两个 0xFF const unsigned char tile_wall[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; // 装饰浅灰实心低位全 1高位全 0 const unsigned char tile_deco[] { 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00 };这里需要注意0x00, 0x00表示四个索引里的 0对应默认调色板的白色0xFF, 0xFF表示索引 3对应黑色0xFF, 0x00表示索引 1对应浅灰。读者可以按这个规则自行绘制想要的图案。5.3 设计地图数据我们定义一个逻辑上的 32x32 地图但屏幕上只绘制左上角 20x18 个瓦片。为了简化初始化用一个全局数组保存地图数据并在main中填充。unsigned char map[32 * 32];填充地图的思路是先全部铺地板再在外围设置一圈墙壁再在内部加几个装饰方块。void init_map() { unsigned int i; unsigned int x; unsigned int y; for (i 0; i 32 * 32; i) { map[i] 0; } for (x 0; x 32; x) { map[x] 1; map[31 * 32 x] 1; } for (y 0; y 32; y) { map[y * 32] 1; map[y * 32 31] 1; } // 内部放几个装饰瓦片 map[5 * 32 5] 2; map[5 * 32 20] 2; map[20 * 32 5] 2; map[20 * 32 20] 2; }拍平成一维数组后map[y * 32 x]就可以访问第 y 行第 x 列的瓦片。这种写法在 Game Boy 开发中很常见因为硬件访问连续内存比二维数组更快。5.4 添加玩家结构玩家用精灵表示坐标以像素为单位。为了让角色移动不那么呆板加入移动速度和方向。typedef struct { UINT8 x; UINT8 y; UINT8 speed; UINT8 moving; } Player; Player hero;UINT8是 GBDK 提供的无符号 8 位整数类型gb/gb.h中已经包含相关定义。如果你更习惯标准 C也可以使用stdint.h中的uint8_t。5.5 初始化游戏在main中完成硬件初始化。void game_init() { // 设置背景瓦片数据索引 0、1、2 分别对应三个瓦片 set_bkg_data(0, 3, tile_floor); // 将地图数据写入背景地图 init_map(); set_bkg_tiles(0, 0, 20, 18, map); // 设置玩家精灵 set_sprite_data(0, 1, tile_wall); set_sprite_tile(0, 0); hero.x 40; hero.y 40; hero.speed 1; hero.moving 0; move_sprite(0, hero.x, hero.y); // 打开背景和精灵显示 SHOW_BKG; SHOW_SPRITES; DISPLAY_ON; }这段代码有几个关键点。set_bkg_data(0, 3, tile_floor)表示把tile_floor数组作为索引 0 开始的 3 个瓦片加载到显存。由于tile_floor、tile_wall、tile_deco是连续定义的它们数组名之间并不保证内存连续因此这里将三个瓦片合并到一个数组更严谨。为了不误导读者我把瓦片数组合并成一个const unsigned char tileset[] { // Tile 0: 地板 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Tile 1: 墙壁 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // Tile 2: 装饰 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00 };初始化时set_bkg_data(0, 3, tileset);玩家精灵使用墙壁瓦片作为临时素材set_sprite_data(0, 1, tileset 16); set_sprite_tile(0, 0);tileset 16指向第二个瓦片也就是黑色方块。5.6 输入处理与移动接下来实现按键响应。为了控制移动速度不让角色一帧内跳太多像素可以用一个简单帧计数来控制更新频率。UINT8 frame_count 0; void process_input() { UINT8 joy joypad(); hero.moving 0; if (joy J_LEFT) { hero.x hero.x - hero.speed; hero.moving 1; } if (joy J_RIGHT) { hero.x hero.x hero.speed; hero.moving 1; } if (joy J_UP) { hero.y hero.y - hero.speed; hero.moving 1; } if (joy J_DOWN) { hero.y hero.y hero.speed; hero.moving 1; } }这里直接把坐标加减 1。初版先用边界碰撞防止角色跑出屏幕。void clamp_player() { if (hero.x 8) hero.x 8; if (hero.x 152) hero.x 152; if (hero.y 8) hero.y 8; if (hero.y 136) hero.y 136; }边界碰撞只是最简单的一层保护。后续想要真正的墙壁碰撞需要把像素坐标换算成瓦片坐标再读取地图数组。像素坐标到瓦片坐标的换算方法是unsigned int tile_x hero.x / 8; unsigned int tile_y hero.y / 8;然后通过map[tile_y * 32 tile_x]判断当前是地板还是墙。注意精灵坐标的原点位于精灵左上角不同模拟器对精灵显示区域的处理略有差异。真实硬件上精灵可以有一个像素级的偏移入门阶段不需要深究先用边界限制让角色保持在屏幕内即可。5.7 主循环与帧同步Game Boy 屏幕刷新率是 59.7 帧每秒。如果程序不做帧同步画面会出现撕裂和闪烁。GBDK 中通过wait_vbl_done()等待垂直空白帧。void main() { game_init(); while (1) { process_input(); clamp_player(); move_sprite(0, hero.x, hero.y); wait_vbl_done(); } }move_sprite在每次循环末尾更新精灵位置wait_vbl_done保证更新发生在屏幕扫描间隙。这样玩家看到的就是稳定移动的画面而不是撕裂的图像。5.8 编译 ar ROM在项目根目录执行编译命令。lcc -o blackcastle2.gb src/main.c如果lcc不在 PATH 中需要使用 GBDK 安装目录下的完整路径。例如/your/gbdk/path/bin/lcc -o blackcastle2.gb src/main.c编译成功后项目目录会出现blackcastle2.gb文件。用模拟器打开这个文件就能看到画面。预期效果是屏幕四周有黑色墙壁。角色是一个黑色方块可以用方向键移动。角色碰触屏幕边缘时会被拦住。5.9 添加简单的墙壁碰撞前面只做了边界碰撞角色能穿墙。现在补上最简单的瓦片碰撞逻辑。在process_input中我们不直接修改真实坐标而是先计算一个临时坐标判断临时坐标对应瓦片是否为地板再决定是否更新真实坐标。void process_input() { UINT8 joy joypad(); UINT8 new_x hero.x; UINT8 new_y hero.y; unsigned int map_index; if (joy J_LEFT) new_x new_x - hero.speed; if (joy J_RIGHT) new_x new_x hero.speed; if (joy J_UP) new_y new_y - hero.speed; if (joy J_DOWN) new_y new_y hero.speed; map_index (new_y / 8) * 32 (new_x / 8); if (map[map_index] 0) { hero.x new_x; hero.y new_y; } clamp_player(); }这版碰撞很简单只检查玩家左上角所在的瓦片。实际游戏中玩家是一个有面积的对象应该同时检查四个角或者至少检查移动方向上的两个角。但作为原型这个判断已经足够说明“先算目标位置再查地图最后决定是否移动”的流程。6. 常见问题与排查思路Game Boy 开发因为工具链比较老旧报错信息和 PC 开发完全不同。下面按现象整理常见问题。问题现象常见原因解决思路编译报错找不到UINT8没有包含gb/gb.h在源文件头部添加#include gb/gb.h黑屏无内容忘记DISPLAY_ON初始化的最后调用DISPLAY_ON背景一片空白地图数据未加载或瓦片索引错误检查set_bkg_tiles参数画面闪烁没有帧同步主循环中调用wait_vbl_done()角色不能移动按键常量写错或模拟器未聚焦检查joypad()的J_LEFT等宏角色穿越墙壁只做了边界碰撞没有瓦片碰撞将像素坐标换算为瓦片坐标并读取地图数组模拟器运行速度异常主机 CPU 与模拟器设置不一致尝试不同模拟器对比运行效果精灵看起来明显闪烁每行精灵数量超限或 OAM 冲突减少同屏精灵数量避免过多 8x16 精灵混用6.1 编译阶段排查如果编译阶段报错优先看头文件和函数名是否写错。GBDK 中很多函数名是全大写加下划线例如SET_SPRITE_TILE是宏set_sprite_tile是函数。旧版本教程混用情况很多如果你下载的是新版 GBDK推荐使用set_sprite_tile这类小写函数。6.2 运行阶段排查运行阶段黑屏先用模拟器的调试器查看 CPU 是否卡在死循环。如果程序卡在某个while(1)死循环且没有wait_vbl_done模拟器会表现得很奇怪甚至看起来像死机。背景花屏时通常是地图数组访问越界。map长度为 1024下标计算错误时可能读取到其他变量数据导致画面随机堆叠瓦片。建议在调试版中限制map_index范围。6.3 性能与内存排查Game Boy 内存很小。如果全局数组加起来超过 8KB链接阶段不会报错但运行时会出现数据互相覆盖。排查方法是生成.map文件查看变量地址分布。lcc -Wl-m -o blackcastle2.gb src/main.c编译后阅读blackcastle2.map关注变量是否被分配到了互相重叠的地址。遇到这种情况优先把大块地图数据改成const放到 ROM 中。7. 最佳实践与工程建议7.1 数据放 ROM变量放 RAMGame Boy 的 RAM 只有 8KB所有可变状态都会占用 RAM。地图、精灵数据、文本等固定不变的内容尽量用const定义让链接器把它们放到 ROM 中。由于const数据在 ROM 中代码不能修改它们。如果需要运行时修改地图比如打开一扇门就要维护一层“动态地图”或者在 RAM 中保留一个可变副本。7.2 使用模块化文件结构当《黑城堡 2》的代码量变大后单个main.c会变得非常难维护。建议把玩家、地图、敌人、关卡分别拆成.c和.h文件。编译时用 Makefile 统一起所有源码文件GBDK_HOME /path/to/gbdk CC $(GBDK_HOME)/bin/lcc all: $(CC) -o blackcastle2.gb src/main.c src/player.c src/map.c clean: rm -f *.gb *.ihx *.map *.lst *.oMakefile 里的路径根据你自己的 GBDK 安装位置修改。Windows 下如果使用命令行可能需要用mingw32-make。7.3 状态机优先于散装逻辑新版《黑城堡 2》如果加入标题画面、暂停、过关动画强烈建议引入状态机。typedef enum { ST_TITLE, ST_PLAYING, ST_PAUSE, ST_LEVEL_CLEAR } GameState; GameState state;main循环里根据state分发到不同的 update 函数。不要把标题画面和游戏逻辑混在同一个 if 里否则后面扩展会非常痛苦。7.4 用工具批量生成瓦片和地图手写二进制瓦片数据只适合学习。真实项目中建议使用支持 Game Boy 格式的瓦片编辑器例如图像编辑器自定义调色板后导出 2bpp 数据。Tiled 编辑地图后按 32x32 地图大小导出 CSV。使用脚本把 CSV 转换成 C 数组。这样美术资源可以独立迭代代码部分只负责加载数据。7.5 多模拟器测试不同模拟器对硬件细节的模拟程度不一样。同一个.gb文件在 BGB、SameBoy、Emulicious 中可能出现细微差别尤其在声音和精灵扫描线效果上。发布前至少用两个以上的模拟器测试。真实硬件测试时注意烧录卡和电源等设备条件建议只在个人学习、自娱自乐和测试环境中使用自制 ROM不要传播未经授权或涉及商业素材的 ROM 内容。7.6 性能优化要注意的边界Game Boy 的 8 位 CPU 处理能力很低。如果循环里频繁做乘除法会产生大量 CPU 周期。常见的优化方式包括用左移右移代替乘以 2、除以 2。用查表代替复杂公式。把常用变量放在全局避免栈上反复分配。不要在主循环里复制大数组。在《黑城堡 2》中像素坐标转瓦片坐标使用new_x / 8是除法。由于除数是 8编译器通常能自动优化成移位但如果你用的是new_x / 10这类非 2 的整数次幂就要考虑是否需要预计算。7.7 版本管理不要只备份gb文件很多自制游戏开发者会直接把.gb文件拷贝到网盘。但二进制 ROM 文件无法可视化对比代码差异回滚版本时很容易丢失声明或逻辑。推荐使用 Git 管理源码并把*.gb、*.ihx、*.map、*.o加入.gitignore。*.gb *.ihx *.map *.lst *.o源码才是项目资产编译产物随时可以重新生成。8. 写在最后这次从零实现《黑城堡 2》的 Game Boy 原型其实只覆盖了自制游戏最核心的骨架屏幕输出、瓦片地图、精灵移动、输入响应、少量碰撞检测。真正让这个游戏变成“作品”的是后续不断加入的敌人逻辑、音效、关卡设计以及美术资源。在模拟器里看到自己控制的黑色方块在地图上移动时Game Boy 自制开发的第一步已经完成了。后面的路还很长但方向已经很清楚继续研究瓦片动画、8x16 精灵、卡带 bank 切换或者尝试用汇编优化鼠标级的操作细节都能让游戏越来越接近你想要的样子。有兴趣继续做的朋友建议确定的下一步是给《黑城堡 2》加入地图切换和敌人巡逻逻辑。这两块是俯视角游戏最常用的机制也能把状态机和碰撞系统真正串起来。祝你早日做出属于自己的 Game Boy 游戏。