重玩 40 年前的经典游戏小蜜蜂,这次通关了源码
本文适合有 C 语言基础的朋友
这里是 HelloGitHub 推出的《讲解开源项目》系列,本期为您讲解的是 80、90 后的儿时记忆,诞生于 1978 年经典街机游戏《太空侵略者》也叫“小蜜蜂”的 C 语言复刻版——si78c。
这款游戏在当时可谓是风靡一时,相信很多朋友小时候都玩过。现在长大了,不知道有多少朋友对它的源码感兴趣呢!
原版的《太空侵略者》由大约 2k 行的 8080 汇编代码写成,但汇编语言太过底层不方便阅读,今天讲解的开源项目 si78c 是按照原版汇编代码用 C 语言重写了一遍,并最大程度还原了原版街机硬件的中断、协程逻辑,在运行时其内存状态也几乎与原始版本相同 几乎达到了完美的复刻,着实让我眼前一亮!
下面就请跟着 HelloGitHub 一起抽丝剥茧,运行这个开源项目、阅读源码,穿越历史感受 40 年前游戏设计的精妙之处!
一、快速开始
本文的实验环境为 Ubuntu 20.04 LTS,GCC 版本大于 GCC 3
1. 准备工作
首先 si78c 使用 SDL2 绘制游戏窗口,所以需要安装依赖:
$ sudo apt-get install libsdl2-dev然后从仓库下载源码:
$ git clone https://github.com/loadzero/si78c.git此外,该项目会从原版的 ROM 中提取原版游戏的图片、字体,所以还需要下载原版的 ROM 文件
2. 文件结构
3. 编译与运行
使用 make 进行编译:
$ make之后会在 bin 文件夹中生成可执行文件,运行即可启动游戏:
$ ./bin/si78c游戏操控按键如下:
二、 前置知识
2.1 简介
《太空侵略者》原版代码运行在 8080 处理器之上,其内容全部由汇编代码写成并涉及一些硬件操作,为了模拟原版街机代码逻辑以及效果,si78c 尽最大可能将汇编代码转换为 C 语言并使用一个 Mem 的结构体模拟了原版街机的硬件,所以有些代码从纯软件的角度来讲是比较奇怪甚至是匪夷所思的,但限于篇幅原因作者无法将代码全部贴进文章进行解释,所以请读者配合本人详细注释代码阅读此文。
2.2 什么是协程
si78c 使用了 ucontex 库的 协程 模拟原版街机的进程调度和中断操作。
协程:协程更加轻便快捷、节省资源,协程 对于 线程 就相当于 线程 对于 进程。
其中 ucontext 提供了 getcontext()、makecontext()、swapcontext() 以及 setcontext() 函数实现协程的创建和切换,si78c 中的初始化函数为 init_thread。下面我们直接来看源码中的例子:
如果这里不够直观可以看后面状态转移图,图文结合更加直观。
代码 2-1
之后每次调用 yield() 都会使用 swapcontext() 进行两个协程间切换:
代码 2-2
具体用法请见后文
由于文章篇幅有限,下面只展示的关键源码部分。
2.3 模拟硬件
前文讲过,si78c 是原版街机游戏像素级的复刻,甚至大部分的内存数据也是相等的,为了做到这一点 si78c 模拟了街机的一部分硬件:RAM、ROM 和 显存,它们在代码中被封装成了一个名为 Mem 的大结构体,内存分配如下:
- 0000-1FFF 8K ROM
- 2000-23FF 1K RAM
- 2400-3FFF 7K Video RAM
- 4000- RAM mirror
可以看出当年机器的 RAM 只有可怜的 1kb 大小,每一个比特都弥足珍贵需要程序认真规划。这里有张 RAM 分配情况表,更多详情
2.4 从模拟显存到屏幕
在详细解释游戏动画显示原理以前,我们需要先了解一下游戏的素材是怎么存储的:
图 2-1
图片来自于街机汇编代码解读
在街机原版 ROM 中,游戏素材直接以二进制格式保存在内存中,其中每一位二进制表示当前位置像素是黑还是白
比如 图 2-1 中显示 0x1BA0 位置的内存数据为 00 03 04 78 14 13 08 1A 3D 68 FC FC 68 3D 1A 00 八位一行 排列和出来就是一个外星人带着一个颠倒字母 “Y” 的图片(图中的内容看起来像是旋转了 90 度这是因为图片是一列一列存储的,每 8 bit 代表一列像素)。
si78c 的作者在显示图片的时候直接将 X Y 轴进行了交换以达到旋转图片的效果。
我们可以找到名为 Mem 的结构体,其中的 m.vram (0x2400 到 0x3FFF)模拟了街机的显存,这里面每一个 bit 代表一个像素的黑(0)白(1),从左下角向右上角进行渲染,其对应关系如图 2-2:
图 2-2
游戏中所有跟动画绘制有关的代码都是在修改这部分区域的数据,例如 DrawChar()、ClearPlayField()、 DrawSimpSprite() 等等。那么怎么让模拟现存的内容显示到玩家的屏幕上呢?注意看代码 3-1 中在循环的末尾调用了 render() 函数,它负责的就挨个读取模拟显存中的内容并在窗口上有像素块的地方渲染一个像素块。
仔细想想不难发现,这种先修改模拟显存再统一绘制的方法其实没有多省事,甚至有些怪异。这是因为 si78c 模拟了街机硬件的显示过程:修改相应的显存然后硬件会自动将显存中的内容显示到屏幕上。
2.5 按键检测
代码 3-1 中的 input() 函数负责检测并存储用户的按键信息,其底层依赖 SDL 库。
三、首次启动
si78c 和所有的 C 程序一样,都是从 main() 函数开始运行:
代码 3-1
启动过程如图所示:
图 3-1
游戏原版代码(8080 汇编)使用的是中断驱动(这种编程方式和硬件有关,具体内容可以自行了解什么是 中断)配合协程多任务操作。为了模拟原版游戏逻辑作者以 main() 中大循环作为硬件行为模拟中心(实现中断管理、协程切换、屏幕渲染)。游戏大约三分之一的时间在运行 主线程,主线程 会被 midscreen 和 vblank 两个中断抢占,代码 3-1 中两个 irq() 就实现了对中断的模拟(设置对应的变量作为标志位)。
在 第一次 进入 loop_core() 时其流程如下:
图 3-2
因为 yield_rason 这个变量是 static 类型其默认值为零
代码 3-2
需要注意的是,在 execute() 中进行了协程的切换,这个时候 execute() 的运行状态就被保存在了变量 frontend_ctx 之中,指针 prev_ctx 更新为指向 frontend_ctx,指针 curr_ctx 更新为指向 main_ctx,其过程如图所示:
图 3-3
实现解释请见代码 2-2
当 execute() 返回时他会按照正常的执行流程返回到 loop_core(),就像它从未被暂停过一样。
仔细观察 main_init 中主循环我们可以发现其多次调用 timeslice() 函数(例如 OneSecDelay() 中),通过这个函数我们就可以实现 main_ctx 与 frontend_ctx 间的时间片轮转操作,其过程如下:
图 3-4
在 main_init() 中主要做了如下事情:
在玩家投币前,游戏会依靠 main_init() 循环播放动画吸引玩家
如果只翻看 main_init() 中出现的函数我们会发现代码中并未涉及太多的游戏逻辑,例如外星人移动、射击,玩家投币检查等内容好像根本不存在一样,更多的时候是在操纵内存、设置标志位。那么有关游戏游戏逻辑处理相关的函数又在哪里呢?这部分内容将在下面揭秘。
四、模拟中断
在 代码 3-1 中 loop_core() 函数被两个 irq() 分隔了开来。我们之前提到 main() 中的大循环本质上是在模拟街机的硬件行为,在真实的机器上中断是只有在触发时才会执行,但在 si78c 上我们只能通过在 loop_core() 之间调用 irq() 来模拟产生中断并在 execute() 中轮询中断状态来判断是不是进入中断处理函数,过程如下:
这时它的协程状态如下:
有两种中断:midscreen_int() 与 vblank_int() 这两种中断会轮流出现。
代码 4-1
我们先来看 midscreen_int():
代码 4-2
在这一部分中 RunGameObjs() 函数基本上包括了玩家的移动和绘制,玩家子弹和外星人子弹的移动、碰撞检测、绘制等等所有游戏逻辑的处理,CursorNextAlien() 则找到要绘制的下一个活着的外星人设置标志位等待绘制,并且检测外星飞船是否碰到了屏幕底端。
运行结束后会返回到 run_int_ctx() 继续运行直到 yield(YIELD_INTFIN) 表示协程切换回 execute(),并在 execute() 中重新将 next 设定为 main_ctx 使 main_init() 能够继续运行(详情见代码 3-2)。
接下来是 vblank_int():
代码 4-3
其主要作用一是检测玩家是否想要退出游戏或是进行了投币操作,如果已经处于游戏模式中则依次播放舰队声音、绘制在 midscreen_int() 中标记出的外星人、运行 RunGameObjs() 处理玩家和外星人开火与移动事件、TimeToSaucer() 随机生成神秘飞碟。如果未在游戏模式中则进入 ISRSplTasks() 调整当前屏幕上应该播放的动画。
我们可以注意到,如果玩家进行了投币会进入 if (m.numCoins != 0) 里,并调用 yield(YIELD_WAIT_FOR_START) 后面会提示这个函数不会再返回。在 si78c 的代码中许多地方都会有这样的提示,这里并不是简单的调用一个不会返回的函数进行套娃。
观察 代码 3-2 可以发现在 YIELD_PLAYER_DEATH、YIELD_WAIT_FOR_START、YIELD_INVADED、YIELD_TILT 这四种分支中都调用了 init_threads(yield_reason),在这个函数里会重置 int_ctx 与 main_ctx 的堆栈并重新绑定调用 run_main_ctx 时的参数为 yield_reason,这样在下一次执行的时候 run_main_ctx 就会根据中断的指示跳转到合适的分支去运行。
五、巧妙地节省 RAM
开篇的时候提到过,当年街机的 RAM 只有可怜的 1kb 大小,这样小的地方必定无法让我们存储屏幕上每个对象的信息,但是玩家的位置、外星人的位置以及它们的子弹、屏幕上的盾牌损坏情况都是会实时更新的,如何做到这一点呢?
我发现《太空侵略者》游戏区域内容分布还是很有规律的,特殊飞船(飞碟)只会出现在屏幕上端,盾牌和玩家的位置不会改变,只有子弹的位置不好把握,所以仔细研读代码,从 DrawSpriteGeneric() 可以看出,游戏对于碰撞的检测只是简单的判断像素块是否重合,对于玩家子弹到底击中了什么在 PlayerShotHit() 函数进行判断时,则只需要判断子弹垂直方向坐标(Y坐标),如果 >= 216 则是撞到上顶,>=206 则是击中神秘飞碟,其他则是击中护盾或者外星人的子弹。且由于外星飞船的是成组一起运动,只需要记住其中一个的位置就能推算出整体每一个外星飞船的坐标。
这样算下来,程序只需要保存外星飞船的存活状态、当前舰队的相对移动位置、玩家和外星人子弹信息,在需要检测碰撞时则去读取显存中的像素信息进行对比然后反推当前时哪两样物体发生了碰撞即可,这种方法相比存储每一个对象的信息节省了不少资源。
六、结语
si78c 不同于其他代码,它本质上是对硬件和汇编代码的仿真,希望通过本文的源码讲解,让更多人看到当年程序员们在有限资源下制作出优秀游戏的困难,还有代码设计的精妙。
最后,感谢本项目作者所做的一切,没有他的付出也就不会有这篇文章。如果您觉得这篇文章还不错,欢迎分享给更多人。