这里是 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. 文件构造在 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 简介
《太空侵略者》原版代码运行在 8080 处理器之上,其内容全部由汇编代码写成并涉及一些硬件操作,为了仿照原版街机代码逻辑以及效果,si78c 尽最大可能将汇编代码转换为 C 措辞并利用一个 Mem 的构造体仿照了原版街机的硬件,以是有些代码从纯软件的角度来讲是比较奇怪乃至是匪夷所思的,但限于篇幅缘故原由作者无法将代码全部贴进文章进行阐明,以是请读者合营本人详细注释代码阅读此文。
2.2 什么是协程si78c 利用了 ucontex 库的 协程 仿照原版街机的进程调度和中断操作。
协程:协程更加轻便快捷、节省资源,协程 对付 线程 就相称于 线程 对付 进程。
个中 ucontext 供应了 getcontext()、makecontext()、swapcontext() 以及 setcontext() 函数实现协程的创建和切换,si78c 中的初始化函数为 init_thread。下面我们直接来看源码中的例子:
如果这里不足直不雅观可以看后面状态转移图,图文结合更加直不雅观。
代码 2-1
// 切换协程时用的中间变量static ucontext_t frontend_ctx;// 游戏紧张逻辑协程static ucontext_t main_ctx;// 游戏中断逻辑协程static ucontext_t int_ctx;// 用于切换两个协程static ucontext_t prev_ctx;static ucontext_t curr_ctx;// 初始化游戏协程static void init_threads(YieldReason entry_point){ // 获取当前高下文,存储在 main_ctx 中 int rc = getcontext(&main_ctx); assert(rc == 0); // 指定栈空间 main_ctx.uc_stack.ss_sp = main_ctx_stack; // 指定栈空间大小 main_ctx.uc_stack.ss_size = STACK_SIZE; // 设置后继高下文 main_ctx.uc_link = &frontend_ctx; // 修正 main_ctx 高下文指向 run_main_ctx 函数 makecontext(&main_ctx, (void ()())run_main_ctx, 1, entry_point); / 以上内容相称于新建了一个叫 main_cxt 的协程,运行 run_main_ctx 函数, frontend_ctx 为后继高下文 (run_main_ctx 运行完毕之后会接着运行 frontend_ctx 记录的高下文) 协程 对付 线程,就相称于 线程 对付 进程 只是协程切换开销更小,用起来更加轻便 / // 获取当前高下文存储在 init_ctx 中 rc = getcontext(&int_ctx); // 指定栈空间 int_ctx.uc_stack.ss_sp = &int_ctx_stack; // 指定栈空间大小 int_ctx.uc_stack.ss_size = STACK_SIZE; // 设置后继高下文 int_ctx.uc_link = &frontend_ctx; // 修正高下文指向 run_init_ctx 函数 makecontext(&int_ctx, run_int_ctx, 0); / 以上内容相称于新建了一个叫 int_ctx 的协程,运行 run_int_ctx 函数, frontend_ctx 为后继高下文 (run_int_ctx 运行完毕之后会接着运行 frontend_ctx 记录的高下文) 协程 对付 线程,就相称于 线程 对付 进程 只是协程切换开销更小,用起来更加轻便 / // 给 pre_ctx 初始值,在第一次调用 timeslice() 时候能切换到 main_ctx 运行 prev_ctx = &main_ctx; // 给 curr_ctx 初始值,这时候 frontend_ctx 还是空的 // frontend_ctx 会在高下文切换的时候用于保存上一个协程的状态 curr_ctx = &frontend_ctx;}
之后每次调用 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 的大构造体,内存分配如下:
0000-1FFF 8K ROM2000-23FF 1K RAM2400-3FFF 7K Video RAM4000- 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
int main(int argc, char argv){ // 初始化 SDL 和 游戏窗口 init_renderer(); // 初始化游戏 init_game(); int credit = 0; size_t frame = -1; // 开始游戏协程调度与仿照触发中断 while (1) { frame++; // 处理按键输入 input(); // 如果退出标志置位推出循环清理游戏内存 if (exited) break; // preserves timing compatibility with MAME // 保留与 MAME(一种街机) 的时序兼容性 if (frame == 1) credit--; / 实行其他进程大概 CRED1 的韶光 (为什么是这个数我也不知道,该当是估计值) (原作者也说这种定时方法不是很准确但不影响游戏效果) / credit += CRED1; loop_core(&credit); // 设置场中间中断标志位,不才面的 loop_core() 中会切换到 int_ctx 实行一次,然后打消标志位 irq(0xcf); // 道理同上 credit += CRED2; loop_core(&credit); // 设置垂直消隐中断标志位,下个循环时候 loop_core() 中会切换到 int_ctx 实行一次,然后打消标志位 irq(0xd7); // 绘制游戏界面 render(); } fini_game(); fini_renderer(); return 0;}
启动过程如图所示:
图 3-1
游戏原版代码(8080 汇编)利用的是中断驱动(这种编程办法和硬件有关,详细内容可以自行理解什么是 中断)合营协程多任务操作。为了仿照原版游戏逻辑作者以 main() 中大循环作为硬件行为仿照中央(实现中断管理、协程切换、屏幕渲染)。游戏大约三分之一的韶光在运行 主线程,主线程 会被 midscreen 和 vblank 两个中断抢占,代码 3-1 中两个 irq() 就实现了对中断的仿照(设置对应的变量作为标志位)。
在 第一次 进入 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); }}
我们先来看 midscreen_int():
代码 4-2
/ 在光将要击中屏幕中间(该当是仿照老式街机的现实事理)时由中断触发 紧张处理游戏工具的移动、开火、碰撞等等的检测更新与绘制(详细看函数 GameObj0到4) 以及确定下一个将要绘制哪个外星人,检测外星人是不是入侵成功了 /static void midscreen_int(){ // 更新 vblank 标志位 m.vblankStatus = BEAM_MIDDLE; // 如果没有运动的游戏工具,返回 if (m.gameTasksRunning == 0) return; // 在欢迎界面 且 没有在演示模式,返回(只在游戏模式 和 demo模式下连续运行) if (!m.gameMode && !(m.isrSplashTask & 0x1)) return; // 运行 game objects 但是略过第一个入口(玩家) RunGameObjs(u16_to_ptr(PLAYER_SHOT_ADDR)); // 确定下一个将要绘制的外星人 CursorNextAlien();}
在这一部分中 RunGameObjs() 函数基本上包括了玩家的移动和绘制,玩家子弹和外星人子弹的移动、碰撞检测、绘制等等所有游戏逻辑的处理,CursorNextAlien() 则找到要绘制的下一个活着的外星人设置标志位等待绘制,并且检测外星飞船是否碰到了屏幕底端。
运行结束后会返回到 run_int_ctx() 连续运行直到 yield(YIELD_INTFIN) 表示协程切换回 execute(),并在 execute() 中重新将 next 设定为 main_ctx 使 main_init() 能够连续运行(详情见代码 3-2)。
接下来是 vblank_int():
代码 4-3
/ 当光击中屏幕末了一点(仿照老式街机事理)时触发 紧张处理游戏结束、投币、游戏中各种事宜处理、播放演示动画 /static void vblank_int(){ // 更新标志位 m.vblankStatus = BEAM_VBLANK; // 计时器减少 m.isrDelay--; // 看看是不是结束游戏 CheckHandleTilt(); // 看看是不是投币了 vblank_coins(); // 如果游戏任务没有运行,返回 if (m.gameTasksRunning == 0) return; // 如果在游戏中的话 if (m.gameMode) { TimeFleetSound(); m.shotSync = m.rolShotHeader.TimerExtra; DrawAlien(); RunGameObjs(u16_to_ptr(PLAYER_ADDR)); TimeToSaucer(); return; } // 如果投币过了 if (m.numCoins != 0) { // xref 005d if (m.waitStartLoop) return; m.waitStartLoop = 1; // 切换协程到等待开始循环 yield(YIELD_WAIT_FOR_START); assert(FALSE); // 不会再返回了 } // 如果以上事情都没发生,播放演示动画 ISRSplTasks();}
其紧张浸染一是检测玩家是否想要退出游戏或是进行了投币操作,如果已经处于游戏模式中则依次播放舰队声音、绘制在 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 不同于其他代码,它实质上是对硬件和汇编代码的仿真,希望通过本文的源码讲解,让更多人看到当年程序员们在有限资源下制作出精良游戏的困难,还有代码设计的精妙。
末了,感谢本项目作者所做的统统,没有他的付出也就不会有这篇文章。如果您以为这篇文章还不错,欢迎分享给更多人。