猜数字游戏
还记得第一次课上我们演示的猜数字小游戏吗?在本方向中,我们将带大家自顶向下地实现这个项目。
2.2.1 游戏介绍
猜数字小游戏的基本过程如下:首先,程序随机生成 3 个介于 0 ~ 5 之间的、互不相同的数码,由玩家进行猜测。玩家每次通过拨码开关(Switches)输入自己猜测的数据序列,按下按钮表明输入完毕。随后,程序检查玩家最近的三次输入和生成的数码是否匹配,并通过 LED 给出对应的反馈结果。玩家在看到结果之后,会再次按下按钮以继续输入自己的猜测。在玩家操作同时,数码管(Segments)会显示一个倒计时,在倒计时结束之前如果匹配正确则视为用户胜利,否则会视为用户失败。之后,用户再次按下按钮,以开始一局新的游戏。
编码开关的输入方式为:
sw[0] - 输入 0
sw[1] - 输入 1
sw[2] - 输入 2
sw[3] - 输入 3
sw[4] - 输入 4
sw[5] - 输入 5
开关的输入为上升沿有效,在其被拨上去时视作输入了一个对应的数字,将开关从开启状态拨动到关闭状态不会触发任何输入动作。按下按钮后,我们仅将最近的三次输入作为用户的最终输入,例如:假定当前的开关状态为 sw = 8'B0011_0011
,经过下面的一系列操作:
sw = 8'B0011_0011 // 初始状态
sw = 8'B0010_0011 // 拨下 sw[4]
sw = 8'B0010_1011 // 拨上 sw[3]
sw = 8'B0010_1111 // 拨上 sw[2]
sw = 8'B0010_1110 // 拨下 sw[0]
sw = 8'B0011_1110 // 拨上 sw[5]
sw = 8'B0011_1111 // 拨上 sw[0]
随后按下按钮,则此时程序接收到的输入序列为 12'H250
。那么如果用户输入了限制范围之外的数值,例如拨动了 sw[7]
应当怎么处理呢?这个问题我们可以之后再考虑。
按钮的作用
这里我们让按钮作为用户输入完成的确定信号。因此,在用户通过开关输入数据时,后续的匹配模块并不会接收到数据;仅当用户按下按钮后,匹配模块才会接收到用户的最近三次输入信息。
匹配阶段需要比较用户猜测的结果和目标结果之间的相似程度。考虑到我们只有三位数,所有可能的结果可以拆成下面几种情况:
- 一个数字都没对;
- 只对了一个数,且位置不正确;
- 只对了一个数,但位置正确;
- 对了两个数,但位置都不正确;
- 对了两个数,但一个位置正确,一个不正确;
- 对了两个数,且位置都正确;
- 对了三个数,且位置都不正确;
- 对了三个数,但一个位置正确,两个不正确;
- 对了三个数,且位置都正确。
我们可以使用 led[5:0]
表示上面的九种情况。对应的关系如下:
-
led[2:0]
:用于指示当前正确但位置不正确的数字数目。其中led[2]
亮起代表 3 个数都正确但位置均不正确led[1]
亮起代表有 2 个数正确但位置均不正确led[0]
亮起代表有 1 个数正确但位置不正确- 如果均不亮起代表没有正确的数字
-
led[5:3]
:用于指示当前正确且位置正确的数字数目。其中led[5]
亮起代表 3 个数位置都正确led[4]
亮起代表有 2 个数位置正确led[3]
亮起代表有 1 个数位置正确- 如果均不亮起代表没有位置正确的数字。
例如,如果用户输入为 023,而当前游戏的答案为 520,则 LED 灯的结果为 led[5:0] = 6'b001_001
,即对了两个数,但一个位置正确,一个不正确。如果用户输入为 052,则 LED 灯的结果为 led[5:0] = 6'b000_100
,即 3 个数都正确但位置均不正确。九种情况的完整对应关系如下:
- 一个数字都没对:
led[5:0] = 6'b000_000
- 只对了一个数,且位置不正确:
led[5:0] = 6'b000_001
- 只对了一个数,但位置正确:
led[5:0] = 6'b001_000
- 对了两个数,但位置都不正确:
led[5:0] = 6'b000_010
- 对了两个数,但一个位置正确,一个不正确:
led[5:0] = 6'b001_001
- 对了两个数,且位置都正确:
led[5:0] = 6'b010_000
- 对了三个数,且位置都不正确:
led[5:0] = 6'b000_100
- 对了三个数,但一个位置正确,两个不正确:
led[5:0] = 6'b001_010
- 对了三个数,且位置都正确:
led[5:0] = 6'b100_000
(这种情况会一闪而过,因为游戏已经胜利了,LED 会全部亮起)
当游戏胜利时,LED 灯会全部亮起,数码管显示 32'H88888888
;当游戏失败时,LED 灯会全部熄灭,数码管显示 32'H44444444
。为了增加游戏的动态效果,我们让 LED 在用户输入的过程中(还没有按下按钮表示输入完成)呈现 Lab3 所示的流水灯效果。
2.2.2 系统设计
根据以上的内容,我们可以设计如下图所示的电路结构。
整个项目可以分为如下的几个部分:
- 外设输入。包括:开关输入处理模块、串口输入处理模块等;
- 外设输出。包括:数码管显示模块、串口输出处理模块等;
- 计时器。包括:核心计时器、计时复位控制模块等;
- 数据检查模块。包括:随机数生成器、结果检测模块等;
- 中央控制器。
我们将分为基础版和进阶版两部分进行说明。
★ 2.2.3 基础内容
为了实现最为基础的功能,Basic 版本的猜数字小游戏硬件结构如下图所示:
其中,rst
信号由 sw[7]
引出,作为全局复位信号。当 rst
信号为高电平时,系统内所有的寄存器都会被复位。LED_flow
模块为 Lab3 中编写的流水灯模块。
2.2.3.1 开关输入
首先,我们需要确定用户拨动开关的时机以及对应的编号,这就是 SW_Input
模块的作用。模块的端口约定如下:
SW_Input | |
---|---|
1 2 3 4 5 6 7 8 |
|
其中 sw
是来自开发板的开关输入,hex
信号用于指示当前拨动的开关编号,范围是 4'd0
至 4'd7
。之所以使用 4bits 位宽是为了便于之后的扩展。pulse
信号为时钟同步的高电平脉冲,当某开关由关闭变为开启状态时,pulse
会发出一个时钟周期的高电平信号。
模块的代码框架如下:
SW_Input | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
下面是对应的示例波形图(图中存在误差,实际的信号应均为时钟同步的。所有的数据均为十六进制):
完成 SW_Input
模块后,我们需要引入一个移位寄存器,用于存储用户最近的开关输入情况。
Shift_Reg | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
这样就实现了用户输入的保存。
题目 2-A-1
请根据以上内容,按要求完成本项练习。
2.2.3.2 数据检查模块
数据检查模块 Check
用于确定当前用户的输入是否与游戏目标相符,并给出对应的检测结果。模块的端口定义如下:
Check.v | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
其中,input_number
为来自用户的输入,target_number
为本局游戏的目标,start_check
信号来自控制单元。当 start_check
信号为高电平时,模块会给出对应的 check_result
。在这里,check_result
就是上面提到的 LED 信号。例如:check_result = 6'B001_001
代表有两个数字正确,其中一个数字位置正确,另一个位置不正确。
Check
模块的代码框架如下:
Check.v | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
|
Tips
实际上,Check
模块可以被设计成一个纯粹的组合逻辑模块,即对于任何给定的 target_number
和 input_number
,都能异步地给出 check_result
信号。你可以自行选择自己喜欢的实现方式。
提示
Basic 版本的猜数字游戏无法随机生成题目,因此在例化 Check
模块时,可以先暂时将 target_number
设定成一个固定常数。
题目 2-A-2
请根据以上内容,按要求完成本项练习。
2.2.3.3 计时器
计时器模块 Timer
用于控制倒计时的开启与关闭。模块的端口定义如下:
Timer.v | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
其中,set
信号用于设置计时器的起始值。当 set
信号有效时,内部的计时器会被设置为 01 分 00 秒 000 毫秒。en
信号来自控制单元。当 en
信号有效时,计时器会持续倒计时;当 en
信号为低电平时,计时器会暂停倒计时(而不是清零或复位)。finish
信号在内部计时器值为 0 分 0 秒 000 毫秒时为高电平,用于告知控制单元此时已经计时完毕。minute
、second
和 micro_second
分别对应当前计时器的分、秒、微秒数值。
提醒
上述所有的数值均为二进制表示,而不是 BCD 码表示。也就是说,minute 的上限值是 8'D59
或 8'H37
,而不是 8'B0101_1001
。
计时器内部的核心为串行计时单元和一个状态机。串行计时单元基于如下的 Clock
模块:
Clock.v | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
Clock
模块是串行计数器的基本模块。其中 carry_in
是来自低位的进位信号,carry_out
是向高位的进位信号。Clock
模块仅在 carry_in
有效时倒计时一次,仅在倒计时达到下限 MIN_VALUE
时发出一次 carry_out
信号。下面是计时器的基本结构:
毫秒计数器每 1ms 跳动一次,当减少到 0 时触发秒计数器跳动一次;秒计数器为 0 时触发分计数器跳动一次,依次类推,这样就实现了串行计数。
计时器内部的状态机则比较简单,仅包含两个状态。
计时器在计时状态下才会启动毫秒计时器,用于产生 micro_second_clock 的 carry_in
信号;在停止状态下,该信号将保持为 0。
Timer
模块的代码框架如下:
Timer.v | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
|
题目 2-A-3
请根据以上内容,按要求完成本项练习。
2.2.3.4 核心控制器
Control
模块用于控制整个电路的正常工作。其端口约定如下:
Control.v | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
你需要结合数据通路与各个子模块的定义,自行设计 Control
模块中的状态机,并确定各个信号的产生逻辑。
题目 2-A-4
请根据以上内容,按要求完成本项练习。
至此,我们已经实现了 Basic 版本的大部分内容。
题目 2-A-5
请根据以上内容,按要求完成本项练习。
2.2.4 进阶内容
进阶内容是在 Basic 版本的基础上进行的细节添加。
2.2.4.1 BCD 显示
你可能已经注意到了,目前数码管的倒计时是十六进制的格式。这是由于我们从 Timer 模块直接将结果 current_time
连到了数码管的输出上,而 Segment
模块是直接将 output_data
的某几位作为 seg_data
的输出的。
现在,我们需要让数码管的倒计时显示为 8421BCD 编码的格式。为此我们需要在 Timer 模块和 Segment 模块之间添加一个 Hex2BCD 模块。该模块可以将输入的十六进制(其实也就是二进制)数据转换为对应的十进制 BCD 码。例如:
8'H0A -> 10 -> 8'B0001_0000
8'H1C -> 28 -> 8'B0010_1000
8'H20 -> 32 -> 8'B0011_0010
关于如何进行编码转换,你可以参考这篇文章与这篇文章的相关内容进行设计。
Tips
本题允许使用循环语句。但如果你使用了循环语句,那么需要在检查时向助教介绍这段代码实际对应的电路结构。
2.2.4.2 闪烁的倒计时
在 Lab3 的选择性必做题目 3 里,我们设计了一个带有掩码功能的 Segment 模块。现在,我们希望基于该模块添加倒计时闪烁的功能。描述如下:
- 剩余时间大于 10s:不闪烁
- 剩余时间大于 3s 且不超过 10s:以 1Hz 的频率闪烁(数码管亮起 0.5s,熄灭 0.5s)
- 剩余时间不超过 3s:以 2Hz 的频率闪烁(数码管亮起 0.25s,熄灭 0.25s)
- 胜利或失败界面:不闪烁
请自行设计模块并修改数据通路,实现上述的功能。
2.2.4.3 随机数生成器
现在的猜数字游戏只有一道题目,因此只能进行一局游戏。为了支持更为多元的游戏模式,我们希望每一局游戏的答案都能「随机」产生。这就是随机数生成器的作用了。
首先,我们需要明确题目可能的数目。对于六个数码组成的无重复数值,总共有 \(6\times5\times4=120\) 种不同的结果。我们使用一个 reg
型数组 targets
进行存储:
reg [11:0] targets [0: 127];
always @(posedge clk) begin
// 这里用 initial 也可以,只要让 targets 中的数值保持不变即可。
targets[0] <= 12'h012;
targets[1] <= 12'h013;
targets[2] <= 12'h014;
targets[3] <= 12'h015;
targets[4] <= 12'h021;
targets[5] <= 12'h023;
targets[6] <= 12'h024;
targets[7] <= 12'h025;
targets[8] <= 12'h031;
targets[9] <= 12'h032;
targets[10] <= 12'h034;
targets[11] <= 12'h035;
targets[12] <= 12'h041;
targets[13] <= 12'h042;
targets[14] <= 12'h043;
targets[15] <= 12'h045;
targets[16] <= 12'h051;
targets[17] <= 12'h052;
targets[18] <= 12'h053;
targets[19] <= 12'h054;
targets[20] <= 12'h102;
targets[21] <= 12'h103;
targets[22] <= 12'h104;
targets[23] <= 12'h105;
targets[24] <= 12'h120;
targets[25] <= 12'h123;
targets[26] <= 12'h124;
targets[27] <= 12'h125;
targets[28] <= 12'h130;
targets[29] <= 12'h132;
targets[30] <= 12'h134;
targets[31] <= 12'h135;
targets[32] <= 12'h140;
targets[33] <= 12'h142;
targets[34] <= 12'h143;
targets[35] <= 12'h145;
targets[36] <= 12'h150;
targets[37] <= 12'h152;
targets[38] <= 12'h153;
targets[39] <= 12'h154;
targets[40] <= 12'h201;
targets[41] <= 12'h203;
targets[42] <= 12'h204;
targets[43] <= 12'h205;
targets[44] <= 12'h210;
targets[45] <= 12'h213;
targets[46] <= 12'h214;
targets[47] <= 12'h215;
targets[48] <= 12'h230;
targets[49] <= 12'h231;
targets[50] <= 12'h234;
targets[51] <= 12'h235;
targets[52] <= 12'h240;
targets[53] <= 12'h241;
targets[54] <= 12'h243;
targets[55] <= 12'h245;
targets[56] <= 12'h250;
targets[57] <= 12'h251;
targets[58] <= 12'h253;
targets[59] <= 12'h254;
targets[60] <= 12'h301;
targets[61] <= 12'h302;
targets[62] <= 12'h304;
targets[63] <= 12'h305;
targets[64] <= 12'h310;
targets[65] <= 12'h312;
targets[66] <= 12'h314;
targets[67] <= 12'h315;
targets[68] <= 12'h320;
targets[69] <= 12'h321;
targets[70] <= 12'h324;
targets[71] <= 12'h325;
targets[72] <= 12'h340;
targets[73] <= 12'h341;
targets[74] <= 12'h342;
targets[75] <= 12'h345;
targets[76] <= 12'h350;
targets[77] <= 12'h351;
targets[78] <= 12'h352;
targets[79] <= 12'h354;
targets[80] <= 12'h401;
targets[81] <= 12'h402;
targets[82] <= 12'h403;
targets[83] <= 12'h405;
targets[84] <= 12'h410;
targets[85] <= 12'h412;
targets[86] <= 12'h413;
targets[87] <= 12'h415;
targets[88] <= 12'h420;
targets[89] <= 12'h421;
targets[90] <= 12'h423;
targets[91] <= 12'h425;
targets[92] <= 12'h430;
targets[93] <= 12'h431;
targets[94] <= 12'h432;
targets[95] <= 12'h435;
targets[96] <= 12'h450;
targets[97] <= 12'h451;
targets[98] <= 12'h452;
targets[99] <= 12'h453;
targets[100] <= 12'h501;
targets[101] <= 12'h502;
targets[102] <= 12'h503;
targets[103] <= 12'h504;
targets[104] <= 12'h510;
targets[105] <= 12'h512;
targets[106] <= 12'h513;
targets[107] <= 12'h514;
targets[108] <= 12'h520;
targets[109] <= 12'h521;
targets[110] <= 12'h523;
targets[111] <= 12'h524;
targets[112] <= 12'h530;
targets[113] <= 12'h531;
targets[114] <= 12'h532;
targets[115] <= 12'h534;
targets[116] <= 12'h540;
targets[117] <= 12'h541;
targets[118] <= 12'h542;
targets[119] <= 12'h543;
end
这样,每一局游戏只需要随机生成一个范围在 0 ~ 119 之间的下标 index
,即可得到对应的游戏目标 targets[index]
了。
下面,我们给出 Random.v 模块的基础端口代码。
Random.v | |
---|---|
1 2 3 4 5 6 |
|
其中:generate_random
信号来自控制模块,高电平有效。在时钟上升沿,如果发现 generate_random
信号为 1,则需要给出一个新的 12bits 随机数 random_data
。一个最为简单的递增实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
这样,每一局游戏的答案以 120 为周期进行循环。你可以在为 target
初始化时将数据随机排序(而不是现在的顺序排序),从而得到更加随机的结果。
接下来,我们需要引入硬件的伪随机。一个直观的思路就是将上一次的 index
、用户输入 sw
以及当前系统运行的时间(可以维护一个计数器)作为因变量,控制 index
的产生逻辑。例如:
always @(posedge clk) begin
if (rst)
index <= 0;
else
index <= (index_old + sw_seed + timer_seed) % 120;
end
现在,由于每一次用户的输入序列不同,以及对应的游戏时间不同,游戏仅有 rst
后的第一次答案固定,之后的答案都将随机产生。你需要将结合了用户输入 sw
以及当前系统运行的时间的 Random
模块正确接入通路,实现随机的题目生成功能。为此你可能需要为 Random 模块增加一些端口。
2.2.4.4 串口命令
为什么串口方向的练习会与串口没关系呢?这不就来了嘛!
我们希望为猜数字小游戏增加一些串口交互。你需要添加如下的内容:
a;
:在串口界面输出当前游戏的答案target_number
。此时通过开关输入答案后游戏可以正常进入胜利状态。n;
:开始一局新的游戏。此时游戏的答案需要发生变化,计时器及核心状态机也需要改动。注意:n;
命令与rst
信号的功能并不等价。p;
:暂停计时器。输入后游戏的计时器暂停工作,其他模块则正常进行。再次输入后计时器则会继续工作。
你可以参考串口通信的教程完成本部分设计。为此,你需要自行设计部分模块,并修改数据通路。
题目 2-A-6
请根据以上内容,按要求完成本项练习。