简介:这个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(空物体,挂 CharacterController)Visual(Sprite/模型,挂 Animator)WeaponPoints(挂多个空子物体,用于刀光特效挂点)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 | 物理模拟 | 无法受击位移、跳跃不落地 |
| Collider2D(Hurtbox) | 承受攻击判定 | 攻击打不到人 |
| 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")) { GetComponent<FighterController>().TryAttack(0); } if (Input.GetKeyDown(KeyCode.K)) { GetComponent<FighterController>().TryAttack(1); } if (Input.GetKeyDown(KeyCode.Space)) { GetComponent<FighterController>().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.GetComponentInParent<FighterController>(); 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.GetComponent<Animator>().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 List<Pool> pools; private Dictionary<string, Queue<GameObject>> poolDict; void Awake() { Instance = this; poolDict = new Dictionary<string, Queue<GameObject>>(); foreach (Pool pool in pools) { Queue<GameObject> q = new Queue<GameObject>(); 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 = GetComponent<FighterController>(); hp = GetComponent<HealthController>(); 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 | 峰值超过5ms | OverlapBox调用过多或碰撞体数量爆炸 |
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() { GetWindow<ComboWindowTool>("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.LoadAssetAtPath<FighterData>(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,只是数据量和状态枚举的叠加,不会再回头改骨架。
本文还有配套的精品资源,点击获取