资讯动态

C++双缓存乒乓球游戏:手写游戏循环与碰撞检测实战

发布时间:2026/10/21 2:49:44 来源:尧图企业网站定制
简介乒乓球游戏C项目源码基于MFC对话框框架实现包含人机对战与双人PVP两种模式适合C图形编程初学者与游戏开发爱好者。玩家1使用W/A/S/D键控制玩家2使用方向键控制游戏核心采用双缓存渲染静态背景与动态小球分层绘制避免重复刷新显著提升画面流畅度。Ball类集中管理球体运动轨迹、碰撞检测与反弹规则AI对手通过状态判断模拟回击策略可帮助理解键盘事件、定时器刷新与人机交互逻辑为二次开发提供清晰线索。资源共52个文件含9个头文件、8个cpp源码以及配套的obj目标文件、DSP工程配置、资源脚本与可直接运行的exe程序压缩包仅2.91MB结构完整便于查阅。已有827人学习下载可直接运行exe完整体验再对照源码理解双缓存渲染与碰撞检测实现也可继续扩展计分、球速或音效等玩法。1. 乒乓球游戏从单机到双人对战一个用 C 和双缓存写出来的小项目很多人觉得游戏开发是引擎的天下离了 Unity、Unreal 就寸步难行。但这次要拆的这个「乒乓球游戏」项目恰恰是用最朴素的 C 图形库从零手写了完整的乒乓对战逻辑。它支持人机对战PVE和双人对战PVP两种模式核心渲染基于双缓存机制没有用任何现成游戏引擎。换句话说你想知道一块像素怎么在屏幕上平滑移动、球怎么反弹、AI 怎么追球这个项目就是一份极佳的原始参考。项目适合两类人一是刚学完 C 语法、想做点有真实交互感的图形程序来练手的学生二是想理解游戏循环、碰撞检测、双缓存绘图这些底层概念的开发者。它不是那种大而全的框架但麻雀虽小五脏俱全——游戏主循环、输入处理、渲染刷新、AI 难度控制、音效触发全都有。照着源码跑一遍你会对「游戏是怎么跑起来的」这件事有个非常具体的体感。2. 项目技术拆解C 图形编程、双缓存与游戏循环的协作2.1 技术选型为什么是 C 而不是 Web 或脚本语言这个项目选择 C 作为唯一语言背后是有道理的。乒乓球游戏的核心逻辑是高频的坐标运算和碰撞检测球每帧都要移动、反弹、判断出界C 的裸指针和直接内存操作能把这部分开销压到最低。同时它依赖一个轻量级的图形库常见做法是 EasyX 或 graphics.h不需要引入引擎级别的依赖编译出来就是一个干净的 exe。从工程角度看C 也让双缓存机制更容易被理解。你可以在代码里显式地创建两个画布一个用于后台绘制一个用于前台显示然后通过 swap 操作完成切换。这比依赖引擎封装好的「自动双缓冲」更接近底层原理学习价值更高。我自己在拆这个项目时最大的感触是它很适合用来反向理解那些大游戏引擎里被藏起来的渲染细节。2.2 双缓存机制先画后备一次刷新双缓存是图形程序里避免画面闪烁的经典方案。这个项目的实现思路是前台缓存负责显示当前画面后台缓存负责绘制下一帧绘制完成后一次性把后台内容复制到前台。这样用户永远看不到「画了一半」的画面视觉上就是连续的动画。// 双缓存核心代码示意 void render(HWND hwnd, HDC hdc, HDC memDC, HBITMAP bmp, int width, int height) { // 创建与窗口兼容的内存 DC memDC CreateCompatibleDC(hdc); bmp CreateCompatibleBitmap(hdc, width, height); SelectObject(memDC, bmp); // 在内存 DC 上绘制所有游戏元素球、球拍、分数 drawBall(memDC, ball.x, ball.y, ball.r); drawPaddle(memDC, leftPaddle.y, rightPaddle.y); drawScore(memDC, scoreLeft, scoreRight); // 一次性将内存 DC 的内容拷贝到前台 BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); }这里的核心逻辑是CreateCompatibleDC和BitBlt。前者创建一块内存中的画布后者负责把内存画布整体搬移。SelectObject把位图绑定到 DC 上后续所有绘图指令都会作用于这块后台画布。BitBlt是一次性操作所以屏幕不会出现撕裂或闪烁。如果用单缓存直接往窗口 DC 上画画面更新时会出现残影和闪烁尤其在球移动速度快的帧上特别明显。这个项目用双缓存后整个画面干净流畅这也是我最推荐初学者先看的部分。2.3 游戏循环输入、更新、渲染三步走任何游戏都有一个主循环这个项目也不例外。循环体里做了三件事读取键盘输入、更新各物体坐标、渲染当前帧。代码结构非常清晰适合作为游戏编程的入门模板。// 游戏主循环 while (running) { // 1. 输入处理 if (GetAsyncKeyState(VK_UP) 0x8000) leftPaddle.y - paddleSpeed; if (GetAsyncKeyState(VK_DOWN) 0x8000) leftPaddle.y paddleSpeed; if (GetAsyncKeyState(VK_W) 0x8000) rightPaddle.y - paddleSpeed; if (GetAsyncKeyState(VK_S) 0x8000) rightPaddle.y paddleSpeed; // 2. 更新逻辑 ball.x ball.vx * ballSpeed; ball.y ball.vy * ballSpeed; checkCollision(ball, leftPaddle, rightPaddle); checkWallBounce(ball, height); checkScore(ball, scoreLeft, scoreRight); // 3. 渲染当前帧 render(hwnd, hdc, memDC, bmp, width, height); // 控制帧率 Sleep(10); }GetAsyncKeyState是 Windows 下读键盘状态的常用函数它返回按键的实时候选信息0x8000表示键盘当前被按下。左右球拍分别用方向键和 W/S 控制直接映射到 PVP 双人对战模式上。球的位置更新就是简单的速度和方向叠加接着执行碰撞与得分判断。最后渲染一次并Sleep(10)让帧率稳定在 100FPS 左右。这种循环结构延伸到后续复杂项目时你只需要把「更新逻辑」替换成物理模拟、状态机或网络同步骨架是通用的。这个项目的价值就在于此它把游戏循环抽象得足够干净方便你对照学习。3. 项目结构从代码文件到模块职责划分3.1 目录结构与编译入口拿到源码包后第一件事是把目录结构看清。这个项目遵循了常见的小型 C 工程风格头文件与实现分离主函数单独放一个文件资源文件集中管理。你可以用 Visual Studio 或 MinGW Makefile 编译建议直接用 Visual Studio 新建空项目然后把源码文件拖进去即可。/pingpong-game ├── main.cpp # 程序入口窗口创建与消息循环 ├── game.h # 游戏常量、数据结构声明 ├── game.cpp # 游戏核心逻辑碰撞、AI、得分 ├── render.h # 渲染相关函数声明 ├── render.cpp # 双缓存渲染实现 ├── resource.h # 资源 ID 定义图标、菜单等 └── README.md # 编译运行说明main.cpp负责创建窗口和消息循环game.cpp是灵魂包含乒乓球所有规则逻辑。render.cpp把画面绘制独立出来避免和业务逻辑混在一起。这种分离是我在小型项目中比较欣赏的做法——哪怕只有几百行代码分层清晰后续扩展 AI 难度或菜单选项时就能少踩很多坑。3.2 数据结构设计球、球拍、分数怎么表示游戏中的每个实体都被建模成结构体存储坐标、速度、尺寸等属性。比起直接用散落的变量这种集中管理的方式让代码可读性大幅提升。// game.h 中的数据定义 struct Ball { int x, y; // 球心坐标 int vx, vy; // 速度方向单位矢量 int r; // 半径 }; struct Paddle { int x, y; // 左上角坐标 int width, height; int speed; // 移动速度像素/帧 }; struct GameState { Ball ball; Paddle leftPaddle; Paddle rightPaddle; int scoreLeft; int scoreRight; bool isPvPMode; // 人机 or 双人 };Ball里的vx、vy只是方向实际速度由全局的ballSpeed控制这样调整游戏难度只需改一个变量。Paddle的x只用来确定左右y在运行时变化。GameState把当前游戏所有状态聚合到一个结构体方便作为参数传递给不同函数。从工程角度讲这种设计很适合初学者模仿用结构体做数据聚合用函数做行为逻辑二者分离但不割裂。如果你想把球加速、改变球拍大小只需要修改对应字段而不必动绘制代码。3.3 人机对战 AI从简单追踪到难度调节AI 是这个项目的亮点之一。它并不复杂却能让单人玩家玩得下去。AI 的实现思路是让右球拍追踪球的 Y 坐标但为了增加真实感AI 不会精确到达预测点而是加入滞后和随机误差。// AI 控制逻辑 void updateAI(GameState state, int errorMargin) { int targetY state.ball.y - state.rightPaddle.height / 2; int currentY state.rightPaddle.y; // 只有当球向 AI 方向运动时才主动追踪 if (state.ball.vx 0) { if (currentY state.rightPaddle.height / 2 targetY - errorMargin) { state.rightPaddle.y state.rightPaddle.speed; } else if (currentY state.rightPaddle.height / 2 targetY errorMargin) { state.rightPaddle.y - state.rightPaddle.speed; } } }这里有个细节只有ball.vx 0即球向右飞行时 AI 才移动。球向左飞时 AI 会回到中间位置这样人类玩家有进攻机会不会一直被压着打。errorMargin控制 AI 的精准度值越大 AI 越容易失误这也是难度调节的关键参数。你想要挑战更高难度就把errorMargin调小甚至设为 0此时 AI 会精准追球但依旧可能被快速球击穿。这种设计让你在改代码时可以「动一个参数」改变游戏体验很适合实验。4. 运行与避坑从编译到第一次开打的完整流程4.1 环境准备与编译运行步骤这个项目依赖 Windows 图形库默认是 EasyX 或 compatible 的 graphics.h所以环境必须落在 Windows 上。我建议使用 Visual Studio 2019 或 2022社区版就够用。装好之后打开项目右键解决方案 → 属性 → 链接器 → 输入确保附加依赖项包含libEasyX.a或对应的静态库。若不想折腾静态库也可用#include graphics.h配合 EasyX 安装包自动配置。编译时注意字符集选择「使用多字节字符集」或 Unicode取决于源码里是否用了宽字符。main.cpp里的窗口创建函数如果用了TEXT()宏则兼容两种字符集但若直接用普通字符串Unicode 会报错。遇到这类报错把项目属性中的字符集改成「使用多字节字符集」一般就能解决。启动后游戏窗口打开左边球拍用方向键上下右边球拍用 W/S。如果选择人机模式右边球拍由 AI 接管此时 W/S 只对双人模式生效。菜单切换模式依赖键盘事件或鼠标点击源码里一般用keybd_event或者GetAsyncKeyState监听回车键来切换。4.2 常见问题排查黑屏、闪屏、按键无响应我把拆项目时踩过的坑整理成清单照着排查能省不少时间。问题一窗口能打开但画面全黑。现象是窗口标题正常但内容区域什么也不显示。原因是绘图前没有正确初始化双缓存或者BitBlt的宽高参数没对齐。检查CreateCompatibleBitmap是否使用了窗口客户区实际宽高不要用窗口边框的宽高。如果用的是getwidth()/getheight()确保它们返回的值与客户区一致。问题二球移动有残影或闪烁。常见原因是没有严格执行「先绘制后台再一次拷贝」的流程。有些朋友会直接往窗口 DC 上画背景然后在背景上画球再画球拍结果清屏延时导致残影。解决方法是把所有绘制操作都放在memDC上BitBlt只调用一次。如果依旧闪烁检查Sleep(10)是否存在——帧率太高时显示器刷新跟不上加 10ms 延时能明显缓解。问题三按键没反应。尤其是 PVP 模式右边球拍按键无效。先检查GetAsyncKeyState的返回值 0x8000是必须的有些人会写成 0x8000导致低 15 位干扰判断。其次检查当前模式人机模式下右球拍由 AI 控制键盘输入被跳过这是设计如此。若想测试 PVP代码里确保isPvPMode被正确设置为true。问题四编译报错找不到graphics.h。这是 EasyX 未正确安装的症状。EasyX 安装包会把头文件复制到编译器 include 目录如果你的 IDE 是绿色版或自定目录需要手动把头文件目录加入「附加包含目录」。我一般还会检查编译输出窗口的报错路径看它到底在找哪个目录下的头文件直接定位更快。问题五球速过快导致碰撞检测穿透。这是游戏开发经典难题也最容易出现在这种「坐标增量 碰撞判断」的简单实现中。现象是球从球拍中间穿过去不反弹。原因是每帧球的位移量大于球拍厚度上一帧在左边、下一帧已经跑到右边碰撞检测就漏了。解决思路有两种一是限制最大帧位移让球速不超过球拍厚度的一半二是做「扫掠检测」——把这一帧的运动轨迹当作一条线段与球拍矩形求交。对于这个小项目最简单的是把ballSpeed限制在 10 像素/帧以内同时保证球拍厚度至少 20 像素碰撞就不会丢。这些坑在我自己的开发经历里都出现过尤其是碰撞穿透和字符集报错。每次跑这个项目我总会先按上面的清单检查一遍基本能扫清 90% 的启动问题。5. 二次开发从双缓存延伸到弹幕与菜单系统5.1 改造一给游戏加一个主菜单与暂停功能原版启动后直接进入对局但如果你想要更友好的用户体验可以增加一个简单的菜单状态。实现思路是在GameState中加入enum GameMode { MENU, PLAY, PAUSE }然后在主循环里根据模式分支执行。enum GameMode { MENU, PLAY, PAUSE }; GameMode mode MENU; while (running) { if (mode MENU) { drawMenu(memDC); // 绘制标题和选项 if (GetAsyncKeyState(VK_RETURN) 0x8000) mode PLAY; } else if (mode PLAY) { updateGame(state, isPvP, aiError); render(hwnd, hdc, memDC, bmp, width, height); if (GetAsyncKeyState(VK_ESCAPE) 0x8000) mode PAUSE; } else if (mode PAUSE) { drawPause(memDC); if (GetAsyncKeyState(VK_SPACE) 0x8000) mode PLAY; } Sleep(10); }这样一来游戏循环从单一线程变成了「状态机」所有绘制函数都需要支持传入当前模式。这种改造对项目结构几乎没有侵入只需要在render函数里加一个模式参数。我自己的操作习惯是把菜单绘制独立成一个函数避免在更新逻辑里参杂 UI 绘制代码。5.2 改造二增加球速递增的难度曲线原版球速是固定的玩久了会单调。你可以让球每次碰拍后速度增加一点但需要设定上限否则最后会快到无法反应。实现思路是在碰撞回调里给ballSpeed乘一个系数比如1.1同时用std::min强制封顶。void onPaddleHit(GameState state) { state.ball.vx -state.ball.vx; // 反弹 float newSpeed state.ballSpeed * 1.1f; state.ballSpeed std::min(newSpeed, maxSpeed); // 速度变化方向但保持方向归一化 ball.vx * (state.ballSpeed / oldSpeed); ball.vy * (state.ballSpeed / oldSpeed); }注意只有反弹后球速变化方向和速度要分离处理。如果你直接让vx和vy等于某个固定值球的运动方向会失真。这里比较稳妥的办法是让vx、vy保持单位方向用一个单独的ballSpeed标量控制大小。我在改这个项目时也踩过坑把vx、vy直接提速结果球的角度越来越斜很快就从上下边出界。5.3 验证方法与调试技巧改完代码后怎么验证改动没问题我的习惯是三步走第一步编译运行看基础对局是否正常第二步专门测试边界情况比如 AI 在球向左时是否不动、暂停菜单是否能正确冻结更新逻辑第三步用日志输出关键变量比如球的坐标和速度判断碰撞是否按预期触发。一个很实用的调试技巧是在updateGame里加一行printf或OutputDebugString把每帧球坐标打到调试窗口。这样一旦出现「球消失」或「穿模」的 bug你可以用坐标回放定位是哪一帧开始异常而不是肉眼盯着屏幕猜。我从类似项目里学来的教训是永远先怀疑「位移大于碰撞体厚度」和「输入模式判断错误」这两类问题覆盖了这个小项目绝大多数翻车场景。从那以后我每次碰 C 小游戏项目、哪怕是改一行参数都会强制把「主循环 → 碰撞边界 → 缓存拷贝」这三段代码重读一遍再跑一轮混合测试。这个习惯救过我很多次也希望这次拆解能帮你在自己的项目里省下几个小时的排错时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑