FPGA 与太空入侵者

2015-07-28, 星期二, 00:00

MAKE

为期四周的关于 FPGA 的数字电路课程设计已经结束近一周,虽说四周里我大部分时间都在做课程无关的事情,但还是缓了一周才将最后的半成品整理完,这个半成品试图拙劣地模仿经典游戏太空入侵者。

看看其他人怎么做

在 Github 上以 space invader 为关键字可以搜到许多人的作品,这里就不再赘述。

设计要求与完成度

本设计尝试通过 FPGA 编程及外围功能电路实现经典街机游戏太空入侵者。其设计/实现的基本功能有:

[x] 在显示器上以 800×600 的分辨率显示彩色的游戏画面 [x] 屏幕上显示固定的游戏框架和背景图像 [x] 游戏中的敌人从屏幕的某个位置刷新,向另一个地点移动 [x] 玩家通过按键发射武器。发射的导弹碰到敌人时,敌人死亡 [ ] 玩家通过按键控制角色的移动 [ ] 屏幕显示玩家的生命值,当一定数量的敌人未被消灭后,玩家生命值耗尽,游戏结束

最终成品

emmmmmm……

开发工具

  • Quartus II 6.0
  • Altera Cyclone II DE2 开发板

FPGA 各项资源占用

  • Total logic elements 3207/33,216(10%)
  • Total registers 131
  • Total pins 40/475(8%)

VGA 显示部分

这张来自 eewiki 图片较为清晰地描述了场消隐和行消隐的过程:

这张来自 eewiki 图片较为清晰地描述了场消隐和行消隐的过程

我使用的开发板上只有 27MHz 和 50MHz 的时钟源各一个。方便起见,又或者是懒得写分频,就选了像素时钟频率为 50MHz 的显示模式。各状态时钟数可以前往 eewiki 查询,支持从 640x350 到 1920x1440 的各种分辨率的相关数据。

这部分的实现比较简单,主要就是声明一个计数器(这里用了 11 位),按 Pixel Clock 的上升沿计数,时候到了就调节一下端口电平。开发板这边 VGA 信号的输出用了 3 个 10 位的 DAC。将 vector 里的值按位赋给 ADC,就获得了模拟信号。除此之外,要正确地传输图像,还需要输出 BLANKSYNC 两个信号用于帧同步。由于我用了两个使能信号来确保行列计数器计数至显示范围外时 FPGA 输出为 0,故 BLANK 永久设置为 1,SYNC 永久设置为 0。

系统的 VHDL 设计

ALIEN

ALIEN 可视为含有同步置数端的计数器,当进程判断敌人被击中时,计数器置入初始的数值(敌人出生点的坐标位置),否则在移动时钟的上升沿计数。当计数器达到特定值时,计数器置入初始数值。实际上位置的变化是由两个计数器实现的,横坐标计数器在加到范围上限时转变为减法计数,在转到计数范围下限时又变为加法计数。纵坐标计数器一直为加法计数器。

各模块之间信号传递

MOVE_CLK 由 VGA 显示产生的帧同步信号分频而得,修改分频计数器的模值可改变移动速度。由 MOVE_CLK 触发的进程判断所处的坐标位置,通过对位置变量做相应的加减计算表示移动。MISSILE_POS 变量提供关于玩家发射的导弹的坐标位置,使用进程语句来判断导弹的位置和 ALIEN 位置是否产生重合。如果重合,则将内部信号 ALIEN_EN 清零。

坐标位置表示

判定命中的坐标范围

通过行计数器和列计数器触发的进程可以判断在当前屏幕坐标上是否显示 ALIEN 的像素。

玩家

玩家的行为和 ALIEN 类似,但无需进行 Y 轴方向的移动(自从发现按键程序逻辑上的 bug 后,X 轴方向也废了)。此外还输出表示导弹发射的信号和发射坐标的向量。

MISSILE

由于导弹发射后沿直线前进,故只需要一个记录纵坐标的计数器,计数器在接收到命中信号或超出边界信号时停止计数。当玩家按下发射键时,计数器被重新激活,装入初始值并开始计数。 由于导弹失能后位置计数不清零,为防止干扰游戏,再添加一个相关信号量表示是否使能。

使用由 HCountVCount 驱动的进程语句块,将图像分割为单行上的连续序列进行显示。先通过取模软件将黑白的背景图片转化为十六进制。再通过自行编写的程序将字符转化为对应的 VHDL 语句,最后合并到工程中。游戏中几乎所有画面的显示均是采用这种方案。

最后完成的文件里大部分都是这种代码,于是写了个用于生成程序代码的程序。

······
elsif(VCount=35)then
    if((HCount>47 and Hcount<53)or(HCount>75 and Hcount<79)or
        (HCount>91 and Hcount<98)or(HCount>120 and Hcount<127)or
        (HCount>148 and Hcount<161)or(HCount>187 and Hcount<196)or
        (HCount>219 and Hcount<224)or(HCount>236 and Hcount<243)or
        (HCount>297 and Hcount<304)or(HCount>316 and Hcount<330)or
        (HCount>349 and Hcount<355)or(HCount>369 and Hcount<376)or
        (HCount>396 and Hcount<403)or(HCount>426 and Hcount<439)or
        (HCount>464 and Hcount<471)or(HCount>492 and Hcount<501)or
        (HCount>514 and Hcount<521)or(HCount>559 and Hcount<566)or
        (HCount>589 and Hcount<596)or(HCount>623 and Hcount<630)or
        (HCount>650 and Hcount<657)or(HCount>669 and Hcount<677)or
        (HCount>691 and Hcount<699)or(HCount>737 and Hcount<750))then
        vga_framework_en<='1';
    end if;
······

这次也体会到 Linux 和 Windows 对换行问题的区别了(所谓 CRLF),在 Linux C 输出的文本要使用 \r\n 才能在 windows 环境下正常显示。

其他

玩家生命值什么的有 bug,最后也没有改。

总结

虽然写好了玩家生命值记录和显示,在调试过程中却发现无法正常工作,分析之后认为在原本设计中敌人达成一次获胜条件后发送一个信号量给负责处理玩家生命值的进程。但信号量产生作用后无法自动清零,所以可能造成多次触发。由于时间所限,没有重写这部分代码。可能是由于相同的原因,玩家角色的移动也存在严重的问题。出现了无法移动和/或跑出设定边界的问题。在最初设计中想要使用 PS/2 键盘作为控制器,通过查阅 PS/2 接口的时序逻辑编写了接收程序。但无法实现自动清除无效数据的功能,最终设计使用了开发板上的按键。在设计制作这个项目时,由于思维没有完全转移到以硬件为依托的 VHDL 上来,结果出现了许多问题,例如编译优化方面的:设计计数器计数上限为 1040,计数器使用 10 位(1024)结果被优化,出现不正常行为。有高度并行性带来的 Can't resolve multiple constant drivers 问题,知道了信号不能在多个并发进程中赋值。还有一个进程内不能同时对多个时钟(信号的边沿)进行判断,就需要通过其他方式达到多路信号触发同一事件的效果。

VHDL 程序在这里