C++控制台小游戏开发实战:贪吃蛇、2048与俄罗斯方块源码解析

📅 2026/8/5 5:55:00
C++控制台小游戏开发实战:贪吃蛇、2048与俄罗斯方块源码解析
1. 从“Hello World”到“贪吃蛇”为什么C小游戏是编程入门的绝佳选择很多朋友学C都是从控制台打印“Hello World”开始的。但说实话对着黑框框敲几行字成就感来得快去得也快。没过多久面对指针、内存、面向对象这些概念热情很容易就被枯燥的语法细节消磨殆尽。我当年也是这么过来的直到我开始尝试用C写第一个小游戏——一个在控制台里跑的贪吃蛇。当那条由字符组成的“蛇”能随着我的按键移动、吃“食物”并变长时那种亲手创造出一个“活”的、可交互的程序所带来的兴奋感是任何教科书习题都无法比拟的。C小游戏尤其是控制台游戏之所以是绝佳的练手项目原因在于它完美地串联了C的核心知识点。它不像大型游戏引擎那样有现成的框架帮你处理一切你需要从零开始亲手处理输入、逻辑、渲染虽然是字符渲染和状态管理。这个过程会逼着你把书本上的理论变成解决实际问题的工具。比如为了不让蛇穿墙你得理解边界判断逻辑运算为了存储蛇身你得选择合适的数据结构数组、链表或向量为了让游戏循环起来你得掌握循环和延时控制流程为了让蛇动得流畅你得处理键盘输入I/O操作。每一个功能点都对应着一个或多个C的核心概念。更重要的是完成一个小游戏带来的正向反馈是巨大的。你不再是单纯地“学习”而是在“创造”。这种从消费者到生产者的角色转变是保持学习动力的关键。今天我就把自己当年写的以及后来积累的几个经典的C控制台小游戏源码整理出来并附上详细的实现思路和关键代码解析。这些代码都遵循“简单、清晰、可运行”的原则你可以在任何支持标准C的编译器如GCC、Clang或Visual Studio中直接复制、编译和运行。我们的目标不是做出多么炫酷的游戏而是通过实现它们真正吃透C的基础。2. 环境准备你的第一个C游戏实验室在开始敲代码之前我们需要一个干净、可用的开发环境。对于C初学者我强烈推荐从Visual Studio CommunityWindows或Code::Blocks跨平台开始。它们安装简单集成了编译器和调试器能让你专注于代码本身而不是繁琐的环境配置。这里我以Visual Studio 2022 Community为例因为它对新手最友好。2.1 安装与第一个项目首先去微软官网下载Visual Studio Installer。安装时在“工作负载”选项卡中务必勾选“使用C的桌面开发”。这个选项包含了编译C控制台程序所需的一切MSVC编译器、标准库和基本的IDE功能。安装完成后打开Visual Studio。点击“创建新项目”选择“控制台应用”模板项目名称可以叫“MyFirstGame”。创建成功后你会看到一个简单的main.cpp文件里面已经有一个打印“Hello World”的程序。直接按F5键开始调试你就能在下方弹出的控制台窗口中看到输出。恭喜你的游戏实验室已经搭建完毕这个黑乎乎的窗口将是我们所有游戏上演的舞台。注意有些教程会教你在创建项目时选择“空项目”这对于追求纯净度的老手是好的但对新手来说“控制台应用”模板自动生成的基本框架能帮你避免一些初期的配置错误更平滑地上手。2.2 理解控制台游戏的“画布”我们做的游戏是控制台Console程序它的“图形界面”就是由一个个字符组成的网格。在Windows下我们可以使用Windows API来稍微美化这个窗口比如设置窗口大小、标题和颜色。但为了代码的纯粹性和跨平台性让代码也能在Linux或Mac的终端里运行我们主要使用标准C库iostream和conio.hWindows特有用于键盘输入或跨平台的替代方案。一个典型的控制台游戏循环结构如下#include iostream #include windows.h // 用于Sleep函数和控制台操作 using namespace std; int main() { // 1. 初始化游戏状态如蛇的初始位置、分数 bool gameOver false; int score 0; // 2. 游戏主循环 while (!gameOver) { // 2.1 处理用户输入非阻塞式检测按键 // 2.2 更新游戏逻辑根据输入移动物体、检测碰撞等 // 2.3 渲染画面清屏后在控制台特定位置打印字符 // 2.4 控制游戏速度延时 Sleep(100); // 每帧延时100毫秒控制游戏速度 } // 3. 游戏结束显示分数 cout Game Over! Your score: score endl; return 0; }这个while循环就是游戏的心脏每秒会运行很多次通过Sleep控制频率每一次循环称为一“帧”。我们的所有游戏逻辑都发生在这个循环里。3. 经典重现一贪吃蛇Snake——数据与逻辑的舞蹈贪吃蛇大概是所有人编程入门游戏的初恋。它的规则简单但实现起来涵盖了数组/链表、循环控制、随机数生成和键盘输入等多个核心知识点。我们来实现一个基础版本。3.1 游戏状态的数据表示首先我们需要用数据来描述游戏世界。#include iostream #include conio.h // 用于_kbhit和_getch #include windows.h #include cstdlib // 用于rand和srand #include ctime // 用于time using namespace std; const int width 20; // 游戏区域宽度 const int height 20; // 游戏区域高度 bool gameOver; int x, y; // 蛇头的坐标 int fruitX, fruitY; // 食物的坐标 int score; int tailX[100], tailY[100]; // 用数组存储蛇尾每一节的坐标 int nTail; // 当前蛇尾的长度 enum eDirection { STOP 0, LEFT, RIGHT, UP, DOWN }; // 方向枚举 eDirection dir;这里的关键是tailX和tailY数组。我们用两个平行的数组来记录蛇身除头部外每一节的坐标。nTail记录当前长度。这是一种简单直观的存储方式但效率不高移动时需要整体移位。更优的方案是使用链表std::list或双端队列std::deque但数组对于理解原理足够了。3.2 核心逻辑移动与增长的奥秘游戏的初始化Setup函数负责设置初始状态蛇头在中间随机生成第一个食物方向初始为停止。void Setup() { gameOver false; dir STOP; x width / 2; y height / 2; fruitX rand() % width; // 在0到width-1之间生成随机数 fruitY rand() % height; score 0; nTail 0; }最核心的是Logic函数它处理移动和碰撞。void Logic() { // 1. 移动蛇尾从最后一节开始每一节的位置等于前一节的位置 int prevX tailX[0]; int prevY tailY[0]; int prev2X, prev2Y; tailX[0] x; tailY[0] y; for (int i 1; i nTail; i) { prev2X tailX[i]; prev2Y tailY[i]; tailX[i] prevX; tailY[i] prevY; prevX prev2X; prevY prev2Y; } // 2. 根据方向移动蛇头 switch (dir) { case LEFT: x--; break; case RIGHT: x; break; case UP: y--; break; case DOWN: y; break; } // 3. 边界碰撞检测可改为穿墙 if (x width) x 0; else if (x 0) x width - 1; // 穿墙逻辑 if (y height) y 0; else if (y 0) y height - 1; // 如果使用撞墙结束则用if (x width || x 0 || y height || y 0) gameOver true; // 4. 检测是否撞到自己 for (int i 0; i nTail; i) { if (tailX[i] x tailY[i] y) { gameOver true; } } // 5. 检测是否吃到食物 if (x fruitX y fruitY) { score 10; fruitX rand() % width; fruitY rand() % height; nTail; // 蛇尾长度增加 } }这段代码的精华在于蛇尾的移动算法。它通过一个循环将每一节的位置向前传递就像一列火车车头带动每一节车厢。吃到食物后我们只需增加nTail下一帧循环时数组末尾就会多出一节其位置会在移动逻辑中被正确计算因为新增长的这一节初始位置会被上一帧的蛇头位置覆盖效果就是长在了蛇头移动前的位置。3.3 渲染在字符矩阵中构建世界Draw函数负责将内存中的数据“画”到控制台上。void Draw() { system(cls); // 清屏。注意频繁调用system有性能开销跨平台可用其他方法。 // 画上边框 for (int i 0; i width2; i) cout #; cout endl; for (int i 0; i height; i) { for (int j 0; j width; j) { if (j 0) cout #; // 左边框 // 画蛇头 if (i y j x) cout O; // 画食物 else if (i fruitY j fruitX) cout F; // 画蛇身 else { bool printTail false; for (int k 0; k nTail; k) { if (tailX[k] j tailY[k] i) { cout o; printTail true; break; } } if (!printTail) cout ; } if (j width - 1) cout #; // 右边框 } cout endl; } // 画下边框 for (int i 0; i width2; i) cout #; cout endl; cout Score: score endl; }这里用两层循环遍历游戏区域的每一个“像素”字符位置判断该位置是空格、边框#、蛇头O、蛇身o还是食物F然后打印相应的字符。3.4 输入处理让游戏响应你的操作Input函数使用_kbhit()非阻塞地检测是否有按键然后用_getch()获取按键。void Input() { if (_kbhit()) { // 如果有按键被按下 switch (_getch()) { // 获取按键字符 case a: dir LEFT; break; case d: dir RIGHT; break; case w: dir UP; break; case s: dir DOWN; break; case x: gameOver true; break; // 按x退出 } } }最后在main函数中组织游戏主循环int main() { srand(time(0)); // 用当前时间初始化随机数种子 Setup(); while (!gameOver) { Draw(); Input(); Logic(); Sleep(50); // 控制游戏速度值越小蛇移动越快 } return 0; }将以上所有代码块按顺序组合到一个.cpp文件中编译运行你的第一个贪吃蛇就诞生了试着修改width、height和Sleep的参数感受游戏的变化。4. 经典重现二控制台版“2048”——状态管理与算法思维如果说贪吃蛇锻炼的是数据操作和实时输入那么2048锻炼的则是状态管理、算法逻辑和界面布局。它没有实时循环而是回合制玩家按一次方向键所有方块移动合并一次然后随机生成一个新方块。4.1 棋盘与核心数据我们用一个4x4的二维数组来表示棋盘。#include iostream #include conio.h #include windows.h #include cstdlib #include ctime using namespace std; const int SIZE 4; int board[SIZE][SIZE] {0}; // 0表示空位 int score;游戏初始化时需要在随机两个位置生成数字290%概率或410%概率。void initGame() { score 0; for (int i 0; i SIZE; i) for (int j 0; j SIZE; j) board[i][j] 0; addRandomTile(); addRandomTile(); } void addRandomTile() { int emptyCells[SIZE*SIZE][2]; int count 0; // 收集所有空位坐标 for (int i 0; i SIZE; i) for (int j 0; j SIZE; j) if (board[i][j] 0) { emptyCells[count][0] i; emptyCells[count][1] j; count; } if (count 0) { int r rand() % count; int x emptyCells[r][0]; int y emptyCells[r][1]; board[x][y] (rand() % 10 0) ? 4 : 2; // 10%概率生成4 } }4.2 移动与合并算法一次方向操作的分解这是2048游戏最核心的部分。以向左移动为例我们需要对每一行单独处理。处理过程可以分为三个子步骤移除零、合并相邻相同数字、再次移除零因为合并可能产生新零。// 辅助函数将一行数组向左紧凑移除零 void slideRowLeft(int row[]) { int temp[SIZE] {0}; int index 0; for (int i 0; i SIZE; i) { if (row[i] ! 0) { temp[index] row[i]; } } for (int i 0; i SIZE; i) { row[i] temp[i]; } } // 辅助函数合并一行中相邻的相同数字 void mergeRowLeft(int row[]) { for (int i 0; i SIZE - 1; i) { if (row[i] ! 0 row[i] row[i 1]) { row[i] * 2; score row[i]; // 加分 row[i 1] 0; i; // 跳过下一个防止三重合并 } } } // 处理整个棋盘向左移动 bool moveLeft() { int oldBoard[SIZE][SIZE]; copyBoard(board, oldBoard); // 复制旧棋盘用于比较 for (int i 0; i SIZE; i) { slideRowLeft(board[i]); mergeRowLeft(board[i]); slideRowLeft(board[i]); } return !isBoardEqual(board, oldBoard); // 如果棋盘有变化返回true }moveRight,moveUp,moveDown的实现原理类似但需要对行或列进行反转或转置操作复用moveLeft的逻辑。这是算法设计中“化归”思想的体现。例如向右移动可以看作先将每一行反转然后向左移动最后再反转回来。void reverseRow(int row[]) { for (int i 0; i SIZE / 2; i) { swap(row[i], row[SIZE - 1 - i]); } } bool moveRight() { // 先反转每一行再左移再反转回来 for (int i 0; i SIZE; i) reverseRow(board[i]); bool changed moveLeft(); for (int i 0; i SIZE; i) reverseRow(board[i]); return changed; }向上和向下移动则需要处理列可以通过矩阵转置行列互换转化为行的处理。这些操作稍微复杂但能极大地锻炼你对二维数组的操作能力。4.3 游戏状态判断与渲染游戏结束的条件是棋盘已满且任意方向都无法移动即没有相邻的相同数字。bool isGameOver() { // 1. 检查是否有空位 for (int i 0; i SIZE; i) for (int j 0; j SIZE; j) if (board[i][j] 0) return false; // 2. 检查水平方向是否有可合并的 for (int i 0; i SIZE; i) for (int j 0; j SIZE - 1; j) if (board[i][j] board[i][j 1]) return false; // 3. 检查垂直方向是否有可合并的 for (int j 0; j SIZE; j) for (int i 0; i SIZE - 1; i) if (board[i][j] board[i 1][j]) return false; return true; }渲染函数drawBoard需要将数字美观地打印出来可以简单地对齐。void drawBoard() { system(cls); cout 2048 - Score: score endl; cout ------------------------- endl; for (int i 0; i SIZE; i) { cout |; for (int j 0; j SIZE; j) { if (board[i][j] 0) cout |; else printf( %4d |, board[i][j]); // 使用printf方便格式化 } cout endl ------------------------- endl; } cout WASD to move, (Q)uit endl; }主循环则是一个典型的回合制结构int main() { srand(time(0)); initGame(); bool quit false; while (!quit !isGameOver()) { drawBoard(); char cmd _getch(); bool moved false; switch (tolower(cmd)) { case a: moved moveLeft(); break; case d: moved moveRight(); break; case w: moved moveUp(); break; case s: moved moveDown(); break; case q: quit true; break; } if (moved) { addRandomTile(); } } drawBoard(); if (isGameOver()) cout Game Over! Final Score: score endl; return 0; }这个2048实现包含了完整的游戏逻辑。你可以尝试挑战自己实现moveUp和moveDown函数这是对二维数组操作的一次绝佳练习。5. 进阶挑战俄罗斯方块Tetris——状态机与复杂碰撞俄罗斯方块是另一个维度上的经典。它引入了下落物体Tetromino、旋转、与静态地图的碰撞、行消除等更复杂的概念。实现它你会对“游戏状态机”有更深的理解。5.1 方块表示与旋转首先我们需要定义七种不同形状的方块Tetromino。每种方块可以用一个4x4的布尔矩阵来表示。// 七种方块的形状定义4x4网格1表示有方块0表示空 int shapes[7][4][4] { { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, // I { {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, // O { {0,0,0,0}, {0,1,1,1}, {0,1,0,0}, {0,0,0,0} }, // J { {0,0,0,0}, {0,1,1,1}, {0,0,0,1}, {0,0,0,0} }, // L { {0,0,0,0}, {0,1,1,0}, {0,0,1,1}, {0,0,0,0} }, // S { {0,0,0,0}, {0,1,1,1}, {0,0,1,0}, {0,0,0,0} }, // T { {0,0,0,0}, {0,0,1,1}, {0,1,1,0}, {0,0,0,0} } // Z };当前下落的方块我们需要记录它的类型、旋转状态以及在游戏区域中的坐标。struct Tetromino { int type; // 0-6对应七种形状 int rotation; // 0-3四种旋转状态 int posX; // 方块左上角在游戏区域中的X坐标 int posY; // 方块左上角在游戏区域中的Y坐标 } currentPiece; // 游戏区域0为空非0为已固定的方块可用不同数字表示颜色 int field[FIELD_HEIGHT][FIELD_WIDTH] {0};方块的旋转是俄罗斯方块的灵魂。旋转的本质是将4x4矩阵绕中心点或某个轴旋转90度。一个简单但有效的实现方法是为每种形状预定义好四个旋转状态下的形状数据。另一种更编程化的方法是实现一个旋转矩阵的函数但考虑到4x4矩阵较小预定义更简单可靠。5.2 碰撞检测游戏规则的守护者在任何移动左、右、下、旋转操作发生前都必须进行碰撞检测。检测函数需要判断方块的新位置或新形状是否与游戏区域边界重叠或者与field中已固定的方块重叠。bool checkCollision(const Tetromino piece) { // 获取当前形状的4x4矩阵 const int (*shape)[4] shapes[piece.type][piece.rotation]; for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (shape[i][j]) { // 如果形状矩阵中该位置有方块 int worldX piece.posX j; int worldY piece.posY i; // 检查是否超出边界 if (worldX 0 || worldX FIELD_WIDTH || worldY FIELD_HEIGHT) { return true; // 碰撞 } // 检查是否与场地中已固定的方块重叠注意Y0才检查 if (worldY 0 field[worldY][worldX]) { return true; // 碰撞 } } } } return false; // 无碰撞 }这个函数是游戏物理的核心。移动方块时我们先计算移动后的位置调用checkCollision如果无碰撞则更新位置否则拒绝移动。5.3 锁定与消行从动态到静态的转换当方块因碰撞无法继续下落时就需要将它“锁定”到场地field中。void lockPiece() { const int (*shape)[4] shapes[currentPiece.type][currentPiece.rotation]; for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (shape[i][j]) { int worldY currentPiece.posY i; int worldX currentPiece.posX j; if (worldY 0) { // 确保不会锁到屏幕上方之外 field[worldY][worldX] currentPiece.type 1; // 用类型1作为颜色标识 } } } } }锁定后立即检查是否有完整的行可以被消除。int clearLines() { int linesCleared 0; for (int row 0; row FIELD_HEIGHT; row) { bool lineFull true; for (int col 0; col FIELD_WIDTH; col) { if (field[row][col] 0) { lineFull false; break; } } if (lineFull) { // 将该行以上的所有行向下移动一格 for (int r row; r 0; r--) { for (int c 0; c FIELD_WIDTH; c) { field[r][c] field[r-1][c]; } } // 清空最顶行 for (int c 0; c FIELD_WIDTH; c) field[0][c] 0; linesCleared; // 因为当前行已被新的行覆盖需要再次检查同一行row不变 row--; } } return linesCleared; }消行后根据消除的行数计算得分并生成一个新的随机方块。5.4 游戏主循环与时间控制俄罗斯方块需要两个时间轴一个是玩家实时控制的输入响应另一个是方块自动下落的时钟。我们可以用Sleep来控制下落速度并用_kbhit来非阻塞检测即时输入。int main() { // ... 初始化 ... srand(time(0)); spawnNewPiece(); // 生成第一个方块 int fallSpeed 500; // 初始下落间隔毫秒 int timeSinceLastFall 0; while (!gameOver) { int startTime GetTickCount(); // 获取当前时间戳Windows API // 处理输入 Input(); // 更新时间到了就尝试下落一格 timeSinceLastFall frameTime; // frameTime是每帧耗时 if (timeSinceLastFall fallSpeed) { if (!movePiece(0, 1)) { // 尝试向下移动 lockPiece(); int lines clearLines(); updateScore(lines); if (!spawnNewPiece()) { gameOver true; // 新方块无法生成游戏结束 } } timeSinceLastFall 0; } // 渲染 Draw(); // 计算本帧耗时用于下一帧的时间控制 int endTime GetTickCount(); frameTime endTime - startTime; Sleep(10); // 防止CPU占用过高 } // ... 游戏结束处理 ... return 0; }这里使用了一个简单的计时器逻辑。GetTickCount()返回系统启动后的毫秒数我们记录每一帧开始的时间在处理完逻辑和渲染后计算本帧耗时累加到timeSinceLastFall中。当累计时间超过设定的下落间隔fallSpeed时就触发一次自动下落。这种方式比简单的Sleep(fallSpeed)更精确能保证输入响应的及时性。6. 从控制台到图形界面你的C游戏之旅下一步完成了以上几个控制台小游戏你已经掌握了用C构建一个完整游戏循环的核心技能状态管理、输入处理、逻辑更新和渲染。但这只是开始。控制台的字符界面限制了我们创造更丰富视觉体验的可能。如果你想看到自己创造的蛇是彩色的方块是光滑的那么是时候迈向图形界面了。对于C来说有几个非常友好且强大的图形库入门选择SFML (Simple and Fast Multimedia Library)这是我的首推。它非常易于上手封装了窗口、图形、音频、网络等模块API清晰直观。用SFML重写贪吃蛇你可以用矩形和圆形来绘制并添加平滑的移动和音效代码结构和你现在写的会非常相似只是把cout打印字符换成window.draw(sprite)。SDL (Simple DirectMedia Layer)更底层一些性能强大被很多商业游戏使用。它给了你更多的控制权但上手难度略高于SFML。Raylib一个新兴的库设计理念是“让每个人都能学习编程和游戏开发”API极其简单且是纯C语言编写但C调用毫无问题非常适合快速原型开发。以SFML为例将贪吃蛇“图形化”的核心变化在于窗口你需要创建一个sf::RenderWindow对象。图形蛇身和食物不再是字符而是sf::RectangleShape或sf::CircleShape对象。输入不再用_getch而是通过sf::Event来监听键盘事件。游戏循环核心依然是while (window.isOpen())循环内部依次处理事件、更新逻辑、清屏、绘制、显示。这个迁移过程会巩固你对游戏架构的理解并让你接触到资源管理、坐标系变换、时间增量delta time等新概念。我强烈建议你选择SFML从它的官方教程开始将我们之前写的控制台游戏用图形的方式重新实现一遍。当你看到彩色的图形在窗口中流畅运行时那种成就感会再次升级。最后分享一个我自己的心得学习编程尤其是像C这样复杂的语言“做”比“看”重要一百倍。不要满足于复制粘贴这些源码。试着去修改它们给贪吃蛇加上不同的关卡地图给2048加上撤销功能给俄罗斯方块实现“下一个方块预览”。在修改和调试的过程中你会遇到各种错误而解决这些错误的过程才是你真正理解和掌握知识的时刻。这些小小的游戏项目就像一个个训练场让你在实战中把C的语法、概念内化成自己的编程本能。祝你玩得开心码得顺利