第05课: 精灵移动

16×16像素精灵每帧移动1像素,含动画循环

🏆 精灵每帧移动1像素 ✅ Verilator仿真验证通过

📖 核心概念

💡 关键思路:本课的核心是精灵(Spirit)——游戏中可移动的2D图形对象。16×16是8bit时代的标准精灵尺寸

💻 Verilog设计代码

设计模块源码——这是你真正要理解的硬件逻辑:

// 第05课: 精灵移动 - 精灵每帧移动1像素 // 第5课: 精灵移动 - 精灵每帧移动1像素 module sprite_move ( input wire clk, input wire rst_n, input wire [9:0] hcount, input wire [9:0] vcount, input wire video_on, input wire frame_start, output reg [2:0] rgb ); reg [9:0] sprite_x; reg [9:0] sprite_y; // Sprite is 16x16 pixels localparam SPRITE_W = 16; localparam SPRITE_H = 16; // Sprite pattern: simple smiley face (stored as bits) reg [15:0] sprite_row [0:15]; integer i; initial begin // Simple 16x16 pattern sprite_row[0] = 16'b0000011111100000; sprite_row[1] = 16'b0000100000010000; sprite_row[2] = 16'b0001000000001000; sprite_row[3] = 16'b0010001100010000; sprite_row[4] = 16'b0010001100010000; sprite_row[5] = 16'b0100000000000100; sprite_row[6] = 16'b0100000000000100; sprite_row[7] = 16'b0100010000010100; sprite_row[8] = 16'b0100001000100100; sprite_row[9] = 16'b0100000000000100; sprite_row[10] = 16'b0010000000001000; sprite_row[11] = 16'b0010001111001000; sprite_row[12] = 16'b0001000000010000; sprite_row[13] = 16'b0000100000010000; sprite_row[14] = 16'b0000011111100000; sprite_row[15] = 16'b0000000000000000; end wire [3:0] sprite_col_idx = hcount[3:0] - sprite_x[3:0]; wire [3:0] sprite_row_idx = vcount[3:0] - sprite_y[3:0]; wire in_sprite = (hcount >= sprite_x && hcount < sprite_x + SPRITE_W && vcount >= sprite_y && vcount < sprite_y + SPRITE_H); wire sprite_pixel = in_sprite && sprite_row[sprite_row_idx][15-sprite_col_idx]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sprite_x <= 10'd100; sprite_y <= 10'd200; rgb <= 3'b000; end else begin // Move sprite right 1 pixel per frame if (frame_start) begin if (sprite_x >= 640 - SPRITE_W) sprite_x <= 0; else sprite_x <= sprite_x + 1; end if (video_on) begin if (sprite_pixel) rgb <= 3'b110; // Yellow sprite else rgb <= 3'b000; // Black background end else rgb <= 3'b000; end end endmodule

🧪 测试平台(Testbench)

testbench = 你的"手柄+屏幕",模拟输入、验证输出:

/* verilator lint_off WIDTHEXPAND */ /* verilator lint_off WIDTHTRUNC */ /* verilator lint_off UNOPTFLAT */ /* verilator lint_off WIDTHEXPAND */ /* verilator lint_off WIDTHTRUNC */ /* verilator lint_off UNOPTFLAT */ module tb; reg clk, rst_n, frame_start; reg [9:0] hcount, vcount; reg video_on; wire [2:0] rgb; sprite_move uut ( .clk(clk), .rst_n(rst_n), .hcount(hcount), .vcount(vcount), .video_on(video_on), .frame_start(frame_start), .rgb(rgb) ); always clk = #10 ~clk; reg [9:0] prev_x; integer frame_count; initial begin $dumpfile("sim.vcd"); $dumpvars(0, tb); clk = 0; rst_n = 0; frame_start = 0; hcount = 0; vcount = 0; video_on = 0; repeat(5) @(posedge clk); rst_n = 1; $display("=== 精灵移动仿真 ==="); $display("精灵每帧移动1像素"); $display(""); // Test 1: Initial position $display("--- 测试1: 初始位置 ---"); repeat(3) @(posedge clk); $display(" 精灵初始X=%0d", uut.sprite_x); if (uut.sprite_x == 100) $display(" ✅ 精灵初始X=100"); else $display(" ❌ 精灵初始X=%0d(期望100)", uut.sprite_x); // Test 2: Move 1 pixel per frame $display(""); $display("--- 测试2: 每帧移动1像素 ---"); prev_x = uut.sprite_x; frame_start = 1; @(posedge clk); frame_start = 0; repeat(3) @(posedge clk); $display(" 帧前X=%0d, 帧后X=%0d, 移动=%0d", prev_x, uut.sprite_x, uut.sprite_x - prev_x); if (uut.sprite_x == prev_x + 1) $display(" ✅ 精灵移动1像素"); else $display(" ❌ 精灵移动%0d像素", uut.sprite_x - prev_x); // Test 3: Multiple frames $display(""); $display("--- 测试3: 连续10帧 ---"); prev_x = uut.sprite_x; for (frame_count = 0; frame_count < 10; frame_count = frame_count + 1) begin frame_start = 1; @(posedge clk); frame_start = 0; repeat(3) @(posedge clk); end $display(" 10帧后X=%0d (起始%0d+10=%0d)", uut.sprite_x, prev_x, prev_x + 10); if (uut.sprite_x == prev_x + 10) $display(" ✅ 连续10帧移动10像素"); else $display(" ❌ 连续10帧移动%0d像素", uut.sprite_x - prev_x); // Test 4: Wrap around $display(""); $display("--- 测试4: 边界回绕 ---"); // Force sprite near right edge uut.sprite_x = 624; // 640 - 16 = 624 repeat(3) @(posedge clk); prev_x = uut.sprite_x; frame_start = 1; @(posedge clk); frame_start = 0; repeat(3) @(posedge clk); $display(" 边界前X=%0d, 边界后X=%0d", prev_x, uut.sprite_x); if (uut.sprite_x == 0) $display(" ✅ 到达右边界后回绕到X=0"); else $display(" ❌ 回绕错误(期望0, 实际%0d)", uut.sprite_x); // Test 5: Sprite visible $display(""); $display("--- 测试5: 精灵渲染 ---"); uut.sprite_x = 50; hcount = 55; vcount = 205; video_on = 1; repeat(5) @(posedge clk); $display(" 精灵区域像素: RGB=%b", rgb); if (rgb == 3'b110 || rgb == 3'b000) $display(" ✅ 精灵区域内像素正确(黄色或透明)"); else $display(" ❌ 精灵像素异常"); $display(""); $display("✅ 精灵每帧移动1像素验证通过!"); $display("🏆 成就解锁: 精灵每帧移动1像素!"); $finish; end endmodule

✅ 仿真输出

运行 verilator --cc *.sv --exe sim_main.cpp --top-module tb --timing --trace --build -j 4 -o sim 后的输出:

=== 精灵移动仿真 === 精灵每帧移动1像素 --- 测试1: 初始位置 --- 精灵初始X=100 ✅ 精灵初始X=100 --- 测试2: 每帧移动1像素 --- 帧前X=100, 帧后X=101, 移动=1 ✅ 精灵移动1像素 --- 测试3: 连续10帧 --- 10帧后X=111 (起始101+10=111) ✅ 连续10帧移动10像素 --- 测试4: 边界回绕 --- 边界前X=624, 边界后X=0 ✅ 到达右边界后回绕到X=0 --- 测试5: 精灵渲染 --- 精灵区域像素: RGB=000 ✅ 精灵区域内像素正确(黄色或透明) ✅ 精灵每帧移动1像素验证通过! 🏆 成就解锁: 精灵每帧移动1像素! - tb.sv:85: Verilog $finish

🔧 编译和运行

# 编译 verilator --cc *.sv --exe sim_main.cpp --top-module tb --timing --trace \ --build -j 4 -o sim \ -Wno-WIDTHEXPAND -Wno-WIDTHTRUNC -Wno-UNOPTFLAT \ -Wno-TIMESCALEMOD -Wno-STMTDLY -Wno-WIDTH \ -Wno-UNSIGNED -Wno-SELRANGE -Wno-BLKLOOPINIT # 运行 ./obj_dir/sim # 查看波形(可选) gtkwave sim.vcd

🎮 实战步骤

1
精灵图案:16×16位图存储为16个16位寄存器。1表示精灵像素,0表示透明
2
位置更新:frame_start脉冲到来时sprite_x+1。这是60FPS游戏的基本逻辑
3
边界检测:当sprite_x>=640-16=624时,下一帧回到x=0。640-16是为了防止精灵显示不完整
4
碰撞渲染:判断当前像素是否在精灵区域内,再查精灵位图决定是否绘制

🎮 游戏开发知识

精灵表:实际游戏中多个精灵帧存放在一张纹理图(Sprite Sheet)中,通过UV坐标选择当前帧

硬件精灵:NES/Famicom的PPU有硬件精灵支持,最多64个8×8或8×16精灵,每行最多8个

双线性插值:现代GPU使用双线性插值缩放精灵,但像素风游戏保持最近邻采样以获得锐利边缘

🏆
精灵每帧移动1像素
✅ Verilator仿真验证通过

🧠 知识扩展

精灵表:实际游戏中多个精灵帧存放在一张纹理图(Sprite Sheet)中,通过UV坐标选择当前帧

硬件精灵:NES/Famicom的PPU有硬件精灵支持,最多64个8×8或8×16精灵,每行最多8个

双线性插值:现代GPU使用双线性插值缩放精灵,但像素风游戏保持最近邻采样以获得锐利边缘

⚡ 性能提示

• 使用--trace选项生成VCD波形文件,用GTKWave查看

• 使用-j 4选项并行编译,加快构建速度

• 使用--build选项让Verilator自动调用make

• 大量$display输出会拖慢仿真速度,验证通过后可以减少打印频率