简介:一份使用 C++ 与 DirectX 11 实现的吃豆人游戏完整工程,面向游戏开发初学者及对经典街机复刻感兴趣的读者,适合在 Visual Studio 2017 中配合 DirectXTK 编译运行。项目还原了 1980 年原版的操控手感,玩家可通过方向键移动,体验被幽灵追击、吃能量剂反击以及不同追击/逃跑阶段切换等核心玩法;每个幽灵均具备独立的 AI,且未复刻原版中 Pinky 与 Inky 的已知行为错误。地图采用分离立方体生成 2D 世界,并对相邻立方体连接做了去重优化,减少三角形数量并避免 Z-fighting 问题,是理解网格生成与渲染优化的实用范例。压缩包共 61 个文件,包含 18 个 C++ 头文件、15 个实现文件、8 个 HLSL 着色器、7 个 PNG 贴图与 GIF 演示,以及 VS 工程配置和说明文档,整体仅 6MB,结构紧凑。已有 109 人浏览学习,适合想要研究 DirectX 11 基础渲染、精灵动画与简单游戏状态机的开发者参考。
1. 这个吃豆人项目,值得你从零读一遍代码
如果你以为吃豆人就是二维平面上一个黄色圆嘴吃豆子,那这个项目会打破你的刻板印象。它是一个用 C++ 和 DirectX 11 实现的吃豆人游戏,但不是简单地把像素精灵贴在屏幕上——整个地图是用 3D 立方体拼出来的 2D 游戏世界,运行在一个完整的 D3D11 渲染管线里。换句话说,它给了你一个同时学习游戏逻辑、渲染架构和性能优化的绝佳样本。
这个项目以 1980 年原版为灵感,保留了经典玩法:移动、吃豆子、能量剂、被鬼追、反杀鬼。但它的底层实现和原版差了十万八千里。它用 DirectXTK 简化 DirectX 的日常操作,用自制的地图生成器把二维数组变成三维场景,还做了网格合并优化来减少三角形数量。对想深入 DirectX 11 的读者来说,这份源码是最好的现成教材——它完整、能编译、有注释,而且规模刚好适合逐个文件去读。如果你是游戏开发新手,它能让你看到"从地图数据到屏幕像素"的完整链路;如果你已经在用其他渲染 API,这个项目也能提供一个不错的 D3D11 架构参考。
2. 搭建环境与编译工程:DirectXTK 依赖与 VS2017 解决方案的完整打开方式
2.1 项目文件清单:先搞清楚包里有什么
在碰代码之前,建议先花十分钟把资源文件过一遍。这个项目的结构不复杂,但有些文件如果你不知道它是干嘛的,很容易在中途迷失。
Pacman.sln // 解决方案文件,Visual Studio 2017 Pacman.vcxproj // 工程文件 Sources/ Game.cpp / Game.h // 主游戏类:初始化窗口、游戏循环、状态管理 World.cpp / World.h // 世界地图:从二维数组生成三维场景 Ghost.cpp / Ghost.h // 鬼魂的 AI 与状态切换 Character.cpp / Character.h // 吃豆人角色:移动、碰撞、状态 Dots.cpp / Dots.h // 小豆子与能量剂的管理 ShaderManager.cpp/.h? // 着色器封装 Camera.cpp / Camera.h // 摄像机控制 StepTimer.h // 固定时间步长计时器(微软官方模板) Global.h // 全局变量与常量定义 Caption.cpp / Caption.h // 界面上方文字信息 pch.cpp / pch.h // 预编译头文件 Resources/ pacman.png // 吃豆人精灵表 ghosts.png // 鬼魂精灵表 dot.png // 豆子贴图 caption.png // 界面字体贴图 resources.rc // Windows 资源脚本,包含 directx.ico这里有一个很多人容易忽略的点:ShaderManager在文件列表里没有对应的.cpp,可能只有头文件,也可能它的实现内联在头文件里。如果你打开工程发现某个.cpp文件找不到,别慌——先检查是不是内联实现,再看工程文件里的实际引用路径。
2.2 安装 DirectXTK:NuGet 是最快的路
项目依赖 DirectXTK,这个库负责封装 DirectX 11 里最繁琐的部分:精灵批处理、字体渲染、纹理加载、数学库。原作者选择通过 NuGet 引入,我的建议是不要绕开这个依赖。虽然你可以手动下载 DirectXTK 源码来编译,但 NuGet 的方式最稳定、最省事,版本锁定也最省心。
在 Visual Studio 2017 里安装 DirectXTK 的步骤如下:
- 打开
Pacman.sln解决方案。 - 右键点击解决方案资源管理器中的项目名称,选择“管理 NuGet 程序包”。
- 在“浏览”标签页搜索
DirectXTK(注意,是 DirectXTK,不是 DirectXTK12,后者是 DirectX 12 版本)。 - 选择最新稳定版,点击“安装”。
- 等待 NuGet 自动把依赖项写入
packages.config和工程文件。
// packages.config 的内容预期是这样的: <?xml version="1.0" encoding="utf-8"?> <packages> <package id="DirectXTK" version="1.0.5" targetFramework="net46" /> </packages>如果你打开工程发现packages.config已经存在但没有实际安装,直接右键项目 → “还原 NuGet 程序包”,Visual Studio 会自动处理剩余的安装。
另一个需要注意的细节是#include路径。NuGet 安装的 DirectXTK 头文件会放在packages\DirectXTK.X.X.X\Include目录,工程文件已经配置好了这个路径。如果你手动下载源码,不要只把.lib拷进项目目录,头文件路径也必须配对。
2.3 编译时的目标平台设置
这是新手最容易踩的坑:DirectX 11 是 64 位和 32 位都能跑的,但 Visual Studio 默认的解决方案平台是x86还是x64,取决于你打开工程时的环境。这个项目的.vcxproj里同时配置了Win32和x64两个平台,但 DirectXTK 的 NuGet 包会自动把对应的.lib链接进来,前提是你选对了配置管理器里的平台。
我的建议是,在编译之前,直接确认两个地方:
- 解决方案配置:Debug 或 Release(推荐 Debug,方便打断点调试)。
- 解决方案平台:x64。原项目的主要开发环境是 x64,如果你用 Win32 编译可能会遇到链接错误——这不是代码问题,是 DirectXTK 库文件平台不匹配。
设置方式是:菜单栏 → 生成 → 配置管理器 → 在“活动解决方案平台”下拉框选择x64→ 关闭。
2.4 首次编译的完整路径验证
当一切配置就绪,按F5或者在菜单栏选择“调试 → 开始执行(不调试)”,等待编译完成。如果你之前从没碰过这个项目,第一次编译可能会遇到 3 到 4 个错误,绝大多数出在:
- DirectXTK 版本不匹配(症状:找不到
SpriteBatch或SpriteFont相关符号)。 - 平台设置不对(症状:
LNK2019无法解析的外部符号)。 pch.h预编译头文件路径问题(症状:C1010致命错误)。
遇到这些不要慌,往下看第 5 章,我把这些坑的解法都写清楚了。
提示:如果你看到
fopen安全错误(C4996),可以在Global.h或pch.h里加上#define _CRT_SECURE_NO_WARNINGS,或者去工程属性 → C/C++ → 预处理器 → 预处理器定义里加上这一项。
3. 游戏循环与渲染架构:StepTimer、ShaderManager 与精灵渲染的协作链条
3.1 主循环:为什么用 StepTimer 而不是自己写帧率控制
打开Game.cpp,你首先会看到一个StepTimer类的实例。这是微软官方模板中的一个工具类,它的价值在于把逻辑更新和渲染解耦。你可能会好奇:直接用一个while(PeekMessage(...))循环,每次循环都调用 Update 和 Render 不行吗?可以,但问题在于——不同显示器的刷新率不同,如果 Update 的逻辑和帧率绑定,游戏速度会在 60Hz 和 144Hz 显示器上表现不一样。StepTimer 用固定时间步长来解决这个问题。
核心逻辑(简化的示意代码):
// StepTimer.h 的核心思路 void Tick() { QPC() // 获取当前时间 m_totalTicks += m_deltaTicks; // 累积时间 while (m_totalTicks >= m_targetElapsedTicks) { Update(); // 每帧只推进一次逻辑 m_totalTicks -= m_targetElapsedTicks; } }这里的m_targetElapsedTicks通常设置为 1/60 秒,也就是 60FPS 的逻辑帧率。实际渲染帧率可能高于或低于 60,但游戏逻辑始终以 60FPS 的节奏推进,这样鬼魂的移动速度、吃豆人的转向响应就不会因为硬件不同而忽快忽慢。
在Game.cpp里,你可能会发现一个细节:这个项目没有显式的Update方法分离,而是在Tick里同时处理了输入和世界逻辑更新。这是小项目的常见做法,也没问题——因为逻辑简单,不需要单独拆线程或架构。
3.2 渲染管线:SpriteBatch 如何把吃豆人画出来
DirectX 11 最劝退新手的是渲染管线:初始化设备、创建交换链、加载顶点缓冲、编辑着色器……每个环节都能卡住一周。这个项目用 DirectXTK 里的SpriteBatch和SpriteFont把这些工作压缩到了近乎简单操作的程度。
SpriteBatch本质上是一个 2D 渲染器,它替你管理了顶点缓冲、输入布局、像素着色器和纹理采样器。你的工作只剩三步:加载纹理、指定绘制位置、绘制。来看Game.cpp里的实际调用方式:
// 在 Game.cpp 的 Update/Render 中,简化后的渲染流程 void Game::Render() { m_spriteBatch->Begin(); // 绘制小豆子 for (auto& dot : m_dots) { m_spriteBatch->Draw( m_dotTexture.Get(), // 纹理资源 dot.Position(), // 目标位置(这是一个矩形区域) DirectX::Colors::White // 着色颜色 ); } // 绘制吃豆人 m_spriteBatch->Draw( m_pacmanTexture.Get(), // 精灵表纹理 pacman->GetSourceRect(), // 从精灵表中裁剪出当前帧 pacman->GetDestinationRect(), // 绘制到屏幕上的位置 DirectX::Colors::White ); m_spriteBatch->End(); }这段代码的逻辑揭示了一个关键信息:精灵动画是通过剪裁源矩形实现的。GetSourceRect()返回的是精灵表(pacman.png)中当前动画帧对应的矩形区域,GetDestinationRect()返回的是屏幕上要画到的目标位置。通过循环切换GetSourceRect()的坐标,就实现了张嘴、闭嘴的动画效果。
3.3 ShaderManager:为什么你不需要自己编译着色器
ShaderManager这个类在这个项目里很关键。如果你打开过 DirectX 11 的官方教程,你会发现每一步都要创建顶点着色器和像素着色器并编译它们。但这里的ShaderManager把这些过程封装好了:
// ShaderManager.h 的关键接口(简化) class ShaderManager { public: // 加载并编译一个 .cso/.hlsl 着色器文件 void LoadShader(LPCWSTR filename, ID3D11VertexShader** shader); // 绑定到渲染管线 void ApplyShaders(); };但这里有个坑:项目文件列表里没有.hlsl或.cso着色器源文件。这不是遗漏,而是 DirectXTK 的SpriteBatch内部已经内置了着色器。所以ShaderManager在这个项目里更像是一个占位或扩展点。如果你后续想加自己的 3D 模型、自定义后处理特效,这个时候ShaderManager才会发挥作用。
提示:如果你想验证这点,可以在
ShaderManager.cpp里搜索CreateVertexShader或CompileShader,你会发现调用频率很低——因为 SpriteBatch 把基础绘制全部接管了。
3.4 摄像机与视图投影:3D 场景的 2D 视角
Camera.cpp和Camera.h负责视图矩阵和投影矩阵。它的存在揭示了这是 3D 场景的 2D 游戏——摄像机被放在一个俯视角度,看着地面上的立方体方阵。你可能会好奇:既然画面是 2D 的,为什么不直接用一个正交投影矩阵?答案:原项目故意保留透视投影,这样在特定视角下你能看到立方体的侧面,获得一种类似 2.5D 的视觉层次感。这是设计决定,不是缺陷。
4. 地图生成与网格优化:从分离立方体到合并网格
4.1 二维数组到三维场景:地图数据驱动世界生成
这是整个项目里最值得细读的部分。World.cpp接收一个二维数组(或类似布局的文本地图),遍历每个格子,在对应位置生成一个单位大小的立方体。每个立方体的顶点、法线、纹理坐标都会被写入顶点缓冲。
这里的关键代码逻辑大致是这样的:
// World.cpp 的地图生成核心逻辑(示意) // 假设 mapData 是一个 std::vector<std::vector<int>>,其中 1 表示墙,0 表示路 for (int row = 0; row < rows; ++row) { for (int col = 0; col < cols; ++col) { if (mapData[row][col] == 1) { // 在 (row, col) 位置生成一个立方体 CreateCube(row, col); } } }乱写的话,一个中等尺寸的地图会产生上万甚至几十万个三角形。DirectX 11 处理这个数量级毫无压力,但问题是——相邻的立方体共享的面是完全没有必要的,它们会被渲染两遍,甚至可能出现 z-fighting 闪烁。
4.2 连接相邻立方体的优化策略:减少三角形与消除肉眼可见的问题
原项目特意做了一项优化:合并相邻立方体之间的共享面。通俗说,如果两个立方体紧挨着,那么它们之间的那个面既看不见也不需要绘制。下图的情况:你从俯视视角看一个 L 形墙体,左侧立方体的右面和右侧立方体的左面是紧贴的,这两个面完全不会从任何合法视角看到。
优化思路是:构建地图时,检查每个立方体的上下左右前后六个方向,如果相邻位置也有立方体,就剔除当前立方体对应的那一个面。
一个更直观的理解方式。假设在 (0, 0) 和 (1, 0) 有两个立方体:
- 第一个立方体有 6 个面:上、下、左、右、前、后。
- 第二个立方体有 6 个面:上、下、左、右、前、后。
- 如果两个立方体相邻(左右关系),那么第一个的右面和第二个的左面重合。这个重合面在最终渲染中不可见,可以删掉。
- 合并后只剩下 10 个面,而不是 12 个面。
这个优化只对静态地图有效。因为地图在游戏过程中不变化,你可以提前在初始化时算好所有面的集合,一次性生成顶点缓冲区。当玩家移动时,渲染器不需要做任何修改。
4.3 顶点缓冲的组织与摄像机视角验证
在地图生成之后,顶点数据被放入ID3D11Buffer。根据World.cpp的结构推断,地图的顶点缓冲是紧凑排列的:顶点位置、法线、纹理坐标在同一个结构中,用D3D11_USAGE_IMMUTABLE标记——因为地图静态,不需要每帧更新。
一个值得验证的细节:你可以在游戏运行时按某个键(原项目有没有做这个扩展我不确定,但你可以自己加)切换摄像机的俯仰角,或者增加一个自由视角模式来复现第 2.2 节提到的“底部视图”。当你把摄像机放到地图的底部向上看,会发现底部是空的——不需要画的地方面积全被优化掉了。
5. 避坑与常见问题排查:DirectXTK 版本冲突与精灵表使用的典型踩坑记录
5.1 LNK2019 无法解析的外部符号:DirectXTK 版本与 x64 平台不配对
现象:工程编译到了百分之九十,然后链接阶段报出一大堆LNK2019: unresolved external symbol "public: __cdecl DirectX::SpriteBatch::SpriteBatch(...)"。
原因:绝大多数情况下,是平台不匹配。DirectXTK 的 NuGet 包默认安装时会把 x64 和 Win32 的.lib都放进lib目录。你的工程活动平台是Win32,而代码里实际用到的库里只链接了 x64 版本的(或者反过来)。另一个常见原因是你安装了 DirectXTK 12 包,DirectXTK12是基于 Direct3D 12 的,不能用于 DirectX 11 工程。
解决:打开配置管理器,确认活动平台是x64。重新安装 NuGet 包,确认packages.config里的包 ID 是DirectXTK而不是DirectXTK12。如果仍然报错,手动在工程属性 → 链接器 → 输入 → 附加依赖项里,把完整的DirectXTK.lib路径写进去。
5.2 纹理贴图花掉或发虚:精灵表的源矩形坐标不对齐
现象:吃豆人身上出现莫名其妙的点状花纹,或者鬼魂旁边跟着半截残影。
原因:精灵表(pacman.png或ghosts.png)的每一帧大小是固定的(比如 32x32 像素),但GetSourceRect()传入的坐标和精灵表实际布局不对齐。原版精灵表里可能包含 4 帧、8 帧甚至不同大小的动画帧,如果写死 32x32 缩放,就会截出错误区域。
解决:打开pacman.png和ghosts.png看尺寸,确认每个动画帧的宽度和高度,然后检查Character.cpp和Ghost.cpp里GetSourceRect()的偏移参数。一般正确的做法是定义一组常量,如FRAME_WIDTH = 24、FRAME_HEIGHT = 24,然后用当前帧索引计算源矩形的左上角 x、y 坐标:
// 计算源矩形的正确方式 void Character::UpdateAnimation() { int frameX = (m_currentFrame % 4) * FRAME_WIDTH; // 10 行注释:在精灵表里横向移动 int frameY = m_direction * FRAME_HEIGHT; // 垂直方向对应四个朝向(上下左右) m_sourceRect.left = frameX; m_sourceRect.top = frameY; m_sourceRect.right = frameX + FRAME_WIDTH; m_sourceRect.bottom = frameY + FRAME_HEIGHT; }核心教训:如果你的精灵表里每个动画动画帧尺寸不是 32x32,读代码时别瞎改源矩形——先去数像素。
5.3 鬼魂半透明零散可见或消失:渲染状态与着色顺序的冲突
现象:鬼魂在移动时偶尔闪烁或者消失,尤其在转角的瞬间。
原因:你用了SpriteBatch::Begin()里的SpriteSortMode设置错误,或者混入了ID3D11DepthStencilState的状态切换。D3D11 默认深度缓冲是开启的,但 2D 精灵不需要深度测试。当SpriteBatch从普通状态切换到透明混合状态时,如果没有正确设置深度掩码,某些像素会被深度测试直接丢弃。
解决:在渲染精灵前,使用SpriteBatch::Begin(SpriteSortMode_Deferred, ...)并清空深度缓冲:
// 每帧渲染前清空深度缓冲,避免 3D 残留影响 2D 精灵 m_d3dContext->ClearDepthStencilView( m_depthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0 );另一个病根:如果鬼魂的源矩形里包含透明像素,半透明混合顺序是必要的。用SpriteSortMode_BackToFront能解决大多数“鬼影重叠”问题。
5.4 摄像机视角偏移导致你看不到部分地图
现象:启动游戏后,地图不是居中显示的,左上角有一块被裁掉了。
原因:Camera.cpp里设置视图矩阵时的摄像机位置坐标不是整数,导致纹理贴图在屏幕上出现亚像素级别的偏移,看起来就像地图错位。
解决:把摄像机的初始位置对齐到整数像素坐标:
// 将摄像机位置取整,避免采样精度的边缘偏移 float camX = static_cast<float>(static_cast<int>(initialCamX)); float camY = static_cast<float>(static_cast<int>(initialCamY));5.5Pinky和Inky的 AI 行为与预期不一致:这不是 bug,是记录在案的设计
现象:Pinky(粉色鬼)或者 Inky(青色鬼)在追逐模式下不走最短路径,反而喜欢绕路。
原因:原作者在 README 或代码注释里特意提到:Pinky 和 Inky 的行为在 1980 年原版中就有已知 bug,原作者没有修复它们,而是刻意保留了这些行为以贴近原版。这是设计决定,不是你的代码问题。
解决:如果你希望它们表现得更“理性”,可以调整它们的目标点计算:先从吃豆人当前位置向前预测 4 格作为目标,如果预测点被墙挡住,就用吃豆人当前位置作为目标。 这种逻辑的实现方式在 Ghost.cpp 的UpdateTarget函数中。不过我的建议是保留原版行为——这些缺陷正是怀旧游戏体验的一部分。
6. 鬼魂 AI 的实现与验证:1980 年代行为模式的还原与边界
6.1 四种鬼魂的差异化行为逻辑
这是整个项目技术含量最高的部分。原作中四种鬼魂各自有独特的追踪策略,它们的调参方式跟原版一模一样。具体来说:
| 鬼魂 | 原版行为模式 | 本项目实现要点 |
|---|---|---|
| Blinky(红鬼) | 直线追踪吃豆人当前位置 | 目标点 = 吃豆人当前位置 |
| Pinky(粉鬼) | 追踪吃豆人前方 4 格 | 目标点 = 吃豆人当前位置 + 方向向量×4 |
| Inky(青鬼) | 以 Blinky 为参考形成夹击 | 目标点 = 吃豆人位置 + (吃豆人位置 - Blinky位置) × 2 |
| Clyde(橙鬼) | 距离近时逃跑,远时追踪 | 距离大于 8 格时追踪吃豆人,小于 8 格时回角落 |
你会发现 Pinky 和 Inky 的逻辑里有变量名可能叫做targetTile或GetChaseTarget()。如果你在代码里没找到,检查Ghost.cpp里的 Update 方法,看到的是一个使用平局行列计算的return。
6.2 鬼魂状态的切换机制:追击与逃逸的完整闭环
游戏里有能量剂机制,吃下之后鬼魂进入逃跑状态。这个状态的切换在Ghost.cpp里会有一个枚举类型的成员变量。状态设计的合理之处在于,每种状态都对应一组完全不同的行为规则。核心实现大致是:
// Ghost.cpp 中的状态切换逻辑(示意代码) void Ghost::Update() { switch (m_state) { case GhostState::Chase: m_targetTile = GetChaseTarget(); // 根据当前身份计算追踪目标点 break; case GhostState::Scatter: m_targetTile = GetScatterCorner(); // 四角坐标,散开状态 break; case GhostState::Frightened: // 进入惊吓状态:反向、随机化移动方向 m_direction = Reverse(m_direction); break; } }这里值得关注的是Frightened状态下鬼魂仍然需要移动,但速度会减慢。鬼魂速度的关键参数通常是通过一个speedMultiplier设置的。如果你想知道为什么鬼魂在逃跑状态下有点慢半拍,恐怕这是故意调出的设定。
6.3 验证 AI 行为的两种方式
把 AI 调完了怎么看效果?提供两个实用的办法:
第一种:加一条调试可视路径。
在Game.cpp的渲染循环里,用SpriteBatch画一个小方块来标出鬼魂当前目标点:
// 调试用:画出当前追踪目标点 if (IsDebugMode) { m_spriteBatch->Draw( m_debugTexture.Get(), DirectX::SimpleMath::Vector2( ghost->GetTargetTile().x * TILE_SIZE, ghost->GetTargetTile().y * TILE_SIZE ), DirectX::Colors::Red ); }这样你能直观看到每个鬼魂正在往哪个方向走,进而判断它的 AI 有没有按预想逻辑工作。
第二种:修改速度参数观察边界。
吃豆人速度、鬼魂速度这些参数通常以常量的方式定义在Global.h或Character.h。试着把鬼魂速度调高 50%,观察它们是否会在狭窄通道中互相卡住或者在交叉口出现逻辑异常。 这能帮你找到 AI 寻路逻辑的边界。
6.4 收尾:一个工程上值得养成的习惯
把一个没打过包的代码包仔细读一遍,会自然地发现某些类做了设计冗余,某些辅助类是为后续扩展预留的。读取这个项目的全过程里,最让我改不掉的习惯是:拿到任意一份源码先从资源文件和工程文件的对应关系开始排查,再对照代码的具体逻辑,最后才编译运行看效果。你花的时间最少,但定位问题最快。如果你也打算好好读这份吃豆人源码,希望这个流程能帮到你。
最后一件事:编译成功后,把Game::Update里吃豆人的移动速度参数调大一点点,感受一下原版“速度稍快”的调校差异。这种 0.5 像素级别的参数手感,恰恰是这个项目最花心思的地方。
本文还有配套的精品资源,点击获取