C#横版动作游戏源码拆解:动画状态机与敌人AI设计实践
发布时间:2026/10/18 10:34:47 锦皓数字建站

简介一套基于C#与Unity开发的横版卷轴动作冒险游戏《勇士传说》完整源码面向C#/Unity初学者、游戏编程爱好者及需要完成毕业设计的学生。横版卷轴是2D动作游戏的经典类型项目再现了街机时代的移动、跳跃、战斗与关卡推进体验覆盖角色动画、碰撞检测、敌人AI、地图切换和输入处理等核心机制可作为从零理解2D游戏开发流程的实战范例。压缩包共2023个文件大小约35.45MB文件以Unity资源、预制体、动画片段、C#脚本、音频和场景为主另含配置与说明文本资源集中在“code”目录下结构清晰便于按模块查阅。其中Unity资源与预制体承载对象结构动画与音频负责表现C#脚本实现交互逻辑便于整体理解与二次开发。目前已有151人学习下载既能直接运行体验完整玩法也可作为毕业设计参考或Unity游戏开发入门训练通过阅读和修改源码掌握游戏项目组织方法减少从零搭建框架的时间。1. 先弄清这个包是什么一个带完整动画状态机的 C# 横版卷轴动作游戏拿到「基于 C# 开发的勇士传说横版卷轴游戏源码.zip」的时候第一眼看到的是那一串 .anim 文件——blueWalk、blueAttack1~3、blueHurt、blueDead、beeAttack、beeDead、BoarDead。这个命名习惯很「Unity 工程」说明作者把角色行为和动画剪辑一一对应地拆开了一个蓝色战士主角两类敌人蜜蜂、野猪攻击、受击、死亡事件都有独立动画。对于正在做毕业设计或者想系统学 C# 游戏开发的开发者来说这个包的价值不在于「能玩」而在于它是一个完整可跑通的 2D 横版卷轴动作游戏骨架输入处理、动画状态切换、敌人 AI、碰撞检测、场景管理都有落点。你可以直接把它打开跑起来观察一条完整的「玩家输入 → 状态切换 → 动画播放 → 碰撞判定 → 敌人反馈」链路是怎么串起来的这在教材里很难一次性看到。2. 工程拆解从动画资源反推代码结构2.1 动画文件清单是理解项目的入口压缩包里的动画文件可以分成三组动画文件所属角色代表行为blueWalk / blueAttack1~3 / blueHurt / blueHurt2 / blueDead玩家蓝色战士移动、三段攻击、受击、死亡beeAttack / beeDead蜜蜂敌人攻击、死亡BoarDead野猪敌人死亡注意 blueHurt 和 blueHurt2 是两个独立的受击动画常见做法是 blueHurt 用于普通受击blueHurt2 用于被重击击飞时的受击反馈。bee 只有攻击和死亡动画而没有移动动画说明蜜蜂的行为大概率是「悬停 接近玩家后攻击」Boar 只有死亡动画可以推断野猪的移动/冲锋状态用的是程序控制的方式直接修改 Transform 位置或 Rigidbody 速度这比给每个状态都做动画省资源也是很多独立游戏的偷懒做法。2.2 code 目录的合理组织方式压缩包里的 code 文件夹大概率长这样我没有打开时的完整目录但按这类 Unity 2D 动作游戏的通行写法你会在里面看到以下脚本文件的归类code/ ├── Assets/ │ ├── Scenes/ # 场景文件通常只有一个主游戏场景 │ ├── Animations/ # 上面那些 .anim 文件所在位置 │ ├── Scripts/ │ │ ├── Player/ │ │ │ ├── PlayerController.cs │ │ │ └── PlayerAttack.cs │ │ ├── Enemy/ │ │ │ ├── EnemyBase.cs │ │ │ ├── BeeEnemy.cs │ │ │ └── BoarEnemy.cs │ │ ├── Managers/ │ │ │ └── GameManager.cs │ │ └── Utilities/ │ │ └── Health.cs │ └── Prefabs/ # 玩家、敌人的预制体如果你打开发现 Scripts 下的文件比我列的少也不用慌毕业设计项目经常把多个类合并到一个文件里比如把 EnemyBase 直接写进 BeeEnemy.cs。关键是找到 PlayerController 和 Enemy 相关的脚本它们是整个项目的核心。2.3 动画状态机和角色行为如何对应Unity 的 Animator Controller 在这个项目里扮演的角色是把动画剪辑按条件连起来。以玩家为例状态机的设计一般是Idleidle 条件满足时 Walkhorizontal 绝对值大于阈值时触发 Attack1/Attack2/Attack3按下攻击键时依次触发用 Trigger 参数 Hurt收到伤害事件时触发播放完回 Idle DeadHP 小于等于 0 时触发动画参数命名上作者既然把动画文件叫 blueWalk 而不是 blue_move说明他对Animator的参数命名也倾向于直接用英文单词比如isWalking、attackTrigger。你在 Animator 面板里看到的参数名会和脚本里的引用一一对应读代码时如果发现某个参数连不上优先检查 Animator 面板里的参数名是不是被改过——这是打开别人工程时最容易踩的坑。3. 核心模块代码走读一条输入到反馈的完整链路3.1 PlayerController移动与转向玩家控制脚本是整个项目第一个要读的文件。它负责把键盘输入转成物理速度同时驱动 Animator 的动画参数。using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed 5f; // 水平移动速度 public float jumpForce 8f; // 跳跃力度 public Transform groundCheck; // 用于检测是否在地面 public LayerMask groundLayer; // 地面所在的 Layer private Rigidbody2D rb; private Animator anim; private bool isGrounded; void Start() { rb GetComponentRigidbody2D(); anim GetComponentAnimator(); } void Update() { // 获取水平输入axis raw 避免键盘按下时出现中间值 float horizontal Input.GetAxisRaw(Horizontal); // 设置动画参数是否在移动 anim.SetBool(isWalking, Mathf.Abs(horizontal) 0.1f); // 转向通过修改 localScale.x 实现角色朝向 if (horizontal 0f) { transform.localScale new Vector3(1, 1, 1); } else if (horizontal 0f) { transform.localScale new Vector3(-1, 1, 1); } } void FixedUpdate() { // 检查地面 isGrounded Physics2D.OverlapCircle(groundCheck.position, 0.2f, groundLayer); // 水平移动保留原有的 y 轴速度 float horizontal Input.GetAxisRaw(Horizontal); rb.velocity new Vector2(horizontal * moveSpeed, rb.velocity.y); // 跳跃 if (Input.GetButtonDown(Jump) isGrounded) { rb.velocity new Vector2(rb.velocity.x, jumpForce); } } }这段代码里有两个细节值得留意。一是移动放在 FixedUpdate 里而转向放在 Update 里因为 Rigidbody2D 的物理模拟发生在 FixedUpdate改速度必须在这里才稳定而转向只是改 Transform 的 localScale放 Update 保证响应更快。二是用Input.GetAxisRaw而非GetAxis前者返回 -1/0/1 三档不会出现 0.37 这样的渐变值横版动作游戏里手感更干脆不会有「角色慢慢加速」的粘滞感。参数说明里moveSpeed和jumpForce是直接在 Inspector 里调的你想让角色跑得更快不用改代码直接在场景里选中玩家预制体改数值就行。groundCheck是一个挂在角色脚底的空物体OverlapCircle以它为圆心画一个 0.2 单位的圆检测是否接触到groundLayer指定的地面层。3.2 攻击系统三段连击靠的是时间窗口blueAttack1、blueAttack2、blueAttack3 这三个动画文件说明玩家支持三段连击。实现连击的常见做法是用攻击次数计数 在一个时间窗口内允许衔接下一段public class PlayerAttack : MonoBehaviour { public int attackCombo 0; // 当前连击段数0 表示未攻击 public float comboWindow 0.8f; // 允许接下一段攻击的时间窗口 private float lastAttackTime; // 上一次攻击的时间 private Animator anim; void Start() { anim GetComponentAnimator(); } void Update() { // 判断攻击键是否按下 if (Input.GetButtonDown(Attack)) { // 如果距离上一次攻击时间在窗口内则连击数1并取模 if (Time.time - lastAttackTime comboWindow) { attackCombo (attackCombo 1) % 3; // 0-1-2-0 } else { attackCombo 1; // 不在窗口内重新从第一段开始 } lastAttackTime Time.time; anim.SetInteger(combo, attackCombo); } } }这套逻辑的关键是comboWindow这个时间窗口只要玩家在 0.8 秒内再次按攻击键attackCombo就会递增并在取模后循环如果超时就从第一段重新开始。Animator那边用 Integer 类型的combo参数来控制播放 blueAttack1、blueAttack2 还是 blueAttack3。这个设计比「每次点击都播放同一段动画」有质感得多玩家能明显感觉到节奏感。实际项目里攻击动画的触发点比如剑碰到敌人的那一帧通常是用 Animation Event 实现的。你可以在 blueAttack1.anim 的某一帧上添加一个事件调用OnAttackHit()方法在那个方法里执行攻击判定的逻辑。动画文件里可能已经带了这些事件读代码时如果发现OnAttackHit之类的函数顺着查一下是哪里调用的就能定位到每段攻击的具体伤害帧。3.3 敌人 AI蜜蜂悬停追踪野猪地面冲撞两个敌人的行为差异很大但可以抽一个基类管理公共的 HP、受击、死亡逻辑public abstract class EnemyBase : MonoBehaviour { public int maxHealth 3; private int currentHealth; protected Animator anim; protected bool isDead false; protected virtual void Start() { currentHealth maxHealth; anim GetComponentAnimator(); } // 子类各自实现移动与攻击 protected abstract void Move(); protected abstract void Attack(); public virtual void TakeDamage(int damage) { if (isDead) return; currentHealth - damage; anim.SetTrigger(hurt); if (currentHealth 0) { Die(); } } protected virtual void Die() { isDead true; anim.SetBool(isDead, true); // 停用碰撞体避免死亡后还能被击中 GetComponentCollider2D().enabled false; // 销毁实体延迟让死亡动画播完 Destroy(gameObject, 1f); } }蜜蜂的实现是典型的「空中追踪」行为每帧把位置朝玩家方向拉近一段距离到达攻击范围后触发攻击动画攻击动画的某帧执行伤害判定。野猪则是「地面直线冲撞」在巡逻状态下来回移动玩家进入一定范围后启动冲锋。注意 Boar 没有移动动画它的位移大概率是直接用rb.velocity new Vector2(direction * chargeSpeed, rb.velocity.y)来实现的你读代码时如果看到 EnemyBase 的子类里没有调用anim.SetBool(isMoving)之类的东西那就说明移动是纯物理驱动正常现象。攻击判定方面这个项目用的是碰撞体触发Trigger。攻击时生成一个临时的攻击区域或直接在攻击动画的事件里启用武器上的 Collider2D检测到PlayerController后就扣血并反馈。子弹式的射程判定在这个项目里用不上因为蜜蜂和野猪都是近身攻击。3.4 GameManager场景加载与游戏状态GameManager在 2D 动作游戏里负责的全局状态一般包括玩家 HP、敌人数量、场景切换、游戏结束逻辑。public class GameManager : MonoBehaviour { public static GameManager Instance; // 单例模式便于全局访问 public int playerLives 3; public string sceneName; void Awake() { if (Instance null) { Instance this; DontDestroyOnLoad(gameObject); // 跨场景保留 } else { Destroy(gameObject); } } public void PlayerDied() { playerLives--; if (playerLives 0) { // 游戏结束逻辑 Debug.Log(Game Over); } else { // 重载当前场景 UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName); } } }单例模式在毕业设计的游戏项目里很常见因为各个脚本都需要访问「玩家还剩几条命」这类全局数据。DontDestroyOnLoad保证场景重载时 GameManager 不会被销毁避免重复创建。这里的sceneName也可以换成在 Inspector 里手动指定或者用SceneManager.GetActiveScene().name动态获取二选一都行。4. 跑起来的过程与避坑记录4.1 从 zip 到能动的场景正确打开 Unity 工程把 zip 解压后不要把Assets文件夹单独拖进一个空 Unity 项目里那样资源引用会全部断掉。正确做法是打开 Unity Hub点击「添加」→ 选择解压后的项目根目录包含Assets文件夹的层级也就是code文件夹本身让 Unity 用原有项目的项目设置打开。如果你用的是 2020 或更高版本首次打开会提示升级项目直接确认即可PlayerController这类核心脚本基本不受升级影响。打开后第一件事是打开Scenes文件夹里的主场景然后点击运行按钮。如果看到角色不动或者报错找不到Animator Controller先检查 Hierarchy 面板里的玩家物体是否挂载了和Animator中参数名一致的脚本再看Animator Controller资产是否为空——这类引丢失的报错在别人的工程里几乎必现重新拖拽赋值即可。4.2 动画状态机的配置是最大的黑匣子动画文件是齐的但状态机的连线信息不在这份资源里。这是 Unity 工程打包分享时的常见情况.anim是剪辑本身而.controller是状态机连线。如果你打开 Animator 面板发现是空的不要怀疑自己搞错了你只需要自己把三个动画拖进去重新连线Idle 是默认状态blueWalk 作为移动状态并设置isWalking的切换条件blueAttack1 到 Attack3 之间设置「下一段攻击」的过渡条件。连线的时候有个参数容易出错Transition 里的Has Exit Time默认是勾选的这意味着攻击动画播完才会切换连击就会显得拖沓。做动作游戏时攻击相关的过渡应当把Has Exit Time取消勾选改为完全由combo参数驱动这样玩家连击时动画响应才跟手。4.3 避坑记录打开别人源码后最容易翻车的五个地方现象 1角色移动时动画播放正常但 Actor 原地滑步压根没位移。原因Rigidbody2D的移动被Animator的Apply Root Motion抢走了Transform 被动画帧里的位移曲线覆盖。 解决选中玩家物体在 Animator 组件上取消勾选Apply Root Motion再在 PlayerController 里通过rb.velocity手动控制移动。现象 2攻击判定打不中敌人刀挥过去了但敌人不掉血。原因攻击区域Hitbox挂在了玩家子物体上但子物体的坐标用的是本地坐标。角色转向只用修改localScale.x而碰撞体 offset 的正方向没有跟随翻转。 解决写攻击逻辑时用transform.right的方向计算碰撞体偏移或者把 Hitbox 做成每次攻击时动态生成的临时物体根据当前朝向设置世界坐标。检查代码里是否在攻击回调里做了attackTransform.position transform.position transform.right * range这样的偏移计算。现象 3蜜蜂一直跟我到场景边缘然后卡在墙里疯狂抖动。原因追踪型的敌人只用Vector2.MoveTowards朝玩家方向移动没有考虑碰撞体阻挡。碰到墙的时候每帧都在「往墙里推」被 Collider 顶回后下一帧又继续往里推视觉上就是抖动。 解决给蜜蜂的移动加一个地面/墙体检测或者改用Rigidbody2D的velocity加上PhysicsMaterial2D的摩擦处理让它被墙挡住时直接停住而不是无限推挤。最省事的做法是先采样一排射线检测前方是否有墙有墙就切换追踪方向或悬停等待。现象 4野猪死亡动画放完了尸体还在地面上挡着玩家走过不去。原因Die()里只停了碰撞体但没考虑可能存在的多个碰撞体BoxCollider2D CapsuleCollider2D 各挂一个。禁用了其中一个另一个还开着。 解决用GetComponentsCollider2D()拿到所有碰撞体后遍历禁用这是动作游戏里处理死亡反馈最稳妥的姿势不会有遗漏。现象 5玩家被攻击后连续掉血两次每次受到伤害都被计算了 double。原因攻击判定用的碰撞体是持续存在的Trigger 带OnTriggerStay2D玩家在敌人攻击碰撞体范围内停留期间每帧都触发一次受伤逻辑。 解决给玩家加一个isInvincible状态受击后立即设为true并在一定时间内比如 1 秒忽略所有伤害同时把OnTriggerStay2D里的逻辑改成OnTriggerEnter2D一次接触只判定一次。5. 二次开发实战把「毕业设计」改成「可扩展的战斗框架」5.1 第一步用参数表摸清手感数值在 Inspector 里把moveSpeed调到 7、jumpForce调到 10感受一下变化。这套源码的手感区间在「轻快型」和「沉稳型」之间moveSpeed在 4~6 比较平衡超过 7 角色会显得飘需要同步把comboWindow缩短到 0.6 秒才能保持连击节奏的紧凑。改手感先动这几个参数改完跑一遍觉得不对再回配置表不要一上来就改代码里的逻辑判断。5.2 第二步给敌人加一个新类型你有现成的EnemyBase抽象类加新敌人比改旧敌人容易得多。新建一个SlimeEnemy.cs继承EnemyBase实现Move和Attack两个方法。Move里做水平方向的往返移动Attack里做范围内的玩家判定。然后在 Prefabs 文件夹里复制一份 Bee 预制体换掉 Sprite 和 Animator Controller挂上自己写的脚本再在场景里放一个一个崭新的敌人就完成了。整个过程不用动 PlayerController 和 GameManager这种扩展方式就是抽象基类的收益。5.3 第三步做一层数据驱动的角色配置如果想让项目具备「换角色皮肤」的能力常见做法是引入ScriptableObject作为角色配置资产把moveSpeed、attackDamage、maxHealth、动画速度倍率全部放进一个角色配置对象里PlayerController在Start时从配置对象读取数值。这样一来换角色就是把一个资产拖进预制体属性的操作不再需要进代码改参数而且不同角色间的数值对比一目了然。5.4 习惯上的一个提醒在我拆过不少同类源码后最深刻的教训是拿到别人工程的第一件事不要急着跑先把 Animator 状态机和脚本里所有GetComponent的引用梳理一遍列出参数名清单再按「输入 → 状态 → 动画 → 判定 → 反馈」这条线理顺调用关系。这套源码的动画命名和脚本结构已经为你留好了这条路把这条路走顺你之后再看到任何 Unity 动作游戏项目都能用同样的思路快速摸清结构。希望这篇解析能帮你在拆这个包的过程里少走一些弯路。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。