为Game Boy创建游戏,第2部分

图片

几周前,我决定为Game Boy开发一款游戏,该游戏的创作给了我极大的乐趣。 它的工作名称是Aqua和Ashes。 该游戏是开源的,并发布在GitHub上 。 本文的前一部分在这里

神奇的精灵及其居住地


在最后一部分中,我完成了在屏幕上渲染几个精灵的操作。 这是以非常武断和混乱的方式完成的。 实际上,我必须在代码中指出要显示的内容和位置。 这使得创建动画几乎是不可能的,花费了大量的CPU时间和复杂的代码支持。 我需要一个更好的方法。

具体来说,我需要一个系统,在其中可以简单地为每个单独的动画迭代动画编号,帧编号和计时器。 如果需要更改动画,则只需更改动画并重置帧计数器。 在每一帧中执行的动画过程应该只选择合适的精灵来显示并将它们放置在屏幕上,而无需我费力。

事实证明,这个任务实际上已经解决了。 我一直在寻找的是精灵映射 。 子画面图是(大致而言)包含子画面列表的数据结构。 每个精灵图都包含用于渲染单个对象的所有精灵。 动画贴图(动画贴图)也与它们相关联, 动画贴图是带有有关如何循环播放的信息的精灵贴图列表。

有趣的是,在五月份,我将动画地图编辑器添加到现成的精灵地图编辑器中,以用于有关Sonic的16位Sonic游戏。 (他在这里 ,您可以学习)它尚未完成,因为它相当粗糙,缓慢且使用不便。 但是从技术角度来看,它是可行的。 在看来,这很酷……(粗糙的原因之一是我最初使用JavaScript框架。)Sonic是一款老游戏,因此非常适合作为我的新游戏的基础。

Sonic 2卡格式


我打算在Sonic 2中使用该编辑器,因为我想为Genesis创建一个hack。 Sonic 1和3K基本相同,但是为了不复杂,我将局限于第二部分的故事。

首先,让我们看一下精灵图。 这是一个非常典型的Tails Sprite,它是眨眼动画的一部分。


Genesis控制台创建精灵的方式有所不同。 就像游戏男孩一样,“创世纪”图块(大多数程序员称之为“模式”)为8x8。 子画面由最多4x4的矩形矩形组成,非常类似于Game Boy上的8x16子画面模式,但更加灵活。 这里的技巧是在内存中这些磁贴应该彼此相邻。 Sonic 2开发人员希望为站立的Tails框架中闪烁的Tails框架重用尽可能多的图块。 因此,“尾巴”分为2个硬件精灵,由3x2瓦片组成-一个用于头部,另一个用于身体。 它们显示在下图中。


该对话框的顶部是硬件精灵属性。 它包含它们相对于起点的位置(负数被切除;实际上,第一个精灵的分别是-16和-12,第二个精灵的是-12),VRAM中使用的初始磁贴,精灵的宽度和高度,以及用于精灵和调色板的镜像。

磁贴从ROM加载到VRAM时,显示在底部。 没有足够的空间来将所有Tails精灵存储在VRAM中,因此必须在每一帧中将必要的图块复制到内存中。 它们被称为动态模式加载提示 。 但是,尽管我们可以跳过它们,但是由于它们几乎独立于子画面贴图,因此以后可以轻松添加它们。


至于动画,这里的一切都变得容易一些。 Sonic中的动画贴图是包含两个元数据的精灵贴图的列表-元数据和动画完成后要执行的操作。 三种最常用的动作是:遍历所有帧的循环,遍历最后N帧的循环或过渡到完全不同的动画(例如,当从站立的Sonic动画切换为他用脚踩踏不耐烦的动画时)。 有两个命令可以在对象的内存中指定内部标志,但是使用它们的对象并不多。 (现在我想到,可以在循环动画时将对象RAM中的位设置为一个值。这对于声音效果和其他功能很有用。)

如果查看反汇编的Sonic 1代码(Sonic 2代码太大而无法链接),您会注意到,到动画的链接不是由任何ID构成的。 每个对象都有一个动画列表,动画索引存储在内存中。 要渲染特定的动画,游戏会获取一个索引,在动画列表中查找该索引,然后进行渲染。 这使工作变得容易一些,因为您无需扫描动画即可找到所需的动画。

我们从结构上清理汤


让我们看一下卡片类型:

  1. 子画面贴图:子画面列表,包括初始图块,图块数,位置,反射状态(子图是否已镜像)和调色板。
  2. DPLC:需要加载到VRAM中的ROM磁贴的列表。 DPLC中的每个项目都由一个初始图块和一个长度组成。 每一项都放在最后一项之后的VRAM中。
  3. 动画贴图:动画列表,包括精灵图,速度值和循环动作的列表。
  4. 动画列表:指向每个动画动作的指针的列表。

鉴于我们正在与Game Boy合作,可以进行一些简化。 我们知道,在8x16子画面中的子画面贴图中,总是会有两个图块。 但是,必须保留其他所有内容。 现在,我们可以完全放弃DPLC,仅将所有内容存储在VRAM中。 这是一个临时解决方案,但是,正如我所说,这个问题将很容易解决。 最后,如果我们假设每个动画以相同的速度工作,则可以放弃速度值。

让我们开始弄清楚如何在我的游戏中实现类似的系统。

检查提交2e5e5b7

让我们从精灵图开始。 映射中的每个元素都应镜像OAM(对象属性内存-子画面VRAM),因此,简单的循环和memcpy足以显示对象。 让我提醒您, OAM的元素由Y,X,初始图块和属性byte组成 。 我只需要创建一个列表。 使用组装好的伪运算符EQU,我预先准备了属性字节,以便对每种可能的属性组合都有一个易读的名称。 (您可能会注意到,在上一次提交中,我替换了卡中的Y / X磁贴。发生这种情况是因为我不专心地阅读了OAM规范。我还添加了一个sprite计数器来知道循环需要多长时间。)

您会注意到北极狐的身体和尾巴是分开存放的。 如果将它们一起存储,则会有很多冗余,因为每个动画都必须针对每个尾部状态进行复制。 并且冗余的规模将迅速增加。 在《音速2》中,同样的问题出现在“尾巴”上。 他们在那里解决了问题,使Tails尾巴成为具有自己的动画状态和计时器的单独对象。 我不想这样做,因为我不想解决相对于狐狸保持正确尾巴位置的问题。

我通过动画贴图解决了这个问题。 如果查看我的(单个)动画贴图,则其中包含三个元数据。 它显示了动画卡的数量,所以我知道它们何时结束。 (在Sonic中,检查下面的动画是无效的,类似于C行中零字节的概念。Sonic的解决方案释放了这种情况,但添加了一个对我不利的比较。)当然,仍然存在循环动作。 (我将2字节的Sonic电路转换为1字节的数字,其中第7位是模式位。)但是我也有Sprite卡的数量,但在Sonic中却没有。 每个动画帧具有多个子画面贴图,使我可以在多个动画中重用动画,我认为这将节省大量宝贵的空间。 您还可以注意到,每个方向的动画都是重复的。 这是因为每个方向的地图都不相同,因此您需要添加它们。

图片

与寄存器跳舞


请参阅1713848上的此文件

让我们从在屏幕上绘制单个精灵开始。 所以,我承认,我撒了谎。 让我提醒您,我们无法在VBlank之外的屏幕上录制。 而且整个过程太长,无法适合VBlank。 因此,我们需要记录将分配给DMA的内存区域。 最后,它什么都不会改变,在正确的位置记录很重要。

让我们开始计数寄存器。 GBZ80处理器具有6个寄存器,从A到E,H和L。H和L是特殊的寄存器,因此它们非常适合从内存执行迭代。 (由于它们一起使用,因此它们称为HL。)在一个操作码中,我可以写入HL中包含的内存地址,然后向其中添加一个。 这很难处理。 您可以将其用作源或目标。 我将其用作地址,并将BC寄存器的组合用作源,因为它最方便。 我们只有A,D和E。我需要寄存器A进行数学运算等。 DE可以用来做什么? 我用D作为循环计数器,用E作为工作区。 这是寄存器结束的地方。

假设我们有4个精灵。 我们将D寄存器(周期计数器)设置为4,将HL寄存器(目标)设置为OAM缓冲区地址,并将BC(源)设置为存储卡的ROM中的位置。 现在我想打电话给memcpy。 但是,出现一个小问题。 还记得X和Y坐标吗? 它们是相对于起点指示的,对象的中心用于碰撞等。 如果我们按原样记录它们,则每个对象将显示在屏幕的左上角。 这不适合我们。 为了解决这个问题,我们需要将对象的X和Y坐标添加到Sprite的X和Y。

简短说明:我谈论的是“对象”,但没有向您解释这个概念。 对象只是与游戏中的对象相关联的一组属性。 属性是位置,速度,方向。 项目说明等 我之所以这样说是因为我需要从这些对象中提取X和Y数据,为此,我们需要第三组寄存器,这些寄存器指向坐标所位于的对象在RAM中的位置。 然后我们需要将X和Y存储在某个位置,这也适用于该方向,因为它可以帮助我们确定精灵在哪个方向上看。 另外,我们需要渲染所有对象,因此它们还需要一个循环计数器。 而且我们还没有动画! 一切都很快失去控制...

决策审查


所以,我跑得太远了。 让我们回头考虑一下我需要跟踪的每条数据以及将数据写入何处。

首先,让我们将其分为“步骤”。 除了执行复制的最后一步外,每个步骤仅应接收下一个数据。

  1. 对象(循环)-查找是否应该渲染对象,然后渲染它。
  2. 动画列表-确定要显示的动画。 还获取对象的属性。
  3. 动画(循环)-确定要使用的地图列表,并从中渲染每个地图。
  4. 映射(循环)-迭代遍历子画面列表中的每个子画面
  5. Sprite-将Sprite属性复制到OAM缓冲区

对于每个阶段,我都列出了它们所需的变量,它们所扮演的角色以及存储它们的位置。 该表如下所示。

内容描述尺码舞台使用方法从哪里来地点到哪里
OAM缓冲区2雪碧指针ll
地图来源2雪碧指针卑诗省卑诗省
当前字节1个雪碧工作空间地图来源Ë
X1个雪碧可变的希拉姆
ÿ1个雪碧可变的希拉姆
动画图的开始2雪碧图指针Stack3
地图来源2雪碧图指针[DE]卑诗省
剩余的精灵1个雪碧图从头开始地图来源d
OAM缓冲区1个雪碧图指针ll堆栈1
动画图的开始2动画制作工作空间BC / Stack3卑诗省Stack3
剩余的卡片1个动画制作工作空间动画开始希拉姆
卡总数1个动画制作可变的动画开始希拉姆
对象方向1个动画制作可变的希拉姆希拉姆
每张卡片1个动画制作可变的动画开始未使用!
车架号1个动画制作可变的希拉姆
地图指针2动画制作指针AnimStart +目录* TMC + MpF * F#卑诗省
OAM缓冲区2动画制作指针堆栈1l
动画表的开始2动画列表工作空间硬集
对象来源2动画列表指针llStack2
车架号1个动画列表可变的对象来源希拉姆
动画编号1个动画列表工作空间对象来源
X对象1个对象清单可变的对象来源希拉姆
Y对象1个动画列表可变的对象来源希拉姆
对象方向1个动画列表可变的Obj src希拉姆
动画图的开始2动画列表指针[Anim Table + Anim#]卑诗省
OAM缓冲区2动画列表指针堆栈1
对象来源2对象周期路标硬集/堆栈2l
剩余物体1个对象周期可变的已计算
对象的有效位字段1个对象周期可变的已计算ç
OAM缓冲区2对象周期指针硬集

是的,非常令人困惑。 坦白地说,我将此表仅作发布之用,以进行更清晰的解释,但是它已经开始变得有用。 我会尽力解释,让我们从头开始,从头开始。 您将看到我开始使用的每条数据:对象的源,OAM缓冲区和预先计算的循环变量。 在每个周期中,我们仅以此开始,只是在每个周期中更新对象的源。

对于我们渲染的每个对象,必须定义显示的动画。 在执行此操作时,我们还可以保存X,Y,帧号和方向属性,然后再将对象指针增加到下一个对象,然后将它们保存到堆栈中以在退出时取回。 我们将动画编号与代码中硬编码的动画表结合使用,以确定动画贴图的开始位置。 (这里,我简化了,假设每个对象都有相同的动画表。这限制了我每场游戏只能播放256个动画,但是我不太可能超过该值。)我们还可以编写OAM缓冲区来保存一些寄存器。

提取动画贴图后,我们需要找到给定帧和方向的子贴图列表位于何处,以及需要渲染多少张贴图。 您可能会注意到未使用每帧的card变量。 发生这种情况是因为我没有考虑并设置常数2。我需要修复它。 我们还需要从堆栈中提取OAM缓冲区。 您可能还会注意到完全缺乏周期控制。 它是在一个单独的,更简单的子过程中执行的,它使您可以摆脱使用寄存器的麻烦。

之后,一切变得非常简单。 一张地图是一堆精灵,因此我们绕着它们旋转,并根据存储的X和Y坐标进行绘制,但是,我们再次将OAM指针保存到了精灵列表的末尾,以便下一张地图从我们完成的地方开始。

这一切的最终结果是什么? 与以前完全相同:北极狐在黑暗中挥舞着尾巴。 但是现在添加新的动画或精灵要容易得多。 在下一部分中,我将讨论复杂的背景和视差滚动。

图片

第4部分。视差背景


让我提醒您,在当前阶段,我们在黑色背景上显示了动画精灵。 如果我不打算制作70年代的街机游戏,那么显然这还不够。 我需要某种背景图片。

在第一部分中,当我绘制图形时,我还创建了一些背景图块。 现在是时候使用它们了。 我们将使用三种“基本”类型的图块(天空,草地和大地)和两个过渡图块。 它们都已加载到VRAM中并可以使用。 现在我们只需要在后台编写它们即可。

背景知识


Game Boy上的背景以32x32的8x8瓦片阵列存储在内存中。 每32个字节对​​应于一行图块。


到目前为止,我计划在整个32x32空间中重复同一图块。 很好,但这会带来一个小问题:我将需要连续设置每个磁贴32次。 要写很长时间。

本能地,我决定使用REPT命令添加32字节/行,然后使用memcpy将背景复制到VRAM中。

 REPT 32 db BG_SKY ENDR REPT 32 db BG_GRASS ENDR ... 

但是,这意味着您只需要为一个背景分配256个字节,这是很多工作。 如果您还记得使用memcpy复制先前创建的背景图将不允许您添加其他类型的列(例如,门,障碍物)而没有明显的复杂性和浪费的盒式ROM,则会使该问题更加严重。

因此,我决定按照以下步骤设置一列:

 db BG_SKY, BG_SKY, BG_SKY, ..., BG_GRASS 

然后使用简单的循环将列表中的每个项目复制32次。 (请参阅 LoadGFX文件中 LoadGFX 。)

这种方法的方便之处在于,以后我可以添加一个队列来编写如下内容:

 BGCOL_Field: db BG_SKY, ... BGCOL_LeftGoal: db BG_SKY, ... BGCOL_RightGoal: db BG_SKY, ... ... BGMAP_overview: db 1 dw BGCOL_LeftGoal db 30 dw BGCOL_Field db 1 dw BGCOL_RightGoal db $FF 

如果我决定渲染BGMAP_overview,那么它将绘制1列LeftGoal,此后将有30列的Field和1列的RightGoal。 如果BGMAP_overview在RAM中,那么我可以根据X中的相机位置即时更改它。

相机和位置


哦,是的,相机。 这是我尚未谈论的重要概念。 这里我们要处理大量的坐标,因此在谈论相机之前,我们将首先分析所有这些。

我们需要使用两个坐标系。 首先是屏幕坐标 。 这是256x256的区域,可以包含在Game Boy控制台的VRAM中。 我们可以在这256x256范围内滚动屏幕的可见部分,但是当我们越过边界时,我们就会崩溃。

在宽度上,我需要超过256个像素,因此我添加了世界坐标 ,在这个游戏中它将具有65536x256的尺寸。 (由于游戏是在平坦的场地上进行,因此我不需要在Y处有额外的高度。)此系统与屏幕坐标系完全分开。 所有物理和碰撞都必须在世界坐标中进行,否则对象会与其他屏幕上的对象发生碰撞。


屏幕和世界坐标的比较

由于所有对象的位置均以世界坐标表示,因此在渲染之前必须将它们转换为屏幕坐标。 在世界的最左侧,世界坐标与屏幕坐标重合。 如果我们需要在屏幕上向右显示事物,那么我们需要以世界坐标为单位获取所有内容并将其向左移动,以便它们位于屏幕坐标中。

为此,我们将设置“ camera X”变量,该变量定义为世界屏幕的左边界。 例如,如果camera X为1000,则我们可以看到世界坐标1000-1192,因为可见屏幕的宽度为192像素。

要处理对象,我们只需将它们放在X中的位置(例如1002),减去等于1000的相机位置,然后在由差值给出的位置(在我们的例子中为2)处绘制对象。 对于不在世界坐标中但已在屏幕坐标中描述的背景,我们将位置设置为等于camera X变量的低字节。 因此,背景将随相机左右滚动。

视差


我们创建的系统看起来相当平坦。 每个背景层以相同的速度移动。 它不具有三维感,我们需要对其进行修复。

添加3D模拟的一种简单方法称为视差滚动。 想象一下,您在沿着道路行驶并且很累。 Game Boy的电池已用完,您必须向车窗外看。 如果您看着旁边的地面,将会看到。 她以每小时70英里的速度运动 但是,如果您查看远处的字段,似乎它们的移动速度要慢得多。 而且,如果您看着很远的山脉,它们似乎几乎没有动。

我们可以用三张纸来模拟这种效果。 如果您在一张纸上绘制山脉,在第二张纸上绘制田野,在第三张纸上绘制道路,则将它们像这样彼此叠放。 这样每一层都是可见的,它将模仿我们从车窗看到的一切。 如果我们想将“汽车”向左移动,那么我们将最上面的工作表(带有道路)向右移动,下一个向右移动一点,最后一个向右移动一点。



但是,当在Game Boy上实现这样的系统时,会出现一个小问题。 控制台只有一个背景层。 这类似于我们只有一张纸的事实。 您不能仅用一张纸来创建视差效果。 还是可能吗?

高空白


Game Boy屏幕逐行呈现。 由于模拟了旧的CRT电视的行为因此每条线之间会有一些延迟。 如果我们可以以某种方式使用它怎么办? 事实证明,Game Boy具有专门用于此目的的特殊硬件中断。

与VBlank中断类似,我们经常使用它来等待帧结束以在VRAM中进行记录,这是一个HBlank中断。 通过将寄存器的第6位设置为$FF41 ,打开LCD STAT中断,并在$FF45写入行号,我们可以告诉Game Boy在即将绘制指定的行(以及它位于其HBlank中)时启动LCD STAT中断。

在这段时间内,我们可以更改任何VRAM变量。 这不是很多时间,所以我们最多只能更改几个寄存器,但仍有一些可能。 我们要在$FF43处更改水平滚动寄存器。 在这种情况下,指定行下方的屏幕上的所有内容都会移动一定的量,从而产生视差效果。

如果回到山区的例子,您会注意到一个潜在的问题。 山,云,花都不是平线! 我们无法在渲染过程中上下移动所选的线条; 如果我们选择它,那么它至少在下一个HBlank之前保持不变。 也就是说,我们只能切成直线。

为了解决这个问题,我们必须更加聪明。 我们可以将背景中的某条线声明为任何线都不能越过的线,这意味着更改其上方和下方的对象的模式,并且播放器将无法注意到任何内容。 例如,这是山上场景中这些线的位置。


在这里,我在山的上方和下方进行了切片。 从顶部到第一行的所有内容均缓慢移动,到第二行的所有内容均以平均速度移动,而该行下方的所有内容均快速移动。 这是一个简单但聪明的技巧。 在了解了这一点之后,您可以在许多复古游戏中注意到它,主要是针对Genesis / Mega Drive,以及在其他游戏机上。 最明显的例子之一是米奇躁狂症洞穴一部分 。 您会注意到,背景中的石笋和钟乳石正好沿着一条水平线分开,各层之间有明显的黑色边框。

我在背景中也意识到了同样的事情。 但是,有一个窍门。 假设前景以与照相机的运动一致的速度移动,并且背景速度是照相机像素运动的三分之一,也就是说,背景的运动像前景的三分之一。 但是,当然不存在像素的三分之一。 因此,我需要每移动三个像素就将背景移动一个像素。

如果您使用的是具有数学计算能力的计算机,则可以将相机位置除以3,然后将该值设为背景偏移。 不幸的是,Game Boy无法进行划分,更不用说程序划分是一个非常缓慢而痛苦的过程。 在80年代,为便携式娱乐游戏机添加一个用于对较弱的CPU进行除法(或乘除)的设备似乎并不是一个具有成本效益的步骤,因此我们必须发明另一种方法。

在代码中,我执行了以下操作:我不是从变量读取相机位置,而是要求其增大或减小。 因此,每增加三分之一,我就可以执行一次背景位置的增加,而每增加一次,我都可以执行前景位置的一个增量。 这会使从字段另一边滚动到某个位置的操作复杂化(最简单的方法是在特定过渡后简单地重置图层的位置),但使我们无需进行划分。

结果


毕竟,我得到以下信息:


对于Game Boy上的游戏,这实际上很酷。 据我所知,并非所有人都具有像这样实现的视差滚动。

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


All Articles