这一篇在干嘛?
前面我们已经分别玩过 PS/2 键盘和 VGA 显示,这一篇把两个模块合在一起做一个打字游戏:屏幕上弹出随机字符,你敲对键盘它就会变色、进度条前进,通关还有庆祝动画,背景音乐也由 FPGA 自己「演奏」。这是对输入解码、VGA 时序、状态机设计的综合大练兵。
实验目的
基于 FPGA 技术实现打字游戏:用 VGA 完成显示、PS2 键盘完成输入,两者联动构成一个完整的人机交互小游戏。
实验原理:整体框图
打字游戏的原理框图如下图所示,主要由四部分组成:PS2 键盘解码模块、打字游戏控制模块、显示模块以及背景音乐模块。

2.1 打字游戏的设计框图
点阵思想与随机字符
打字游戏控制主要基于点阵思想,在程序上通过状态机实现。显示器画面可以看成由列与行坐标构成的点阵,控制某个特定位置就能显示对应内容。
通过外围的模可控计数器产生随机字符,并送到 VGA 显示在屏幕上;再通过状态机让随机字符像弹球一样在屏幕上运动。
键盘比对逻辑
PS2 键盘输入数字,经 PS2 解码电路得到键值数据,将输入数据与随机数产生的数据进行比较:如果两个数据相等,则将 aa 置 1,供内部处理并输出。
判断正确的效果是:爬动的动态阿狸图案会变色,显示器下方的进度条向前增加一格;打错了进度条保持不变。当进度条到达边界(通关)时,会产生动态的蜡笔小新图案表示庆祝。
背景音乐:音调与音长
乐曲由音调和音长组成,只要控制好音调和音长并准确驱动蜂鸣器,就能演奏出动听的乐曲。该模块通过蜂鸣器产生不同音乐,并用数码管实时显示音乐音高,模块框图如下:

图2.2
音调控制:根据简谱音符的音高控制蜂鸣器频率。数字电路中,模 N 计数器溢出信号的频率是计数脉冲频率的 1/N,所以设计一个模可控计数器、改变分频比,就能让溢出信号频率满足不同音高的要求。三个八度音程中相同音名的频率相差一倍,因此分频系数不变时,把计数脉冲频率升高(或降低)一倍,音调就升高(或降低)八度。
音高对应的分频比如下表(分频比 / 预置数):
| 音名 | 分频比 | 预置数 | 音名 | 分频比 | 预置数 | 音名 | 分频比 | 预置数 |
|---|---|---|---|---|---|---|---|---|
| 低音1 | 11468 | 4916 | 中音1 | 5736 | 10647 | 高音1 | 2867 | 13516 |
| 低音2 | 10222 | 6168 | 中音2 | 5111 | 11272 | 高音2 | 2555 | 13829 |
| 低音3 | 9102 | 7281 | 中音3 | 4552 | 11831 | 高音3 | 2276 | 14108 |
| 低音4 | 8592 | 7791 | 中音4 | 4296 | 12087 | 高音4 | 2148 | 11535 |
| 低音5 | 7653 | 8730 | 中音5 | 3827 | 12556 | 高音5 | 1913 | 14470 |
| 低音6 | 6818 | 9565 | 中音6 | 3409 | 12974 | 高音6 | 1704 | 14678 |
| 低音7 | 6073 | 10310 | 中音7 | 3036 | 13346 | 高音7 | 1518 | 14864 |
图 2.3 音高对应分频比
音长控制:某个音符的音长由该音高频率信号的持续时间决定,可用一个计数器对音长定时,计数脉冲周期是乐谱时值,决定了该音符的持续时间。做法是:以乐谱中时值最短的音符作为度量单位,其它音符按与最短音符的倍数关系持续不同的计数脉冲周期。比如乐谱中最短音符为 8 分音符,则 4 分音符为 2 个时钟周期,全音符为 8 个时钟周期。
实验程序:控制模块
控制模块 ball3 的端口包括 25MHz 时钟 clk_25m、RGB 与行场同步输出(r, g, b, hs, vs)、PS2 键盘信号(ps2_clk, ps2_dat)、随机种子 seed、点阵数据 q 与 rgbin、比较结果 aa 等:
module ball3(clk_25m, r, g, b, hs, vs, clk_count, rst, ps2_clk, ps2_dat, seed, q, DOUT, DOUT1, rgbin, aa);
input[5:0] seed;////////
input clk_25m;
input clk_count;
input rst;
input ps2_clk;
input ps2_dat;
input [7:0] q;///////////////
input [199:0] rgbin;
output [9:0] DOUT;//////////
output r, g, b, hs, vs, aa;
reg [9:0] x_cnt;
reg [9:0] y_cnt;
reg [9:0] x_ball;
reg [9:0] y_ball;
reg [2:0] rgb;
reg hs, vs;
reg [2:0] state;
reg flag_x;
reg flag_y;
reg [9:0] x_block;
reg [7:0] ps2_byte_buf;
reg ps2_clk_r0;
reg ps2_clk_r1;
reg [3:0] cnt;
reg[9:0] DOUT;
reg a;
reg aa;
reg bb;
reg[199:0] charbit;
output [10:0] DOUT1;reg [10:0] DOUT1;
reg [5:0] data; /////////
reg[5:0] data1;
reg[4:0] time1; /////////
assign r=rgb[2];
assign g=rgb[1];
assign b=rgb[0];
always @(posedge clk_25m)
if(rst)
begin
if(x_cnt==10'd800) begin x_cnt<=10'd0;end
else x_cnt<=x_cnt+1'b1;
end
always @(posedge clk_25m)
if(rst)
begin
if(x_cnt==800)
begin
if(y_cnt<10'd525) y_cnt<=y_cnt+1'b1;
else y_cnt<=10'd0;
end
end
always @(posedge clk_25m)
if(rst)
begin
if((x_cnt>=640+8+8)&& (x_cnt<640+8+8+96)) hs<=1'b0;
else hs<=1;
end
always @ (posedge clk_25m)
if(rst)
begin
ps2_clk_r0<=ps2_clk;
ps2_clk_r1<=ps2_clk_r0;
endVGA 时序就在这两个计数器里
x_cnt 数到 800 归零、y_cnt 随 x_cnt 归零递增到 525,这就是 640×480@60Hz 的扫描骨架;hs 在 640+8+8 到 640+8+8+96 之间拉低,vs 在 480+10 到 480+12 之间拉低,行场同步脉冲全靠这两个计数器划分区间。理解了这段,后面的所有「画图」都只是在给 (x_cnt, y_cnt) 区域上色。
PS/2 键盘解码
ps2_clk 经过两级寄存器打拍同步后取下降沿 ps2_clk_n,在每个 ps2_clk_n 到来时用 cnt 计数,把 ps2_dat 上的 8 个数据位依次移入 ps2_byte_buf(跳过起始位,取 cnt=1~8 共 8 位):
wire ps2_clk_n=(~ps2_clk_r0)&ps2_clk_r1;
always @(posedge clk_25m)
if(rst)
begin
if(ps2_clk_n)
begin
if(cnt==4'hA) cnt<=0;
else cnt<=cnt+1'b1;
end
end
always @ (posedge clk_25m)
begin
if(ps2_clk_n)
begin
case(cnt)
4'h1:ps2_byte_buf[0]<=ps2_dat;
4'h2:ps2_byte_buf[1]<=ps2_dat;
4'h3:ps2_byte_buf[2]<=ps2_dat;
4'h4:ps2_byte_buf[3]<=ps2_dat;
4'h5:ps2_byte_buf[4]<=ps2_dat;
4'h6:ps2_byte_buf[5]<=ps2_dat;
4'h7:ps2_byte_buf[6]<=ps2_dat;
4'h8:ps2_byte_buf[7]<=ps2_dat;
endcase
end
end
always @(y_cnt)
if(rst)
begin
if((y_cnt>=480+10)&&(y_cnt<480+12)) vs<=1'b0;
else vs<=1'b1;
end
always @ (posedge clk_25m) if(rst)
begin随后用查表法把键盘扫描码翻译成 0~42 的字符序号 data1(原文扫描码表中有个别笔误,如 8’hle 应为 8’h1e,按原样保留):
case(ps2_byte_buf)
8'h1c:data1<=0;
8'h32:data1<=1;
8'h21:data1<=2;
8'h23:data1<=3;
8'h24:data1<=4;
8'h2b:data1<=5;
8'h34:data1<=6;
8'h33:data1<=7;
8'h43:data1<=8;
8'h3b:data1<=9;
8'h42:data1<=10;
8'h4b:data1<=11;
8'h3a:data1<=12;
8'h31:data1<=13;
8'h44:data1<=14;
8'h4d:data1<=15;
8'h15:data1<=16;
8'h2d:data1<=17;
8'h1b:data1<=18;
8'h2c:data1<=19;
8'h3c:data1<=20;
8'h2a:data1<=21;
8'h1d:data1<=22;
8'h22:data1<=23;
8'h35:data1<=24;
8'h1a:data1<=25;
8'h45:data1<=26;
8'h16:data1<=27;
8'hle:data1<=28;
8'h26:data1<=29;
8'h25:data1<=30;
8'h2e:data1<=31;
8'h36:data1<=32;8'h3d:data1<=33;
8'h3e:data1<=34;
8'h46:data1<=35;
8'h4e:data1<=36;
8'h55:data1<=37;
8'h5d:data1<=38;
8'h54:data1<=39;
8'h58:data1<=40;
8'h4c:data1<=41;
8'h4a:data1<=42;
endcase
end
/*always @(data1)
if(data1==data) */进度条与命中判断
aa 置 1 后在 x_cnt==0 时刻更新:data 取新的随机种子 seed,进度条位置 x_block 前进 20,到 400 封顶:
always @ (posedge clk_25m)
if(rst)
begin
if((aa==1)&&(x_cnt==0))begin data<=seed;x_block<=x_block+20;begin
if(x_block==400)
begin x_block<=400;end end end
else begin data<=data;x_block<=x_block;end
end
else
x_block<=0;
always @(posedge clk_25m)
if(rst)
if((x_cnt==0)&&(y_cnt==100))begin timel<=time1+1;if(time1==60)
timel=0;end
always @(posedge clk_25m)
if(rst)
begin
if(((x_cnt>=640)&&(x_cnt<800))||((y_cnt>=480)&&(y_cnt<525))
rgb<=3'b000;
else屏幕区域划分与上色
接下来的大段组合逻辑按 (x_cnt, y_cnt) 把屏幕划分成多个区域:边框、进度条(每格 20 像素、格间留黑缝)、字符显示区(用 rgbin 点阵数据上色,字符颜色随 data%8 变化)、阿狸图案区与弹球区(弹球区域内用 q 点阵画出字符,命中时变色):
if (((x_cnt<10)&&(x_cnt>=0)&&(y_cnt>=0)&&(y_cnt<460)) || ((x_cnt<420)&&(x_cnt>=0)&&(y
_cnt>=0)&&(y_cnt<10)) || ((x_cnt<420)&&(x_cnt>=0)&&(y_cnt>=450)&&(y_cnt<460)) || ((x_c
nt<420)&&(x_cnt>=410)&&(y_cnt>=0)&&(y_cnt<460))) rgb<=3'b110;
else
if((x_cnt>=0)&&(x_cnt<x_block)&&(y_cnt>=460)&&(y_cnt<480)) if((x_cnt%20)<3)
rgb<=3'b000;else rgb<=3'b100;
else if((x_cnt>=430)&&(x_cnt<630)&&(y_cnt>=0)&&(y_cnt<200))
begin
if(x_block<400)
begin
if(time1<=20) DOUT1<=y_cnt;else if(time1<=40) DOUT1<=y_cnt+200;else
DOUT1<=y_cnt+400;
charbit<=629-x_cnt;
if(rgbin[charbit]) begin rgb<=data%8;if(rgb==3'b011) rgb<=010;end
else begin rgb<=3'b011;end
end
else rgb<=3'b011;
end
///gaile
else if((x_cnt>=430)&&(x_cnt<630)&&(y_cnt>=200)&&(y_cnt<250))
begin
if(x_block==400)
rgb<=3'b011;
else
begin
DOUT1<=y_cnt+400;
charbit<=629-x_cnt;
if(rgbin[charbit]) begin rgb<=data%8;if(rgb==3'b011) rgb<=010;end
else begin rgb<=3'b011;end
end
endelse if((x_cnt>=100)&&(x_cnt<300)&&(y_cnt>=250)&&(y_cnt<450))
begin
if(x_block==400)
begin
begin if(time1<=20) DOUT1<=400+y_cnt;else if(time1<=40)
DOUT1<=y_cnt+600;else DOUT1<=y_cnt+800;end
charbit<=299-x_cnt;
if(rgbin[charbit]) rgb<=3'b101;else rgb<=3'b011;
end
else
if((x_cnt>=x_ball)&&(x_cnt<x_ball+16)&&(y_cnt>=y_ball)&&(y_cnt<y_ball+32))
begin
DOUT<=(y_cnt-y_ball)/2+data*16;a<=q[(x_cnt-x_ball)/2%8];
begin
if((a==1)&&(data1!=data)) begin rgb<=3'b000;aa<=0;end
else if(data1==data) begin aa<=1;rgb<=3'b011 ;end
else begin rgb<=3'b011;aa<=0;end
end
end
else rgb<=3'b011;
end
else if((x_cnt>=100)&&(x_cnt<300)&&(y_cnt>=200)&&(y_cnt<250))
begin
if(x_block==400)
begin
DOUT1<=y_cnt+1050;
charbit<=299-x_cnt;
if(rgbin[charbit]) rgb<=3'b100;else rgb<=3'b011;
end
else
if((x_cnt>=x_ball)&&(x_cnt<x_ball+16)&&(y_cnt>=y_ball)&&(y_cnt<y_ball+32))
beginDOUT<=(y_cnt-y_ball)/2+data*16;a<=q[(x_cnt-x_ball)/2%8];
begin
if((a==1)&&(data1!=data)) begin rgb<=3'b000; aa<=0;end
else if(data1==data) begin aa<=1; rgb<=3'b011 ;end
else begin rgb<=3'b011;aa<=0;end
end
end
else rgb<=3'b011;
end
else
if((x_cnt>=x_ball)&&(x_cnt<x_ball+16)&&(y_cnt>=y_ball)&&(y_cnt<y_ball=32))
if(x_block==400) rgb<=3'b011;
else begin
DOUT<=(y_cnt-y_ball)/2+data*16;a<=q[(x_cnt-x_ball)/2%8];
if((a==1)&&(data1!=data)) begin rgb<=3'b000; aa<=0;end
else if(data1==data) begin aa<=1; rgb<=3'b011 ;end
else begin rgb<=3'b011;aa<=0;end
end
else rgb<=3'b011;
end
always @(posedge clk_25m)
if(rst)
begin
if(aa==1);
end弹球运动状态机
弹球有 8 个状态(state 的 3’b000~3’b111),对应「右移上升 / 右移下降 / 左移下降 / 左移上升」等 8 种运动方向组合。每个时钟节拍里 x_ball 步进 1、y_ball 步进 2,撞到上下边界(y_ball10 或 420)或左右边界(x_ball10 或 390)时用 flag_x/flag_y 标记反弹方向:
always @(posedge clk_25m)
begin
if(rst)
begin
case(state)
3'b000: if((y_ball!=10)&&(clk_count)&&(x_ball!=390)) begin
x_ball<=x_ball+1;y_ball<=y_ball-2; flag_x<=1;flag_y<=0; end
else if((y_ball==10)||(x_ball==390)) begin flag_x<=1;flag_y<=0;end3'b001:if((x_ball!=390)&&(clk_count)&&(y_ball!=420)) begin
x_ball<=x_ball+1;y_ball<=y_ball+2; flag_x<=1;flag_y<=1; end
else if((y_ball==420) ||( x_ball==390)) begin flag_x<=1;flag_y<=1;end
3'b010:if((y_ball!=420)&&(clk_count)&&(x_ball!=10)) begin
x_ball<=x_ball-1;y_ball<=y_ball+2; flag_x<=1;flag_y<=0; end
else if((y_ball==420) || (x_ball==10)) begin flag_x<=0;flag_y<=1;end
3'b011:if((y_ball!=10)&&(x_ball!=10)&&(clk_count)) begin
x_ball<=x_ball-1;y_ball<=y_ball-2; flag_x<=0;flag_y<=0; end
else if((y_ball==10) ||( x_ball==10)) begin flag_x<=0;flag_y<=0;end
3'b100:if((y_ball!=420)&&(clk_count)&&(x_ball!=390)) begin
x_ball<=x_ball+1;y_ball<=y_ball+2;flag_x<=1;flag_y<=1; end
else if((y_ball==420) ||( x_ball==390)) begin flag_x<=1;flag_y<=1;end
3'b101:if((y_ball!=10)&&(clk_count)&&(x_ball!=390)) begin
x_ball<=x_ball+1;y_ball<=y_ball-2; flag_x<=1;flag_y<=0; end
else if((y_ball==10) ||(x_ball==390)) begin flag_x<=1;flag_y<=0;end
3'b110:if((y_ball!=10)&&(clk_count)&&(x_ball!=10)) begin
x_ball<=x_ball-1;y_ball<=y_ball-2; flag_x<=0;flag_y<=0; end
else if((y_ball==10) ||( x_ball==10)) begin flag_x<=0;flag_y<=0;end
3'b111:if((y_ball!=420)&&(clk_count)&&(x_ball!=10)) begin
x_ball<=x_ball-1;y_ball<=y_ball+2; flag_x<=0;flag_y<=1; end
else if((y_ball==420) ||(x_ball==10)) begin flag_x<=0;flag_y<=1;end
endcase
end
else
begin
x_ball<=240;
y_ball<=120;
end
end状态切换同样依据 flag_x/flag_y 与当前碰撞边界:
always @ (posedge clk_25m)
if(rst)
begin
if ((flag_x==0)&&(flag_y==0)&&(x_ball==10)) state<=3'b000;
else if((flag_x==1)&&(flag_y==0 )&&(y_ball==10)) state<=3'b001;else if((flag_x==1)&&(flag_y==1)&&(x_ball==390)) state<=3'b010;
else if((flag_x==0)&&(flag_y==1)&&(y_ball==420)) state<=3'b011;
else if((flag_x==0)&&(flag_y==1)&&(x_ball==10)) state<=3'b100;
else if((flag_x==1)&&(flag_y==1)&&(y_ball==420)) state<=3'b101;
else if((flag_x==1)&&(flag_y==0)&&(x_ball==390)) state<=3'b110;
else if((flag_x==0)&&(flag_y==0)&&(y_ball==10)) state<=3'b111;
else state<=state;
end
else state<=3'b011;
endmodule弹球为什么用 1:2 的步进
x 方向每拍走 1 个像素、y 方向每拍走 2 个像素,且运动节拍受 clk_count 门控(每若干帧走一步),这样弹球速度才适合人眼观察。另外注意状态机里 y 方向在「上升」时是减、在「下降」时是加,屏幕坐标系 y 轴向下,别和数学坐标系搞混。
实验仿真
根据描述语言进行仿真测试,分析波形。
管脚约束
set_pin_assignment { b } { LOCATION = E13; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { clk } { LOCATION = R7; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { g } { LOCATION = N6; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { hs } { LOCATION = E7; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { ps2_clk } { LOCATION = M16; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { ps2_dat } { LOCATION = K16; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { r } { LOCATION = D5; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }
set_pin_assignment { rst } { LOCATION = A2; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { spk_kk } { LOCATION = H11; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { vs } { LOCATION = F7; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8;
PULLTYPE = NONE; }ps2_clk、ps2_dat 都配了上拉,这是 PS/2 接口开漏特性的要求;r/g/b、hs/vs 是 VGA 的六根模拟接口线。
下载测试
- 将 PS/2 键盘连接扩展模块 PS/2 接口,VGA 连接开发板 VGA 接口;
- 将连接线连接至学习板的 USB 下载端口,并连接计算机的 USB 接口后,给板上电,进行程序下载;
- 程序下载完毕后 KEY1 复位,显示屏跳出字符,根据字符敲打键盘对应字符,看屏幕出现什么效果:打对时阿狸图案变色、下方进度条前进一格,通关后出现蜡笔小新庆祝动画。

KEY1复位 BUZ/SW拨上蜂鸣器工作


现象与总结
这个实验把三个「零件」拧成了一台机器:PS/2 解码提供输入,VGA 扫描提供画布,弹球状态机提供游戏逻辑。最重要的设计思想有两点——一是一切显示都是坐标区间判断,只要会划分 (x_cnt, y_cnt) 区域就能画边框、进度条、点阵图案;二是用状态机描述运动,把「方向 + 反弹」编码成 8 个状态,让随机字符像弹球一样动起来。这两招组合起来,你就可以自己设计任何简单的屏上小游戏了。
自测
自测
- 打字游戏整体分为哪几个模块?各模块的职责是什么?
- PS/2 键盘的扫描码是如何被解码成字符序号的?解码时为什么要在 25MHz 时钟域对 ps2_clk 打两拍?
- 程序中 hs 与 vs 分别在什么计数区间被拉低?对应 640×480@60Hz 时序的哪一段?
- 打对字符后屏幕上会发生哪两件事?x_block 计数到多少表示通关?
- 模可控计数器如何控制音调?为什么「分频系数不变、计数脉冲频率翻倍」音调就升高八度?