Skip to content
正常

Tetris 成品导览

sourceapp/08-games/tetris/ related:app 栏游戏类整机成品

Tetris 是 app 栏「游戏」这一类的整机成品。前面 widget 栏讲究单控件、sqlite-browser 讲究「把模块织成一台能用的机器」;这件换一条线——不靠任何控件框架,纯 QPainter 自绘出一个能玩的俄罗斯方块。它的价值不在某个算法多巧,而在把游戏循环里最容易翻车的几个雷——形态表写错位、旋转撞墙卡死、消行行移污染、副面板和棋盘的状态同步、键盘焦点被主窗口抢走——都防住了,而且把「自绘 + QTimer + 键盘事件 + QPainter」这一套 Widgets 游戏骨架走通。

本篇是「成品导览」

想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 想自己从零搓出来 → 转 手搓手册

1. 它做什么

一个能玩的俄罗斯方块:

  • 下落 + 旋转 + 平移:方向键 ←→ 移、↑ 旋转、↓ 软降、Space 硬降到底,P 暂停,R 重开
  • 7 种经典方块:I/O/T/S/Z/J/L,每种预排 4 个旋转态,颜色按经典约定(青/黄/紫/绿/红/蓝/橙)
  • 碰撞 + 壁踢:旋转后撞墙/撞底时自动尝试左/右/上踢,全部失败才回滚不转(简化版,非 SRS)
  • 消行 + 计分 + 等级加速:1/2/3/4 行 = 100/300/500/800 分(乘当前等级),每 10 行升 1 级、下落变快(每级减 60ms,下限 80ms)
  • 投影:当前方块在棋盘上的落点画半透明虚线投影,方便瞄准
  • 副面板:右侧 Next 预览(下一块形态)+ SCORE/LEVEL/LINES/BEST + 操作说明
  • 最高分持久化:用 QSettings 把历史最高分存进注册表/ini,重开程序还在

跑起来看一眼:

bash
cmake -B build -S app && cmake --build build
./build/08-games/tetris/demo/tetris_demo

2. 架构总览

类关系

整机三个类:TetrisBoard(自绘棋盘 QWidget,游戏逻辑全在这)+ NextPreview(Next 副面板自绘 QWidget,复用 TetrisBoard 的形态表画小方格组)+ TetrisWindow(QMainWindow,只管装配、键盘兜底、副面板刷新、最高分持久化)。棋盘状态变化发 statsChanged() 让窗口刷副面板,gameOver() 让窗口落最高分。所有游戏逻辑(出生/旋转/碰撞/锁定/消行/计分/等级)都封装在 TetrisBoard,窗口不碰棋盘数据。

文件职责

文件职责
demo/tetris_board.h棋盘接口:常量(10×20 棋盘、4×4 包络)+ PieceShape 公开(供 NextPreview 复用)+ 游戏逻辑私有方法;头注释讲清 4×4 包络 + 简化壁踢 + board 存 type+1 三条关键设计
demo/tetris_board.cpp棋盘实现:7 形态方块预排 4 旋转态(字符串字面量可读化)、QTimer 下落、碰撞/旋转壁踢/锁定、消行行移(自底向上)、自绘渲染(网格+方块+投影+遮罩)、立体格绘制(高光左上阴影右下)
demo/tetris_window.h主窗口 + NextPreview 接口:装配骨架、键盘兜底、QSettings 最高分键
demo/tetris_window.cpp主窗口实现:QHBoxLayout 装棋盘+副面板、菜单 Game/Help、statsChangedrefreshSidePanel 刷面板 / gameOver+closeEvent→落最高分、NextPreview 居中画下一块
demo/main.cpp入口:设 applicationName/organizationName(QSettings 要用)+ 主窗口 show
demo/CMakeLists.txt工程配置——qt_add_executable + 链 Qt6::Widgets(游戏自绘不用 Sql/Network)

一个方块从「下落」到「锁定 + 消行」怎么走

重点:锁定后必先消行结算、再出新块spawnNext 内部检测「出生位即碰撞」判定堆顶 game over(停钟 + 发 gameOver())。这条「锁定 → 消行 → 结算 → 出新块 → 检测堆顶」是整份代码的游戏循环命脉。

3. 关键设计决策

① 7 种方块的 4 个旋转态全部预排成静态表,不在运行时转置。 运行时算旋转(矩阵转置 + 行反转)极易写错翻转方向,I 型这种满宽方块尤其坑。这里把每种方块 4 个旋转态用「####/..#. 这样的字符串字面量」逐态手填成 4×4 矩阵(mk lambda 翻成 int),可读性远胜手填 0/1,且旋转态静态化后方向永远对。代价是表有点长,但永不旋转 bug。(tetris_board.cpp:20-92)

② 方块用统一 4×4 包络,旋转在包络内进行;撞墙靠「壁踢序列」而非 SRS。 SRS(Super Rotation System)的踢墙表复杂且分方块。这里用简化版:旋转后若碰撞,依次试「原位 → 左1 → 右1 → 左2 → 右2 → 上1」六个位置,任一不撞就采用,全失败回滚不转。实测足够防 99% 的卡墙场景,代码量是 SRS 的零头。(tetris_board.cpp:184-202)

board_ 存「方块类型 + 1」,让「空(0)」与「I 型(0 型)」可区分。 若直接存 type(0..6),board_[r][c] == 0 就有歧义——既可能是空格,也可能是 I 型(type=0)的格子。这里锁定时存 type + 1(1..7),0 专属「空」,paintEvent 取色时 shapeOf(type_plus1 - 1).color 还原。一个 +1 消掉一类边界 bug。(tetris_board.cpp:256paintEvent 取色 tetris_board.cpp:429)

④ 消行行移「自底向上」处理,同位置连锁满行用 ++r 抵消 --r 扫满行必须从下往上(r 从 19 递减到 0)——否则某行下移后会把尚未检查的行覆盖掉。更深一层:一行被上方填满的行下移后,它自己可能又满了(连锁),所以处理完一行后要 ++r 抵消 for 循环的 --r,让下一轮重检同一行。漏掉这步会导致连锁消行少消。(tetris_board.cpp:283-308)

⑤ 焦点交给棋盘 Qt::StrongFocus,方向键走棋盘 keyPressEvent,R/P 交 Game 菜单 QActionNextPreview 复用 TetrisBoard 的形态表,杜绝形态定义漂移。 子控件默认不抢键盘焦点,棋盘不设 StrongFocus 就收不到方向键。方向键走棋盘自己的 keyPressEventKey_R/Key_P 由 Game 菜单的 QActionWindowShortcut)统一处理,窗口 keyPressEvent 不再重复绑定(避免三处重复绑定单键双触发/漏触发),棋盘 keyPressEvent 保留 Key_P 供 offscreen/自动化直投走。Next 副面板不重复定义形态——直接调 TetrisBoard::shapeOf(type) 取矩阵和颜色,棋盘改形态表副面板自动同步。(tetris_board.cpp:99/401-403tetris_window.cpp:256-260tetris_window.cpp:53/73)

4. 怎么读这份 code

按这个顺序读,最快建立心智:

  1. demo/tetris_board.h 头注释 + 常量——先看棋盘多大(10×20)、方块多大(4×4)、PieceShape 结构、4 条关键设计
  2. pieceShapestetris_board.cpp:20)——7 种方块怎么用字符串字面量定义 4 旋转态、颜色怎么配
  3. collidestetris_board.cpp:160)——碰撞判定:边界 + 已堆格子,行允许负(出生缓冲)
  4. rotateCurrenttetris_board.cpp:184)——壁踢序列怎么试、全失败怎么回滚
  5. lockPiece + clearFullLinestetris_board.cpp:219/283)——锁定存 type+1、消行自底向上 + 连锁 ++r、计分与升级
  6. step + applyLevelSpeedtetris_board.cpp:331/340)——QTimer tick 下落到底锁定、等级 → interval(下限 80ms)
  7. paintEventtetris_board.cpp:413)——自绘顺序:已堆方块 → 网格 → 边框 → 投影 → 当前方块 → 暂停/结束遮罩;投影怎么算(按整块行偏移一路下落到撞底)
  8. drawBlock/drawGhosttetris_board.cpp:502/521)——立体感(高光左上、阴影右下)+ 投影半透明虚线
  9. tetris_window.cpp::setupCentraltetris_window.cpp:115)——QHBoxLayout 棋盘 + 副面板,副面板 make_row lambda 造 SCORE/LEVEL/LINES/BEST
  10. NextPreview::paintEventtetris_window.cpp:46)——怎么复用形态表、怎么算包围盒居中(避免方块在框里跳)
  11. keyPressEventtetris_window.cpp:256)——主窗口只兜底 R/P,其余交棋盘
  12. loadHighScore/saveHighScoretetris_window.cpp:234/239)——QSettings 持久化、为什么要在 board 构造后回填 high_score_

入口:demo/main.cppTetrisWindow 跑起来,对照读。

5. 踩坑

#现象原因后果解法
主窗口装配后,棋盘收不到方向键子控件默认不抢键盘焦点,setFocusPolicy 没设或设成 NoFocus游戏完全没法玩、按键无响应构造时 setFocusPolicy(Qt::StrongFocus);窗口装配后 board_->setFocus() 确保焦点在棋盘(tetris_board.cpp:99tetris_window.cpp:190
旋转后方块翻转方向错乱 / I 型旋转扭曲运行时算转置(矩阵转置 + 行反转)方向写反,或包络尺寸不统一方块形态对不上、游戏体验崩4 旋转态全部预排静态表(字符串字面量),不在运行时算(tetris_board.cpp:20-92
方块贴墙旋转直接失败(卡墙)旋转后撞墙没做壁踢,硬性回滚I 型贴墙转不过来、玩家被墙逼死壁踢序列试 6 个偏移(原位→左1→右1→左2→右2→上1),任一不撞采用,全失败才回滚(tetris_board.cpp:184-202
board_[r][c] == 0 把 I 型格子误判成空格board 直接存 type,I 型 type=0 与「空格 0」撞值I 型方块锁定后「消失」、消行逻辑错乱锁定存 type + 1(1..7),0 专属空格,取色 type_plus1 - 1tetris_board.cpp:256paintEvent 取色 429
连锁消行只消了一部分 / 多消了clearFullLines 自顶向下扫(下移污染未检行);或满行下移后没重检同位置消行数不对、计分错乱、棋盘残留自底向上扫(r 递减);处理一行后 ++r 抵消 --r 重检同一位置(连锁)(tetris_board.cpp:287-305
暂停后计时器没停,方块还在下落 / 恢复后速度乱了setPaused 只改 paused_ 标志没动 timer,或 step 没查标志暂停形同虚设、不公平setPausedpaused_ + timer_->stop()/start()step 入口查 paused_/game_over_ 双保险(tetris_board.cpp:331-334356-367
高级等级下方块下落快到不可玩interval 没下限,高等级 interval 趋近 0游戏后半段无法操作、体验崩applyLevelSpeedstd::max(80, 700 - (level-1)*60) 兜底 80ms(tetris_board.cpp:340-344
重开程序后最高分丢了 / 显示为 0QSettings 没 set applicationName/organizationName,或 board 构造时 restart 把 high_score_ 清零后没回填持久化失效、玩家分数白打main 设 applicationName/orgName;窗口构造后 board_->setHighScore(loadHighScore()) 回填;最高分落盘推迟到 gameOver + closeEventstatsChanged 只刷面板、不写盘)(main.cpp:13-14tetris_window.cpp:101-102/106-110234-242/262-266
Next 副面板画的下一块位置乱跳 / 形态跟棋盘对不上副面板自己重定义形态,或没按包围盒居中(不同方块格子分布不同)预览不准、误导玩家副面板复用 TetrisBoard::shapeOf(type),画前算 4×4 矩阵的 min/max 包围盒再居中(tetris_window.cpp:53-73
投影画在缝隙形(S/Z)上错位投影按单格逐个算落点,缝隙形上下格落点不同步投影位置与真实落点不符投影按整块行偏移算(ghost_dr 一路下落直到整块撞底),整块同步移动(tetris_board.cpp:453-457
硬降分数没加 / 软降每格分不一致softDrop/hardDrop 计分没实现或写错(硬降应 +2/格、软降 +1/格)计分机制缺失、玩法变味softDrop tryMove 成功 score_ += 1;hardDrop while(tryMove) 数 dropped,score_ += dropped*2tetris_board.cpp:310-329
方块被踢到棋盘顶外(br<0)锁定时,部分格子凭空消失,且未判 game overlockPiece 遍历写盘时对 br<0 的格子直接 continue 丢弃(只当双保险),没在锁定前判定顶出方块形态被截断、堆到顶却继续游戏(漏判结束)lockPiece 写盘前先扫一遍:任一格 current_.row + r < 0 即判顶出 game over(停钟 + 刷分 + emit gameOver + return)(tetris_board.cpp:222-242
P/R 键被多处重复绑定,行为不一致 / offscreen 自动化投键到棋盘不生效QAction(WindowShortcut)+ 棋盘 keyPressEvent + 窗口 keyPressEvent 三处都绑同一键真实 UI 下 QAction 先 accept、棋盘收不到;自动化直投棋盘又走另一条路,单键双触发或漏触发棋盘 keyPressEvent 保留 Key_P(真实 UI 经事件派发链时 QAction 先命中不冲突;offscreen 直投棋盘走这条);窗口 keyPressEvent 删掉 Key_P/Key_R 死代码;Key_R 仍交 QAction(R 不需 offscreen 测)(tetris_board.cpp:401-403tetris_window.cpp:256-260
狂按 ↓ 时疯狂写盘(CPU/磁盘抖动),中途关窗最高分可能丢statsChanged 回调里直接 saveHighScore(QSettings 落盘),而 softDrop 每格 +1 都 emit 一次高频无效 I/O、性能差;落盘只挂在信号上则中途关窗来不及落statsChanged 只刷副面板(不落盘);最高分落盘推迟到 gameOver + closeEvent override(新增,保中途关窗不丢分)(tetris_window.cpp:106107-110262-266

6. 官方文档


这套「自绘棋盘 + QTimer 游戏循环 + 键盘路由 + 副面板状态同步 + QSettings 持久化」是纯 Widgets 游戏类整机应用的通用骨架——任何「自绘画布 + 节拍驱动 + 状态机」的小游戏(贪吃蛇、扫雷、2048、俄罗斯方块变体)都能换皮复用。想自己搓?手搓手册带你从一个空自绘 QWidget 一行行搓到这个成品。

AwesomeQt v0.2.0-58-gde7eeb4-dirty · de7eeb4 · 2026-07-03