
简介一份基于C开发的双缓存乒乓球游戏完整项目包兼顾人机对战与双人对战两种模式适合C初学者或对图形编程、游戏开发感兴趣的读者学习与扩展。项目采用双缓存技术优化渲染静态背景与动态球体分缓冲区处理可有效提高画面刷新流畅度人机模式中的电脑策略与双人实时对抗均体现了交互与算法设计。压缩包共52个文件大小约2.91MB包含9个头文件与8个源文件对应球、玩家、电脑等核心模块另有可执行程序、工程配置、图标和背景图片便于直接运行或对照代码学习。已有826人下载学习从源码中可了解碰撞检测、球体运动模拟、双缓冲绘图及键盘事件处理等关键实现整体代码量适中适合作为课程设计或入门练手项目是一份麻雀虽小五脏俱全的实践案例。1. 乒乓球游戏先把画面闪没掉再聊 C 双缓存我从 C 写乒乓球游戏这件事上学到的最重要一课是小游戏卡顿跟代码量没关系跟你有没有把「双缓存」这套绘图机制想明白才有关系。刚开始我用单缓冲直接往屏幕上画球球一移动就整个画面闪烁调刷新率、调延迟都像玄学怎么调都救不回来。后来把渲染改成后台画完、一次拷到屏幕闪烁直接消失整局游戏才像样。这份资源是一个可复现的 C 乒乓球游戏工程支持人机对战和 PVP 双人对战两种模式适合刚写完 GUI 作业、想弄懂双缓存到底解决什么问题的开发者也适合想研究简单 AI 预测逻辑的人。2. 双缓存与绘图管线把画面闪烁降到肉眼不可见2.1 为什么直接绘制会闪GDI 消息泵与屏幕撕裂乒乓球游戏里小球每秒要刷新几十次位置表面上只是重绘一个圆实际上每次重绘都触发一次 WM_PAINT 消息。如果没有双缓存绘制过程是这样的先擦除旧的背景再画新的小球和球拍。擦除和重画之间有一个短暂时间窗窗口内容处于空白或半旧半新状态。人眼对高频变化的边界特别敏感这个时间窗每一次出现都会变成一条闪烁带。有个常见误区是用 InvalidateRect 的延迟参数解决问题或者用定时器的频率去压闪烁这些做法都只能压低闪烁的出现频率不能消除它。因为问题本质在绘制顺序不在绘制次数。只要擦除动作和绘制动作分别发生在屏幕上就必然存在“看到空白帧”的窗口。双缓存的解决思路是把擦除和绘制都搬到内存里做在后台缓冲区完成整帧内容后再用一次 BitBlt 操作把整块后台缓冲区的内容整体拷贝到屏幕。屏幕只接受两次状态切换一次是旧帧一次是新帧中间没有空白过渡视觉上就稳定了。这也是为什么很多入门图形编程教程把双缓存叫做“后备缓冲区交换”本质上是把多次绘制合并成一次提交。2.2 渲染器实现兼容 DC、后台位图与 BitBlt 换帧在 Windows GDI 环境下最轻量的双缓存写法是创建一个与屏幕兼容的内存 DC再把一块兼容位图选进这个内存 DC。之后所有画圆、画矩形、写文字的 GDI 调用都打到这块位图上最后再把整块位图通过 BitBlt 送到窗口 DC。下面是渲染器核心代码void GameRenderer::Render(HDC hdcDest, HWND hWnd) { RECT rc; GetClientRect(hWnd, rc); int width rc.right - rc.left; int height rc.bottom - rc.top; // 创建兼容 DC 与位图所有绘制先落在后台缓冲区 HDC memDC CreateCompatibleDC(hdcDest); HBITMAP memBitmap CreateCompatibleBitmap(hdcDest, width, height); HBITMAP oldBitmap (HBITMAP)SelectObject(memDC, memBitmap); // 第一步清空后台缓冲区避免上一帧残影 FillRect(memDC, rc, (HBRUSH)GetStockObject(BLACK_BRUSH)); // 第二步在后台缓冲区上绘制全部游戏对象 DrawBall(memDC); DrawPaddles(memDC); DrawScore(memDC); // 第三步一次位图拷贝把整帧送到屏幕 BitBlt(hdcDest, 0, 0, width, height, memDC, 0, 0, SRCCOPY); // 第四步释放 GDI 对象防止句柄泄漏 SelectObject(memDC, oldBitmap); DeleteObject(memBitmap); DeleteDC(memDC); }这段代码里最关键的是 CreateCompatibleDC 和 CreateCompatibleBitmap 的参数。hdcDest 是窗口客户区的 DC兼容位图按客户区的宽高创建保证后台缓冲区跟屏幕实际显示区域是一比一大小。如果缓冲区尺寸小于窗口尺寸BitBlt 拉伸间距会导致锯齿所以每次窗口尺寸变化时都要重新创建位图。还有两个值得注意的细节。第一SelectObject 保存 oldBitmap 是必要的如果不恢复DeleteObject 删除位图时会失败。第二FillRect 用的黑色画笔来自系统库存画刷不需要手动 DeleteObject但如果你换用了 CreateSolidBrush就必须在绘制结束后删除否则每次渲染都会泄漏一个 GDI 对象。2.3 把渲染挂到消息循环上帧节奏与 WM_PAINT 的配合双缓存渲染器本身不产生帧节奏它只是负责“画好一帧”。真正的帧循环可以用定时器驱动也可以在主消息循环里按时间差驱动。我用的是可控定时器方案SetTimer 设为 16ms也就是大约 60 帧每秒。每次定时器触发时更新游戏逻辑然后调用 InvalidateRect 只更新客户端区域不擦除背景重绘响应里再走完整渲染器。这里有一个容易踩的坑WM_ERASEBKGND 和 WM_PAINT 的优先级问题。Windows 在收到 WM_PAINT 之前会先发送 WM_ERASEBKGND默认处理是用窗口背景色擦除客户区。如果这时候擦除动作直接触发在屏幕上双缓存就白做了。我的处理方式是拦截 WM_ERASEBKGND 并直接返回 1告诉系统不需要擦除背景背景清理统一放到渲染器里完成后台缓冲区清理。case WM_ERASEBKGND: // 返回 1 阻止系统默认背景擦除避免屏幕级闪烁 return 1; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); renderer.Render(hdc, hWnd); EndPaint(hWnd, ps); } return 0;消息循环里还应该用一个帧间隔变量控制游戏逻辑的推进速度不能让物理更新直接绑定在定时器回调频率上。某个回调要是阻塞了一下物理步长就会跳变球的速度看起来忽快忽慢。实用做法是维护一个上次更新时间戳每次定时器触发时计算真实间隔再按固定步长累进更新。3. 人机对战的球路预测从追踪球体到预测落点3.1 预测算法的基本思路目标不是“追球”是“等球”人机对战的难度核心在电脑的接球逻辑。初学者最容易写成的 AI 是让球拍始终追踪球体的当前 Y 坐标球往上拍子往上球往下拍子往下。这种 AI 有两个明显问题一是球在两侧快速折返时拍子会来回抖动二是当球速很快时拍子永远慢半拍。正确做法是让 AI 预测球抵达球拍所在 X 平面的那一刻的 Y 坐标然后直接移动到这个预测点上。这样可以避免在球运动过程中一直被球“牵着走”也能让 AI 看起来像是在思考而不是在盯人。预测点需要分两步计算第一步算出球从当前位置到球拍平面的所需帧数第二步用当前 Y 坐标和 Y 方向速度推算出目标帧的 Y 位置同时考虑上下墙体的反弹。int ComputePredictY(const Ball ball, int paddleX, int tableHeight) { // 球没有水平速度时不预测保持当前 Y if (ball.vx 0) return ball.y; // 用距离除以水平速度得到抵达球拍平面的帧数 int dx paddleX - ball.x; int frames abs(dx / ball.vx); // 按帧数推算 Y 坐标考虑上下边界的多次反弹 int predictedY ball.y ball.vy * frames; while (predictedY 0 || predictedY tableHeight) { if (predictedY 0) { predictedY -predictedY; } else if (predictedY tableHeight) { predictedY 2 * tableHeight - predictedY; } } return predictedY; }这个算法的关键参数有两个第一是 frames 的计算用水平距离除以水平速度商取整。如果 dx 是负数说明球在远离球拍AI 就不需要预测直接回中待命。第二是 while 循环处理反弹因为球可能在预定帧数内撞墙多次所以要用循环不能只用一次 if 判断。在小球初始化时我会把 vx 设为 ±1vy 设为 0.6。这样球体在墙上反弹的角度会被速度比例放大或缩小所有反弹路径都由此推出来不需要额外处理斜墙的物理方程。3.2 难度分级反应延迟、误差范围与追击速度的组合预测点算出之后AI 还需要一个“故意打不准”的参数组合否则每次都能正中球心玩起来很没意思。我把难度拆成三个独立维度反应延迟、最大瞄准误差、追击加速度。反应延迟指的是 AI 在算出预测点之后会故意缓几帧再移动球拍模拟人类看到球飞过来的反应时间。最大瞄准误差是给预测点加上一个随机偏移偏移范围越大AI 接球越容易扑空。追击加速度决定球拍每帧最多移动多少个像素这个值越小 AI 的手“越慢”。struct AIConfig { int reactionDelayFrames; // 反应延迟帧数 int maxAimErrorPx; // 最大瞄准误差像素 float moveSpeed; // 每帧移动速度 }; AIConfig aiConfigs[] { { 4, 42, 0.8f }, // 简单反应慢、误差大、移动慢 { 2, 18, 1.5f }, // 普通反应适中、误差适中 { 0, 6, 2.6f } // 困难反应快、误差小、移动快 };难度切换不需要改动预测算法本身只改配置。AI 的 Update 逻辑里每一步都做三件事如果当前帧还在反应延迟阶段只记录目标点不移动球拍延迟结束后把预测点加上一个随机误差再作为目标然后按 moveSpeed 向目标点移动。这里有一个实现细节容易翻车随机误差每帧都重新生成会导致 AI 的球拍在目标点附近来回抖动。我的做法是只在进入“接球状态”的那一帧生成误差值后续帧沿用同一个误差值直到球被击中或反弹。从效果上看简单难度下 AI 偶尔扑空困难难度下 AI 能接住大多数球松紧度的体感差异立刻拉开。3.3 PVP 模式下如何复用双缓存与 AI 预测PVP 模式不涉及 AI 预测但输入逻辑变了。单人模式下键盘的上下键同时控制一个球拍双人模式下需要把按键拆成两组一组控制左拍一组控制右拍。这里最容易犯的错误是把两个的按键状态混在一个 update 分支里导致按键冲突一方的操作会影响另一方。如果计划做成单机双人同屏我的做法是给每个玩家维护一个独立的输入结构体用 GetAsyncKeyState 分别采样两组按键。左拍用 W/S右拍用方向键的上下。更新顺序是先同步输入再更新物理最后统一走一遍双缓存渲染。PVP 模式下 AI 预测函数完全不参与但双缓存渲染器和碰撞检测逻辑完全复用。结构上 PVP 和 AI 两个模式的差异被限制在一小段代码内显卡渲染逻辑和物理碰撞逻辑完全不变这也方便你在这个框架上继续加功能比如加音量、改颜色、加特效、加第三个球改动集中且不会影响渲染层。4. 边界翻车与常见问题排查四个典型 Bug 的实测复盘4.1 球拍碰撞瞬间的跳动与反向穿透现象球碰到球拍边缘时经常出现一种“先跳后穿”的视觉错误拍子和球明显短暂重叠了一下然后球才弹开。球速越快这个问题越明显。原因碰撞检测按矩形相交判断相交时球已经有一小部分嵌进球拍矩形区域。如果没有处理重叠位置球会弹开时带着上一帧的位置刷出来看起来像跳了一下。极端情况下球移动距离超过球拍宽度一帧直接穿越本是检测不到碰撞的。解决检测到碰撞后先把球的位置“推回”到临界位置再反转水平速度。这样球不会陷入拍体内从视觉上球是贴在拍面弹开的。同时给球设置最大速度上限避免单帧位移过大导致彻底穿透。实际操作时我用一个 maxFrameMove 做钳制把球的移动距离限制在球拍厚度的一半以内穿透问题直接绝迹。if (IsIntersect(ballRect, paddleRect)) { // 先把球压回碰撞前的位置避免陷进拍面 ball.x - ball.vx; // 水平速度反向同时给纵向速度分一点拍子运动速度 ball.vx -ball.vx; ball.vy paddle.vy * 0.3f; }4.2 WM_ERASEBKGND 导致双缓存失效现象双缓存代码写好后球在运动时仍然出现明显的残影和频闪看起来像是双缓存没起作用。原因我在最初实现时没有拦截 WM_ERASEBKGND。系统默认会在每次 BeginPaint 前擦除客户区背景这个擦除发生在屏幕显示层。后台缓冲区虽然完整但屏幕先经历了一次空白擦除再显示后台缓冲内容中间就多了一次闪烁。解决在窗口过程里处理 WM_ERASEBKGND 并返回 1。同时把背景填充动作移到双缓存渲染的第一阶段用 FillRect 完成确保背景擦除也在后台缓冲区里做。从那以后我再也没在这台机器上见过残影。4.3 帧率不稳定导致球速忽快忽慢现象在低配机器上球速明显比高配机器慢同一局里偶尔也会突然加速或减速。原因游戏逻辑更新绑在 WM_TIMER 消息上定时器并不保证严格的时间间隔。系统忙时一条消息延迟几毫秒球就相当于多走了一个步长。长期累积下来球的位置跟预期差出几十个像素体感就是忽快忽慢。解决用 GetTickCount 或 QueryPerformanceCounter 记录真实时间每次定时器触发时计算当前时间与上一帧时间的差值把差值累加到累计时间上再用固定的物理步长做更新。这个方法也顺带解决了“不同机器速度不同”的问题因为逻辑更新频率完全不依赖系统时钟只依赖帧间隔计数。uint32_t currentTick GetTickCount(); accumulator currentTick - lastTick; lastTick currentTick; while (accumulator kFixedStepMs) { UpdatePhysics(); // 固定步长更新 accumulator - kFixedStepMs; }4.4 AI 打得太“准”或太“蠢”的体感失衡现象困难难度下 AI 几乎每球必接玩家觉得像在跟挂打简单难度下 AI 又完全追不上球像在放水。原因难度参数只调了 moveSpeed误差和延迟没有联动。移动速度快但误差为零时AI 永远精准接球移动速度慢但误差为零时AI 又总是差一两个像素够不到球。解决把反应延迟、瞄准误差和移动速度三个参数放在同一张配置表里联动调整。困难时移速高且误差小AI 表现为“精准接球但也有极少数失误”普通时三个参数取中间值简单时误差大移速低AI 大概率接不住角度刁钻的球但能接住直线球。这种三参数联动的设计给人机的体感比单调一个速度自然得多。5. 进阶技巧PVP 按键映射与五秒回放验证5.1 PVP 的输入映射让双人同时操作不互相干扰双人模式的输入映射本质上是一个状态收集器。我用一个简化结构体封装两位玩家的按键状态主循环每帧触发一次采集。注意不要用 GetKeyState它返回的是线程消息队列里的状态在消息循环里用它会拿到滞后一帧的数据。用 GetAsyncKeyState 直接读取物理键盘状态更可靠。struct PlayerInput { bool up; bool down; }; void PollInputForPvp(PlayerInput p1, PlayerInput p2) { SHORT p1Up GetAsyncKeyState(VK_W); SHORT p1Down GetAsyncKeyState(VK_S); SHORT p2Up GetAsyncKeyState(VK_UP); SHORT p2Down GetAsyncKeyState(VK_DOWN); p1.up (p1Up 0x8000) ! 0; p1.down (p1Down 0x8000) ! 0; p2.up (p2Up 0x8000) ! 0; p2.down (p2Down 0x8000) ! 0; }0x8000 这个掩码是 GetAsyncKeyState 的关键参数它表示按键当前是否处于按下状态。只用高位的 16 位做判断避免取到按键切换动作的历史信息。5.2 把整个游戏状态记录成环形缓冲做回放与回归验证调试 AI 时你经常需要反复看同一个球路但手动重玩很难复现同一帧。我的做法是把每帧完整游戏状态写入一个环形缓冲容量开到 4096 帧约等于 68 秒的游戏画面。回放时直接锁定循环逐帧把记录的 XY 和速度塞回对象再走一遍渲染器。struct FrameRecord { int frame; float ballX, ballY, ballVx, ballVy; float paddle1Y, paddle2Y; int score1, score2; }; FrameRecord replayBuffer[4096]; int recordCount 0;记录逻辑放在 UpdatePhysics 之后、Render 之前保证录进去的每一帧都是提交到屏幕的完整状态。出了 Bug 切换到回放模式按帧前进键逐帧排查比对着输出日志猜位置高效得多。从那以后我每次改球速参数或 AI 策略都强制自己先打三局、再回放三局确认视觉节奏没被改坏才收工。希望帮到你。本文还有配套的精品资源点击获取