ARTICLE DETAIL

资讯详情

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

C++和DirectX 11重制经典吃豆人:核心实现与发布指南

C++和DirectX 11重制经典吃豆人:核心实现与发布指南 简介这是一份基于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 标准选 C17 或 C20。链接器 - 输入 - 附加依赖项常见的是d3d11.lib; dxgi.lib; d3dcompiler.lib。这三个库分别对应 D3D11 设备接口、DXGI 交换链和着色器编译。如果用不上着色器编译可以不留 d3dcompiler但吃豆人至少要有一个绘制 2D 纹理的像素着色器所以留着更省事。2.1.1 为什么用 DXGI 而不是直接创建交换链DirectX 11 自己并不直接管理窗口与显示的缓冲区负责这块的是 DXGIDirectX 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; // 关闭 MSAA2D 游戏先不做多重采样 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::vectorstd::string读入再转换成枚举数组。enum class Tile { Wall, Dot, Empty, Pellet, SpawnP, SpawnG }; std::vectorstd::vectorTile LoadMap(const std::string path) { std::vectorstd::vectorTile grid; std::ifstream f(path); std::string line; while (std::getline(f, line)) { std::vectorTile 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::vectorstd::vectorTile 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::vectorstd::pairint, int GetValidDirections( int row, int col, int backRow, int backCol, const std::vectorstd::vectorTile grid) { std::vectorstd::pairint, int dirs { {-1, 0}, {1, 0}, {0, -1}, {0, 1} }; std::vectorstd::pairint, 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 像素时会变得模糊。这个参数不是谁都能一眼看到的问题但排查画面发虚时最先看它大多数时候都能直接命中。本文还有配套的精品资源点击获取
返回列表