☰
C#横版卷轴游戏源码实战:动画状态机与碰撞检测解析
2026/10/18 17:50:06 网站建设 项目流程

简介:这是一份基于C#开发的横版卷轴动作冒险游戏源码包,适合计算机专业毕业设计参考,也适合Unity 2D游戏开发初学者研习。项目围绕“勇士传说”实现了角色移动、攻击、受伤与死亡动画,并包含场景管理、敌人AI、碰撞检测、地图切换、音效播放等核心游戏机制。压缩包共2023个文件,以853个asset资源文件和999个meta文件为主体,另有39个anim动画、51个wav音频、10个cs脚本、7个prefab预制体及unity场景、controller状态机等,整体约35.45MB,目录结构完整,便于对照源码理解各模块的组织方式。目前已有151人学习下载。通过阅读和调试这套源码,可以掌握C#脚本驱动游戏对象的基本思路,学会用Animator管理角色状态、用碰撞体实现交互反馈,并了解一个中小型Unity项目的工程组织方法;项目内脚本注释清晰、模块边界明确,适合提取复用,对完成毕业设计或独立开发同类横版游戏都很有帮助。

1. 基于C#的横版卷轴游戏源码:打开压缩包先看 .anim 文件,再看 C# 脚本

拿到这份「勇士传说」横版卷轴游戏源码的压缩包时,第一反应是里面的文件结构比想象中更偏向美术资源——大量 blueWalk、blueAttack1 这样的 .anim 动画文件排在前面,真正的 C# 逻辑脚本被收在更深层的目录里。这类基于 C# 开发的 Unity 2D 项目,在毕业设计场景里非常典型:玩家操控一名蓝色角色横版移动、攻击敌人、处理受击和死亡状态,蜜蜂与红猪作为敌对单位提供基础 AI 反馈。源码的可贵之处在于,它把动作游戏的几个核心模块——角色状态机、输入响应、碰撞检测、敌人行为——都压缩在一个可以复现的小游戏工程里。适合正在做毕业设计、想快速理解 Unity Animator 与 C# 脚本如何配合的开发者。下文按动画状态机、角色控制、碰撞与 AI、排错、扩展这条线拆开讲。

2. 从 .anim 文件反推角色动画系统:blueWalk 到 blueAttack3 的状态机设计

2.1 先看懂动画资源命名:同一角色的四类状态

压缩包里 blueHurt.anim、blueHurt2.anim、blueAttack1.anim、blueAttack2.anim、blueAttack3.anim、blueDead.anim、blueWalk.anim 这一组,明显是一个蓝衣主角的完整动作集合。命名习惯是「角色名 + 动作名 + 序号」:Walk 是移动循环,Attack1 到 Attack3 是三种攻击动作,Hurt 与 Hurt2 是两种受击反馈,Dead 是死亡播放一次。这种命名方式直接对应 Unity Animator 里的 Animation Clip,每个 .anim 文件本质上是一段关键帧曲线,记录的是 Sprite 序列帧切换的时间点与属性变化。

如果想知道某个动作在代码里如何被触发,直接搜索 .anim 文件名对应的字符串。大多数情况下 C# 脚本里会有animator.Play("blueAttack1")或者animator.SetTrigger("attack")这类调用,把动画剪辑的名字暴露在代码层。拿到源码后我一般会先做一件简单的事:在项目里用 IDE 全局搜索「blueAttack1」字符串,看它出现在哪些 .cs 文件里,这比逐个打开脚本猜测状态机设计要高效得多。

2.2 动画状态机的最小骨架:Walk、Attack、Hurt、Dead 四个状态

横版动作游戏的主角动画状态机,核心就是这四类状态加一个空闲态。从 .anim 文件推断,蓝色角色的状态机会长这样:默认状态是 Idle,按下移动键后切到 Walk 并循环播放;按攻击键时从 Idle 或 Walk 切到某个 Attack 状态,播完一段后自动回到 Idle;被敌人打中时切到 Hurt,Hurt 播完回 Idle;生命值归零切到 Dead,Dead 播完后不再切换。

这里有一个关键设计:Attack 和 Hurt 这类一次性动作,要用「退出时间」或「动画结束事件」来控制返回,而不是靠固定延时。怎么看源码里用的是哪种?打开主角的 AnimatorController 文件(扩展名 .controller),在 Any State 到各状态的 Transition 上能看到退出条件。如果源码里没有保存 .controller 的可视化配置,只给了脚本,那就看animator.GetCurrentAnimatorStateInfo(0).normalizedTime >= 1f这类判断,用 normalizedTime 判断动画是否播完是常见做法。

2.3 在 Unity 编辑器里重建 Animator 的实操顺序

如果压缩包里只给了脚本和 .anim,没有完整的 AnimatorController,需要自己拼一个。实操顺序是:

  1. 在 Assets 窗口创建 Animator Controller,命名 PlayerController。
  2. 把 blueIdle(素材里没出现,需自行从 idle 序列帧生成)、blueWalk、blueAttack1 到 blueAttack3、blueHurt、blueHurt2、blueDead 拖入 Animator 窗口。
  3. 创建 Idle 状态,右键 Make Transition 连接到 Walk,条件是isWalking == true;Walk 回到 Idle 的条件是isWalking == false。
  4. Attack 的 Transition 勾选 Has Exit Time,退出时间设 0.9,让攻击动作播到接近结尾才允许回 Idle。
  5. Hurt 和 Dead 用 Any State 进入:triggerHurt == true、triggerDead == true。

配置过程中注意一个细节:动画状态机里的参数名必须和脚本里 SetBool、SetTrigger 的字符串完全一致,大小写也不能差。源码里如果用的是isWalking,脚本里写IsWalking就永远触发不了。

3. 用 C# 驱动角色移动与攻击:输入层与逻辑层的分工边界

3.1 InputManager 与 PlayerController 的分工

看 C# 源码时,先找 InputManager 和 PlayerController 两个文件。这个项目典型的做法是:InputManager 负责收集键盘输入并转换成布尔值或方向向量,PlayerController 只消费这些结果,不直接读取 Input.GetAxis。这样拆分的好处是后续改成手柄或触屏虚拟摇杆时,只需要替换 InputManager,角色逻辑不用动。

角色移动的核心代码一般长这样:

// 假设 InputManager 里已经有 horizontalInput 这个公共属性 public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; public Rigidbody2D rb; public Animator animator; private float horizontalInput; void Update() { // 从 InputManager 拿输入,不在角色脚本里直接读键盘 horizontalInput = InputManager.Instance.horizontalInput; // 同步动画参数:isWalking 给 Animator 状态机判断 animator.SetBool("isWalking", Mathf.Abs(horizontalInput) > 0.1f); // 如果角色朝左移动,翻转 Sprite 而不是把速度取反了事 if (horizontalInput > 0.1f) transform.localScale = new Vector3(1, 1, 1); else if (horizontalInput < -0.1f) transform.localScale = new Vector3(-1, 1, 1); } void FixedUpdate() { // 物理相关操作放 FixedUpdate,避免和渲染帧率耦合 rb.linearVelocity = new Vector2(horizontalInput * moveSpeed, rb.linearVelocity.y); } }

这段代码里有两个值得注意的参数:moveSpeed 是水平移速,调成 3 会显得角色走得拖沓,调成 8 会难以控制跳跃落点,5 到 6 是比较合适的横版手感范围;FixedUpdate 里只改横向速度、保留 rb 原有的 Y 轴速度,是为了不和跳跃或重力冲突。如果把rb.linearVelocity整个赋成new Vector2(x, 0),跳跃后角色会直接定在半空,这是新手最容易翻车的位置。

3.2 攻击动作的触发与角色朝向修正

攻击触发一般放在 Update 里检测按键,这样响应最快。但攻击方向和角色当前朝向要一致,否则会出现面向左却向右边出拳的问题。

void Update() { if (Input.GetKeyDown(KeyCode.J)) { // 触发攻击前先锁定角色朝向,防止攻击过程中被移动键改朝向 Vector3 scale = transform.localScale; int facing = scale.x > 0 ? 1 : -1; animator.SetInteger("attackType", Random.Range(0, 3)); animator.SetTrigger("attack"); // 实际伤害判定不要在这里做,等动画事件回调 } }

这里的 attackType 用 SetInteger 而不是分别做三个 Trigger,是常见做法:Animator 里 Attack 状态的 Transition 条件写成attackType == 0、attackType == 1、attackType == 2,一个 Trigger 加一个整型参数就能覆盖三种攻击动画,比建三个 Trigger 干净得多。Random.Range(0, 3) 会产生 0、1、2 三个值,正好匹配 Attack1 到 Attack3。

3.3 利用 Animation Event 处理伤害判定时机

动画事件是源码里最容易忽略但最关键的机制。攻击动作从按下到实际伤敌有一个前后摇时间,如果把伤害判定放在按键触发的同一帧,玩家还没看到动画挥出拳头,敌人就已经扣血了。正规做法是在 .anim 文件的时间轴上插入事件,事件函数名写在脚本里:

// 此方法由 Animation Event 在攻击动画的特定帧调用 public void OnAttackHit() { // 检测攻击范围内是否有碰撞体 Collider2D[] hits = Physics2D.OverlapBoxAll( attackPoint.position, new Vector2(attackRange, attackHeight), 0f, enemyLayer ); foreach (var hit in hits) { hit.GetComponent<Enemy>().TakeDamage(attackDamage); } }

OverlapBoxAll的四个参数分别表示:中心点从 attackPoint 出发、检测盒子的宽高、旋转角度、目标层级。attackPoint 建议是角色手部的空物体,attackRange 约 0.5 到 0.8,attackHeight 约 0.4 就够用。如果源码里用的是OnTriggerEnter2D做伤害,也能跑,但攻击手感会显得发飘,因为判定持续整段动画时间而不是某一个瞬间。

4. 碰撞检测与敌人 AI:让红猪和蜜蜂从装饰变成威胁

4.1 碰撞体选型:碰撞与触发的取舍

在 Unity 2D 项目里,地面和墙体用 BoxCollider2D(带碰撞的实体),玩家和敌人的受击区域用 BoxCollider2D 加Is Trigger,脚下用一个单独的物理碰撞体防止穿透。为什么不做成一个 Collider?因为角色踩到地面时要有物理阻挡,但挥拳打向敌人时又不能真的把敌人弹开,这两个需求互相冲突。

源码里如果有 Player 预制体,观察它的结构:通常会有两个或多个 Collider2D 组件。一个负责与地面碰撞,Rigidbody2D 的 body type 设为 Dynamic;另一个挂在子物体上,标记为 Trigger,用于伤害检测。Trigger 不产生物理阻挡,但能触发OnTriggerEnter2D/OnTriggerStay2D回调,正好适合战斗判定。

4.2 敌人 AI 的行为循环:巡逻、追击、攻击、死亡

从文件列表能看出,红猪(BoarDead.anim)和蜜蜂(beeDead.anim、beeAttack.anim)是两种行为模式不同的敌人。红猪在地面巡逻追人,蜜蜂在固定路径或一定范围内悬停攻击。它们的共同点是状态切换逻辑可以用一个简单的枚举驱动:

public enum EnemyState { Patrol, Chase, Attack, Dead } public class BoarController : MonoBehaviour { public float patrolRange = 3f; public float detectRange = 5f; public float moveSpeed = 2f; public Transform player; private EnemyState state = EnemyState.Patrol; private float patrolDirection = 1f; void Update() { switch (state) { case EnemyState.Patrol: // 在固定范围内来回走,碰到边界反转方向 Patrol(); // 检测到玩家后切换追击 if (Vector2.Distance(transform.position, player.position) < detectRange) state = EnemyState.Chase; break; case EnemyState.Chase: // 朝玩家方向移动,进入攻击距离后停止并出手 Chase(); break; } } }

detectRange和patrolRange是两个最值得改的参数:detectRange 调太小,玩家站到怪物脸上才反应;调太大,AI 会隔着整张地图追你。这类源码包默认值通常是 4 到 6,具体数值要看游戏地图的尺寸。Patrol 状态下要注意边界判断,如果只是按时间反转方向,红猪可能会卡在墙角抽搐。

4.3 敌人的死亡处理:动画播完再回收

beeDead.anim、BoarDead.anim 的存在说明敌人死亡不是直接销毁对象,而是先播死亡动画再清理。常见做法是:

public void TakeDamage(int damage) { health -= damage; if (health <= 0) { GetComponent<Collider2D>().enabled = false; GetComponent<Rigidbody2D>().simulated = false; animator.SetTrigger("die"); // 等动画播完再销毁,避免半空中尸体突然消失 Destroy(gameObject, 1.2f); } }

这里有两个容易踩的坑:一是先禁用 Collider2D 和 Rigidbody2D,否则死掉的敌人还会挡路、还会被物理系统推来推去;二是Destroy(gameObject, 1.2f)的延时数值要大于死亡动画的实际长度,如果 Dead 动画是 0.8 秒,延时就别写 0.5,否则动画播到一半物体消失,玩家会看到闪烁的残影。

5. 避坑与常见问题排查:跑不起来、动画不切换、穿透的现场记录

5.1 现象:动画能播放但角色原地不动

现象:场景里角色一直在播 Walk 动画,但横向位置不变,像在跑步机上。

原因:Rigidbody2D的constraints里冻住了 X 轴,或者transform.position被某段代码每帧强制复位。最常见的是在 Update 里写了transform.position = something,又同时在 FixedUpdate 里用物理速度驱动,两套位移系统互相打架。

解决:先查 Rigidbody2D 的 Constraints 是否勾选了 Freeze Position X。再看代码里是否混用了transform.position和rb.linearVelocity。统一成一条路:物理移动只走rb.linearVelocity,只在传送点写transform.position。

5.2 现象:敌人始终不追击,哪怕玩家贴脸

现象:红猪只会来回巡逻,玩家走到身边也无动于衷。

原因:detectRange检测的player变量没在 Start 里赋值。场景里玩家的 Tag 是 Untagged,或者脚本里用的是FindObjectOfType<PlayerController>()但玩家对象一开始是隐藏的。

解决:把玩家的 Tag 设为 Player,用GameObject.FindGameObjectWithTag("Player")。如果玩家对象是动态生成的,要在敌人激活之后再去获取引用,避免 Start 时拿到空引用。

5.3 现象:攻击动画播完还能继续移动

现象:攻击动作还在播放,角色已经满地图跑了,动作和位移脱节。

原因:没有在攻击期间锁定移动。Update 里的animator.SetBool("isWalking", ...)仍然在根据输入实时更新。

解决:攻击开始时设置一个isAttacking布尔锁,在锁期间跳过移动赋值:

if (animator.GetCurrentAnimatorStateInfo(0).IsName("Attack")) { rb.linearVelocity = new Vector2(0f, rb.linearVelocity.y); return; }

5.4 现象:角色高速下落时穿过地板

现象:从高处跳下时偶尔卡进地板或直接穿到地面以下。

原因:Unity 的物理引擎处理高速物体的方式是每帧做一次碰撞检测,瞬移距离超过碰撞体厚度时就会穿透。地面如果是一块薄 Tilemap,厚度不够更容易穿模。

解决:给 Rigidbody2D 设置Collision Detection Mode = Continuous,代价是轻微性能损耗,但对 2D 横版游戏完全可接受。同时把地板的地形碰撞体加厚一点,比如用三四个 Tile 叠一层做物理底。

5.5 现象:场景切换后角色动画停在 Dead 状态

现象:玩家死亡后重开场景,角色仍然播放死亡动画或持续触发掉血。

原因:静态变量或单例里保存了受伤状态没重置。常见的是health是静态字段,或者 Animator 初始状态的 Trigger 残留。

解决:在场景加载的入口处强制重置 Animator 参数:

void OnSceneLoaded() { animator.ResetTrigger("die"); animator.ResetTrigger("hurt"); health = maxHealth; }

6. 把这份源码扩展成你的毕业设计:验证、改UI、加系统的顺序

6.1 验证源码完整性的三步走

收到压缩包别急着改代码。第一步,启动 Unity 打开工程,确认所有 .anim 文件能正常拖入 Animator 窗口,没有红色 Material 丢失警告;第二步,跑一次游戏,如果黑屏或报脚本编译错误,对照报错行号检查是否缺了某个 .cs 文件;第三步,单独检查敌人 AI 是否能被击杀、角色死亡后能否重置。这三步走完,再开始写自己的新功能。

6.2 给红猪加重型冲锋攻击的最小改法

在现有 BoarController 里加一个冲锋状态,不需要新动画,用代码控制位移速度:

case EnemyState.Charge: float chargeSpeed = 8f; transform.position += Vector3.right * GetFacingDirection() * chargeSpeed * Time.deltaTime; break;

触发条件设为玩家进入红猪背后 2 米范围时启动。这个改动只用 20 行代码,但体现了你对状态机扩展的理解,毕业设计答辩时能讲出设计意图。

6.3 把横版地图改成无尽卷轴:摄像机跟随与场景回收

横版卷轴的核心不在角色移动,而在摄像机。给 Main Camera 挂一个跟随脚本,用smoothTime控制跟随的弹性感,再用下方代码做场景块的循环利用:

void OnTriggerExit2D(Collider2D other) { if (other.gameObject.CompareTag("MapChunk")) { other.transform.position += new Vector3(50f, 0f, 0f); } }

这段逻辑的含义是:当地图块完全离开摄像机视野后,把它右移 50 个单位,循环使用。这样做内存占用稳定,不需要动态生成和销毁大量对象,比单纯的Instantiate方案省心很多。

从那以后我每次拿到这类 C# 游戏源码包,都会先花十分钟对一遍动画文件和脚本里的 Trigger 名称,再决定改动方案。你手上这份「勇士传说」源码,最大的价值不在它本身多完整,而在于它把 Animator 状态机、碰撞检测、敌人 AI 这三个最折磨新手的模块串在了一起。希望这篇拆解能帮你少走几个弯路,把别人的毕业设计改造成属于自己的作品。

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

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

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

立即咨询