ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++双缓存渲染与双模式乒乓球游戏开发实战

C++双缓存渲染与双模式乒乓球游戏开发实战 简介这是一份基于 C 实现的乒乓球游戏完整工程面向具备基础编程能力的游戏开发初学者与 C 学习者演示双缓存渲染、人机对战与双人同屏竞技等核心玩法适合作为图形界面编程和游戏循环设计的练手项目。压缩包共52个文件整体大小约2.91MB包含 .h / .cpp 源代码、可直接运行的 .exe 程序、编译中间文件 .obj 与 .sbr以及 .bmp/.ico/.rc 等界面与图标资源目录结构清晰能够帮助还原完整的 VC 项目组织方式。目前已有825人学习下载。通过学习这份代码可以深入理解双缓存机制如何将静态背景与动态元素分开渲染以提升画面流畅度掌握基于规则或简单状态判断的人机 AI 对抗设计同时通过 Ball 类中的运动轨迹计算、碰撞检测与反弹处理体会乒乓球游戏真实感的核心所在。此外双人模式下的键盘操控分配、回合计分与胜负判定也提供了完整的多人互动逻辑参考适合课程设计、二次开发或作为学习游戏编程的入门素材。1. 乒乓球游戏从零写到能玩C 双缓存渲染与双模式的取舍做小游戏最容易被忽略的不是玩法而是渲染链路。这个「乒乓球游戏」项目正好踩在最典型的路径上C 写逻辑、GDI 做双缓存渲染、人机对战PVE与双人同屏PVP两套模式并存。它解决的是「画面上电、球拍跟手、球速可信」这三件事适合刚开始用 C 写交互程序的人拿来当骨架也适合想快速搭一个可扩展对战小游戏的人直接改。全文我会把窗口初始化、主循环、碰撞判定、双缓存绘制、AI 追踪和常见翻车点一次性拆透代码可以直接抄。2. 窗口与主循环把 C 游戏骨架先立住2.1 双缓存为什么是必需品而不是优化项很多第一次写 GDI 程序的人会直接用TextOut、Rectangle往窗口 DC 上画结果就是画面闪烁得厉害。原因是每次画都直接操作显存对应的 DC窗口在重绘时先擦除背景再画前景中间那段时间屏幕会闪出一条空白。双缓存的思路很直接先在内存里画好一整帧画完一次性拷贝到窗口 DC。内存 DC 的操作不会立刻出现在屏幕上拷贝动作又是整块内存复制所以既不闪又不会出现半个球的撕裂感。这个项目里用到的就是标准的三件套CreateCompatibleDC创建和窗口 DC 兼容的内存 DCCreateCompatibleBitmap创建一块内存位图BitBlt把内存整帧拷贝到窗口这三步是 GDI 双缓存的地基后面第 4 章会展开讲。这里先记住一个结论凡是每一帧要擦掉重画的项目双缓存都不是可选项是默认项。2.2 窗口注册与主循环骨架Win32 的窗口程序有一个固定套路注册窗口类、创建窗口、进入消息循环。游戏和普通对话框程序最大的区别在于主循环——对话框用GetMessage阻塞等待游戏必须用PeekMessage非阻塞轮询不然球不会自己动。// 窗口过程只在 WM_PAINT 里做一件事用双缓存画当前游戏状态 LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_PAINT: RenderFrame(hwnd); // 统一走双缓存渲染 ValidateRect(hwnd, NULL); // 避免 WM_PAINT 反复触发 return 0; case WM_KEYDOWN: HandleKeyDown(wp); // PVP/PVE 共用按键入口 return 0; case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wp, lp); } int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow) { // 注册窗口类类名 PongGame样式用 CS_OWNDC 避免重绘时 DC 失效 WNDCLASS wc {}; wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.lpszClassName LPongGame; wc.hCursor LoadCursor(NULL, IDC_ARROW); RegisterClass(wc); // 窗口尺寸 800x600客户区实际大小以 AdjustWindowRect 校准 HWND hwnd CreateWindowW(LPongGame, LPong, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInst, NULL); ShowWindow(hwnd, nCmdShow); // 主循环PeekMessage 非阻塞配合定时器驱动逻辑更新 MSG msg; while (true) { if (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) break; TranslateMessage(msg); DispatchMessage(msg); } else { UpdateGame(); // 按帧更新球和球拍位置 InvalidateRect(hwnd, NULL, FALSE); // 触发 WM_PAINT Sleep(16); // 约 60 FPS让出 CPU } } return 0; }逻辑说明主循环里PeekMessage负责把窗口消息取走没有消息时就执行UpdateGame推进一步游戏逻辑再InvalidateRect让系统发出WM_PAINT走渲染。这里刻意不用GetMessage因为GetMessage收不到消息时会让程序挂起等待球就冻在屏幕上。Sleep(16)是把帧率压到 60 FPS 附近避免空转吃满 CPU。CS_OWNDC让每个窗口独占一份 DC防止多次GetDC/ReleaseDC后 GDI 对象状态被重置。参数说明窗口的客户区尺寸由外层窗口尺寸决定如果你把CreateWindowW的 800x600 直接当画布大小实际客户区会被标题栏和边框吃掉一块。我一般先用AdjustWindowRect计算真正的客户区尺寸再SetWindowPos校准这一步不做好球拍画出来会偏短。2.3 帧率与球速参数怎么定这一节的参数决定了游戏的手感。球的移动我不建议用「每帧固定像素」这种写法因为帧率波动会让球一会儿快一会儿慢。常见的做法是引入一个时间基准// 简单的帧间隔累计保证不同帧率下球速一致 static DWORD lastTick GetTickCount64(); DWORD nowTick GetTickCount64(); float deltaMs (float)(nowTick - lastTick); lastTick nowTick; // 球的像素速度按毫秒计算0.3 px/ms 就是每秒 300 像素 ball.x ball.vx * deltaMs; ball.y ball.vy * deltaMs;逻辑说明用GetTickCount64拿当前毫秒时间和上一帧做差得到deltaMs。球每帧移动的距离是「速度 × 经过的毫秒数」这样即使某帧卡了 50ms球走的距离也和在 60 FPS 下 3 帧内走的距离一致不会出现突然跳变。参数说明球速我建议起步给0.3 px/ms换算成每秒 300 像素在 800x600 的窗口里横穿要 2 秒多玩家有反应余地。球拍移动速度给0.6 px/ms左右比球快一截不然追不上。这两个值直接写在ball.vx、ball.vy的初始化位置改起来最顺手。3. 球拍、球与碰撞判定把物理算得像那么回事3.1 球的运动方向与出界判定球拍看似简单但方向控制有个容易翻车的地方如果球已经出了边界你还继续让它反弹球会在边界外反复横跳出现「卡边」现象。所以正确顺序是「先移动再判定最后修正位置」。struct Ball { float x, y; // 球左上角坐标以像素为单位 float vx, vy; // 水平/垂直速度正值表示向右/向下 int radius; // 球半径4px 比较合适 }; void UpdateBall(Ball ball, int fieldW, int fieldH) { ball.x ball.vx * deltaMs; ball.y ball.vy * deltaMs; // 上下墙壁反弹撞到顶/底就反转 vy并把坐标拉回边界内 if (ball.y 0) { ball.y 0; ball.vy -ball.vy; } else if (ball.y ball.radius * 2 fieldH) { ball.y fieldH - ball.radius * 2; ball.vy -ball.vy; } // 左右出界这里只置标记由 GameState 决定谁得分 if (ball.x ball.radius * 2 0) { event SCORE_RIGHT; } else if (ball.x fieldW) { event SCORE_LEFT; } }逻辑说明上下墙的反弹把ball.y直接硬拉到边界防止球嵌入墙体。左右边界不做反弹而是抛出一个SCORE_LEFT/SCORE_RIGHT事件由上层游戏状态决定加分和重置位置。这种设计把「物理」和「规则」分开了后面加人机逻辑时不用改物理代码。3.2 球拍与球的碰撞判定AABB 就够了乒乓球游戏的球拍是矩形球是圆形最稳妥的碰撞检测是「圆与矩形的 AABB 相交测试」。网上有人用像素级碰撞完全没必要球速快的时候像素检测会漏判。bool CheckBallHitPaddle(const Ball ball, const Paddle paddle) { // 把圆看成外接正方形先做粗筛避免每帧都算距离 if (ball.x ball.radius * 2 paddle.x) return false; if (ball.x paddle.x paddle.width) return false; if (ball.y ball.radius * 2 paddle.y) return false; if (ball.y paddle.y paddle.height) return false; // 粗筛通过后找圆心上离矩形最近的点算距离 float nearestX max(paddle.x, min(ball.x ball.radius, paddle.x paddle.width)); float nearestY max(paddle.y, min(ball.y ball.radius, paddle.y paddle.height)); float dx (ball.x ball.radius) - nearestX; float dy (ball.y ball.radius) - nearestY; return (dx * dx dy * dy) (ball.radius * ball.radius); }逻辑说明先做外接正方形粗筛把明显不可能碰到的帧直接false掉省掉平方根运算。粗筛通过后再用「圆心到矩形最近点的距离」和半径比大小。这里的max/min双层套嵌是在找矩形内离圆心最近的那个点如果圆心本身在矩形里最近点就是圆心距离为 0必定碰撞。参数说明paddle.width和paddle.height是球拍的宽高一般取12 x 80。球半径取4注意碰撞检测用的是球的中心点加上半径不是左上角。如果你把ball.x当中心坐标代码里所有ball.x ball.radius的写法都要换成ball.x本身否则碰撞偏右偏下。3.3 反弹角度固定 45 度会让游戏死板最省事的做法是球撞到球拍就vx -vx不改变vy但这样球永远沿着原角度往返玩家几局就能背板。我建议用「落点比例」映射出射角球打在球拍上半部就往上方弹打在中心就平飞打在下半部就往下方弹。void Rebound(Ball ball, const Paddle paddle) { // 计算球心相对球拍中心的偏移比例范围约在 -1 到 1 之间 float ballCenterY ball.y ball.radius; float paddleCenterY paddle.y paddle.height / 2.0f; float ratio (ballCenterY - paddleCenterY) / (paddle.height / 2.0f); // 把比例限制在 [-1, 1]防止球从球拍侧面横向撞进来时算出离谱角度 ratio max(-1.0f, min(1.0f, ratio)); // 出射角范围映射到 [-60°, 60°]角度越大 vy 占比越高 float angle ratio * 60.0f * 3.14159f / 180.0f; float speed sqrt(ball.vx * ball.vx ball.vy * ball.vy); ball.vx speed * cos(angle) * (ball.vx 0 ? -1 : 1); ball.vy speed * sin(angle); }逻辑说明ratio算出球撞击点在球拍上的相对高度-1表示最顶部1表示最底部。出射角线性映射到 60 度范围内用三角函数拆成vx和vy。ball.vx 0 ? -1 : 1这个三目运算保证球不管从左边还是右边撞上来水平方向都正确反转。这里有个细节如果你把出射角设成 90 度球就会垂直上下飞要接住就变得很折磨设太小比如 20 度球基本贴边直飞没什么游戏性。60 度是我试下来比较舒服的值写代码时可以直接作为常量抽出来。4. 双缓存渲染落地把画面稳定画到 60 FPS4.1 内存画布的创建与销毁双缓存的核心是「先在内存里画再整体上屏」。这里最容易犯的错是在WM_PAINT里每次都创建CreateCompatibleDC不停分配 GDI 对象几万帧下来句柄泄漏程序慢慢变卡。我一般把内存 DC 和位图放到窗口创建时一次性初始化// 全局 GDI 对象只创建一次程序结束才释放 HDC hMemDC NULL; HBITMAP hMemBmp NULL; HBITMAP hOldBmp NULL; void InitDoubleBuffer(HWND hwnd) { HDC hdc GetDC(hwnd); hMemDC CreateCompatibleDC(hdc); // 位图尺寸跟随客户区800x600 的画布就建 800x600 的位图 RECT rc; GetClientRect(hwnd, rc); hMemBmp CreateCompatibleBitmap(hdc, rc.right - rc.left, rc.bottom - rc.top); // SelectObject 返回旧位图句柄销毁前要选回去 hOldBmp (HBITMAP)SelectObject(hMemDC, hMemBmp); ReleaseDC(hwnd, hdc); } void CleanupDoubleBuffer() { if (hMemBmp) { SelectObject(hMemDC, hOldBmp); // 先还原旧位图 DeleteObject(hMemBmp); } if (hMemDC) DeleteDC(hMemDC); }逻辑说明CreateCompatibleBitmap创建的是和窗口 DC 颜色格式完全一致的内存位图这样后续BitBlt拷贝时不需要颜色转换。SelectObject(hMemDC, hMemBmp)把位图选进内存 DC之后所有的Rectangle、Ellipse画到hMemDC上就是画在位图上。参数说明销毁顺序必须严格先SelectObject把旧位图选回去再DeleteObject删除新位图最后DeleteDC。如果直接删hMemDC之前选进去的位图句柄会悬挂DeleteObject的内存区域可能已经被释放轻则内存泄漏重则程序退出时崩溃。这在 Windows 调试里是个经典坑下一章的避坑部分会细说。4.2 每帧渲染的完整流程有了全局的内存 DC 之后WM_PAINT里不再直接画到窗口而是先画到内存、再整体拷过去void RenderFrame(HWND hwnd) { // 用背景色清空内存画布注意这里不是画到屏幕上 RECT rc; GetClientRect(hwnd, rc); HBRUSH bgBrush CreateSolidBrush(RGB(16, 16, 16)); FillRect(hMemDC, rc, bgBrush); DeleteObject(bgBrush); // 画左右球拍直接画到内存 DC Rectangle(hMemDC, paddleLeft.x, paddleLeft.y, paddleLeft.x paddleLeft.width, paddleLeft.y paddleLeft.height); // 画球Ellipse 需要的是外接矩形不是圆心 Ellipse(hMemDC, (int)ball.x, (int)ball.y, (int)(ball.x ball.radius * 2), (int)(ball.y ball.radius * 2)); // 一次性拷贝到窗口客户区 HDC hdc GetDC(hwnd); BitBlt(hdc, 0, 0, rc.right - rc.left, rc.bottom - rc.top, hMemDC, 0, 0, SRCCOPY); ReleaseDC(hwnd, hdc); }逻辑说明每一帧先FillRect清屏再依次画左拍、右拍、球最后BitBlt整帧上屏。因为所有绘制都发生在内存 DC 上画面内容在画到一半的时候不会被用户看到这就是双缓存消除闪烁的根因。参数说明SRCCOPY表示直接覆盖目标区域不用任何位运算。如果改成SRCPAINT会做 OR 运算画面就花了。背景色RGB(16, 16, 16)接近黑色但比纯RGB(0, 0, 0)柔和一点长时间盯屏幕没那么刺眼。球的颜色可以在画完背景后重新选个白色画刷再Ellipse不然球也是黑的看不见。这里有个取舍如果窗口尺寸是固定的 800x600CreateCompatibleBitmap可以写死尺寸省掉每次GetClientRect。但一旦你后期想加全屏按钮写死尺寸的代码就得重构所以我在示例里用GetClientRect动态拿尺寸这样改起来最省事。4.3 PVP 双人输入与 PVE 的 AI 追踪对战模式的输入分配是很多人第一次写多玩家程序时最头疼的部分。单人游戏只要处理一套键盘状态双人同屏就得考虑「W/S 控制左拍方向上/下控制右拍」的键位重叠问题。void HandleKeyDown(WPARAM wp) { if (gameMode MODE_PVP) { switch (wp) { case W: paddleLeft.moveUp true; break; case S: paddleLeft.moveDown true; break; case VK_UP: paddleRight.moveUp true; break; case VK_DOWN: paddleRight.moveDown true; break; } } // PVE 模式下右拍不接收键盘输入由 UpdateGame 里的 AI 接管 }人机模式的 AI 追踪逻辑不能写成「直接贴到球的 y 坐标上」那样玩家永远赢不了。合理做法是让 AI 拍有滞后void UpdateAI(Paddle ai, const Ball ball) { // 只追踪球移动中的 y 位置x 方向不管 float targetY ball.y ball.radius - ai.height / 2.0f; // 死区球和 AI 拍中心距离小于 20px 就不动防止高频抖动 float error targetY - (ai.y ai.height / 2.0f); if (fabs(error) 20.0f) return; // 追踪速度 0.4 px/ms比玩家的球拍速度略慢 if (error 0) ai.y 0.4f * deltaMs; else ai.y - 0.4f * deltaMs; // 限制 AI 拍不出界 ai.y max(0.0f, min(ai.y, FIELD_H - ai.height)); }逻辑说明targetY是 AI 拍希望对齐的 y 位置error是当前位置和目标位置的差。20px 死区避免 AI 在目标附近左右横跳也就是常说的「机械抖动」。追踪速度 0.4 比玩家的 0.6 慢手感上玩家会觉得自己快一拍但 AI 又能接住球不会毫无挑战。参数说明deadZone和aiSpeed是控制 AI 难度最核心的两个参数。死区调大比如 40pxAI 会明显迟钝适合新手模式速度调小比如 0.2AI 追不上高速球适合趣味局。想要终极难度死区设 0、速度调到和玩家一致AI 就是一个近乎完美的追踪器。5. 避坑排查双缓存黑屏、碰撞穿透与输入失灵5.1 现象画面黑屏但窗口能正常拖动现象程序启动后窗口显示一片黑色拖动窗口能看到背景变化球和球拍完全看不到。原因WM_PAINT里没有调用BeginPaint/EndPaint或者ValidateRect的位置不对。更常见的原因是BitBlt的目标 DC 用的是GetDC(hwnd)但窗口刚创建时客户区还没准备好首次渲染拿到的 DC 有效区域为空。解决把首次渲染放到WM_SIZE消息之后触发或者干脆在WinMain里ShowWindow之后手动调一次RenderFrame。另外确认内存 DC 的位图尺寸和窗口客户区一致如果位图比窗口小BitBlt只能拷贝局部其余部分就是黑的。我自己的习惯是每次WM_SIZE都重建一次位图彻底杜绝尺寸不匹配。5.2 现象球高速运动时直接穿过球拍现象把球速调到 0.6 px/ms 以上偶尔能看到球从球拍中间穿过去不是每次都穿但关键时刻穿一次就很闹心。原因这是经典的运动碰撞问题。球速快的时候单帧移动距离可能超过球拍厚度上一帧球还在球拍左边下一帧已经跑到右边碰撞检测永远测不到相交状态。解决要么限制单帧最大移动距离要么在碰撞检测时做「扫掠检测」。我一般先用土办法把球速上限约束在球拍厚度的一半以内即ball.vx paddle.width / 2。如果必须支持高速球就把CheckBallHitPaddle升级为分段检测把deltaMs拆成几小段每段都跑一次碰撞。分割段数等于ceil(球速 * deltaMs / 球拍宽度)保证每段移动距离小于球拍宽度。5.3 现象按 W/S 没反应但鼠标点击正常现象PVP 模式下按 W 和 S 左拍不动方向键控制右拍却正常。原因窗口焦点问题。WM_KEYDOWN只在窗口拥有键盘焦点时才触发如果窗口启动后没有调用SetFocus(hwnd)系统默认焦点可能在桌面或调试输出窗口上。方向键有时有效是因为某些窗口管理器对方向键有特殊处理普通字母键就没这么走运。解决在WinMain里ShowWindow之后立刻SetFocus(hwnd)。另一个隐蔽因素是对话框资源的按键扫描码如果用DialogBox创建窗口默认会走WM_GETDLGCODE处理字母键必须重写这个消息返回DLGC_WANT_ALL_KEYS这个坑在纯CreateWindowW里不存在但要注意别混用窗口创建方式。5.4 现象AI 在最高难度下变成「全知隔挡」现象把 AI 速度调满、死区设 0 之后AI 拍永远精确对准球玩家基本没有得分机会游戏失去乐趣。理论上「更强 AI」不等于「更好的游戏」。原因AI 使用了真值数据——它直接读取了球的位置和速度。强 AI 没有信息延迟反应时间为 0玩家当然打不过。解决给 AI 加「感知噪声」。常见的做法是让 AI 每 100ms 才更新一次目标位置模拟人眼的视觉刷新间隔或者在追踪时引入随机扰动让 AI 偶尔朝错误方向微移一下。我试下来最自然的是「目标缓冲」AI 记录球的最后 N 个位置只基于这些历史位置做预测而不是用当前帧的精确坐标。这样高强度对局玩家会明显感觉到「AI 很强但不是永远接得住」。5.5 现象程序退出时 GDI 句柄泄漏任务管理器里占用持续上涨现象在循环里反复开关窗口或者长时间运行后任务管理器的 GDI 对象数稳定增长程序越来越卡最后画布变白。原因内存 DC 和位图在每一帧的WM_PAINT里创建却忘记释放。前面第 4 章说的SelectObject还原旧位图再删除的步骤被跳过了DeleteObject删的是一个已经被选入 DC 的位图实际上位图没有被释放。解决全局唯一的双缓存对象放在窗口的生命周期里管理WM_CREATE里初始化WM_DESTROY里清理。不要在任何WM_PAINT里创建新的CreateCompatibleDC。排查的时候用任务管理器「详细信息」标签页看 GDI 对象数如果每次WM_PAINT触发都涨几个就是这里出了问题。6. 进阶技巧用回放 buffer 调试手感再补上分数与音效游戏逻辑跑通之后下一步值得做的事不是加特效而是加一个回放 buffer。做法很简单在全局维护一个环形数组每帧记录球的坐标、球拍位置和速度容量设成 600 帧约 10 秒。按下热键R时进入回放模式游戏逻辑暂停渲染函数改从 buffer 里取数据画帧。struct FrameRecord { float ballX, ballY; float lPaddleY, rPaddleY; }; #define MAX_FRAMES 600 FrameRecord g_records[MAX_FRAMES]; int g_frameIndex 0; int g_maxRecorded 0; void RecordFrame(const GameState gs) { g_records[g_frameIndex].ballX gs.ball.x; g_records[g_frameIndex].ballY gs.ball.y; g_records[g_frameIndex].lPaddleY gs.left.y; g_records[g_frameIndex].rPaddleY gs.right.y; g_frameIndex (g_frameIndex 1) % MAX_FRAMES; if (g_maxRecorded g_frameIndex) g_maxRecorded g_frameIndex; }回放出来之后调试碰撞角度、AI 追踪死区参数都比对着内存里的瞬时值更直观。回放 buffer 唯一的成本是每帧 32 字节600 帧不到 20KB内存开销可以忽略。从那以后我每次调整球速或 AI 参数都要先跑一局回放看看轨迹是否自然再决定是否保留这组参数。分数和音效相对简单。分数用全局计数器每次SCORE_LEFT或SCORE_RIGHT事件在渲染函数的BitBlt之后用TextOutW画在顶部。音效用PlaySound配一个短促的 wav 文件击球时异步播放——注意PlaySound的第二个参数传入模块句柄如果在多个击球事件间隔极短SND_ASYNC标志是必须的不然声音会卡住后续逻辑。这套打完一个能拿得出手的 C 双缓存乒乓球游戏就齐活了。希望这次拆解能帮到你复制骨架的时候少走两步弯路。本文还有配套的精品资源点击获取
返回列表