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 Community(Windows)或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.h>(Windows特有,用于键盘输入)或跨平台的替代方案。

一个典型的控制台游戏循环结构如下:

#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;

这里的关键是tailXtailY数组。我们用两个平行的数组来记录蛇身(除头部外)每一节的坐标。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 < width+2; 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 < width+2; 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文件中,编译运行,你的第一个贪吃蛇就诞生了!试着修改widthheightSleep的参数,感受游戏的变化。

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;

游戏初始化时,需要在随机两个位置生成数字2(90%概率)或4(10%概率)。

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实现包含了完整的游戏逻辑。你可以尝试挑战自己,实现moveUpmoveDown函数,这是对二维数组操作的一次绝佳练习。

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; // 碰撞 } // 检查是否与场地中已固定的方块重叠(注意:Y>=0才检查) 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++来说,有几个非常友好且强大的图形库入门选择:

  1. SFML (Simple and Fast Multimedia Library):这是我的首推。它非常易于上手,封装了窗口、图形、音频、网络等模块,API清晰直观。用SFML重写贪吃蛇,你可以用矩形和圆形来绘制,并添加平滑的移动和音效,代码结构和你现在写的会非常相似,只是把cout打印字符换成window.draw(sprite)
  2. SDL (Simple DirectMedia Layer):更底层一些,性能强大,被很多商业游戏使用。它给了你更多的控制权,但上手难度略高于SFML。
  3. Raylib:一个新兴的库,设计理念是“让每个人都能学习编程和游戏开发”,API极其简单,且是纯C语言编写(但C++调用毫无问题),非常适合快速原型开发。

以SFML为例,将贪吃蛇“图形化”的核心变化在于:

  • 窗口:你需要创建一个sf::RenderWindow对象。
  • 图形:蛇身和食物不再是字符,而是sf::RectangleShapesf::CircleShape对象。
  • 输入:不再用_getch,而是通过sf::Event来监听键盘事件。
  • 游戏循环:核心依然是while (window.isOpen())循环,内部依次处理事件、更新逻辑、清屏、绘制、显示。

这个迁移过程会巩固你对游戏架构的理解,并让你接触到资源管理、坐标系变换、时间增量(delta time)等新概念。我强烈建议你选择SFML,从它的官方教程开始,将我们之前写的控制台游戏用图形的方式重新实现一遍。当你看到彩色的图形在窗口中流畅运行时,那种成就感会再次升级。

最后,分享一个我自己的心得:学习编程,尤其是像C++这样复杂的语言,“做”比“看”重要一百倍。不要满足于复制粘贴这些源码。试着去修改它们:给贪吃蛇加上不同的关卡地图,给2048加上撤销功能,给俄罗斯方块实现“下一个方块预览”。在修改和调试的过程中,你会遇到各种错误,而解决这些错误的过程,才是你真正理解和掌握知识的时刻。这些小小的游戏项目,就像一个个训练场,让你在实战中把C++的语法、概念内化成自己的编程本能。祝你玩得开心,码得顺利!