Dev-C++控制台双人跑酷游戏开发:从Windows API到实时交互
1. 项目概述:为什么选择Dev-C++做控制台游戏?
很多刚接触C/C++编程的朋友,可能都想过自己动手写个小游戏。但一上来就面对Unity、Unreal这些庞然大物,或者复杂的图形库,很容易被劝退。其实,用最经典的Dev-C++集成开发环境,配合Windows自带的控制台,就能做出好玩又有挑战性的游戏。这次我们要做的,就是一个“双人跑酷游戏”。
你可能会问,控制台黑底白字的,能做游戏吗?当然能,而且别有洞天。我们不用任何外部图形库,纯粹用字符(比如@、#、-、|)来构建游戏世界。两个玩家分别控制两个角色,在由字符组成的障碍赛道中奔跑、跳跃、躲避。这听起来简单,但背后涉及了控制台光标定位、实时键盘事件捕获、游戏状态逻辑、碰撞检测等核心编程概念。对于想夯实C语言基础、理解程序实时交互本质的开发者来说,这是一个绝佳的练手项目。
选择Dev-C++,一方面是因为它轻量、经典,在Windows环境下配置简单;另一方面,它自带的MinGW编译器能很好地支持我们需要的Windows API调用。我们将大量使用windows.h这个头文件里的函数,来实现控制台“画布”的绘制和键盘的即时响应。网络上常说的“小熊猫Dev-C++”或“Red Panda Dev-C++”,是原版的一个活跃分支,界面更现代,对中文支持更好,同样完全适用本项目。
这个项目的核心价值在于“完整思路”。它不是教你抄几行代码,而是带你从零开始,像搭积木一样,构建一个可运行、可交互的游戏程序。你会清晰地看到,如何将“画个静态字符界面”的想法,一步步变成“两个角色能实时响应你的按键并动态更新”的活游戏。下面,我们就来拆解这整个过程。
2. 环境准备与核心API扫盲
2.1 Dev-C++的安装与项目创建
首先,你需要一个Dev-C++。如果你还没有,直接搜索“小熊猫Dev-C++”或访问其官方仓库下载最新版,它的界面和体验比古老的Dev-C++ 5.11要好很多。安装过程一路下一步即可,没有特别需要注意的坑。
安装完成后,打开软件,新建一个项目:文件->新建->项目。在弹出的窗口中,选择Console Application(控制台应用程序),语言选择C或C++都可以,因为我们将主要使用C风格的API和语法。给项目起个名字,比如DoubleDashRunner,然后选择一个干净的目录保存。
项目创建好后,你会看到一个包含了main函数的初始.cpp文件。我们后续的所有代码都将写在这里。首先,请确保在文件的最开头包含我们最核心的头文件:
#include <windows.h> #include <conio.h> #include <stdio.h> #include <stdlib.h> #include <time.h>windows.h:提供了所有Windows API的声明,是我们操纵控制台窗口和键盘输入的基础。conio.h:经典的控制台输入输出头文件,虽然我们会用更强大的API,但其中_kbhit()等函数有时仍很方便。stdio.h和stdlib.h:用于标准输入输出和动态内存管理、随机数生成等。time.h:主要用于初始化随机数种子,让每次游戏运行时障碍物的出现位置是随机的。
2.2 必须掌握的Windows控制台API
我们的游戏画面本质上是不断刷新的字符矩阵。Windows API提供了一组函数,允许我们精确控制控制台屏幕缓冲区(可以理解为控制台背后的“画布”)的每一个字符单元。以下是几个最关键的函数,理解了它们,你就掌握了控制台图形学的钥匙:
获取与设置控制台句柄
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE);GetStdHandle函数获取标准输出和标准输入的句柄。句柄(Handle)是Windows系统用来标识各种资源(如窗口、文件、控制台缓冲区)的一个唯一值。hOutput和hInput就是我们后续所有绘制和输入操作的“入场券”。设置控制台窗口标题和大小
SetConsoleTitle("双人跑酷大冒险 - Dev-C++制造"); system("mode con cols=80 lines=25");SetConsoleTitle顾名思义,设置窗口标题。system(“mode con …”)是调用系统命令来快速设置控制台窗口的缓冲区大小为80列、25行。这是一个经典的尺寸,足够我们构建游戏画面。你也可以调整成其他尺寸,比如cols=120 lines=30来获得更宽阔的视野。核心绘制函数:
SetConsoleCursorPosition和WriteConsole控制台光标就像你的画笔笔尖。SetConsoleCursorPosition函数将笔尖移动到指定的坐标位置。COORD pos = {x, y}; // COORD是一个结构体,包含X和Y两个成员 SetConsoleCursorPosition(hOutput, pos);坐标原点
(0,0)在屏幕的左上角,X轴向右增加,Y轴向下增加。移动光标后,你可以用printf输出字符,但更推荐使用WriteConsole,它性能更好,且能避免一些格式问题。char ch = '@'; DWORD bytesWritten; WriteConsole(hOutput, &ch, 1, &bytesWritten, NULL);不过,对于单个字符的绘制,更常见的做法是组合使用
SetConsoleCursorPosition和printf(“%c”, ch),代码更简洁。键盘事件处理的基石:
SetConsoleMode与ReadConsoleInput这是实现双人实时操控的关键。默认情况下,控制台输入是“行缓冲”模式,你需要按回车键程序才能读到输入。对于游戏,我们需要“即时”模式。DWORD mode; GetConsoleMode(hInput, &mode); SetConsoleMode(hInput, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) | ENABLE_WINDOW_INPUT);这段代码关闭了行输入和回显,开启了窗口输入模式,允许我们读取输入事件队列。 然后,在一个循环中,我们可以检查并读取输入事件:
INPUT_RECORD inputRecord; DWORD numEventsRead; PeekConsoleInput(hInput, &inputRecord, 1, &numEventsRead); if (numEventsRead > 0) { ReadConsoleInput(hInput, &inputRecord, 1, &numEventsRead); if (inputRecord.EventType == KEY_EVENT && inputRecord.Event.KeyEvent.bKeyDown) { // 处理按键按下事件 WORD keyCode = inputRecord.Event.KeyEvent.wVirtualKeyCode; // 根据keyCode判断是哪个键被按下 } }INPUT_RECORD结构体包含了各种输入事件的详细信息,我们只关心KEY_EVENT(键盘事件)。bKeyDown为TRUE表示键被按下。wVirtualKeyCode是虚拟键码,比如VK_UP、VK_W、VK_A等,这是我们判断玩家操作的依据。
注意:很多教程会用
_getch()或kbhit(),但这些函数在处理双人同时按键时会有阻塞或丢失输入的问题。ReadConsoleInput是更专业、更可靠的解决方案,它能完美捕获所有按键事件,是实现流畅双人操控的基础。
3. 游戏核心架构设计与数据定义
在动手写代码之前,我们必须先规划好游戏里有哪些“东西”,以及它们如何运作。这就像盖房子先画图纸。
3.1 游戏世界的抽象:定义结构体
我们将游戏中的元素抽象为几个结构体,这能让代码逻辑更清晰。
1. 游戏角色 (Player)每个玩家控制一个角色。角色需要知道自己的位置、形状,以及是否处于跳跃状态。
typedef struct { int x; // 角色所在的列(横向坐标) int y; // 角色所在的行(纵向坐标) char shape; // 角色的字符表示,比如'@'或'&' int isJumping; // 是否正在跳跃,0表示否,>0表示跳跃剩余帧数 int jumpForce; // 跳跃的初始力度(决定能跳多高) int gravity; // 重力值,每帧增加,让角色下落 } Player;这里我引入了一个简单的跳跃模拟:isJumping是跳跃计时器,当它大于0时,角色每帧会向上移动(跳跃上升阶段),同时gravity累积;当计时器结束或碰到天花板,gravity开始起作用,让角色加速下落,直到落回地面。
2. 障碍物 (Obstacle)障碍物是玩家需要躲避的东西。它可能是一堵墙、一个坑或者一个移动的敌人。
typedef struct { int x; // 障碍物起始列 int y; // 障碍物所在行(通常在地面行) int width; // 障碍物的宽度(占几列) char shape; // 障碍物的填充字符,如'#'或'^' int isActive; // 障碍物是否处于活动状态(是否在屏幕上) } Obstacle;我们将采用“对象池”的思想。预先生成一个障碍物数组,游戏过程中循环使用。当一个障碍物移出屏幕左端,我们就将其重置到屏幕右端某个位置,并随机其类型(宽度),从而实现无穷无尽的障碍赛道。
3. 游戏全局状态 (GameState)用一个结构体来管理游戏的全局信息。
typedef struct { Player player1; Player player2; Obstacle obstacles[MAX_OBSTACLES]; // 假设我们定义最多10个障碍物 int score; // 游戏分数(比如存活时间或越过障碍数) int gameOver; // 游戏是否结束,0进行中,1结束 int groundY; // 地面的行坐标,角色和障碍物都以此为基准 int screenWidth; // 屏幕宽度(列数) int screenHeight; // 屏幕高度(行数) } GameState;3.2 游戏主循环:心跳与节奏
所有实时游戏都有一个核心——游戏主循环。它每秒运行很多次(比如30次或60次),每一次循环称为一“帧”(Frame)。在每一帧里,程序按顺序做以下几件事:
- 处理输入:检查这段时间内玩家按了哪些键。
- 更新逻辑:根据输入和当前状态,更新角色位置、障碍物位置、判断碰撞、计算分数等。
- 渲染输出:根据最新的游戏状态,重新绘制整个控制台画面。
这个顺序非常重要。如果先渲染再处理输入,玩家会感觉到操作延迟。我们的主循环骨架如下:
void gameLoop(GameState* state) { clock_t lastTime = clock(); const double frameTime = 1.0 / 30.0; // 目标帧率:30帧/秒,即每帧约33.3毫秒 while (!state->gameOver) { clock_t currentTime = clock(); double elapsedTime = (double)(currentTime - lastTime) / CLOCKS_PER_SEC; // 如果距离上一帧的时间还没达到目标帧时间,就稍微等待一下 // 这可以防止游戏在性能过剩的电脑上跑得太快。 if (elapsedTime < frameTime) { continue; } lastTime = currentTime; // 1. 处理输入 processInput(state); // 2. 更新游戏状态 updateGame(state, elapsedTime); // 3. 渲染画面 renderGame(state); // 一个简单的延时,也可以帮助稳定帧率,但不如上面的时间差判断精确 // Sleep(30); } // 游戏结束,显示结算画面 renderGameOver(state); }这里用clock()函数和CLOCKS_PER_SEC来计算真实经过的时间,实现一个简单的、与电脑速度无关的帧率控制。processInput、updateGame、renderGame就是我们接下来要实现的三个核心函数。
4. 核心模块实现详解
4.1 输入处理模块:双人键盘响应的奥秘
双人游戏的关键在于输入处理要公平、及时、不冲突。我们为两位玩家分配不同的按键:
- 玩家1(通常用WASD键):
W键跳跃,A键左移(可选),D键右移(可选)。在纯跑酷游戏中,左右移动可能不需要,角色自动向右跑,跳跃是主要操作。 - 玩家2(通常用方向键):
↑键跳跃,←键左移,→键右移。
processInput函数的核心逻辑如下:
void processInput(GameState* state) { INPUT_RECORD inBuf[128]; // 缓冲区,一次读取多个事件 DWORD numEvents, numEventsRead; // 1. 窥探输入缓冲区里有多少事件 GetNumberOfConsoleInputEvents(hInput, &numEvents); if (numEvents == 0) return; // 没有新输入,直接返回 // 2. 读取所有事件(不超过缓冲区大小) ReadConsoleInput(hInput, inBuf, min(128, numEvents), &numEventsRead); // 3. 遍历处理每一个事件 for (DWORD i = 0; i < numEventsRead; ++i) { if (inBuf[i].EventType == KEY_EVENT && inBuf[i].Event.KeyEvent.bKeyDown) { WORD vkCode = inBuf[i].Event.KeyEvent.wVirtualKeyCode; // 处理玩家1按键 if (vkCode == 'W' || vkCode == VK_UP) { // 为了方便,也可以允许玩家1用上箭头 // 触发玩家1跳跃,前提是角色在地面上 if (state->player1.y == state->groundY) { state->player1.isJumping = 15; // 设置跳跃帧数 state->player1.gravity = -4; // 设置初始向上的速度 } } // 可以在这里添加玩家1的左右移动逻辑 // if (vkCode == 'A') state->player1.x = max(0, state->player1.x - 1); // if (vkCode == 'D') state->player1.x = min(state->screenWidth-1, state->player1.x + 1); // 处理玩家2按键 if (vkCode == VK_UP) { if (state->player2.y == state->groundY) { state->player2.isJumping = 15; state->player2.gravity = -4; } } // 玩家2左右移动逻辑 // if (vkCode == VK_LEFT) ... // if (vkCode == VK_RIGHT) ... // 处理通用按键,比如按ESC退出游戏 if (vkCode == VK_ESCAPE) { state->gameOver = 1; } } } // 4. 清空可能残留的鼠标等无关事件(可选,保持输入队列清洁) FlushConsoleInputBuffer(hInput); }实操心得:
ReadConsoleInput是一次性读取多个事件,这比单次读取效率高得多,也避免了在连续快速按键时丢失事件。FlushConsoleInputBuffer有时很有用,特别是在游戏开始前或场景切换时,可以清空之前积累的无效输入,防止误操作。
4.2 游戏逻辑更新模块:让世界动起来
updateGame函数是游戏的大脑,负责计算下一帧所有物体的状态。
1. 更新玩家状态(物理模拟)
void updatePlayers(GameState* state) { // 更新玩家1 updateSinglePlayer(&(state->player1), state->groundY); // 更新玩家2 updateSinglePlayer(&(state->player2), state->groundY); } void updateSinglePlayer(Player* p, int groundY) { // 跳跃处理 if (p->isJumping > 0) { p->y += p->gravity; // 应用当前速度改变位置 p->gravity++; // 模拟重力加速度,速度越来越向下 p->isJumping--; // 如果跳跃时间结束,或者角色已经落到地面以下,则结束跳跃状态 if (p->isJumping == 0 || p->y >= groundY) { p->isJumping = 0; p->y = groundY; // 确保角色稳稳站在地面上 p->gravity = 0; } } // 注意:这里只处理了跳跃。如果游戏设计是自动向右奔跑,可以在这里增加 p->x++ 之类的逻辑。 }这是一个非常简化的物理模型。gravity初始为负值(向上),每帧增加(受重力加速),所以角色速度会先减小到0,然后变为正值下落。
2. 更新障碍物(制造挑战)障碍物通常从屏幕右侧出现,向左移动。
void updateObstacles(GameState* state) { for (int i = 0; i < MAX_OBSTACLES; i++) { if (state->obstacles[i].isActive) { // 障碍物向左移动 state->obstacles[i].x--; // 如果障碍物移出屏幕左边界,则将其重置到右侧,并随机其属性 if (state->obstacles[i].x + state->obstacles[i].width < 0) { resetObstacle(&(state->obstacles[i]), state->screenWidth); } } } } void resetObstacle(Obstacle* obs, int screenWidth) { obs->x = screenWidth + rand() % 20; // 从屏幕右侧稍远的位置随机出现 obs->width = 2 + rand() % 4; // 随机宽度在2到5之间 obs->shape = (rand() % 2) ? '#' : '^'; // 随机选择障碍物外观 obs->isActive = 1; }3. 碰撞检测(游戏成败的关键)碰撞检测决定了玩家是否碰到障碍物。由于我们使用字符网格,可以采用简单的“轴对齐包围盒”(AABB)检测。
int checkCollision(const Player* p, const Obstacle* obs) { // 玩家简化成一个点(其x坐标),障碍物是一个从obs->x开始,宽度为obs->width的矩形 // 检测玩家的x坐标是否在障碍物的水平范围内,并且玩家的y坐标是否与障碍物所在行重合(或在一定容差内) // 更精确一点,可以认为玩家也有宽度,比如占据1列。 int playerLeft = p->x; int playerRight = p->x; // 如果玩家占1列,则左右相同 int obstacleLeft = obs->x; int obstacleRight = obs->x + obs->width - 1; // 水平方向有重叠 int horizontalOverlap = !(playerRight < obstacleLeft || playerLeft > obstacleRight); // 垂直方向:判断玩家是否站在“地面”上,而障碍物也在地面行。如果玩家跳跃高度不足以越过障碍物高度,则碰撞。 // 这里我们简化:如果玩家在地面行(或接近地面),且水平重叠,则碰撞。 int verticalOverlap = (p->y >= obs->y); // 假设障碍物y坐标就是地面行 return (horizontalOverlap && verticalOverlap); } void checkAllCollisions(GameState* state) { for (int i = 0; i < MAX_OBSTACLES; i++) { if (state->obstacles[i].isActive) { if (checkCollision(&(state->player1), &(state->obstacles[i]))) { state->gameOver = 1; // 可以在这里记录是哪个玩家失败了 return; } if (checkCollision(&(state->player2), &(state->obstacles[i]))) { state->gameOver = 1; return; } } } }在updateGame函数中,依次调用updatePlayers、updateObstacles和checkAllCollisions,就完成了一帧的逻辑更新。
4.3 渲染模块:在控制台上“作画”
渲染模块renderGame负责将内存中的游戏状态(结构体数据)变成屏幕上可见的字符画面。核心思想是:先清屏,然后从头绘制所有元素。
1. 清屏与光标隐藏直接使用system(“cls”)是最简单的清屏方法,但它在快速刷新时可能会导致屏幕闪烁。更高级的做法是使用“双缓冲区”,但为了入门简单,我们先用cls。为了美观,我们还可以把光标隐藏起来。
void clearScreen() { system("cls"); } void hideCursor() { HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cursorInfo; GetConsoleCursorInfo(hOutput, &cursorInfo); cursorInfo.bVisible = FALSE; // 隐藏光标 SetConsoleCursorInfo(hOutput, &cursorInfo); }2. 绘制游戏场景绘制顺序一般是:先画背景和静态元素,再画动态物体(障碍物),最后画角色(确保角色在顶层)。
void renderGame(const GameState* state) { clearScreen(); // 清空上一帧画面 // 1. 绘制地面 COORD groundPos = {0, state->groundY}; SetConsoleCursorPosition(hOutput, groundPos); for (int i = 0; i < state->screenWidth; i++) { printf("-"); // 用一连串的‘-’表示地面 } // 2. 绘制所有障碍物 for (int i = 0; i < MAX_OBSTACLES; i++) { if (state->obstacles[i].isActive) { COORD obsPos = {state->obstacles[i].x, state->obstacles[i].y}; SetConsoleCursorPosition(hOutput, obsPos); for (int w = 0; w < state->obstacles[i].width; w++) { printf("%c", state->obstacles[i].shape); } } } // 3. 绘制玩家1 COORD p1Pos = {state->player1.x, state->player1.y}; SetConsoleCursorPosition(hOutput, p1Pos); printf("%c", state->player1.shape); // 例如‘@’ // 4. 绘制玩家2 COORD p2Pos = {state->player2.x, state->player2.y}; SetConsoleCursorPosition(hOutput, p2Pos); printf("%c", state->player2.shape); // 例如‘&’ // 5. 绘制分数等信息(在屏幕顶部) COORD infoPos = {0, 0}; SetConsoleCursorPosition(hOutput, infoPos); printf("Score: %d | Player1: @ | Player2: & | Press ESC to quit", state->score); // 6. 强制刷新输出缓冲区(在某些环境下可能需要) fflush(stdout); }注意事项:频繁调用
system(“cls”)和SetConsoleCursorPosition进行全屏重绘,在低速终端上可能会有闪烁感。一个优化技巧是“局部更新”:只重绘那些位置发生变化的字符。但对于我们这个规模的项目,全屏重绘完全可接受,代码也更清晰。
5. 项目整合与运行调试
5.1 主函数与初始化
现在,我们把所有模块像拼图一样组合到main函数里。
int main() { // 初始化随机数种子 srand((unsigned int)time(NULL)); // 初始化控制台 HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); HANDLE hInput = GetStdHandle(STD_INPUT_HANDLE); SetConsoleTitle("双人跑酷 - Dev-C++"); system("mode con cols=80 lines=25"); hideCursor(); // 设置输入模式为非行缓冲 DWORD mode; GetConsoleMode(hInput, &mode); SetConsoleMode(hInput, mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) | ENABLE_WINDOW_INPUT); // 初始化游戏状态 GameState game; game.screenWidth = 80; game.screenHeight = 25; game.groundY = 20; // 地面在第20行 game.gameOver = 0; game.score = 0; // 初始化玩家1 game.player1.x = 10; game.player1.y = game.groundY; game.player1.shape = '@'; game.player1.isJumping = 0; game.player1.gravity = 0; // 初始化玩家2 game.player2.x = 20; game.player2.y = game.groundY; game.player2.shape = '&'; game.player2.isJumping = 0; game.player2.gravity = 0; // 初始化障碍物 for (int i = 0; i < MAX_OBSTACLES; i++) { resetObstacle(&(game.obstacles[i]), game.screenWidth); // 让障碍物在x轴上分散开,避免扎堆出现 game.obstacles[i].x += i * 15; game.obstacles[i].y = game.groundY; // 障碍物也在地面行 } // 进入游戏主循环 gameLoop(&game); // 游戏结束,显示结束信息 COORD endPos = {game.screenWidth / 2 - 10, game.screenHeight / 2}; SetConsoleCursorPosition(hOutput, endPos); printf("游戏结束!最终得分:%d", game.score); Sleep(3000); // 停留3秒 return 0; }你需要确保之前定义的hOutput和hInput是全局变量,或者在函数间以参数传递。为了代码简洁,我建议将它们定义为全局变量。
5.2 编译、运行与常见问题
在Dev-C++中,点击“编译运行”(F11)即可。如果代码没有语法错误,一个控制台窗口会弹出,你的双人跑酷游戏就运行起来了!
常见问题与排查技巧实录:
问题:程序一闪而过,窗口直接关闭。
- 原因:最可能的原因是
main函数执行完毕正常退出了。检查你的gameLoop函数,确保while (!state->gameOver)这个循环条件正确,并且有逻辑能将gameOver设置为1(比如碰撞检测或按ESC键)。 - 解决:在
main函数的return 0;前加一句system(“pause”);或getchar();,可以临时让窗口停住,方便查看错误信息。
- 原因:最可能的原因是
问题:按键没反应或反应迟钝。
- 原因A:输入模式没有设置正确。确保成功调用了
SetConsoleMode关闭了ENABLE_LINE_INPUT。 - 原因B:游戏帧率太快,输入处理循环跑得太快,
ReadConsoleInput可能来不及捕获短按的按键。或者帧率太慢,导致输入响应延迟。 - 解决:检查帧率控制逻辑。确保
processInput函数被稳定地每帧调用。可以在processInput里打印一下读取到的虚拟键码,确认是否正确捕获。
- 原因A:输入模式没有设置正确。确保成功调用了
问题:角色或障碍物移动时屏幕闪烁严重。
- 原因:
system(“cls”)清屏是导致闪烁的主要原因。 - 解决(进阶):实现“双缓冲区”。原理是创建两个屏幕缓冲区,在一个“后台缓冲区”里完成所有绘制,然后一次性切换到前台显示。这需要用到
CreateConsoleScreenBuffer、SetConsoleActiveScreenBuffer等更复杂的API。对于初学者,可以尝试减少不必要的重绘,或者稍微降低帧率(如将frameTime改为1.0/20.0)。
- 原因:
问题:两个角色或障碍物重叠时,显示异常。
- 原因:后绘制的字符会覆盖先绘制的字符。这是正常的,因为控制台每个位置只能显示一个字符。
- 解决:调整绘制顺序。确保角色最后绘制,这样他们就会显示在最上层。如果希望障碍物覆盖角色(比如角色跳到障碍物后面),则需要更精细的Z轴(深度)管理,这在本项目中比较复杂,通常不需要。
问题:Dev-C++编译时报错,提示某些函数未定义或找不到标识符。
- 原因:通常是拼写错误,或者没有包含必要的头文件。
- 解决:仔细检查函数名和变量名。确保
#include <windows.h>在最前面。MinGW编译器可能需要#include <conio.h>来使用_kbhit(),但我们主要用Windows API,所以问题不大。如果使用srand和rand,确保包含了<stdlib.h>和<time.h>。
6. 功能扩展与优化思路
一个基础版本完成后,你可以像给房子装修一样,为游戏添加更多有趣的功能:
增加游戏性:
- 分数系统:每安全度过一段时间,或者每跳过一个障碍物就加分。
- 生命值:碰撞后不是立即游戏结束,而是减少生命值,多条命增加容错率。
- 道具系统:随机出现一些字符代表道具(如‘*’代表加速,‘$’代表无敌),玩家吃到后有临时效果。
美化与反馈:
- 颜色:使用
SetConsoleTextAttribute(hOutput, color)函数可以为输出的字符设置前景色和背景色。让玩家、障碍物、地面拥有不同颜色,画面会生动很多。 - 音效:虽然控制台本身不能播放复杂音频,但可以用
Beep(frequency, duration)函数发出简单的蜂鸣声,作为跳跃、碰撞的音效反馈。 - 更精细的图形:用多个字符组合成更大的“精灵图”(Sprite),比如用
/|\和/-\\可以组合成一个奔跑的小人动画。
- 颜色:使用
代码优化:
- 双缓冲区:如前所述,彻底解决闪烁问题。
- 模块化:将玩家、障碍物、渲染、输入等代码分别放入不同的
.c和.h文件中,使项目结构更清晰。 - 配置文件:将屏幕大小、角色速度、重力常数、按键设置等参数写到文件里,方便调整而不需要重新编译。
这个项目麻雀虽小,五脏俱全。从环境配置、API学习、数据结构设计、主循环构建、到输入处理、物理模拟、碰撞检测、画面渲染,你完整地走了一遍游戏开发的核心流程。用最原始的控制台字符画,你创造了一个可以和朋友一起竞技的、充满乐趣的双人跑酷世界。这其中的成就感,远比直接使用一个成熟的游戏引擎要强烈得多,因为它背后的每一个像素(字符)的跳动,都完全由你的代码逻辑所驱动。
