
简介纯C坦克大战代码是一份面向C初学者和进阶学习者的游戏开发实践项目适合在掌握基础语法后通过完整案例理解面向对象设计与游戏循环。项目围绕坦克、子弹、障碍物等类展开涉及继承与多态、状态机、事件处理、碰撞检测以及基于SDL或SFML的图形绘制、键盘交互和资源管理等知识可用于课程设计、自学练手或课堂示例改造。压缩包约1.37MB文件总数一栏为0、类型明细暂无数据无法据此判断具体目录构成从项目描述看内容应包含源码及配套素材便于编译运行和逐模块阅读。已有1295人学习说明该案例具有一定参考价值。读者可从中梳理游戏主循环、对象封装、输入响应和资源释放的实现路径并对照修改坦克移动、射击与碰撞逻辑积累调试经验对需要完成小型C项目或准备游戏开发入门的人来说这份代码可作为可拆解、可扩展的实践样本。1. 从一份纯 C 坦克大战代码说起没有引擎只有 Win32 和循环翻到这份纯 C 坦克大战代码第一眼多半会失望没有引擎、没有第三方库一个 Win32 窗口加一堆 cpp 文件看着像十年前的课程设计。可真编译跑起来会发现地图、坦克、子弹、砖墙、爆炸、关卡切换一个不少游戏循环、对象管理、碰撞检测全是手写的没有一行是引擎替你兜底。它适合两类人被引擎包装惯坏、想搞清楚一帧里到底发生了什么的开发者以及想找个干净底座往上加背包、弹幕或者双人玩法的动手派。下面按能跑、能读、能改的顺序把这份纯 C 坦克大战代码拆一遍。2. 目录结构与技术选型一个 Win32 窗口怎么装下整场坦克大战拿到这种没有 README 的老工程我一般先不急着编译而是把目录扫一遍看依赖是不是单向的。依赖乱的项目改一个功能会牵出三个文件依赖干净的项目你甚至能把渲染层整个换掉。这份代码属于后者。2.1 一份典型的目录结构TankBattle/ ├─ src/ │ ├─ main.cpp WinMain、消息循环、固定步长驱动 │ ├─ GameApp.h/.cpp 窗口句柄、资源、全局状态的持有者 │ ├─ Tank.h/.cpp 玩家/敌方共用的坦克基类 │ ├─ Bullet.h/.cpp 子弹结构体 定长对象池 │ ├─ Map.h/.cpp 瓦片地图、关卡数据、碰撞查询 │ ├─ Input.h/.cpp 键盘状态缓存 │ ├─ Renderer.h/.cpp 双缓冲、位图贴图、文字 │ └─ Scene.h/.cpp 菜单/游戏中/暂停/结算的状态机 ├─ res/ │ ├─ tiles.bmp 砖、钢、草、水、基地 │ ├─ tanks.bmp 四方向 × 两帧动画 │ └─ boom.bmp 爆炸序列帧 └─ TankBattle.vcxproj文件职责二次开发时改它的概率main.cpp只负责循环节奏几乎不含业务低GameApp窗口生命周期、资源加载中Scene游戏流程与状态切换高Tank / Bullet实体行为高Map关卡数据与碰撞查询高Renderer所有 GDI 调用都关在这里换渲染后端时才动这种切法遵循“谁被谁调用”不是随便分的。main 只认识 GameAppGameApp 认识 Scene、Renderer、InputScene 认识 Tank、Bullet、Map而 Tank 和 Bullet 互相不认识都只调用 Map 暴露的碰撞查询接口。依赖是单向的所以哪天你想把 GDI 换成 SDL只需要重写 Renderer 一个文件逻辑层几乎不用动。这一点在第六节还会用到。常见的翻车点是有人把所有东西塞进 main.cpp两千行一个函数改一个子弹速度要在文件里搜十分钟。如果你手上这份代码已经接近那种状态先别急着加功能第一步做的是拆分。2.2 为什么是 GDI 而不是图形库Win32 GDI 是很老的做法好处也实在零依赖。工程里没有 include 路径、没有 lib 目录、没有 vcpkg双击 sln 或敲一行 g 就能出 exe。位图资源可以直接编进 exe也可以运行时从 res 目录读拷到别的机器上照样跑。代价是没有硬件加速。GDI 的 BitBlt 走的是 CPU 拷贝同屏几百个精灵就开始掉帧。但坦克大战这种场景26×26 格地图、几十个活动的对象一帧要贴的图不到一百张完全够用。选型的判断标准不是“新不新”而是“这个量级撑不撑得住”。真正要处理的是闪烁。直接往窗口 DC 上画每帧先清屏再逐个贴图用户看到的是绘制过程画面就会抖。标准解法是双缓冲建一个和窗口兼容的内存 DC所有绘制先在内存里完成最后一次性 BitBlt 到窗口。常见做法是内存 DC 和位图只在初始化时创建一次缓存在 Renderer 里复用释放放在析构里。// Renderer.cpp —— 双缓冲内存 DC 只创建一次每帧复用 void Renderer::Begin(HDC hdc) { if (!m_memDC) { // 懒初始化避免构造时还没有窗口句柄 m_memDC CreateCompatibleDC(hdc); m_memBmp CreateCompatibleBitmap(hdc, m_width, m_height); m_oldBmp (HBITMAP)SelectObject(m_memDC, m_memBmp); } FillRect(m_memDC, m_rc, m_bgBrush); // 清屏只清内存 DC } void Renderer::End(HDC hdc) { BitBlt(hdc, 0, 0, m_width, m_height, m_memDC, 0, 0, SRCCOPY); // 一次性上屏 } Renderer::~Renderer() { if (m_memDC) { SelectObject(m_memDC, m_oldBmp); // 必须先还原否则位图泄漏 DeleteObject(m_memBmp); DeleteDC(m_memDC); } }SelectObject那行还原是必须的。GDI 对象在还被 DC 选中时删不掉漏掉这一步就是慢性句柄泄漏跑十几分钟游戏开始卡任务管理器里句柄数一路涨。这类问题不报错、不崩溃只表现为“越玩越慢”最难查。2.3 主循环PeekMessage 加固定时间步Windows 游戏的主循环有两个坑一是用GetMessage阻塞等消息游戏就卡住不动二是直接用dt推进逻辑帧率一变手感就变。这份代码用的是PeekMessage非阻塞取消息加累加器固定步长算是标准答案。// main.cpp —— 非阻塞取消息 固定 60Hz 逻辑步长 int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) { GameApp app; if (!app.Init(hInst, 960, 720)) return -1; LARGE_INTEGER freq, prev, now; QueryPerformanceFrequency(freq); QueryPerformanceCounter(prev); const double STEP 1.0 / 60.0; // 逻辑固定 60Hz和刷新率解耦 double acc 0.0; MSG msg {}; while (msg.message ! WM_QUIT) { if (PeekMessage(msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(msg); DispatchMessage(msg); continue; // 有消息先处理这一帧不再推进 } QueryPerformanceCounter(now); double dt double(now.QuadPart - prev.QuadPart) / freq.QuadPart; prev now; if (dt 0.25) dt 0.25; // 切窗口、打断点回来时别让坦克瞬移 acc dt; while (acc STEP) { // 追帧欠了多少步就补多少步 app.Update(STEP); acc - STEP; } app.Render(); // 渲染每帧一次不和逻辑步长绑定 } return 0; }QueryPerformanceCounter比GetTickCount精度高一个数量级60Hz 下一帧才 16.7ms用毫秒级的计时器误差太明显。dt上限那行看着不起眼实际很关键调试断点停十秒、或者用户拖窗口卡住回来时dt可能是十几秒累加器会一次补上千步坦克直接飞出地图。我一般把这个上限设在 0.25 秒宁可丢帧也不让逻辑雪崩。PeekMessage的PM_REMOVE别写错成PM_NOREMOVE否则消息永远留在队列里msg.message一直是同一条循环空转。3. 核心模块拆解坦克、子弹、地图与碰撞检测这一节是整份代码里最值得读的部分。三个模块加起来不到八百行但把游戏里最容易写错的三件事——移动、发射、碰撞——都覆盖到了。3.1 坦克类坐标、朝向与分轴移动// Tank.h —— 坐标用浮点存左上角和 GDI 的贴图原点保持一致 class Tank { public: enum class Dir { Up, Down, Left, Right }; void Update(float dt, const Map map); void SetDir(Dir d) { if (d ! m_dir) { m_dir d; m_animFrame 0; } } Rect Bounds() const { return Rect{ m_x 2, m_y 2, m_x TILE - 2, m_y TILE - 2 }; } float X() const { return m_x; } float Y() const { return m_y; } bool Alive() const { return m_hp 0; } private: bool TryMove(float nx, float ny, const Map map); float m_x 0.f, m_y 0.f; float m_speed 90.f; // 单位是像素/秒不是像素/帧 Dir m_dir Dir::Up; int m_hp 1; int m_animFrame 0; float m_animTimer 0.f; };Bounds()里内缩 2 像素是个小技巧。贴图是 32×32但如果碰撞盒也用满 32×32坦克贴着墙角走的时候会因为一像素的误差判定为撞墙手感上就是“卡住不动”。内缩之后玩家感觉不出差别卡角概率大幅下降。这个数字不是玄学试出来的。// Tank.cpp —— 分轴试探撞墙时只丢撞的那个轴 void Tank::Update(float dt, const Map map) { float nx m_x, ny m_y; switch (m_dir) { case Dir::Up: ny - m_speed * dt; break; case Dir::Down: ny m_speed * dt; break; case Dir::Left: nx - m_speed * dt; break; case Dir::Right: nx m_speed * dt; break; } if (TryMove(nx, m_y, map)) m_x nx; // 先单独试水平 if (TryMove(m_x, ny, map)) m_y ny; // 再单独试垂直 m_animTimer dt; if (m_animTimer 0.08f) { m_animTimer 0.f; m_animFrame ^ 1; } } bool Tank::TryMove(float nx, float ny, const Map map) { Rect r{ nx 2, ny 2, nx TILE - 2, ny TILE - 2 }; if (!map.InsideWorld(r)) return false; return !map.HitSolid(r); }分轴这段是血泪经验。一开始我是先算出新坐标整体判定一次撞了就 x、y 一起回滚。结果是坦克沿着墙斜着走时完全动不了——因为斜向移动同时改了 x 和 y任何一个轴撞墙就整体失败。改成分轴之后贴着墙横向移动依然顺畅这才是老坦克大战的手感。speed的单位必须写清楚。写成“每帧 3 像素”在 144Hz 显示器上坦克速度直接翻倍多这类 bug 在开发机上永远复现不了。3.2 子弹与定长对象池// Bullet.h —— 定长池一次分配全程不 new/delete struct Bullet { bool active false; float x 0, y 0, vx 0, vy 0; int owner 0; // 0 玩家 1 敌方防止打到自己 float life 3.0f; // 秒超时自动回收 }; class BulletPool { public: void Fire(float x, float y, Tank::Dir d, int owner) { for (auto b : m_bullets) { if (b.active) continue; b Bullet{}; // 重置成干净状态 b.active true; b.owner owner; b.life 3.0f; b.x x; b.y y; switch (d) { case Tank::Dir::Up: b.vy -SPEED; break; case Tank::Dir::Down: b.vy SPEED; break; case Tank::Dir::Left: b.vx -SPEED; break; case Tank::Dir::Right: b.vx SPEED; break; } return; // 池满就不发射行为可预期 } } void Update(float dt, Map map) { for (auto b : m_bullets) { if (!b.active) continue; b.x b.vx * dt; b.y b.vy * dt; b.life - dt; if (b.life 0.f || !map.InsideWorld(b.x, b.y)) { b.active false; continue; } if (map.DamageAt(b.x, b.y)) b.active false; // 打中砖块 } } private: static constexpr float SPEED 320.f; // 像素/秒 std::arrayBullet, 64 m_bullets{}; // 定长栈上分配 };为什么不用std::vectorBullet加push_back/erase因为子弹是高频生灭的对象一秒钟可能创建销毁十几次vector会不断扩容、搬移、释放跑久了内存碎片化。定长池把这个成本压到零代价是池满时新子弹发不出去。经典坦克大战里玩家同屏最多两发子弹这个限制反而是规则的一部分。owner字段不能省。子弹从坦克中心生成如果忘了标记归属出膛第一帧就会判定打到发射者自己。life字段是后悔药子弹飞出地图边界的判定可能有漏网之鱼加个超时兜底永远不会出现“永久存在的幽灵子弹”。3.3 地图瓦片与碰撞检测// Map.h —— 32×32 瓦片碰撞只查矩形覆盖到的格子 class Map { public: static constexpr int TILE 32; static constexpr int COLS 26; static constexpr int ROWS 26; bool InsideWorld(const Rect r) const { return r.l 0 r.t 0 r.r COLS * TILE r.b ROWS * TILE; } bool HitSolid(const Rect r) const { int c0 int(r.l) / TILE, c1 (int(r.r) - 1) / TILE; int r0 int(r.t) / TILE, r1 (int(r.b) - 1) / TILE; for (int ry r0; ry r1; ry) for (int cx c0; cx c1; cx) if (m_cells[ry][cx] Cell::Brick || m_cells[ry][cx] Cell::Steel) return true; return false; } private: Cell m_cells[ROWS][COLS]; };(int(r.r) - 1)那个减一是必须的。假设右边界正好落在 6464 / 32 2会多查一列不该查的格子表现就是坦克明明没碰到东西却被挡住。边界值和格子对齐时的判定错误是瓦片地图最常见的一类 bug。查格子范围而不是遍历全图复杂度从 676 次降到最多 4 次坦克数量一多差距就出来了。DamageAt是子弹专用的把子弹中心点换算成格子索引砖块置空、钢块不碎返回值告诉调用方子弹是否需要销毁。3.4 状态机别用一堆 bool 管流程// Scene.h enum class State { Menu, Playing, Paused, GameOver, Win }; void Scene::Update(float dt) { switch (m_state) { case State::Menu: if (Input::Pressed(VK_RETURN)) { ResetLevel(); m_state State::Playing; } break; case State::Playing: if (Input::Pressed(VK_ESCAPE)) { m_state State::Paused; break; } m_world.Update(dt); // 只有这个状态下世界才推进 if (m_world.PlayerDead() || m_world.BaseDestroyed()) m_state State::GameOver; break; case State::Paused: if (Input::Pressed(VK_ESCAPE)) m_state State::Playing; break; case State::GameOver: if (Input::Pressed(VK_RETURN)) m_state State::Menu; break; } }用bool m_paused、bool m_gameOver、bool m_inMenu三个标志位管流程刚开始能跑加两个状态就开始出现“暂停中还能开火”“结算界面坦克还在动”这类组合爆炸的问题。枚举加 switch 写起来啰嗦一点但每个状态该做什么一目了然渲染层也能直接按状态决定画什么。4. 编译与运行从零把工程跑起来拿到代码先跑通再谈改造。这一节按实际动手顺序来。4.1 环境准备与工程配置有.vcxproj就用 Visual Studio装“使用 C 的桌面开发”工作负载即可不需要任何额外的 SDK 或库。四个必须确认的工程属性属性项值不设的后果配置类型应用程序 (.exe)编译出一堆 obj 找不到入口子系统Windows (/SUBSYSTEM:WINDOWS)链接报WinMain未解析字符集使用 Unicode 字符集窗口标题乱码、CreateWindowW报错C 语言标准C17 及以上std::array初始化、enum class语法报错没有 sln 的话MinGW 一行命令也能出包# 注意 -municode 对应宽字符入口 wWinMain-mwindows 去掉控制台黑窗 g -stdc17 -municode -mwindows src/*.cpp -o TankBattle.exe -lgdi32 -luser32漏掉-lgdi32会报BitBlt、CreateCompatibleDC一堆未定义引用看着吓人其实就是没链库。4.2 窗口初始化与资源加载// GameApp.cpp —— 客户区尺寸要自己算CreateWindow 的宽高含边框 bool GameApp::Init(HINSTANCE hInst, int w, int h) { WNDCLASSEX wc { sizeof(WNDCLASSEX) }; wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc GameApp::WndProc; wc.hInstance hInst; wc.hCursor LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName LTankBattleWnd; if (!RegisterClassEx(wc)) return false; RECT rc { 0, 0, w, h }; AdjustWindowRect(rc, WS_OVERLAPPEDWINDOW, FALSE); // 把边框算进去 m_hWnd CreateWindow(wc.lpszClassName, LTank Battle, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, nullptr, nullptr, hInst, this); return m_hWnd ! nullptr; }AdjustWindowRect这行如果省了你请求 960×720实际客户区只有 944×681 左右地图右边和下边会被裁掉一截。很多人以为是地图数据错了其实是窗口尺寸算错了。资源加载放在WM_CREATE里做LoadImage拿HBITMAP一次性把 tiles、tanks、boom 全部读进 Renderer之后每帧只做 BitBlt。绘制入口在WM_PAINTcase WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); m_renderer-Begin(hdc); m_scene-Draw(*m_renderer); m_renderer-End(hdc); EndPaint(hWnd, ps); return 0; } case WM_ERASEBKGND: return 1; // 告诉系统背景我自己擦去掉一层闪烁WM_ERASEBKGND返回 1 是配双缓冲用的。系统默认会先用背景刷把窗口刷一遍白再让你画这层多余的擦除就是闪烁的来源之一。4.3 跑起来之后的验证清单编译通过不等于跑对。按这个顺序验一遍能快速定位是环境问题还是逻辑问题窗口尺寸对不对地图右下角有没有被裁掉。方向键移动坦克能不能贴墙滑行四个方向都试。空格发射子弹打到砖块消失打到钢块也消失但钢块不碎。敌方坦克是否会自己转向、是否会被墙挡住。基地被打爆后是否进入结算界面坦克是否停止移动。连点关闭按钮进程是否干净退出任务管理器里句柄数是否稳定。第 6 条最容易被忽略。句柄数一直涨说明有 GDI 对象没释放后面加功能只会越来越卡。5. 排查与避坑五个让我调半夜的问题5.1 画面持续闪烁像老电视现象窗口一开就抖坦克边缘有拖影。原因直接往窗口 DC 绘制加上默认的WM_ERASEBKGND擦背景用户看到的是“擦白—画一部分—画完”的过程。解决内存 DC 双缓冲所有绘制先在内存完成最后一次BitBlt上屏同时WM_ERASEBKGND返回 1。两处都改才有用只做双缓冲不做擦除处理还是会闪一下。5.2 坦克贴墙时完全卡死现象按住方向键坦克在墙角一动不动换个方向又能动。原因x 和 y 一起算完再整体判定斜向移动时任一轴撞墙就整体回滚。解决分轴试探先单独试水平位移再单独试垂直位移各自独立判定。代价是代码多两行收益是贴墙滑行的手感。5.3 高刷屏上坦克跑得飞快现象开发机正常换台 144Hz 显示器坦克和子弹速度全部翻倍。原因速度写成“每帧多少像素”帧率越高单位时间移动越多。解决速度统一改成“像素/秒”更新时乘dt。或者用第 2 节那套固定步长主循环逻辑步长恒为 1/60与渲染帧率解耦。5.4 子弹从砖块中间穿过去现象子弹速度快的时候偶尔会穿过一格砖块不触发销毁。原因子弹每帧按speed * dt位移速度调到 600 以上时一帧位移超过 32 像素跳过了中间那格碰撞又只检查终点位置。解决两种做法一是提高逻辑频率或降低子弹速度让单帧位移小于半格二是做扫掠检测在起点到终点之间按步长采样几个点逐个查。我一般先用第一种速度快到必须用第二种时再上采样。5.5 玩十分钟后越来越卡句柄数一路涨现象前几分钟流畅之后掉帧任务管理器里进程句柄数持续上升。原因每帧CreateCompatibleDC、LoadBitmap、CreateCompatibleBitmap用完不DeleteObject或者SelectObject换进去的旧对象没还原就删 DC。解决所有 GDI 资源在初始化阶段一次性创建Renderer 持有成员变量析构时按“先还原、再删除”的顺序释放。查这类问题可以直接在调试器里看 GDI 对象计数涨得快的那个类型就是元凶。6. 二次开发把能跑的 Demo 改成自己的游戏跑通之后最容易上头的就是加功能。我的建议是先挑一个改动面小、验证快的方向把“改—验”这个循环跑顺再动大手术。第一刀可以切在关卡数据上。现在地图大概率是硬编码在Map.cpp里的二维数组改成从文本文件读一行一个字符串B砖、S钢、.空地。改完之后你能几分钟造一张新图测试效率立刻不一样。// 关卡文件 level01.txt26 行 × 26 列 bool Map::LoadFromFile(const std::string path) { std::ifstream in(path); if (!in) return false; std::string line; for (int r 0; r ROWS std::getline(in, line); r) { for (int c 0; c COLS c (int)line.size(); c) { switch (line[c]) { case B: m_cells[r][c] Cell::Brick; break; case S: m_cells[r][c] Cell::Steel; break; case W: m_cells[r][c] Cell::Water; break; default: m_cells[r][c] Cell::Empty; break; } } } return true; }第二刀是渲染层替换。因为所有 GDI 调用都关在 Renderer 里把Begin/End/DrawSprite三个接口用别的后端重新实现就行逻辑层一行不用改。这是当初分层设计最直接的回报。第三刀才是玩法敌方 AI 从“随机转向”换成带权重的寻路玩家加道具输入层扩成双人。每加一项都先回到第 4.3 节那份验证清单跑一遍确认没有回归再往下走。从那以后我每接一个这种老工程都先把主循环节奏和碰撞检测这两处单独拎出来跑最小用例确认它们是对的才敢往上堆功能。顺序反了后面所有 bug 都会指向这两处而你已经分不清是新代码的问题还是地基的问题。希望帮到你。本文还有配套的精品资源点击获取