Unity横版格斗源码拆解:从状态机到对象池,打造可扩展的战斗系统

发布时间:2026/9/14 7:35:06
Unity横版格斗源码拆解:从状态机到对象池,打造可扩展的战斗系统 简介这个Unity火柴人横版格斗项目源码面向Unity游戏开发者、独立制作人或想学习横版动作类游戏框架的读者完整呈现一款名为Stickman Warriors - Super Dragon的生存格斗游戏工程。项目支持Unity 2018.4.18f1及以上版本包含100多个关卡、8个终极恶魔Boss、超级赛亚人变身、技能升级与连击系统适合用作格斗游戏原型开发或毕业设计二次开发。压缩包大小约581MB共2000个文件核心代码以272个C#脚本为主辅以116个asset场景资源、97个材质文件、多个prefab预制体与shader着色器并配有73个wav音效、70张png贴图以及用于安卓打包的aar、jar、dll和广告SDK整体目录完整可直接打开运行。当前已有494人学习下载能够获得完整工程源码及配套素材便于从项目结构、战斗逻辑和技能实现入手快速掌握Unity横版格斗项目的设计思路与打包发布流程。1. 从火柴人勇士到Super Dragon一套Unity横版格斗源码拆出来能学什么拿到一份名为 Stickman Warriors - Super Dragon 的 Unity 横版格斗项目源码很多人第一反应是找正式版或者直接改个角色名就完事。但真正把这类源码榨干价值在于对照一套已经跑通的横版战斗循环输入、状态机、判定框、受击反馈、连段、AI、对象池和动画事件。火柴人素材简单反而容易看清逻辑骨架不至于被美术资源绕晕。这篇博文会从目录结构、角色预制体、战斗链路到必杀技和Boss AI逐步说清一套能改、能跑、能加新角色的方案。如果你正打算用Unity做横版格斗或者手头有类似源码想换皮顺着这份拆解走至少能少踩一轮动画和判定框的坑。2. 先读懂项目地形Unity横版格斗源码的目录结构与场景组织2.1 源码目录该看什么Prefab、ScriptableObject与动画状态机打开Unity项目后先别急着点Play。横版格斗项目的Assets目录通常按资源类型分而不是按功能分。常见结构是Assets/Characters/角色预制体、骨骼动画、贴图Assets/Scripts/战斗逻辑、AI、工具脚本Assets/Prefabs/可复用对象特效、攻击判定、地面Assets/Animation/Animator Controller 和动画片段Assets/ScriptableObjects/角色属性、连段表、技能配置如果源码里没把角色数值写死而是用 ScriptableObject 加载说明作者有扩展意识。常见的做法是给每个角色建一个FighterData资产里面挂移动速度、攻击力、硬直时间、血量等。这样换皮时不用改代码只改资产。[CreateAssetMenu(fileName FighterData, menuName 格斗游戏/角色数据)] public class FighterData : ScriptableObject { public string fighterName; public float moveSpeed 6f; public float jumpPower 12f; public int maxHp 100; public float hitStun 0.25f; public float hitPushback 3f; public AttackData[] attackList; }这段代码定义了一个基础的角色数据资产。moveSpeed直接参与人物移动插值hitStun决定被打后不能操作的时间attackList是一个攻击数据数组可以在Inspector里逐项配置。好处是策划改数值程序不用重编代码。动画状态机是另一个关键目录。横版格斗的 Animator 会包含Idle、Walk、Jump、Attack1、Attack2、Hit、KnockDown、Block 等状态。源码里如果状态之间的切换只有点击攻击就播Attack1那多半是简化版真正的格斗逻辑需要区分攻击前摇、攻击中和攻击后摇否则连段会非常飘。2.2 用Hierarchy拆解一个火柴人角色的根节点在Unity场景里选中一个火柴人角色Hierarchy层通常长这样PlayerRoot空物体挂 CharacterControllerVisualSprite/模型挂 AnimatorWeaponPoints挂多个空子物体用于刀光特效挂点Hitboxes攻击判定框默认关闭Hurtboxes受击判定框常驻开启PlayerRoot负责移动和碰撞Visual只负责表现。这样拆分的好处是切换动画、换武器、加受击变色都不用动根的Transform。如果源码把Visual做成根节点的直接子级后续换骨骼动画时容易位移错位需要自己在父级做位置修正。2.3 场景移动边界与摄像机跟随的最小配置横版格斗的关卡通常是一块长条形地面左右有墙。常见做法是给摄像机加一个BoxCollider2D或者用脚本来限制orthographicSize和transform.position。using UnityEngine; public class CameraFollow2D : MonoBehaviour { public Transform target; public float smoothTime 0.12f; public Vector2 minPosition; public Vector2 maxPosition; private Vector3 velocity Vector3.zero; void LateUpdate() { if (target null) return; Vector3 targetPos new Vector3( Mathf.Clamp(target.position.x, minPosition.x, maxPosition.x), Mathf.Clamp(target.position.y, minPosition.y, maxPosition.y), transform.position.z ); transform.position Vector3.SmoothDamp(transform.position, targetPos, ref velocity, smoothTime); } }这里smoothTime越大摄像机跟随越滞后。做格斗游戏我会调到0.1以内因为玩家需要看到自己角色的硬直帧太缓慢的镜头会糊掉取消技的时间窗口。minPosition和maxPosition直接限制战斗场地四边界限防止玩家被打到场景外。这个脚本挂在主摄像机上Play前把target拖到角色根节点上。2.4 角色预制体组件表打开任意角色预制体时可以对照下表确认这套源码的完整度组件作用缺失时的表现Rigidbody2D物理模拟无法受击位移、跳跃不落地Collider2DHurtbox承受攻击判定攻击打不到人Animator播放状态机角色僵硬无动画CharacterController移动碰撞穿墙、重叠攻击判定子物体激活时命中敌人拳脚无伤害AudioSource音效被攻击反馈无打击感、游戏节奏感差如果发现Module碰撞体是动态生成的说明源码已经做了判定框池化。这时候不要轻易改碰撞体的 layer格斗源码里攻击方和受击方必须严格按Layer区分否则会出现自己打自己或者穿透。3. 战斗逻辑怎么跑从输入到受击反馈的完整链路3.1 输入系统用旧Input Manager还是新Input System很多旧版源码还在用Input.GetKeyDown(KeyCode.J)这种写法在做demo时很方便但一旦要接控制器、改按键映射、做录制回放就非常痛苦。新开的项目我建议直接迁到 Input System 的PlayerInput组件但如果你只是想先把源码跑通先保留旧的输入逻辑也能玩。using UnityEngine; public class CombatInput : MonoBehaviour { // 旧输入兼容写法 void Update() { if (Input.GetButtonDown(Fire1)) { GetComponentFighterController().TryAttack(0); } if (Input.GetKeyDown(KeyCode.K)) { GetComponentFighterController().TryAttack(1); } if (Input.GetKeyDown(KeyCode.Space)) { GetComponentFighterController().TryJump(); } } }这段脚本只是转发按键不直接处理战斗状态。好处是以后换成新Input System只改这一层不用动FighterController。参数TryAttack(0)里的0代表普攻段数1代表必杀技后续可以更优雅地改成枚举。3.2 连段状态机用Animator参数还是自研状态机横版格斗最容易翻车的点就是连段。很多源码用Animator里的ComboIndex参数控制每按一次攻击键Animator跳到更后面的攻击动画。这做法能跑但会造成一个现象动画播一半时玩家狂按状态被跳过表现上像卡帧。更稳的方案是在C#里自己维护战斗状态Animator只负责播放对应动画。public enum FightState { Idle, Walk, Jump, Attack, HitStun, Down } public class FighterController : MonoBehaviour { public FightState state; public int currentCombo; public void TryAttack(int attackSlot) { if (state FightState.HitStun || state FightState.Down) return; if (state ! FightState.Attack) { currentCombo 0; } else if (currentCombo 2 Time.time - lastAttackTime comboWindow) { currentCombo; } else { return; } state FightState.Attack; lastAttackTime Time.time; animator.SetInteger(Combo, currentCombo); animator.SetTrigger(Attack); } }关键参数是comboWindow也就是上一段攻击动画的可取消时间窗口。这个窗口必须大于攻击动画前摇中段但小于后摇。设置到0.4~0.6秒比较稳妥具体要看动画长度。state FightState.Attack时再次输入会判断是否进入下一段如果超时则直接重置。如果源码里没有lastAttackTime这个字段说明连段是纯靠动画事件实现的修改时要小心动画剪辑的帧事件位置。3.3 判定框生成用Trigger还是射线攻击判定是横版格斗源码中最容易写脏的部分。两种主流实现一种是给拳脚挂一个Collider2D攻击时开启一帧并监测OnTriggerEnter2D另一种是攻击瞬间用Physics2D.BoxCast或OverlapBox。项目场景里如果角色有武器或者长手用OverlapBoxAll更可控。using UnityEngine; public class HitboxController : MonoBehaviour { public Vector2 boxSize new Vector2(1f, 1f); public float attackRange 2f; public LayerMask enemyLayer; // 由动画事件调用 public void ActivateHitbox() { Vector2 origin transform.position transform.right * attackRange; Collider2D[] hits Physics2D.OverlapBoxAll(origin, boxSize, 0f, enemyLayer); foreach (Collider2D hit in hits) { FighterController enemy hit.GetComponentInParentFighterController(); if (enemy ! null) { enemy.TakeHit(attackDamage, pushDir); } } } }ActivateHitbox通过动画事件触发而不是在Update里每帧判断这样判定帧与动画帧完全对齐。attackRange控制攻击伸出去的距离boxSize的宽度控制横向范围。做横版格斗时enemyLayer一定要过滤本地玩家自己否则多人在场时会造成误伤。这个方案比OnTriggerEnter2D好在主动发一次检测不需要依赖物理系统的自动碰撞回调也不会因为事件触发次序不同而漏判。3.4 受击硬直与伤害数字的UI表现受击反馈不只是扣血条还要有硬直、后退和闪白。在TakeHit实现里硬直期间要让FighterController进入HitStun状态并立刻停掉角色的攻击动作。public void TakeHit(int damage, Vector2 pushDir) { if (state FightState.Invincible || isBlocking) return; currentHp - damage; state FightState.HitStun; animator.SetTrigger(Hit); rb.velocity pushDir * hitPushback; // 伤害数字生成 DamagePopup popup ObjectPooler.Spawn(DamagePopup, transform.position Vector3.up * 2f); popup.SetText(damage.ToString()); popup.SetColor(FighterColor); }isBlocking是防御状态横版格斗中一般用Back Attack触发。PushDir的方向要依据攻击者的朝向不能固定向左或向右。伤害数字建议用对象池生成否则一场战斗打下来会产生几十个瞬时GameObject每次实例化和销毁都会触发GC。源码里如果每段攻击都Instantiate一个数字后面打Boss时会明显掉帧。4. 把Super Dragon做出来必杀技、Boss战与特效管理4.1 必杀技的资源加载与CD控制Super Dragon这种名字暗示会有一个大招角色。必杀技在横版格斗里通常会有超长的动画、屏幕震动和全屏特效。实现时不要把这些表现逻辑都塞进角色脚本里否则代码会爆炸。使用一个独立的技能播报系统通过ScriptableObject缓存攻击参数和资源引用。[System.Serializable] public class SuperSkillData { public string skillName; public float cooldown; public float activeTime; public float damageMultiplier; public GameObject effectPrefab; public AudioClip launchSound; public bool isProjectile; } public class SuperSkillController : MonoBehaviour { public SuperSkillData skillData; private float lastUseTime -999f; public bool CanUse() { return Time.time - lastUseTime skillData.cooldown; } public void Execute() { if (!CanUse()) return; lastUseTime Time.time; animator.SetTrigger(Super); if (skillData.effectPrefab ! null) { var effect ObjectPooler.Spawn(skillData.effectPrefab, transform.position); effect.GetComponentAnimator().SetFloat(Direction, Mathf.Sign(transform.localScale.x)); } } }lastUseTime初始值必须足够小否则游戏开场不能立即放技能。Direction参数用来让特效正确翻转因为角色的localScale.x在左右朝向时是1或-1。必杀技的冷却时间建议放在SkillData资产上不要在代码里写死这样调平衡时不用重新编译。4.2 Boss AI的有限状态机设计如果源码里有Boss关卡Boss的AI动作通常是一套有限状态机FSM。Boss会轮流走Idle、Chase、Attack、Recover几个状态。如果项目状态特别粗糙可能会用一堆if判断距离和血量来决定出招这种代码扩展性很差。public enum BossState { Idle, WalkTowardsPlayer, MeleeAttack, RangeAttack, Stunned } public class BossAI : MonoBehaviour { public BossState currentState; private Transform player; public float attackDistance 2f; public float skillDistance 6f; void Update() { float dist Vector2.Distance(transform.position, player.position); switch (currentState) { case BossState.Idle: if (dist 8f) currentState BossState.WalkTowardsPlayer; break; case BossState.WalkTowardsPlayer: MoveToward(player.position); if (dist attackDistance) SetState(BossState.MeleeAttack); else if (dist skillDistance Random.value 0.1f) SetState(BossState.RangeAttack); break; } } void SetState(BossState newState) { if (currentState newState) return; ExitState(currentState); currentState newState; EnterState(newState); } }注意SetState里的进入/退出方法Boss切换状态时可以先做收手动作再接新技能。假如之前是MeleeAttack退出时要把攻击判定框关闭否则新状态刚进入时上一次的判定框还在玩家会莫名其妙被打中。Boss AI最好不要每帧直接改变速度而是通过MoveToward以加速度方式逼近这样移动更平滑。4.3 特效池与对象池避免GC卡顿横版格斗里刀光、爆炸、尘土、受击火花都是高频生成对象。如果全部用Instantiate即使只有5个特效也会造成卡顿。常见做法是做一套通用对象池专门回收特效和伤害数字。using System.Collections.Generic; using UnityEngine; public class ObjectPooler : MonoBehaviour { public static ObjectPooler Instance; [System.Serializable] public class Pool { public string tag; public GameObject prefab; public int size; } public ListPool pools; private Dictionarystring, QueueGameObject poolDict; void Awake() { Instance this; poolDict new Dictionarystring, QueueGameObject(); foreach (Pool pool in pools) { QueueGameObject q new QueueGameObject(); for (int i 0; i pool.size; i) { GameObject obj Instantiate(pool.prefab); obj.SetActive(false); q.Enqueue(obj); } poolDict.Add(pool.tag, q); } } public static GameObject Spawn(string tag, Vector3 position) { if (!Instance.poolDict.ContainsKey(tag)) return null; GameObject obj Instance.poolDict[tag].Dequeue(); obj.transform.position position; obj.SetActive(true); Instance.poolDict[tag].Enqueue(obj); return obj; } }这里Spawn方法直接把异动的队列重置到尾部队列省去了Deactivate操作。特效对象要自己约定一个生命期比如ParticleSystem播放完自动关闭或者在代码里Invoke(Deactivate, 1f)。如果源码里的特效没有自动禁用脚本池子很快会变成全活跃状态再次取出时会出现特效闪烁。解决方法是给所有特效挂一个AutoDeactivate组件它在粒子播放完毕或者动画完成后把自己SetActive(false)。4.4 阴影问题与地面贴合做火柴人横版格斗时阴影往往是被人忽略的细节。如果角色是Sprite直接加Unity内置的Shadow会产生影子跟在身后的便宜效果。更稳的做法是使用一个独立的阴影Sprite对象固定在地面上方并跟随角色水平移动。using UnityEngine; public class GroundShadow : MonoBehaviour { public Transform characterRoot; public Transform shadowVisual; public float groundOffset 0.02f; void LateUpdate() { Vector3 pos characterRoot.position; pos.y groundOffset; pos.z 0.1f; shadowVisual.position pos; // 根据角色跳跃高度缩放影子 float height characterRoot.position.y - groundOffset; float scale Mathf.Clamp(1f - height * 0.05f, 0.4f, 1f); shadowVisual.localScale new Vector3(scale, scale, 1f); } }这个脚本会实时把阴影贴到地面上高度越高阴影越小。如果角色有Rigidbody2D不要用transform.position直接修改根节点物理系统会在物理帧重新修正位置。这里只修改shadowVisual而保留characterRoot不动是一种规避物理干扰的办法。如果项目场景地面是起伏地形上述方案会失效这时候更合理的是放置多个阴影吸附点或者用Physics2D.Raycast向下求交点。5. 修改和扩展前要做的三件事备份、哈希与性能基线5.1 用Git LFS管理场景与动画Unity项目的.unity场景文件和.anim动画文件是文本格式但一旦涉及Prefab变体、光照烘焙还是会膨胀严重。给源码仓库加上 Google Git LFS 是大文件管理的第一步。没有LFS时多个美术协作会发生场景冲突解决起来特别痛苦。git lfs track *.unity git lfs track *.anim git lfs track *.fbx git lfs track *.prefab git add .gitattributes git commit -m 配置LFS跟踪Unity资源这里必须连.prefab也跟踪因为预制体包含嵌套的动画引用和Sprite序列帧。如果不加跟踪大仓库克隆时会出现下载卡死或者UnityPackageManager无法加载部分资源。提交前用git lfs migrate重写历史需要注意如果原仓库已经非常大重写所有历史会让团队其他人的本地仓库失效需要提前同步。5.2 给关键战斗参数做一个ScriptableObject配置表源码里的角色数值往往散落在多个脚本的 public 字段上。想调新角色强度就得打开十多个MonoBehaviour去改。一套干净的方案是把所有角色数值收敛到配置表里然后让角色预制体在Awake时自动读取。using UnityEngine; public class FighterConfigurer : MonoBehaviour { public FighterData data; private FighterController controller; private HealthController hp; void Awake() { controller GetComponentFighterController(); hp GetComponentHealthController(); controller.moveSpeed data.moveSpeed; controller.jumpPower data.jumpPower; controller.attackList data.attackList; hp.maxHp data.maxHp; hp.currentHp data.maxHp; } }这个FighterConfigurer只做数据搬运不写任何业务逻辑。好处是在编辑器里预览新角色时直接替换data资产就能看到变化打包后也方便用字符串路径动态加载不同角色。调平衡时的操作路径变成打开角色数据资产 → 修改moveSpeed→ 保存 → 回到场景点Play验证。这套流程比改代码友好很多。5.3 用Profile校验掉帧点游戏跑起来如果觉得打击感不够爽或是放大招卡顿不要靠肉眼猜。Unity 的 Profiler 窗口里看CPU Usage和Rendering Profiler是关键操作。聚焦到战斗场景时重点检查三个模块模块异常表现可能原因Physics.Process峰值超过5msOverlapBox调用过多或碰撞体数量爆炸ParticleSystem占比高粒子没有池化战斗中不断创建销毁GC.Alloc堆分配快速增长字符串拼接、GetComponent频繁调用如果Physics.Process异常先查攻击判定是不是每帧都在检测而不是通过动画事件触发。如果粒子是单独创建的立刻把发射特效换成对象池中的预制体。做完这些再跑一轮用Profiler导出前后对比数据就能量化优化成果。热词里有unity阴影问题如果你开了实时阴影但地面是无限重复Sprite那阴影计算的消耗也不小可以改成上面说的独立阴影Sprite方案。5.4 最后的技巧用自订Editor工具批量调连段间隔横版格斗的手感很大程度上是攻击动画之间的可取消间隔决定的。每次改一个角色的comboWindow就要打开脚本改数字效率太低。我给你一个常见的做法写一个Editor窗口批量扫描所有FighterData资产统一调整攻击间隔偏移。using UnityEditor; using UnityEngine; public class ComboWindowTool : EditorWindow { private float offsetValue 0f; [MenuItem(Tools/格斗/批量调整连段窗口)] public static void ShowWindow() { GetWindowComboWindowTool(Combo Window Tool); } void OnGUI() { GUILayout.Label(对选中角色资产统一添加间隔偏移); offsetValue EditorGUILayout.FloatField(Interval Offset, offsetValue); if (GUILayout.Button(应用到所有FighterData)) { string[] guids AssetDatabase.FindAssets(t:FighterData); foreach (string guid in guids) { string path AssetDatabase.GUIDToAssetPath(guid); FighterData data AssetDatabase.LoadAssetAtPathFighterData(path); SerializedObject so new SerializedObject(data); SerializedProperty combo so.FindProperty(comboWindow); combo.floatValue offsetValue; so.ApplyModifiedProperties(); EditorUtility.SetDirty(data); } AssetDatabase.SaveAssets(); } } }这个工具遍历项目中所有FighterData资产将comboWindow统一加上一个偏移值。AssetDatabase.FindAssets(t:FighterData)能自动找到所有该类型资产不用手动拖拽。SetDirty确保Prefab 或资产保存修改。上线前调整个整体手感时先用这个工具把所有角色的连段间隔拉长0.02秒然后试玩一局反馈往往比逐个人物微调更快。阴影、伤害数字、特效池和状态机都理顺了再做一套类似的新技能或者新Boss只是数据量和状态枚举的叠加不会再回头改骨架。本文还有配套的精品资源点击获取