教程二 · 第 5 章:继承、多态、接口
public override void _Ready()——你每天都在写 override,却在 override 谁?这一章把面向对象剩下的两大支柱讲透:继承与多态,以及游戏架构里极其实用的接口。
5.1 继承:站在父类肩膀上
Godot 节点类型本身就是一棵继承树:
GodotObject└── Node └── CanvasItem └── Node2D └── CollisionObject2D └── PhysicsBody2D └── CharacterBody2D └── ...你的脚本声明:
public partial class Player : CharacterBody2D读作”Player 是一种 CharacterBody2D”。冒号表示继承:Player 自动拥有 CharacterBody2D 及其所有父类的成员——Position、Velocity、MoveAndSlide()、AddChild()、QueueFree() 全都白来,你只写玩家特有的逻辑。
自己也可以建继承链。纯 C# 类例子:
public class EnemyStats{ public int Hp = 30; public void TakeDamage(int n) { Hp -= n; }}
public class BossStats : EnemyStats // Boss 是一种 EnemyStats{ public int EnrageThreshold = 10; // 只加自己新的东西}
var boss = new BossStats();boss.TakeDamage(5); // 父类方法直接能用GD.Print(boss.Hp); // 25Godot 脚本继承也合法(但有约束,见 5.6):
public partial class BaseEnemy : CharacterBody2D{ [Export] public float MoveSpeed = 100f; protected Player? _player; // protected:本类和子类能访问
protected Vector2 ToPlayer() => _player is null ? Vector2.Zero : (_player.GlobalPosition - GlobalPosition).Normalized();
public virtual void Die() { EmitSignal(SignalName.Died); QueueFree(); }}public partial class BatEnemy : BaseEnemy // 蝙蝠敌人复用基类逻辑{ public override void Die() { GD.Print("蝙蝠碎成了雾"); base.Die(); // base:调用父类版本(死亡通用流程照走) }}5.2 virtual / override / sealed:重写的规矩
- virtual:父类声明”这个方法允许子类重写”
- override:子类”我要重写它”
- base.方法():在重写版本里调用父类原版本
- sealed:子类不许再重写(或 sealed class 不许被继承)
Godot 的生命周期方法就是 virtual 的:Node._Ready()、Node._Process(double)、Node._PhysicsProcess(double)、Node._UnhandledInput(InputEvent)——引擎在合适的时机调用它们,你 override 注入自己的逻辑。这就是”模板方法”模式:引擎定流程,你填空。
public override void _Ready(){ base._Ready(); // Godot 的 Node._Ready 是空实现,调不调都行;习惯上省略 AddToGroup("player");}注意区分:
- override:重写的是父类同名同参方法
- overload(重载):同名方法不同参数列表(第 2 章),两码事
new关键字可以”藏起”父类方法但不构成多态,新手别用
5.3 多态:同一个调用,不同的表现
继承 + override 组合出的能力叫多态。经典 Godot 场景:
public partial class BaseEnemy : CharacterBody2D{ public virtual void AiTick() { /* 默认:什么也不做 */ }}
public partial class MeleeEnemy : BaseEnemy{ public override void AiTick() => ChasePlayer(); // 近战:追}
public partial class RangedEnemy : BaseEnemy{ public override void AiTick() => KeepDistanceAndShoot(); // 远程:拉扯射击}刷怪器/主循环不需要知道具体类型:
private readonly List<BaseEnemy> _enemies = new();
public override void _PhysicsProcess(double delta){ foreach (BaseEnemy enemy in _enemies) enemy.AiTick(); // 同一个调用,近战会追、远程会射击——运行时各走各的实现}这就是多态的威力:新增一种敌人 = 新写一个子类,刷怪器一行不改。这符合”对扩展开放、对修改封闭”的设计原则。
Godot 引擎内部处处是多态:你调 node.QueueFree(),引擎不关心它是 Player 还是 Bullet;_Process 你不重写,引擎就走空实现。
类型判断与转换三件套回顾
foreach (Node n in GetTree().GetNodesInGroup("damageable")){ if (n is BaseEnemy e) // is:判断 + 取变量 e.TakeDamage(10);}
BaseEnemy e = node as BaseEnemy; // as:转换,失败给 nullif (e != null) { }
var typed = (BaseEnemy)node; // 强转:失败抛异常GetNode<T> 的泛型 T 也是类型信息:GetNode<Sprite2D>("Sprite"),取到直接是 Sprite2D 类型。
5.4 抽象类:只定规矩不实现
父类可能本身没有合理的默认行为——“敌人”不该有一个能实例化的通用形态,它只规定”所有敌人都必须会 AiTick”:
public abstract partial class BaseEnemy : CharacterBody2D{ public abstract void AiTick(); // 抽象方法:无实现,子类必须重写 public virtual int ScoreValue => 10; // 虚属性:有默认,子类可改}abstract class:不能 new,只能被继承abstract成员:只签名无方法体,子类必须 override- 抽象类里也可以有普通方法和字段(共用逻辑放这)
纯 C# 抽象类例子(伤害管线):
public abstract class DamageModifier{ public abstract int Modify(int rawDamage);}public class FireArmor : DamageModifier{ public override int Modify(int raw) => Math.Max(0, raw - 5);}5.5 接口:定义”能做什么”的契约
接口(interface)= 一组方法/属性的承诺,不含实现(C# 8 后可有默认实现,初学忽略),一个类可以实现多个接口。
public interface IDamageable{ int Hp { get; } void TakeDamage(int amount);}
public interface IInteractable{ string Prompt { get; } void Interact(Player who);}实现接口(和继承一样用冒号,可以多个,逗号分隔):
public partial class Enemy : CharacterBody2D, IDamageable, IInteractable{ public int Hp => _hp; public string Prompt => "按 E 对话";
public void TakeDamage(int amount) { /* ... */ } public void Interact(Player who) { /* ... */ }}箱子不是敌人,但也能被打:
public partial class Crate : StaticBody2D, IDamageable{ public int Hp => 10; public void TakeDamage(int amount) => Break();}子弹的逻辑瞬间清爽——它只关心目标”能不能受伤”,不关心它是谁:
private void OnBodyEntered(Node2D body){ if (body is IDamageable target) { target.TakeDamage(_damage); QueueFree(); }}配合组(教程一第 5 章):
foreach (var node in GetTree().GetNodesInGroup("damageable")) if (node is IDamageable d) d.TakeDamage(10);继承 vs 接口怎么选
- 继承回答”是什么”(is-a):蝙蝠是一种敌人——共享数据+逻辑,用基类
- 接口回答”能做什么”(can-do):箱子、玩家、敌人都能受伤——跨继承树的共同能力,用接口
- C# 只能单继承,但能实现任意多个接口
- 经验:优先用接口和组合定义交互,继承层次保持浅(最多两三层)。Godot 节点本身已经给了一棵继承树,业务逻辑再叠深继承会很难改。常见的敌人 AI 差异,“状态机 + 组合数据(Resource)“往往比一棵大继承树健康
5.6 Godot 脚本继承的特殊规矩
- 父子脚本挂的节点类型要兼容:
BaseEnemy : CharacterBody2D,子类脚本也只能挂 CharacterBody2D(或它的派生节点) - 场景继承 vs 脚本继承是两套机制:场景面板”新建继承场景”继承的是 .tscn(继承节点结构和属性),脚本继承的是类。两者可以叠加,但新手先只用脚本继承
[GlobalClass]:给类加上后,它会注册进编辑器——能在”附加脚本”对话框、[Export]类型选择里出现。自定义 Resource 必加;节点脚本想被当作可分配类型/出现在节点创建列表时加- 构造函数在 Godot 对象上不可靠,初始化一律
_Ready - 不要在字段初始化器里访问场景树(
GetNode),那时还没进树:
// 错误:字段初始化时引擎侧对象未就绪private Sprite2D _s = GetNode<Sprite2D>("Sprite"); // 必崩
// 正确private Sprite2D _s = null!;public override void _Ready() => _s = GetNode<Sprite2D>("Sprite");5.7 一个小架构演练:伤害系统
需求:玩家的攻击能打敌人、打箱子;火属性对冰护甲敌人增伤;死亡产生掉落。
// 契约public interface IDamageable{ void TakeDamage(DamageInfo info);}
// 纯 C# 数据(第 6 章会看到 record 更合适)public class DamageInfo{ public int Amount; public DamageType Type; public Node2D? Source;}public partial class BaseEnemy : CharacterBody2D, IDamageable{ [Export] public WeaponData? Weakness; // 弱点(自定义 Resource) private int _hp = 30;
[Signal] public delegate void DiedEventHandler();
public void TakeDamage(DamageInfo info) { int final = info.Amount; if (Weakness != null && info.Type == Weakness.Element) final = Mathf.RoundToInt(final * 1.5f);
_hp -= final; GD.Print($"{Name} 受到 {final} 点伤害"); if (_hp <= 0) { EmitSignal(SignalName.Died); QueueFree(); } }}public partial class Crate : StaticBody2D, IDamageable{ public void TakeDamage(DamageInfo info) => Break(); private void Break() { /* 掉金币、播粒子 */ QueueFree(); }}攻击方只依赖接口:
if (area is IDamageable d) d.TakeDamage(new DamageInfo { Amount = 20, Type = DamageType.Fire, Source = this });以后加”可破坏的冰墙""友军 NPC""召唤物”——全部实现 IDamageable 即可,攻击代码零修改。这就是面向对象在游戏代码里的正确打开方式。
5.8 作业
- 写
BaseEnemy(abstract,抽象方法AiTick)+ 两个子类:近战追玩家、远程站定朝玩家发射子弹。用一个 List 统一驱动。 - 给箱子和敌人都实现
IDamageable,用同一个 Area2D 攻击判定同时能打两者。 - 思考:
IDamageable.TakeDamage的参数为什么用DamageInfo对象而不是(int amount, DamageType type, ...)一堆参数?(想想以后要加”暴击""击退方向”时哪种写法不用改所有实现类。)
第 6 章:集合、泛型、委托与事件、LINQ、async——C# 工具箱里最实用的一批。
《Godot C# 零基础》系列导航
⬅️ 上一篇:为 Godot 学 C# 04:类与对象 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:为 Godot 学 C# 06:集合、泛型、委托事件、LINQ 与异步