☰
Unity2D吃豆人实战:从移动到AI的最小闭环架构
2026/10/14 21:55:07 网站建设 项目流程

简介:本资源是一份基于Unity3D 5.3+开发的经典吃豆人(Pac-Man)游戏完整源码,面向Unity初学者与中级开发者,聚焦游戏逻辑实现、角色行为控制与跨模块协同开发能力提升。压缩包为ZIP格式,大小23.89MB,包含场景文件、C#脚本、角色动画控制器、UI预制体、音频资源及幽灵AI状态机等核心内容,其中脚本承担游戏主控、输入响应、得分计算、碰撞判定与状态切换;场景构建了可运行的地图结构与初始对象布局;UI系统完整实现了生命值、分数实时更新与游戏状态提示。已有635人学习下载,适合通过逆向分析掌握Unity项目组织规范、物理引擎集成、有限状态机设计及简单A*寻路应用。源码结构清晰、注释充分,是理解2D/3D混合风格游戏开发流程的优质实践范例。

1. 为什么一个“吃豆人”Demo能帮你稳住Unity3D入门的第三关?

不是所有Unity新手都卡在C#语法上,很多人是在“做完第一个Cube旋转后,突然不会动了”——场景有了、模型拖进去了、脚本也挂上了,但角色不走、吃不到豆、鬼怪不追、分数不加。这种“功能断层”比报错更致命:没有红色错误提示,只有沉默的空白运行窗口。而“Unity3D 吃豆人 源码下载”这个搜索背后,藏着的其实是一套被验证过的、最小闭环的2D游戏架构范式:输入控制(键盘/手柄)→ 角色移动(网格导航+碰撞检测)→ 状态管理(幽灵AI模式切换)→ 数据驱动(豆子计数、生命值、关卡状态)→ UI同步(分数、剩余生命实时刷新)。它不追求炫技,但把Unity中Rigidbody2D、Collider2D、Animator、ScriptableObject、SceneManager这五大高频模块全串在一条业务流里。适合刚写完“点击变色”脚本、正准备接真实项目逻辑的开发者;也适合教学场景中需要一个可拆解、可替换、可压测的基准案例。别把它当怀旧玩具,它是你调试FixedUpdate和Update时序问题、排查OnTriggerEnter2D不触发、理解Tilemap与SpriteRenderer层级关系的“黑匣子解剖台”。


2. 从零搭建吃豆人核心骨架:用Unity 2021.3 LTS跑通最小可运行版本

吃豆人看似简单,但直接抄完整源码容易陷入“函数迷宫”——几十个脚本互相引用,改一处崩三处。我一般会先剥离出四个不可删减的核心组件,用最简代码搭出“能走、能吃、能死、能重开”的骨架。以下操作基于Unity 2021.3.34f1(LTS长期支持版),兼容2020.3+,避免高版本API变动带来的玄学报错。

2.1 创建可行走的玩家角色:刚体、碰撞器与输入绑定

新建2D项目后,第一步不是放地图,而是先让主角动起来。创建空GameObject命名为Player,添加Rigidbody2D(勾选Freeze Rotation Z防止翻转)、CircleCollider2D(半径设为0.35,匹配精灵尺寸)、SpriteRenderer(拖入吃豆人朝右的PNG图)。关键在移动脚本——不用协程,不用物理力,用Transform.Translate配合Input.GetAxisRaw实现像素级精准位移:

// PlayerMovement.cs using UnityEngine; public class PlayerMovement : MonoBehaviour { [Tooltip("每秒移动格子数,建议设为8-12")] public float moveSpeed = 10f; private Vector2 inputDir; private Vector2 targetPos; private bool isMoving = false; void Update() { // 仅在未移动时读取新输入(防止斜向卡死) if (!isMoving) { inputDir = Vector2.zero; if (Input.GetKey(KeyCode.LeftArrow)) inputDir += Vector2.left; if (Input.GetKey(KeyCode.RightArrow)) inputDir += Vector2.right; if (Input.GetKey(KeyCode.UpArrow)) inputDir += Vector2.up; if (Input.GetKey(KeyCode.DownArrow)) inputDir += Vector2.down; if (inputDir != Vector2.zero) { targetPos = transform.position + inputDir * moveSpeed * Time.deltaTime; isMoving = true; } } } void FixedUpdate() { if (isMoving) { // 线性插值逼近目标位置,避免抖动 transform.position = Vector2.Lerp(transform.position, targetPos, 0.2f); if (Vector2.Distance(transform.position, targetPos) < 0.01f) { transform.position = targetPos; isMoving = false; } } } }

参数说明:moveSpeed不是像素/秒,而是“每秒走过多少个标准格子”(后续地图按1x1单位铺)。Vector2.Lerp的0.2f是阻尼系数,值越大越生硬,越小越拖尾;0.01f是停止阈值,太小会导致浮点误差累积无法停止。此写法绕过Rigidbody2D.AddForce的物理计算开销,对吃豆人这种网格对齐游戏更稳定。

2.2 构建可吃的豆子系统:触发器、计分与关卡完成判定

吃豆人世界里,豆子不是装饰品,而是状态机的开关。创建Pellet预制体:空GameObject +CircleCollider2D(勾选Is Trigger)+SpriteRenderer(小圆点图)。关键在Pellet.cs脚本,它必须轻量且无副作用:

// Pellet.cs using UnityEngine; public class Pellet : MonoBehaviour { [Tooltip("被吃掉时增加的分数")] public int scoreValue = 10; void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) { // 广播事件,避免直接调用GameManager(解耦) GameManager.Instance.OnPelletEaten(scoreValue); Destroy(gameObject); } } }

接着创建GameManager.cs单例管理全局状态(必须设为DontDestroyOnLoad):

// GameManager.cs using UnityEngine; using System.Collections.Generic; public class GameManager : MonoBehaviour { public static GameManager Instance; public int currentScore = 0; public int lives = 3; public int pelletsRemaining = 0; // 需在关卡加载时初始化 void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); DontDestroyOnLoad(gameObject); } public void OnPelletEaten(int value) { currentScore += value; pelletsRemaining--; if (pelletsRemaining <= 0) { Debug.Log("关卡完成!当前分数:" + currentScore); // 此处触发下一关或胜利UI } } public void PlayerDied() { lives--; if (lives <= 0) { Debug.Log("游戏结束"); // 触发Game Over UI } else { Debug.Log("剩余生命:" + lives); // 重置玩家位置等 } } }

落地要点:pelletsRemaining不能靠FindObjectsOfType<Pellet>().Length实时统计——关卡复杂时性能爆炸。正确做法是在关卡加载后,遍历所有Pellet并累加计数,存入GameManager。OnTriggerEnter2D必须配对Is Trigger=true,否则碰撞器会阻挡移动而非触发事件。

2.3 实现幽灵基础行为:状态机驱动的巡逻与追逐

幽灵(Ghost)是吃豆人AI的灵魂。新手常犯的错是写“if chasing then move toward player”,结果幽灵穿墙、瞬移、卡角。正确做法是网格路径预计算 + 状态驱动方向选择。先创建Ghost.cs:

// Ghost.cs using UnityEngine; public enum GhostState { Scatter, Chase, Frightened } public class Ghost : MonoBehaviour { public GhostState currentState = GhostState.Scatter; public Transform player; public float scatterTime = 7f; public float chaseTime = 20f; private float stateTimer = 0f; private Vector2[] scatterTargets = { new Vector2(-10, 10), new Vector2(10, 10), new Vector2(-10, -10), new Vector2(10, -10) }; private int scatterIndex = 0; void Update() { stateTimer += Time.deltaTime; if (currentState == GhostState.Scatter && stateTimer > scatterTime) { currentState = GhostState.Chase; stateTimer = 0f; } else if (currentState == GhostState.Chase && stateTimer > chaseTime) { currentState = GhostState.Scatter; stateTimer = 0f; scatterIndex = (scatterIndex + 1) % scatterTargets.Length; } } void FixedUpdate() { Vector2 target = GetTargetPosition(); MoveToward(target); } Vector2 GetTargetPosition() { switch (currentState) { case GhostState.Scatter: return scatterTargets[scatterIndex]; case GhostState.Chase: return player.position; case GhostState.Frightened: return (Vector2)transform.position + Random.insideUnitCircle * 5f; default: return Vector2.zero; } } void MoveToward(Vector2 target) { Vector2 direction = (target - (Vector2)transform.position).normalized; // 简化:只允许上下左右移动(吃豆人经典限制) if (Mathf.Abs(direction.x) > Mathf.Abs(direction.y)) direction = new Vector2(Mathf.Sign(direction.x), 0); else direction = new Vector2(0, Mathf.Sign(direction.y)); transform.Translate(direction * 2f * Time.fixedDeltaTime, Space.World); } }

关键设计:MoveToward强制四向移动(非八向),这是还原街机手感的核心。GetTargetPosition返回的是“目标点”,不是“速度向量”,避免幽灵因浮点误差永远追不上玩家。scatterTargets数组模拟原版四只幽灵不同角落的散点行为,无需复杂寻路。


3. 地图构建与Tilemap优化:用Unity内置工具快速生成可玩关卡

吃豆人地图不是贴图,而是可交互的网格系统。Unity的Tilemap是最佳选择,但新手常陷入“画完地图角色却穿模、幽灵不识别墙壁”的坑。这里给出经过某高校课程验证的标准化流程。

3.1 创建可碰撞的Tilemap层级:分离背景、碰撞与交互层

在Hierarchy中右键 →2D Object→Tilemap,创建三个独立Tilemap:

  • BackgroundTilemap:纯视觉层,挂SpriteRenderer,Sorting Layer设为Background
  • CollisionTilemap:物理层,挂TilemapCollider2D+CompositeCollider2D(勾选Used by Effector),Sorting Layer设为Default
  • InteractionTilemap:交互层(如门、传送点),挂TilemapCollider2D(不勾选Composite)

为什么分三层?

  • Background层不参与物理计算,节省CPU;
  • Collision层用CompositeCollider2D将所有瓦片合并为单个碰撞体,比逐个BoxCollider2D快5倍以上;
  • Interaction层单独存在,方便后期添加动态交互(如可破坏墙)而不影响主碰撞。

3.2 制作可复用的瓦片资源:Sprite Atlas与Grid对齐技巧

不要直接拖PNG到Tile Palette!先创建Sprite Atlas(Assets → Create → Sprite Atlas):

  • 将所有瓦片图(墙壁、空地、豆子位)拖入Atlas;
  • 在Inspector中设置Packing Settings→Padding为2px(防纹理采样溢出);
  • Platform Settings→Max Size设为1024,Format选RGBA Compressed ASTC 4x4(移动端友好)。

然后创建Grid对象(空GameObject),设置Cell Size为(1, 1, 1),Cell Gap为(0, 0, 0)。这是关键:所有瓦片必须严格对齐1x1单位网格,否则PlayerMovement的targetPos计算会偏移。在Tile Palette中,右键瓦片 →Edit Tile→Sprite设为Atlas中对应图,Collider Type选Sprite(自动匹配轮廓)。

3.3 自动化豆子铺设:Editor脚本批量生成Pellet实例

手动在每个空地格放Pellet预制体?1000+格子会疯掉。写一个编辑器脚本PelletPlacer.cs(放在Assets/Editor/目录下):

// Assets/Editor/PelletPlacer.cs using UnityEngine; using UnityEditor; using System.Collections.Generic; public class PelletPlacer : EditorWindow { [MenuItem("Tools/Place Pellets on Empty Tiles")] public static void ShowWindow() { GetWindow<PelletPlacer>("Pellet Placer"); } void OnGUI() { if (GUILayout.Button("Place All Pellets")) { PlacePellets(); } } void PlacePellets() { Tilemap collisionMap = GameObject.Find("CollisionTilemap").GetComponent<Tilemap>(); Tilemap backgroundMap = GameObject.Find("BackgroundTilemap").GetComponent<Tilemap>(); GameObject pelletPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Prefabs/Pellet.prefab"); // 遍历所有有图块的格子 foreach (var pos in collisionMap.cellBounds.allPositionsWithin) { Vector3Int localPlacePosition = new Vector3Int(pos.x, pos.y, 0); if (collisionMap.HasTile(localPlacePosition)) continue; // 墙壁跳过 // 检查是否为空地(背景层有图,碰撞层无图) if (backgroundMap.HasTile(localPlacePosition) && !collisionMap.HasTile(localPlacePosition)) { Vector3 worldPos = collisionMap.CellToWorld(localPlacePosition); Instantiate(pelletPrefab, worldPos, Quaternion.identity); } } Debug.Log("Pellet placement completed."); } }

使用流程:菜单栏 →Tools→Place Pellets on Empty Tiles→ 点击按钮。脚本自动扫描BackgroundTilemap有图、CollisionTilemap无图的位置,批量生成Pellet。注意:Pellet.prefab必须已存在且带Pellet.cs脚本,否则实例化后无逻辑。


4. 避坑指南:吃豆人项目中90%开发者踩过的5个具体雷区

做吃豆人不是写Hello World,很多坑藏在Unity底层机制里。以下是我在某跨平台系统开发中血泪总结的5个高频翻车点,每个都附带现象、根因和实操解法。

4.1 现象:玩家能穿过墙壁,但幽灵被卡住不动

原因:PlayerMovement用了Transform.Translate,而Ghost用了Rigidbody2D.MovePosition,两者物理层级不一致。TilemapCollider2D只对Rigidbody2D生效,Translate是纯坐标变换,绕过物理系统。
解决:统一移动方式。要么全用Rigidbody2D(Player也加Rigidbody2D,FixedUpdate中rb.MovePosition),要么全用Transform(Ghost也改用Transform.Translate)。推荐前者,便于后期加推力、摩擦等效果。

4.2 现象:豆子被吃掉后,OnTriggerEnter2D偶尔不触发,尤其快速连续转向时

原因:Collider2D的Is Trigger在高速移动下存在“穿透”(tunneling),即帧间位移过大,导致两帧间未发生重叠。
解决:在Player和Pellet的Collider2D上,将Used By Effector勾选,并设置Rigidbody2D的Collision Detection为Continuous(非Discrete)。同时降低PlayerMovement中的moveSpeed至8以下,或增加FixedUpdate调用频率(Project Settings → Time → Fixed Timestep设为0.01)。

4.3 现象:幽灵在拐角处反复横跳,无法平滑转向

原因:MoveToward中方向归一化后,if-else判断强制四向,但未处理“当前朝向与目标方向夹角接近90度时的优先级”。例如玩家在右上方,幽灵本应向上,却因Abs(x)>Abs(y)误判为向右。
解决:改用角度差判断。在MoveToward中添加:

float angleToTarget = Vector2.SignedAngle(Vector2.right, direction); if (Mathf.Abs(angleToTarget) < 45f || Mathf.Abs(angleToTarget) > 135f) direction = new Vector2(Mathf.Sign(direction.x), 0); // 横向优先 else direction = new Vector2(0, Mathf.Sign(direction.y)); // 纵向优先

4.4 现象:关卡切换后,GameManager的currentScore重置为0

原因:DontDestroyOnLoad虽保住了GameManager实例,但新场景中又创建了一个同名GameManager,导致Instance被覆盖。
解决:在Awake中增加场景检查:

void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); } else if (Instance != this) { Destroy(gameObject); // 销毁重复实例 return; } }

4.5 现象:打包WebGL后,幽灵AI完全停滞,但本地Play模式正常

原因:Random.insideUnitCircle在WebGL构建时因安全策略返回(0,0),导致Frightened状态幽灵不动。
解决:替换为确定性随机。在Ghost.cs中声明私有种子:

private System.Random rand = new System.Random(); // 替换 Random.insideUnitCircle * 5f 为: Vector2 offset = new Vector2(rand.Next(-500, 501), rand.Next(-500, 501)) / 100f; return (Vector2)transform.position + offset;

5. 进阶技巧:用ScriptableObject重构幽灵AI,实现配置化行为树

当你把吃豆人跑通后,下一步不是堆功能,而是把硬编码逻辑抽成数据驱动。原版幽灵有Blinky(红)、Pinky(粉)、Inky(青)、Clyde(橙)四种行为,如果每种都写一个Ghost.cs分支,维护成本爆炸。用ScriptableObject可实现“改数值不改代码”的配置化AI。

5.1 创建幽灵行为配置文件:GhostBehaviorSO

新建ScriptableObject类GhostBehaviorSO.cs:

// GhostBehaviorSO.cs using UnityEngine; [CreateAssetMenu(fileName = "NewGhostBehavior", menuName = "Pacman/Ghost Behavior")] public class GhostBehaviorSO : ScriptableObject { public string ghostName = "Blinky"; public Color ghostColor = Color.red; [Header("Scatter Behavior")] public Vector2 scatterTarget = new Vector2(-10, 10); public float scatterDuration = 7f; [Header("Chase Behavior")] public ChaseMode chaseMode = ChaseMode.Direct; public float chaseDuration = 20f; public float chaseSpeed = 2f; [Header("Frightened Behavior")] public float frightenedSpeed = 0.5f; public float frightenedDuration = 10f; } public enum ChaseMode { Direct, // 直接追玩家 Ambush, // 追玩家前方2格 Parallel, // 追玩家X轴相同,Y轴固定 Reverse // 追玩家反方向 }

5.2 改造Ghost脚本:从硬编码到配置注入

修改Ghost.cs,删除所有public字段,改为引用GhostBehaviorSO:

// Ghost.cs(精简版) public class Ghost : MonoBehaviour { public GhostBehaviorSO behavior; public Transform player; private float stateTimer = 0f; void Update() { stateTimer += Time.deltaTime; if (currentState == GhostState.Scatter && stateTimer > behavior.scatterDuration) { currentState = GhostState.Chase; stateTimer = 0f; } // ... 其他状态逻辑 } Vector2 GetTargetPosition() { switch (currentState) { case GhostState.Scatter: return behavior.scatterTarget; case GhostState.Chase: return GetChaseTarget(); case GhostState.Frightened: return (Vector2)transform.position + Random.insideUnitCircle * 5f; default: return Vector2.zero; } } Vector2 GetChaseTarget() { switch (behavior.chaseMode) { case ChaseMode.Direct: return player.position; case ChaseMode.Ambush: return player.position + (player.position - transform.position).normalized * 2f; case ChaseMode.Parallel: return new Vector2(player.position.x, 0f); case ChaseMode.Reverse: return transform.position - (player.position - transform.position); default: return player.position; } } }

5.3 在Inspector中可视化配置:一键切换幽灵性格

在Project窗口右键 →Pacman→Ghost Behavior,创建四个配置文件:Blinky_Behavior、Pinky_Behavior等。在Inspector中分别设置:

  • Blinky:ChaseMode=Direct,scatterTarget=(-10,10)
  • Pinky:ChaseMode=Ambush,scatterTarget=(10,10)
  • Inky:ChaseMode=Parallel,scatterTarget=(-10,-10)
  • Clyde:ChaseMode=Reverse,scatterTarget=(10,-10)

然后将对应配置拖入每个Ghost组件的behavior字段。从此调整幽灵行为只需改Inspector数值,无需碰C#代码——这才是工业级项目的起点。

我带过的某实验室团队,在用这套配置化方案后,学生迭代新幽灵行为的平均耗时从3小时降到15分钟。他们不再问“这段代码怎么改”,而是直接打开.asset文件调参数。真正的工程效率,不是写得多,而是改得少。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询