尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++面向对象实践:从零构建俄罗斯方块游戏架构与实现

C++面向对象实践:从零构建俄罗斯方块游戏架构与实现 1. 为什么用C重写俄罗斯方块是个好主意你可能在很多地方见过用C语言写的俄罗斯方块代码量不大逻辑也清晰作为入门练手项目确实不错。但今天我想聊点不一样的用C来重构这个经典游戏。这绝不仅仅是把.c文件改成.cpp那么简单而是一次从“面向过程”思维到“面向对象”思维的完整实践。对于正在学习C或者想从C语言过渡到C的朋友来说这个项目是一个绝佳的跳板。很多人学C上来就是啃《深入浅出C》这类大部头概念记了一堆什么类、对象、继承、多态但一到自己动手写点东西还是习惯性地用C那套全局变量加函数的方式。结果就是代码写着写着又回去了C的特性根本没用到实处。用C写俄罗斯方块恰恰能逼着你把这些特性用起来。比如一个方块Tetromino天然就是一个类它有形状、颜色、位置这些属性成员变量也有旋转、移动、绘制这些行为成员函数。游戏主循环、渲染逻辑、碰撞检测都可以被合理地封装到不同的类中。当你完成这个项目后你会对“高内聚、低耦合”有更直观的感受这才是从C到C进阶的关键。从技术栈来看这个选择也非常务实。你不需要依赖任何复杂的游戏引擎用最基础的图形库比如Windows的GDI/GDI或者跨平台的SDL、SFML就能实现。这让你能专注于C语言本身和游戏逻辑而不是被引擎的API搞得晕头转向。同时它又足够有趣能让你看到自己写的代码“动起来”变成可交互的游戏这种正向反馈是学习编程的巨大动力。下面我们就从零开始一步步拆解如何用C构建一个属于自己的俄罗斯方块。2. 项目核心架构设计与类规划在动手写代码之前花点时间设计好架构是事半功倍的关键。一个结构清晰的C俄罗斯方块其核心类通常不会超过5个但每个类的职责必须明确。2.1 核心类职责划分我设计的核心类主要有以下几个Game游戏主控类这是整个游戏的大脑。它负责创建游戏窗口、管理游戏状态开始、进行中、暂停、结束、驱动主游戏循环、协调其他所有类的工作。它内部会持有Board棋盘和Tetromino当前方块的实例并处理用户输入键盘事件到游戏逻辑的转换。Board棋盘类代表那个10x20或自定义大小的网格区域。它的核心职责是记录哪些格子已经被固定的方块占据。它需要提供的方法包括bool isValidPosition(const Tetromino t)判断一个方块在当前形状和位置下是否与棋盘上已固定的方块冲突或超出边界。void merge(const Tetromino t)当一个方块落到底部无法再下落时将其形状“固化”到棋盘的对应格子中。int clearFullLines()检查并清除所有已被填满的行然后让上面的行下落并返回本次清除的行数用于计分。它本质上是一个二维数组或std::vectorstd::vectorCellState的封装。Tetromino方块类这是面向对象设计体现最明显的地方。俄罗斯方块有7种基本形状I, J, L, O, S, T, Z每种形状有4种旋转状态。我们可以用一个类来统一表示。成员变量方块类型一个枚举值、当前旋转状态、在棋盘上的坐标x, y、颜色、以及一个表示其形状的4x4布尔矩阵。成员函数void rotate()根据当前旋转状态计算下一个状态的形状矩阵。这里的关键是旋转的算法比如关于中心点旋转坐标是封装在类内部的外部只需调用。void move(int dx, int dy)简单地修改坐标。const std::vectorstd::vectorbool getShape() const获取当前形状的矩阵用于碰撞检测和绘制。设计技巧我们可以用一个静态的std::map或数组预先存储所有7种形状在4个方向上的矩阵定义。这样rotate()函数就变成了简单地从这个表中查找下一个状态效率更高且代码清晰。Renderer渲染器类负责所有绘制工作。将游戏逻辑Board的状态、Tetromino的位置转换为屏幕上的图像。这个类隔离了图形API的具体调用。如果你想从控制台图形用字符画切换到GDI再切换到SDL只需要重写这个类游戏逻辑类Game, Board, Tetromino几乎不用改动。这是“分离关注点”原则的很好实践。CellState格子状态可选为枚举或结构体这不是一个类但很重要。它用来表示棋盘上一个格子的状态是空的还是被某种颜色的方块占据。在Board类中就可以用std::vectorstd::vectorCellState来存储。2.2 类之间的关系与数据流理解了单个类的职责再看它们如何协作Game类拥有Board和Tetromino的实例。游戏主循环中Game检查用户输入调用Tetromino::move或Tetromino::rotate。在移动或旋转前Game会询问Board::isValidPosition如果无效则操作被取消。当方块无法再下落时Game调用Board::merge将其固化。然后Game调用Board::clearFullLines进行消行和计分。每一帧Game都会调用Renderer::draw传入当前的Board和Tetromino完成画面更新。这个数据流清晰且单向避免了复杂的双向依赖使得每个类都可以独立测试和理解。3. 关键实现细节与C特性应用有了架构蓝图我们来深入几个关键模块的实现看看如何运用C的特性让代码更健壮、更优雅。3.1 方块Tetromino的形状表示与旋转这是游戏的核心逻辑之一。最直观的方法是用一个4x4的布尔矩阵bool shape[4][4]来表示一个方块true表示该位置有方块单元。class Tetromino { public: enum class Type { I, J, L, O, S, T, Z }; enum class Rotation { Up, Right, Down, Left }; // 四种旋转状态 private: Type m_type; Rotation m_rotation; int m_posX, m_posY; // 方块左上角在棋盘网格中的坐标 Color m_color; // 形状数据可以实时计算也可以预存储 };旋转的实现有两种主流思路坐标变换法实时计算对于一个4x4矩阵旋转90度相当于先转置矩阵再水平翻转每一行顺时针。我们可以为Tetromino类写一个通用的矩阵旋转函数。这种方法代码量小但每次旋转都需要计算。预存储法查表法这是更高效、更常用的方法。因为7种形状每种4个方向总共也就28个4x4矩阵。我们可以在类初始化时比如在静态成员中定义好所有这些矩阵。class Tetromino { private: // 使用一个三维数组预先存储所有形状的所有旋转状态 // shapes[static_castint(Type)][static_castint(Rotation)][4][4] static constexpr std::arraystd::arraystd::arraystd::arraybool, 4, 4, 4, 7 SHAPES { ... }; public: void rotate(bool clockwise) { int currentRot static_castint(m_rotation); int nextRot (currentRot (clockwise ? 1 : 3)) % 4; // 计算下一个旋转状态的索引 m_rotation static_castRotation(nextRot); // 形状矩阵不需要修改绘制时根据m_type和m_rotation去SHAPES里取即可 } const auto getCurrentShape() const { return SHAPES[static_castint(m_type)][static_castint(m_rotation)]; } };提示使用constexpr和std::array能在编译期确定这些数据没有任何运行时开销是现代C鼓励的做法。我个人的经验是选择预存储法。理由很简单俄罗斯方块的旋转规则是固定的预存储使得逻辑无比清晰rotate()函数只负责更新状态索引getCurrentShape()负责返回数据完全符合“单一职责”。调试时也方便你可以直接打印出预定义的矩阵来核对形状是否正确。3.2 碰撞检测的精确定义与实现碰撞检测是游戏物理的基础必须严谨。在我们的架构里碰撞检测发生在Board类中由isValidPosition(const Tetromino t)方法完成。这个方法需要检查一个方块t在它当前的位置(m_posX, m_posY)和形状下是否与棋盘现有内容冲突。具体分两步边界检查遍历方块形状矩阵中每一个为true的单元格计算其在棋盘上的实际坐标(boardCol m_posX cellX, boardRow m_posY cellY)。如果boardCol 0或boardCol BOARD_WIDTH或boardRow BOARD_HEIGHT则视为越界注意boardRow 0通常是允许的表示方块还未完全进入棋盘顶部。这一步防止方块移出左右边界或底部。重叠检查在通过边界检查后对于每一个方块单元检查棋盘grid[boardRow][boardCol]这个格子是否已经被占据即状态不为空。如果被占据则发生重叠。这里有一个非常重要的细节boardRow 0方块部分在棋盘顶部之上是合法的不应该判为碰撞。这对应着新方块刚刚生成或者进行“T旋转”等特殊操作时方块可能短暂地有一部分在可见区域上方。只有在boardRow 0时才需要进行棋盘格子的重叠检查。实现代码骨架如下bool Board::isValidPosition(const Tetromino t) const { const auto shape t.getCurrentShape(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (shape[r][c]) { // 如果这个单元格有方块 int boardX t.getX() c; int boardY t.getY() r; // 1. 检查左右和底部边界 if (boardX 0 || boardX BOARD_WIDTH || boardY BOARD_HEIGHT) { return false; } // 2. 检查重叠仅当格子进入棋盘区域后 if (boardY 0 m_grid[boardY][boardX] ! CellState::Empty) { return false; } } } } return true; }3.3 消行与棋盘更新算法当一行被填满时需要消除该行并将上方的所有行整体下移。这是一个经典的数组操作问题。最直接的方法是从棋盘底部向上遍历每一行。如果某一行所有格子都不是Empty则标记该行为“满行”。消除时从最底部的满行开始将其上方的所有行数据向下覆盖一行。最顶部的一行则填充为Empty。但这个方法在有多行需要消除时需要多次移动数据。一个更高效的算法是使用“双指针”或“写入指针”int Board::clearFullLines() { int linesCleared 0; int writeRow BOARD_HEIGHT - 1; // 从底部开始写入的行指针 // 从底部向顶部遍历 for (int readRow BOARD_HEIGHT - 1; readRow 0; --readRow) { bool lineFull true; for (int col 0; col BOARD_WIDTH; col) { if (m_grid[readRow][col] CellState::Empty) { lineFull false; break; } } if (!lineFull) { // 如果该行没满则将其复制到writeRow位置 if (writeRow ! readRow) { std::copy(m_grid[readRow].begin(), m_grid[readRow].end(), m_grid[writeRow].begin()); } --writeRow; // 写入指针上移 } else { // 如果该行满了只增加清除计数不复制writeRow不动 linesCleared; } } // 最后writeRow指针之上的所有行索引小于等于writeRow都应该被清空 for (int row 0; row writeRow; row) { std::fill(m_grid[row].begin(), m_grid[row].end(), CellState::Empty); } return linesCleared; }这个算法只遍历了棋盘一次时间复杂度是O(N*M)N为行数M为列数并且原地操作没有额外的内存分配非常高效。writeRow指针始终指向下一个应该写入有效数据的位置。4. 游戏主循环与状态管理游戏主循环是驱动一切的引擎。一个典型的、基于时间的游戏循环结构如下void Game::run() { init(); // 初始化窗口、资源、游戏状态 auto lastTime std::chrono::steady_clock::now(); float accumulatedTime 0.0f; const float targetFrameTime 1.0f / 60.0f; // 目标60FPS const float fixedDeltaTime 1.0f / 60.0f; // 物理更新步长也取60Hz while (m_isRunning) { auto currentTime std::chrono::steady_clock::now(); float deltaTime std::chrono::durationfloat(currentTime - lastTime).count(); lastTime currentTime; accumulatedTime deltaTime; // 1. 处理输入实时事件 processInput(); // 2. 固定时间步长的更新确保物理逻辑稳定 while (accumulatedTime fixedDeltaTime) { update(fixedDeltaTime); // 这里进行方块自动下落、碰撞判定等 accumulatedTime - fixedDeltaTime; } // 3. 渲染尽可能快 render(); // 4. 简单的帧率控制如果渲染太快 // ... 可以加入sleep或使用垂直同步 } cleanup(); }在这个循环中有几个关键状态需要管理游戏全局状态enum class GameState { StartScreen, Playing, Paused, GameOver }。根据状态决定processInput()和update()的行为。比如在Paused状态update()可能被跳过。方块自动下落这是游戏的核心节奏。我们可以在Game类中设置一个下落间隔计数器例如每0.5秒下落一格。在update(float dt)中累加时间当超过间隔时尝试将当前方块下移一格如果失败则固化并生成新方块。void Game::update(float dt) { if (m_state ! GameState::Playing) return; m_fallTimer dt; if (m_fallTimer m_fallInterval) { m_fallTimer 0.0f; if (!tryMoveCurrentPiece(0, 1)) { // 尝试下落 // 下落失败固化到棋盘 m_board.merge(m_currentPiece); int lines m_board.clearFullLines(); updateScore(lines); // 根据消行数更新分数 spawnNewPiece(); // 生成新方块 if (!isValidPosition(m_currentPiece)) { // 新方块刚生成就碰撞游戏结束 m_state GameState::GameOver; } } } // 这里还可以更新其他逻辑比如根据分数等级提高下落速度m_fallInterval }用户输入处理在processInput()中检测键盘事件。通常左右键是即时移动下键是加速下落上键是旋转。这里需要注意按键去抖和重复触发。对于移动可以设置为按下即触发一次然后短暂延迟后开始连续触发以提供更好的操作手感。对于旋转每次按键应只触发一次旋转动作。5. 图形渲染的跨平台考量与实现为了让项目更有价值我们不应该把图形渲染代码和游戏逻辑硬编码在一起。定义一个抽象的Renderer基类是一个好主意。class Renderer { public: virtual ~Renderer() default; virtual void clear() 0; virtual void drawBoard(const Board board) 0; virtual void drawTetromino(const Tetromino tetromino) 0; virtual void drawUI(int score, int level) 0; virtual void present() 0; // 交换缓冲区显示画面 };然后我们可以为不同的后端提供实现控制台实现 (ConsoleRenderer)使用空格和字符如[]、##来绘制适合纯学习环境不依赖任何图形库。SDL2实现 (SDLRenderer)SDL是一个轻量级、跨平台的多媒体库非常适合这种2D小游戏。它处理窗口、事件、2D渲染都很方便。SFML实现 (SFMLRenderer)另一个优秀的跨平台多媒体库API更面向对象用起来也很顺手。Windows GDI实现 (GDIRenderer)如果你只想在Windows上运行用原生GDI也可以但跨平台性为零。以SDL2为例drawBoard的实现大致是遍历棋盘网格对于每个非空单元格计算其在屏幕上的像素坐标然后调用SDL_RenderFillRect绘制一个彩色矩形。一个重要的技巧双缓冲渲染。无论是SDL、SFML还是其他库都应该在内存中完成所有绘制离屏缓冲区然后一次性将完整的画面提交到屏幕SDL_RenderPresent或SFML::display。这能有效避免画面闪烁。6. 进阶功能与优化思路一个基础版本完成后你可以考虑添加更多功能让游戏更完整、更专业。6.1 “下一个方块”预览这需要游戏管理两个Tetromino对象m_currentPiece当前操作的方块和m_nextPiece下一个方块。当m_currentPiece被固化后m_nextPiece就变成新的当前方块同时再随机生成一个新的m_nextPiece。在渲染时在棋盘旁边单独绘制m_nextPiece即可。6.2 方块“暂存”Hold这是一个经典机制。增加一个m_heldPiece成员可能为std::optionalTetromino因为初始时是空的。当玩家按下暂存键如C键时如果m_heldPiece为空则将当前方块存入并立即生成一个新方块。如果m_heldPiece有值则交换当前方块和暂存方块。 关键规则是在一次方块落地固化之前只能执行一次暂存操作。你需要一个布尔标志m_canHold来记录当前方块是否已被允许暂存每次新方块生成时设为true执行暂存后设为false。6.3 旋转墙踢Wall Kick这是现代俄罗斯方块如Guideline规范的标准特性。当方块旋转时如果默认位置与棋盘冲突系统会尝试将其向旁边移动一个或几个格子直到找到合适的位置。这通常通过一个预定义的“墙踢表”来实现表中列出了对于每种方块、从旋转状态A到状态B时需要尝试的偏移量(x, y)列表。在Tetromino::rotate中如果旋转后的新形状在当前位置无效就依次尝试应用墙踢表中的偏移直到找到一个有效位置或者所有偏移都无效则旋转失败。6.4 分数与等级系统分数计算可以很丰富单消100分双消300分三消500分四消Tetris800分。连续消除Combo有额外奖励。随着等级提高方块下落速度m_fallInterval应逐渐减小速度加快。等级可以根据已消除的总行数来提升例如每消除10行升一级。6.5 代码优化与可读性使用智能指针管理资源如果渲染器涉及纹理、字体等资源使用std::unique_ptr来管理生命周期避免内存泄漏。避免不必要的拷贝在函数传参时对于Board、Tetromino这类可能较大的对象使用const 传递。使用枚举类enum class我们已经用了它比传统的enum更安全避免了隐式转换和命名污染。常量规范化将棋盘宽度BOARD_WIDTH、高度BOARD_HEIGHT、初始下落间隔等定义为constexpr常量集中管理。7. 开发环境搭建与调试技巧工欲善其事必先利其器。一个舒适的开发环境能极大提升效率。7.1 编辑器与编译器选择Visual Studio (Windows)对于初学者或Windows开发者是最友好的选择。创建空项目添加源文件即可。它的调试器非常强大。VSCode CMake (跨平台)这是更灵活、更现代的选择。你需要安装C扩展如MS的C/C扩展和CMake Tools扩展。在项目根目录创建CMakeLists.txt文件指定C标准如set(CMAKE_CXX_STANDARD 17)、可执行文件名称、以及源文件。配置c_cpp_properties.json文件让IntelliSense正确找到头文件。配置launch.json和tasks.json来实现一键编译和调试。编译器Windows上可以用MSVCVisual Studio自带或MinGW-w64。Linux/macOS上通常用GCC或Clang。确保在CMake或编译命令中开启必要的警告标志如-Wall -Wextra。7.2 第三方库管理以SDL2为例如果你选择SDL2进行渲染需要将其集成到项目中。Windows (VSCode CMake)推荐使用vcpkg或conan这类C包管理器。以vcpkg为例安装vcpkg。执行vcpkg install sdl2:x64-windows安装64位版本。在你的CMakeLists.txt中在project()命令后添加find_package(SDL2 REQUIRED) target_link_libraries(你的项目名 PRIVATE SDL2::SDL2)使用CMake配置项目时指定-DCMAKE_TOOLCHAIN_FILE[vcpkg根目录]/scripts/buildsystems/vcpkg.cmake。手动配置也可以从SDL官网下载开发库手动将include目录和lib文件路径添加到项目设置中并将.dll运行时库复制到可执行文件旁边。这种方法稍显繁琐但有助于理解链接过程。7.3 调试中的常见问题图形窗口不显示或一闪而过检查主循环是否在持续运行确保没有提前退出。在main函数末尾或循环后加个断点或者临时加个std::cin.get()暂停控制台看看是否有错误输出。方块旋转或移动位置错乱这是最常遇到的问题。善用调试器在Tetromino::rotate和Board::isValidPosition函数中设置断点观察旋转前后形状矩阵的变化以及碰撞检测时计算的坐标是否正确。可以将棋盘的当前状态和方块的形状、位置打印到控制台进行可视化调试。内存泄漏如果你使用了new分配内存在现代C中应尽量避免多用std::vector和智能指针确保有对应的delete。使用工具如ValgrindLinux或Visual Studio的诊断工具来检测。随机数生成问题方块需要随机生成。不要用rand()它质量差且线程不安全。使用C11的random库std::random_device rd; // 真随机数种子 std::mt19937 gen(rd()); // 梅森旋转算法引擎 std::uniform_int_distribution distrib(0, 6); // 均匀分布生成0-6对应7种方块 int typeIndex distrib(gen);从C语言风格的俄罗斯方块过渡到C面向对象的版本最大的收获不是多写了几行代码而是思维模式的转变。你会开始思考哪些数据和操作应该绑定在一起成为一个类类与类之间应该如何传递消息如何让代码更容易测试和扩展当你成功运行起自己写的C版俄罗斯方块并且能够轻松地加入“下一个预览”或“暂存”功能时你会真切地感受到良好架构带来的力量。这个项目虽小但涵盖了类设计、状态管理、算法实现、资源管理、甚至简单的设计模式如渲染器的策略模式是一个性价比极高的C综合练习。我建议你在实现基础版本后不要停下尝试去实现墙踢、连续消除奖励这些进阶特性过程中遇到的每一个问题和解法都会成为你宝贵的经验。
返回列表