简介这是一份面向C初学者与游戏开发爱好者的坦克大战游戏源码基于C语言与easyX图形库实现适合用来练习Windows平台图形编程、理解游戏循环与模块化设计。压缩包共29个文件约692KB包含5个cpp源文件与5个头文件构成核心逻辑7个png与7个jpg图片用于坦克、地图与界面素材2个wav音频提供音效另有vcxproj项目文件、filters过滤器及readme说明可直接用Visual Studio打开编译运行。项目按坦克、子弹、地图、菜单等模块拆分便于阅读与二次扩展已有293人学习下载。读者可从中掌握事件处理、碰撞检测、资源加载与音效播放等关键环节并借鉴其目录组织方式作为课程设计或自学练手项目都较为合适。1. 从一张贴图到可玩关卡C 与 EasyX 做坦克大战到底在做什么很多人第一次听到「基于 C 和 EasyX 引擎的坦克大战游戏设计源码」脑子里浮现的是小时候红白机那个双人对战画面但真正动手时卡住的地方往往不是游戏逻辑而是「窗口怎么开、图怎么贴、键怎么读」。EasyX 是 Windows 平台上一套轻量的 C 图形库它把 GDI 那套繁琐的绘图接口封装成initgraph、loadimage、putimage这类直白函数配合 Visual Studio 或 Dev-C 就能在半小时内让一个方块动起来。坦克大战这个题材恰好覆盖了游戏开发最核心的几件事实时输入、帧循环、碰撞检测、地图数据、状态机。它不像贪吃蛇那样简单到没有架构也不像 3D 引擎那样一上来就被数学劝退是 C 入门到小游戏实战之间最合适的练手项目。这篇笔记面向的是想拿一份能跑、能改、能扩展的坦克大战源码的 C 学习者以及需要课程设计案例的在校同学我会把窗口初始化、地图表示、坦克移动、子弹与碰撞、关卡数据这几条主线拆开讲清楚参数怎么设、哪里容易翻车都写明白。2. 环境搭建与 EasyX 最小可运行框架先让窗口活起来2.1 为什么选 EasyX 而不是 SDL 或 Qt选型这件事得先讲清楚否则后面踩坑会怀疑人生。EasyX 的本质是对 Windows GDI 的 C 封装它的优势是零依赖、头文件加库文件丢进工程就能用绘图 API 是同步阻塞式的putimage调用完图就已经在屏幕上了不需要理解渲染管线、双缓冲交换、事件队列这些概念。对于坦克大战这种 2D、像素级、帧率要求不高的项目EasyX 的开发效率远高于 SDL。代价是它只支持 Windows且没有硬件加速画面元素上千后会有明显掉帧。如果你的目标是课程设计或者个人练手EasyX 完全够用如果要做跨平台或者商业级 2D 游戏SDL2 或 raylib 更合适。这里锁定 EasyX因为标题里的技术栈就是它。环境上最常见的组合是 Visual Studio 2019/2022 社区版加 EasyX 官方安装包安装程序会自动把graphics.h、easyx.h和对应的 lib 文件放进 VS 的 include 与 lib 目录。如果你用 VS Code需要手动配置c_cpp_properties.json里的 includePath 和 tasks.json 里的链接参数这一步是新手翻车高发区后面避坑章节会细说。Dev-C 也能用但它的编译器版本较老对 C11 以上特性支持不完整不建议。2.2 一个能编译能出画面的最小骨架先别急着写坦克把最小骨架跑通确认环境没问题再往上堆逻辑。下面这段代码创建 640x480 的窗口加载一张背景图并进入消息循环按 ESC 退出。#include graphics.h #include conio.h int main() { // 创建 640x480 的窗口第三个参数不加就是无边框 initgraph(640, 480); // 设置背景色为黑色清屏一次 setbkcolor(BLACK); cleardevice(); // 开启批量绘图减少闪烁这是 EasyX 双缓冲的关键 BeginBatchDraw(); while (true) { // 处理 Windows 消息防止窗口无响应 ExMessage msg; while (peekmessage(msg, EX_KEY)) { if (msg.message WM_KEYDOWN msg.vkcode VK_ESCAPE) { EndBatchDraw(); closegraph(); return 0; } } // 这里后续放游戏逻辑与绘制 cleardevice(); setfillcolor(GREEN); fillrectangle(100, 100, 140, 140); // 先画个方块占位 FlushBatchDraw(); // 把这一帧真正刷到屏幕 Sleep(16); // 约 60 帧每秒 } }逻辑说明initgraph负责创建窗口和设备上下文BeginBatchDraw与FlushBatchDraw配对使用所有绘制先画在内存 DC 上最后一次性提交这是消除画面闪烁的标准做法。peekmessage是非阻塞取消息只取键盘事件避免鼠标移动消息把队列塞满。Sleep(16)控制帧间隔16 毫秒约等于 60 FPS这个值直接决定游戏手感后面调参还会提到。参数说明initgraph的宽高建议用 640x480 或 800x600坦克大战的经典地图是 13x13 格每格 32 或 48 像素640 宽刚好放 13 个 48 像素格加边距。Sleep的值不要低于 10否则 CPU 占用飙升也不要高于 33否则移动会有明显拖影。peekmessage的第二个参数用EX_KEY只过滤键盘消息如果你要加鼠标点击菜单改成EX_MOUSE | EX_KEY。提示如果你的 VS 报「无法打开 graphics.h」九成是 EasyX 没装到当前 VS 版本对应的目录重装时注意勾选正确的 VS 版本。3. 地图数据与坦克移动把格子、坐标和按键串起来3.1 用二维数组表示地图别用像素硬编码坦克大战的地图本质是一张网格墙、草地、河流、基地各占一格。最忌讳的做法是把每个障碍物的像素坐标一个个写死那样改地图等于重写代码。正确做法是用二维整型数组存地图每个数字代表一种地形绘制和碰撞都查这张表。// 0 空地 1 砖墙 2 钢墙 3 草地 4 河流 5 基地 const int MAP_W 13; const int MAP_H 13; const int TILE 48; // 每格 48 像素 int map[MAP_H][MAP_W] { {2,2,2,2,2,2,2,2,2,2,2,2,2}, {2,0,0,0,0,0,0,0,0,0,0,0,2}, {2,0,1,1,0,0,0,0,0,1,1,0,2}, {2,0,1,0,0,0,0,0,0,0,1,0,2}, {2,0,0,0,0,3,3,3,0,0,0,0,2}, {2,0,0,0,0,3,5,3,0,0,0,0,2}, {2,0,0,0,0,3,3,3,0,0,0,0,2}, {2,0,1,0,0,0,0,0,0,0,1,0,2}, {2,0,1,1,0,0,0,0,0,1,1,0,2}, {2,0,0,0,0,0,0,0,0,0,0,0,2}, {2,0,0,0,0,0,0,0,0,0,0,0,2}, {2,0,0,0,0,0,0,0,0,0,0,0,2}, {2,2,2,2,2,2,2,2,2,2,2,2,2} }; // 把格子坐标转成屏幕像素坐标 inline int ToPixelX(int col) { return col * TILE; } inline int ToPixelY(int row) { return row * TILE; }逻辑说明地图数组的每个元素对应一个格子绘制时双重循环遍历根据数值选颜色或贴图。碰撞检测时把坦克的像素坐标除以TILE得到格子坐标查数组判断能不能走。这种「逻辑格子 像素渲染」的分离是 2D 游戏的标准架构改地图只动数组不动渲染代码。参数说明TILE取 48 是因为 640/13 约等于 49取 48 留出边距如果你用 800x600 窗口TILE可以取 60。地图数组的边界建议全填钢墙省得写越界判断。基地那格用 5 标记游戏结束条件就是这格被子弹打中。3.2 坦克移动按键状态、方向与格子对齐坦克移动最容易翻车的地方是「按住方向键只动一格就停」或者「斜着穿墙」。根因是没有区分「按键按下」和「按键持续按住」以及没有做移动前的碰撞预判。EasyX 里用GetAsyncKeyState可以拿到按键的实时状态比消息循环更适合做持续移动。struct Tank { int x, y; // 像素坐标左上角 int dir; // 0 上 1 右 2 下 3 左 int speed; // 每帧移动像素 bool alive; }; // 判断某个像素矩形是否与障碍物重叠 bool CanMoveTo(int px, int py, int size) { int left px / TILE; int right (px size - 1) / TILE; int top py / TILE; int bottom (py size - 1) / TILE; for (int r top; r bottom; r) for (int c left; c right; c) { if (r 0 || r MAP_H || c 0 || c MAP_W) return false; if (map[r][c] 1 || map[r][c] 2 || map[r][c] 4) return false; } return true; } void UpdateTank(Tank t) { int nx t.x, ny t.y; if (GetAsyncKeyState(VK_UP) 0x8000) { t.dir 0; ny - t.speed; } if (GetAsyncKeyState(VK_RIGHT) 0x8000) { t.dir 1; nx t.speed; } if (GetAsyncKeyState(VK_DOWN) 0x8000) { t.dir 2; ny t.speed; } if (GetAsyncKeyState(VK_LEFT) 0x8000) { t.dir 3; nx - t.speed; } if (CanMoveTo(nx, ny, TILE)) { t.x nx; t.y ny; } }逻辑说明GetAsyncKeyState返回值的最高位为 1 表示当前按下 0x8000就是取这一位。每次移动前先算出目标坐标用CanMoveTo检查目标矩形覆盖的所有格子是否可通行全部可通行才真正赋值。这样坦克永远不会穿墙也不会卡进障碍物里出不来。参数说明speed建议取 2 到 4 像素每帧取 1 太慢取 8 会直接穿过一格导致碰撞检测失效。坦克尺寸用TILE即 48 像素和格子对齐这样CanMoveTo的边界计算最简单。如果你想让坦克比格子小一点比如 40 像素留出视觉间隙CanMoveTo的 size 参数传 40 即可但要注意居中偏移。注意GetAsyncKeyState在窗口失焦时依然会返回按键状态正式项目里应该先判断窗口是否激活否则切出去按方向键坦克还在动。4. 子弹、碰撞与敌人 AI让游戏真正跑起来4.1 子弹的生成、飞行与销毁子弹逻辑看着简单但它是内存泄漏和性能问题的重灾区。常见错误是用new动态创建子弹却忘了delete或者子弹飞出屏幕后还在数组里占位。稳妥做法是用固定大小的数组加alive标记复用槽位。struct Bullet { int x, y; int dir; int speed; bool alive; bool fromPlayer; }; const int MAX_BULLETS 64; Bullet bullets[MAX_BULLETS]; void FireBullet(Tank t, bool fromPlayer) { for (int i 0; i MAX_BULLETS; i) { if (!bullets[i].alive) { bullets[i].alive true; bullets[i].fromPlayer fromPlayer; bullets[i].dir t.dir; bullets[i].speed 8; // 从坦克中心发出 bullets[i].x t.x TILE / 2 - 4; bullets[i].y t.y TILE / 2 - 4; return; } } } void UpdateBullets() { for (int i 0; i MAX_BULLETS; i) { if (!bullets[i].alive) continue; if (bullets[i].dir 0) bullets[i].y - bullets[i].speed; if (bullets[i].dir 1) bullets[i].x bullets[i].speed; if (bullets[i].dir 2) bullets[i].y bullets[i].speed; if (bullets[i].dir 3) bullets[i].x - bullets[i].speed; // 出界销毁 if (bullets[i].x 0 || bullets[i].x 640 || bullets[i].y 0 || bullets[i].y 480) { bullets[i].alive false; continue; } // 撞墙检测 int col (bullets[i].x 4) / TILE; int row (bullets[i].y 4) / TILE; if (row 0 row MAP_H col 0 col MAP_W) { if (map[row][col] 1) { map[row][col] 0; bullets[i].alive false; } else if (map[row][col] 2) { bullets[i].alive false; } else if (map[row][col] 5) { /* 基地被毁游戏结束 */ } } } }逻辑说明FireBullet遍历子弹池找空槽找不到就丢弃这次开火避免无限增长。UpdateBullets每帧更新位置出界或撞墙就置alive false槽位自动回收。砖墙被打中变成空地钢墙只销毁子弹不改变地形这是坦克大战的经典规则。参数说明MAX_BULLETS取 64 对单人关卡绰绰有余双人加敌人同屏一般不超过 20 发。子弹速度取 8 比坦克的 2 到 4 快一倍以上手感才对。子弹尺寸这里用 8x8检测时加 4 是取中心点如果你把子弹画成 6x6偏移量要相应改成 3。4.2 敌人 AI别一上来就写寻路敌人 AI 是很多人卡住的地方一上来就想写 A* 寻路结果代码量爆炸还跑不通。坦克大战的敌人行为其实很简单随机选一个方向走撞墙或走到路口时按一定概率换方向看到玩家或基地在正前方就开火。用状态机加随机数就够了。#include cstdlib struct Enemy { Tank tank; int moveTimer; // 还有多少帧换方向 int fireCooldown; }; void UpdateEnemy(Enemy e) { if (!e.tank.alive) return; // 计时到了就随机换方向 if (--e.moveTimer 0) { e.tank.dir rand() % 4; e.moveTimer 30 rand() % 60; // 0.5 到 1.5 秒 } int nx e.tank.x, ny e.tank.y; if (e.tank.dir 0) ny - e.tank.speed; if (e.tank.dir 1) nx e.tank.speed; if (e.tank.dir 2) ny e.tank.speed; if (e.tank.dir 3) nx - e.tank.speed; // 走不通就立刻换方向不等计时器 if (CanMoveTo(nx, ny, TILE)) { e.tank.x nx; e.tank.y ny; } else { e.moveTimer 0; } // 冷却好了就开火 if (--e.fireCooldown 0) { FireBullet(e.tank, false); e.fireCooldown 60 rand() % 60; } }逻辑说明moveTimer控制换方向频率撞墙时直接清零让它下一帧就换避免卡在墙角抖动。开火用独立的冷却计时器和移动解耦。rand()需要先srand(time(nullptr))播种否则每次运行行为完全一样。参数说明moveTimer初始值 30 到 90 帧对应 0.5 到 1.5 秒太短敌人像无头苍蝇太长显得呆滞。fireCooldown取 60 到 120 帧即 1 到 2 秒一发比玩家慢给玩家反应时间。敌人速度建议比玩家慢 1 像素取 1 到 2。提示rand()的随机性对游戏来说够用但如果你要做回放或联机同步得换成带种子的确定性随机否则两边行为对不上。5. 避坑与排查EasyX 坦克大战最常见的五个翻车点5.1 画面疯狂闪烁方块像在抽搐现象坦克和子弹移动时整个画面一闪一闪眼睛受不了。原因没有用BeginBatchDraw/FlushBatchDraw每次putimage或fillrectangle都直接写显存绘制过程中屏幕被刷新了多次。解决在initgraph之后立刻调用BeginBatchDraw()所有绘制完成后调用FlushBatchDraw()退出前EndBatchDraw()。注意cleardevice要放在批量绘制内部不能放在FlushBatchDraw之后。5.2 按键没反应或者按一下动十格现象方向键按了坦克不动或者轻轻一按冲出去老远。原因用了getch()这类阻塞函数或者把GetAsyncKeyState的返回值直接当布尔用而没取0x8000位。解决持续移动一律用GetAsyncKeyState(VK_XX) 0x8000单次触发比如发射子弹才用消息循环里的WM_KEYDOWN。另外检查窗口是否获得焦点失焦时GetAsyncKeyState仍返回按下状态。5.3 坦克卡在墙角来回抖动现象坦克贴着墙走时左右横跳或者卡在两块砖之间出不来。原因碰撞检测只判断了目标点没有判断坦克整个矩形覆盖的所有格子导致部分嵌入墙体后无法脱出。解决CanMoveTo必须用坦克的左上角和右下角算出覆盖的格子范围逐格检查。坦克尺寸和TILE对齐能大幅减少这类问题如果坦克比格子小要保证居中偏移量在计算时被正确加上。5.4 子弹打中砖墙但墙不消失现象子弹碰到砖墙就没了墙还在。原因子弹的碰撞检测用的是子弹中心点所在格子但子弹速度是 8一帧跨过了 8 像素可能直接跳过了薄墙所在的格子。解决子弹速度不要超过TILE的一半或者做连续碰撞检测即从上一帧位置到当前位置之间采样多个点逐个判断。最省事的做法是把子弹速度降到 6 以下配合 48 像素的格子基本不会穿透。5.5 敌人越打越多帧率越来越低现象玩了几分钟后画面变卡敌人数量异常增长。原因敌人被击毁后alive置了 false但生成新敌人的逻辑没有检查上限或者子弹数组满了之后FireBullet静默失败但敌人仍在疯狂调用。解决给敌人数量设硬上限比如同屏最多 4 个用计数器维护子弹池满了就跳过开火不要动态扩容。每帧遍历数组时先判断alive再处理避免对已销毁对象做无效计算。6. 从能跑到好玩关卡数据外置与手感调参的实战技巧把游戏跑通只是第一步真正让它像个作品的是关卡设计和手感调参。我一般会把地图数据从代码里抽出来放到单独的文本文件或者头文件里用二维数组初始化这样改关卡不用重新编译逻辑代码。具体做法是定义一个levels.h里面放多个int level1[13][13]、int level2[13][13]主程序用一个指针指向当前关卡通关后切换指针并重置坦克和子弹状态。这样加十关也只是复制数组改数字的事。手感调参有几个关键值值得反复试。坦克速度我试过 1、2、3、4最后定在 3因为 2 太拖沓4 在 48 像素格子里转向时会有明显的「滑步」感。子弹速度定在 10比坦克快三倍多打出去有干脆利落的感觉。敌人开火冷却从 60 帧调到 90 帧玩家才有空间走位。帧间隔Sleep(16)是 60 FPS 的基准如果你发现移动有细微抖动可以改成Sleep(15)或Sleep(17)微调但不要低于 10。验证游戏是否真的稳定我有个笨办法但很管用让敌人 AI 自己跑十分钟玩家不操作看会不会崩溃、内存是否持续增长、帧率是否下降。用任务管理器盯住进程的内存占用如果十分钟后涨了几十 MB八成是某个数组在偷偷扩容或者贴图资源重复加载。EasyX 的loadimage每调用一次都会占内存正确做法是在initgraph之后统一加载所有贴图到IMAGE对象游戏循环里只putimage绝不重复加载。最后说个我踩过的坑透明贴图。坦克和子弹如果直接用putimage贴上去图片的白色背景会把地图盖住。EasyX 里要用putimage的掩码版本或者先用SetWorkingImage处理最简单的办法是把图片做成带 Alpha 通道的 PNG然后用putimage配合SetImageTransparent之类的接口。我当初不知道这个坦克开过草地时屁股后面拖着一块白方块被同学笑了半天。后来养成习惯所有精灵图先确认透明通道再写渲染代码。这套源码的价值不在于它有多完整而在于它把 C 的类、数组、循环、条件判断和 EasyX 的绘图、输入、计时全部串在了一起改一个参数就能看到画面变化这种即时反馈是看书学不来的。如果你打算拿它做课程设计建议在敌人 AI 和关卡编辑上多花时间这两块最能体现工作量。希望帮到你。本文还有配套的精品资源点击获取