ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++实现经典2048小游戏:核心算法与避坑指南

C++实现经典2048小游戏:核心算法与避坑指南 如果让我选一个最适合用来练手 C 的经典小项目2048 绝对排前三。棋盘就一个 4×4 二维数组规则几句话能讲完但真正实现起来却能踩出一堆 C 的细节坑数组传参退化、随机数种子、控制台方向键读取、中文乱码、合并逻辑的边界条件……把这些坑都趟平了你对 C 的理解会比啃十遍语法书都管用。这篇文章我打算按一个完整的项目开发过程来讲从环境准备到数据结构设计从核心的移动合并算法到控制台交互再到我实际测试中踩过的坑和后续可以扩展的方向。不管你是课设需要、面试准备还是纯粹想用一个小游戏验证自己的 C 学习成果都可以直接照着撸一遍。代码量不大重点在思路。1. 开发环境选择与项目规划先想清楚再动手1.1 编译器选型VS Code 也可以但别再折腾半天环境搜 C 相关内容时经常会看到“error: microsoft visual c 14.0 or greater is required”这类报错很多新手一看到 MSVC、Build Tools、MinGW 就被绕晕了。这里我直接说结论写 2048 这种纯控制台小程序你只需要一个能编译 C11 的编译器不需要任何图形库。三个常见方案我帮你对比一下方案适合人群优点需要处理的问题Visual Studio CommunityWindows纯新手装完直接用F5 跑起来不需要手动配环境安装包比较大VS Code MinGW-w64Windows喜欢折腾编辑器的人轻量界面舒服需要手动配置 tasks.json 和 launch.json编译器要加 PATHg / clangmacOS / Linux有一定命令行基础一条命令编译最接近“真实开发”终端操作对纯新手有门槛我给新手的建议是如果目标只是把 2048 写出来、把 C 语法跑通直接用 Visual Studio Community新建一个“控制台应用”项目选 C 项目类型代码写进去直接编译就行省下的时间拿去调游戏逻辑不香吗。如果你确实想用 VS Code这里给一个最小可行的配置思路安装 C/C 扩展后在.vscode/tasks.json里配置编译任务参数大概是{ version: 2.0.0, tasks: [ { label: build 2048, type: shell, command: g, args: [-stdc11, -o, 2048.exe, 2048.cpp], group: build } ] }调试配置launch.json指向生成的 exe 即可。这套东西一次性配好后面所有练习项目都能复用。1.2 功能拆解2048 本质上只需要五个模块写代码之前最关键的一步是把游戏规则翻译成程序逻辑。2048 的游戏规则其实可以用五句话覆盖棋盘是 4×4每个格子要么是空要么是 2 的若干次幂。玩家按方向键所有方块朝该方向移动中间不留空位。两个值相同的方块在移动方向相遇时合并成一个数值翻倍且一次移动中不能连续合并。每次有效移动后在空白位置随机生成一个新方块通常 90% 是 210% 是 4。当没有任何空位、且上下左右都没有可合并的相邻方块时游戏结束合出 2048 则胜利。对应到代码模块五个部分就够了棋盘模块用一个int board[4][4]表示棋盘状态用score记录累计得分生成模块负责在空格子随机生成 2 或 4移动模块处理四个方向的移动和合并这是整个游戏的核心判定模块判断是否胜利、是否无路可走渲染交互模块控制台输出棋盘、读取方向键输入。模块划分清楚之后你写的就不是“一坨代码”而是可以单独测试的零件。我实际写的时候就是先把移动逻辑拎出来用一个硬编码的棋盘测试确定没问题了再接入主循环排查问题的效率高很多。1.3 核心常量与状态设计把会变的东西都提到上面写 2048 的时候棋盘尺寸4、胜利目标值2048、新方块生成概率这些都应该定义成常量不要写死在代码各个角落。原因很简单第一以后想改成 5×5 或者 4096 目标值只改一个地方第二面试或者课设答辩时这能体现你写出可维护代码的意识。我习惯这样组织全局状态const int SIZE 4; // 棋盘边长 const int WIN_VALUE 2048; // 胜利目标 const int BOARD_BYTES SIZE * SIZE * sizeof(int); int board[SIZE][SIZE]; // 棋盘数组 int score 0; // 当前得分 bool isWin false; // 是否已经达成胜利条件 bool isOver false; // 是否游戏结束这里有个 C 新手特别容易踩的坑board[4][4]如果作为函数参数传递会退化成指向一维数组的指针sizeof(board)拿到的就不是整个棋盘的大小而是4 * sizeof(int)或者甚至是指针大小。所以我的做法是尽量在全局直接访问board保持简单如果哪天你想把棋盘封装成类就不要再对board用sizeof而是明确用SIZE * SIZE来控制循环和清空范围。2. 2048 移动合并算法整个项目的灵魂2.1 规则落地的关键把“移动 合并”拆成两步先说一个很多教程里没讲透彻的地方一行方块朝一个方向移动正确的处理方式是“压缩 → 合并 → 再压缩”。以向左移动为例将一行[2, 0, 2, 4]向左推第一次压缩把非零元素全部靠左塞满前面的位置得到[2, 2, 4, 0]合并扫描从左到右比较相邻元素如果相等就合并到左边那个位置右边清零。比较2和2相等合并成4得到[4, 0, 4, 0]第二次压缩再次把非零元素靠左得到[4, 4, 0, 0]。为什么合并之后还要再压缩一遍因为合并过程中会产生“空洞”。比如[2, 2, 2, 2]如果只做合并扫描第一次比较合并后变成[4, 0, 2, 2]继续扫描到后面2和2又合并成4最终得到[4, 0, 4, 0]中间就留了一个 0 的空位。必须再用一次压缩把所有数字顶到边上棋盘的布局才符合 2048 的物理直觉。我一开始写的时候直接漏掉了第二次压缩结果跑出来的棋盘中间总是有空位方块没有完全靠边看起来非常奇怪。这个细节是排查过程中印象最深的一个问题。2.2 单行处理方法四行共用同一套逻辑既然规则是对“每一行”做的那我们就把“处理一行”的逻辑提取成函数。我自己用的写法是void slideRow(int row[]) { // 1. 压缩非零元素全部靠左 int tmp[SIZE] {0}; int idx 0; for (int i 0; i SIZE; i) { if (row[i] ! 0) { tmp[idx] row[i]; } } // 2. 合并相邻相同元素合并到左侧 for (int i 0; i SIZE - 1; i) { if (tmp[i] ! 0 tmp[i] tmp[i 1]) { tmp[i] * 2; score tmp[i]; // 合并得分 tmp[i 1] 0; } } // 3. 再次压缩去掉合并产生的空洞 idx 0; for (int i 0; i SIZE; i) { if (tmp[i] ! 0) { row[idx] tmp[i]; } } for (int i idx; i SIZE; i) { row[i] 0; } }这样左移的时候只要对四行分别调用slideRow(board[i])就可以了。注意这一步里没有做“每个方块只能合并一次”的特殊标记因为“压缩-合并-再压缩”这个流程天然保证了每个原位置的方块最多参与一次合并——合并完右侧变 0后续扫描就不会再碰到它了。2.3 四个方向只写一套代码矩阵旋转的妙用如果说“处理行”是 2048 的第一层抽象那么“四个方向”就是第二层。最直观的写法是把左、右、上、下各写一个函数四个方向复制四份。但你会发现四个函数的逻辑几乎一模一样只是数组遍历的行列顺序不同。更好的方案是把棋盘旋转一下统一当成“左移”处理处理完再转回来。顺时针旋转 90 度的逻辑void rotateBoard() { int tmp[SIZE][SIZE]; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { tmp[j][SIZE - 1 - i] board[i][j]; } } for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { board[i][j] tmp[i][j]; } } }然后移动的入口函数就可以这样设计void move(int dir) { // dir: 0左, 1上, 2右, 3下 switch (dir) { case 1: rotateBoard(); break; // 上移 - 旋转后左移 case 2: rotateBoard(); rotateBoard(); break; // 右移 - 旋转180度 case 3: rotateBoard(); rotateBoard(); rotateBoard(); break; // 下移 } bool changed false; for (int i 0; i SIZE; i) { int before[SIZE]; for (int j 0; j SIZE; j) before[j] board[i][j]; slideRow(board[i]); for (int j 0; j SIZE; j) { if (before[j] ! board[i][j]) { changed true; break; } } } // 转回原始方向 switch (dir) { case 1: rotateBoard(); rotateBoard(); rotateBoard(); break; case 2: rotateBoard(); rotateBoard(); break; case 3: rotateBoard(); break; } if (changed) { spawnTile(); // 只有棋盘发生变化时才生成新方块 } }这个方案的隐含收益是你不必为四个方向分别调试。旋转函数只要写对一次四个方向的移动就全部正常代码维护成本直线下降。对于这种 4×4 的小棋盘旋转产生的开销完全忽略不计。这里的changed变量也很关键。2048 的规则是“每次有效移动后才生成新方块”如果玩家在方块已经全部靠左的情况下还按左键棋盘没有任何变化不应该生成新方块。我用before数组对比移动前后的每一行只要有一行发生变化就认为这次移动有效。3. 完整源码模块拆解从随机生成到胜负判定3.1 棋盘初始化与随机生成两个细节很重要开局时棋盘上应该有两个初始方块。初始化函数我这样写void initGame() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { board[i][j] 0; } } score 0; isWin false; isOver false; spawnTile(); spawnTile(); }新方块生成函数我建议第一步先收集所有空位再从中随机选一个。不要写成“随机选行再随机选列如果非空就重试”因为这种重试法在棋盘快满时效率很低而且概率分布不均匀void spawnTile() { int emptyPos[SIZE * SIZE][2]; int emptyCount 0; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) { emptyPos[emptyCount][0] i; emptyPos[emptyCount][1] j; emptyCount; } } } if (emptyCount 0) return; int pos rand() % emptyCount; int value (rand() % 10 9) ? 2 : 4; // 90% 生成 210% 生成 4 board[emptyPos[pos][0]][emptyPos[pos][1]] value; }随机数方面经典 2048 是 90% 概率生成 210% 生成 4。rand()的随机性虽然一般但做游戏完全够用。记得在main里用srand(time(NULL))设置种子否则每次启动游戏生成的序列都一样。3.2 胜负判定这可是最容易想简单的地方很多新手写判断结束只检查“棋盘满了没”但这其实不严谨。2048 的结束条件是“满了”并且“上下左右都没有相邻相等的方块”。比如下面这种状态2 4 2 4 4 2 4 2 2 4 2 4 4 2 4 0棋盘虽然没满但如果没有任何空格且怎么移都合并不了才会结束。正确的判定逻辑是先找有没有空格有空格说明还能生成新方块没结束再检查每一行、每一列是否存在相邻相等只要有任意一对相邻相等就还能合并也没结束。只有当两者都不满足时游戏才真正结束。bool isGameOver() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) return false; if (j 1 SIZE board[i][j] board[i][j 1]) return false; if (i 1 SIZE board[i][j] board[i 1][j]) return false; } } return true; }胜利判定相对简单每次移动后扫描全盘看是否出现了不小于WIN_VALUE的方块bool checkWin() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] WIN_VALUE) return true; } } return false; }顺带一提这里用而不是是因为合出 2048 之后继续玩方块可能变成 4096、8192它们显然也算“已经达到过胜利条件”。3.3 主循环结构把游戏状态机跑起来游戏主循环可以设计得非常简单本质就是一个 while 循环渲染当前棋盘读输入执行移动然后判定状态。我用的结构是int main() { srand(time(NULL)); initGame(); while (true) { clearScreen(); printBoard(); if (isGameOver()) { cout Game Over! Final Score: score endl; break; } int dir getDirection(); if (dir -1) continue; // 无效按键 if (dir 4) break; // 退出游戏 move(dir); if (checkWin()) { clearScreen(); printBoard(); cout You Win! Score: score endl; cout Continue? (y/n): ; char ch; cin ch; if (ch ! y ch ! Y) break; isWin true; // 把胜利标志置位防止每次移动都触发 } } return 0; }这里有一个很实际的体验问题如果玩家合出 2048 后不想退出还想继续挑战更高数字怎么办我的处理是询问是否继续继续之后把isWin标志置位避免每次移动后都再次弹胜利提示。你也可以做成“达到 2048 后直接结束”但那样游戏的扩展性就差了很多。4. 控制台渲染与方向键输入细节决定体验4.1 棋盘输出setw 对齐和中文乱码的那些事控制台游戏也是要讲“UI 体验”的。如果输出像下面这样2 4 0 0虽然信息没丢但数字长短不一时对齐很丑。用iomanip的setw可以很轻松地解决void printBoard() { cout Score: score endl; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { cout setw(5) board[i][j]; } cout endl endl; } }setw(5)会保证每个输出至少占 5 个字符宽度数字位数不同的情况也能整齐对齐。再来说一个真坑中文乱码。如果你用的是 Windows 控制台编译器以 UTF-8 编码保存源码但控制台默认按 GBK 显示中文就会打印出乱码。解决办法有几个游戏内部全用英文输出最简单最不容易出问题在main最开始调用system(chcp 65001 nul)切换到 UTF-8 代码页用 Visual Studio 时把源码文件保存为带 BOM 的 UTF-8。我自己写小项目时图省事界面用英文。你要是在课设演示时想用中文界面推荐system(chcp 65001)这个方法实测在 Win10 的 Windows Terminal 里效果没问题。4.2 方向键读取_getch() 为什么返回两个字节控制台游戏读方向键最常用的函数是conio.h里的_getch()gcc 环境下可能叫getch()。它不需要按回车键就能立即拿到用户按键很适合做实时输入。但方向键比较特殊按一次方向键_getch()会返回两次结果。第一次返回 224某些环境是 0代表“这是一个扩展键”第二次返回具体的扫描码上 72、下 80、左 75、右 77。int getDirection() { int key _getch(); if (key 224 || key 0) { key _getch(); switch (key) { case 72: return 1; // 上 case 80: return 3; // 下 case 75: return 0; // 左 case 77: return 2; // 右 } } // 兼容 WASD if (key w || key W) return 1; if (key s || key S) return 3; if (key a || key A) return 0; if (key d || key D) return 2; if (key q || key Q) return 4; return -1; }我建议在按键处理里同时支持 WASD 和方向键。原因是很多 IDE 的内嵌控制台对方向键的扫描码处理得很奇怪我遇到过在 Visual Studio 调试窗口里方向键完全无效但 WASD 能正常用的情况。两套方案都做走到哪里都不慌。4.3 清屏与跨平台宏一句 system 就能搞定清屏是控制台游戏绕不开的操作。Windows 用system(cls)Linux/macOS 用system(clear)。像我这样主要用 Windows 的人可能觉得无所谓但代码万一拿到别的环境编译就报错了。一个小宏就能解决#ifdef _WIN32 #define CLEAR_SCREEN() system(cls) #else #define CLEAR_SCREEN() system(clear) #endif写完之后在main里用CLEAR_SCREEN()代替system(cls)就能保持跨平台兼容。5. 实测中的边界问题与后续扩展思路5.1 最容易被忽略的合并边界一行四个相同方块怎么处理先给结果2 2 2 2向左合并后应该得到4 4 0 0而不是8 0 0 0。这是 2048 最基本的一条隐藏规则——“一次滑动中一个方块只能参与一次合并”。如果按“直接从头扫到尾就地合并”的思路写很容易得出错误的8。我的“压缩-合并-再压缩”流程为什么天然正确因为第一次压缩后所有非零元素靠左合并扫描从左到右当i位置和i1位置合并后i1位置变成 0后续扫描再往后走不会回头二次合并。某个历史版本的[2,2,2,2]执行过程可以自己手推一遍能彻底理解这个规则。5.2 随机数种子与可复现性测试时的一个小技巧rand()生成的随机数序列实际上一个固定序列只不过每次启动游戏前用srand(time(NULL))把它“搅”得不同。这在正常游戏时没问题但测试时有个麻烦你没法复现一个恰好需要某种局面的场景。我测试时的做法是临时把srand(time(NULL))换成固定数字比如srand(42)这样每次启动游戏生成的新方块位置和数值都一模一样。等验证完逻辑再改回时间种子。你要是在写 AI 相关功能时也需要这种可控的随机可以留着这个思路。5.3 进阶方向从“能玩”到“玩得漂亮”核心功能跑通之后可以按兴趣选几个方向继续加深存档系统把board二维数组和score写入文本文件下次启动读回来。这是 C 文件流ifstream/ofstream的绝佳练习。AI 自动玩只需要写一个评估函数比如对每个方块根据它在棋盘上的位置给权重再加上空位数、相邻相同方块对数然后做深度优先搜索或者简单的期望值最大化。2048 的搜索空间不算大AI 做到两三千分并不难。彩色输出Windows 10 以上的终端支持 ANSI 转义码。不同数字给不同颜色视觉效果好很多。图形化把控制台版改成 SFML 或 SDL 图形版。移动逻辑完全不用动只需要加一个渲染层。这也是体会“逻辑与表现分离”设计思想的好机会。5.4 我对这个项目的整体评价如果目标是把 C 的基础语法吃透2048 覆盖的知识点非常全面数组与多维数组、循环与分支、函数封装、随机数、输入输出、还有一点字符串处理和简单的状态机设计。如果目标是练习代码组织能力这个项目又不会大到让人半途而废。我见过不少学生拿它当课程设计也见过有人用它作为面试时展示代码风格的项目确实是一个成本低、收益高的练手选择。最后分享一个我自己改代码时用的技巧先把SIZE临时改成 3 或者 5跑一遍看算法是否还能正常工作。如果移动和合并逻辑在 3×3 棋盘上依然正确说明你的核心逻辑没有把 4 这个魔法数字写死在代码里如果一改就出问题恭喜你找到了下一处需要重构的地方。这个“改参数验证”的习惯比多写一千行代码都值钱。
返回列表