简介一套基于C#与Unity实现的2D跑酷闯关冒险游戏课程设计项目名称为《忍者小狐》面向正在完成游戏开发类课程设计、毕业设计或想学习Unity实战流程的高校学生与初级开发者。玩家控制小狐狸在横板关卡中奔跑闯关借助沿途小动物的帮助躲避陷阱、对抗敌人收集钻石道具开启最终房间的武器对战游戏亮点包括随机出现的障碍物机关、与特殊物品碰撞产生的不同效果、生命计数与钻石收集等在保证可通关的同时兼顾挑战性与成就感。压缩包约52.18MB内含课程论文报告、答辩PPT以及完整C#源码覆盖了角色控制、碰撞检测、场景切换、敌人AI、UI交互与游戏状态管理等关键模块读者可直接参考项目结构、复用脚本逻辑并学习Unity2D游戏从搭建到发布的基础流程。目前已有2275人选择学习下载是一份用于课程答辩、项目复盘或个人练手都比较完整的参考方案。1. 这份 C# Unity 的 2D 跑酷闯关冒险游戏到底值不值得下如果你最近在做基于 C# Unity 的 2D 跑酷闯关冒险游戏课程设计大概率会在答辩前一周被这三件事折磨角色跑起来晃动得像弹簧、敌人贴脸但子弹总打不中、一改碰撞体整个关卡就散架。这份资源把完整的 2D 跑酷闯关对战工程拆成了玩家控制器、碰撞检测、摄像机跟随、敌人管理四个独立模块每一块都能单独调试而不是把代码全堆在一个脚本里。引用的素材和默认参数不算花哨但每个关键功能都留了调参入口。既适合在校生照着复现交课设也适合想快速搭横版 Demo 验证玩法的从业者至少能帮你省掉从零搭建工程的两三个晚上。2. 从打开工程到第一次 Play先看目录、再跑场景、最后调参数2.1 工程目录与脚本职责先搞清楚谁在管什么拿到 zip 解压后直接用 Unity Hub 打开。资源内部是 Unity 常规 2D 工程模板目录结构不算复杂打开后先看 Assets 下的组织方式Scripts按职责拆分Managers 下是 GameManager 和 AudioManagerPlayer 下是 PlayerControllerEnemy 下是敌人的 AI 和子弹脚本Camera 下是摄像机跟随脚本Prefabs玩家、敌人、子弹、障碍物的预制体都放在这里Scenes默认场景 Main.unitySprites美术素材和精灵图集Resources供脚本动态加载的公共资源我一般会先打开 PlayerController 看字段因为从字段能直接看出这个工程的玩法边界。默认字段是 moveSpeed、jumpForce、doubleJump、groundCheckOffset、groundMask这套设计说明移动和跳跃才是跑酷手感的主心骨地面检测走的是射线而不是 Trigger 事件。字段全部在 Inspector 暴露没有魔法数字这点很适合答辩时现场调参演示。public class PlayerController : MonoBehaviour { [Header(移动)] public float moveSpeed 6f; public float jumpForce 9f; public bool enableDoubleJump true; [Header(地面检测)] public Transform groundCheck; public float checkDistance 0.15f; public LayerMask groundMask; private Rigidbody2D rb; private int jumpCount; private bool isGrounded;字段层先不要改值跑通后再调。个别脚本里挂着#if UNITY_EDITOR的调试 gizio 绘制代码不要删后面调射线参数时全靠它看命中状态。2.2 场景层级与 Layer/Tag 配置碰撞不灵八成是 Layer 的问题打开 Main.unity 后场景层级大致是这样组织的GameRoot 空物体挂 GameManager下面分别是 Player、Ground、Obstacles、Enemies、UI Canvas。UI Canvas 里是 ScoreText、StartPanel 和 EndPanel。这个层级关系很典型每类物体一个父节点方便批量开关和统一管理。我为什么建议先检查 Layer 而不是先改代码因为 2D 碰撞检测的过滤优先走 LayerTag 只是逻辑识别不参与物理判定。资源里已经分好了层你只需要在 Inspector 的 Layer 下拉里复核一遍。对象TagLayer玩家PlayerPlayer地面GroundGround敌人/子弹EnemyEnemy障碍物ObstacleObstacle设完 Layer 还要去 Project Settings → Physics 2D → Layer Collision Matrix 过一遍把不需要碰撞的层对关掉。这里有个频繁出现的易混点Unity 里的 LayerMask 和 RenderingLayerMask 是两个东西。RenderingLayerMask 是 SRP 渲染管线用来控制光照和渲染批次的跟 2D 物理射线判断脚下一点关系都没有。你在Physics2D.Raycast里传的是 LayerMask也就是普通 Layer。如果只给物体设置了 Layer没在物理矩阵里把对应层的碰撞打开射线检测照样返回空。提示打开工程时如果弹出版本不匹配窗口先不要点 Upgrade看清楚当前 Unity 版本再决定。老工程直接升级容易触发 API 报错第 5 章有专门的排查方式。2.3 第一次 Play先让默认场景跑起来在改任何东西之前先让工程能跑。我的检查顺序是固定的场景视图右上角切到 2D 模式确认 MainCamera 是正交投影 Orthographic建议 size 保持 5 左右。选中 MainCamera确认 Projection 是 Orthographic背景色别是黑的。按下 Play观察角色是否会自动跑动。如果资源里默认关闭了自动跑就按一下 Start 按钮或者 Enter 键。如果运行后画面右下角出现 Trial Version 水印说明当前 Unity 是未激活的试用许可。这个水印不影响编辑器内调试但打包出来会带水印做课程演示前务必去 Unity Hub 登录个人许可证不然演示时满屏水印很尴尬。如果角色不动先看 GameRoot 下 GameManager 的状态枚举最常见是停留在 WaitForStart需要手动触发才进入 Running。第一遍 Play 只要做到角色能跳、撞到地面不会直接穿下去、镜头不抖就算验证通过。你不用一次看懂所有代码先把“能跑”和“跑起来有哪些问题”这两个感觉建立起来。3. 核心玩法主角手感、敌人管理与对战流程怎么落地3.1 移动与跳跃怎样调出手感速度、加速度与二段跳跑酷游戏最核心的是主角移动。这里移动控制用的是 Rigidbody2D 的 velocity 而不是 transform.position目的是让物理引擎参与碰撞响应避免直接改坐标导致穿墙。只听声音可能会觉得这不是基本功吗但翻车的人不在少数。private void FixedUpdate() { float x Input.GetAxisRaw(Horizontal); if (x ! 0) { rb.velocity new Vector2(x * moveSpeed, rb.velocity.y); } else if (isGrounded) { rb.velocity new Vector2(0f, rb.velocity.y); } }逻辑说明FixedUpdate 按固定频率执行放在这里移动物理计算才稳定。GetAxisRaw返回 -1、0、1输入响应更灵敏如果用GetAxis角色会有一个线性加速过程手感偏“肉”跑酷游戏通常不喜欢这种延迟感。rb.velocity.y要原样保留这样改水平速度不会把跳跃的竖直速度清零。跳跃判定类似用GetButtonDown而不是GetKey可以兼容手柄和键盘两套输入。二段跳的判定方式是看 jumpCount落地时在 OnCollisionEnter2D 里重置为 0。这样写比靠“是否离开地面”判断稳至少不会出现连续按两下跳直接飞上天的情况。if (Input.GetButtonDown(Jump)) { bool canJump isGrounded || (enableDoubleJump jumpCount 2); if (canJump) { rb.velocity new Vector2(rb.velocity.x, jumpForce); jumpCount; } }参数建议moveSpeed6、jumpForce9 是比较稳的起点。jumpForce 太小会觉得坠太大又有飘的感觉。如果按 jumpForce9 跳起来不到两个身位高就往上加到 10 到 11如果你想要更重的下落手感把 Rigidbody2D 的 Gravity Scale 从默认 1 调到 1.3这个参数比反复调 jumpForce 更直观你可以在 Inspector 里边拖边试。3.2 敌人管理List 与数组怎么选对象池怎么用敌人列表用数组还是 List这在 C# 课堂和答辩里基本都是必问。结论很直接如果敌人数量固定且关卡内不增删用数组访问最快如果敌人要动态生成、死亡后移除用 List增删操作不用自己维护索引。这个工程里的敌人是动态出现的所以默认用了 List。public ListEnemyController enemies new ListEnemyController(); private void Update() { for (int i enemies.Count - 1; i 0; i--) { if (enemies[i] ! null enemies[i].isDead) { enemies.RemoveAt(i); } } }循环写enemies.Count - 1倒序遍历是为了在循环里 RemoveAt 不会跳过未检查的元素。正序遍历时 RemoveAt 会让后面的元素前移索引错位这是初学者最容易犯的错。数组和集合在内存访问上其实差别不大真正的差异在增删语义数组长度固定删一个元素就得手动搬移List 封装好了 Add 和 RemoveAt维护成本低很多。子弹和敌人这类频繁出生和消失的对象这个工程里默认用了对象池。直接频繁 Instantiate/Destroy 会导致 GC 频繁分配玩几分钟就会出现卡顿。对象池的核心是复用用队列保存隐藏对象public class ObjectPool : MonoBehaviour { private QueueGameObject pool new QueueGameObject(); public GameObject Get(GameObject prefab, Vector3 pos, Quaternion rot) { GameObject obj pool.Count 0 ? pool.Dequeue() : Instantiate(prefab); obj.transform.position pos; obj.transform.rotation rot; obj.SetActive(true); return obj; } public void Release(GameObject obj) { obj.SetActive(false); pool.Enqueue(obj); } }逻辑说明队列先进先出Get 时优先复用队列里的旧对象没有才新生成Release 把对象隐藏后放回队列而不是 Destroy。这里要特别注意的是释放前必须把位置、缩放、速度和颜色全部重置否则会出现“敌人没血却看起来是活的”“上一个子弹是红色下一个还是红色”这种残留状态这个坑第 5 章会详细展开。3.3 关卡配置与对战流程状态机的正确切法对战不是简单的过关动画而是轻量状态机。GameManager 用一个枚举控制整体流程状态切换集中在公开方法里避免各个脚本到处改 state 导致流程失控。public enum GameState { WaitForStart, Running, Victory, Defeat } public void StartRun() { state GameState.Running; playerController.SetControllable(true); } private void Update() { if (state GameState.Running) { // 计时、判定胜利条件 } }建议把状态切换集中在 GameManager不要在子类脚本里直接改 state。后续扩展加“暂停”“商城”“复活”时只需要在 GameState 里加枚举值在 Update 里加对应分支改动范围可控。这个结构如果拆得合理答辩时被问“后续怎么扩展”就不用临场编。关卡设计层面资源里的思路是“平台区 敌人区 关底 BOSS”三段式。平台区考跳跃与躲避敌人区考对战操作BOSS 区考整体资源配合。普通敌人用血量控制伤害来源是踩头或发射子弹踩头伤害在 EnemyController 里通过一个回调触发改成子弹伤害时只需要替换伤害入口。4. 碰撞检测与摄像机跟随两套最容易翻车的系统4.1 脚下判定Raycast 与 LayerMask 的正确姿势跑酷游戏里判断“是否着地”最稳的方式不是碰撞事件而是射线检测。原因在于 OnTriggerEnter 是一次性事件你在 Update 里按下跳跃键时触发事件可能已经在上帧执行完了这一帧 isGrounded 还是 false跳跃就按不出来。玩家感知到的就是“明明落地了但跳不起来”这是典型的跳跃手感玄学问题。private void Update() { Vector2 origin new Vector2(transform.position.x, groundCheck.position.y); RaycastHit2D hit Physics2D.Raycast(origin, Vector2.down, checkDistance, groundMask); isGrounded hit.collider ! null; }参数说明origin 用 groundCheck 的 y 而不是角色中心 y这样射线起点在脚底附近checkDistance 取 0.15 左右给模型留一点视觉空隙。groundMask 必须在 Inspector 里指到 Ground 层如果留成 Everything角色踩在自己身上也会判定为落地形成抖动。LayerMask 字段直接暴露比代码里写LayerMask.GetMask(Ground)要方便后者每次拿名字查层字符串拼错一个就返回 0排查问题时会很崩溃。如果需要在代码里动态构造 mask用1 LayerMask.NameToLayer(Ground)这种位运算写法。想看射线有没有射中加一个 Gizmos 绘制这个问题立刻可视private void OnDrawGizmosSelected() { Gizmos.color isGrounded ? Color.green : Color.red; Gizmos.DrawLine(transform.position, transform.position Vector3.down * checkDistance); }绿色代表着地红色代表悬空改参数时不用靠猜。再一次强调第 2 章那个点物理检测用的 LayerMask 是普通 Layer不是 RenderingLayerMask后者是 HDRP/URP 渲染管线的概念跟物理判定是两套体系。不少人在网上问“为什么 Raycast 不生效”查到最后都是把物体设置到了错误的层或者物理矩阵里没勾选对应层对。4.2 摄像机跟随LateUpdate 与 SmoothDamp 的组合拳2D 横版跑酷的摄像机是典型的跟随型摄像机最容易犯两个错一是在 Update 里直接跟随二是不做插值导致镜头硬跳。硬跳在跑酷这种高速场景里会让玩家视觉疲劳答辩现场演示时也会被评委一眼看出来。public class CameraFollow : MonoBehaviour { public Transform target; public float smoothTime 0.2f; public float minX -10f; public float maxX 10f; private Vector3 velocity Vector3.zero; private void LateUpdate() { Vector3 dest new Vector3(target.position.x, target.position.y, transform.position.z); transform.position Vector3.SmoothDamp(transform.position, dest, ref velocity, smoothTime); transform.position new Vector3( Mathf.Clamp(transform.position.x, minX, maxX), transform.position.y, transform.position.z); } }逻辑说明LateUpdate 在 Update 和 FixedUpdate 之后执行保证摄像机读取到的目标位置是本帧最终位置否则物体会抖动。SmoothDamp 带一个平滑速度参数摄像机停止时不会拖尾振荡。smoothTime 越小镜头越紧0.1 跟手感强0.3 会有弹性跑酷场景我一般从 0.2 开始调。Z 轴要锁死代码里 dest 的 z 取的是transform.position.z如果直接赋值target.position摄像机 Z 会变成玩家的 Z默认 0后续 UI 空间距离一乱画面就会出现遮挡和黑块。这段脚本可扩展的点在于边界。Mathf.Clamp限制了摄像机跟随范围防止镜头跟出关卡边界。如果你想让摄像机在特定区域做缓慢平移把 minX、maxX 换成两个空物体的 x 坐标就行比在代码里写死数字好维护。5. 避坑与常见问题排查五个真实踩坑记录5.1 高速移动时角色直接穿墙现象moveSpeed 调到 10 以上高速撞击障碍物时角色直接穿过甚至掉出地图。原因Rigidbody2D 默认的 Collision Detection 是 Discrete物理引擎每帧只在前后两个位置之间做一次接触判断当一帧位移量大于碰撞体厚度时碰撞就被跳过了。这是 2D 碰撞检测里最典型的边界问题。解决把 Rigidbody2D 的 Collision Detection 改为 Continuous。连续检测会对移动路径做扫描代价是少量性能开销2D 场景里普通关卡基本无所谓。改完再跑一遍高速撞击就不会穿了。注意连续检测只对移动刚体本身生效。地面如果用的是 Tilemap 或 CompositeCollider2D要确认没有把地板碎成多个小碰撞体接缝处也有漏检风险。我一般会优先用 Composite Collider 合并地面碰撞体。5.2 落地判定失灵明明在地面上却跳不起来现象按下跳跃键经常没反应或者在平台边缘走两步后就跳不动了。原因最常见的有两种。第一地面物体所在的 Layer 没设成 GroundRaycast 的 groundMask 与地面层不匹配射线从脚底穿过但什么都没检测到。第二射线起点嵌进了地面内部checkDistance 设得太小起点已经在碰撞体里检测结果反而为空。解决打开 Gizmos 看射线颜色。如果射线红色先检查物体 Layer 是否为 Ground如果 Layer 没错把 checkDistance 从 0.15 加到 0.2 左右。另外Tilemap 的碰撞体属于 Tilemap 这个 GameObject不是每个 tile 一个层只要 Tilemap 根节点的 Layer 是 Ground 即可不需要逐块设置。5.3 摄像机抖动镜头像帕金森一样晃现象角色原地站立时镜头还在左右小幅抖动快速跑动时画面有撕裂感。原因摄像机跟随放在 Update 里更新顺序和物理不同步。另一种可能是 smoothTime 设得太小镜头跟得太紧物理引擎的微小位置波动被放大成了可见抖动。解决先把跟随逻辑整体移到 LateUpdate。如果还抖把 smoothTime 从 0.05 调到 0.15 到 0.25让镜头保留一点惰性。最后检查摄像机 Z 轴有没有锁死Z 轴没锁会在跳跃时出现远近跳动视觉上很像抖动。这三个原因按顺序排查90% 的镜头抖动都能解决。5.4 对象池回收后敌人“死而复生”现象敌人死亡后从池里复用下次出现时血条是空的碰撞体还没关闭或者颜色还是上一个敌人的。原因Release 只做了 SetActive(false)没有重置位置、速度和血量。下次 Get 时直接把对象激活脚本里的 currentHealth 还残留着上次的死亡值表现自然不对。解决写一个统一的初始化入口在 OnEnable 里重置所有可变字段private void OnEnable() { currentHealth maxHealth; rb.velocity Vector2.zero; collider.enabled true; animator.Play(Idle); }逻辑说明OnEnable 在 SetActive(true) 之后立即调用所有进入对象池的物体属性重置都写在这里不留给外部脚本处理。从那以后我凡是进对象池的对象所有可变字段必须在 OnEnable 里写默认值一秒都不留。这个习惯能省掉你后面至少一个小时的查 bug 时间。5.5 Unity 版本升级后报 API 错误现象用新版 Unity 打开老工程控制台刷一大片Physics2D或Rigidbody2D报错Canvas 渲染也异常。原因老课设工程常用 .NET Framework 4.x 和旧 API新版默认 .NET Standard 2.1API 兼容性变化导致编译失败。解决先去 Player Settings → Other Settings → Api Compatibility Level 改成 .NET Standard 2.1。如果还报错就直接用 2021.3 LTS 打开这个版本对老工程兼容性最好先跑通再谈升级。升级完发现某个 UI 组件缺失去 Package Manager 重装对应包不要手动删目录删了更容易引发连带报错。6. 验证清单与后续扩展把课设升级成可用 Demo6.1 开箱验证清单五步内确认核心功能完整每次改完参数或重开工程我都建议先过一遍这个清单。不用写进答辩 PPT但能帮你快速定位问题是出在输入、物理还是逻辑层。操作预期反馈按 A/D 或方向键角色左右移动不穿墙按空格跳跃、连续按两下二段跳生效落地后可再次跳跃踩到敌人头顶敌人扣血玩家弹起被敌人子弹命中玩家扣血有闪光或击退效果到达终点状态切到 Victory结算界面出现6.2 三个低成本扩展点如果课设答辩想加亮点不需要大改三个小改动性价比极高。第一个是 C# 委托和事件。把玩家扣血逻辑从 GameManager 的轮询改成事件驱动玩家受伤时触发event Actionint OnPlayerHurtUI 和音效各自订阅。这样 GameManager 不需要每帧检查玩家血量代码内聚性更好答辩讲起来也好说。public event Actionint OnPlayerHurt; public void TakeDamage(int dmg) { currentHealth - dmg; OnPlayerHurt?.Invoke(currentHealth); }逻辑说明?.Invoke是 C# 6 的判空调用写法没有订阅者也不会抛异常。订阅方只需要在 OnEnable 里注册、OnDisable 里注销避免泄漏。第二个是用对象池统一管理子弹。这个工程里 ObjectPool 已经实现了把它接到玩家射击逻辑上即可。答辩被问“性能优化”时直接解释“用队列复用对象避免了运行时反复 Instantiate 导致的 GC 分配”这句话足够清楚。第三个是把地面检测距离从常量改成 Inspector 暴露参数。答辩时现场调一段参数演示“跳跃手感变化”比翻代码找注释更直观。我写脚本的习惯是把这类常数全部用 [Header] 分组放在最上方方便一眼定位。从那以后我每次进入新工程都会强制走一遍“先跑默认场景、再验证碰撞、再动参数”的流程每次都先确认版本兼容性再调对象池重置逻辑最后才敢动摄像机参数。这套流程帮我避开过很多次演示现场翻车希望帮到你。本文还有配套的精品资源点击获取