十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用C++编写2048小游戏:数组设计、合并算法与实现全解析

用C++编写2048小游戏:数组设计、合并算法与实现全解析 翻到两年前写的2048小游戏代码忍不住还是觉得这个项目对C新手来说太合适了。当时我刚学完C语法指针、引用、vector这些概念背得滚瓜烂熟但让我独立写点东西就发懵。后来在地铁上玩了几把2048突然意识到这个游戏逻辑并不复杂用C在命令行里做一版应该不难——这个念头让我花掉了两个完整的周末却也真正把C的语法从头到尾用了个遍。这篇文章就把整个2048小游戏的编写过程完整拆开从怎么设计数据结构到怎么处理移动合并的核心算法再到控制台界面怎么渲染最后附上完整的C代码和编译运行方法。不管你是刚学完C基础语法、想找一个能落地的小项目还是面试前想复习一下数组和状态机相关的写法这篇都能直接拿来参考。1. 为什么用C写2048需求分析与整体设计1.1 这个项目到底在练什么先聊聊这个项目本身的定位。2048的规则一句话就能说完4乘4棋盘滑动让相同数字碰撞合并每次滑动后随机位置生成一个2或4棋盘被填满且无法合并时游戏结束。规则越简单越适合拿来当练手项目。很多新手学C最大的困惑是“语法都会但不知道能做什么”。图书管理系统、学生成绩管理这类项目确实经典但坦白讲有点枯燥。2048不一样玩过的人多、规则零门槛、代码量又在几百行的规模内。写这个项目的过程等于把C里最常用的一批技能点全部过了一遍二维数组的定义与遍历这是棋盘数据结构的核心函数的拆分与模块化把一个完整游戏拆成初始化、渲染、移动、判定等函数随机数的用法每次滑动后在空位生成新方块控制台输入输出的处理包括方向键读取和界面刷新。比如我当年写的时候最大的收益是彻底搞懂了“函数到底该怎么拆”。你会很自然地发现如果把所有逻辑堆在主函数里代码会迅速膨胀到没法调试。拆成函数之后每改一个地方都不用从头捋一遍这种体感是刷题刷不出来的。1.2 整体架构与模块划分动手之前先明确一下这个项目由哪几个部分组成。我实现的版本分为下面这些模块模块职责核心函数初始化生成棋盘、开局随机两个方块init、spawnBlock输入处理读取方向键和退出指令handleInput移动合并核心逻辑将某个方向的格子滑动并合并move、moveLeft、rotateClockwise随机生成在空位生成2或4spawnBlock状态判定判断游戏是否结束、是否达成2048isGameOver、胜利检测界面渲染把棋盘数组打印到控制台drawBoard这个设计是照着“一局游戏的标准流程”来的初始化、等待输入、执行移动、生成新块、渲染、判断结束。每个步骤是独立函数主循环只需要把这些函数串起来就行。采用这种拆分方式的直接原因是可调试性。2048最麻烦的问题不是写不出来而是出bug之后不好定位。如果把方向键读取、移动算法、渲染逻辑全部塞在一起棋盘一旦错乱很难判断是哪个环节出了偏差。拆开之后每个函数只干一件事单独测试一个方向的移动逻辑也就顺手很多。关于二维数组的初始化多说一句。棋盘一开始全部填0之后每次生成一个2或4这个设计让判断空格变得非常简单只要值是0就是一个可生成新方块的位置。我见过有人用-1表示空位也能跑但0的判断明显更符合直觉打印空位的时候单独处理一下显示效果就行。2. 移动合并算法核心思路与数据结构2.1 棋盘用二维数组还是vector棋盘用int board[4][4]就够了没必要用vector。固定大小意味着可以避免动态分配的开销而且旋转、搬运数据时用memcpy一类操作也很方便。如果用vector代码写起来会更“现代”但在这种固定规模场景下不会带来多少额外收益。当然如果以后想扩展成动态尺寸的棋盘比如5乘5、6乘6再把数组换成vector也是水到渠成的事移动算法的核心思路不变。这里选固定数组主要是为了让代码对新手更友好索引访问和整体拷贝都直观。2.2 怎么把四种滑动方向统一成一种2048最核心的算法是“移动合并”。四个方向的移动逻辑本质是一样的把非零数字向目标方向靠拢相邻相同数字合并成一个。如果为上、下、左、右分别写一套逻辑代码会变得又长又容易错。我自己实验过四个方向分开写大概要160行以上而且调试的时候要在四个方向之间来回比对着改。更聪明的做法是“旋转矩阵”。以“向左移动”为基准把其他三个方向通过旋转后统一处理具体对应关系是左移不做处理直接对每行执行左移右移把棋盘旋转180度执行左移再旋转180度上移逆时针旋转90度执行左移再顺时针旋转90度下移顺时针旋转90度执行左移再逆时针旋转90度。这样只需要维护一个“单行左移”的核心函数其他方向全是“旋转左移旋转回去”的组合逻辑集中了问题自然好定位。旋转函数本身也不需要花哨直接用一个临时数组做坐标映射void rotateClockwise() { 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]; } } }旋转的本质是坐标变换把(i, j)映射到(j, SIZE - 1 - i)。这个公式可以自己在纸上画一个2乘2矩阵推一遍左上角会旋转到右上角右上角会旋转到右下角方向是顺时针的。整个过程用到一个临时数组这是为了避免原地覆盖数据。2.3 单行合并的实现细节单行左移是整个游戏逻辑的核心也是新手最容易写错的地方。这里的关键点有两个第一是非零数字要全部靠左第二是相邻相同数字合并并且“同一行一次滑动中最多只能合并一次”。举个例子一行是2 2 2 2滑动后结果应该是4 4 0 0而不是8 0 0 0。一行是4 4 8滑动后是8 8 0一行是2 2 4 4滑动后是4 8 0 0。如果从左边开始连续合并就会做出一行2 2 2 2滑成8的bug这是最常见的翻车点。我把单行处理拆成三步压缩、合并、补零。第一步压缩把这一行的非零数字按顺序提取出来形成一个没有空位的新数组。 第二步合并从左往右扫描这个数组如果当前数字和下一个数字相等就合并成一个当前数字乘2分数累加然后跳过下一个数字。 第三步补零长度不够4的用0补满最后整体写回原棋盘。对应的代码是bool moveLeft() { bool changed false; for (int i 0; i SIZE; i) { int row[SIZE]; int n 0; for (int j 0; j SIZE; j) { if (board[i][j] ! 0) { row[n] board[i][j]; } } int merged[SIZE]; int m 0; for (int j 0; j n; j) { if (j 1 n row[j] row[j 1]) { merged[m] row[j] * 2; score row[j] * 2; j; } else { merged[m] row[j]; } } while (m SIZE) merged[m] 0; for (int j 0; j SIZE; j) { if (board[i][j] ! merged[j]) changed true; board[i][j] merged[j]; } } return changed; }合并时为什么要跳过下一个数字核心就在“同一行一次滑动中最多只能合并一次”。比如2 2 2 2从左往右合并索引0和1得到4如果继续用合并后的4去和索引2的2比较就会错必须跳过它去比较索引2和索引3最终得到4 4。这个细节是2048规则里非常容易被忽略的地方也是我在测试中发现很多初版实现会踩中的坑。函数结束时返回一个bool类型的changed表示这一行有没有发生实际变化。这个返回值会传给上层如果一次移动后棋盘完全没变化就不生成新方块如果完全没变化说明这个方向键按了也白按这个细节在玩的时候体验差异很大。3. 完整游戏循环随机生成、胜负判定与输入响应3.1 开局初始化与随机方块生成游戏开局时棋盘的16个格子全是0。我的做法是调用spawnBlock两次让开局就有两个可用的初始方块。每次spawnBlock做两件事先扫描所有值为0的格子把它们的位置收集到一个数组里然后用随机数从中选一个位置填入新方块。新方块的值不是等概率的按照2048的官方规则90%的概率生成210%的概率生成4。代码上就是int val (rand() % 10 0) ? 4 : 2;srand要在main函数最前面调用一次用当前时间做种子这样每次运行游戏的随机序列才不一样。容易踩的坑是有人会在spawnBlock内部反复调用srand这样一来同一个程序内多次调用随机结果反而会被时间种子重置出现“新方块总是出现在同一个位置”之类的诡异现象。srand只在程序入口调用一次就够了。3.2 怎么判断游戏还能不能继续游戏结束的条件是棋盘没有空格且任意相邻的两个格子都不相等。这里“相邻”包括上下和左右两个方向。等价写法是“还能不能移动”只要存在至少一个空格就一定还能继续或者存在任意两个相邻的相同数字也一定能通过一次滑动合并出空格。对应的函数是bool isGameOver() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) return false; if (i 1 SIZE board[i][j] board[i 1][j]) return false; if (j 1 SIZE board[i][j] board[i][j 1]) return false; } } return true; }这个判定的思路是先找“还能继续”的证据一旦找到就直接返回false全程遍历完之后还没找到才能确定游戏真正结束。这种先否定再肯定的写法比直接尝试枚举所有移动方向是否有效要简单不少。3.3 控制台方向键读取在Windows控制台里要实现方向键操作最常用的方案是读conio.h中的_getch函数。麻烦的是方向键不是单个字符而是一个前缀加上一个扫描码按上方向键时_getch第一次返回224或者0第二次才返回真正的方向码72。所以我专门写了一个处理函数int handleInput() { int ch _getch(); if (ch 224 || ch 0) { ch _getch(); switch (ch) { case 72: return 0; // 上 case 80: return 1; // 下 case 75: return 2; // 左 case 77: return 3; // 右 default: return -2; // 无效输入 } } if (ch q || ch Q) return -1; // 退出 return -2; }这个函数是一个很好的“状态机”示例。如果你见过别人写的键盘控制小游戏多半都会遇到这种“两个字符代表一个键位”的情况提前理解它对后面做其他控制台项目也很有帮助。注意这套方案是Windows专属的Linux/macOS的终端里通常没有conio.h。如果你在macOS或者Linux上编译这份代码会直接报错。后面我会在第5节给出一些替代方案。4. 控制台渲染把数组变成能玩的界面4.1 先解决对齐再谈美观棋盘在控制台里的显示本质就是把二维数组按照某种格式打印出来。看起来简单但直接cout输出的话数字位数不同会导致对不齐比如2和1024的宽度完全不同画面会歪得没法看。我是用iomanip中的setw来固定每个格子占6个字符宽度并且用边框线把格子分隔开。这样不管数字多长都能保持表格形状void drawBoard() { system(cls); cout Score: score \n\n; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) cout ------; cout \n; for (int j 0; j SIZE; j) { cout |; if (board[i][j] 0) cout ; else cout setw(6) board[i][j]; } cout |\n; } for (int j 0; j SIZE; j) cout ------; cout \n\n; }setw(6)是右对齐所以数字会贴右侧看起来比较整齐。如果你喜欢居中可以自己写一个居中对齐函数但整体工作量不大多数人用默认右对齐就够了。4.2 清屏、闪烁与低成本彩色渲染很多初学者会直接用system(cls)清屏然后在下一帧重新打印。这个方式的优点是简单缺点也明显每次清屏控制台会闪烁一下而且system函数本质上是在启动一个新的命令行进程来执行清屏命令性能开销远比你想象的大。如果你只是做一个小游戏闪烁问题不致命但体验确实会打折扣。更平滑的做法是“光标复位”也就是把光标移动到控制台左上角重新打印棋盘避免清空整屏。Windows下可以用SetConsoleCursorPosition这也是我最后采用的方案。至于颜色Windows控制台可以通过SetConsoleTextAttribute来改变文字颜色。不同数字用不同颜色界面会一下好看很多。我在这版代码里保留了最基础的配色逻辑2是白色4是绿色8是黄色16和32是红色再大的数字换蓝底之类。实际效果就是看着不累玩起来舒服一些。做这些优化一共只需要几十行代码性价比很高。这里有一个小的体验问题如果你在程序里输出了中文字符Windows控制台默认的代码页是GBK而源代码文件可能是UTF-8编码混起来很容易出现乱码。最简单的做法是程序开头执行一次system(chcp 65001 nul)切换到UTF-8代码页或者干脆全部用英文提示。我在这版代码里全部用英文就是为了避开这个坑。5. 完整代码与编译运行直接拿走可用5.1 全量代码下面把我最后整理的完整版本放出来这条代码在Windows下用VS2022或者MinGW都可以直接编译运行。#include iostream #include iomanip #include cstdlib #include cstring #include ctime #include conio.h #include windows.h using namespace std; const int SIZE 4; int board[SIZE][SIZE]; long long score 0; void rotateClockwise() { 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]; } } } bool moveLeft() { bool changed false; for (int i 0; i SIZE; i) { int row[SIZE]; int n 0; for (int j 0; j SIZE; j) { if (board[i][j] ! 0) { row[n] board[i][j]; } } int merged[SIZE]; int m 0; for (int j 0; j n; j) { if (j 1 n row[j] row[j 1]) { merged[m] row[j] * 2; score row[j] * 2; j; } else { merged[m] row[j]; } } while (m SIZE) merged[m] 0; for (int j 0; j SIZE; j) { if (board[i][j] ! merged[j]) changed true; board[i][j] merged[j]; } } return changed; } bool move(int dir) { switch (dir) { case 0: rotateClockwise(); rotateClockwise(); rotateClockwise(); break; case 1: rotateClockwise(); break; case 3: rotateClockwise(); rotateClockwise(); break; default: break; } bool changed moveLeft(); switch (dir) { case 0: rotateClockwise(); break; case 1: rotateClockwise(); rotateClockwise(); rotateClockwise(); break; case 3: rotateClockwise(); rotateClockwise(); break; default: break; } return changed; } bool hasEmpty() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) return true; } } return false; } void spawnBlock() { if (!hasEmpty()) return; int emptyCells[SIZE * SIZE][2]; int cnt 0; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) { emptyCells[cnt][0] i; emptyCells[cnt][1] j; cnt; } } } int idx rand() % cnt; board[emptyCells[idx][0]][emptyCells[idx][1]] (rand() % 10 0) ? 4 : 2; } bool isGameOver() { for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] 0) return false; if (i 1 SIZE board[i][j] board[i 1][j]) return false; if (j 1 SIZE board[i][j] board[i][j 1]) return false; } } return true; } void drawBoard() { system(cls); cout Score: score \n\n; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) cout ------; cout \n; for (int j 0; j SIZE; j) { cout |; if (board[i][j] 0) { cout ; } else { cout setw(6) board[i][j]; } } cout |\n; } for (int j 0; j SIZE; j) cout ------; cout \n\n; } int handleInput() { int ch _getch(); if (ch 224 || ch 0) { ch _getch(); switch (ch) { case 72: return 0; case 80: return 1; case 75: return 2; case 77: return 3; default: return -2; } } if (ch q || ch Q) return -1; return -2; } int main() { srand(static_castunsigned(time(NULL))); memset(board, 0, sizeof(board)); spawnBlock(); spawnBlock(); drawBoard(); while (true) { int dir handleInput(); if (dir -1) break; if (dir -2) continue; bool changed move(dir); if (changed) spawnBlock(); drawBoard(); bool win false; for (int i 0; i SIZE !win; i) { for (int j 0; j SIZE !win; j) { if (board[i][j] 2048) win true; } } if (win) { cout You Win! Press any key to continue...\n; _getch(); } if (isGameOver()) { cout Game Over! Press any key to exit...\n; _getch(); break; } } return 0; }这段代码的移动方向设定是0上、1下、2左、3右q退出。运行之后你就可以在控制台里玩上一整局达到2048会提示胜利无路可走时提示游戏结束。5.2 编译运行与常见环境问题编译本身不复杂我常用的命令是g 2048.cpp -o 2048 -stdc11在VS2022里新建一个空C控制台应用把代码粘贴进去直接按F5运行。如果用MSVC的cl命令行则是cl 2048.cpp /EHsc这里有一个很多新手会遇到的报错“error: microsoft visual c 14.0 or greater is required.”。这句话字面意思是“需要14.0或更高版本的Microsoft Visual C”但你首先要搞清楚它根本不是编译C程序时报的错而是写Python扩展模块时pip在安装带C扩展的包时找不到MSVC编译器导致的。如果你是在跑C项目而不是pip install多半不会遇到这个提示。真正遇到的话装一个VS2022 Build Tools基本就能解决路径是Visual Studio Installer里勾选“使用C的桌面开发”。还有一个常见问题是运行生成的exe时提示msvcp140.dll缺失。这个dll属于微软的Visual C运行库不是程序本身的bug。解决办法是到微软官网下载Visual C Redistributablevcredist_x64.exe安装一遍装完基本就一路顺畅了。6. 踩坑记录与优化方向6.1 我在开发中踩过的三个坑这个项目虽然不大但我在实现和帮别人调试的过程中遇到过不少重复的问题挑三个典型的分享一下。第一个坑是前面提过的“重复合并”。一行2 2 2 2第一版代码从左往右连续合并直接合并成16结果是数值暴涨但棋盘没满这是最容易被新手忽略的规则细节。第二版代码修正后变成了4 4 0 0这一步做完一整局玩下来都不会再出错。第二个坑是“移动后没变化也生成新方块”。这个会带来幽灵方块你在无处可去的方向按了一下本来不该有变化结果莫名其妙多出一个新方块把布局打乱。加一个changed返回值把“实际有没有变化”反馈到主循环问题就不存在了。第三个坑是随机数种子。我第一次在spawnBlock里面调用srand结果游戏每次重新开始后生成的方块位置似乎总是一样的。调试了两三个小时才意识到是srand被反复重置导致的。这个教训挺值钱的随机种子设置一次就好别放在会被重复调用的函数里。6.2 下一步可以怎么扩展能做到这一步基础版的2048已经完成。如果你想继续加点东西我建议按下面的顺序扩展每加一个功能都能学到新东西存档系统退出时把棋盘数据写进文件下次进入时读回。涉及文件读写和序列化可以练习fstream。撤销功能用一个栈记录历史棋盘按一次撤销键就回退一步。这是“stack”这个数据结构的典型应用。AI自动玩实现一个简单的启发式算法让程序自己决定往哪个方向走。哪怕是最朴素的“优先合大数”“不往上走”规则也能跑到一两千分。Web版移植把核心算法抽出来放到前端JavaScript里可以顺便对比一下C和JS在实现同一个逻辑时代码结构的差别。大小可变的棋盘把SIZE改成可配置参数需要把固定数组改成动态数组或者vector核心算法不变但你会对“固定大小”和“动态大小”两种模式有更直观的认识。我自己的体会是这个项目写到能跑只是第一步真正值钱的是你在拆功能、调bug、加功能的过程中建立起来的那套“把规则转化成数据结构”的思维。这个能力靠看别人的代码学不来只能自己一行一行敲出来。最后一句话想分享给正卡在“写完语法不知道干什么”阶段的朋友找一个规则简单的游戏用尽可能短的时间先做出来再花时间慢慢改这个循环走一次你对C的理解会比刷十遍语法书都扎实。
返回列表