0.前言
这篇博客手把手带你用Verilog语言结合图形原理图实现一个通过数字电路实现的贪吃蛇游戏机。看完之后,你一定能做出自己的贪吃蛇游戏机!整个项目在Quartus 17.0
上编译完成并进行仿真,并用Cyclone IV E - EP4CE40U19A7烧录进行实机测试。
1.构思与设计
1.1功能需求
由于目标是复刻经典小游戏,贪吃蛇,所以这个数字电路系统需要实现游戏的以下几个功能:
- 屏幕显示蛇的移动;
- 显示游戏的倒计时,倒计时结束则游戏结束;
- 蛇吃到老鼠即得分,同时显示游戏的得分;
- 进行撞墙判定,撞墙则直接游戏结束;
- 开关控制游戏的开始与重置;
1.2系统设计
根据需要实现的功能,自上而下设计这个系统。整个系统可以分为两个主要的子系统:游戏控制系统与显示系统。设计时,系统被细分为多个子模块,分别进行实现,方便调试与改进。
控制系统是游戏的逻辑处理系统,所有的信息都需要经过该系统的处理,包括游戏不同状态的判定、显示信息的更新等,都需要由该系统进行完成。控制系统被分为蛇坐标模块,按键输入模块,老鼠刷新模块与游戏控制模块共4个模块。
-
游戏控制模块,寄存蛇的坐标。蛇由许多节点组成的整体,每一个节点都包含x与y坐标,当游戏在8*8的点阵上显示时,每个节点都需要两组3位寄存器分别存储横纵坐标信息,并且根据输入状态实时更新各个节点的坐标信息。蛇坐标模块被涵盖在这个模块中
-
按键输入模块,使用四个机械按键进行输入,分别对应控制游戏中蛇的上下左右移动。同时考虑到机械按键存在抖动,需要添加消抖模块,以提高系统的稳定性。
-
老鼠刷新模块,游戏初始化时能够在屏幕上显示一个亮点代表老鼠,在老鼠被蛇吃之后能够自动且随机的刷新。即需要实现一个伪随机数生成器以达到老鼠随机刷新的效果。
显示系统顾名思义,负责将游戏信息显示在晶体管或者点阵上,便于玩家直观的进行操作。基于需要的功能进行分析,该系统可以分为两个独立模块,数码管显示模块与点阵显示模块。
-
数码管显示模块,展示两个模块的信息:计时模块,需要显示游戏时间的倒计时,当游戏倒计时到0,则要直接终止游戏;得分模块,则需要接收游戏的实时状态,若蛇吃到老鼠则需要更新游戏得分。
-
点阵显示模块将游戏的范围限定在8*8的范围内,显示蛇与老鼠。
最后,根据各模块之间所需要的输入信息以及不同的输出信息,能够得到如下的系统框图:
2.显示系统
2.1倒计时模块
该模块需要实现59秒的倒计时功能,当倒计时为0时,需要输出游戏结束信号。
本质上是实现一个模为59的减法计数器,所以采用两块可逆10进制计数器74LS190实现。起始高位片置数为5,低位片置数为9。
同时提供以下几个输入控制口及输出口:
- RESET控制端:游戏开始和游戏结束时的复位
- BO输出端:倒计时结束输出高电平表示游戏结束
- Q0[3…0]、Q1[3…0]:表示倒计时的个位及十位
原理图如下:
2.2计分模块
该模块本质功能就是实现一个100模的计数器,控制端改为贪吃蛇吃到食物的输入信号,以记录贪吃蛇的游戏得分。具体实现很简单,可以使用两片74190,改为加法计数,也可以使用74160或者其他计数器芯片,或者为了配合数码管的显示,可以使用74390。此处选择两片74160级联实现。
原理图:
2.3译码显示模块
为了使上述两个模块的输出直观的呈现出来,可以使用七段数码管显示,对输入信号进行译码显示:将输入的4位二进制数译为数码管需要的显示译码信号。
此处采用Verilog语言实现一个广义译码器:
module DCD7SG(A,LED);
input [3:0] A;
output [6:0] LED;
reg [6:0] LED;
always @(A)
case(A)
4'b0000:LED<=7'b0111111;
4'b0001:LED<=7'b0000110;
4'b0010:LED<=7'b1011011;
4'b0011:LED<=7'b1001111;
4'b0100:LED<=7'b1100110;
4'b0101:LED<=7'b1101101;
4'b0110:LED<=7'b1111101;
4'b0111:LED<=7'b0000111;
4'b1000:LED<=7'b1111111;
4'b1001:LED<=7'b1101111;
default:LED<=7'b0100000;
endcase
endmodule
同时,多位结果可以采取“动态扫描”的方式呈现出来:利用人眼的视觉停留,高频率依次扫描点亮四个数码管对应段,就能达到持续显示多位的效果。换句话说,上面我们完成了数码管的段选,还需要结合一个数码管的位选模块,依次显示计分、计时的个位、十位。可以选用74153实现数码管的段选,也可以自己实现一个四选一数据选择器。
四选一数据选择器的参考代码如下:
module MUX4(A,B,D0,D1,D2,D3,Y);
input A,B,D0,D1,D2,D3;
output reg Y;
always @(*) begin
case ({A, B})
2'b00: Y = D0;
2'b01: Y = D1;
2'b10: Y = D2;
2'b11: Y = D3;
default: Y = 1'b0;
endcase
end
endmodule
结合74138实现对数码管的位选,就能实现依次点亮不同的数码管的功能,该模块的最终原理图如下:
2.4点阵显示模块
点阵显示与数码管显示原理相同,我们通过动态扫描的方式,逐行点亮点阵中的二极管,当循环点亮的速度足够快,从视觉上看就是同时亮的。基本逻辑为:循环扫描点阵的行,对应引脚输入高电平。然后检查列,如果为蛇或者老鼠所在的行,则同时对其所在列的引脚输入低电平,从而点亮。
要使点阵看起来是连续的,则每秒整个点阵需要刷新60次及以上,所以输入的时钟信号的频率应大于8*60=480Hz。
Verilog代码如下:
module snake_display (
input clk, // 时钟信号,用于动态扫描
input rst, // 复位信号
input [2:0] snake_head_x, // 蛇头横坐标(0-7)
input [2:0] snake_head_y, // 蛇头纵坐标(0-7)
input [2:0] snake_tail_x, // 蛇尾横坐标(0-7)
input [2:0] snake_tail_y, // 蛇尾纵坐标(0-7)
input [2:0] mouse_x, // 老鼠横坐标(0-7)
input [2:0] mouse_y, // 老鼠纵坐标(0-7)
output reg [7:0] row, // 行信号,动态扫描
output reg [7:0] col // 列信号,点亮对应的列
);
// 扫描计数器
reg [2:0] scan_idx; // 行扫描索引(0-7)
always @(posedge clk or posedge rst) begin
if (rst)
scan_idx <= 0; // 复位时,从第 0 行开始扫描
else
scan_idx <= scan_idx + 1; // 循环扫描 0-7 行
end
// 行信号生成
always @(*) begin
case (scan_idx)
3'd0: row = 8'b0000_0001; // 第 0 行激活
3'd1: row = 8'b0000_0010; // 第 1 行激活
3'd2: row = 8'b0000_0100; // 第 2 行激活
3'd3: row = 8'b0000_1000; // 第 3 行激活
3'd4: row = 8'b0001_0000; // 第 4 行激活
3'd5: row = 8'b0010_0000; // 第 5 行激活
3'd6: row = 8'b0100_0000; // 第 6 行激活
3'd7: row = 8'b1000_0000; // 第 7 行激活
default: row = 8'b0000_0000; // 默认行信号关闭
endcase
end
// 列信号生成
always @(*) begin
// 默认列信号关闭
col = 8'b0000_0000;
// 如果当前扫描的行是蛇头所在的行
if (scan_idx == snake_head_y)
col[snake_head_x] = 1'b1; // 点亮蛇头的列
// 如果当前扫描的行是蛇尾所在的行
if (scan_idx == snake_tail_y)
col[snake_tail_x] = 1'b1; // 点亮蛇尾的列
// 如果当前扫描的行是老鼠所在的行
if (scan_idx == mouse_y)
col[mouse_x] = 1'b1; // 点亮老鼠的列
end
endmodule
3.控制系统
3.1输入模块
消抖模块
通常,电子电路中所用到的开关都是机械装置,在通断的时候会产生机械抖动,所以在使用按键的时候,经常需要设计按键消抖的电路设计。
该消抖模块由4个D触发器和1个输入与门构成。当信号传入电路后,在输出端口的条件是4个D触发器的输出Q同时为1。因为干扰抖动信号是一群宽度狭窄的随机信号,因此在串入时,很难十分整齐的地同时使与门输出为1,而只有正常信号才有足够的宽度通过此电路,从而起到了“滤波”的作用。
电路原理图:
输入方向编码模块
将上述设计好的消抖模块与输入按键相接,然后编写一个编码模块,将四位二进制输入编码为用两位二进制表示的次态方向信息:
00 | 向上 |
---|---|
01 | 向下 |
10 | 向左 |
11 | 向右 |
按键不可能持续有输入,在大部分时间,按键输入都将会是4'b0000
,需要主动判断输入是否有效,只有产生了有效的输入信号才能向后输出到控制模块,==如果将defalut
随意置为仍以方向状态,会导致你的蛇的移动不受控制!!==向后输出的时钟频率应保持与主控制模块同步,才能实时更新蛇的移动状态。同时,按键输入检测的时钟频率应该是一个相对较高频率的时钟信号,才能对按键输入较为敏感。
Verilog语言实现该编码模块[该段代码已经包含了去抖功能,不需要再外接上面的去抖模块]:
module Input_decode(clk_h,clk_l,rst,Up,Dn,Le,Ri,OUT);
input clk_h,clk_l; //clk_h为高频去抖模块时钟,clk_l为低频控制模块时钟
input rst;
input Up,Dn,Le,Ri;
output reg [1:0]OUT;
// 寄存有效方向
reg [1:0] direction_reg; // 寄存有效方向状态
reg [3:0] input_state; // 寄存按键输入状态
// 处理按键输入逻辑 (1kHz)
always @(posedge clk_h or posedge rst) begin
if (rst) begin
direction_reg <= 2'b00; // 复位时默认方向向上
input_state <= 4'b0000; // 清空按键输入状态
end else begin
// 获取按键输入状态
input_state <= {Up, Dn, Le, Ri};
// 检查按键输入是否有效
case (input_state)
4'b1000: direction_reg <= 2'b00; // 向上
4'b0100: direction_reg <= 2'b01; // 向下
4'b0010: direction_reg <= 2'b10; // 向左
4'b0001: direction_reg <= 2'b11; // 向右
default: direction_reg <= direction_reg; // 无效输入,保持原方向
endcase
end
end
// 在 1Hz 时钟下输出方向状态
always @(posedge clk_l or posedge rst) begin
if (rst) begin
OUT <= 2'b00; // 复位时默认方向向右
end else begin
OUT <= direction_reg; // 输出寄存的方向状态
end
end
endmodule
3.2游戏控制模块
该模块是最核心的模块。其逻辑判断流程图如下:
具体的代码需要实现:
次态方向输入:
next_direction
是 2 位宽的输入,直接控制蛇的方向。- 根据规则(不能掉头),更新当前方向
direction
。
蛇头更新:
- 根据当前方向
direction
,更新蛇头的坐标。 - 例如,
UP
表示纵坐标减 1,LEFT
表示横坐标减 1。
蛇尾更新:
- 蛇尾的坐标始终等于上一个时钟周期的蛇头坐标。
边界检测:
- 如果蛇头超出网格范围 (
snake_head_x
或snake_head_y
小于 0 或大于等于网格大小),则游戏结束。 - 如果
time_up
信号为高电平,也立即结束游戏。
得分判断:
- 如果蛇头的坐标等于老鼠的坐标,则
score
信号置高。 - 每次时钟周期更新
score
,否则置低。
复位逻辑:
- 在
rst
高电平时,重置所有状态,包括蛇头和蛇尾的位置、方向、得分信号等。
使用Verilog语言实现该模块:
module Controller (
input clk, // 时钟信号
input rst, // 复位信号,高电平复位
input [1:0] next_direction, // 次态方向输入(2 位宽:00=上,01=下,10=左,11=右)
input [2:0] mouse_x, // 老鼠横坐标输入(0-7)
input [2:0] mouse_y, // 老鼠纵坐标输入(0-7)
input time_up, // 时间状态输入,高电平表示时间到
output reg game_state, // 游戏状态输出,低电平表示游戏结束
output reg score, // 得分信号,高电平表示吃到老鼠
output reg [2:0] snake_head_x, // 蛇头横坐标输出(0-7)
output reg [2:0] snake_head_y, // 蛇头纵坐标输出(0-7)
output reg [2:0] snake_tail_x, // 蛇尾横坐标输出(0-7)
output reg [2:0] snake_tail_y // 蛇尾纵坐标输出(0-7)
);
// 参数定义
parameter GRID_WIDTH = 8; // 网格宽度(8)
parameter GRID_HEIGHT = 8; // 网格高度(8)
// 方向参数
parameter UP = 2'b00, DOWN = 2'b01, LEFT = 2'b10, RIGHT = 2'b11;
// 当前方向
reg [1:0] direction; // 当前方向
// clk或rst上升沿有效
always @(posedge clk or posedge rst) begin
if (rst) begin
// 初始化游戏状态
game_state <= 1; // 游戏开始状态
score <= 0; // 得分信号清零
snake_head_x <= 4; // 蛇头初始位置
snake_head_y <= 5;
snake_tail_x <= 4; // 蛇尾初始位置
snake_tail_y <= 6;
direction <= UP; // 初始方向向上
end else if (game_state) begin
// 更新方向(掉头保护逻辑)
if ((next_direction == UP && direction != DOWN) ||
(next_direction == DOWN && direction != UP) ||
(next_direction == LEFT && direction != RIGHT) ||
(next_direction == RIGHT && direction != LEFT)) begin
direction <= next_direction; // 更新为合法的方向
end
// 更新蛇尾位置为上一个蛇头的位置
snake_tail_x <= snake_head_x;
snake_tail_y <= snake_head_y;
// 更新蛇头位置
case (direction)
UP: begin
if (snake_head_y > 0)
snake_head_y <= snake_head_y - 1; // 向上移动
else
game_state <= 0; // 撞墙,游戏结束
end
DOWN: begin
if (snake_head_y < GRID_HEIGHT - 1)
snake_head_y <= snake_head_y + 1; // 向下移动
else
game_state <= 0; // 撞墙,游戏结束
end
LEFT: begin
if (snake_head_x > 0)
snake_head_x <= snake_head_x - 1; // 向左移动
else
game_state <= 0; // 撞墙,游戏结束
end
RIGHT: begin
if (snake_head_x < GRID_WIDTH - 1)
snake_head_x <= snake_head_x + 1; // 向右移动
else
game_state <= 0; // 撞墙,游戏结束
end
endcase
// 吃到老鼠判断
if (snake_head_x == mouse_x && snake_head_y == mouse_y) begin
score <= 1; // 吃到老鼠,得分
end else begin
score <= 0; // 未得分
end
// 游戏结束条件:时间到
if (time_up) begin
game_state <= 0; // 时间到,游戏结束
end
end
end
endmodule
3.3老鼠坐标生成模块
该模块需要实现的功能是随机生成老鼠坐标,并且在得分产生后更新老鼠的坐标。内部可以采用一个8位二进制计数器随时钟信号不断计数,两个4位二进制寄存器,分别存储横纵坐标。当需要更新坐标时,直接取计数器的输出作为横纵坐标,从而达到伪随机数的效果。
Verilog代码如下:
module MOUSE_UPDATE (
input clk, // 时钟信号
input rst, // 复位信号
input score, // 得分信号,高电平时生成新坐标
output reg [2:0] mouse_x, // 鼠标横坐标 (0-7)
output reg [2:0] mouse_y // 鼠标纵坐标 (0-7)
);
// 内部寄存器
reg [2:0] new_x; // 新生成的横坐标
reg [2:0] new_y; // 新生成的纵坐标
reg [15:0] counter; // 计数器,用于伪随机数生成
// 计数器:伪随机种子生成
always @(posedge clk or posedge rst) begin
if (rst)
counter <= 16'b0; // 复位时计数器清零
else
counter <= counter + 1; // 每个时钟周期计数器递增
end
// 新坐标生成逻辑
always @(*) begin
new_x = counter[2:0]; // 计数器最低 3 位作为横坐标
new_y = counter[5:3]; // 计数器第 3~5 位作为纵坐标
end
// 输出更新逻辑
always @(posedge clk or posedge rst) begin
if (rst) begin
mouse_x <= 3'b011; // 复位时鼠标横坐标清零
mouse_y <= 3'b011; // 复位时鼠标纵坐标清零
end else if (score) begin
// 只有得分信号为高电平时更新鼠标坐标
if (new_x != mouse_x || new_y != mouse_y) begin
mouse_x <= new_x; // 更新横坐标
mouse_y <= new_y; // 更新纵坐标
end else begin
// 如果生成的坐标与当前坐标相同,强制更新
mouse_x <= new_x + 3'b001; // 偏移一位,确保不同
mouse_y <= new_y + 3'b001;
end
end
end
endmodule
3.4时钟分频模块
根据不同模块的功能不同,需要不同频率的时钟信号,因此在前端要一个分频模块,得到不同频率的信号。
具体模块设定的频率如下:
- 游戏控制模块,倒计时模块 1Hz
- 显示模块 512Hz
- 输入模块 128Hz
板子上产生的晶振频率为50MHz。首先用锁相环降低到2048Hz,再使用一个12位的计数器实现进一步分频,最终获得所需的不同频率的时钟信号。具体什么模块需要多少频率的时钟信号将取决于不同模块的功能,以及相互之间的联系。比如食物坐标生成模块的时钟信号应该与主控制模块的时钟信号保持一致,才能在吃掉食物之后同步更新新的食物坐标。
分频模块的原理图如下:
3.5游戏状态重置模块
为了实现对游戏的重置,需要一个模块同时处理拨杆开关输入以及来自控制模块的游戏状态信息。第一局游戏,通过拨动开关,以开始游戏;当游戏结束时,自动重置游戏,重新开始游戏。所以要检测来自拨杆开关输入的上升沿,游戏状态从1变为0的下降沿信号。
输出也要注意是短暂的高电平脉冲信号,否则游戏是无法开始的。
Verilog代码如下:
module Gated(clk,state,set,out);
input wire clk;
input wire state,set; // 游戏状态,控制开关,高电平启动/复位
output reg out; // 输出复位信号,高电平复位,低电平运行
reg previous_switch_state;
reg pluse;
always @(posedge clk) begin
out <=0;
if(!state)begin
out<=1;
pluse<=1;
end else if(set && !previous_switch_state) begin
out<=1;
pluse<=1;
end else if(pluse) begin
//重置脉冲信号
out<=0;
pluse<=0;
end
previous_switch_state <= set;
end
endmodule
4.总结
至此,该项目完成了80%,之后根据系统框图把不同的模块链接起来,编译后将输出文件烧录到FPGA上,就能通过按键玩这个贪吃蛇小游戏了!
如果中途出现任何问题,可以分模块进行调试,既可以实机观察效果,还可以分别进行波形仿真以判断实际输出与设计的输出是否一致。分模块的好处就在于我们可以更容易排查出错误产生的位置进而修正和优化。此外,该游戏机还有以下几个优化的方向:
- 增加一个游戏开始的控制模块,增加选择游戏难度的功能:控制模块输入不同频率的时钟,就能改变蛇的移动速度
- 为了实现的方便,蛇是固定长度只有头尾,可以增加一个逻辑功能,当蛇吃到食物之后身体会变长。进而游戏也有可能会因为是蛇撞到自己的身体而结束。
项目仍然有许多的小瑕疵,如果有什么问题,欢迎大佬指正!