资讯动态

C++与SFML实战:俄罗斯方块的数据抽象与碰撞检测

发布时间:2026/10/17 13:43:06 来源:尧图企业网站定制
做俄罗斯方块这种项目最容易被低估的其实是“数据表示”。很多人一上来就想着怎么画方块、怎么监听键盘结果写到碰撞检测的时候发现自己根本说不清楚“当前方块到底占了哪几个格子”然后就开始用一堆零散的 if 判断硬凑最后越改越乱。我这次用 C 配合 SFML 重写了一遍俄罗斯方块最大的感触是这个项目真正考验的是抽象能力而不是图形 API 熟不熟练。我会把从棋盘定义、方块形状、旋转与踢墙、主循环节奏到最后落定消行这一整条链路完整拆开讲每一步都带上我实测过的坑和取舍。如果你是刚学 C 不久、想找一个能完整练手图形界面项目的阶段这篇应该能让你少走不少弯路。1. 在写任何绘制代码之前先定死“棋盘和方块”的数据结构很多教程上来就教你怎么建窗口、怎么画矩形但我的建议相反先把纯逻辑层写完再碰 SFML 的渲染部分。俄罗斯方块的全部玩法本质上就是在一个二维网格里做“放置”和“消除”只要这个网格设计好了后面的渲染只是把格子映射成屏幕坐标而已。1.1 棋盘用二维数组还是带边界的网格标准俄罗斯方块的棋盘是 10 列 × 20 行。我见过不少人用bool board[20][10]但 bool 数组有个麻烦当你后面想实现“幽灵方块”、不同颜色的锁定块、甚至是消行特效时一个格子只存 0/1 就太贫瘠了。我建议用int值数组0 表示空非 0 表示已经被方块占用顺便用数字来区分颜色索引const int COLS 10; const int ROWS 20; std::vectorstd::vectorint board(ROWS, std::vectorint(COLS, 0));这里有个细节经常被忽略要不要把边界也放进数组里。一种做法是碰撞检测的时候单独判断x 0 || x COLS || y ROWS另一种做法是把数组多扩两圈用 -1 表示边界。我试过第二种它会让collides()函数的代码特别统一但因为需要频繁处理索引偏移写起来反而容易把自己绕晕。最后我回归了第一种逻辑清晰边界判断就三行代码的事。棋盘高度我建议至少设成 22让新方块从顶上的两排“隐藏行”里生成。如果你只做 20 行方块一生成就贴顶玩家几乎没有反应时间实际玩起来特别憋屈。1.2 方块四个坐标点足够描述一切俄罗斯方块的 7 种形状本质上都是 4 个小正方形在平面上的不同排列。所以最通用的表示方法是用相对坐标数组而不是为每种形状单独写一个二维矩阵struct Point { int x; int y; }; // I 形方块横着排成一条线 std::vectorPoint iShape { {0, 0}, {1, 0}, {2, 0}, {3, 0} }; // T 形方块 std::vectorPoint tShape { {1, 0}, {0, 1}, {1, 1}, {2, 1} }; // O 形方块 std::vectorPoint oShape { {1, 0}, {2, 0}, {1, 1}, {2, 1} };相对坐标的意思是“相对于当前方块的中心锚点”。比如 T 形方块中心点是(1,1)其他三块分别位于它的上、左、右。这里用(1,1)而不是(0,0)当锚点有个好处旋转的时候1×1 的格子大小对旋转公式特别友好而且方块看起来像是在“围绕自己中心”转动玩家视觉上更自然。如果你想把所有形状存在一个统一的容器里可以用std::vectorstd::vectorPoint shapes { iShape, tShape, oShape, ... };再配合一个随机数引擎去选形状。注意真正的俄罗斯方块随机算法是“7-bag”也就是把 7 种形状打乱成一袋一袋取完再开新袋避免连续两三个同款形状砸下来。我试过纯随机连续来三个 S 形真的会让人想砸键盘。1.3 像素坐标和网格坐标必须分开一开始犯过的错直接把方块位置放在sf::Vector2f里用浮点像素坐标做碰撞。这会让旋转、对齐、碰撞全部变得很难受因为浮点数误差会导致方块嵌进已有格子半格。正确姿势是逻辑层全部用整数网格坐标只在最终绘制时做一次像素映射sf::Vector2f GridToPixel(const Point p) { return sf::Vector2f( originX p.x * BLOCK_SIZE, originY p.y * BLOCK_SIZE ); }其中originX和originY是棋盘左上角在窗口里的像素位置。整个游戏循环里方块位置就是一个Point position移动就是position.x 1或position.y 1干净利落永远不需要担心“两个方块之间差了 0.0001 个像素”。这块我认为是整个项目最划算的提前设计——花十分钟把坐标系统分离能帮你省掉后面几小时的调试时间。2. 方块形状、旋转坐标与踢墙检测的实现选择形状定义好了接下来就是最核心的机制旋转。如果你让方块绕锚点旋转本质上就是数学里的坐标变换不需要对七种形状分别写旋转逻辑。2.1 一个旋转函数搞定所有形状顺时针旋转 90 度的坐标变换公式是(x, y) - (-y, x)。放在俄罗斯方块里因为锚点通常不一定是(0,0)只要相对坐标算好直接套公式即可。我封装了一个旋转函数std::vectorPoint RotateClockwise(const std::vectorPoint shape, int times 1) { std::vectorPoint result shape; for (int t 0; t times; t) { for (auto p : result) { int tmp p.x; p.x -p.y; p.y tmp; } } return result; }实测中你会发现旋转操作并不需要每帧调用很多次所以性能完全不是问题。比较重要的是思考一个问题旋转后碰撞了怎么办。最朴素的方案是旋转失败就不转了但这会让玩家在靠墙时无法旋转手感非常差尤其是 I 形方块贴边的时候。2.2 踢墙旋转失败后的补救偏移“踢墙”这个词听起来玄乎实际就是旋转之后先检查一下有没有碰撞如果碰撞了试着把方块往左挪一格、往右挪一格、往上挪一格如果某个偏移后不碰撞了就用那个位置。这就是简化版踢墙bool TryRotate(std::vectorPoint currentShape, Point pos) { auto rotated RotateClockwise(currentShape); const int kicks[5][2] { {0, 0}, // 原位 {-1, 0}, // 左移一格 {1, 0}, // 右移一格 {0, -1}, // 上移一格 {-2, 0} // 左移两格对付 I 形常见情况 }; for (int i 0; i 5; i) { Point testPos { pos.x kicks[i][0], pos.y kicks[i][1] }; if (!Collides(rotated, testPos)) { currentShape rotated; pos testPos; return true; } } return false; }官方标准规则里的踢墙表比这个复杂得多但如果是做个人项目或课程作业这套 5 偏移方案已经能覆盖 90% 的“旋转后卡边界”场景。我当时测试发现真正频繁触发踢墙的就是 I 形方块在左右两边旋转以及 T 形方块贴墙时旋转。给 I 形加上{-2,0}偏移后手感明显改善。2.3 碰撞检测的边界条件要一次写对碰撞检测是整个项目最容易出 bug 的地方。核心思路是把方块的当前格子坐标全部计算出来再逐个判断它在棋盘里是不是合法bool Collides(const std::vectorPoint shape, const Point pos) { for (const auto p : shape) { int gx pos.x p.x; int gy pos.y p.y; // 超出左右边界或掉出底部 if (gx 0 || gx COLS || gy ROWS) { return true; } // 落到底部之上且该位置已有方块 if (gy 0 board[gy][gx] ! 0) { return true; } // 如果 gy 0说明还在隐藏行区域不判碰撞 } return false; }这里千万要注意棋盘数组的 y 方向是往下增长的也就是board[0]是顶行board[ROWS-1]是底行。新手经常把二维数组的行列记反导致方块往右移动却变成往下掉这种问题排查起来特别浪费时间。我的经验是把“行 y 方向 垂直方向”这个关系写成注释贴在文件顶部。另一个隐蔽的坑是当gy 0时也就是方块刚生成、还没完全进入可见棋盘时如果去访问board[gy][gx]会导致数组越界。所以必须加gy 0这个前置判断。很多崩溃看似随机其实就是这几个负数坐标引起的。3. 主循环里的输入节奏、下落计时和行消除顺序游戏逻辑设计得再漂亮最后都要塞进主循环里。这里最讲究的是一个字节奏。俄罗斯方块的下落速度不是固定不变的它会随着消除行数增加而加快这部分需要你单独维护一个“等级/速度”映射。3.1 用固定时间步长控制下落而不是每帧直接减 ySFML 的主循环默认是“越快越好”吗不是。如果直接写成if (frame % N 0) moveDown()在高帧率显示器和普通 60Hz 显示器上会有完全不同的手感。正确做法是记录累计时间到达某个阈值才让方块下落一格sf::Clock clock; float fallTimer 0.f; float fallInterval 0.6f; // 初始每 0.6 秒下落一格 while (window.isOpen()) { float dt clock.restart().asSeconds(); fallTimer dt; // 处理事件和输入 // ... if (fallTimer fallInterval) { fallTimer - fallInterval; if (!Collides(currentShape, { currentPos.x, currentPos.y 1 })) { currentPos.y 1; } else { LockPiece(); } } // 渲染 }用这种“计时器累减”而不是“每 N 帧”的方式是因为dt已经是经过归一化的时间增量不管屏幕刷新率是 60、144 还是 240方块下落的真实速度都一致。这属于基本但非常重要的“帧率无关”思想以后做任何游戏都会用到。3.2 输入处理用 isKeyPressed 还是 EventSFML 里监听键盘有两种方式sf::Event::KeyPressed和sf::Keyboard::isKeyPressed。前者适合“只触发一次”的动作比如旋转、硬降后者适合“持续按住”的动作比如左右平移。我的实现里左右移动也做了“按住持续移动但带延迟”的处理否则玩家必须疯狂点按方向键非常累。核心节奏是按住 100ms 后进入连续移动模式之后每 50ms 移动一格。这种手感比较接近大多数商业俄罗斯方块。因为 SFML 没有内置重复延迟接口我手动维护每个方向键的“按住时长”计数器sf::Vector2f holdTime(0.f, 0.f); if (sf::Keyboard::isKeyPressed(sf::Keyboard::Left) || sf::Keyboard::isKeyPressed(sf::Keyboard::Right)) { float dir sf::Keyboard::isKeyPressed(sf::Keyboard::Left) ? -1.f : 1.f; holdTime.x dt; if (holdTime.x 0.12f repeatTimer 0.f) { TryMove(dir); repeatTimer 0.05f; } repeatTimer - dt; } else { holdTime.x 0.f; }这里的关键参数0.12f和0.05f可以直接当常量调整最终目标是手感不黏滞、不飘忽。如果你为了省事不做这个用事件流处理也要记得在 while 循环里清空事件队列否则窗口会失去响应。3.3 消除整行的完整顺序检查、清除、下移当方块落定后需要检查是否有某一行被完全填满。这里有一个 C 新手容易踩的坑如果你从上往下逐行清除并下移行号会因为数组元素搬移而错乱。所以正确做法是从下往上扫int ClearLines() { int lines 0; for (int row ROWS - 1; row 0; ) { bool full true; for (int col 0; col COLS; col) { if (board[row][col] 0) { full false; break; } } if (full) { // 将 [0, row) 的所有行整体下移一行 for (int r row; r 0; --r) { board[r] board[r - 1]; } // 顶行置空 for (int col 0; col COLS; col) { board[0][col] 0; } lines; // 注意row 不递减因为新下移的行还没检查 } else { --row; } } return lines; }这个row不递减的小细节是很多人消行消到一半出现“幽灵行”的原因。往下移动的过程看起来简单但一定要用“整行拷贝”而不是逐格拷贝否则效率虽然差别不大代码却容易写乱。我用的是board[r] board[r - 1]C 的std::vector支持整行赋值相当于一次内存拷贝简单可靠。3.4 锁定与结束条件别让方块在顶部卡死方块无法继续下移时会执行LockPiece()把当前方块的所有格子坐标写进board数组然后生成新方块。但这里存在一个隐藏问题如果新方块生成的初始位置就已经与其他方块碰撞游戏就该结束。几乎每个初学者都会把它写成“方块落到底部才结束”结果发现有时候棋局明明还很空旷却莫名其妙判定失败。我的判定逻辑是调用TrySpawnNewPiece()时先做一次碰撞检测如果新方块的初始位置不合法直接把gameOver置为 true。为了给玩家一点缓冲我还会在锁定后先检查一下是否超过顶行若只有一小截露在隐藏行里还能继续玩只有碰到“棋盘有效区域内的已有方块”时才结束。这个边界判断可以通过一个IsGameOver()函数封装起来避免在主循环里堆一堆判断。4. 上手实测后的难点颜色、渲染性能与手感调试逻辑层跑通之后剩下的是纯 SFML 绘制和手感微调。这部分工作量其实不小但每一步都能直接看到效果比较有成就感。4.1 用 RectangleShape 绘制但别为每个小块创建对象最直观的绘制方式是对着格子遍历生成sf::RectangleShape一个格子一个对象。但如果一帧里生成 200 个对象性能尚可一旦你后面想加残影、粒子、扫光特效就会开始卡顿。我的建议是用sf::VertexArray加上sf::Texture/纯色填充把所有矩形的顶点一次性提交给显卡。如果你还不想接触顶点数组那么退而求其次的做法是在初始化阶段就把所有可能出现的格子颜色预先创建好sf::RectangleShape模板比如shapeTile[1]代表红色、shapeTile[2]代表蓝色绘制时拷贝一份再改坐标。这样避免每帧动态new对象性能会稳很多。4.2 颜色索引让每个方块自带身份我前面特意用int棋盘而不是 bool就是为了能在消行时知道每个格子原本属于什么形状从而渲染出不同颜色。颜色索引的定义可以和形状数组顺序绑定// 7 种形状分别对应 0~61 后作为棋盘值 board[gy][gx] shapeIndex 1;绘制棋盘时for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { int idx board[row][col]; if (idx 0) continue; // 根据 idx 选择颜色 } }这里我踩过一个坑坐标映射错误导致画面里的方块和棋盘数组里的位置上下颠倒。因为棋盘数组第 0 行是最顶部但 SFML 窗口的y坐标是从上往下增加的这两个方向实际上是一致的——按理不会出错可是你要是把ROWS - 1 - row这种转换混进去就完全错乱了。我最后统一用GridToPixel(row, col)直接映射不搞任何花活反而最稳。4.3 硬降和下落的实现区别一个即时一个逐格按键“硬降”通常是空格应该是瞬间落到最底部并立刻锁定而不是快速逐格下落。逐格模拟会产生“软绵绵”的感觉。硬降的实现其实很简单while (!Collides(currentShape, { currentPos.x, currentPos.y 1 })) { currentPos.y 1; } LockPiece();但要注意硬降后立刻就要锁定不能再保留当前方块继续玩否则玩家可以在底端来回平移。很多新手实现里硬降后还能左右移动会让游戏出现“方块半截嵌进底排/墙面”的诡异状态。我实测后建议在硬降锁定时多调用一次消行检测这样动作更干净。4.4 调试工具没有网格线就别谈手感棋盘网格线对调试太重要了。没有网格线你很难直观判断方块是否对齐、偏移量是否准确。我建议把网格线作为常驻显示内容不做成可选项。网格线用sf::VertexArray(sf::Lines)画每一行、每一列一条短线段成本极低for (int col 0; col COLS; col) { // 横向线段从 (originY, originY ROWS * BLOCK_SIZE) // 纵向同理 }画好网格之后建议加一个“显示当前方块形状和位置”的调试 overlay比如在窗口标题栏打印当前坐标。实测下来这会让你在调踢墙偏移时省一半时间。4.5 手感调试才是重头戏逻辑全部写完后大约有一半的时间会花在“调参数”上初始下落间隔多少、硬降后的惩罚时间、按住方向键第一下和连续移动之间的延迟、踢墙偏移的顺序。我这里给一组我调完后觉得比较舒服的初值供参考参数初始值说明初始下落间隔0.6s第一关速度每次消行后减少0.025s最低不低于 0.05s方向键首次延迟0.12s按住多久后开始连续移动连续移动间隔0.05s按住后的重复移动频率硬降键空格瞬时到达底部显示网格线开启帮助判断对齐这些参数不要抄完就跑建议全部做成常量放在一个Config.h里随时调整。真正决定一个俄罗斯方块好不好玩的不是图形多华丽而是这些响应参数是否顺手。4.6 一个实战中容易忽略的渲染细节方块描边纯色方块之间没有描边时视觉上会黏成一片看不出网格结构的层次。给每个小方块加一条 1~2 像素的深色描边画面会立刻清晰许多。SFML 的RectangleShape自带的setOutlineColor和setOutlineThickness就能实现tile.setFillColor(fillColor); tile.setOutlineColor(sf::Color(30, 30, 30)); tile.setOutlineThickness(1.f);注意这个描边厚度是按像素算的和棋盘格子大小有关。如果你把BLOCK_SIZE调大setOutlineThickness也要相应调整否则描边看起来会过细。我习惯用BLOCK_SIZE / 16.f让它和格子尺寸关联。5. 扩展想法从“能玩”到“像样”的三件事基础版本跑起来之后你会发现还有很多插件式功能可以加。这几件事难度不大但对于游戏完整度的提升非常明显。5.1 幽灵方块落点预览“幽灵方块”是在当前位置正下方画一个半透明的落点预览本质就是模拟一下“如果一直下落到碰撞为止会在哪里”。因为碰撞检测已经写好了这个功能十几行代码就能完成Point ghostPos currentPos; while (!Collides(currentShape, { ghostPos.x, ghostPos.y 1 })) { ghostPos.y 1; }绘制的时候把透明度降到 0.3 左右不要挡住真实方块玩家就能一眼看出当前形状落点在哪里。没有这个预览新手玩家会频繁靠“来回左右移动”寻找落点体验差很多。5.2 消除动画别急着清除当检测到整行满时不要立刻把它们从数组里删掉而是先做一个短暂的“整行闪烁”动画比如 150ms 后再真正清行。这个机制比其他地方更麻烦因为棋盘数组始终是游戏判定的一部分搞出“动画状态”会让数据结构变得复杂。我的简化做法是维护一个flashingRows列表记录当前正在播放动画的行索引。在动画期间Collides()先忽略这些行防止新方块与“正在消失的方块”发生错误的碰撞判定。动画结束后再执行ClearLines()。实测下来这个机制加上之后整局游戏的反馈感会强很多。5.3 连击计数与分数俄罗斯方块的得分标准其实各家不太一样我自己用的是“一次消除 1/2/3/4 行分别得 100/300/500/800 分连续多次消行额外加乘”。因为消行逻辑已经返回了本次消除的行数lines做乘法加分非常容易int lineScore[5] {0, 100, 300, 500, 800}; score lineScore[lines] * comboMultiplier;这里有个细节一次性消 4 行也就是“Tetris”和分两次各消 2 行在分数上应该有明显区别这样才能鼓励玩家刻意堆叠出长条竖行。我目前给的线数分值已经体现了这种激励。分数和等级显示可以直接用sf::Text画在窗口右上角或者画在棋盘右侧的独立面板上。5.4 下一块预览与暂存“下一块”预览几乎是俄罗斯方块的标配。实现方式很简单在生成新方块之前先随机好下一块的索引存起来等当前方块锁定后再把它复制成当前方块同时再随机一个新预览。难点在于预览的位置和绘制比例——如果直接复用主棋盘里的大小会占掉很多窗口空间我习惯按 0.5 倍大小绘制在棋盘右侧。“暂存块”功能按住某个键把当前方块换到手边的临时槽里能极大提升策略性但实现时要注意“每回合只能暂存一次”的限制否则玩家会疯狂切换方块导致游戏失去连贯性。这个限制我用一个布尔标志位实现锁定或切换后重置。6. 我最后想说的话做这一个项目下来C 和 SFML 其实只占了一半的收获另一半是“如何把模糊的想法拆成清晰的数据结构和状态机”。俄罗斯方块明明是个小游戏但它逼着你思考网格坐标、碰撞检测、输入节奏、渲染和逻辑分离这些能力迁移到任何一个游戏项目里都通用。如果你正在做这个项目我建议先别急着追求特效和完美手感。第一版能跑通“下落、左右移、旋转、消行、结束”五件事就算成功然后再回头看看哪里的手感不对劲。你会发现改参数比改逻辑更容易改变游戏体验这也是为什么我坚持把所有的速度、延迟、间隔参数都集中放到了配置常量里。在我第二次从头写这个项目的时候我甚至先写了一个纯控制台版本的俄罗斯方块用字符#代替方块跑通全部逻辑后再套上 SFML 的渲染层。这个方法我想分享给每一个觉得图形编程太绕的人先把判定做对再让画面变好看一步到位反而容易陷入“不知道是逻辑错了还是绘制错了”的泥潭。聪明人不一定一次做对但一定会让犯错的地方足够小、足够好找。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑