
简介这是一份面向C与DirectX 11图形编程学习者的吃豆人游戏完整源码工程适合想通过实战项目理解2D渲染、游戏循环与AI行为设计的开发者参考。项目以1980年原版吃豆人为蓝本实现了方向键移动、吃豆、能量剂追击、幽灵追逐与逃跑的多阶段玩法每个幽灵都带有基于原版的独特AI逻辑并保留了Pinky与Inky行为差异的已知说明。压缩包共61个文件约6MB包含18个h头文件与15个cpp源文件构成核心逻辑8个hlsl着色器负责渲染管线另有png、gif等贴图与动画素材以及sln、vcxproj等Visual Studio 2017工程配置。世界由分离立方体拼合的2D地图生成并对相邻立方体做了合并优化以减少三角形数量、规避z-fighting问题。目前已有109人学习读者可从中获取完整的DirectX 11项目结构、DirectXTK集成方式、精灵表渲染与幽灵AI实现思路适合作为图形学课程设计或游戏开发入门的参考案例。1. 从一份吃豆人 zip 说起C 和 DirectX 11 到底能做出什么很多人第一次看到「使用 C 和 DirectX 11 开发的吃豆人游戏.zip」这类标题第一反应是「又一个 c小游戏代码 合集」下载下来发现要么跑不起来要么只有一堆散乱的头文件。但如果你真的想搞懂 c游戏 是怎么从零搭起来的吃豆人反而是个极好的练手对象它有固定网格地图、有实时移动的 AI 幽灵、有碰撞检测、有状态机切换几乎覆盖了 2D 游戏循环里所有核心环节。而 DirectX 11 虽然常被贴上「3D 渲染」的标签用它做 2D 精灵批处理同样干净利落性能也远比 GDI 靠谱。这篇文章不假设你手里已经有一份能跑的源码而是顺着这个标题把「用 C 和 DirectX 11 写一个吃豆人」这件事拆成能复现的路径环境怎么配、窗口和渲染管线怎么起、地图和精灵怎么组织、幽灵 AI 怎么设计、碰撞和状态机怎么落地最后再讲几个只有真正动手才会遇到的坑。适合已经学过 c基础、写过控制台程序想往图形和游戏方向迈一步的人也适合手上有类似 zip 但跑不通、想搞清内部结构的同学。读完你应该能自己从空项目搭出一个可玩的版本而不是只会改别人的代码。2. 环境与工程骨架把 DirectX 11 窗口先跑起来2.1 工具链选择Visual Studio 还是 vscode配置c环境做 DirectX 11 开发工具链的选择直接决定你后面调试顺不顺手。Windows 平台上最省事的仍然是 Visual Studio因为 DirectX SDK 早已并入 Windows SDKVS 安装时勾选「使用 C 的桌面开发」就会带上 d3d11.lib、dxgi.lib 这些库链接器配置几乎不用手动改。如果你习惯 vscode配置c/c环境也能做但需要自己装 MSVC 或 MinGW、手写 tasks.json 和 c_cpp_properties.json而且 MinGW 对 DirectX 的支持并不完整容易在链接阶段翻车。我的建议是第一次搭 DirectX 11 工程老老实实用 Visual Studio等窗口跑通了再考虑换编辑器。创建工程时选「空项目」而不是「Windows 桌面应用程序」后者会塞进一堆用不上的框架代码。建好后在项目属性里确认两点字符集用 UnicodeC 语言标准至少 C17。然后新建一个 main.cpp把下面这段最小窗口代码贴进去。// main.cpp - DirectX 11 最小窗口骨架 #include windows.h #include d3d11.h #include dxgi.h #pragma comment(lib, d3d11.lib) #pragma comment(lib, dxgi.lib) ID3D11Device* g_device nullptr; ID3D11DeviceContext* g_context nullptr; IDXGISwapChain* g_swapChain nullptr; ID3D11RenderTargetView* g_rtv nullptr; LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg WM_DESTROY) { PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); } bool InitD3D(HWND hwnd, int width, int height) { DXGI_SWAP_CHAIN_DESC sd {}; sd.BufferCount 1; sd.BufferDesc.Width width; sd.BufferDesc.Height height; sd.BufferDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM; sd.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow hwnd; sd.SampleDesc.Count 1; sd.Windowed TRUE; D3D_FEATURE_LEVEL level; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, D3D11_SDK_VERSION, sd, g_swapChain, g_device, level, g_context); if (FAILED(hr)) return false; ID3D11Texture2D* backBuffer nullptr; g_swapChain-GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)backBuffer); g_device-CreateRenderTargetView(backBuffer, nullptr, g_rtv); backBuffer-Release(); return true; }这段代码做了三件事定义交换链描述、一次性创建设备和上下文、从后台缓冲拿到渲染目标视图。参数里D3D_DRIVER_TYPE_HARDWARE表示用独显或集显硬件加速调试阶段如果驱动有问题可以临时换成D3D_DRIVER_TYPE_WARP走软件渲染虽然慢但能帮你判断是不是显卡驱动导致的失败。SampleDesc.Count 1表示不开多重采样2D 游戏基本不需要开了反而增加显存开销。BufferDesc.Format用R8G8B8A8_UNORM是通用选择后面做透明混合时 alpha 通道直接可用。2.2 消息循环与帧循环别把渲染写进 WndProc窗口创建和消息循环是 Win32 的标准套路但新手最容易犯的错是把渲染代码塞进WM_PAINT或者WndProc里。正确做法是消息循环只负责分发消息渲染放在独立的帧循环里用PeekMessage而不是GetMessage这样窗口不阻塞游戏才能持续刷新。int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int nCmdShow) { WNDCLASS wc {}; wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.lpszClassName LPacmanDX11; RegisterClass(wc); HWND hwnd CreateWindowEx(0, LPacmanDX11, LPacman, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 960, 720, nullptr, nullptr, hInst, nullptr); ShowWindow(hwnd, nCmdShow); if (!InitD3D(hwnd, 960, 720)) return -1; MSG msg {}; while (msg.message ! WM_QUIT) { if (PeekMessage(msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(msg); DispatchMessage(msg); } else { // 帧循环清屏 绘制 呈现 float clearColor[4] { 0.0f, 0.0f, 0.0f, 1.0f }; g_context-ClearRenderTargetView(g_rtv, clearColor); g_swapChain-Present(1, 0); // 1 开启垂直同步 } } return 0; }Present(1, 0)的第一个参数是同步间隔1 表示跟随显示器刷新率能避免画面撕裂同时防止 CPU 空转把风扇拉满。如果你后面做性能测试想跑满帧可以临时改成 0但正式玩的时候建议保持 1。清屏颜色先用纯黑等地图和精灵接进来再换成深蓝底。到这里编译运行你应该能看到一个黑色窗口这就是后面所有内容的画布。3. 地图、精灵与渲染管线把吃豆人的世界画出来3.1 用网格数据描述迷宫而不是硬编码坐标吃豆人的地图是典型的规则网格常见做法是用一个二维字符数组或整型数组描述每个格子的类型墙、豆子、能量豆、空地、幽灵出生点。这样地图和渲染逻辑解耦改关卡只需要改数据不用动代码。下面是一个 28x31 的简化地图片段用 0 表示墙、1 表示豆子、2 表示能量豆、3 表示空地。// 地图数据每行一个字符串便于肉眼校对 const int MAP_W 28; const int MAP_H 31; const char* g_map[MAP_H] { 0000000000000000000000000000, 0111111111111001111111111110, 0211111111111001111111111120, 0111111111111001111111111110, 0111001111000000001111001110, // ... 中间省略实际项目补全 31 行 0000000000000000000000000000 }; // 每个格子的像素尺寸28*24672 宽31*24744 高 const int TILE 24;用字符串数组的好处是改地图时直接在编辑器里对齐字符比一堆{x, y, type}结构体直观得多。TILE决定每个格子在屏幕上的像素大小取 24 是因为 960 宽的窗口放 28 列刚好留出边距同时 24 能被 2 整除精灵缩放时不容易出现半像素模糊。实际项目里地图数据建议放到单独的头文件或文本文件用ifstream读进来这样打包发布时改地图不用重新编译。3.2 精灵批处理为什么 2D 游戏也要用 DirectX 11有人会问2D 游戏用 GDI 或 Direct2D 不就行了为什么非要上 DirectX 11答案在精灵数量上。吃豆人一帧要画几十个豆子、四个幽灵、一个吃豆人还有 UI 文字如果用 GDI 逐个BitBltCPU 占用会明显上升而且缩放和旋转支持很差。DirectX 11 的精灵批处理可以把同一张纹理上的所有精灵合并成一次 draw call性能差距在低端机上非常明显。核心思路是把所有精灵图打包到一张纹理图集texture atlas里每个精灵记录它在图集上的 UV 矩形渲染时用实例化或动态顶点缓冲一次性提交。下面是一个简化的精灵结构和一个基于动态顶点缓冲的绘制函数。struct Sprite { float x, y; // 屏幕像素坐标 float u0, v0, u1, v1; // 图集 UV 坐标 float r, g, b, a; // 顶点色用于 tint }; // 每帧把所有精灵填充进顶点缓冲一次 draw call 提交 void RenderSprites(const std::vectorSprite sprites) { std::vectorVertex verts; verts.reserve(sprites.size() * 6); // 每个精灵两个三角形 for (const auto s : sprites) { float w (s.u1 - s.u0) * ATLAS_W; float h (s.v1 - s.v0) * ATLAS_H; // 左上、右上、左下、右下四个顶点组成两个三角形 // 具体顶点填充略关键是 UV 和位置对应 } // 映射动态顶点缓冲memcpyUnmapDraw D3D11_MAPPED_SUBRESOURCE mapped; g_context-Map(g_vertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, mapped); memcpy(mapped.pData, verts.data(), verts.size() * sizeof(Vertex)); g_context-Unmap(g_vertexBuffer, 0); g_context-Draw(verts.size(), 0); }D3D11_MAP_WRITE_DISCARD是关键参数它告诉驱动这块缓冲的旧内容不要了驱动可以分配新内存避免等待 GPU这是动态缓冲每帧更新的标准做法。如果错用D3D11_MAP_WRITE驱动会等上一帧画完才让你写帧率会直接掉一半这个坑我在第一次做批处理时踩过查了半天才发现是 Map 标志写错。图集纹理创建时记得把Filter设为D3D11_FILTER_MIN_MAG_MIP_POINT2D 像素风游戏用点采样才不会糊。4. 幽灵 AI、碰撞与状态机让游戏真正动起来4.1 幽灵寻路网格上的贪心与目标格切换吃豆人的幽灵 AI 不需要 A* 那么重经典做法是每个幽灵有一个「目标格」在交叉路口选择离目标格方向最近的可行方向。四个幽灵用不同策略产生差异红色直接追吃豆人当前位置粉色追吃豆人前方四格青色追吃豆人后方橙色在距离远时追、距离近时逃。这种设计用几十行代码就能实现而且行为可预测玩家能学会套路。// 选择下一个方向在可行方向中选离目标格曼哈顿距离最小的 Direction ChooseDirection(const Ghost g, int targetX, int targetY) { Direction best g.dir; int bestDist INT_MAX; const Direction candidates[4] { UP, DOWN, LEFT, RIGHT }; for (Direction d : candidates) { if (d Opposite(g.dir)) continue; // 不能原地掉头 int nx g.tileX DX[d]; int ny g.tileY DY[d]; if (g_map[ny][nx] 0) continue; // 撞墙 int dist abs(nx - targetX) abs(ny - targetY); if (dist bestDist) { bestDist dist; best d; } } return best; }Opposite(g.dir)判断是否掉头经典吃豆人里幽灵在非惊吓状态不能直接反向这个限制让追逐更有节奏。曼哈顿距离计算简单在网格地图上和实际路径长度高度相关够用。注意目标格要 clamp 到地图范围内否则吃豆人贴边时幽灵会算出越界坐标导致数组访问崩溃这是很隐蔽的一个坑。4.2 碰撞检测格子级判定比像素级更稳2D 游戏里像素级碰撞听起来精确但在吃豆人这种网格游戏里反而容易出问题吃豆人移动是连续的幽灵也是两个精灵的包围盒在高速下可能一帧穿过对方。更稳的做法是把碰撞判定放在格子级别每帧把吃豆人和幽灵的像素坐标换算成格子坐标如果格子相同就判定碰撞。豆子收集同理吃豆人所在格子有豆子就吃掉并置为空格。// 像素坐标转格子坐标 inline int ToTileX(float px) { return static_castint(px) / TILE; } inline int ToTileY(float py) { return static_castint(py) / TILE; } void CheckCollisions() { int px ToTileX(g_pacman.x); int py ToTileY(g_pacman.y); // 吃豆子 if (g_map[py][px] 1) { g_map[py][px] 3; g_score 10; } else if (g_map[py][px] 2) { g_map[py][px] 3; g_score 50; EnterFrightenedMode(); // 触发幽灵惊吓状态 } // 撞幽灵 for (auto ghost : g_ghosts) { if (ToTileX(ghost.x) px ToTileY(ghost.y) py) { if (ghost.state FRIGHTENED) { ghost.state EATEN; g_score 200; } else if (ghost.state CHASE) { g_pacman.lives--; ResetPositions(); } } } }格子级判定的前提是吃豆人和幽灵的移动速度不能超过每帧一格否则还是会穿。解决办法是限制速度上限或者把移动拆成子步。EnterFrightenedMode把所有幽灵状态切到FRIGHTENED并计时计时结束切回CHASE这就是一个最简单的状态机。状态用枚举而不是布尔标志后面加SCATTER散开和EATEN被吃回巢时扩展起来干净得多。5. 避坑与排查那些让吃豆人跑不起来的常见问题5.1 现象窗口一闪而过或直接崩溃原因通常是InitD3D返回 false 但没做错误处理或者wWinMain的返回值被系统忽略。更隐蔽的是CreateWindowEx失败后hwnd为 nullptr后面InitD3D拿它去创建设备直接崩。解决每一步都检查返回值CreateWindowEx后加if (!hwnd) return -1;InitD3D里FAILED(hr)时用MessageBox把hr的十六进制值弹出来常见0x887A0004表示设备创建失败多半是驱动或远程桌面环境问题。5.2 现象画面全黑但程序不崩先确认ClearRenderTargetView的颜色不是黑色再确认Present被调用了。如果清屏颜色改了还是黑检查渲染目标视图是否绑定到输出合并阶段g_context-OMSetRenderTargets(1, g_rtv, nullptr);这行很容易漏漏了之后所有绘制都写到默认目标屏幕自然没反应。另外视口也要设D3D11_VIEWPORT vp {0,0,960,720,0,1}; g_context-RSSetViewports(1, vp);不设视口时默认是 0 尺寸同样黑屏。5.3 现象精灵边缘有黑边或半透明像素发黑这是纹理预乘 alpha 没处理好。DirectX 11 的混合状态默认假设颜色是预乘的如果你用普通 PNG 直接加载透明区域的 RGB 是 0混合后边缘会发黑。解决加载纹理时把 RGB 乘以 alpha 做预乘或者把混合状态改成SrcBlend D3D11_BLEND_SRC_ALPHA、DestBlend D3D11_BLEND_INV_SRC_ALPHA并关闭预乘假设。我一般直接在图片处理阶段预乘运行时省事。5.4 现象幽灵卡在墙角来回抖动原因是方向选择时没有排除「当前方向不可行」的情况幽灵撞墙后每帧重新选方向两个方向距离相同就来回切。解决在ChooseDirection里优先保持当前方向只有当前方向撞墙时才重新选同时给方向切换加一个最小间隔计时避免一帧内多次切换。这个抖动问题在低帧率下更明显加计时器后基本消失。5.5 现象Release 模式正常Debug 模式卡顿Debug 模式下 DirectX 调试层会做大量参数校验帧率掉一半是正常的。如果你在 Debug 下测性能记得在设备创建时不要传D3D11_CREATE_DEVICE_DEBUG标志或者用ID3D11Debug只在需要时开。另外 Debug 下std::vector的迭代器检查也会拖慢精灵批处理发布版本用 Release 测帧率才准。6. 进阶技巧用固定时间步长让游戏手感一致做到这里游戏能跑了但你会发现不同电脑上吃豆人速度不一样高刷屏上快得离谱低端机上又慢吞吞。根因是帧循环直接用了每帧固定位移帧率一变速度就变。解决办法是引入固定时间步长fixed timestep逻辑更新固定按 1/60 秒推进渲染按实际帧率插值。这样无论显示器是 60Hz 还是 144Hz游戏逻辑速度完全一致。const double FIXED_DT 1.0 / 60.0; double accumulator 0.0; LARGE_INTEGER freq, prev, now; QueryPerformanceFrequency(freq); QueryPerformanceCounter(prev); while (msg.message ! WM_QUIT) { QueryPerformanceCounter(now); double frameTime double(now.QuadPart - prev.QuadPart) / freq.QuadPart; prev now; if (frameTime 0.25) frameTime 0.25; // 防止卡顿后追帧爆炸 accumulator frameTime; while (accumulator FIXED_DT) { UpdateGame(FIXED_DT); // 逻辑更新速度与帧率解耦 accumulator - FIXED_DT; } float alpha float(accumulator / FIXED_DT); RenderGame(alpha); // 渲染时用 alpha 插值位置画面更顺滑 g_swapChain-Present(1, 0); }QueryPerformanceCounter是 Windows 上精度最高的计时器比GetTickCount的毫秒精度靠谱得多。frameTime 0.25那行是后悔药如果窗口被拖动或系统卡了一下累积的时间会很大不截断的话下一帧会连续跑几百次逻辑更新游戏直接瞬移。alpha用于渲染插值把上一帧和当前帧的位置按比例混合即使逻辑是 60Hz画面在 144Hz 屏上也不会一顿一顿。这个模式我在后面所有 2D 项目里都保留改一次一劳永逸。最后说个习惯每次改完渲染相关代码先跑 Debug 看调试层有没有报 warningDirectX 的调试层会把资源泄漏、状态错误直接打出来比你自己猜快得多。吃豆人这个项目不大但把窗口、批处理、AI、状态机、时间步长都串了一遍做完之后再看其他 c游戏 项目结构会清晰很多。希望帮到你。本文还有配套的精品资源点击获取