
简介这是一份Unity火柴人横版格斗游戏《Stickman Warriors - Super Dragon》的完整项目源码面向Unity开发者与游戏设计学习者可作为横版格斗玩法、技能养成及关卡系统的实战参考。支持Unity 2018.4.18f1及以上版本包含C#控制脚本、场景与资源文件、材质、音频、贴图、着色器及预制体等共2000个文件压缩包约581MB工程结构完整便于按模块查看与二次开发。资源涵盖大量核心玩法内容大范围组合攻击、装备升级、技能多级强化、影子Boss战、超级赛亚人愤怒变身、异星场景及8个终极恶魔Boss并内置100多个难度递增的关卡与游戏内黑市交易系统。目前已有494人学习下载适合希望系统了解Unity格斗游戏项目组织、战斗逻辑与资源管理方式的开发者深入研读。1. 火柴人格斗项目的技术骨架与复刻路径横版格斗是游戏开发里最“吃手感”的品类之一而“火柴人”这个极简形象恰恰把技术挑战从美术资源转移到了逻辑实现上。没有复杂骨骼贴图和三渲二特效玩家注意力完全集中在帧率、输入响应、判定框和动作衔接上。正因如此用 Unity 做一个火柴人横版格斗项目是检验一个开发者对动画状态机、物理交互和场景管理理解深度的最佳试金石也是面试作品集中性价比极高的选择。本篇文章要拆解的不是某个具体商业项目的内部代码而是围绕“Unity 火柴人横版格斗项目源码”这类项目最常见的工程结构和实现方案展开。我会从动画状态机的搭建、输入缓冲与连击判定、基于 Transform 和物理的混合移动方案、Hitbox 命中的几种实现以及项目后期如何做性能基线这几个层面把一条从零到可玩 demo 的路径完整讲清楚。无论你是拿它做毕业设计、面试作品集还是想从中学习格斗游戏的通用架构这篇文章都能给你一份可以直接照着落的方案。2. 动画状态机设计火柴人动作切换的核心逻辑与 Unity 参数绑定2.1 为什么格斗项目几乎都选 Animator Controller 而非纯代码控制火柴人横版格斗的动作数量虽然比不了复杂 3D 游戏但“站立、走路、冲刺、轻击、重击、跳跃、受击、倒地”这些状态加在一起如果直接用代码控制 Sprite 切换会陷入大量的 if-else 嵌套而且无法处理“轻击被重击打断”“跳跃中受击”这种优先级交叉的情况。Animator Controller 的好处在于把状态转换图可视化谁可以打断谁一目了然而且转换条件的判断由 Animator 每帧自动评估不需要在 Update 里写状态枚举分支。常见做法是给角色挂一个 Animator 组件把每个动作做成单独的 Animation Clip然后在 Animator 面板里建立状态节点。这里的关键点是状态机的参数类型要选对。对于格斗游戏最常用的参数只有三种——Bool如 isGrounded、Float如 speedX、Trigger如 attackTrigger。不要用 Int 去表示“当前在哪个招式”因为 Triggle 自带的“触发一次后自动复位”特性比手动 manage bool 状态要安全得多。// 在 Animator 中设置触发参数的标准写法 private Animator anim; void Start() { anim GetComponentAnimator(); } void Update() { float horizontal Input.GetAxisRaw(Horizontal); anim.SetFloat(speedX, Mathf.Abs(horizontal)); if (Input.GetButtonDown(Jump) isGrounded) { anim.SetTrigger(jump); } if (Input.GetButtonDown(Fire1)) { anim.SetTrigger(lightAttack); } }这段代码的逻辑是speedX 用绝对值驱动“走路/停驻”的混合树节点切换跳转和攻击用 Trigger 驱动一次性动作。要注意的是SetTrigger有个经典坑——如果在同一个帧里连续调用两次第二次会被忽略因为 Trigger 只保留一个挂起信号。解决方法是引入输入缓冲队列这个在第三章详细说。2.2 用 2D 骨骼还是逐帧序列帧火柴人项目的两难选择火柴人角色有两种主流实现路线第一种是 Spine 或 Unity 自带的 2D Animation 包做骨骼绑定把“头、躯干、四肢”做成独立骨头通过旋转和缩放摆出各种姿势第二种是美术直接导出逐帧 Sprite 序列类似拳皇的赛璐璐风格。对于火柴人来说我的建议是——如果你要的是“源码可学”选序列帧如果要的是“效率高好调整”选 2D 骨骼。序列帧的好处是动画本身是确定性的不存在骨骼插值导致的姿势穿模而且 Sprite 的碰撞体可以直接附着在图片上Hitbox 的尺寸能精确配合拳脚伸出的那一帧。缺点是文件量大而且如果你想调整某一次攻击的“帧数快慢”得回去改美术资源不能像骨骼动画那样单独拉一条旋转关键帧。// 使用 SpriteRenderer 配合 Animator 自动播放序列帧无需额外代码 // 核心是 Animator 的 Animation Clip 中每一帧设置 Sprite 属性 // 参数说明 // - spriteRenderer.sprite当前帧显示的精灵图 // - AnimationClip 的帧率建议设为 12fps符合火柴人打击感的常用节奏如果你决定用 2D 骨骼方案Unity 的 2D Animation 包需要安装com.unity.2d.animation然后用 Skinning Editor 给角色图片切分骨骼。这里要特别注意骨骼绑定只影响视觉表现不影响物理碰撞。也就是说角色拳头的伤害判定还是要靠单独的脚本控制不能用骨骼位置直接代 Hitbox 位置否则换皮改骨骼就会破坏战斗数值。2.3 格斗动画切换的三个隐藏参数过渡时长、Can Transition To、Animation Speed很多新手做火柴人格斗时调完动画就直接跑了结果发现攻击动作切换生硬得没法看。其实是 Animator 的过渡设置没调好。三个最关键的隐藏参数过渡时长Transition Duration拳击类动作建议 0.01 秒到 0.05 秒之间超过 0.1 秒就会出现拖泥带水的感觉。但“击倒倒地”这种动作反而要 0.1 到 0.2 秒的过渡模拟失重倒地的效果。Can Transition To 是有方向性的你允许“攻击 → 受击”打断但不要让“受击”自动接“攻击”否则角色会一边挨打一边出拳。在 Animator 面板里选择受击 → 攻击这条线把 “Has Exit Time” 勾掉并把 Can Transition To Self 关掉。这样受击动画至少播放完一个循环再回到待机态。Animation Speed 的坑更多。unity 的 Animator 用anim.speed来控制全局播放速率但这会连累受击动画和走路动画一起变快。正确做法是给每个 Animation Clip 单独设 Speed Multiplier然后代码里只改 Multiplier 参数。// 用 Animator 的 SetFloat 控制单个动画的播放速度 // 示例重击比轻击慢 0.8 倍但走路动画不受影响 anim.SetFloat(heavyAttackSpeedMultiplier, 0.8f); anim.SetFloat(lightAttackSpeedMultiplier, 1.2f);参数说明SetFloat的 key 必须在 Animator Controller 的 Parameters 面板里提前声明并且在 Animation Clip 的 Inspector 中勾选 “Apply Override” 确认该参数被应用到这条动画。否则这个 float 值传进去动画也不会变速。2.4 用 OnStateEnter 和 OnStateExit 解决动作与逻辑不同步的老问题动画系统跟战斗逻辑最大的矛盾是动画是视觉表现攻击判定是数据表现两者处于不同的循环频率。你不能在 Update 里每帧去检测“如果当前正在播放攻击动画第 5 帧则开启碰撞体”因为动画帧率不稳定且 Animator 的帧和代码的 Update 不在一个时间轴上。可靠的做法是使用 StateMachineBehaviour 的子类在动画状态机进入特定状态的瞬间通知战斗系统启动判定。比如一个名为AttackStateBehaviour的组件继承自 StateMachineBehaviour重写OnStateEnter函数在其中给武器挂载点开启碰撞体并在OnStateExit关闭它。public class AttackStateBehaviour : StateMachineBehaviour { public HitboxType hitboxType; public float startTime 0.1f; // 动画开始后多少秒启动判定 public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // 获取角色身上的战斗逻辑脚本 var fighter animator.GetComponentFighter(); if (fighter ! null) { fighter.EnableHitbox(hitboxType); } } public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { var fighter animator.GetComponentFighter(); if (fighter ! null) { fighter.DisableHitbox(hitboxType); } } }这段代码的意义在于把伤害判定和动画播放彻底解耦。无论动画是正常播完被打断还是被新的攻击顶掉OnStateExit 都会被调用因此不会出现“动画已经切走了但碰撞体还开着”的穿帮情况。这是很多半成品火柴人格斗项目手感塑料的根源——碰撞体没有和动画状态绑定牢固。3. 横版格斗移动与碰撞从 Transform 位移到 Rigidbody2D 的混合方案3.1 横版格斗该用哪套移动系统物理还是非物理Unity 横版游戏移动有两条主路线用Transform.Translate直接改坐标或者用Rigidbody2D.velocity设置刚体速度。对格斗项目来说单纯的 Transform 方案控制感强起步、急停都很即时但缺点是没有物理反馈——被攻击击飞时无法自然产生受击位移。反过来纯物理方案能完美模拟受击击退、浮空、倒地摩擦但 Walking 时物理会导致角色打滑而且和 Animator 配合时速度曲线不平滑。火柴人横版格斗项目的成熟做法是“主控移动用 Transform 强制物理效果用 Rigidbody2D”。也就是说角色日常的左右移动、跳跃直接通过 Transform 的增量位移来驱动而收到攻击后的击退、浮空则通过临时给 Rigidbody2D 添加AddForce或直接设置velocity完成然后定时恢复。这个方案的好处是手感可控不需要为了物理效果牺牲操作精度。// 混合移动方案正常移动用 Transform受击位移用物理 public class FighterMovement : MonoBehaviour { public float moveSpeed 6f; public float jumpForce 12f; public Rigidbody2D rb; public Transform bodyTransform; [SerializeField] private bool isPhysicsControlled false; private float physicsControlTimer 0f; void Update() { if (isPhysicsControlled) { physicsControlTimer - Time.deltaTime; if (physicsControlTimer 0f) { isPhysicsControlled false; rb.velocity Vector2.zero; } return; // 物理接管期间不响应玩家输入 } float horizontal Input.GetAxisRaw(Horizontal); bodyTransform.Translate(Vector2.right * horizontal * moveSpeed * Time.deltaTime); } public void ApplyKnockback(Vector2 force, float duration) { isPhysicsControlled true; physicsControlTimer duration; rb.velocity Vector2.zero; rb.AddForce(force, ForceMode2D.Impulse); } }参数说明moveSpeed控制地面移动速度6 这个数值对于火柴人横版格斗来说大约是每秒移动 6 个 Unity 单位和角色身高约 2 个单位相比移动范围适中jumpForce拉动量大小12 能让角色跳到约 3.6 个单位高度足够越过对手头顶完成跳跃攻击physicsControlTimer表示物理接管时长通常受击击退持续 0.2 到 0.4 秒。如果用完直接清零速度会有急停的顿挫感建议用一个简单协程平滑衰减。3.2 Collider2D 的设置参数火柴人为什么不建议用复合碰撞体很多从 RPG 项目转过来的开发者喜欢给角色挂一个 BoxCollider2D 做身体另挂两个 CircleCollider2D 做拳头和脚。但对格斗游戏来说这种复合碰撞体是性能灾难的开始。每个 Collider2D 都要参与物理运算而且当场景有多个角色、武器飞行物、场景碰撞时碰撞配对数量会平方级增长。正确的策略是身体用单个 BoxCollider2D并且把 Size 控制在角色实际受击面的 80% 左右。为什么不是 100%因为在格斗游戏里被判定“击中”的感觉不只是物理上的接触还有一种基于视觉的宽容度。如果你的身体碰撞体完全贴合图片玩家会觉得“明明差了一点却被打中了”这让操作精准度大幅下降。反过来把攻击方拳头判定做窄一点把受击方身体判定放宽一点手感会好得多。这个原理类似在线游戏中的“延迟补偿”只不过这里补偿的是人类的视觉误差。物理材质方面火柴人角色必须用摩擦力为 0 的 PhysicsMaterial2D否则按下左右方向键时角色会因为残余摩擦力出现制动延迟。在 Project 窗口创建Frictionless材质设置 Friction 0Bounciness 0然后拖到角色的 Collider2D 的 Material 槽中。3.3 摄像机跟随与边界锁定横向卷轴格斗的镜头协议横版格斗和横版过关最大的镜头差异是格斗游戏要求两个角色始终在画面内不能出现一方跑到屏幕外攻击另一方的场景。常见做法是使用一个CameraController脚本锁定一个“关注矩形区域”摄像机只在这个区域内移动。public class CameraFollow : MonoBehaviour { public Transform player1; public Transform player2; public float minX -5f; public float maxX 5f; public float minY -2f; public float maxY 2f; public float smoothTime 0.15f; private Vector3 velocity Vector3.zero; void LateUpdate() { // 取两个角色的中点作为镜头关注目标 Vector3 middlePoint (player1.position player2.position) / 2f; // 限制两个角色之间的距离防止一方跑到视野外 float distance Vector2.Distance(player1.position, player2.position); if (distance 8f) { // 如果太远压缩中间点向强者方向偏移简化处理 } float targetX Mathf.Clamp(middlePoint.x, minX, maxX); float targetY Mathf.Clamp(middlePoint.y, minY, maxY); Vector3 targetPosition new Vector3(targetX, targetY, transform.position.z); transform.position Vector3.SmoothDamp(transform.position, targetPosition, ref velocity, smoothTime); } }这段代码用SmoothDamp来实现带阻尼的镜头跟随smoothTime 0.15意味着镜头追焦动作稍有延迟让玩家视线不至于被镜头拖动搞晕。minX和maxX的锁定需要根据你场景的实际尺寸来设常见做法是在 Start 方法里读取场景边界的EdgeCollider2D位置自动计算。minY和maxY锁定地平线上下各 2 个单位避免角色跳起后镜头也跟着上移幅度过大导致下方露出场景外。4. 战斗系统核心输入缓存、Hitbox 判定与连击状态机4.1 输入缓冲机制为什么你按了攻击键角色没反应格斗游戏手感最大的敌人是“输入丢失”。玩家在攻击动作后摇即将结束的最后一帧按下下一招你的代码如果在这个帧没有检测到按键那玩家的这次输入就被吞了。专业格斗项目的标准解法是输入缓冲区用一个长度为 8 到 12 帧的队列记录玩家的按键动作当角色状态机进入可接受新输入的窗口时从队列里弹出最早的有效输入。public class InputBuffer { private QueueBufferedInput buffer new QueueBufferedInput(); private int bufferWindow 8; // 允许缓冲 8 帧 public void RegisterInput(string actionName) { buffer.Enqueue(new BufferedInput(actionName, Time.frameCount)); if (buffer.Count bufferWindow) { buffer.Dequeue(); // 超过窗口丢弃最旧输入 } } public bool TryGetAction(out string actionName) { while (buffer.Count 0) { var input buffer.Peek(); if (Time.frameCount - input.frame bufferWindow) { actionName input.actionName; buffer.Dequeue(); return true; } buffer.Dequeue(); } actionName null; return false; } } public class BufferedInput { public string actionName; public int frame; public BufferedInput(string actionName, int frame) { this.actionName actionName; this.frame frame; } }注意这段代码里“帧”用的是Time.frameCount它代表游戏逻辑运行的累计帧数而不是真实时间。为什么不用Time.time因为格斗游戏的输入判定通常以“逻辑帧”为单位和渲染帧率无关。在低帧率环境中比如 30FPS 下bufferWindow 8表示 0.27 秒的输入窗口这个数值能让连续输入连贯又不至于误触发。4.2 Hitbox 的三种实现方式选型格斗游戏的命中判定Hitbox在 Unity 里有三种常见实现方式各有优缺点第一种是直接挂 Collider2D 碰撞检测。把武器的碰撞体绑定在攻击动画中的某个时间点开启然后通过OnTriggerEnter2D判断是否碰到了对手的 Hurtbox。这是最简单的方式适合新手缺点是碰撞回调在物理线程中触发可能和动画状态机的状态不同步导致“人都站起来了还提示攻击命中”。第二种是物理射线检测Raycast在攻击判定帧向前发射一条射线或者扇形区域检测。这个方案速度快没有物理引擎的配对开销适合做飞行道具、剑气等直线攻击。缺点是无法精确匹配复杂挥击轨迹比如上勾拳的弧线。第三种是数学几何判定把攻击范围抽象成圆形、矩形或胶囊形每帧检测它与对手身体碰撞体的包围盒是否相交。这是专业格斗游戏如《街头霸王》系列采用的方式计算开销极小而且判定结果可以精确到帧级和动画状态机的配合最稳定。public class HitboxDetector { public static bool CheckCircleHit(RectTransform attacker, RectTransform target, float radius) { Vector2 attackerPos attacker.position; Vector2 targetPos target.position; float distance Vector2.Distance(attackerPos, targetPos); return distance radius target.rect.width / 2f; } }参数说明radius是攻击点的半径建议取值 1.0 到 1.5 之间对应火柴人手臂完全伸长的距离target.rect.width / 2f是受击方身材宽度的一半用来近似受击边界。这种近似的精度在手机平台和低端 PC 上已经足够且性能损耗几乎可以忽略不计。4.3 格斗伤害系统的数值模型硬直、霸体与伤害衰减火柴人格斗项目的伤害数值模型直接决定了游戏深度。一个成熟的伤害系统至少包含四个参数伤害值Damage、硬直时间Hit Stun、格挡值Block Value和连击衰减Combo Scaling。伤害值决定扣血量硬直时间决定被击后角色多少秒不能行动格挡值决定格挡后还能承受多少攻击。连击衰减是让一套连招不至于一套带走的伤害百分比的递减系数。常见设定是一套轻中重三连击伤害比例大约 1.0 : 0.8 : 0.6。第一个攻击造成全额伤害第二击只造成第一击的 80%第三击再乘以 0.75。衰减的计算需要在每次命中时对攻击方做独立记录而不是修改攻击方的攻击力属性否则会污染后续单发攻击的伤害。用代码实现时建议把连击数存储在一个ComboCounter组件中在玩家成功命中对手时递增在玩家自己被击、或超过 2 秒没有继续输入时清零。public class ComboSystem : MonoBehaviour { private int comboCount 0; private float comboTimer 0f; public float comboWindow 2f; void Update() { if (comboTimer 0f) { comboTimer - Time.deltaTime; if (comboTimer 0f) comboCount 0; } } public float GetDamageMultiplier() { float baseMultiplier Mathf.Pow(0.8f, comboCount); return Mathf.Max(baseMultiplier, 0.4f); // 最低保留 40% 伤害 } public void RegisterHit() { comboCount; comboTimer comboWindow; } }这段代码中Mathf.Pow(0.8f, comboCount)用幂函数实现了指数的衰减第一次命中倍率是 1.0第二次 0.8第三次 0.64以此类推。comboWindow 2f表示每次命中后如果 2 秒内没有下一次命中连击计数归零。这个时间窗口同样要配合全局Time.timeScale来适应慢动作或子弹时间效果——如果游戏里做了“最后一击慢放”2 秒不能按真实时间算。4.4 必杀技与特殊技能的冷却管理火柴人格斗项目中的必杀技通常需要在代码层面给每个技能配置一个SkillConfigScriptableObject包含技能名、伤害倍率、冷却时间、是否消耗能量、施展时的动画层遮罩。用 ScriptableObject 的好处是策划可以直接在 Inspector 里调节数值而不需要重新编译代码这是项目源码里最容易体现工程水平的部分。[CreateAssetMenu(fileName NewSkill, menuName Fighter/SkillConfig)] public class SkillConfig : ScriptableObject { public string skillName; public float damageMultiplier 1.5f; public float cooldownSeconds 5f; public bool consumeEnergy true; public float energyCost 30f; public AnimationClip attackAnimation; public float hitboxRadius 1.8f; }使用 ScriptableObject 配置技能的好处是每个脚本实例都共享同一个配置对象不会出现“复制了技能脚本后调了几个技能数值导致其他技能也变”的问题。同时你在 Animator Controller 中创建对应的 Skill 状态节点时可以直接把 SkillConfig 中的attackAnimation赋值给 Animation Clip实现数据和逻辑的彻底分离。5. 工程架构从单场景原型到可扩展的格斗游戏框架5.1 场景管理如何安排菜单、对战和结算场景火柴人横版格斗项目源码中工程结构往往比单片代码更能体现作者水准。一个合理的场景划分是一个 Bootstrap 启动场景用于加载管理器、一个主菜单场景、一个角色选择场景、一个对战场景、一个结算场景。如果你是拿别人的源码学习第一步绝对不是看战斗代码而是先看项目管理器里有哪些全局单例以及场景是怎么切换的。Unity 的场景加载有两种方式SceneManager.LoadScene的同步加载和SceneManager.LoadSceneAsync的异步加载。格斗项目对场景切换速度有要求强烈建议用异步加载配合加载进度条。在 Bootstrap 场景里挂一个GameManager用DontDestroyOnLoad保持单例跨场景存活这样玩家在对战场景中保存的按键设置、音量配置、战绩统计都不会丢失。5.2 管理器划分InputManager、AudioManager、UIManager 的职责边界很多初学者把输入检测直接写在角色脚本的 Update 里这在单角色原型中没问题但一旦需要支持双人同屏、手柄和键盘混用就会陷入逻辑混乱。常见做法是单独做一个InputManager负责收集所有玩家的输入并把输入数据格式化为统一的事件分发给各个角色。public class InputManager : MonoBehaviour { public static InputManager Instance; private Dictionaryint, PlayerInputData playerInputs new Dictionaryint, PlayerInputData(); void Awake() { if (Instance null) Instance this; else Destroy(gameObject); } void Update() { // Player 1 使用 WASD 键盘攻击 playerInputs[0] new PlayerInputData( Input.GetAxisRaw(Horizontal), Input.GetButtonDown(Jump), Input.GetButtonDown(Fire1), Input.GetButtonDown(Fire2) ); // Player 2 使用方向键 小键盘攻击 playerInputs[1] new PlayerInputData( Input.GetAxisRaw(Horizontal2), Input.GetButtonDown(Jump2), Input.GetButtonDown(Fire3), Input.GetButtonDown(Fire4) ); } public PlayerInputData GetInput(int playerIndex) { return playerInputs.ContainsKey(playerIndex) ? playerInputs[playerIndex] : default; } }这段代码用playerInputs字典保存每帧所有玩家的输入状态GetInput对外提供只读访问。这样做的好处不只是便于支持双人同屏更重要的是输入检测和游戏逻辑在时间和线程上彻底分离。将来要做回放功能、AI 模拟输入时只需要把 PlayerInputData 的序列化数据流替换成 AI 决策产生的事件流角色行为不会受影响。5.3 对象池如何管理攻击特效、数字飘字和倒地灰尘火柴人格斗对性能要求很高的一个点在于特效实例化。每一次攻击命中可能同时产生火花粒子、伤害飘字、倒地扬尘、音效触发器。如果每次都用Instantiate和Destroy创建销毁频繁的 GC 会卡帧这在低端安卓手机上非常致命。解决方案是用对象池预先实例化 30 到 50 个特效复制体隐藏起来需要时从池中取出激活用完再归还隐藏。public class ObjectPoolT where T : Component { private QueueT pool new QueueT(); private T prefab; private Transform parent; public ObjectPool(T prefab, int initialSize, Transform parent) { this.prefab prefab; this.parent parent; for (int i 0; i initialSize; i) { T obj GameObject.Instantiate(prefab, parent); obj.gameObject.SetActive(false); pool.Enqueue(obj); } } public T Get() { if (pool.Count 0) { T obj GameObject.Instantiate(prefab, parent); pool.Enqueue(obj); } T item pool.Dequeue(); item.gameObject.SetActive(true); return item; } public void Return(T item) { item.gameObject.SetActive(false); pool.Enqueue(item); } }使用这个泛型对象池你需要给每个特效类型如 HitSpark、DamageText、DustParticle各建一个池实例。一个常见错误是不同特效共用一个池导致取出的对象类型不对。更稳妥的做法是给每个特效分类单独初始化池虽然初始占用内存较大但运行时完全不产生垃圾内存和实例化开销。5.4 角色数据的持久化选人、血量、胜场数的存储方案格斗对战结束后玩家的胜场数、角色使用频率、解锁状态通常需要保存。Unity 自带的PlayerPrefs能保存基本数据类型但如果你需要保存复杂的角色解锁进度或设置多个关卡建议直接用 JSON 序列化后存本地文件或使用PlayerPrefs.SetString保存序列化后的 JSON 字符串。[System.Serializable] public class PlayerSaveData { public int totalWins; public int totalLosses; public Liststring unlockedCharacters new Liststring(); public string lastSelectedCharacter Stickman01; } public class SaveSystem : MonoBehaviour { private string savePath; void Awake() { savePath Path.Combine(Application.persistentDataPath, player_save.json); } public void Save(PlayerSaveData data) { string json JsonUtility.ToJson(data, true); File.WriteAllText(savePath, json); } public PlayerSaveData Load() { if (File.Exists(savePath)) { string json File.ReadAllText(savePath); return JsonUtility.FromJsonPlayerSaveData(json); } return new PlayerSaveData(); } }JsonUtility.ToJson(data, true)中的第二个参数prettyPrint为 true 会把 JSON 格式化成可读的多行形式方便手动调试查看。注意JsonUtility不支持 List 之外的泛型集合也不支持 Dictionary 的直接序列化如果你要保存按键设置这种键值对需要先把 Dictionary 转成 List 再存。6. 项目源码排错技巧从 Hitbox 穿模到动画卡帧的快速定位法6.1 用 Gizmos 可视化 Hitbox一场战斗不到 1 分钟找到判定偏移火柴人格斗最常见的 bug 是“明明打到人却没有伤害反馈”。问题大概率出在 Hitbox 的坐标基准和 Sprite 的视觉位置不同步。要在编辑器里快速排查这个问题最有效的方法是重写OnDrawGizmos把攻击判定范围实时画出来并开启 Gizmos 窗口运行游戏观察。public class DebugHitboxDrawer : MonoBehaviour { public float debugRadius 1.5f; public Vector2 debugOffset Vector2.zero; private void OnDrawGizmosSelected() { // 仅在选中该物体时绘制避免场景被大量线框干扰 Gizmos.color Color.red; Vector3 center transform.position (Vector3)debugOffset; Gizmos.DrawWireSphere(center, debugRadius); } }OnDrawGizmosSelected只在你在 Hierarchy 面板里选中这个物体时才绘制如果要一直显示把函数名改成OnDrawGizmos就行。如果发现 Hitbox 的圆心和角色拳头的视觉位置差了一个偏移量直接调debugOffset就能看到位置跟随变化。这个技巧对于排查“跳跃攻击打空中敌人时判定偏低”这类问题尤其有效。6.2 帧率独立与 FixedUpdate 的坑在低帧率机器上验证手感格斗游戏对帧率极其敏感60FPS 下连招手感流畅30FPS 下输入延迟感明显。如果你的代码里把物理和逻辑写在 Update 中那帧率变化就会直接影响速度。正确做法是把所有依赖物理的运算写在FixedUpdate中而把输入检测和动画状态判断写在 Update 中因为 Animator 的更新本身就基于 Update 的帧率。有一种常见隐患会让项目在手机上有说不上来的卡顿你在 FixedUpdate 里读取Input.GetAxisRaw导致输入检测的频率和物理步长绑定而物理步长在性能波动时会调整步进次数最终表现为按键偶尔不灵。要在低帧率机器上验证手感最简单的方式是在 Game 视图中点击右侧的 “Simulator” 模式把 Target Framerate 设为 30跑一遍完整的连招测试观察输入缓冲窗口和动画启动时间是否配合正常。6.3 递归依赖与场景加载顺序为什么技能特效闪了一下就消失最后占一个常见坑技能特效是从对象池取出的但因为特效脚本的OnEnable中订阅了某个全局事件比如OnAttackHit而特效被归还对象池SetActive false时没有正确取消订阅就会导致下一次取出来时事件回调被多次注册出现“一次命中触发三次特效、伤害数值叠加三倍”的诡异 bug。这个问题的定位方法是在特效脚本的OnDisable中强制取消所有事件订阅。private void OnEnable() { EventManager.OnAttackHit HandleHit; } private void OnDisable() { EventManager.OnAttackHit - HandleHit; }这段代码的要点是OnEnable与OnDisable成对出现。因为对象池会反复触发这两个函数只要保证每次启用前先取消上一次的订阅再重新注册就不会出现多次注册。这也是面向对象池设计时最容易忽略的生命周期管理问题也是火柴人格斗项目源码从 demo 走向可交付产品的一道分水岭。如果你的项目在实战中遇到“伤害异常叠加”“特效频繁闪烁”这一类怪问题优先排查所有对象的 OnEnable/OnDisable 配对是否严格对称。本文还有配套的精品资源点击获取