这里是 HelloGitHub 推出的《讲解开源项目》系列,本期为您讲解的是 80、90 后的儿时记忆,诞生于 1978 年经典街机游戏《太空侵略者》也叫“小蜜蜂”的 C 语言复刻版——si78c。
这款游戏在当时可谓是风靡一时,相信很多朋友小时候都玩过。现在长大了,不知道有多少朋友对它的源码感兴趣呢!
原版的《太空侵略者》由大约 2k 行的 8080 汇编代码写成,但汇编语言太过底层不方便阅读,今天讲解的开源项目 si78c 是按照原版汇编代码用 C 语言重写了一遍,并最大程度还原了原版街机硬件的中断、协程逻辑,在运行时其内存状态也几乎与原始版本相同 几乎达到了完美的复刻,着实让我眼前一亮!
下面就请跟着 HelloGitHub 一起抽丝剥茧,运行这个开源项目、阅读源码,穿越历史感受 40 年前游戏设计的精妙之处!
一、快速开始
1. 准备工作
首先 si78c 使用 SDL2 绘制游戏窗口,所以需要安装依赖:
$ sudo apt-get install libsdl2-dev
然后从仓库下载源码:
$ git clone https://github.com/loadzero/si78c.git
此外,该项目会从原版的 ROM 中提取原版游戏的图片、字体,所以还需要下载原版的 ROM 文件
2. 文件结构
在 si78c 源码文件夹中新建名为 inv1 和 bin 的文件夹
$ cd si78c-master$ mkdir inv1 bin
然后将 invaders.zip 中的内容解压到 inv1 中,最后目录结构如下:
si78c-master├── bin├── inv1│ ├── invaders.e│ ├── invaders.f│ ├── invaders.g│ └── invaders.h├── Makefile├── README.md├── si78c.c└── si78c_proto.h
3. 编译与运行
使用 make 进行编译:
$ make
之后会在 bin 文件夹中生成可执行文件,运行即可启动游戏:
$ ./bin/si78c
游戏操控按键如下:
a LEFT(左移)d RIGHT(右移)1 1P(单人)2 2P(双人)j FIRE(射击)5 COIN(投币)t TILT(结束游戏)
二、 前置知识
2.1 简介
2.2 什么是协程
si78c 使用了 ucontex 库的 协程 模拟原版街机的进程调度和中断操作。
协程:协程更加轻便快捷、节省资源,协程 对于 线程 就相当于 线程 对于 进程。
其中 ucontext 提供了 getcontext()、makecontext()、swapcontext() 以及 setcontext() 函数实现协程的创建和切换,si78c 中的初始化函数为 init_thread。下面我们直接来看源码中的例子:
如果这里不够直观可以看后面状态转移图,图文结合更加直观。
代码 2-1
之后每次调用 yield() 都会使用 swapcontext() 进行两个协程间切换:
代码 2-2
static void yield(YieldReason reason){ // 调度原因 yield_reason = reason; // 调度到另一个协程上 switch_to(&frontend_ctx);}// 协程切换函数static void switch_to(ucontext_t *to){ // 给 co_switch 包装了一层,简化了代码量 co_switch(curr_ctx, to);}// 协程切换函数static void co_switch(ucontext_t *prev, ucontext_t *next){ prev_ctx = prev; curr_ctx = next; // 切换到 next 指向的上下文,将当前上下文保存在 prev 中 swapcontext(prev, next);}
具体用法请见后文
由于文章篇幅有限,下面只展示的关键源码部分。更详细的源码逐行中文注释:
地址:https://github.com/AnthonySun256/easy_games
2.3 模拟硬件
前文讲过,si78c 是原版街机游戏像素级的复刻,甚至大部分的内存数据也是相等的,为了做到这一点 si78c 模拟了街机的一部分硬件:RAM、ROM 和 显存,它们在代码中被封装成了一个名为 Mem 的大结构体,内存分配如下:
可以看出当年机器的 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 代表一列像素)。
我们可以找到名为 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
在 第一次 进入 loop_core() 时其流程如下:
图 3-2
因为 yield_rason 这个变量是 static 类型其默认值为零
代码 3-2
// 根据游戏状态标志切换到相应的上下文static int execute(int allowed){ int64_t start = ticks; ucontext_t *next = NULL; switch (yield_reason) { // 刚启动时 yield_reason 是 0 表示 YIELD_INIT case YIELD_INIT: // 当需要延迟的时候会调用 timeslice() 将 yield_reason 切换为 YIELD_TIMESLICE // 模拟时间片轮转,这个时候会切换回上一个运行的任务(统共就俩协程),实现时间片轮转 case YIELD_TIMESLICE: next = prev_ctx; break; case YIELD_INTFIN: // 处理完中断后让 int_ctx 休眠,重新运行 main_ctx next = &main_ctx; break; // 玩家死亡、等待开始、外星人入侵状态 case YIELD_PLAYER_DEATH: case YIELD_WAIT_FOR_START: case YIELD_INVADED: init_threads(yield_reason); enable_interrupts(); next = &main_ctx; break; // 退出游戏 case YIELD_TILT: init_threads(yield_reason); next = &main_ctx; break; default: assert(FALSE); } yield_reason = YIELD_UNKNOWN; // 如果有中断产生 if (allowed && interrupted()) { next = &int_ctx; } switch_to(next); return ticks - start;}
需要注意的是,在 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
// 处理中断的函数static void run_int_ctx(){ while (1) { // 0xcf = RST 1 opcode (call 0x8) // 0xd7 = RST 2 opcode (call 0x16) if (irq_vector == 0xcf) midscreen_int(); else if (irq_vector == 0xd7) vblank_int(); // 使能中断 enable_interrupts(); yield(YIELD_INTFIN); }}
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!