用类似Lisp语言编写的现代NES游戏

What Remains是一款针对8位NES视频游戏机的叙事冒险游戏,于2019年3月发布,作为在模拟器中运行的免费ROM发行。 它是由一个由Iodine Dynamics组成的小型小组间歇地创建的,历时两年。 目前,该游戏正处于硬件的实施阶段:我们正在使用回收零件制造一套有限的墨盒。


该游戏分为6个等级,玩家可以使用四向滚动卡​​在多个场景中漫步,与NPC进行交流,收集线索,了解他们的世界,玩迷你游戏并解决简单的难题。 我是该项目的首席工程师,因此在实现团队愿景时遇到了许多困难。 鉴于NES设备的严重局限性,要为其创建任何游戏都是非常困难的,更不用说项目的内容与《剩余》中的内容一样多。 仅由于创建了有用的子系统,该子系统使我们能够隐藏并管理这种复杂性,我们才能够团队合作并完成游戏。


在本文中,我将讨论游戏引擎各个部分的一些技术细节。 我希望其他开发人员会发现它们有用或至少感到好奇。

NES设备


在开始编写代码之前,我将向您简要介绍我们使用的设备的规格。 NES是1983年(日本,1985年-美国)发布的游戏机。 它的内部有一个8位CPU 6502 [1],其频率为1.79 MHz。 由于控制台每秒产生60帧,因此每帧大约有3万个CPU周期,对于计算主要游戏周期中发生的所有事件而言,这是很小的。

此外,控制台总共具有2048字节的RAM(可以使用其他RAM扩展到10,240字节,而我们没有这样做)。 它也可以一次寻址32 KB的ROM,可以通过切换存储区来扩展(剩余容量使用512 KB的ROM)。 切换存储库是现代程序员无法处理的复杂主题[2]。 简而言之,CPU可用的地址空间小于ROM中包含的数据,也就是说,当手动切换时,整个存储块仍然无法访问。 您要调用某些功能吗? 直到您通过调用库切换命令来更换库。 如果不这样做,则在调用函数时,程序将崩溃。

实际上,为NES开发游戏时,最困难的事情是同时考虑所有这些。 优化代码的一个方面(例如内存使用情况)通常会影响其他方面,例如CPU性能。 该代码应该有效,同时支持起来也很方便。 通常,游戏是用汇编语言编写的。

二氧化碳


但就我们而言,并非如此。 相反,与游戏串联将发展其自己的语言。 Co2是一种类似Rasp的语言,它是基于球拍方案构建的,并被编译为汇编器6502。最初,该语言是由Dave Griffiths创建的,用于构建What Remains演示,我决定在整个项目中使用它。

Co2允许您在必要时编写内置的汇编代码,但它也具有简化某些任务的高级功能。 它实现了对RAM消耗和访问速度均有效的局部变量[2]。 它有一个非常简单的宏系统,使您可以编写可读且高效的代码[3]。 最重要的是,由于Lisp的同质性 ,它极大地简化了直接在源中显示数据的过程。

编写自己的工具在游戏开发中非常普遍,但是创建完整的编程语言要少得多。 但是,我们做到了。 尚不清楚开发和支持CO2的复杂性是否得到了证明,但它无疑具有帮助我们的优势。 在帖子中,我不会详细讨论Co2的工作(这值得单独撰写),但是我会不断提及它,因为它的使用与开发过程紧密相关。

以下是示例Co2代码,该代码在调暗场景之前为刚刚加载的场景绘制背景:

; Render the nametable for the scene at the camera position (defsub (create-initial-world) (camera-assign-cursor) (set! camera-cursor (+ camera-cursor 60)) (let ((preserve-camera-v)) (set! preserve-camera-v camera-v) (set! camera-v 0) (loop i 0 60 (set! delta-v #xff) (update-world-graphics) (when render-nt-span-has (set! render-nt-span-has #f) (apply-render-nt-span-buffer)) (when render-attr-span-has (set! render-attr-span-has #f) (apply-render-attr-span-buffer))) (set! camera-v preserve-camera-v)) (camera-assign-cursor)) 

实体系统



任何比《俄罗斯方块》更复杂的实时游戏,本质上都是一个“实体系统”。 此功能允许各种独立的参与者同时行动并负责自己的状况。 尽管《余生》绝不是一款活跃的游戏,但它仍然拥有许多行为复杂的独立演员:他们为自己设置动画并进行渲染,检查碰撞并引发对话。

该实现非常典型:大型数组包含场景中的实体列表,每个记录均包含与实体相关的数据以及类型标签。 主要游戏玩法周期中的更新功能会绕过所有实体,并根据其类型实现相应的行为。

 ; Called once per frame, to update each entity (defsub (update-entities) (when (not entity-npc-num) (return)) (loop k 0 entity-npc-num (let ((type)) (set! type (peek entity-npc-data (+ k entity-field-type))) (when (not (eq? type #xff)) (update-single-entity k type))))) 

存储实体数据的方式更加有趣。 通常,游戏具有如此众多的独特实体,以至于使用大量ROM可能会成为问题。 这里的CO2展示了其强大功能,使我们能够以简洁但易读的形式呈现场景的每个要素-作为键值对流。 除了诸如初始位置之类的数据外,几乎每个键都是可选的,这使它们仅在必要时才可以向实体声明。

 (bytes npc-diner-a 172 108 prop-palette 1 prop-hflip prop-picture picture-smoker-c prop-animation simple-cycle-animation prop-anim-limit 6 prop-head hair-flip-head-tile 2 prop-dont-turn-around prop-dialog-a (2 progress-stage-4 on-my-third my-dietician) prop-dialog-a (2 progress-stage-3 have-you-tried-the-pasta the-real-deal) prop-dialog-a (2 progress-diner-is-clean omg-this-cherry-pie its-like-a-party) prop-dialog-a (2 progress-stage-1 cant-taste-food puff-poof) prop-dialog-b (1 progress-stage-4 tea-party-is-not) prop-dialog-b (1 progress-stage-3 newspaper-owned-by-dnycorp) prop-dialog-b (1 progress-stage-2 they-paid-a-pr-guy) prop-dialog-b (1 progress-stage-1 it-seems-difficult) prop-customize (progress-stage-2 stop-smoking) 0) 

在此代码中, prop-palette设置用于实体的调色板, prop-anim-limit设置动画帧的数量,并且prop-dont-turn-around可以防止NPC在玩家尝试从另一侧与他交谈时转向。 它还设置了几个条件标志,这些条件标志会在玩家通过游戏的过程中更改实体的行为。

这种表示形式对于将内容存储在ROM中非常有效,但是在运行时访问它的速度非常慢,并且对于游戏而言效率太低。 因此,当玩家进入新场景时,该场景中的所有实体都将加载到RAM中并处理可能影响其初始状态的所有条件。 但是您不能为每个实体下载任何详细信息,因为它会占用更多的RAM。 引擎仅加载每个实体最需要的内容,以及指向ROM中其完整结构的指针,该指针在诸如处理对话框之类的情况下被取消引用。 这组特定的折衷方案使我们能够提供足够的性能。

门户网站



游戏《遗留物》有许多不同的位置,街道上有滚动地图的多个场景,而房间中的许多场景保持静态。 要从一个移动到另一个,您需要确定播放器已经到达出口,加载新场景,然后将播放器放置在所需的位置。 在开发的早期阶段,这种转换以独特的方式描述为两个相互连接的场景,例如“第一城市”和“咖啡馆”,以及if语句中有关每个场景中门位置的数据。 为了确定更改场景后玩家的位置,您只需检查他要去的地方和位置,然后将其放置在相应的出口旁边。

但是,当我们开始填充在两个不同位置连接到第一座城市的“第二座城市”场景时,这样的系统开始崩溃。 突然,该对(_, _)不再适合。 在考虑了这一点之后,我们意识到连接本身确实很重要,在游戏代码内部将其称为“门户”。 为了解决这些更改,引擎已被重写。 导致我们陷入类似实体的情况。 门户网站可以存储键值对列表并在场景开始时加载。 进入门户网站时,您可以使用与离开时相同的位置信息。 此外,与实体所具有的条件类似,简化了附加条件:在游戏中的某些点,我们可以修改门户,例如打开或关闭门。

 ; City A (bytes city-a-scene #x50 #x68 look-up portal-customize (progress-stage-5 remove-self) ; to Diner diner-scene #xc0 #xa0 look-down portal-width #x20 0) 

这也简化了添加“传送点”的过程,这通常用于电影插入中,根据情节的发生,玩家不得不将其移动到场景中的另一个位置。

这是第3级开始时的隐形传态:

 ; Jenny's home (bytes jenny-home-scene #x60 #xc0 look-up portal-teleport-only jenny-back-at-home-teleport 0) 

请注意look-up值,该值指示“进入”此门户的方向。 离开门户时,玩家将朝另一个方向看; 在这种情况下,珍妮(游戏的主要角色)在家里,而低头。

文字区块


事实证明,渲染文本块是整个项目中最复杂的代码之一。 NES的图形限制被迫欺骗。 首先,NES仅具有一层图形数据,即要释放文本块的空间,您需要在背景下擦除部分地图,然后在关闭文本块后将其还原。


另外,每个单独场景的调色板必须包含用于渲染文本的黑白颜色,这对艺术家施加了其他限制。 为了避免与背景的其余部分发生颜色冲突,文本块应与16×16网格对齐[5]。 在有房间的场景中绘制文本块比在摄像机可以移动的街道上绘制文本块容易得多,因为在这种情况下,您必须考虑到垂直和水平滚动的图形缓冲区。 最后,暂停屏幕消息是一个经过稍微修改的标准对话框,因为它显示了不同的信息,但是使用了几乎相同的代码。

经过无数次错误的代码版本之后,我终于设法找到了一种解决方案,其中的工作分为两个阶段。 首先,执行所有计算以确定在何处以及如何绘制文本块,包括所有边界情况的处理代码。 因此,所有这些困难都集中在一个地方。

然后,逐行绘制具有状态保留的文本块,并使用第一阶段的计算,以免使代码复杂化。

 ; Called once per frame as the text box is being rendered (defsub (text-box-update) (when (or (eq? tb-text-mode 0) (eq? tb-text-mode #xff)) (return #f)) (cond [(in-range tb-text-mode 1 4) (if (not is-paused) ; Draw text box for dialog. (text-box-draw-opening (- tb-text-mode 1)) ; Draw text box for pause. (text-box-draw-pausing (- tb-text-mode 1))) (inc tb-text-mode)] [(eq? tb-text-mode 4) ; Remove sprites in the way. (remove-sprites-in-the-way) (inc tb-text-mode)] [(eq? tb-text-mode 5) (if (not is-paused) ; Display dialog text. (when (not (crawl-text-update)) (inc tb-text-mode) (inc tb-text-mode)) ; Display paused text. (do (create-pause-message) (inc tb-text-mode)))] [(eq? tb-text-mode 6) ; This state is only used when paused. Nothing happens, and the caller ; has to invoke `text-box-try-exiting-pause` to continue. #t] [(and (>= tb-text-mode 7) (< tb-text-mode 10)) ; Erase text box. (if (is-scene-outside scene-id) (text-box-draw-closing (- tb-text-mode 7)) (text-box-draw-restoring (- tb-text-mode 7))) (inc tb-text-mode)] [(eq? tb-text-mode 10) ; Reset state to return to game. (set! text-displaying #f) (set! tb-text-mode 0)]) (return #t)) 

如果您习惯了Lisp风格,则可以很方便地阅读代码。

精灵Z层


最后,我将讨论一个不会特别影响游戏玩法的小细节,但会增加我引以为傲的良好触感。 NES只有两个图形组件:一个名称表(nametable),用于静态和网格对齐的背景;以及一个精灵(sprite)-大小为8x8像素的对象,可以放置在任意位置。 如果将诸如玩家角色和NPC之类的元素放置在名称表图形的顶部,它们通常会被创建为精灵。

但是,NES设备还可以指定可以完全放置在名称表下的部分精灵。 这毫不费力地使您实现炫酷的3D效果。


它的工作方式如下:用于当前场景的调色板以特殊方式处理位置0处的颜色:这是全局背景色。 在其顶部绘制一个名称表,并在其他两层之间绘制具有z层的精灵。

这是此场景的调色板:


因此,将最左上角的深灰色用作全局背景色。

图层的效果如下:


在大多数其他游戏中,这一切都结束了,但是,《余生》又向前迈了一步。 游戏不会将Jenny完全放在名称表的图形之前或下方-她的角色以正确的方式在它们之间划分。 如您所见,精灵的大小为8x8,整个角色的图形由几个精灵组成(从3到6,具体取决于动画帧)。 每个子画面都可以设置自己的z层,也就是说,某些子画面将位于名称表的前面,而其他子画面将在其后面。

这是此作用的示例:


实现这种效果的算法非常棘手。 首先,检查玩家周围的碰撞数据,尤其是图块,这可能需要整个角色来绘制。 在此图中,实心图块以红色正方形显示,而黄色图块表示具有z层的部分。


使用各种试探法,将它们组合在一起以创建“参考点”和四位的位掩码。 相对于参考点的四个象限对应于四个位:0表示玩家必须在名称表的前面,1-在其后面。


当放置单个精灵以渲染播放器时,将其位置与参考点进行比较,以确定此特定精灵的z层。 其中一些位于顶层,其他位于背面。


结论


我简要地谈到了我们新的现代复古游戏的内部运作的不同方面。 代码库中还有很多有趣的事情,但是我概述了使游戏正常工作的重要部分。

我从该项目中学到的最重要的教训是数据驱动引擎可以带来的好处。 几次我设法用表和迷你解释器替换了一些独特的逻辑,并且由于这个原因,代码变得更简单易读。

希望您喜欢这篇文章!



注意事项


[1]严格来说,NES中安装了一种称为Ricoh 2A03的CPU 6502。

[2]实际上,这个项目使我确信,对于任何超过一定大小的NES项目,切换存储体/管理ROM是主要限制。

[3]为此,尽管我很难找到有关它的文献,但应该感谢“编译堆栈”-一种用于嵌入式系统编程的概念。 简而言之,您需要构建一个完整的项目调用图,将其从叶节点到根进行排序,然后为每个节点分配等于其需求+最大子节点数的内存。

[4]宏是在开发的较晚阶段添加的,坦率地说,我们无法利用它们的特殊优势。

[5]您可以在我的系列文章中阅读有关NES图形的更多信息。 颜色冲突是由第一部分中描述的属性引起的。

Source: https://habr.com/ru/post/zh-CN467125/


All Articles