代码来源:https://www.fpga4fun.com/BreakoutGame.html
项目结构
从网站上下载下来的代码很简洁,只有四个文件:
.├── breakout_playfield.v├── breakout_videogen.v├── breakout.ucf└── breakout.v
1 directory, 4 files
其中:
- breakout.v: 硬件接口层(VGA、LED、音频)
- breakout_videogen.v: 显示控制层(时序、渲染)
- breakout_playfield.v: 游戏逻辑层(碰撞、状态)
而 breakout.ucf
则是约束文件,它负责引脚映射。
breakout.v
从 breakout.v
开始。
// (c) KNJN LLC 2024 for fpga4fun.com
////////////////////////////////////////////////////////////////////////module breakout( input clk, output VGA_HS, VGA_VS, VGA_R, VGA_G, VGA_B, output [1:0] LED, output audioR, audioL);
wire [9:0] PaddleX; // paddle position
// if you have a way to control the paddle, make sure to update PaddleX hereassign PaddleX = 10'd900; // othersise this line puts the paddle off-screen to run the game in demo mode
wire DrawArea, hSync, vSync, red, green, blue, Collision, BrickHit;breakout_videogen myVideoGen( .clk(clk), .PaddleX(PaddleX), .DrawArea(DrawArea), .hSync(hSync), .vSync(vSync), .red(red), .green(green), .blue(blue), .Collision(Collision), .BrickHit(BrickHit));
assign VGA_R = DrawArea & red;assign VGA_G = DrawArea & green;assign VGA_B = DrawArea & blue;assign VGA_HS = ~hSync;assign VGA_VS = ~vSync;assign LED = {BrickHit, Collision & ~BrickHit};
reg [15:0] audio; always @(posedge clk) audio <= audio + Collision + BrickHit;assign audioR = audio[15];assign audioL = audio[15];endmodule
开头先是连接 VGA 输出信号(RGB、同步信号),然后处理球拍位置。虽然但是,这个球拍在这个程序里是一个写死的常量,如果有控制球拍的逻辑还要另外跟它信号连上。
然后和 breakout_videogen.v
有关的代码,和传视频信号有关的,放到后面再看。
breakout_videogen.v
// (c) KNJN LLC 2024 for fpga4fun.com
////////////////////////////////////////////////////////////////////////module breakout_videogen( input clk, input [9:0] PaddleX, output reg DrawArea, hSync, vSync, output red, green, blue, output reg Collision, BrickHit);
//localparam ballspeed = 2; // ball moves 4 pixels per framelocalparam ballspeed = 3; // ball moves 8 pixels per framereg [9:0] ballX = 100; // initial ball positionreg [8:0] ballY = 300;reg ball_dirX, ball_dirY;
////////////////////////////////////////////////////////////////////////parameter hDrawArea = 640;parameter hSyncPorch = 16;parameter hSyncLen = 96;parameter hFrameSize = 800;
parameter vDrawArea = 480;parameter vSyncPorch = 10;parameter vSyncLen = 2;parameter vFrameSize = 525;
reg [9:0] CounterX;reg [8:0] CounterY;always @(posedge clk) CounterX <= (CounterX==hFrameSize-1) ? 10'd0 : CounterX+10'd1;always @(posedge clk) if(CounterX==hFrameSize-1) CounterY <= (CounterY==vFrameSize-1) ? 9'd0 : CounterY+9'd1;always @(posedge clk) DrawArea <= (CounterX<hDrawArea) & (CounterY<vDrawArea);always @(posedge clk) hSync <= (CounterX>=hDrawArea+hSyncPorch) & (CounterX<hDrawArea+hSyncPorch+hSyncLen);always @(posedge clk) vSync <= (CounterY>=vDrawArea+vSyncPorch) & (CounterY<vDrawArea+vSyncPorch+vSyncLen);
////////////////////////////////////////////////////////////////////////wire DrawBall, DrawBorder, DrawPaddle, DrawBrick, BrickHit_now, BrickHit_acq;reg RestoreBrickwall = 1'b1;reg MoveBall;breakout_playfield #(hDrawArea, vDrawArea) game( .clk(clk), .PaddleX(PaddleX), .CounterX(MoveBall ? ballX + {6'h00, {4{CounterX[0]}}} : CounterX), .CounterY(MoveBall ? ballY + {5'h00, {4{CounterX[1]}}} : CounterY), .ballX(ballX), .ballY(ballY), .DrawBall(DrawBall), .DrawBorder(DrawBorder), .DrawPaddle(DrawPaddle), .DrawBrick(DrawBrick), .BrickHit_now(BrickHit_now), .BrickHit_acq(BrickHit_acq), .RestoreBrickwall(RestoreBrickwall));
// we are going to update the ball position during offscreen timingwire FrameTick = (CounterX==hFrameSize-1) & (CounterY==vDrawArea-1);always @(posedge clk) MoveBall <= MoveBall ? ~&CounterX[ballspeed+2:0] : FrameTick;wire BounceableOject = DrawBorder | DrawPaddle | DrawBrick;reg [3:0] HBC; always @(posedge clk) HBC <= {BounceableOject, HBC[3:1]}; // record the ball corners hits in HBC (HotBallCorner)wire [15:0] updateDirX = 16'b01101101_10110110; // and update the ball direction if neededwire [15:0] updateDirY = 16'b01111001_10011110;always @(posedge clk) if(MoveBall & CounterX[2:0]==3'h5 & updateDirX[HBC]) ball_dirX <= (~HBC[0] & HBC[1]) | (~HBC[2] & HBC[3]);always @(posedge clk) if(MoveBall & CounterX[2:0]==3'h5 & updateDirY[HBC]) ball_dirY <= (~HBC[0] & ~HBC[1]) | ( HBC[2] & HBC[3]);always @(posedge clk) if(MoveBall & CounterX[2:0]==3'h7) ballX <= ballX + {{9{ball_dirX}}, 1'b1}; // and then the ball positionalways @(posedge clk) if(MoveBall & CounterX[2:0]==3'h7) ballY <= ballY + {{8{ball_dirY}}, 1'b1};
// then get stats on ball collisions and brick hitsreg [2:0] BHA; always @(posedge clk) BHA <= {DrawBrick, BHA[2:1]};assign BrickHit_now = MoveBall & CounterX[2] & BHA[0];always @(posedge clk) if(FrameTick) BrickHit<=1'b0; else if(BrickHit_now) BrickHit<=1'b1;reg [7:0] BrickHit_count=0; always @(posedge clk) BrickHit_count <= RestoreBrickwall ? 8'h00 : BrickHit_count + BrickHit_acq;always @(posedge clk) RestoreBrickwall <= RestoreBrickwall ? ~FrameTick : (BrickHit_count==19*7) & ballY[8];always @(posedge clk) if(FrameTick) Collision<=1'b0; else if(MoveBall & CounterX[2] & HBC[1]) Collision<=1'b1;
wire DrawAll = DrawBall | DrawBorder | DrawPaddle | DrawBrick;assign red = DrawBrick;assign green = DrawAll;assign blue = DrawAll;endmodule
查了一下,breakout_videogen.v
实现标准 VGA 640x480@60Hz 时序,即以下规范:
- 水平时序:
- 可见区域:640 像素
- 前肩(Front Porch):16 像素
- 同步脉冲:96 像素
- 后肩(Back Porch):48 像素
- 总计:800 像素
- 垂直时序:
- 可见区域:480 行
- 前肩:10 行
- 同步脉冲:2 行
- 后肩:33 行
- 总计:525 行
因为老 CRT 显示器是用电子束从左到右、从上到下逐行扫描的,扫描完一行需要时间回到下一行开始需要返回时间,所以需要同步脉冲来调整时序。好吧这个不重要。
接着通过两个计数器生成像素坐标:
- CounterX: 水平像素计数(0-799),对应寄存器长度是 ,大于 ;
- CounterY: 垂直行计数(0-524),对应寄存器长度是 ,实际上不够存,不知道这里为什么这么设计;
- DrawArea: 标识当前像素是否在可显示区域内。
总计 个时钟周期完成一帧。
球的运动用 X 和 Y 两个分解方向表示,运动状态机由 MoveBall 控制。MoveBall 的逻辑是:当为真时检查~&CounterX[ballspeed+2:0]
(不全为 1 时继续),当为假时等待 FrameTick 重新启动,这里 +2
是控制球移动频率。也就是说:
- 当 MoveBall=0 时,等待 FrameTick(帧结束)信号来启动移动
- 当 MoveBall=1 时,检查
~&CounterX[ballspeed+2:0]
,当 CounterX 的低 (ballspeed+3) 位不全为 1 时继续移动。ballspeed=3 时,检查CounterX[5:0]
,意味着每 64 个时钟周期球移动一次。
碰撞检测通过 HBC(Hot Ball Corner)机制实现,HBC <= {BounceableOject, HBC[3:1]}
进行右移操作,记录连续 4 个时钟周期的碰撞历史状态。系统使用两个 16 位查找表updateDirX
和updateDirY
,根据 HBC 的 4 位值索引决定是否更新球的方向,在时钟周期 5 更新方向,时钟周期 7 更新位置,避免了复杂的碰撞计算。
砖块碰撞通过 BHA 记录砖块碰撞历史,BrickHit_now
检测当前击中状态,BrickHit
作为帧级标志在每帧开始清零、有碰撞时置 1。BrickHit_count
计数器跟踪总击中砖块数(19×7=133 块),当全部击中且球到达顶部时RestoreBrickwall
重置砖墙。Collision
标志类似地在每帧清零,球移动时检测到碰撞则置 1。
breakout_playfield.v
// (c) KNJN LLC 2024 for fpga4fun.com
/////////////////////////////////////////////////////////////////module breakout_playfield( input clk, input [9:0] CounterX, ballX, PaddleX, input [8:0] CounterY, ballY,
output reg DrawBall, DrawBorder, DrawPaddle, output DrawBrick,
input BrickHit_now, RestoreBrickwall, output BrickHit_acq);
parameter hDrawArea = 640;parameter vDrawArea = 480;
always @(posedge clk) DrawBall <= (CounterX>=ballX) && (CounterX<ballX+10'd16) && (CounterY>=ballY) && (CounterY<ballY+9'd16);always @(posedge clk) DrawBorder <= (CounterX[9:2]==0) || (CounterX[9:2]==hDrawArea/4-1) || (CounterY[8:2]==0) || (CounterY[8:2]==vDrawArea/4-1);always @(posedge clk) DrawPaddle <= (CounterX>=PaddleX) && (CounterX<=PaddleX+10'd64) && (CounterY>=vDrawArea-9'd46) && (CounterY<vDrawArea-9'd30);
// the brickwall starts at coordinates (16,48)wire [9:0] BrickXo = CounterX-10'd16; wire [4:0] BrickX_H = BrickXo[9:5]; wire [4:0] BrickX_L = BrickXo[4:0];wire [8:0] BrickYo = CounterY- 9'd48; wire [4:0] BrickY_H = BrickYo[8:4]; wire [3:0] BrickY_L = BrickYo[3:0];wire [9:0] BrickA = {BrickY_H, BrickX_H}; // and is organized as a 32 x 32 matrixreg [1023:0] RAMbrickwall; // in this blockram
reg BrickPresent, BrickHit_nowR;always @(posedge clk)begin if(BrickHit_now | RestoreBrickwall) RAMbrickwall[BrickA] <= RestoreBrickwall ? BrickX_H<19 & BrickY_H<7 : 1'b0; // 19 x 7 brickwall BrickPresent <= RAMbrickwall[BrickA]; BrickHit_nowR <= BrickHit_now;endassign BrickHit_acq = BrickPresent & BrickHit_nowR;
reg BrickBody; always @(posedge clk) BrickBody <= |BrickY_L[3:1] && |BrickX_L[4:1]; // leave two pixels between each brickassign DrawBrick = BrickPresent & BrickBody;endmodule
这个模块负责游戏场景中各个对象的绘制判断。球的绘制区域是 16×16 像素,通过比较当前扫描位置与球坐标确定;边框绘制使用位移操作CounterX[9:2]
和CounterY[8:2]
,实际上是将坐标除以 4,在 640×480 显示区域的边缘绘制 4 像素宽的边框;挡板绘制在屏幕底部,宽度 64 像素,位置在距离底部 30-46 像素的区域,跟随 PaddleX 水平移动。所有这些绘制判断都通过比较当前扫描坐标与对象坐标范围来实现。
砖墙从坐标 (16,48) 开始,通过坐标变换BrickXo
和BrickYo
计算相对位置,然后分别提取高位和低位部分。BrickX_H
和BrickY_H
作为 32×32 矩阵的索引,组成 10 位地址BrickA
访问 1024 位的块 RAMRAMbrickwall
。砖块的实际排列是 19×7 的矩阵,在RestoreBrickwall
信号时恢复,在BrickHit_now
信号时清除对应位置的砖块。BrickBody
通过检查坐标的低位部分BrickY_L[3:1]
和BrickX_L[4:1]
是否非零来在砖块间留出 2 像素的间隙,最终的DrawBrick
信号需要同时满足砖块存在和在砖块主体区域的条件。BrickHit_acq
信号在砖块存在且检测到击中时产生,用于游戏逻辑的砖块计数。