简介:这是一份基于C++与DirectX 11实现的吃豆人游戏完整工程,适合游戏开发初学者及对DirectX渲染管线、经典AI追逐逻辑感兴趣的开发者参考。项目复刻1980年原版玩法,支持方向键移动、吃豆得分、被鬼魂捕捉、服用能量豆后反捕幽灵等核心机制,并设计了追击/逃跑多阶段切换;每个幽灵都带有源自1980年版的独特AI,同时保留了Pinky与Inky的已知行为差异,便于对比研究。工程共61个文件,主体为18个头文件与15个C++源文件,包含8个HLSL着色器、7张PNG精灵图和2个GIF演示动画,另附VS2017解决方案、NuGet依赖配置、地图素材、README与资源内容说明,整体约6MB,包体紧凑且目录分层明确。项目通过分离立方体拼接2D地图,并对相邻立方体做合并优化以减少三角形数量、规避z-fighting,这些实现细节都可在源码中直接查看。目前已有109人学习下载,适合拿来分析DirectX 11游戏框架搭建、编译运行后自行扩展关卡或AI逻辑。
1. 用 C++ 和 DirectX 11 写吃豆人的起点在哪里
很多人第一句话是"D3D11 是不是太老了"。对做游戏引擎来说它确实不是新宠,但如果我们目标是"吃豆人"这个级别的 2D 游戏,DirectX 11 恰好是 Windows 上最稳的选择:文档全、示例多、对低端显卡友好,而且从 D3D11 起步学到的渲染概念,迁移到 D3D12 或 Vulkan 时不会有太多推翻重来的地方。这篇文章围绕一份典型的"C++ + DirectX 11 吃豆人"工程展开,把地图数据、精灵渲染、碰撞检测和幽灵 AI 拆开讲透。适合两类人:一类是刚学完 C++ 语法,想拿一个 Windows 原生项目练手;另一类是已经能做控制台程序,但在 DirectX 上还没有完整跑通过一个窗口的开发者。看完你能得到一份可以直接写代码的检查清单,以及发布时容易被忽略的 Visual C++ Redistributable 打包细节。
2. DirectX 11 环境准备与最小窗体框架
2.1 工具链选型:Visual Studio 2022 与 Windows SDK
常见做法是直接装 Visual Studio 2022 Community,安装时勾选"使用 C++ 的桌面开发"和"适用于最新生成工具的 Windows SDK"。DirectX 11 的运行时在 Windows 7 到 Windows 11 上都是系统自带的,所以开发机上一般不需要额外装 DirectX SDK;真正需要的是头文件和库,它们随 Windows SDK 一起分发。
工程创建选择"空项目",然后在项目属性里注意两处:
- C/C++ -> 语言 -> C++ 标准,选 C++17 或 C++20。
- 链接器 -> 输入 -> 附加依赖项,常见的是
d3d11.lib; dxgi.lib; d3dcompiler.lib。
这三个库分别对应 D3D11 设备接口、DXGI 交换链和着色器编译。如果用不上着色器编译可以不留 d3dcompiler,但吃豆人至少要有一个绘制 2D 纹理的像素着色器,所以留着更省事。
2.1.1 为什么用 DXGI 而不是直接创建交换链
DirectX 11 自己并不直接管理窗口与显示的缓冲区,负责这块的是 DXGI(DirectX Graphics Infrastructure)。创建交换链时你用DXGI_SWAP_CHAIN_DESC描述缓冲区数量、格式、刷新频率,然后通过D3D11CreateDeviceAndSwapChain一次性拿到设备、上下文和交换链三个核心对象。理解这条线能少走许多弯路:设备负责创建资源,上下文负责记录绘制命令,交换链负责把渲染结果呈现到窗口。
提示:调试 C++ 与 DirectX 程序时,Debug 模式下可开启
D3D11_CREATE_DEVICE_DEBUG标志,输出窗口会打印不正确的引用计数或资源状态错误,这是最有价值的反馈来源。
2.2 创建窗口与 D3D11 设备的最小代码
下面这段代码浓缩了一个最简初始化流程,省略了窗口过程函数细节,只保留 D3D 部分的骨架:
#include <windows.h> #include <d3d11.h> #include <dxgi.h> ID3D11Device* g_pd3dDevice = nullptr; ID3D11DeviceContext* g_pd3dImmediateContext = nullptr; IDXGISwapChain* g_pSwapChain = nullptr; ID3D11RenderTargetView* g_pRTV = nullptr; bool InitD3D(HWND hWnd) { DXGI_SWAP_CHAIN_DESC sd = {}; sd.BufferCount = 2; // 双缓冲 sd.BufferDesc.Width = 960; sd.BufferDesc.Height = 960; sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow = hWnd; sd.SampleDesc.Count = 1; // 关闭 MSAA,2D 游戏先不做多重采样 sd.Windowed = TRUE; sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; UINT flags = 0; #ifdef _DEBUG flags |= D3D11_CREATE_DEVICE_DEBUG; #endif D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_0 }; HRESULT hr = D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, 2, D3D11_SDK_VERSION, &sd, &g_pSwapChain, &g_pd3dDevice, nullptr, &g_pd3dImmediateContext); if (FAILED(hr)) return false; ID3D11Texture2D* pBackBuffer = nullptr; g_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer); g_pd3dDevice->CreateRenderTargetView(pBackBuffer, nullptr, &g_pRTV); pBackBuffer->Release(); return true; }这段代码核心逻辑分三步:先填DXGI_SWAP_CHAIN_DESC描述交换链需求;再调用D3D11CreateDeviceAndSwapChain获得设备、上下文、交换链;最后调用GetBuffer拿到后台缓冲纹理,创建渲染目标视图。渲染循环只需要在每帧调用ClearRenderTargetView再绘制即可。
参数上值得说明的是SampleDesc.Count = 1。很多人在教程里看到非要设 4 才觉得"开了抗锯齿",其实 2D 像素风格游戏的开销大头在纹理采样,不在几何边缘,这里留 1 反而更符合吃豆人场景;等后面需要做图形特效时再启用 MSAA 也不迟。
2.3 交换链呈现与窗口尺寸变化
吃豆人这类固定视野的 2D 游戏,最省事的做法是窗口尺寸写死(例如 960 乘 960),窗口不可缩放,也不需要处理WM_SIZE和交换链 Resize 的完整逻辑。如果以后想扩成自适应窗口,需要调用ResizeBuffers并重新创建 RTV,同时更新视口。
void ResizeSwapChain(UINT width, UINT height) { if (g_pSwapChain) { g_pSwapChain->ResizeBuffers(2, width, height, DXGI_FORMAT_R8G8B8A8_UNORM, 0); // 重新创建 RenderTargetView ID3D11Texture2D* pBackBuffer = nullptr; g_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer); g_pd3dDevice->CreateRenderTargetView(pBackBuffer, nullptr, &g_pRTV); pBackBuffer->Release(); } }窗口尺寸变化时,BufferDesc.Width与Height也要同步,否则画面会被拉伸变形。常见坑是只改了窗口大小而忘了更新视口(RSSetViewports),导致渲染图仍然铺满旧视口,表现是边缘有残影或内容裁剪。
3. 吃豆人游戏逻辑的骨架设计
3.1 地图数据:二维数组与瓦片尺寸
吃豆人的经典地图是 28 列乘 31 行。我一般用文本文件按行存储,字符 '#' 表示墙壁,'.' 表示豆子,' ' 表示空格,'P' 表示玩家初始位置,'G' 表示幽灵初始位置。加载时用一个std::vector<std::string>读入,再转换成枚举数组。
enum class Tile { Wall, Dot, Empty, Pellet, SpawnP, SpawnG }; std::vector<std::vector<Tile>> LoadMap(const std::string& path) { std::vector<std::vector<Tile>> grid; std::ifstream f(path); std::string line; while (std::getline(f, line)) { std::vector<Tile> row; for (char c : line) { switch (c) { case '#': row.push_back(Tile::Wall); break; case '.': row.push_back(Tile::Dot); break; case 'P': row.push_back(Tile::SpawnP); break; case 'G': row.push_back(Tile::SpawnG); break; default: row.push_back(Tile::Empty); break; } } grid.push_back(row); } return grid; }地图数据与渲染分离有个直接好处:调整关卡不需要重编译。但要注意行与行的长度必须一致,否则后续的数组访问越界很难查。我习惯在加载后立刻断言所有行长度相等,并且行列数符合预期。
瓦片大小关系到窗口尺寸和精灵放缩。如果窗口是 960 乘 960,去掉上下各一行 HUD 区域,游戏区取 896 像素,那么 28 列对应每个瓦片 32 像素,正好对齐。像素风素材建议原图就是 16 或 32 乘 32,避免运行时缩放产生模糊。
3.2 玩家移动与碰撞判定
吃豆人的移动不是像素级的,而是"格子级"的。也就是说,玩家按下方向键后,逻辑上先判断下一个格子是否为 Wall,如果不是才移动。这种判定简单可靠,不会出现卡在墙里的情况。
bool TryMove(Player& player, int dRow, int dCol, const std::vector<std::vector<Tile>>& grid) { int nRow = player.row + dRow; int nCol = player.col + dCol; if (nRow < 0 || nRow >= int(grid.size())) return false; if (nCol < 0 || nCol >= int(grid[0].size())) return false; if (grid[nRow][nCol] == Tile::Wall) return false; player.row = nRow; player.col = nCol; return true; }这里有个容易被忽略的点:真实吃豆人里玩家可以"预输入"下一次方向,也就是在到达格子中心之前按下的方向键会被缓存,到达中心后立即转向。实现方式是在结构中记录pendingDir,每帧检查一次是否合法,合法才转向。不做这个细节,游戏手感会明显变钝。
豆子计数直接关系得分。每次TryMove成功后,检查当前格子是否为 Dot 或 Pellet,是则加分并置空。这里不涉及物理引擎,所以碰撞判定就退化成"查询格子内容"。
3.3 幽灵 AI:从随机游走到四散追击
吃豆人里每个幽灵的性格不同,但最常用的基础模式是:红幽灵 Blinky 追踪玩家当前格子,粉幽灵 Pinky 追玩家面向方向前四格,蓝幽灵 Inky 以红幽灵位置和玩家位置连线做镜像目标,橙幽灵 Clyde 在距离小于 8 格时转向地图角落。全部实现只是目标点不同,寻路用的都是同一种格子路径。
一个被大量初级教程简化的点是:幽灵在岔路口才选择方向,而不是每帧随机变向。实现方式是对每个幽灵维护row, col, dir,到达格子中心时才根据当前位置和目标位置计算下一个方向。计算逻辑一般分两步:
std::vector<std::pair<int, int>> GetValidDirections( int row, int col, int backRow, int backCol, const std::vector<std::vector<Tile>>& grid) { std::vector<std::pair<int, int>> dirs = { {-1, 0}, {1, 0}, {0, -1}, {0, 1} }; std::vector<std::pair<int, int>> result; for (auto& d : dirs) { int nr = row + d.first; int nc = col + d.second; if (nr == backRow && nc == backCol) continue; // 禁止掉头 if (nr < 0 || nr >= (int)grid.size()) continue; if (nc < 0 || nc >= (int)grid[0].size()) continue; if (grid[nr][nc] == Tile::Wall) continue; result.push_back(d); } return result; }禁止掉头是关键,否则幽灵会在两个相邻格子之间来回抖动。有了合法方向列表,再计算曼哈顿距离选最小即可:
int Manhattan(int r1, int c1, int r2, int c2) { return std::abs(r1 - r2) + std::abs(c1 - c2); }对每个合法方向得到的新格子,计算到目标点的曼哈顿距离,取最小。这个方案不是最优路径,但在吃豆人迷宫里表现已经很接近原作。如果想再进一步,可以在被追踪状态(Frightened)下改用随机选择而不是距离最小,这样玩家能明显感到幽灵变笨了,实现难度曲线调节。
4. DirectX 11 渲染吃豆人场景
4.1 顶点缓冲与纹理贴合
吃豆人一帧要画的东西很多:地图瓦片、豆子、玩家、四个幽灵。最简单的方式是每帧重建顶点缓冲,但对上千个瓦片来说会有 CPU 开销。常见做法是把地图静态部分在初始化时构建到顶点缓冲里,动态物体每帧更新。
顶点结构可以这样定义:
struct Vertex { float pos[2]; // 局部坐标 float uv[2]; // 纹理坐标 float color[4]; // 顶点色, 用来做闪烁 };D3D11 用输入布局(InputLayout)描述这份内存的布局,然后在像素着色器里做纹理采样或纯色输出。吃豆人场景里豆子通常就是一个白色小圆点,用一个 4 顶点 triangle strip 加一张圆点贴图即可,不需要走精灵表。
4.1.1 精灵表的需求判断
经典吃豆人动画帧很少,玩家张嘴合嘴 2 帧,幽灵只有方向差异。做一张 32x32 的精灵表或者干脆每物体单图都行。建议用 DirectXTex 或自带 WIC 读取 PNG,再CreateShaderResourceView。网上很多教程引入第三方库,其实 Windows 自带的 WIC 就够读 PNG 了,只需要链接windowscodecs.lib。
如果目标是快速出结果,可以在初始化时用 CPU 生成纯色纹理(例如 4x4 白色纹理),配合顶点色画出豆子和玩家的基础形状,这样连 PNG 都不用准备。对练习者来说,先让逻辑跑通再替换美术资源,迭代速度会快不少。
4.2 混合状态与闪烁效果
DirectX 11 的渲染状态开启混合需要设置ID3D11BlendState。吃豆人里的使用场景是幽灵半透明效果和吃豆人略微发光的感觉。创建一个 alpha blend 状态:
D3D11_BLEND_DESC bd = {}; bd.RenderTarget[0].BlendEnable = TRUE; bd.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA; bd.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA; bd.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; bd.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; bd.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ZERO; bd.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; bd.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; ID3D11BlendState* g_pBlendState = nullptr; g_pd3dDevice->CreateBlendState(&bd, &g_pBlendState); g_pd3dImmediateContext->OMSetBlendState(g_pBlendState, nullptr, 0xFFFFFFFF);幽灵在惊惧状态会变成深蓝色半透明,这个效果本质是顶点色里 alpha 通道随状态机变化。混合状态开启之后,渲染顺序变得重要:必须先画不透明的地图和豆子,再画半透明的幽灵,避免错误叠加。2D 游戏没有深度缓冲,顺序全靠 CPU 端控制绘制调用次序。
4.3 帧率控制与垂直同步
吃豆人的游戏逻辑是按格子移动的,所以逻辑更新不需要和渲染同步到每帧。常见做法是用固定时间步长 1/60 秒调用一次Update,每帧累积真实时间差,到达步长就执行多步逻辑更新。
double accum = 0.0; const double step = 1.0 / 60.0; LARGE_INTEGER freq, curr, prev; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&prev); while (running) { QueryPerformanceCounter(&curr); double delta = double(curr.QuadPart - prev.QuadPart) / double(freq.QuadPart); prev = curr; accum += delta; while (accum >= step) { Update(step); // 固定步长逻辑 accum -= step; } Render(); // 渲染可以每帧都做 g_pSwapChain->Present(1, 0); // 1 表示开启垂直同步 }Present(1, 0)里的第一个参数是同步间隔,设 1 开启垂直同步,画面撕裂消失但延迟增加;设 0 关闭,帧率跑满但可能撕裂。吃豆人画面变化不大,选择开启垂直同步能减少 GPU 占用和画面撕裂。注意accum不能无限累加,当程序被最小化或拖动窗口时 delta 会很大,逻辑步长会空转,正确做法是限制累积值上限,比如if (accum > 0.25) accum = 0.25;防止"螺旋死亡"。
5. 发布前的资源打包与常见运行时报错排查
5.1 Visual C++ Redistributable 与静态链接选择
工程项目几周后回看,最常见的发布失败原因是缺少运行时组件。使用 Visual Studio 编译的 C++ 程序默认动态链接到vcruntime140.dll和msvcp140.dll,目标机器如果没有对应版本的 Visual C++ Redistributable,双击会直接报"0xc000007b"或"找不到 VCRUNTIME140.dll"。
两种解法:一是安装包内带上 Redistributable 安装包并在静默安装后启动游戏;二是在项目属性里把运行库从"多线程 DLL"改成"多线程",把 CRT 静态链接进程序。吃豆人这种规模不大的项目,我一般选静态链接,省去用户额外装运行库的步骤。
静态链接的代价是可执行文件体积增大 1 到 2 MB,且安全更新需要重新编译。对这个小项目完全可接受。visual c++ redistributable aio那种合集包适合做多台机器统一布置的工具,游戏产品本身不建议依赖用户单独安装。
5.2 调试资源泄漏与常见卡壳点
DirectX 程序的 D3D11 Debug Layer 会在刷新时输出未释放对象的引用计数警告。常见位置是创建了ID3D11Texture2D作为后台缓冲却忘记 Release,以及每帧都创建一个新的ID3D11BlendState却不释放。规范做法是把不常用的状态对象在初始化时创建一次,仅当状态改变时才重新配置。
还有一个直接关联项目压缩包使用的坑:很多人从 zip 解压源码后直接打开解决方案文件,重新生成时提示中间目录冲突。这是因为多个工程共用同一个$(IntDir)。在项目属性 -> 常规里把中间目录和输出目录改成带工程名的路径即可解决。
5.2.1 验证帧率与渲染资源是否正常
在 HUD 上显示一个帧率计数器是最直接的验证手段。用QueryPerformanceCounter计算每秒帧数,然后在窗口标题栏更新:
int frameCount = 0; double fpsTimer = 0.0; double fps = 0.0; void UpdateFPS(double delta) { frameCount++; fpsTimer += delta; if (fpsTimer >= 1.0) { fps = frameCount / fpsTimer; frameCount = 0; fpsTimer = 0.0; } }这个数字不能只看是否大于 60,更要看是否稳定。吃豆人画面简单,但如果你看到帧率在 60 与 120 之间来回跳,通常说明Present模式不稳定或逻辑更新中有偶发的大量内存分配操作。打开垂直同步后,稳定在 60 就对了。
5.3 最后检查一遍这些容易翻车的细节
把窗口尺寸、地图数据和纹理尺寸三者对齐是最容易忽略的一项。窗口 960 乘 960,地图 28 列乘 28 行,瓦片 32 乘 32,游戏区正好 896,剩下 64 像素分给 HUD。任何一个数字改动,另外两个都要跟着变,最省事的方式是全局常量统一引用,不要在代码里散落魔法数字。
第二个易错点是幽灵移动速度在吃豆人不同关卡中有差异,而玩家速度恒定。实现时可以在幽灵结构体里加一个speedMultiplier,作为步长的系数传入Update。不要简单地把两个方向输入叠加压缩,否则之后的回放功能或状态同步会很痛苦。
第三个细节是纹理采样器(ID3D11SamplerState)的设置:吃豆人的像素美术风格适合D3D11_FILTER_MIN_MAG_MIP_POINT,如果用了线性过滤,32 像素小图被放大到 128 像素时会变得模糊。这个参数不是"谁都能一眼看到"的问题,但排查画面发虚时最先看它,大多数时候都能直接命中。
本文还有配套的精品资源,点击获取