FPGA双游戏系统设计实战:VGA显示与碰撞检测的Verilog实现
简介:本资源是面向高校电子信息类专业课程设计与FPGA创新实践的双游戏系统完整实现方案,适用于具备数字电路基础的本科生及工程技术人员,解决FPGA图形显示、实时交互与物理建模等典型教学难点。压缩包共50个文件,含8个Verilog源码(如VGA_piano.v、SBgame.v、pll.vhd)、10个综合/时序日志(.log/.rpt)、8个备份文件(.zbak)、2个约束文件(.xdc/.adc)及配套bit流、头文件与IP配置,总大小3.66MB,结构清晰,便于模块化学习与调试。已有140人下载学习,项目源自安路平台FPGA创新设计大赛参赛作品,已通过高校课程设计评审并获优秀评分。用户可直接部署运行钢琴节奏与弹球物理仿真双游戏,获取完整硬件描述代码、开发文档、时序约束与VGA显示驱动逻辑,亦可在现有架构上扩展音效合成、难度算法或触控交互功能。 做过FPGA相关项目的朋友应该都有体会,这类“FPGA+小游戏”的组合在课程设计和电子设计竞赛里出现频率极高,原因很简单:游戏逻辑覆盖了状态机、时序控制、分频、按键消抖、VGA显示驱动等数字逻辑的核心知识点,又比单纯跑马灯、数码管计数器更有展示效果和趣味性。我这次做的是一个双游戏系统,一块FPGA开发板上同时实现了钢琴块和弹球两个游戏,通过按键自由切换。整个项目从顶层架构到每个模块的Verilog代码、仿真文件、引脚约束,再到配套的PPT和设计文档,都整理齐全了。这篇文章就把整个设计思路、关键模块的实现细节、以及我在调试过程中踩过的坑完整复盘一遍。
1. 整体架构设计:为什么把两个游戏放在一个系统里
单做一个钢琴块或者单做一个弹球,网上能找到的参考代码其实不少,但绝大多数是单模块的演示工程,简单把VGA驱动和游戏逻辑堆在一起,扩展性很差。这次我决定做双游戏系统,一方面是为了在综测和竞赛答辩时有更完整的故事线,另一方面也是想验证自己在系统级设计上的能力。
1.1 系统顶层模块划分
整个系统以状态机为核心,在顶层模块里完成游戏选择、VGA显示切换、按键事件分发和音频输出的统一调度。具体分为以下几个功能模块:
- 顶层控制模块:负责游戏模式切换(钢琴块/弹球/待机界面),协调各子模块的使能信号。
- VGA显示驱动模块:生成640x480@60Hz的行场时序,并且为两个游戏提供统一的像素坐标接口。
- 钢琴块游戏模块:负责方块生成、下落、判定、计分。
- 弹球游戏模块:负责球运动、砖块碰撞、挡板控制、胜负判定。
- 按键扫描与消抖模块:处理物理按键输入,产生稳定的单脉冲触发信号。
- 音频输出模块:利用PWM或蜂鸣器产生音符。
这样做的好处是各模块之间的耦合度很低,游戏逻辑完全不关心VGA时序怎么生成,只需要按照坐标把像素画出来。后续如果想加第三个游戏,只要再写一个游戏模块,在顶层状态机里加一个状态分支就行。
1.2 开发板资源与接口规划
我用的开发板是Altera Cyclone IV系列的EP4CE15,板载有VGA接口、4个独立按键、蜂鸣器、50MHz有源晶振。资源占用情况如下:
| 资源类型 | 使用情况 | 说明 |
|---|---|---|
| 逻辑单元 | 约3400 / 15408 | 两个游戏逻辑+显示驱动,整体占用不高 |
| 内部存储器 | 约34Kbit / 504Kbit | 主要用于砖块图案和界面显示的缓存 |
| PLL | 1个 | 50MHz输入,产生25MHz VGA像素时钟 |
| 引脚 | 约25个 | VGA(RGB各3位+行场同步)+ 按键 + 蜂鸣器 |
开发板本身是学校实验室的旧款,但跑这种规模的逻辑绰绰有余。如果你用的是正点原子、黑金或者小梅哥的板子,原理上都一样,只要修改引脚约束文件就可以直接复用代码。
1.3 双游戏切换的逻辑设计
游戏切换我采用的是一个名为game_mode的2bit寄存器,2'b00表示待机菜单,2'b01表示钢琴块,2'b10表示弹球。切换按键在顶层模块中通过边沿检测和状态互斥的方式实现,按下切换键后当前游戏模块立即复位,防止上一个游戏的状态残留到下一个游戏里。
这个设计里最容易忽略的问题是复位。之前的版本里切换游戏时只修改了game_mode,没有对子模块做复位,结果钢琴块切到弹球后,砖块数量变成了初始值的一半,后来追查发现是钢琴块的计分寄存器通过共用总线污染了弹球的砖块计数,所以在游戏切换时一定要将无效模块彻底复位,不能只靠使能信号拉低。
2. VGA显示驱动与像素绘制基础
VGA驱动是这个项目的底座,两个游戏的所有画面输出都建立在这套时序之上。如果你的代码之前没写过VGA驱动,我建议把它当成一个独立的模块先跑通,单独显示一个纯色画面或彩条,确认时序没有问题了再往上叠加游戏逻辑。
2.1 640x480@60Hz的时序参数
标准VGA时序其实没那么神秘,核心就是两组同步信号:行同步(HSync)和场同步(VSync),再加上模拟的RGB信号。时序参数是VESA标准里定死的,640x480@60Hz的参数如下:
| 参数 | 像素时钟 | 周期 | 同步脉冲 | 后沿 | 有效数据 | 前沿 |
|---|---|---|---|---|---|---|
| 行时序 | 25MHz | 800 | 96 | 48 | 640 | 16 |
| 场时序 | 60Hz | 525 | 2 | 33 | 480 | 10 |
其中行时序的总周期是800个像素时钟,其中前96个时钟是同步脉冲,然后经过48个时钟的后沿,接着是640个时钟的有效显示区,最后16个时钟的前沿;场时序也一样,总行数是525行,同步脉冲2行、后沿33行、有效480行、前沿10行。
代码里我习惯用两个计数器来实现:一个行计数器hcnt在0~799之间循环,一个场计数器vcnt在0~524之间循环。只有当hcnt在144~783(即96+48到96+48+640)且vcnt在35~514(即2+33到2+33+480)之间时,才处于有效显示区域,此时给出像素坐标:
assign pixel_x = (hcnt >= 144 && hcnt < 784) ? hcnt - 144 : 0; assign pixel_y = (vcnt >= 35 && vcnt < 515) ? vcnt - 35 : 0; assign display_en = (hcnt >= 144 && hcnt < 784) && (vcnt >= 35 && vcnt < 515);注意:pixel_x和pixel_y在非显示区域时要赋予0,不然综合时可能会推断出锁存器,导致时序问题。
2.2 像素坐标与游戏绘制的映射关系
有了pixel_x和pixel_y之后,游戏画面的绘制就变成了“给定坐标,输出RGB值”的组合逻辑。比如钢琴块的方块区域,只要判断当前坐标是否落在某个方块的矩形范围内,如果落在就输出对应颜色,否则输出背景色。
// 伪代码示意 if (pixel_x >= block_x && pixel_x < block_x + BLOCK_WIDTH && pixel_y >= block_y && pixel_y < block_y + BLOCK_HEIGHT) begin RGB = 12'hF00; // 红色方块 end else begin RGB = 12'hFFF; // 白色背景 end这种绘制方式本质上是在每一个像素时钟周期内做若干次矩形范围判断,在资源允许的情况下,我直接用了比较器阵列而不是Block RAM查表,因为这种方法逻辑简单,代码可读性强,也方便后续调整游戏参数。
2.3 使用PLL产生25MHz像素时钟
关于时钟,这里有一个新手容易踩的坑。开发板的晶振通常是50MHz,但VGA标准640x480@60Hz的像素时钟是25.175MHz,最接近的整数分频就是25MHz。直接用50MHz跑VGA驱动会导致行场频率变成120Hz,大多数显示器会提示“超出刷新率范围”。
我使用Altera的ALTPLL IP核,输入50MHz,输出25MHz,作为VGA模块的像素时钟。注意PLL的输出时钟在仿真和上板时行为有差异,仿真模型需要额外加复位释放时间,这在ModelSim或Vivado Simulator里比较容易遇到,具体我在调试章节再展开。
3. 钢琴块游戏模块设计与实现
钢琴块这个游戏,核心规则就是屏幕从上往下掉黑色方块,方块落到判定线时按下对应位置的按键,按到了得分,没按到或按错就游戏结束。话虽简单,但用状态机实现方块的生成、下落、判定,还是有一定细节的。
3.1 方块生成算法与随机数设计
我设置了4列方块区域,每一列宽度为160个像素。方块下落有3个速度档位,随着得分增加速度逐渐加快。方块的生成逻辑放在一个低频率的定时器触发生成:
// 方块生成逻辑 always @(posedge clk_vga or negedge rst_n) begin if (!rst_n) begin block_0_y <= 0; block_1_y <= BLOCK_START_Y; // ... end else if (generate_en) begin block_0_y <= 0; block_1_y <= BLOCK_START_Y; // 下一行方块从顶部开始下落 end end在每一列方块下落的过程中,当方块完全移出屏幕底部时,就需要生成一个新的方块。这里涉及一个核心问题:如何随机决定新方块落在哪一列。我使用的是LFSR(线性反馈移位寄存器)来产生伪随机数:
// 16位LFSR伪随机数生成器 reg [15:0] lfsr; wire feedback = lfsr[15] ^ lfsr[13] ^ lfsr[12] ^ lfsr[10]; always @(posedge clk_vga or negedge rst_n) begin if (!rst_n) lfsr <= 16'hACE1; // 非零初始值 else if (generate_en) lfsr <= {lfsr[14:0], feedback}; end wire [1:0] rand_col = lfsr[1:0]; // 取低2位作为列索引LFSR的优点是资源占用极小,不需要额外的随机数IP核。需要注意初始值不能为0,否则LFSR会永远锁死在0状态。
3.2 方块下落速度控制与难度递增
速度控制这部分,我采用了一个分频计数器。VGA像素时钟是25MHz,如果每一帧(60Hz)让方块下移一行,那么下落速度就是60行每秒,这个速度对玩家来说太慢了,所以要把方块的下落步长改成按帧移动多个像素:
reg [4:0] speed_step; // 每帧移动的像素数 reg [23:0] frame_cnt; always @(posedge frame_clk) begin if (score >= 10 && score < 20) speed_step <= 5'd3; else if (score >= 20 && score < 30) speed_step <= 5'd4; // ... 更多档位 end这里我踩过一个坑:游戏逻辑中如果直接使用clk_vga的25MHz来写计数器,要注意判断条件的时序关系。VGA的帧同步信号是每60Hz产生一个脉冲,我是用这个帧同步脉冲作为时钟使能,然后在有效区域内更新方块坐标,这样可以保证方块的运动与屏幕刷新同步,不会出现闪烁或撕裂。
3.3 按键判定逻辑与误触处理
按键判定是整个钢琴块游戏的核心。我采用的是“判定区域”概念——当方块上边缘到达判定线以下,且方块下边缘仍在判定线以上时,按下对应按键有效。具体实现时:
// 判定区域定义 parameter JUDGE_LINE_Y = 400; parameter JUDGE_RANGE = 30; // 判定范围上下各30像素 wire [15:0] block_top_y = block_0_y; wire key1_valid = (block_top_y >= JUDGE_LINE_Y - JUDGE_RANGE) && (block_top_y <= JUDGE_LINE_Y + JUDGE_RANGE);判定时还需要处理“按空”的情况——玩家按了键,但方块还没有落到判定区域,此时按键应该无效;或者方块已经过了判定区域但没有按键,此时游戏结束。这两个状态我都用寄存器的边沿去捕获,并且在判定完成后立即清除标志位。
这里特别要注意按键消抖不能只考虑机械抖动,还要考虑到玩家的连续按下导致同一方块被判定两次的问题。我在按键模块里增加了“单次触发”逻辑:每次按键产生一个单周期脉冲,不允许持续触发,这样可以保证一个方块只被判定一次。
3.4 计分、连击与游戏结束状态
计分模块我用了两个寄存器:score和combo。score每命中一次加1,combo记录连续命中的次数。当combo超过5时,每次命中额外加2分,这个设计增加了游戏的挑战性。游戏结束条件是音符块超过判定线且没有被按下,或者按了无方块存在的列。
游戏结束后,界面会显示“Game Over”字符,同时得分保持静止。此时按键切换键可以返回菜单,再按一次切换键重新开始游戏。
4. 弹球游戏模块设计与实现
弹球游戏(打砖块)的逻辑比钢琴块复杂一些,因为涉及球在二维平面内的运动、方向计算、以及球与砖块、挡板、屏幕边界的碰撞检测。这部分是整篇设计中最容易出Bug的模块,我单独拿出来详细说。
4.1 游戏规则设定与画面布局
我的弹球游戏借鉴了经典打砖块玩法:屏幕上方排列6行砖块,每行8块,共48块;屏幕底部有一个可左右移动的挡板;球从底部发射,碰到砖块后砖块消失,碰到顶部和左右边界反弹,碰到挡板反弹;如果球从底部漏出,则失去一条生命,初始有3条生命。
砖块颜色按行区分,从上到下依次为红、橙、黄、绿、蓝、紫,每行的分值不同,最上面一行分值最高。
4.2 球的运动模型与坐标更新
球的核心参数有4个:ball_x、ball_y(坐标),ball_vx、ball_vy(速度向量)。在每一帧的刷新时钟到来时,更新坐标:
always @(posedge frame_clk or negedge rst_n) begin if (!rst_n) begin ball_x <= BALL_INIT_X; ball_y <= BALL_INIT_Y; ball_vx <= 2; ball_vy <= -2; end else begin ball_x <= ball_x + ball_vx; ball_y <= ball_y + ball_vy; end end速度向量的正负号表示方向:ball_vx为正向右,为负向左;ball_vy为正向下,为负向上。初始状态下球向上运动,也就是ball_vy为负。
4.3 碰撞检测的核心算法
碰撞检测是这个游戏最重要的部分,精度直接决定了游戏手感。我做的是矩形碰撞检测,球的运动范围看成一个矩形,砖块和挡板也看成矩形,然后计算两个矩形是否有交集:
// 球与砖块碰撞检测 always @(*) begin hit_brick = 0; for (i = 0; i < 48; i = i + 1) begin if (!brick_removed[i] && ball_x + BALL_SIZE > brick_x[i] && ball_x < brick_x[i] + BRICK_W && ball_y + BALL_SIZE > brick_y[i] && ball_y < brick_y[i] + BRICK_H) begin brick_index = i; hit_brick = 1; end end end这个代码在行为仿真时没问题,但综合时要注意for循环的展开。48个砖块同时检测,逻辑规模还可以接受,但如果砖块数量再翻倍,组合逻辑路径就会变长,导致时序收敛困难。我当时用EP4CE15跑48块检测,时序裕量还有富余,但为了保险起见,我让综合工具关闭了自动优化,手动指定了(* keep *)属性保留所有比较器。
碰撞后球的反向逻辑,关键在判断碰到了砖块的哪个面。我用的是最实用的方法:计算球中心点相对于砖块中心点的偏移方向。如果球中心在砖块的左/右侧,则反弹水平方向速度;如果在砖块的上/下方,则反弹垂直方向速度。
这个方法在实际测试中效果很好,唯一的缺陷是在球速很快时,球可能穿过砖块——也就是所谓的“隧穿效应”。解决方法是限制球的最大速度,不让它一帧内移动超过砖块的宽度或高度的一半。我的砖块宽64像素,高20像素,球速度最大限制在每帧8像素以内,完全避免了隧穿问题。
4.4 挡板控制与球发射机制
挡板控制有两种方式:一种是按键控制左右移动,另一种是通过板载串口发送数据。我用的是按键控制,两个按键分别控制左移和右移。
挡板的反弹角度我做了增强设计:球的反弹角度不是固定的,而是根据球碰到挡板的位置动态变化。挡板分成5个区域,球碰到不同区域,反弹的水平速度分量不同:
// 根据碰撞位置计算反弹角度 integer hit_region; hit_region = (ball_x + BALL_SIZE/2 - paddle_x) * 5 / PADDLE_WIDTH; case (hit_region) 0: ball_vx <= 1, ball_vy <= -4; 1: ball_vx <= 2, ball_vy <= -3; 2: ball_vx <= 3, ball_vy <= -2; 3: ball_vx <= 2, ball_vy <= -3; 4: ball_vx <= 1, ball_vy <= -4; endcase这个设计让球的角度变得丰富多变,不会出现球永远在同一个轨迹上反弹的单调情况,可玩性大幅提升。
4.5 砖块消除与胜利判定
每碰撞一个砖块,就将其对应的removed标志位置1,同时分数增加。当所有48块砖块都被消除时,游戏进入胜利状态,屏幕显示“You Win”。砖块数据的存储我用了一个reg [47:0] brick_status,每一位代表一块砖是否存活,这样只需要一个48位的寄存器就可以管理整个砖墙的状态,比使用二维数组节省大量资源。
5. 音频与按键交互设计
音频和按键虽然只是辅助模块,但做得好能显著提升游戏体验。钢琴块游戏中每个方块对应一个音调,按下正确的方块时蜂鸣器发出相应的音符;弹球游戏中,球碰砖块和挡板时也会播放短促的提示音。
5.1 基于PWM的蜂鸣器音符输出
FPGA开发板上常见的无源蜂鸣器需要外部提供特定频率的方波才能发声。我使用PWM方式产生方波,通过一个分频计数器来生成不同频率的方波:
// 以C大调do(523Hz)为例,50MHz时钟下分频 // 计数值 = 50_000_000 / (2 * 523) ≈ 47800 parameter C4_DIV = 47800; reg [15:0] audio_cnt; reg audio_out; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin audio_cnt <= 0; audio_out <= 0; end else if (audio_cnt >= C4_DIV) begin audio_cnt <= 0; audio_out <= ~audio_out; end else begin audio_cnt <= audio_cnt + 1; end end音符频率表我预定义了一个查找表:
| 音符 | 频率(Hz) | 分频值(50MHz) |
|---|---|---|
| C4 | 523.25 | 47800 |
| D4 | 587.33 | 42570 |
| E4 | 659.26 | 37928 |
| F4 | 698.46 | 35793 |
| G4 | 783.99 | 31888 |
| A4 | 880.00 | 28409 |
| B4 | 987.77 | 25310 |
| C5 | 1046.50 | 23889 |
5.2 按键消抖与边沿检测
机械按键按下时会产生大约5~20ms的抖动,如果不做消抖处理,一个按键事件会被误判成多次触发。我使用了20ms的消抖计数:
// 按键消抖 reg [19:0] debounce_cnt; reg key_in_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin debounce_cnt <= 0; key_in_reg <= 1; end else if (key_in != key_in_reg) begin debounce_cnt <= debounce_cnt + 1; if (debounce_cnt >= 20_000_000) begin key_in_reg <= key_in; debounce_cnt <= 0; end end else begin debounce_cnt <= 0; end end注意我这里使用的是带计数器的“稳态确认”方案,比单纯延迟取值的消抖更可靠。实际测试中,无论是轻触开关还是薄膜键盘,都能稳定识别,没有出现误触发。
6. 调试经验与常见问题排查
这个项目前前后后调试了两周,遇到的坑不少,我把最典型的几个问题和排查思路整理在这里,希望能帮到做类似项目的朋友。
6.1 Vivado/Quartus仿真与上板不一致的问题
最开始做仿真时,ModelSim里一切正常,但上板后VGA屏幕完全是黑色的。排查了很久,最后发现是PLL IP的复位时序问题。有些PLL IP核的locked信号在上电后需要一段时间才稳定,而我的复位信号在locked信号有效之前就释放了,导致逻辑处于不确定状态。解决办法是使用locked信号作为模块的复位信号:
wire locked; wire rst_n = locked; // 用PLL锁定信号作为全局复位6.2 VGA画面闪烁和偏移问题
画面闪烁通常有两种原因:一种是刷新率不对,显示器工作在非标准时序下;另一种是帧同步信号处理不当。偏移则通常是同步脉冲前后的时序参数没有对齐,导致画面上下或左右位移。
建议用逻辑分析仪或者示波器测量行场同步信号,对比VESA标准参数。没有示波器的话,也可以通过一个简单的方法判断:在画面的四个角画不同的色块,如果画面偏移,你立刻就能看出来偏移的方向和距离,然后根据偏移量调整时序参数。
6.3 碰撞检测漏判和误判的实际处理
弹球游戏刚开始调试时,高频出现球穿过砖块或者一次消掉两块砖的情况。穿砖块的根因是球速过快,隧穿效应导致碰撞检测的矩形在相邻两帧之间没有交集;一次消两块砖则是因为碰撞检测里没有在检测到碰撞后立即跳出循环,导致同一帧里撞了多个砖块。
解决第一个问题的方法是限制最大速度;解决第二个问题的方法是在检测到第一块碰撞后立即记录砖块索引,并且暂停对其他砖块的碰撞判断。这个逻辑在组合逻辑里需要注意优先级,我使用的是case语句而不是多个if并行判断。
6.4 资源占用优化与布局布线
两个游戏加显示驱动,整体资源占用其实不高,但在布局布线时还是遇到了一次时序不收敛的问题。排查后发现是钢琴块模块中一个组合逻辑路径过长——方块坐标的比较逻辑直接串联了很多级比较器和加法器。
解决办法是在关键路径上插入寄存器,将比较逻辑拆成两拍完成,时序问题立即解决。这说明在设计游戏逻辑时,不能只关注功能正确,还要时刻留意综合后的时序报告。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| VGA无画面 | PLL未锁定/时序参数错误 | 检查locked信号连接,确认行场参数 |
| 画面偏移 | 同步脉冲前后沿不正确 | 对照VESA标准,调整同步时序 |
| 按键无反应 | 消抖时间过短/未产生单脉冲 | 增加消抖计数,增加边沿检测 |
| 钢琴块乱生成 | LFSR初始值为0 | 设置非零初始种子 |
| 球穿过砖块 | 球速过快 | 限制最大速度,采用分段碰撞检测 |
| 游戏切换后停留旧界面 | 子模块未复位 | 切换时全局复位子模块 |
| 蜂鸣器声音嘶哑 | PWM频率不稳定 | 使用参数化分频,避免频繁切换音符 |
7. 项目资料与源码结构
文章最后展示一下完整项目资料的目录结构。整理这套资料的时候,我特意把每个模块的文件名和功能对齐,方便后续参考和二次开发。
project/ ├── rtl/ │ ├── top.v // 顶层模块 │ ├── vga_controller.v // VGA显示驱动 │ ├── pll.v // PLL IP核例化 │ ├── game_piano.v // 钢琴块游戏逻辑 │ ├── game_breakout.v // 弹球游戏逻辑 │ ├── key_debounce.v // 按键消抖模块 │ ├── audio_pwm.v // 音频输出模块 │ ├── lfsr_random.v // 随机数生成器 │ └── score_display.v // 计分显示模块 ├── sim/ │ ├── tb_top.v // 顶层测试平台 │ ├── tb_vga_controller.v │ └── tb_game_piano.v ├── constraint/ │ ├── pin_assign.tcl // 引脚约束文件 │ └── timing.sdc // 时序约束文件 ├── docs/ │ ├── 项目设计文档.md │ ├── 答辩PPT.pptx │ └── 使用说明.pdf └── README.md代码中每个模块都加了详细的注释,关键逻辑均提供了对应的仿真测试文件,烧录到开发板后可以直接运行。两个游戏通过KEY0切换,KEY1和KEY2分别控制钢琴块的左列和右列按键,弹球游戏用KEY1和KEY2控制挡板左右移动,KEY3用于开始/重新开始。
这个项目后续还有很多可以扩展的方向,比如通过修改VGA驱动来支持更高分辨率(1024x768),或者加入PS2键盘进行更复杂的输入控制,再或者用SD卡存放音频数据来替换简单的PWM音符输出。个人觉得最有价值的部分是弹球的碰撞检测和钢琴块的随机生成逻辑,这两块理解了,再做其他类型的FPGA游戏基本就是举一反三的事。做完这个项目后,我对FPGA的时序设计和状态机建模有了更深的理解,回头再看那种单纯的流水灯、数码管练习题,明显感觉到完全不在一个层次上了。
本文还有配套的精品资源,点击获取
