简介:这份C++编写的乒乓球游戏资源包,提供了人机对战与双人对战(PVP)两种模式,适合正在学习游戏开发或图形界面编程的初学者,以及希望快速获得趣味项目练手的开发者。游戏通过键盘W/S/A/D与方向键分别控制两位玩家,并利用双缓存技术提升渲染效率,减少画面闪烁,是理解经典游戏循环与碰撞检测逻辑的直观样例。压缩包内含52个文件,大小约2.91MB,主要涵盖h和cpp源码文件以及obj、exe等编译产物与工程配置文件,便于直接查看源码、编译运行或对比调试。已有827人下载学习。本资源可作为学习C++图形编程、双缓存机制及简单AI设计的入门参考,从中可以获取完整的工程结构、可运行的程序和清晰的功能模块划分,有助于快速上手并扩展为更丰富的游戏玩法。
1. 乒乓球游戏:一个把 C++ 双缓存、人机与 PVP 全塞进源码的小项目
先说结论:这份资源不是让你玩,是让你拆。用 C++ 和 Win32 API 实现一个双人同屏对战、人机对战双模式都齐的乒乓球游戏,核心价值集中在三件事:双缓存绘图怎么做到不闪屏、球拍碰撞的物理判定怎么不飘、AI 难度怎么调才像真人。乒乓球游戏听起来简单,但 80% 的人第一次写完都会遇到画面闪烁、球速不稳、AI 要么弱到白送要么强到无解的问题——这份源码恰好把这些坎都趟过了。
适合两类人:刚学完 C++ 语法、想找一个完整小项目练手的人;以及已经在写图形界面程序、但没系统处理过双缓存和碰撞细节的人。它不是一个开源框架,是一个能直接编译运行、改几行参数就能变难度的完整工程,下面我会把每一块怎么拆、怎么改、坑在哪讲清楚。
2. 先搭骨架:窗口循环、定时器和三种游戏状态
2.1 为什么不用控制台,而用 Win32 窗口
乒乓球游戏需要实时响应键盘、按固定帧率刷新画面,控制台那套cin阻塞式输入根本没法用。常见做法是直接调 Win32 API 创建窗口,因为这份资源的目标就是「零第三方依赖、编译器一开就能跑」,用 MFC 或 Qt 反而增加环境配置成本。Win32 的消息循环天然适合做游戏主循环:键盘按下是消息、定时器到点是消息、窗口重绘也是消息,全都在同一个线程里串行处理,不需要考虑多线程同步。
主窗口的注册和创建大概长这样:
// 注册窗口类并创建主窗口 WNDCLASS wc = {}; wc.lpfnWndProc = WndProc; // 窗口过程函数 wc.hInstance = hInstance; wc.lpszClassName = L"PongWindow"; // 窗口类名 RegisterClass(&wc); HWND hwnd = CreateWindow( L"PongWindow", L"乒乓球游戏", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, // 客户区宽高 NULL, NULL, hInstance, NULL); ShowWindow(hwnd, SW_SHOW);逻辑说明:WndProc是窗口过程,所有消息都汇到这里。WS_OVERLAPPEDWINDOW是标准窗口样式。800×600 是客户区尺寸,不是窗口外框尺寸。
参数说明:宽高后面如果要适配不同分辨率,只需要改这两个数字,但球拍和球的坐标范围也要跟着缩放,否则球会跑到边界外。这个资源里用的是固定 800×600,省事,但如果你想接高清屏,得自己加一个坐标映射层。
2.2 定时器驱动的游戏循环
游戏循环有两种写法:一种是while(1)里用GetMessage死转,另一种是用SetTimer让系统按固定间隔发消息。这份源码用的是SetTimer,理由是省电且简单——在WM_TIMER里统一做「更新状态 + 触发重绘」,帧率被系统定时器锁住,不会出现 CPU 满载。
case WM_CREATE: SetTimer(hwnd, TIMER_GAME, 16, NULL); // 约 60 FPS break; case WM_TIMER: UpdateGame(); // 更新球、球拍、AI 逻辑 InvalidateRect(hwnd, NULL, FALSE); // 请求重绘,但不擦背景 break;逻辑说明:UpdateGame是所有游戏逻辑的入口,球移动、球拍跟随、得分判定都在这里做。InvalidateRect最后一个参数传FALSE,意思是重绘时不先擦背景——配合双缓存后文会讲,这能进一步减少闪烁。
参数说明:间隔 16 毫秒对应约 60 FPS。如果你把定时器间隔改成 10,球速会显著变快;改成 33 就变成 30 FPS,肉眼能明显感到卡顿。建议保持 16,想调难度改球速参数而不是改帧率。
2.3 三态状态机:菜单、对局、暂停
游戏不能一进窗口就开打,得有菜单选模式、对局中能暂停。源码里用了一个简单的枚举状态机,每个状态下UpdateGame的行为完全不同:
enum GameState { STATE_MENU, // 菜单,选 PVP 或人机 STATE_PLAYING, // 对局中 STATE_PAUSED // 暂停 }; void UpdateGame() { switch (g_state) { case STATE_MENU: // 光标闪烁,按 1 进入 PVP,按 2 进入人机 break; case STATE_PLAYING: UpdateBall(); UpdatePaddles(); UpdateAI(); CheckScore(); break; case STATE_PAUSED: break; // 什么都不动,画面停在原地 } }逻辑说明:状态机的好处是把「不同阶段做不同事」这件事显式化,避免在回调里堆一堆if (flag)。菜单状态下球不移动,暂停状态下所有逻辑冻结,只有恢复后才继续。
参数说明:这里用枚举而不是布尔值,是为了未来扩展——比如加一个「发球倒计时」状态,只需要在枚举里加一项,不用改其他分支的判断条件。
3. 双缓存绘图:GDI 内存位图与 WM_PAINT 的正确配合
3.1 什么是双缓存,为什么乒乓球游戏必须用
单缓存绘图的问题很直接:每次WM_PAINT先擦背景、再画球、再画球拍,屏幕会在极短时间内经历「背景色 → 元素出现」的过程,60 FPS 下这个闪烁虽然快,但视觉上拖影和跳动非常明显。双缓存的思路是:先在内存里画好一帧完整的画面,再一次性拷贝到屏幕上,用户永远看不到「画了一半」的中间状态。
乒乓球游戏的画面元素少、背景简单,是双缓存最容易出效果的应用场景。如果你连这个都闪,说明缓存结构没做对。
3.2 内存 DC 的创建与每帧绘制流程
源码在窗口创建时分配一块内存位图,之后每一帧都在这块位图上画,最后BitBlt一次性输出:
// 在 WM_CREATE 里创建内存 DC 和后缓冲区 HDC hdc = GetDC(hwnd); g_memDC = CreateCompatibleDC(hdc); g_memBM = CreateCompatibleBitmap(hdc, 800, 600); SelectObject(g_memDC, g_memBM); ReleaseDC(hwnd, hdc); // 在 WM_PAINT 里绘制整帧 case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); // 1. 先清空后缓冲为黑色背景 FillRect(g_memDC, &g_rectBg, (HBRUSH)GetStockObject(BLACK_BRUSH)); // 2. 画中线、球拍、球 DrawCourt(g_memDC); DrawPaddle(g_memDC, g_leftPaddle); DrawPaddle(g_memDC, g_rightPaddle); DrawBall(g_memDC, g_ball); // 3. 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, 800, 600, g_memDC, 0, 0, SRCCOPY); EndPaint(hwnd, &ps); break; }逻辑说明:CreateCompatibleDC创建与屏幕 DC 兼容的内存 DC,CreateCompatibleBitmap分配实际像素存储。每帧绘制顺序固定:清屏 → 画静态元素 → 画动态元素 → 整体输出。
参数说明:SRCCOPY是直接覆盖拷贝,不做透明处理。如果你的球是圆形,BitBlt拷贝出来会是方形黑底,需要改成TransparentBlt或者绘制时用SetBkMode+Ellipse只画圆。这份源码里直接用了矩形球,避免了透明问题,代价是视觉上不那么精致。想换圆形球的话,注意TransparentBlt要求目标 DC 和源 DC 都是 32 位色深,提前检查桌面色深以免出现透明色变黑。
3.3 后缓冲与窗口尺寸不一致时的处理
800×600 是写死的,如果用户强行拉伸窗口比客户区变大,BitBlt只拷贝左上角 800×600,窗口右侧和下方会出现绘制残留。源码的做法是在WM_SIZE里重新记录客户区大小,但在BitBlt时仍按固定值拷贝。这里有个隐藏的坑:窗口最小化再恢复时,WM_PAINT会重发,g_memBM内容可能被系统清掉。
case WM_SIZE: { g_clientW = LOWORD(lParam); g_clientH = HIWORD(lParam); // 客户区比后缓冲区小时才拷贝,否则留黑边 int copyW = min(g_clientW, 800); int copyH = min(g_clientH, 600); g_copyW = copyW; g_copyH = copyH; break; }注意:如果客户区比 800×600 大,BitBlt用g_copyW/g_copyH拷贝,剩余区域是系统默认背景色,不会闪烁。如果你想做自适应缩放,就得写一个「坐标 → 客户区坐标」的映射函数,所有绘制和碰撞判定都走这个映射。但这份资源没有做,它是刻意保持简单的。
3.4 双缓存常见误用:在 WM_PAINT 里创建内存 DC
有人把CreateCompatibleDC放进WM_PAINT里,每帧重新创建、画完销毁。这在低帧率下能跑,但 60 FPS 下每秒创建 60 次 GDI 对象,GDI 句柄会持续波动,运行十几分钟后窗口突然画不出东西。正确做法是 WM_CREATE 里创建、WM_DESTROY 里释放,中间绝不在 WM_PAINT 里重复创建。
4. 球拍物理与 AI:从坐标更新到难度曲线
4.1 球的运动:位置增量与速度分层
球移动本质上就是每帧更新x += vx * dt。但帧率不是绝对稳定的——定时器偶尔会偏移几毫秒。更稳妥的做法是记录上次更新的时间戳,用真实时间差做乘法,而不是假设每帧都是 16ms:
void UpdateBall() { DWORD now = GetTickCount(); float dt = (now - g_lastTick) / 1000.0f; g_lastTick = now; // 先算水平碰撞(左右边界 -> 得分) if (g_ball.x + g_ball.r < 0) { g_scoreRight++; // 球出左边界,右方得分 ResetBall(1); // 参数 1 = 向右发球 return; } if (g_ball.x - g_ball.r > 800) { g_scoreLeft++; ResetBall(-1); // 向左发球 return; } // 上下边界反弹 if (g_ball.y - g_ball.r < 0 || g_ball.y + g_ball.r > 600) { g_ball.vy = -g_ball.vy; g_ball.y = Clamp(g_ball.y, g_ball.r, 600 - g_ball.r); } g_ball.x += g_ball.vx * dt; g_ball.y += g_ball.vy * dt; }逻辑说明:dt是真实时间差,单位秒。vx、vy单位是「像素/秒」而不是「像素/帧」,这样当你把定时器间隔从 16 改成 10,球速不会变快,只是更新更频繁、运动更平滑。这是源码里值得抄走的设计。
参数说明:ResetBall(1)的 1 表示方向,-1 向左。发球速度建议在 200-300 像素/秒之间,低于 200 球太慢玩家会无聊,高于 400 反应时间不足。上下边界碰撞后把位置Clamp回界限内,防止球嵌入边界导致反复反弹。
4.2 球拍移动:速度限制与键盘状态
球拍移动最蠢的写法是WM_KEYDOWN里一次性移动一大段。按下和松开之间的时间不确定,移动距离也不确定。源码里用的是「按键状态持续读取」:
void UpdatePaddles() { // 左拍:W/S 或 方向键上/下 if (GetAsyncKeyState('W') & 0x8000) { g_leftPaddle.y -= g_paddleSpeed * dt; } if (GetAsyncKeyState('S') & 0x8000) { g_leftPaddle.y += g_paddleSpeed * dt; } // 右拍:方向键上/下 if (GetAsyncKeyState(VK_UP) & 0x8000) { g_rightPaddle.y -= g_paddleSpeed * dt; } if (GetAsyncKeyState(VK_DOWN) & 0x8000) { g_rightPaddle.y += g_paddleSpeed * dt; } // 限制球拍不出界 g_leftPaddle.y = Clamp(g_leftPaddle.y, PADDLE_HALF_H, 600 - PADDLE_HALF_H); g_rightPaddle.y = Clamp(g_rightPaddle.y, PADDLE_HALF_H, 600 - PADDLE_HALF_H); }逻辑说明:GetAsyncKeyState是轮询式按键读取,和WM_KEYDOWN消息的区别在于——前者是「当前是否按着」,后者是「刚按下的那一刻」。你在WM_KEYDOWN里移动的话,按键不放就只触发一次;轮询则可以做到长按持续移动。这对游戏是必要条件。
参数说明:PADDLE_HALF_H决定球拍高度。g_paddleSpeed单位也是像素/秒,和球速度保持同一套单位体系,推荐值在 400-500。太快会追不上球,太慢则球拍看起来像在泥里挪。
4.3 碰撞判定:AABB 与中心点偏移问题
源码里球拍和球都是矩形,碰撞检测用的是 AABB 包围盒。这里有一个非常容易翻车的点:如果你只检测「球心是否在球拍矩形内」,球拍边缘碰到球时球速很快,一帧可能穿过整个球拍而检测不到——这就是俗称的「穿拍」。源码的处理是加一层膨胀检测:
bool CheckCollision(const Rect& paddle, const Ball& ball) { // 把球拍矩形膨胀ball.r,等价于检测球心是否接近球拍 Rect expanded = { paddle.x - ball.r, paddle.y - ball.r, paddle.w + ball.r * 2, paddle.h + ball.r * 2 }; return ball.x >= expanded.left && ball.x <= expanded.right && ball.y >= expanded.top && ball.y <= expanded.bottom; } // 碰撞后按命中点改变反弹角度 void OnPaddleHit(int paddleSide) { float hitPos = (g_ball.y - paddle.y) / paddle.h; // -0.5 ~ 0.5 float angle = hitPos * 3.14f / 3.0f; // 最大60度 float speed = sqrt(g_ball.vx * g_ball.vx + g_ball.vy * g_ball.vy); g_ball.vx = (paddleSide == 1 ? 1 : -1) * speed * cos(angle); g_ball.vy = speed * sin(angle); }逻辑说明:AABB 检测就是「两个矩形的 x 范围有交集且 y 范围有交集」。膨胀球拍是为了容忍高速下球心已经在球拍外边缘的情况。hitPos是球击中球拍时的相对位置,越靠近边缘反弹角度越大,这是乒乓球游戏手感的关键——如果所有反弹都是垂直的,玩家没法控制球的落点。
参数说明:3.14f / 3.0f即 60 度,是最大折射角。角度越大,球越容易打出边界。想调难就调小角度(比如 45 度),想调休闲就调大角度(比如 75 度)。cos(angle)和sin(angle)算出的 vx、vy 需要保持速率不变,即sqrt(vx*vx + vy*vy)和碰撞前一致,否则球每打一次会越飞越快或越来越慢。
4.4 AI 难度:位置预测与外插步数
人机对战是这份资源的重头戏。最傻的 AI 是「球在哪就往哪追」,球一反弹就追,结果 AI 球拍永远在球的正下方,玩家随便打角度就得分。源码里做的是预测拦截——根据球的方向算出落点,再让球拍移动到落点,而且每一步都只追预测点的一部分:
void UpdateAI() { if (g_aiErrorFrames > 0) { g_aiErrorFrames--; // 故意延迟反应 return; } float targetY; if (g_ball.vx > 0) { // 球朝 AI 这边来,预测到 AI 球拍 x 位置时的 y float t = (AI_PADDLE_X - g_ball.x) / g_ball.vx; targetY = g_ball.y + g_ball.vy * t; // 考虑上下边界反弹 while (targetY < 0 || targetY > 600) { if (targetY < 0) targetY = -targetY; if (targetY > 600) targetY = 1200 - targetY; } // 不追完美,只追 70% targetY = g_aiPaddle.y + (targetY - g_aiPaddle.y) * 0.7f; } else { // 球远离 AI,回到中线待命 targetY = 300; } float diff = targetY - g_aiPaddle.y; float move = Clamp(diff, -g_aiSpeed * dt, g_aiSpeed * dt); g_aiPaddle.y += move; }逻辑说明:t是球从当前位置飞到 AI 球拍平面所需的时间,用vy * t算出预期 y 坐标,再加一个「跑出边界反弹」的折叠循环。0.7f是追击比例,AI 永远追到预测点的 70%,而不是 100%,这是手感像真人的关键。
参数说明:g_aiErrorFrames模拟人类反应延迟——每隔几帧不动,数值越大 AI 越弱。g_aiSpeed控制 AI 追球的最大速度,推荐值是玩家速度的 80%-120%。两者结合可以调出「打起来有来有回」的曲线:
| AI 参数 | 效果 | 适用场景 |
|---|---|---|
| ErrorFrames=0, Speed=120% | 反应极快,几乎无敌 | 挑战模式 |
| ErrorFrames=2, Speed=90% | 偶尔失误,但能打角度 | 普通难度 |
| ErrorFrames=6, Speed=70% | 明显迟钝,新手能赢 | 练习模式 |
注意:0.7f这个追击比例是「空间上的不完美」,和g_aiErrorFrames的「时间上的不完美」要分开调。如果你只调ErrorFrames而不调追击比例,AI 要么在球速度慢的时候毫无破绽,要么在球速快的时候反应不过来,曲线不平滑。
4.5 PVP 模式的键位冲突处理
PVP 双人同屏时,两个人共用一块键盘。默认键位左拍 W/S、右拍方向键上下,这组键位有一个问题:如果右拍想用「WASD」而不是方向键,会和左拍冲突。源码里是通过两组独立键的状态位来处理的,不互相覆盖:
// 在 UpdateGame 里同时读两组状态 bool leftUp = (GetAsyncKeyState('W') & 0x8000); bool leftDown = (GetAsyncKeyState('S') & 0x8000); bool rightUp = (GetAsyncKeyState(VK_UP) & 0x8000); bool rightDown = (GetAsyncKeyState(VK_DOWN) & 0x8000);逻辑说明:GetAsyncKeyState支持同时读取多键状态,不会因为WM_KEYDOWN的焦点机制而吞掉其中一个人的按键。PVP 模式下,如果两个人同时按方向键和 W/S,互不干扰。但有一个键盘硬件限制:部分廉价键盘不支持「方向键左 + W + 方向键右」这样的三键组合,这是硬件层面的事,代码改不了。
5. 常见问题排查:闪烁、变速、穿拍与按键冲突的实战记录
5.1 画面还是闪烁
现象:已经按双缓存实现了,BitBlt也调了,但球移动时画面仍然闪烁,屏幕有轻微跳动。
原因:忘了在WM_PAINT里用BeginPaint/EndPaint。BeginPaint会向系统确认「这块区域已重绘」,如果只调BitBlt不调BeginPaint,系统会认为重绘未完成,反复发WM_PAINT消息,导致重绘风暴。另一个原因是InvalidateRect的bErase参数传了TRUE,系统先擦背景再触发你的绘制代码,造成瞬间白闪。
解决:WM_PAINT里一定写成BeginPaint→ 绘制 →EndPaint。擦除标志统一传FALSE,在内存 DC 里自己控制背景。从那以后我每次写WM_PAINT都强制检查这两个点,没再被闪烁回马枪。
5.2 球在低帧率下穿透球拍
现象:把定时器间隔改成 33ms 后,球经常从球拍中间穿过,但 16ms 时没有这个问题。
原因:帧率变低后球每帧移动距离变大。假设球速 300 像素/秒,16ms 时每帧移动约 5 像素,球拍厚度 12 像素能接住;33ms 时每帧移动约 10 像素,如果刚好赶上球从球拍边缘切入,球心检测法会漏判。这是「隧穿效应」,固定帧率下注定存在。
解决:两招。如果你只是想通,把球拍矩形膨胀ball.r * 2就能堵住大部分漏洞;如果想要严谨,做前后两帧之间的线段相交检测——记录上一帧的球心位置,和当前帧球心位置连成线段,检测线段与球拍矩形是否相交。后者的计算量每帧就多一次线段求交,完全值得:
// 线段与 AABB 的相交测试(简化为检测两点之间的中点) bool SweptAABB(const Vec2& prev, const Vec2& ball, const Rect& paddle, float r) { if (CheckCollision(paddle, ball)) return true; // 中点检测折中方案:把上一帧到当前帧分成 4 步 for (int i = 1; i <= 4; i++) { Vec2 mid = { prev.x + (ball.x - prev.x) * i / 4.0f, prev.y + (ball.y - prev.y) * i / 4.0f }; if (mid.x >= paddle.x - r && mid.x <= paddle.x + paddle.w + r && mid.y >= paddle.y - r && mid.y <= paddle.y + paddle.h + r) return true; } return false; }逻辑说明:这个函数把一帧的移动距离细分成 4 段做连续检测,不是真正意义上的连续碰撞,但对乒乓球这种低速球完全够用。i/4.0f是插值比例,4 步意味着每步只移动原来的四分之一,漏判概率大幅降低。
参数说明:步数可以调,但 4 是性价比点。8 步更稳但每帧要多算 4 次比较,乒乓球场景没必要。真正的高速球(比如速 1000+ 像素/秒)建议用数学求交而不是暴力细分。
5.3 AI 难度调不出「中间态」
现象:ErrorFrames调到 3 的时候 AI 还是无敌,调到 4 就变成菜鸟,找不到「打起来有来有回」的档位。
原因:ErrorFrames是一个布尔性质的参数——它只影响 AI 是否反应,不影响 AI 的判断精度。3 帧延迟在球速 300 像素/秒时只差约 0.05 秒,只要 AI 判断准,这点延迟根本不够玩家得分。
解决:调「追击比例」0.7f比调ErrorFrames更有效。把它从 1.0 降到 0.6,AI 会持续追在预测点附近但经常追不到位,玩家就能打角度了。我的调法是:先固定ErrorFrames=2,再调追击比例,从 0.9 往下每降 0.1 打三局,找到自己胜率在 40%-60% 的档位。
5.4 PVP 模式下两个玩家的按键互相吞
现象:两个人同时按键时,有一个球拍偶尔「卡住」不动,松开再按才恢复。
原因:一种可能是键盘矩阵冲突——方向键和 WASD 在某些键盘上共用信号线,硬件层不支持同按。另一种可能是代码里用了WM_KEYDOWN且没有处理重复消息,一个键按住不放会触发重复WM_KEYDOWN,状态标志位被反复置位和清零。
解决:换成GetAsyncKeyState轮询后问题解决。如果特定键位组合仍然冲突,把 PVP 的键位改成不冲突的组合,比如左拍 W/S、右拍「数字键盘 8/2」。数字键盘是独立扫描线,几乎不和主键区冲突,代价是笔记本没有数字键盘会尴尬。
5.5 窗口最小化恢复后画面全黑
现象:最小化再还原,窗口是黑的,按一下键盘才恢复。
原因:窗口最小化时,g_memBM的内容被系统释放或覆盖,UpdateGame在最小化时也还在跑(定时器没停),球和球拍的位置已经变了,但内存位图还是旧内容。恢复后WM_PAINT会用残留的旧缓冲输出,看起来就是黑的或残影。
解决:在WM_SIZE里判断lParam为 0 时停掉定时器,非 0 时重启定时器并强制重绘一帧。同时需要重建后缓冲区,因为最小化时位图对象可能被丢弃:
case WM_SIZE: if (wParam == SIZE_MINIMIZED) { KillTimer(hwnd, TIMER_GAME); } else { // 重建后缓冲 if (g_memBM) DeleteObject(g_memBM); g_memDC = CreateCompatibleDC(GetDC(hwnd)); g_memBM = CreateCompatibleBitmap(GetDC(hwnd), 800, 600); SelectObject(g_memDC, g_memBM); SetTimer(hwnd, TIMER_GAME, 16, NULL); InvalidateRect(hwnd, NULL, FALSE); } break;逻辑说明:最小化时系统不发WM_PAINT,定时器还在跑只会白耗 CPU,直接杀掉。恢复时重建整个后缓冲是保险动作,虽然浪费一点点内存,但避免了一切「恢复后花屏」的诡异问题。
6. 收尾调优:FPS 计数、滞粘手感修正与碰撞细分检测
6.1 加一个简单的 FPS 计数器
调试双缓存是否真正生效,最快的验证方式是显示 FPS。代码里加一个计数器,每秒钟统计WM_TIMER触发的次数,画在窗口左上角。低于 55 说明定时器调度被拖累,或者窗口过程里有阻塞操作。
void DrawFPS(HDC hdc) { static DWORD lastSec = 0; static int frameCount = 0; DWORD now = GetTickCount(); frameCount++; if (now - lastSec >= 1000) { g_fps = frameCount; frameCount = 0; lastSec = now; } wchar_t buf[32]; swprintf(buf, 32, L"FPS: %d", g_fps); TextOut(hdc, 700, 10, buf, wcslen(buf)); }逻辑说明:frameCount累计帧数,跨秒后把累计值写入g_fps并清零。显示在右上角不挡球赛,只在调试时保留。
6.2 滞粘手感修正:碰撞后的位置回退
球撞到球拍后,如果没有把球位置「推」到球拍边缘之外,下一帧可能又满足碰撞条件,发生两次碰撞导致球反向卡在拍内。修正方法是碰撞后直接把球放到拍边缘:
void ResolveCollision(int side) { if (side == 1) { // 左拍 g_ball.x = g_leftPaddle.x + g_leftPaddle.w + g_ball.r + 0.1f; } else { g_ball.x = g_rightPaddle.x - g_ball.r - 0.1f; } }逻辑说明:0.1f是防止浮点误差导致位置仍然重叠。别小看这 0.1 像素,少了它高速状态下仍然可能偶发卡球。
6.3 碰撞细分检测的最后一道保险
第 5 章里 4 步细分检测已经能用,但如果你想彻底消灭高速穿拍,可以把插值步数从 4 改成动态计算——根据当前帧球的位移量决定步数,位移超过 20 像素就细分:
int steps = max(1, ceil(ballSpeed * dt / 10.0f));逻辑说明:ballSpeed * dt是这一帧的实际移动距离,除以 10 得到步数——每步最多移动 10 像素。球速越快、帧率越低,步数自动增多,不会因为固定 4 步而在极端速度下失效。
参数说明:10 是「半拍厚」的经验值,球拍厚 12 像素,步进不超过 10 就基本不会穿过。改大这个数会增加 CPU 消耗,且没有实际收益。
6.4 拦截型 AI 与碰撞细分共存时的坑
AI 做预测拦截时用的是球的位置和速度,但如果球在高速穿拍中被细分检测「截住」并弹回,AI 的预测是基于上一帧位置的,会出现 AI 球拍猛冲到误差方向的情况。解决办法是:AI 预测时也用细分后的最新球位置,而不是等UpdateBall跑完再读。
具体来说,把UpdateBall拆成「移动一小步 → 检测一次碰撞」的循环,每步之间UpdateAI都能读到最新球的位置,AI 的反应就会平滑很多。这算是一个不常见但很实用的组合技巧:物理细分和 AI 预测共用一个循环。
6.5 最终验证清单
写完这些优化后,我每次都会强制走一遍验证流程:开 60 FPS 计时器打三局 PVP;把定时器改成 33ms 确认球不穿拍;把 AI 追击比例从 1.0 改到 0.5 观察手感变化;最小化窗口再还原确认画面恢复;最后开任务管理器确认 CPU 占用低于 5%。五项全过才算合格。
这套流程帮我躲过很多次「本地跑得好好的、发给别人就出问题」的尴尬。乒乓球游戏这个项目的源码不算大,但把双缓存、AABB 碰撞、AI 预测、状态机这些 C++ 图形编程的基础课全部串了一遍,你照着上面的步骤跑一遍、改一遍、坏一遍,比看十遍教程都管用。希望帮到你。
本文还有配套的精品资源,点击获取