2.2 C# 面向对象:值班员的岗位说明书 本节摘要:每个 Unity 脚本都是一个继承了 MonoBehaviour 的 C# 类。本节补齐游戏开发最常用的 C# 底座:字段与方法、继承与多态、接口与命名空间,并用一个"伤害系统"案例展示继承与接口如何让不同类型的角色共用一套结算逻辑。 MonoBehaviour 是一张继承网 写下 public class PlayerController : MonoBehaviour 时,编译器其实搭了一条继承链:PlayerController 继承自 MonoBehaviour,而 MonoBehaviour 的上游是 Behaviour、Component、UnityEngine.Object,一路到 C# 的基类。
本节摘要:每个 Unity 脚本都是一个继承了 MonoBehaviour 的 C# 类。本节补齐游戏开发最常用的 C# 底座:字段与方法、继承与多态、接口与命名空间,并用一个"伤害系统"案例展示继承与接口如何让不同类型的角色共用一套结算逻辑。
写下 public class PlayerController : MonoBehaviour 时,编译器其实搭了一条继承链:PlayerController 继承自 MonoBehaviour,而 MonoBehaviour 的上游是 Behaviour、Component、UnityEngine.Object,一路到 C# 的基类。第 1 章说"脚本是一张自定义岗位卡",现在可以补全:岗位卡的制式(能被挂载、能被启停、能收到引擎点名)全部由上游基类提供,你只是在子类里填内容。
C# 的基本构件在游戏里的对应关系值得列一遍:字段是值班员随身携带的记忆(血量、速度、状态),方法是他会做的动作(跳跃、受伤、死亡),属性(Property)是带门卫的记忆读写口,构造函数在 Unity 里几乎不用——因为对象的出生由引擎负责,初始化工作交给 Awake 与 Start。
游戏里到处是"一类东西、多种变种":敌人有人形、飞行、炮塔三种,血量、受伤、死亡逻辑相同,移动方式各异。继承在这里上场:把共性放进基类 Enemy,子类只覆写差异。C# 用 virtual 标记允许覆写的方法,子类用 override 接住。
using UnityEngine; // 基类:所有敌人共用的岗位说明书 public class Enemy : MonoBehaviour { public int maxHp = 100; protected int hp; // protected:子类可读写,外部不可见 protected virtual void Awake() // virtual 允许子类追加初始化 { hp = maxHp; } // 受伤流程对所有敌人一致 public virtual void TakeDamage(int amount) { hp -= amount; if (hp <= 0) Die(); } protected virtual void Die() { Debug.Log(gameObject.name + " 阵亡"); Destroy(gameObject); } // 移动方式由子类决定:基类留空 protected virtual void Move() { } }
继承解决"是什么",接口解决"会不会"。火焰陷阱、毒气区域、掉落的尖刺——它们不是敌人,却有"能造成伤害"这个共同能力。给能力定义接口 IDamageable 或 IAttacker,任何类实现接口即可被统一处理,不受单继承限制(C# 类只能继承一个基类,却可以实现多个接口)。
// 能被伤害的东西都实现这个接口 public interface IDamageable { void TakeDamage(int amount); } // 玩家实现它,敌人实现它,可破坏的木箱也实现它 public class Player : MonoBehaviour, IDamageable { public int hp = 100; public void TakeDamage(int amount) { hp -= amount; Debug.Log("玩家剩余血量 " + hp); } } // 伤害结算器:只认接口,不关心对方是玩家还是木箱 public class DamageZone : MonoBehaviour { public int damage = 10; void OnTriggerEnter(Collider other) { // 尝试把碰到的对象当作 IDamageable,拿得到就结算 IDamageable target = other.GetComponent<IDamageable>(); if (target != null) { target.TakeDamage(damage); } } }
GetComponent 不只能找组件类,还能找接口——这是 Unity 与 C# 接口结合最实用的一个点,上面 DamageZone 已经演示。第 3.3 节的碰撞事件会直接用到这段结构。
背景:近战兵、远程炮塔、飞行蜂三种敌人,受伤与死亡逻辑相同,移动方式不同,还要支持一个"障碍物"不可被伤害但可被碰撞。
操作:让三种敌人继承 Enemy 并各自覆写 Move,让障碍物只实现碰撞不实现 IDamageable。关键代码如下:
public class MeleeSoldier : Enemy { protected override void Move() { // 近战兵:朝玩家方向平移(方向计算略) // transform.Translate(moveDir * speed * Time.deltaTime); } } public class Turret : Enemy { protected override void Move() { // 炮塔不移动,覆写为空即可,保留默认实现也无妨 } } public class DamageResolver : MonoBehaviour { public int damage = 25; void Update() { if (Input.GetKeyDown(KeyCode.Alpha1)) { // 场景里所有实现 IDamageable 的对象一起掉血 foreach (IDamageable d in FindObjectsOfType<MonoBehaviour>()) { if (d is IDamageable) d.TakeDamage(damage); } } } }
结果:按一次数字键 1,三种敌人同时掉血并可能阵亡;障碍物毫无反应——它不在 IDamageable 的名册里。
解读:DamageResolver 的循环里那句 d is IDamageable 看似多余,其实是在过滤 null 与已销毁对象,避免对空引用调方法。这个案例的价值在于结构:结算器对"谁会受伤"一无所知,新增一种敌人不用改结算器一行代码——这就是面向接口编程带来的可扩展性。反过来也要警惕过度设计:只有一种敌人时先写直的代码,出现第二种变种再抽基类,抽象是欠账后的偿还,不是预付款。
变式:给 Enemy 基类加一个 public float moveSpeed 字段并在 Awake 里打印,观察子类没有覆写 Awake 时基类行为;再让 MeleeSoldier 覆写 Awake 并在末尾调用 base.Awake(),体会"子类覆写后如何保留父类逻辑"——漏掉 base 调用是继承体系里最常见的初始化丢失事故。
项目变大后,重名不可避免:两个模块都有自己的 Utils。命名空间(namespace)就是包间隔离:把类包进 namespace Combat 与 namespace UI,同名类互不冲突,使用方用 using 引入。团队约定一条即可:一个模块一个命名空间,编辑器扩展代码放进带 Editor 后缀的专属命名空间,避免打包时把调试工具带上线。
有些任务一口气做不完,需要中途让出值班台——下一节的协程就是干这个的。