当前位置: 首页 > news >正文

Unity C#编程:封装、访问修饰符与工程化实践指南

1. 项目概述:从“能用”到“好用”的代码规范跃迁

在Unity开发社区里,无论是新手还是有一定经验的开发者,我经常看到一种现象:脚本里的变量,要么清一色全是public,要么就是毫无章法地混用publicprivate。这背后反映的,其实是一个从“功能实现”到“工程化思维”的认知鸿沟。很多人觉得,代码能跑起来不就行了?public多方便,在Inspector里拖拖拽拽就能赋值,其他脚本想用的时候直接点出来就能访问。但当你参与一个稍具规模的团队项目,或者一个需要长期维护迭代的个人项目时,这种“方便”很快就会变成一场噩梦。变量被意外修改导致诡异的Bug、Inspector面板被一堆无关紧要的字段挤得杂乱无章、想重构一个功能却因为牵一发而动全身而束手无策……这些问题,根源往往就在于对访问修饰符和封装概念的轻视。

这篇文章,我想和你深入聊聊Unity C#编程中publicprivate的正确使用姿势,以及“封装”这个面向对象编程(OOP)核心思想在游戏开发中的实战意义。这不仅仅是语法规则,更是一种关乎代码健壮性、可维护性和团队协作效率的工程实践。我们会从最基础的“为什么需要封装”开始,逐步拆解publicprivateprotectedinternal等修饰符的适用场景,并结合Unity编辑器的特性,探讨如何利用[SerializeField]、属性(Property)等工具,在保持封装性的同时,又不失编辑器配置的灵活性。我的目标是,让你看完后,不仅能写出“正确”的代码,更能理解其背后的设计逻辑,从而写出“优雅”且“坚固”的代码。

2. 核心概念拆解:封装、访问修饰符与Unity的独特生态

2.1 封装的本质:不是隐藏,而是管理

一提到封装,很多教程会简单地说“把数据藏起来”。这个说法容易引起误解,让人觉得封装是为了制造障碍。实际上,封装的核心理念是管理复杂度建立契约

想象一下你家的电路总闸。你不会把电线直接裸露在外面让人随便碰(public),而是用一个带开关和保险丝的电闸箱(封装)保护起来。你需要用电时,通过开关(公共方法)来控制,保险丝(数据验证)会在电流异常时熔断以保护整个电路。这个电闸箱并没有“隐藏”电,它只是提供了一个安全、可控的访问和管理方式。

在代码中,一个类就是这样一个“电闸箱”。类的内部数据(字段)就像是墙里的电线,状态可能很复杂。如果外部代码可以直接修改这些字段(public字段),就相当于让人直接去摆弄电线,很容易导致系统处于不一致或无效的状态(比如,把玩家的血量health直接设为-100)。封装通过将字段设为private,然后提供公共方法(或属性)来访问和修改它们,就像提供了那个安全的开关。在这些方法里,你可以加入必要的检查、验证和逻辑。

在Unity游戏开发中的具体价值:

  1. 防止非法状态:确保游戏对象的状态始终有效。例如,通过属性设置血量时,可以将其限制在0到最大血量之间。
  2. 降低耦合度:外部代码只依赖于类的公共接口(方法、属性),而不依赖于其内部实现细节。当内部实现需要改变时(比如优化算法、改变数据结构),只要公共接口不变,就不会影响其他代码。
  3. 提升可调试性:所有对数据的修改都通过有限的几个方法进行,当数据出现问题时,你只需要在这些方法中设置断点或添加日志,就能快速定位问题源头,而不是在海量代码中寻找是谁直接修改了那个public字段。
  4. 便于Inspector管理:合理使用[SerializeField]可以让必要的配置项在编辑器里可见,同时保持代码的封装性,避免Inspector被大量public字段污染。

2.2 C#访问修饰符全景图与Unity语境下的再理解

C#提供了多个访问修饰符,但在Unity脚本中最常用、也最易混淆的是publicprivate。我们需要在Unity这个特定环境下理解它们。

public

  • 含义:完全公开。该成员可以被任何其他类访问,无论是否在同一个程序集中。
  • Unity特性:标记为public的字段默认会在Unity编辑器的Inspector面板中显示为一个可编辑的输入框。这是Unity为快速原型开发提供的便利,但也正是滥用之源。
  • 典型误用:把一个仅在类内部使用的临时计算变量、或者不应该由设计者随意修改的核心逻辑变量声明为public,仅仅因为“方便在Inspector里看值”。

private

  • 含义:私有。该成员只能在声明它的类(或结构体)内部访问。
  • Unity特性:标记为private(或protected)的字段默认不会在Inspector中显示。
  • 典型误用:过度使用,导致一些本应在编辑器中进行配置的参数(如敌人的移动速度、子弹的伤害值)无法方便地设置,只能硬编码在代码里,降低了项目的可配置性和设计迭代速度。

其他重要修饰符:

  • protected:受保护的。成员在类内部和派生类(子类)中可访问。这在构建游戏对象的继承体系时非常有用,比如一个Enemy基类的某些字段需要被BossEnemyMiniEnemy等子类使用,但又不应对其他无关类公开。
  • internal:程序集内可访问。在Unity中,一个程序集通常对应一个项目或一个插件。当你开发一个供团队内部使用的工具库时,可以用internal来暴露一些功能给同一程序集的其他脚本,而对程序集外隐藏。
  • protected internal/private protected:组合修饰符,用于更精细的访问控制,在大型或模块化程度高的Unity项目中可能会用到。

注意:一个常见的误区是认为public字段就是“坏的”,private字段就是“好的”。这是一种非黑即白的错误观念。正确的思路是:根据成员的职责和需要暴露的范围,选择最合适的访问级别。该公开的公开,该隐藏的隐藏。

2.3 Unity Inspector与代码封装的冲突与调和

Unity编辑器的工作流(拖拽赋值、实时调整参数)与传统的代码封装理念存在天然的张力。很多开发者为了利用编辑器的便利性,牺牲了代码的封装性。其实,两者完全可以兼得。

冲突点:设计师或策划希望能在Inspector中灵活调整一个Weapondamage(伤害值)。如果damageprivate,他们调不了;如果做成public,又破坏了封装,任何脚本都能随意修改它。

解决方案

  1. [SerializeField]属性:这是Unity提供的解决方案。你可以将一个private字段标记为[SerializeField],这样它就会在Inspector中显示,但在代码层面它仍然是private的,外部脚本无法直接访问。这完美地调和了编辑器配置需求和代码封装需求。
    public class Weapon : MonoBehaviour { [SerializeField] // 在Inspector中显示 private int damage = 10; // 代码层面仍是私有的 // ... 其他代码 }
  2. 属性(Property):C#的属性提供了更强大、更灵活的控制。你可以创建一个公共的getter(读取器)和一个私有的setter(设置器),或者通过get; private set;的语法,来实现“只读”暴露。你甚至可以在setter中加入逻辑。
    public class Player : MonoBehaviour { [SerializeField] private int maxHealth = 100; // 公共属性,外部可以读取当前血量,但不能直接设置 public int CurrentHealth { get; private set; } // 通过方法来修改血量,可以加入逻辑(如伤害计算、死亡判断) public void TakeDamage(int amount) { CurrentHealth -= amount; if (CurrentHealth <= 0) Die(); // 可以在这里触发受伤动画、音效、UI更新等 } }
  3. 自定义编辑器(Editor Scripting):对于更复杂的配置需求,你可以为你的组件编写自定义的Inspector界面,以更友好、更安全的方式暴露和编辑私有数据。这属于进阶内容,但它是Unity强大编辑器扩展能力的体现。

3. 实战场景分析与最佳实践

理解了理论,我们来看几个Unity开发中高频出现的场景,分析如何正确应用封装和访问修饰符。

3.1 场景一:游戏角色(Player/Enemy)状态管理

这是最经典的场景。角色的属性(生命值、魔法值、攻击力、速度等)是游戏逻辑的核心。

反面教材(全Public流):

public class BadPlayer : MonoBehaviour { public int health = 100; public int mana = 50; public float speed = 5.0f; // ... 无数个public字段 }

问题:任何脚本(包括一个无关的UI脚本或环境脚本)都可以直接写player.health = -999;,游戏瞬间崩溃。Inspector里也杂乱无章。

正确姿势(封装+属性/方法):

public class GoodPlayer : MonoBehaviour { // 1. 基础属性,设计时配置,用[SerializeField]保持私有 [SerializeField] private int maxHealth = 100; [SerializeField] private int maxMana = 50; [SerializeField] private float baseSpeed = 5.0f; // 2. 运行时状态,通过属性暴露,控制修改权限 public int CurrentHealth { get; private set; } public int CurrentMana { get; private set; } public float CurrentSpeed { get; private set; } // 可能受减速效果影响 // 3. 初始化 private void Start() { CurrentHealth = maxHealth; CurrentMana = maxMana; CurrentSpeed = baseSpeed; } // 4. 通过定义良好的方法改变状态 public void TakeDamage(int damage) { if (IsInvulnerable) return; // 状态检查 damage = Mathf.Max(1, damage - Defense); // 伤害计算 CurrentHealth -= damage; CurrentHealth = Mathf.Clamp(CurrentHealth, 0, maxHealth); // 边界保护 OnHealthChanged?.Invoke(CurrentHealth, maxHealth); // 事件通知(如更新UI) if (CurrentHealth <= 0) { Die(); } } public void UseMana(int amount) { if (CurrentMana >= amount) { CurrentMana -= amount; OnManaChanged?.Invoke(CurrentMana, maxMana); } else { // 触发法力不足的反馈 } } // 5. 私有或受保护的方法处理内部逻辑 private void Die() { // 播放死亡动画、音效,触发游戏结束逻辑等 // 而不是让外部直接调用 } }

设计思路maxHealth等是设计参数,应可在Inspector中配置,但代码中不应被随意修改,故用[SerializeField] privateCurrentHealth是运行时状态,对外提供只读访问,修改必须通过TakeDamageHeal等方法,在这些方法里集中了所有相关逻辑(计算、验证、事件触发)。

3.2 场景二:管理器(Manager)与全局服务

比如游戏管理器GameManager、音频管理器AudioManager。它们通常是单例(Singleton),需要被广泛访问,但内部状态也需要保护。

反面教材(静态Public字段满天飞):

public class GameManager : MonoBehaviour { public static int score; public static bool isGameOver; // ... 其他静态字段 }

问题:同样是全局可写,难以追踪修改来源。isGameOver可能在奇怪的地方被设为true

正确姿势(单例封装 + 属性/事件):

public class GameManager : MonoBehaviour { // 单例实例,私有静态,通过公共属性访问 private static GameManager _instance; public static GameManager Instance { get { if (_instance == null) { _instance = FindObjectOfType<GameManager>(); // 也可以实现按需创建 } return _instance; } } // 内部状态 private int _score; private bool _isGameOver; // 通过属性暴露,提供可控的访问 public int Score { get => _score; private set { _score = value; OnScoreChanged?.Invoke(_score); // 分数变化时通知UI } } public bool IsGameOver { get => _isGameOver; private set { if (_isGameOver != value) { _isGameOver = value; OnGameStateChanged?.Invoke(_isGameOver); // 游戏状态变化通知 } } } // 定义事件,用于解耦通信 public event Action<int> OnScoreChanged; public event Action<bool> OnGameStateChanged; // 修改状态的方法 public void AddScore(int points) { if (!IsGameOver) // 游戏结束时不能加分 { Score += points; } } public void EndGame() { IsGameOver = true; // 保存数据、显示结算界面等 } // 防止外部实例化 private void Awake() { if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; DontDestroyOnLoad(gameObject); } }

使用示例:

// 其他脚本中 // 读取是安全的 int currentScore = GameManager.Instance.Score; bool gameOver = GameManager.Instance.IsGameOver; // 写入必须通过定义好的方法 GameManager.Instance.AddScore(100); // GameManager.Instance.Score = 999; // 错误!因为set是private的 // GameManager.Instance.IsGameOver = true; // 错误!

设计思路:单例实例本身通过属性暴露,确保唯一性。核心数据通过带有private set的属性暴露,外部只能读不能写。所有修改必须通过AddScoreEndGame等公共方法进行,这些方法包含了业务规则(如游戏结束后不能加分)。同时,使用事件(event)机制通知状态变化,而不是让其他脚本不断地轮询(Update里判断),这更高效、更解耦。

3.3 场景三:组件间通信与数据传递

Unity中,GetComponentFindObjectOfType是常见的获取其他组件引用的方式。如何传递数据既安全又高效?

反面教材(直接暴露字段引用):

public class Shooter : MonoBehaviour { public Target target; // 直接public引用 public void Shoot() { if (target != null) { target.health -= 10; // 直接修改! } } }

问题Shooter直接修改了Target的内部状态,两者紧密耦合。如果Target的伤害计算逻辑变了(比如要加入护甲),所有直接修改health的地方都要改。

正确姿势(定义接口或发送消息):

// Target.cs public interface IDamageable { void TakeDamage(int damage, GameObject damageSource); } public class Target : MonoBehaviour, IDamageable { [SerializeField] private int health = 100; public void TakeDamage(int damage, GameObject damageSource) { // 在这里集中处理所有伤害逻辑:减血、播放受击特效、判断死亡等 health -= damage; // ... 其他逻辑 } } // Shooter.cs public class Shooter : MonoBehaviour { // 可以通过多种方式获取目标,不一定是public字段 // 例如:射线检测、触发器、在Inspector中拖拽[SerializeField] private字段等 [SerializeField] private GameObject currentTarget; public void Shoot() { if (currentTarget != null) { IDamageable damageable = currentTarget.GetComponent<IDamageable>(); if (damageable != null) { damageable.TakeDamage(10, this.gameObject); // 通过接口调用 } else { // 目标不可被伤害,处理逻辑(如播放无效音效) } } } }

设计思路:定义IDamageable接口,任何可以受到伤害的对象都实现它。Shooter只关心目标是否“可伤害”,并通过接口调用TakeDamage方法,而不关心Target内部如何实现伤害计算。这遵循了“依赖接口而非实现”的原则,极大降低了耦合度。即使未来有新的BossTargetShieldedTarget,只要实现了IDamageableShooter的代码都无需修改。

4. 进阶技巧与性能考量

4.1 属性(Property)的妙用与陷阱

属性是C#实现封装的核心语法糖,但在Unity中需要特别注意。

自动属性(Auto-Property)的序列化问题:

public int Health { get; set; } // Unity默认无法序列化自动属性

Unity的序列化系统(用于在Inspector显示和保存场景/预制体)默认不支持自动属性。这意味着Health不会出现在Inspector中,其值也不会被保存。

解决方案:

  1. 使用支持序列化的后备字段:这是最常用、最兼容的方法。
    [SerializeField] private int _health = 100; public int Health { get => _health; private set => _health = Mathf.Clamp(value, 0, MaxHealth); }
  2. 使用[SerializeField]标记属性(Unity 2020.3+):新版本Unity允许对自动属性的支持字段进行序列化。
    [field: SerializeField] public int Health { get; private set; } = 100;
    这种写法更简洁,但需要注意团队使用的Unity版本是否支持。

属性的性能开销:属性本质上是一对方法(getter/setter)。在性能极度敏感的代码路径中(如每帧在Update中调用成千上万次的循环),直接访问字段会比通过属性访问略快。但对于绝大多数游戏逻辑,这点开销可以忽略不计。可读性、安全性和封装性带来的好处远大于微小的性能损失。切勿因小失大,过早优化。

4.2readonlyconststatic的配合使用

  • readonly:用于字段,表示该字段只能在声明时或构造函数中被赋值,之后即为只读。适用于在对象生命周期内不变,但又需要在运行时计算或通过参数传入的常量。例如,一个根据难度生成的敌人的初始血量。
    public class Enemy { private readonly int _initialHealth; public Enemy(int baseHealth) { _initialHealth = baseHealth * 2; } // _initialHealth 之后不能再被修改 }
  • const:编译时常量,必须在声明时赋值,且值必须在编译时确定(基本类型、字符串)。适用于真正的数学常量(如Mathf.PI)。
    private const float Gravity = 9.81f;
  • static:属于类型本身,而非类型的实例。与readonly组合可以创建类级别的常量。
    public static class GameConstants { public static readonly Vector3 SpawnPoint = new Vector3(0, 10, 0); public const int MaxPlayerCount = 4; }

4.3 何时可以打破封装?——实用主义考量

封装是原则,不是教条。在以下特定情况下,出于性能或便利性考虑,可以适当放宽:

  1. 极简数据结构(Data Container):如果一个类仅用于存储一组相关的数据,没有任何行为(方法),且数据字段间没有复杂的约束关系,例如:
    public struct PlayerSaveData { public string PlayerName; public int Level; public Vector3 LastPosition; // 没有方法,只有数据 }
    这种情况下,使用公共字段是可以接受的。在Unity中,Vector3Color等内置结构体就是例子。
  2. Unity事件回调(UnityEvent):UnityEvent在Inspector中需要公开方法或字段来添加监听器。通常,用于回调的方法本身可以是public的,即使它只在内部被调用。这是为了配合编辑器的工作流。
    public UnityEvent OnPlayerDeath; public void PublicMethodForEvent() { /* 内部逻辑 */ }
  3. 编辑器扩展脚本:专门用于Editor文件夹下的工具脚本,其公开字段主要是为了配置工具参数,不参与运行时游戏逻辑,可以更自由地使用public

核心准则:每一次决定使用public字段时,都要问自己:“未来这个字段被任意代码在任何地方修改,会不会导致问题?我是否愿意在项目后期花几个小时去追踪一个因它而起的诡异Bug?”如果答案是否定的,那就请使用属性或方法进行封装。

5. 常见问题与排查技巧实录

在实际开发中,即使理解了原则,还是会遇到各种具体问题。下面是我总结的一些高频“坑点”和解决思路。

5.1 Inspector中字段不显示或显示异常

问题现象可能原因解决方案
public字段不显示字段类型不被Unity序列化系统支持(如某些自定义类未标记[System.Serializable])。1. 为自定义类添加[System.Serializable]属性。
2. 如果不需要在Inspector编辑,可忽略。
[SerializeField] private字段不显示1. 字段是静态的(static)。
2. 字段名以特殊前缀开头(旧版本Unity有bug)。
3. 脚本编译错误。
1. 静态字段属于类,不属于实例,本就不应序列化到每个对象。考虑改用ScriptableObject存储配置。
2. 避免使用m_k_等前缀(虽然现在问题不大)。
3. 检查Console窗口,修复所有编译错误。
字段显示为灰色(不可编辑)字段被标记为readonly,或者其setter是私有的(对于属性)。这是预期行为。如果需要在Inspector中初始化readonly字段,可以在Awake()Start()中通过[SerializeField]的另一个字段来赋值。
数组/列表在Inspector中展开后内容丢失常见的Unity序列化Bug,尤其在修改脚本或撤销操作时。1.勤备份场景和预制体
2. 避免在运行时通过代码修改[SerializeField]的列表/数组的结构(如Add/Remove),这容易破坏序列化。如需动态集合,考虑使用List<T>但不序列化,在Awake中初始化。
3. 使用[NonSerialized]System.NonSerialized属性标记不需要序列化的字段。

5.2 空引用异常(NullReferenceException)与封装

空引用异常是Unity开发者的“头号公敌”。良好的封装能有效减少和定位这类问题。

问题场景:一个publicGameObject引用,在Inspector中忘记拖拽赋值,运行时其他脚本访问它就会抛出空引用异常。

封装改进方案:

  1. 使用[SerializeField]并添加初始值
    [SerializeField] private GameObject projectilePrefab = null;
    至少能在代码层面看到它可能为null。
  2. 在访问前进行检查(防御性编程)
    public void Fire() { if (projectilePrefab == null) { Debug.LogError($"{gameObject.name}: Projectile prefab is not assigned!", this); return; // 或使用一个默认预制体 } Instantiate(projectilePrefab, transform.position, transform.rotation); }
  3. 使用RequiredComponent属性(对于当前GameObject上的组件)
    [RequireComponent(typeof(Rigidbody))] public class MovingObject : MonoBehaviour { private Rigidbody _rb; private void Awake() { _rb = GetComponent<Rigidbody>(); // 确保能获取到 } }
  4. 惰性初始化(Lazy Initialization):对于非必须的组件或引用,在第一次使用时再尝试获取。
    private AudioSource _audioSource; private AudioSource MyAudioSource { get { if (_audioSource == null) { _audioSource = GetComponent<AudioSource>(); if (_audioSource == null) { _audioSource = gameObject.AddComponent<AudioSource>(); } } return _audioSource; } } // 使用时直接调用 MyAudioSource.Play();

5.3 循环依赖与设计模式

当两个类互相持有对方的public引用,并通过这些引用频繁调用对方的方法时,就形成了紧耦合的循环依赖。这会使代码难以理解、测试和修改。

症状:类A调用类B的方法,类B的方法内部又调用了类A的方法,如此循环。修改其中一个类,常常需要同时修改另一个。

解耦策略:

  1. 引入接口:如上文IDamageable的例子,让双方依赖于抽象接口,而非具体实现。
  2. 使用事件(Event)或消息系统:类A完成某事后,发布一个事件。类B订阅这个事件,并在事件触发时执行自己的逻辑。两者不需要直接引用对方。Unity自带的UnityEvent或C#的event Action都是简单易用的工具。对于大型项目,可以考虑消息总线(Message Bus)或观察者模式(Observer Pattern)的框架。
  3. 依赖注入(Dependency Injection):通过构造函数、属性或方法参数,将依赖项从外部“注入”到类中,而不是在类内部直接newFindObjectOfType。这提升了代码的可测试性和灵活性。在Unity中,可以手动管理,或使用像Zenject(现称Extenject)、StrangeIoC这样的DI框架。

一个简单的事件解耦示例:

// 事件发布者 public class PlayerHealth : MonoBehaviour { public event Action OnPlayerDied; private void Die() { // ... 死亡逻辑 OnPlayerDied?.Invoke(); // 通知所有订阅者 } } // 事件订阅者 public class GameOverUI : MonoBehaviour { [SerializeField] private PlayerHealth playerHealth; private void OnEnable() { if (playerHealth != null) playerHealth.OnPlayerDied += ShowGameOverScreen; } private void OnDisable() { if (playerHealth != null) playerHealth.OnPlayerDied -= ShowGameOverScreen; } private void ShowGameOverScreen() { /* 显示UI */ } }

这样,PlayerHealth完全不知道GameOverUI的存在,它只负责发布事件。任何关心玩家死亡的对象(如音效管理器、成就系统)都可以独立订阅这个事件,彼此不再直接依赖。

6. 代码重构实战:将一个“Public混乱”的脚本改造成型

让我们看一个真实的简单例子,并一步步重构它。假设我们有一个最初由新手编写的Enemy脚本:

原始版本(问题重重):

public class BadEnemy : MonoBehaviour { public int health = 50; public float speed = 3.0f; public GameObject player; public AudioClip deathSound; private AudioSource audioSource; void Start() { audioSource = GetComponent<AudioSource>(); if (player == null) // 试图补救空引用 player = GameObject.FindGameObjectWithTag("Player"); } void Update() { // 直接修改transform,没有封装 transform.position = Vector3.MoveTowards(transform.position, player.transform.position, speed * Time.deltaTime); if (health <= 0) { if (deathSound != null) audioSource.PlayOneShot(deathSound); Destroy(gameObject, 0.5f); // 直接销毁,没有通知 } } // 被攻击时,其他脚本直接修改 health // void OnCollisionEnter(Collision other) { ... } }

重构步骤与思考:

第一步:识别并私有化内部字段healthspeeddeathSound都是敌人的内部属性,不应该被外部直接修改。player引用是为了寻路,也是内部逻辑所需。

public class BetterEnemy : MonoBehaviour { [SerializeField] private int maxHealth = 50; [SerializeField] private float moveSpeed = 3.0f; [SerializeField] private AudioClip deathSound; private int currentHealth; private Transform playerTransform; private AudioSource audioSource;

第二步:使用属性控制访问,并提供初始化方法

public int CurrentHealth => currentHealth; // 只读属性 public bool IsDead => currentHealth <= 0; private void Awake() { audioSource = GetComponent<AudioSource>(); currentHealth = maxHealth; // 初始化 } private void Start() { GameObject playerObj = GameObject.FindGameObjectWithTag("Player"); if (playerObj != null) playerTransform = playerObj.transform; else Debug.LogWarning("Player not found for enemy targeting.", this); }

第三步:将核心逻辑封装成方法将移动和死亡逻辑从Update中抽离,使其更清晰,并便于控制和扩展。

private void Update() { if (!IsDead && playerTransform != null) { MoveTowardsPlayer(); } } private void MoveTowardsPlayer() { Vector3 direction = (playerTransform.position - transform.position).normalized; // 可以考虑加入NavMeshAgent等更复杂的移动逻辑 transform.Translate(direction * moveSpeed * Time.deltaTime, Space.World); } public void TakeDamage(int damageAmount) { if (IsDead) return; // 已死亡不再受到伤害 currentHealth -= damageAmount; currentHealth = Mathf.Max(currentHealth, 0); // 确保不小于0 // 可以在这里触发受伤动画、音效、UI飘字等 Debug.Log($"{gameObject.name} took {damageAmount} damage. Health: {currentHealth}"); if (IsDead) { Die(); } } private void Die() { // 集中处理所有死亡相关逻辑 if (deathSound != null && audioSource != null) audioSource.PlayOneShot(deathSound); // 通知其他系统(如分数管理器、生成器) // GameManager.Instance?.OnEnemyDied(this); // 禁用碰撞体、渲染器等,而不是立即销毁,以便死亡动画播放完 GetComponent<Collider>().enabled = false; // GetComponent<Renderer>().enabled = false; // 或播放死亡动画 Destroy(gameObject, 2f); // 延迟销毁 }

第四步:考虑使用事件进行解耦如果死亡需要通知多个系统(分数、任务、UI),可以定义事件。

// 在类内部定义 public event Action<BetterEnemy> OnEnemyDeath; private void Die() { OnEnemyDeath?.Invoke(this); // ... 其他死亡逻辑 }

最终重构版本的优势:

  1. 状态安全:外部只能通过TakeDamage方法改变血量,确保了伤害计算的唯一入口。
  2. Inspector整洁:只有maxHealthmoveSpeeddeathSound这几个设计参数可见且可配置。
  3. 逻辑清晰:移动、受伤、死亡逻辑被封装在独立的方法中,Update函数简洁明了。
  4. 易于扩展:要添加“受伤无敌时间”、“移动速度随血量变化”等功能,只需在对应方法内修改,不会影响外部调用。
  5. 便于测试:可以单独测试TakeDamageDie方法的行为。

这个重构过程体现了从“面向过程编程”到“面向对象设计”的思维转变。一开始的代码只关心“怎么把功能做出来”,而重构后的代码更关心“如何组织代码使其更健壮、更易维护”。这正是一个Unity开发者从新手走向资深必须跨越的门槛。记住,写出能运行的代码只是第一步,写出能经得起时间考验的代码才是我们的目标。

http://www.jsqmd.com/news/1149343/

相关文章:

  • FontCenter:构建AutoCAD字体缺失问题的智能解决方案
  • Matlab2021a中基于DQ变换的三相异步电机矢量控制仿真工程(含实操录像与模型文件)
  • Qwen2.5-Coder:嵌入VS Code的本地化代码协作者
  • Mac运行Microsoft Project的终极解决方案:虚拟机配置与深度优化
  • 2026年自建Overleaf服务器:面向高校与科研机构的LaTeX基础设施部署指南
  • 高效智能文件去重:dupeguru完整使用指南
  • 现代C++学习:右值和移动究竟解决了什么问题?
  • Slides Agent 技术拆解:版式生成、内容记忆与视觉一致性的实现
  • openEuler 22.03 SP3 上 Oracle 12cR2 一键安装实战指南
  • STM32 PID温控项目:从零实现±0.5°C高精度温度控制
  • 3分钟拿回你的QQ聊天记录:全平台数据库解密终极指南
  • Apriori 算法 Python 实战:3步计算布尔数据集关联规则支持度与置信度
  • MATLAB一键运行IIR带阻滤波语音降噪:含测试音频与三组对比图
  • LUI报表工具:从GUI到语言交互的三大技术支柱
  • 3分钟掌握完整网页截图:告别拼接烦恼的Chrome扩展神器
  • 如何在Windows上实现完整的AirPlay 2投屏功能:免费开源解决方案终极指南
  • Matlab多变量时序预测工具:RF-Adaboost集成模型+可视化结果+可调参数源码
  • STM32F4 + FreeRTOS 下直接可用的 BQ27421-G1 电量监测工程包
  • MVTec AD 数据集实战:5种无监督缺陷检测方法对比,AUROC 最高 0.99
  • Cocos Creator游戏开发:HTTP与WebSocket网络通信实战与优化
  • WSL2下编译GROMACS实战:Ubuntu 22.04 + CMake + GPU加速全链路指南
  • 雀魂数据分析终极指南:如何用免费工具快速提升麻将水平
  • STM32L152ZD与TLA2518高精度ADC信号采集方案
  • Qwen Code CLI Windows安装与批量代码整理实战指南
  • DDrawCompat终极指南:让Windows 10/11完美运行经典DirectX游戏的免费解决方案
  • Unity Spine骨骼动画UI跟随:BoneFollowerGraphic组件实战指南
  • STM32F469II与EasyPull Click的嵌入式信号配置实战
  • 计算机毕业设计之基于SSM框架的共享单车管理系统设计与实现
  • WSL 2安装失败根因分析与生产级调校指南
  • 用友GRP-U8 SQL注入漏洞修复实战:Snort规则编写与补丁验证3步法