☰
C++与EasyX手搓坦克大战:从环境搭建到碰撞检测的完整实战
2026/10/5 5:46:13 网站建设 项目流程

简介:这是一份面向C++初学者与游戏开发爱好者的坦克大战游戏完整源码,基于C++语言与easyX图形库实现,适合作为图形编程入门、课程设计或小型项目练手的实践素材。压缩包共29个文件,约692KB,包含5个cpp源文件与5个头文件构成游戏核心逻辑,7个png与7个jpg图片用于坦克、地图、界面等素材,另有2个wav音效文件及vcxproj项目文件,可直接在Visual Studio中打开编译运行。项目已做模块化拆分,坦克、子弹、地图、菜单等各自独立成文件,便于理解游戏循环、事件处理与碰撞检测等关键机制。目前已有293人学习下载,读者可借此掌握easyX绘图与音效接口的调用方式,并参考其目录组织与代码结构,快速搭建属于自己的小游戏框架。

1. 从零手搓坦克大战:为什么 C++ 配 EasyX 仍是新手最稳的起手式

很多人第一次想写游戏,脑子里蹦出来的要么是 Unity 拖控件,要么是 Python 的 pygame。但如果你搜到「基于 C++ 和 EasyX 引擎的坦克大战游戏设计源码」,大概率你想要的是一条更硬核、更贴近底层、又能快速看到画面的路径。EasyX 是 Windows 平台上一个极轻量的图形库,它把 Win32 GDI 那套繁琐的窗口、消息循环、绘图 API 封装成了类似 Turbo C 时代initgraph的简单接口。你不需要理解 COM、不需要配 DirectX,装好 Visual Studio 或 VS Code 配好 C++ 环境,几行代码就能弹出一个窗口画矩形。坦克大战这个题材恰好覆盖了游戏开发最核心的几个模块:地图格子系统、实时输入、碰撞检测、对象生命周期管理、简单 AI。它不像贪吃蛇那样过于简单,也不像动作游戏那样一上来就压垮你。这篇文章我会按实际动手顺序,把从环境搭建到可运行源码的完整路径拆开,包括我踩过的那些编译不过、画面闪烁、坦克穿墙的坑。适合有 C++ 基础语法概念、想通过一个完整小项目把面向对象和游戏循环串起来的人。

2. 环境与工程骨架:把 EasyX 塞进你的 C++ 工具链

2.1 为什么选 Visual Studio 而不是 VS Code 裸配

热搜里「vscode配置c/c++环境」和「vscode c++」出现频率很高,但做 EasyX 项目我强烈建议用 Visual Studio 2019 或 2022 社区版。原因很直接:EasyX 官方只提供针对 MSVC 编译器的库文件,它的安装包会自动识别你机器上的 VS 版本并把头文件和 lib 塞进正确目录。如果你非要用 VS Code,就得手动配c_cpp_properties.json的 includePath、tasks.json 的链接参数,还要确保用的是 MSVC 而不是 MinGW。MinGW 下 EasyX 基本跑不起来,因为它的底层依赖 Windows GDI 的特定链接方式。我见过太多人卡在graphics.h找不到或者initgraph链接错误上,最后发现是编译器选错了。所以第一步:去 Visual Studio 官网下载社区版,安装时勾选「使用 C++ 的桌面开发」工作负载,确保 MSVC 和 Windows SDK 都装上。

2.2 安装 EasyX 与创建空项目

EasyX 的获取方式很简单,搜「EasyX 官网」下载最新版安装程序,运行后它会检测你已安装的 VS 版本,点安装即可。装完后新建一个「空项目」,注意不要选「Windows 桌面应用程序」模板,那个会预生成一堆 WinMain 代码干扰你。空项目建好后,右键源文件文件夹添加新建项,选 C++ 文件,命名main.cpp。然后关键一步:项目属性里确认「C/C++」→「常规」→「附加包含目录」已经自动包含了 EasyX 的头文件路径,通常安装程序会帮你配好。链接器→输入→附加依赖项里应该能看到EasyXa.lib或EasyXw.lib,前者是 ANSI 版本,后者是 Unicode 版本。我一般用 Unicode,因为 Windows 现代 API 默认走宽字符,避免中文乱码。

// main.cpp 最小验证程序 #include <graphics.h> // EasyX 核心头文件 #include <conio.h> // 用于 _getch() int main() { initgraph(640, 480); // 创建 640x480 的绘图窗口 setbkcolor(WHITE); // 设置背景色为白色 cleardevice(); // 用背景色清空屏幕 setfillcolor(RED); // 设置填充色为红色 fillrectangle(100, 100, 200, 200); // 画一个红色方块 _getch(); // 等待按键,防止窗口一闪而过 closegraph(); // 关闭绘图窗口 return 0; }

这段代码是整个项目的试金石。initgraph负责创建窗口并初始化图形环境,宽高参数根据你屏幕分辨率调整,坦克大战常用 640x480 或 800x600。cleardevice不是必须的,但养成习惯,每帧重绘前清屏。fillrectangle的四个参数是左上角 x、y 和右下角 x、y。_getch来自 conio.h,阻塞等待一个字符输入,不加这行窗口会瞬间关闭。如果这段跑不起来,后面所有代码都白搭。常见报错是无法打开源文件 graphics.h,说明 EasyX 没装好或者项目属性里的包含路径不对;另一个是LNK2019 无法解析的外部符号,说明链接器没找到 lib 文件,检查附加依赖项。

2.3 工程目录怎么分才不乱

坦克大战源码如果全塞一个 main.cpp,写到后面改一个参数要翻几百行。我习惯按职责拆成几个文件:main.cpp只放游戏主循环和初始化;Tank.h/Tank.cpp管坦克基类和玩家、敌人派生;Map.h/Map.cpp管地图格子和碰撞;Bullet.h/Bullet.cpp管子弹;GameConfig.h放常量比如格子大小、速度、窗口宽高。这样编译时每个 cpp 独立编译,改一个模块不影响其他。注意头文件里用#pragma once防止重复包含,全局变量尽量用extern在头文件声明、在 cpp 里定义,避免多重定义链接错误。

3. 游戏循环与地图格子:让坦克动起来的第一行逻辑

3.1 双缓冲解决画面闪烁的玄学问题

EasyX 默认的绘图方式是直接画到窗口上,如果你在循环里先cleardevice再画所有对象,屏幕会疯狂闪烁,眼睛根本受不了。这是新手遇到的第一个「玄学」现象:代码逻辑明明对,画面就是抖。解决办法是双缓冲:用BeginBatchDraw()开启批量绘制,所有绘图操作先画到内存缓冲区,最后FlushBatchDraw()一次性刷到屏幕。这样每帧只刷新一次,闪烁消失。注意BeginBatchDraw和FlushBatchDraw要成对出现,通常放在主循环的头部和尾部。

#include <graphics.h> #include <conio.h> int main() { initgraph(640, 480); BeginBatchDraw(); // 开启双缓冲 while (true) { cleardevice(); // 清空缓冲区 // 这里画地图、坦克、子弹 setfillcolor(GREEN); fillrectangle(0, 0, 32, 32); FlushBatchDraw(); // 把缓冲区内容刷到屏幕 Sleep(16); // 约 60 帧每秒 } EndBatchDraw(); closegraph(); return 0; }

Sleep(16)控制帧率,16 毫秒约等于 62.5 帧每秒,实际游戏里可以用GetTickCount()做更精确的帧时间控制,但新手项目用固定 Sleep 足够。EndBatchDraw在循环退出后调用,释放双缓冲资源。如果忘了FlushBatchDraw,屏幕会一直黑屏或者只显示第一帧,这也是常见翻车点。

3.2 地图格子与坐标换算

坦克大战的地图本质是一个二维数组,每个元素代表一个格子类型:空地、砖墙、钢墙、草丛、河流。格子大小通常取 32x32 或 40x40 像素,窗口 640x480 对应 20x15 个格子。坐标换算公式:格子列号col = x / TILE_SIZE,行号row = y / TILE_SIZE。坦克移动时不能直接改像素坐标然后判断,而是先算出目标格子,检查该格子是否可通行,再决定是否更新像素坐标。我一般把地图数据存成int map[15][20],0 表示空地,1 表示砖墙,2 表示钢墙。碰撞检测就是查表。

const int TILE_SIZE = 32; const int ROWS = 15; const int COLS = 20; int map[ROWS][COLS] = {0}; // 全零初始化,后续填充墙壁 // 判断像素坐标 (x, y) 是否可通行 bool canMoveTo(int x, int y) { int col = x / TILE_SIZE; int row = y / TILE_SIZE; if (col < 0 || col >= COLS || row < 0 || row >= ROWS) { return false; // 出界 } return map[row][col] == 0; // 只有空地能走 }

这里有个边界坑:坦克有体积,不能只判断中心点。常见做法是取坦克矩形的四个角分别判断,或者用坦克的包围盒与格子做 AABB 检测。我一般取左上角和右下角两个点,如果都通行才允许移动。canMoveTo的参数是像素坐标,内部转格子坐标。注意整数除法是向下取整,对于正坐标没问题,但如果坦克坐标出现负数,除法会向零取整导致判断错误,所以出界检查要放在除法之前或者用浮点。

3.3 键盘输入与坦克移动的帧同步

EasyX 没有内置的键盘事件队列,常用GetAsyncKeyState或者peekmessage。我习惯用GetAsyncKeyState(VK_UP)这类调用,它直接查询物理按键状态,适合实时游戏。但要注意它返回的是 short,最高位为 1 表示按下,所以判断条件是GetAsyncKeyState(VK_UP) & 0x8000。每帧检查方向键,如果有按下就尝试移动坦克。移动逻辑:根据方向计算目标像素坐标,调用canMoveTo判断,通过则更新坦克坐标。这里有个帧同步问题:如果每帧移动速度是 5 像素,60 帧就是 300 像素每秒,太快了。通常坦克速度设 2 到 3 像素每帧,或者用时间差乘以速度值。新手项目直接固定每帧移动 2 像素,简单可靠。

// 玩家坦克结构体简化版 struct Tank { int x, y; // 像素坐标 int dir; // 0上 1下 2左 3右 int speed; // 每帧移动像素 }; void updatePlayer(Tank& t) { int dx = 0, dy = 0; if (GetAsyncKeyState(VK_UP) & 0x8000) { dy = -t.speed; t.dir = 0; } if (GetAsyncKeyState(VK_DOWN) & 0x8000) { dy = t.speed; t.dir = 1; } if (GetAsyncKeyState(VK_LEFT) & 0x8000) { dx = -t.speed; t.dir = 2; } if (GetAsyncKeyState(VK_RIGHT) & 0x8000) { dx = t.speed; t.dir = 3; } if (dx != 0 || dy != 0) { int nx = t.x + dx; int ny = t.y + dy; if (canMoveTo(nx, ny) && canMoveTo(nx + TILE_SIZE - 1, ny + TILE_SIZE - 1)) { t.x = nx; t.y = ny; } } }

canMoveTo调两次分别检查左上角和右下角,确保坦克整个矩形都在可通行区域内。TILE_SIZE - 1是因为矩形右下角坐标是开区间,实际像素范围是[x, x+TILE_SIZE-1]。如果只检查一个点,坦克会有一半身子嵌进墙里,视觉上就是穿墙。这个坑我踩过,调了半天才发现是碰撞盒没对齐。

4. 坦克、子弹与碰撞:对象管理的血泪经验

4.1 用面向对象拆出 Tank 基类

热搜里「设计模式与游戏完美开发图书资源」说明很多人关心怎么把设计模式用到游戏里。坦克大战不需要上太重的模式,但继承和多态是绕不开的。我定义一个Tank基类,包含坐标、方向、速度、生命值、绘制函数draw()和更新函数update()。玩家坦克PlayerTank继承它,重写update()处理键盘输入;敌人坦克EnemyTank也继承它,重写update()处理 AI 移动和射击。基类里放公共的移动检测和碰撞逻辑,派生类只关心自己的行为差异。这样加新类型坦克时不用改主循环,符合开闭原则。

// Tank.h #pragma once #include <graphics.h> class Tank { public: int x, y; int dir; // 0上 1下 2左 3右 int speed; int hp; bool alive; Tank(int startX, int startY, int s) : x(startX), y(startY), speed(s), hp(1), alive(true), dir(0) {} virtual ~Tank() {} virtual void update() = 0; // 纯虚函数,派生类必须实现 virtual void draw() { setfillcolor(alive ? GREEN : BLACK); fillrectangle(x, y, x + 32, y + 32); } };

纯虚函数update()强制派生类实现自己的更新逻辑。draw()用虚函数但给了默认实现,派生类可以覆盖。注意析构函数要虚,否则通过基类指针删除派生类对象时不会调用派生类析构,造成内存泄漏。这个点在 C++ 面试里常考,实际项目里也真的会翻车。

4.2 子弹对象池与生命周期

子弹如果用new动态创建、delete销毁,频繁分配释放会导致内存碎片,而且容易忘记 delete 造成泄漏。我一般用对象池:预先分配一个固定大小的子弹数组,每个子弹有active标志。发射时找一个active == false的槽位激活它,子弹飞出屏幕或击中目标后把active设回 false。这样没有动态内存操作,性能稳定。子弹结构体包含坐标、方向、速度、归属(玩家还是敌人)。更新时遍历数组,只处理 active 的子弹,移动后检查是否出界或碰撞。

const int MAX_BULLETS = 100; struct Bullet { int x, y; int dir; int speed; bool active; bool fromPlayer; // true 表示玩家子弹 }; Bullet bullets[MAX_BULLETS]; void fireBullet(int x, int y, int dir, bool fromPlayer) { for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].active) { bullets[i] = {x, y, dir, 8, true, fromPlayer}; return; } } // 池满了,忽略这次发射 } void updateBullets() { for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].active) continue; switch (bullets[i].dir) { case 0: bullets[i].y -= bullets[i].speed; break; case 1: bullets[i].y += bullets[i].speed; break; case 2: bullets[i].x -= bullets[i].speed; break; case 3: bullets[i].x += bullets[i].speed; break; } // 出界检测 if (bullets[i].x < 0 || bullets[i].x > 640 || bullets[i].y < 0 || bullets[i].y > 480) { bullets[i].active = false; } } }

fireBullet遍历池找空位,找不到就放弃。子弹速度设 8 像素每帧,比坦克快很多,符合直觉。出界检测用窗口边界,实际项目里还要检测是否击中墙壁或坦克。注意子弹的初始坐标应该从坦克炮口位置算,不能直接用坦克中心,否则子弹看起来从坦克肚子里冒出来。我一般根据方向偏移 16 像素。

4.3 碰撞检测的三种场景与参数

坦克大战的碰撞分三类:子弹与墙壁、子弹与坦克、坦克与坦克。子弹与墙壁:取子弹坐标转格子,查 map 值,如果是砖墙就销毁子弹并可能销毁墙。子弹与坦克:用 AABB 矩形相交判断,子弹的矩形和坦克的矩形有重叠就算击中。坦克与坦克:同样 AABB,但通常敌人之间不碰撞,只有玩家和敌人碰撞时玩家掉血。AABB 判断函数很简单:abs(x1 - x2) < w && abs(y1 - y2) < h,其中 w 和 h 是矩形宽高。注意用abs要包含<cstdlib>或<cmath>。

bool isRectOverlap(int x1, int y1, int w1, int h1, int x2, int y2, int w2, int h2) { return (x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2); }

这个函数是碰撞检测的核心,参数分别是两个矩形的左上角坐标和宽高。返回 true 表示重叠。子弹击中坦克后,坦克 hp 减一,hp 为零时 alive 设 false,子弹 active 设 false。注意子弹和坦克的矩形都要用实际绘制区域,不要用格子大小代替,否则判定会偏大或偏小。

5. 避坑与排查:那些让坦克穿墙、子弹消失的瞬间

5.1 坦克斜着穿墙:碰撞盒没对齐

现象:坦克在靠近墙角时,明明看着还有距离,却突然卡进墙里或者穿过去。原因:碰撞检测只用了坦克左上角一个点,而坦克有 32x32 的体积,右下角已经进入墙壁区域但没被检测到。解决:用两个点或者完整 AABB 检测,确保坦克整个矩形都在可通行区域内。我现在的习惯是封装一个canTankMoveTo(Tank& t, int nx, int ny)函数,内部检查四个角。

5.2 子弹一发射就消失:初始坐标在墙里

现象:按下发射键,子弹瞬间不见,没有飞出去。原因:子弹初始坐标用了坦克中心点,而坦克紧贴墙壁时中心点可能已经在墙的格子范围内,子弹一生成就触发墙壁碰撞被销毁。解决:子弹初始坐标根据方向偏移到坦克前方,比如向上发射时y = tank.y - 8,确保子弹出生点在坦克外部。同时子弹与墙壁的碰撞检测要放在移动之后,不要一生成就检测。

5.3 画面闪烁到眼瞎:忘了双缓冲

现象:坦克移动时屏幕疯狂闪烁,像老式 CRT 刷新率不足。原因:每帧cleardevice后直接绘制,绘图操作直接写显存,人眼能看到中间状态。解决:BeginBatchDraw和FlushBatchDraw包住整个绘制过程。注意BeginBatchDraw只需在循环外调用一次,FlushBatchDraw在每帧绘制结束后调用。如果每帧都BeginBatchDraw会出问题。

5.4 按键没反应:GetAsyncKeyState 返回值判断错误

现象:按方向键坦克不动,或者只有某个方向能动。原因:GetAsyncKeyState返回 short,最高位表示当前是否按下,但很多人写成if (GetAsyncKeyState(VK_UP)),这样只要最低位有值就为真,可能被其他状态干扰。解决:用& 0x8000掩码取最高位。另外注意GetAsyncKeyState是全局键盘状态,如果你的窗口没有焦点,按键仍然会被捕获,这可能导致调试时行为异常。

5.5 敌人坦克卡在角落抖动:AI 移动没有回退逻辑

现象:敌人坦克走到地图角落时原地抖动,像抽搐一样。原因:AI 每帧随机选方向,如果选到不可通行的方向就原地不动,下一帧又随机,看起来就是抖动。解决:AI 选方向时先检测该方向是否可通行,不可通行就换一个方向,或者记录上次方向,优先继续走。我一般给敌人加一个moveTimer,每隔一定帧数才换方向,避免每帧随机。

6. 进阶技巧:用状态机和帧动画让坦克大战更像样

6.1 用有限状态机管理敌人 AI

基础敌人 AI 就是随机移动加随机射击,但玩起来很傻。进阶做法是给敌人加状态:巡逻、追击、射击、逃跑。巡逻时沿固定路线走,发现玩家进入视野范围切换到追击,距离足够近切换到射击,血量低切换到逃跑。状态切换用switch或者函数指针表。我习惯用枚举加switch,简单直观。每个状态有自己的enter、update、exit逻辑,update里根据条件决定下一个状态。这样敌人行为有层次,不会像无头苍蝇。

enum EnemyState { PATROL, CHASE, ATTACK, FLEE }; struct EnemyTank : public Tank { EnemyState state; int stateTimer; void update() override { stateTimer++; switch (state) { case PATROL: // 沿固定方向移动,撞墙换方向 if (stateTimer > 60) { stateTimer = 0; /* 随机换方向 */ } break; case CHASE: // 朝玩家方向移动 break; case ATTACK: // 停止移动,朝玩家射击 break; case FLEE: // 远离玩家 break; } } };

stateTimer用来控制状态持续时间,避免频繁切换。实际项目里状态切换条件要调参,比如视野范围设 200 像素,攻击距离设 100 像素。这些数值没有标准答案,多跑几次看手感。

6.2 帧动画与精灵图切换

EasyX 本身没有精灵图概念,但你可以用IMAGE对象加载图片,然后用putimage的透明贴图版本putimage(x, y, &img, SRCAND)或者手动处理掩码。更简单的做法是用loadimage加载一张包含多帧的图片,根据当前帧号计算源矩形,用putimage的扩展版本绘制指定区域。坦克履带动画、爆炸效果都靠这个。如果不想用图片,也可以用fillrectangle画不同颜色块模拟帧切换,但视觉效果差很多。我建议至少给坦克加个方向变化时的颜色微调,让玩家能感知到状态变化。

6.3 用帧时间控制速度而不是固定像素

前面用Sleep(16)固定帧率,但如果电脑性能波动,游戏速度会不一致。进阶做法是记录上一帧时间,计算deltaTime,所有移动速度乘以deltaTime系数。这样无论帧率多少,坦克每秒移动的像素数恒定。EasyX 里可以用GetTickCount()获取毫秒时间。注意deltaTime要归一化,比如以 60 帧为基准,deltaTime = (now - last) / 16.67f。这样速度参数不用改,直接乘上去。

DWORD lastTick = GetTickCount(); while (true) { DWORD now = GetTickCount(); float deltaTime = (now - lastTick) / 16.67f; // 以 60fps 为 1.0 lastTick = now; // 移动时:t.x += speed * deltaTime; // 注意坐标要取整,因为绘图 API 需要 int }

deltaTime用浮点,最后坐标转 int 时用round或直接强制转换。这个技巧在跨设备时特别有用,高刷屏和普通屏体验一致。我一开始没做这个,后来换到 144Hz 显示器上坦克快得飞起,才回头补上。

6.4 源码组织与调试输出

最后说一个实用习惯:在GameConfig.h里用#define DEBUG_MODE 1控制调试输出,比如打印坦克坐标、子弹数量、碰撞结果。EasyX 窗口里可以用outtextxy在角落显示帧率和对象数。发布时把DEBUG_MODE设 0,所有调试代码用#if DEBUG_MODE包起来,不产生运行时开销。我还会在关键路径加assert,比如子弹池满时断言,帮助早期发现问题。这些习惯让后期改 bug 轻松很多,不用靠猜。

写到这里,坦克大战的核心骨架已经完整了。从环境搭建到双缓冲、格子碰撞、对象池、状态机,每一步都是实际写代码时绕不开的。我最大的教训是别一开始就追求完美架构,先把窗口弹出来、方块动起来,再逐步加功能。每次只改一个模块,改完立刻运行看效果,比一次性写几百行再调试快得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询