
1. 项目概述用现代引擎重温经典最近在整理旧项目翻出来一个用 Cocos2d-x 4.0 写的俄罗斯方块。这玩意儿虽然是个老掉牙的游戏但用它来上手 Cocos2d-x 这个跨平台游戏引擎尤其是学习其核心的节点系统、渲染流程和物理虽然方块用不上复杂物理逻辑简直是绝配。很多朋友想学游戏开发一上来就奔着炫酷的3D效果去结果在复杂的坐标系和着色器里绕晕了。其实从俄罗斯方块这种规则清晰、逻辑完整的2D游戏入手才是真正理解游戏循环、状态管理和碰撞检测等核心概念的捷径。我这个实现不仅包含了所有经典俄罗斯方块的规则旋转、消行、加速下落、预览下一个方块等还特意用 Cocos2d-x 4.0 相对现代的 API 重构了一遍代码结构更清晰更适合作为学习模板。无论你是想入门 Cocos2d-x还是单纯想重温一下经典游戏的实现逻辑这个项目都能给你提供一个扎实的、可运行的参考。2. 整体架构与核心设计思路2.1 为什么选择 Cocos2d-x 4.0Cocos2d-x 发展到 4.0 版本已经是一个相当成熟的 C 跨平台游戏引擎了。相比早期版本4.0 在渲染器、内存管理、API 设计上都有不少优化。选择它来做俄罗斯方块主要基于几点考虑一是性能足够对于这种逻辑计算密集但图形简单的游戏Cocos2d-x 游刃有余能保证在所有目标平台iOS, Android, Windows 等上流畅运行二是 C 作为核心开发语言执行效率高对于需要实时响应键盘或触控输入的方块下落和移动控制非常有利三是其丰富的 2D 功能如精灵Sprite、标签Label、动作Action和调度器Scheduler完美契合俄罗斯方块的开发需求。当然你也可以用 Unity 或 Godot 来实现但用 Cocos2d-x 能让你更专注于游戏逻辑本身而不是被编辑器复杂的界面所干扰对于理解底层游戏循环更有帮助。2.2 游戏核心数据结构设计俄罗斯方块的核心是一个二维的网格棋盘以及在其中活动的、由四个小方块我们称之为“方块单元”组成的“方块形态”。在代码中我设计了几个关键的数据结构来映射这些概念。首先游戏棋盘GameBoard。我使用了一个二维的std::vectorstd::vectorint来表示。棋盘宽度通常为10高度为20可见区域。数组中的每个整数代表对应格子的状态0 表示空非0比如1-7表示已被不同颜色的方块占据。使用整数而非布尔值是为了方便后续根据方块类型渲染不同的颜色。// 示例定义一个10x20的游戏棋盘 const int BOARD_WIDTH 10; const int BOARD_HEIGHT 20; std::vectorstd::vectorint gameBoard(BOARD_HEIGHT, std::vectorint(BOARD_WIDTH, 0));其次方块形态Tetromino。经典的俄罗斯方块有7种基本形态I, J, L, O, S, T, Z。每种形态有四个旋转状态O型只有1个。我预先定义了一个三维数组来存储所有形态的所有旋转状态。每个形态用一个 4x4 的布尔矩阵表示true代表该位置有方块单元。// 示例定义T型方块的4种旋转状态简化表示 bool tetrominoT[4][4][4] { { // 状态0 {0, 1, 0, 0}, {1, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { // 状态1 (旋转90度) // ... 其他状态定义 } };最后当前活动方块CurrentPiece。这是一个对象它需要记录当前是哪种形态type、处于哪个旋转状态rotation、在棋盘上的坐标x, y。所有的移动、旋转、碰撞检测都围绕这个对象进行。注意坐标系统的设计很重要。我通常将棋盘左下角定义为原点 (0, 0)x 向右增长y 向上增长。这样和 Cocos2d-x 默认的坐标系一致处理渲染位置时更直观。2.3 游戏主循环与状态管理游戏的主驱动是一个游戏循环。在 Cocos2d-x 中我们通常利用Scheduler来模拟这个循环。我设置了一个每帧或每固定时间间隔如0.5秒调用的更新函数update(float dt)。在这个函数里主要做三件事处理输入检测键盘按键左右移动、旋转、加速下落或触屏事件。更新游戏状态根据输入和计时更新当前方块的位置。检查是否需要固定当前方块、消行、生成新方块。渲染根据最新的游戏状态更新屏幕上所有精灵的位置和外观。Cocos2d-x 的渲染是自动的我们只需要更新节点属性。游戏状态则用一个简单的枚举来管理GameState::READY游戏准备开始。GameState::PLAYING游戏进行中。GameState::PAUSED游戏暂停。GameState::GAME_OVER游戏结束。在PLAYING状态下核心逻辑就是“方块不断下落 - 玩家控制 - 触底固定 - 检查消行 - 生成新方块”的循环。这个循环的节奏下落速度是游戏难度的关键可以通过调整Scheduler的间隔时间来实现。3. 核心模块实现详解3.1 方块生成与随机化不能让方块按固定顺序出现那样就太容易预测了。我采用了经典的“包随机”Random Bag算法而不是纯随机。纯随机可能导致长时间不出某种方块或者连续出同一种方块影响游戏体验。“包随机”算法的思路是预先准备好一个包含所有7种方块的“袋子”然后随机打乱这个袋子玩家按顺序从袋子里取出方块。当一个袋子取空后再重新装满7种方块并打乱。这样就保证了在每7个方块中每种形态都会出现且仅出现一次既随机又公平。class PieceGenerator { private: std::vectorint bag; // 当前“袋子” int index; // 当前取到袋子的第几个 void refillBag() { bag.clear(); // 装入7种方块类型编号例如 0-6 for(int i 0; i 7; i) bag.push_back(i); // 使用 std::shuffle 随机打乱袋子顺序 std::random_device rd; std::mt19937 g(rd()); std::shuffle(bag.begin(), bag.end(), g); index 0; } public: PieceGenerator() { refillBag(); } int getNextPiece() { if(index bag.size()) { refillBag(); } return bag[index]; } };在游戏中我会维护一个PieceGenerator实例。当需要生成新方块时调用getNextPiece()获取下一个方块的类型。同时为了提供“下一个方块预览”功能我会预取一个或多个方块类型并显示在预览区。3.2 移动、旋转与碰撞检测这是游戏逻辑中最核心的部分直接决定了手感和游戏规则的准确性。移动当玩家按下左/右键时我们尝试将当前方块的 x 坐标加1或减1。但在这之前必须进行碰撞检测。碰撞检测的函数bool checkCollision(int newX, int newY, int newRotation)会模拟方块移动到新位置或旋转到新状态后其四个方块单元是否与棋盘边界或其他已固定的方块重叠。如果检测到碰撞则此次移动或旋转无效。bool GameLayer::checkCollision(int pieceX, int pieceY, int rotation, int pieceType) { // 获取指定类型和旋转状态的方块形态矩阵 const auto shape tetrominoShapes[pieceType][rotation]; for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (shape[row][col]) { // 如果这个位置是方块的一部分 int boardX pieceX col; int boardY pieceY row; // 检查是否超出边界 if (boardX 0 || boardX BOARD_WIDTH || boardY 0) { return true; // 碰撞 } // 检查是否与棋盘上已固定的方块重叠 (boardY BOARD_HEIGHT 时视为超出顶部但俄罗斯方块通常允许暂时超出) // 更准确的判断是如果 boardY BOARD_HEIGHT 且该位置非空则碰撞 if (boardY BOARD_HEIGHT gameBoard[boardY][boardX] ! 0) { return true; // 碰撞 } } } } return false; // 无碰撞 }旋转旋转的逻辑稍复杂。除了检查旋转后是否碰撞还需要处理“踢墙”Wall Kick机制。这是官方俄罗斯方块的标准规则当方块旋转后因为紧贴墙壁或地面而导致位置非法时系统会尝试将方块向左、右或上方微调一个单位如果调整后合法则旋转成功。我实现了一个kickTable来存储不同旋转状态下尝试的偏移量使旋转手感更符合现代玩家的预期。快速下落与硬降快速下落软降是按住下键时方块加速下落但玩家仍可控制。硬降Space键是方块瞬间落到最底部。实现硬降时需要循环调用checkCollision让方块 y 坐标不断减1直到发生碰撞然后回退一步并固定。3.3 消行逻辑与分数计算当一行中的所有格子都被方块填满时这一行就需要被消除。我的实现步骤是每当一个方块被固定到棋盘后遍历这个方块所占据的几行最多4行。对每一行检查是否所有格子都非空gameBoard[row][col] ! 0。如果某行已满则将该行标记为待消除。将所有被标记的行消除将这些行从棋盘中移除同时将其上方的所有行整体向下移动一行。在棋盘顶部补充新的空行。消行是游戏得分的主要来源。分数计算通常不是简单的线性关系而是鼓励连续消除Combo和一次性消除多行。我采用的经典计分规则是消除1行100 分 * 当前等级消除2行300 分 * 当前等级消除3行500 分 * 当前等级消除4行Tetris800 分 * 当前等级同时每固定一个方块也有少量基础分如10分。等级会随着消除的总行数增加而提升等级越高方块下落的基础速度越快游戏难度也随之增加。实操心得在实现消行动画时不要立即更新棋盘数据。可以先记录要消除的行号然后触发一个短暂的视觉特效比如让该行的方块闪烁在特效播放完毕后再执行真正的数据移除和重绘。这能极大提升游戏的反馈感和流畅度。Cocos2d-x 的Action系统如Blink、Sequence、CallFunc非常适合用来实现这种效果。3.4 渲染与UI实现Cocos2d-x 的渲染基于节点树。我的游戏主场景包含以下主要节点背景层一个简单的颜色层或背景图片。棋盘层一个Node容器用于放置所有已固定的方块精灵和当前活动方块的精灵。每个格子可以是一个Sprite根据gameBoard中的数据设置其纹理颜色和显示/隐藏状态。当前方块由四个小方块精灵组成作为一个Node的子节点。通过更新这个父节点的位置就能整体移动当前方块。预览窗口另一个独立的Node用于显示下一个方块的形态。UI层包含分数Label、等级Label、下一个方块提示、开始/暂停按钮等。性能优化点对于棋盘上已固定的上百个格子如果每个格子都是一个独立的Sprite并在每帧都进行更新开销较大。一个优化策略是使用“脏矩形”或批量更新。更简单有效的方法是只在棋盘数据发生改变时如方块固定、消行后才重新创建或更新棋盘层所有精灵的显示状态。对于当前活动方块由于其频繁移动单独更新它的四个子精灵位置是高效的。4. 关键功能实现与代码剖析4.1 游戏控制与输入处理Cocos2d-x 提供了多种输入处理方式。对于桌面平台我使用键盘事件监听器EventListenerKeyboard。auto keyboardListener EventListenerKeyboard::create(); keyboardListener-onKeyPressed [this](EventKeyboard::KeyCode keyCode, Event* event){ if(_gameState ! GameState::PLAYING) return; switch(keyCode) { case EventKeyboard::KeyCode::KEY_LEFT_ARROW: tryMove(-1, 0); // 尝试左移 break; case EventKeyboard::KeyCode::KEY_RIGHT_ARROW: tryMove(1, 0); // 尝试右移 break; case EventKeyboard::KeyCode::KEY_UP_ARROW: tryRotate(1); // 顺时针旋转 break; case EventKeyboard::KeyCode::KEY_DOWN_ARROW: tryMove(0, -1); // 加速下落软降 break; case EventKeyboard::KeyCode::KEY_SPACE: hardDrop(); // 硬降 break; case EventKeyboard::KeyCode::KEY_P: togglePause(); // 暂停/继续 break; } }; _eventDispatcher-addEventListenerWithSceneGraphPriority(keyboardListener, this);对于移动端则需要使用触屏事件。我可以在屏幕左右下区域设置虚拟按钮或者通过滑动手势来控制例如左右滑动移动上滑旋转下滑加速快速下滑硬降。这需要用到EventListenerTouchOneByOne并分析触摸点的位移和速度。注意处理连续按键如长按左右键持续移动时需要额外逻辑。一种常见做法是在onKeyPressed中标记按键按下并启动一个定时器在按键持续期间每隔一个较短间隔如0.1秒执行一次移动操作。在onKeyReleased中清除标记和定时器。这样既能响应快速点按又能实现长按连续移动。4.2 方块固定与游戏状态更新方块下落到无法再向下移动时就需要“固定”到棋盘上。void lockPiece()函数负责这个关键操作void GameLayer::lockPiece() { // 1. 将当前方块的数据写入 gameBoard for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (currentPiece.shape[row][col]) { int boardX currentPiece.x col; int boardY currentPiece.y row; // 安全检查确保坐标在棋盘有效范围内 if (boardY 0 boardY BOARD_HEIGHT boardX 0 boardX BOARD_WIDTH) { gameBoard[boardY][boardX] currentPiece.type 1; // 用类型1作为颜色索引 } else { // 如果方块有任何一部分在棋盘顶部之外说明堆叠得太高游戏结束 if (boardY BOARD_HEIGHT) { gameOver(); return; } } } } } // 2. 检查并消除满行 int linesCleared clearLines(); if(linesCleared 0) { updateScore(linesCleared); // 播放消行动画和音效 } // 3. 生成新的当前方块 spawnNewPiece(); // 4. 检查游戏是否立即结束新方块出生即碰撞 if (checkCollision(currentPiece.x, currentPiece.y, currentPiece.rotation, currentPiece.type)) { gameOver(); } }spawnNewPiece()函数负责从PieceGenerator获取下一个方块类型并将其初始位置设置在棋盘顶部的中央。同时它需要更新“下一个方块预览”的显示。4.3 动画与特效实现虽然俄罗斯方块看起来简单但适当的动画能极大提升游戏质感。我用 Cocos2d-x 的Action系统实现了几个效果方块移动/旋转的平滑过渡在改变当前方块位置时不是直接设置坐标而是使用MoveTo或Sequence动作使其有一个短暂的移动过程手感更柔和。消行闪烁特效找到要消除的行后将该行所有格子的精灵执行一个Blink闪烁动作闪烁几次后再执行清除。auto blinkAction Blink::create(0.5f, 3); // 0.5秒内闪烁3次 auto removeAction CallFunc::create([this, row](){ // 实际移除该行数据的回调函数 removeLine(row); }); auto sequence Sequence::create(blinkAction, removeAction, nullptr); lineSprite-runAction(sequence);分数增加动画当消行得分时分数标签可以执行一个缩放再恢复的动画Sequence::create(ScaleTo::create(0.1f, 1.2f), ScaleTo::create(0.1f, 1.0f), nullptr)吸引玩家注意。游戏结束动画游戏结束时可以让整个棋盘层的所有方块依次执行掉落或淡出的动作营造“崩塌”的效果。音效的添加则使用 Cocos2d-x 的SimpleAudioEngine在移动、旋转、固定、消行、游戏结束等关键节点播放对应的音效文件。5. 性能优化与多平台适配5.1 内存与渲染优化对于棋盘渲染如前所述避免每帧全量刷新。我采用了一个std::vectorSprite*来管理所有格子精灵其大小等于BOARD_WIDTH * BOARD_HEIGHT。初始化时创建好所有精灵并设置好位置但将其设为不可见。游戏过程中根据gameBoard数组的值只更新那些状态发生变化的格子精灵的纹理和可见性。这比频繁创建和销毁精灵要高效得多。对于当前活动方块由于它频繁变化我将其四个单元精灵作为成员变量持有只更新其位置和颜色而不是每次生成新方块时都新建精灵。使用纹理图集Texture Atlas将所有方块颜色的小图片打包成一张大图能减少 OpenGL 绘制调用Draw Call提升渲染效率。Cocos2d-x 的SpriteFrameCache可以方便地管理图集。5.2 跨平台构建与发布Cocos2d-x 项目使用 CMake 进行构建这是 Cocos2d-x 4.0 推荐的方式。我的项目根目录下有一个CMakeLists.txt文件。桌面平台Windows/macOS/Linux# 在项目根目录下 mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease cmake --build . --config Release构建完成后可在build/bin/目录下找到可执行文件。Android 平台 这是大家搜索“android studio 俄罗斯方块”时最关心的部分。Cocos2d-x 4.0 提供了完善的 Android Studio 工程集成。首先确保你安装了 Android SDK, NDK, CMake并配置好环境变量。在项目根目录运行 Cocos2d-x 提供的脚本或使用 CMake来生成 Android Studio 项目。# 进入项目proj.android目录如果存在 # 或者使用cocos命令行工具 cocos compile -p android -m release用 Android Studio 打开生成的proj.android或proj.android-studio文件夹。在 Android Studio 中配置好签名连接真机或启动模拟器点击运行即可。踩坑实录Android 构建最常见的坑是 NDK 版本和 CMake 版本不兼容。Cocos2d-x 4.0 通常需要较新版本的 NDK (r21)。务必检查proj.android/app/build.gradle中的externalNativeBuild配置确保arguments里的-DCMAKE_BUILD_TYPE和cppFlags正确并且abiFilters包含了目标架构如armeabi-v7a, arm64-v8a, x86, x86_64。iOS 平台 使用 Xcode 打开proj.ios_mac/目录下的.xcodeproj文件。选择目标设备或模拟器直接编译运行即可。注意证书和权限配置。6. 常见问题排查与调试技巧在开发过程中我遇到了不少典型问题这里记录下排查思路。6.1 方块旋转或移动时“卡进”墙里现象方块在旋转或移动到边缘时有时会有一部分图形嵌入到棋盘边界或已固定的方块中。原因碰撞检测的边界条件判断不严谨。特别是当方块的一部分在棋盘顶部之外boardY BOARD_HEIGHT时是允许的新方块出生时但当其部分在棋盘底部之外boardY 0或左右之外时必须判定为碰撞。我的checkCollision函数最初漏掉了对boardY 0的检查导致方块能“穿”过地板。解决完善碰撞检测函数对所有四个方向左、右、下以及与其他方块的叠加进行严格检查。对于顶部允许临时超出但在lockPiece时需要做游戏结束判断。6.2 消行后上方方块不下落或下落错位现象消除中间一行后上面的方块悬空了或者下落的位置不对。原因消除行并移动上方行的逻辑有 bug。最容易出错的地方是行索引的遍历方向。如果从上往下遍历当消除一行后上面的行下移会打乱后续遍历的索引。必须从下往上遍历检查是否需要消除并且消除行后移动数据时也要从被消除行的上一行开始逐行向下覆盖。解决int GameLayer::clearLines() { int linesCleared 0; // 从棋盘最底部开始向上检查 for (int row 0; row BOARD_HEIGHT; row) { bool lineFull true; for (int col 0; col BOARD_WIDTH; col) { if (gameBoard[row][col] 0) { lineFull false; break; } } if (lineFull) { // 将该行以上的所有行下移一行 for (int moveRow row; moveRow BOARD_HEIGHT - 1; moveRow) { gameBoard[moveRow] gameBoard[moveRow 1]; } // 最顶部一行置为空 gameBoard[BOARD_HEIGHT - 1] std::vectorint(BOARD_WIDTH, 0); linesCleared; // 因为当前行被新的行覆盖了需要再检查一次同一行row索引不变 row--; } } return linesCleared; }6.3 在 Android 设备上运行崩溃或黑屏现象在桌面平台运行良好打包到 Android 后启动即崩溃或只显示黑屏。排查步骤检查日志使用adb logcat命令查看 Android 设备日志过滤你的应用包名或cocos2d-x关键字寻找Fatal、Exception或Error信息。这是最直接的线索。资源路径问题Cocos2d-x 在 Android 上访问资源文件图片、音效的路径与桌面不同。确保使用FileUtils::getInstance()-fullPathForFilename(image.png)来获取绝对路径而不是硬编码相对路径。纹理尺寸格式确保图片资源是2的幂尺寸如 128x128, 256x512并且格式兼容推荐 PNG。某些设备对非2的幂纹理支持不好。C 异常与 NDK 调试在Android.mk或 CMakeLists 中开启-fexceptions和-g调试符号。在Application.mk中设置APP_STL : c_static或c_shared需与 Cocos2d-x 库匹配。如果崩溃在原生代码可能需要使用ndk-stack工具解析崩溃堆栈。权限检查AndroidManifest.xml确保申请了必要的权限例如网络权限如果需要在线功能、存储权限如果需要写文件。6.4 游戏逻辑与渲染不同步现象方块在棋盘上的逻辑位置和屏幕上显示的位置不一致。原因这是坐标转换错误。Cocos2d-x 的渲染坐标系原点在屏幕左下角而我的游戏逻辑棋盘数组索引gameBoard[row][col]可能将row0对应底部或顶部。解决建立清晰的映射关系。我定义逻辑坐标(gridX, gridY)其中(0,0)对应棋盘左下角。那么该格子精灵在屏幕上的像素位置应为float posX originX gridX * cellSize; float posY originY gridY * cellSize;其中originX, originY是棋盘左下角在屏幕上的像素坐标cellSize是每个格子的像素大小。在初始化时计算好这个映射并在任何更新逻辑坐标的地方同步更新精灵的setPosition。最后分享一个调试小技巧在开发初期可以绘制棋盘的网格线和坐标轴并打印当前方块的逻辑坐标这能快速帮你定位是逻辑计算错误还是渲染坐标转换错误。用DrawNode画线非常方便。当游戏稳定后再移除这些调试图形。