2139 字
11 分钟
为 Godot 学 C# 05:继承、多态与接口

教程二 · 第 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); // 25

Godot 脚本继承也合法(但有约束,见 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:转换,失败给 null
if (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 脚本继承的特殊规矩#

  1. 父子脚本挂的节点类型要兼容:BaseEnemy : CharacterBody2D,子类脚本也只能挂 CharacterBody2D(或它的派生节点)
  2. 场景继承 vs 脚本继承是两套机制:场景面板”新建继承场景”继承的是 .tscn(继承节点结构和属性),脚本继承的是类。两者可以叠加,但新手先只用脚本继承
  3. [GlobalClass]:给类加上后,它会注册进编辑器——能在”附加脚本”对话框、[Export] 类型选择里出现。自定义 Resource 必加;节点脚本想被当作可分配类型/出现在节点创建列表时加
  4. 构造函数在 Godot 对象上不可靠,初始化一律 _Ready
  5. 不要在字段初始化器里访问场景树(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 作业#

  1. 写 BaseEnemy(abstract,抽象方法 AiTick)+ 两个子类:近战追玩家、远程站定朝玩家发射子弹。用一个 List 统一驱动。
  2. 给箱子和敌人都实现 IDamageable,用同一个 Area2D 攻击判定同时能打两者。
  3. 思考:IDamageable.TakeDamage 的参数为什么用 DamageInfo 对象而不是 (int amount, DamageType type, ...) 一堆参数?(想想以后要加”暴击""击退方向”时哪种写法不用改所有实现类。)

第 6 章:集合、泛型、委托与事件、LINQ、async——C# 工具箱里最实用的一批。


《Godot C# 零基础》系列导航

⬅️ 上一篇:为 Godot 学 C# 04:类与对象 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:为 Godot 学 C# 06:集合、泛型、委托事件、LINQ 与异步

为 Godot 学 C# 05:继承、多态与接口
https://021028.xyz/posts/default/174/
作者
021028
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0