godot创建两种敌人僵尸 一种吐舌头 一种在角色脚下生成圆形伤害圈 两种僵尸均继承enemy脚本 理解继承

这次通过创建两种敌人,理解了继承

首先我做了个敌人场景,有sprite,碰撞,脚本,然后有两种僵尸动画animatedsprite2d的frame,基础的功能做了僵尸在距离内追角色,开始是在主场景动态创建enemy场景,实例化僵尸,然后给僵尸通过type区分种类跟动画,后面两种僵尸攻击逻辑都写在这个enemy场景的脚本了,脚本很长,也很难看懂。

然后我想到,既然这两种僵尸攻击逻辑都不同,那分开做,每个都创建个场景,僵尸还有共同的地方,追逐角色,血条,受击闪烁都是通用的,那enemy作为主类,写僵尸基本逻辑,然后舌头僵尸,法阵僵尸继承enemy脚本,自己写自己的攻击逻辑,后来想起来,这是java中类似的继承,跟ai说了,她说大概是继承的理念,通过这个僵尸我才弄懂,继承看来还是很有用的,下面是对话跟脚本

godot如果有两种僵尸 他们的攻击方式不同 那咋设计 是一个enemy脚本写出两种僵尸的逻辑 根据type判断 还是做两种僵尸场景单独写脚本 还是写个总体的enemy脚本 创建两个场景 总体enemy脚本写基础的移动追随 然后每个僵尸继承这个脚本 在自己的场景脚本写自己的功能

嗯,用户问的是Godot游戏引擎中设计两种不同攻击方式的僵尸的设计模式选择。用户给了三种方案:一个enemy脚本用type判断;两个僵尸场景单独写脚本;写个总体的enemy脚本,然后两个僵尸继承这个脚本,各自实现自己的功能。

我需要分析一下每种方案的优缺点,然后给出建议。首先,用type判断的话,代码会集中在同一个脚本里,用if或者match分支处理不同的攻击方式。这样会导致脚本越来越臃肿,修改一个僵尸的攻击逻辑可能会影响另一个,而且不利于扩展,每加一种新僵尸就要修改原有脚本,违反了开闭原则。不太推荐。

第二种方案:两个僵尸场景完全独立写脚本,不共享代码。这样会导致大量重复代码,比如移动、追逐玩家的逻辑每个脚本都要写一遍,维护起来很麻烦。也不推荐。

第三种方案:写一个总体enemy脚本(基类),包含通用的移动、追逐、生命值等逻辑,然后每种僵尸继承这个基类,重写攻击相关的方法(比如attack())。这是最符合面向对象设计原则的。Godot中可以使用场景继承或者脚本继承。用户提到"创建两个场景",总体enemy脚本作为基础场景,然后派生场景。这样每个派生场景只需要实现自己的攻击逻辑,基类负责通用的部分。这样扩展性好,代码复用度高,也易于维护。

用户的问题其实还隐含了一点:攻击方式不同可能还涉及到不同的攻击动画、冷却时间、范围等。继承的方式可以在子类中覆写_attack()或者定义自己的攻击行为,基类可以留出虚拟方法。

所以我会推荐第三种方案,并给出具体实现思路:创建一个Enemy基类场景,包含CharacterBody2D、CollisionShape、Sprite等,挂载Enemy.gd脚本,实现追逐玩家、移动、基础属性(速度、生命值),并定义一个attack()方法为空或者可覆写。然后创建两个新场景,比如ZombieMelee和ZombieRanged,分别继承Enemy场景,修改外观和攻击行为,覆写attack()方法实现近战攻击或远程投射物攻击。这样符合Godot的最佳实践。

另外也可以考虑使用组件模式(组合),但用户问的是继承还是type判断,所以继承方案更适合。我会解释为什么type判断不好,以及继承的优势。

这个问题问得很典型,直接决定了项目后期的可维护性和扩展性。

先说结论:强烈推荐第三种方案------写一个总体 Enemy 基类脚本,然后每种僵尸继承并实现自己的攻击逻辑。

下面我详细解释原因,并给出具体实现思路。

为什么不选另外两种?

❌ 一个脚本 + type 判断:初期很快,但后期会变成「意大利面条式代码」。每次加新僵尸,都要在 _attack()、_process() 里增加 if type ==,一个文件动辄上千行,调试困难,改一处可能影响所有类型。

❌ 两个完全独立的脚本:代码无法复用。移动、寻路、生命值、受伤闪白、死亡动画......这些通用逻辑要在两个文件里写两遍,改一个 bug 得改两份,维护成本翻倍。

✅ 推荐方案:继承 + 场景独立

架构思路:

基础 Enemy 场景:包含 CharacterBody2D、碰撞体、精灵、动画等节点。

基础 Enemy 脚本:实现通用功能------跟随玩家、生命值、受伤/死亡、移动动画,并声明一个钩子函数供子类覆盖。

具体僵尸场景:继承基础场景,只重写攻击相关的部分。

代码示例(Godot 4)

go 复制代码
Enemy.gd(基础类)

gdscript
extends CharacterBody2D

@export var speed = 50
@export var hp = 3
var player: CharacterBody2D

# 重要:声明一个空的攻击函数,子类必须覆盖它


func attack() -> void:
    pass  # 什么都不做,等待子类实现

func _ready() -> void:
    player = get_tree().get_first_node_in_group("player")

func _physics_process(delta: float) -> void:
    if player:
        var direction = (player.global_position - global_position).normalized()
        velocity = direction * speed
        move_and_slide()
    
    # 如果碰到了玩家,调用攻击(子类会用自己的实现)
    for i in get_slide_collision_count():
        var collision = get_slide_collision(i)
        if collision.get_collider() == player:
            attack()  # 多态:实际执行的是子类的方法
            break

func take_damage(amount: int) -> void:
    hp -= amount
    if hp <= 0:
        queue_free()
    else:
        # 闪白、受击动画等通用逻辑
        pass
go 复制代码
MeleeZombie.gd(近战僵尸,继承 Enemy.gd)

gdscript
extends "res://Enemy.gd"

@export var attack_damage = 1
@export var attack_cooldown = 1.0
var can_attack = true


# 覆盖父类的 attack 方法

func attack() -> void:
    if can_attack:
        can_attack = false
        player.take_damage(attack_damage)
        # 播放攻击动画
        $AnimationPlayer.play("attack")
        await get_tree().create_timer(attack_cooldown).timeout
        can_attack = true
go 复制代码
RangedZombie.gd(远程僵尸,同样继承 Enemy.gd)

gdscript
extends "res://Enemy.gd"

@export var projectile_scene: PackedScene
@export var shoot_range = 150

func attack() -> void:
    if global_position.distance_to(player.global_position) <= shoot_range:
        var bullet = projectile_scene.instantiate()
        bullet.global_position = global_position
        bullet.direction = (player.global_position - global_position).normalized()
        get_tree().root.add_child(bullet)
        # 可选:播放远程攻击动画或冷却

如何在编辑器里创建具体僵尸?

先做好基础场景 Enemy.tscn,挂载 Enemy.gd

在文件系统面板里,右键 Enemy.tscn → 新建继承,就会自动生成 MeleeZombie.tscn 和 RangedZombie.tscn。

打开继承出的场景,把它们的脚本分别改为 MeleeZombie.gdRangedZombie.gd(注意继承关系仍保持)。

调整各自的外观(比如近战僵尸拿拳头,远程僵尸拿投掷物)、攻击参数、动画。

什么时候可以考虑「type 判断」?

只有一种情况:两种僵尸的差异极小,比如只是一个攻击力数值不同。那么直接在基础类里加 @export var attack_damage,通过拖拽调节即可,不需要写子类。

但一旦攻击方式(近战 vs 远程、冰 vs 火、召唤 vs 自爆)、攻击动画、攻击特效不同,就必须用继承。

进阶思路:组合优于继承

如果未来僵尸类型非常多(20+种),且攻击方式可以任意组合(比如「近战+毒属性」、「远程+冰冻」),可以考虑组件模式:

基础 Enemy 只负责移动和生命。

AttackComponent 是一个独立的资源或节点,近战、远程、爆炸分别做成不同的组件。

在僵尸场景里拖入需要的组件即可。

但对大多数游戏来说,继承方案已经足够清晰和简单。

总结

用 基础 Enemy 场景 + 脚本 写通用移动、生命、追逐逻辑。

用 场景继承 创建具体的僵尸类型。

在基础脚本里定义一个 attack() 虚函数,子类重写它。

父类在碰撞到玩家时自动调用 attack(),实现多态。

这样做出来的僵尸系统:增加新类型无需改动现有代码,修改通用逻辑一处生效,代码干净易调试。

请说下标准做法是啥

我们被问到"标准做法是啥"。用户之前问了三种设计方式:一个脚本用type判断、两个独立脚本、总体基类+继承。现在要求"标准做法"。需要给出游戏开发领域公认的最佳实践。答案应该是继承+多态,组合也可。需要简洁说明标准做法及理由。

游戏开发中的标准做法是:继承 + 多态。

具体来说:

一个基础 Enemy 类(或场景)

封装所有僵尸共有的逻辑:

移动(跟随玩家)

生命值系统(受伤、死亡)

动画状态机(移动、受击)

碰撞检测

一个虚方法(如 attack()),默认空实现或抛出错误。

每种僵尸单独继承这个基类

重写 attack() 方法实现各自独特的攻击逻辑(近战、远程、自爆等)。

若需要额外数据(射程、冷却、投射物场景),在子类中添加导出变量。

场景结构

基础场景 Enemy.tscn(挂载基类脚本)

继承场景 MeleeZombie.tscn、RangedZombie.tscn(各自挂载子类脚本,调整外观和参数)

为什么这是标准做法?

原则 说明

开闭原则 对扩展开放(新增僵尸只需写新子类),对修改关闭(无需改动基类或其他僵尸)

单一职责 基类管移动/生存,子类管攻击,各司其职

代码复用 移动、受伤、追逐等逻辑只写一次,所有僵尸自动获得

类型安全 编辑器里可直接识别不同僵尸的专属导出变量,不会出现 type == 1 这种魔法数字

维护成本低 修复通用 bug 只改基类;调整某类僵尸攻击只改对应子类,互不影响

与"组合"的比较

组合(组件模式)在某些大型项目中也被推崇,但它比继承更复杂,适合攻击方式可自由组合(如"近战+毒素"、"远程+爆炸")的场景。

对于"两种僵尸攻击方式不同"这种明确、有限的变化,继承是更简单、更标准的答案。

一句话总结

标准做法 = 基类写通用逻辑 + 子类重写攻击方法 + 场景继承,这是面向对象游戏设计中验证过的最佳实践。

那这是不是我说的第三种方法

我们确认一下:用户之前提出了三种方案:

一个enemy脚本写出两种僵尸的逻辑,根据type判断

做两种僵尸场景单独写脚本

写个总体的enemy脚本,创建两个场景,总体enemy脚本写基础的移动追随,然后每个僵尸继承这个脚本,在自己的场景脚本写自己的功能

然后用户问"那这是不是我说的第三种方法"。显然,我推荐的"标准做法"就是用户说的第三种方法。需要确认回答:是的。

是的,完全正确。

你原文中的第三种方案------「写个总体的enemy脚本,创建两个场景,总体enemy脚本写基础的移动追随,然后每个僵尸继承这个脚本,在自己的场景脚本写自己的功能」------正是我描述的标准做法,也就是继承 + 多态。

两者的核心完全一致:

一个基础 Enemy 脚本(负责移动、生命等通用逻辑)

两个继承自它的子场景/子脚本(各自实现独特的攻击方式)

所以你当初想到的第三种方案就是业界标准做法,可以直接采用。

重要:声明一个空的攻击函数,子类必须覆盖它 为啥声明 为啥不子类自己写 还得覆盖

我们被问到:为什么要在基类中声明一个空的 attack() 函数,而不是让子类自己直接写 attack()?用户认为子类自己写就可以,不需要覆盖。需要解释声明空函数(或虚函数)的必要性。

关键点:多态。基类中的碰撞检测逻辑需要调用 attack(),但基类不知道子类的具体实现。如果基类中不声明 attack(),那么当基类代码中写 attack() 时,会报错(方法不存在)。声明一个空函数或者虚函数(甚至可以是抽象函数)是为了让基类能够调用它,而子类通过覆盖提供具体实现。这样基类的 _physics_process 中就可以统一调用 attack(),而不需要知道子类的类型。

如果不在基类中声明,子类自己写 attack(),基类代码中如何调用?要么基类根本不调用 attack(),而是由子类在各自的 _physics_process 中重写整个逻辑,这会导致代码重复(移动逻辑也要重写)。所以必须声明。

此外,声明为空函数是一种常见的做法(虚函数),也可以使用 assert(false, "必须实现") 或 Godot 4 的 func attack() -> void: push_error("not implemented")。但最简单的就是 pass。

答案需要解释:为了让基类能够调用 attack() 实现多态,以及避免重复代码。

这是一个很好的问题,体现了对多态机制的深入思考。

核心答案:因为基类的 _physics_process 中需要调用 attack(),而基类本身不知道攻击应该怎么实现。如果不声明,基类代码里写 attack() 就会报错。

详细解释:

  1. 基类负责"何时攻击",子类负责"如何攻击"
    在你的设计中,基类 Enemy 已经统一处理了攻击的触发条件------碰撞到玩家时就调用 attack()。这个逻辑写在基类的 _physics_process 里:
go 复制代码
gdscript
# 基类 Enemy.gd
func _physics_process(delta):
    # ... 移动逻辑 ...
    if 碰撞到玩家:
        attack()   # 👈 这里调用了 attack()

这个 attack() 必须存在,否则 Godot 会报错 Invalid call. Nonexistent function 'attack'。

  1. 基类无法实现具体的攻击

    基类不知道每个子类要怎样攻击------近战需要减血和冷却,远程需要发射子弹。所以基类只能提供一个"空壳",让子类去填充具体内容。这个空壳就是声明一个空的 attack() 函数。

  2. 如果不声明,子类自己写会怎样?

    假设你不在基类里写 attack(),只在子类里写:

近战子类:func attack(): 减少玩家血量

远程子类:func attack(): 发射子弹

那么基类的 _physics_process 中 attack() 这一行就会报错,因为基类没有这个方法。你只能删掉基类里的 attack() 调用,然后把整个碰撞检测 + 攻击逻辑复制到每个子类。结果就是:每个子类都要重复写相同的碰撞检测代码,违背了 DRY(Don't Repeat Yourself)原则。

  1. 声明空函数的作用
    提供接口:告诉所有子类"你们必须实现一个 attack() 方法"。

允许基类调用:基类可以安全地写 attack(),因为知道这个方法存在(即使是空实现)。

实现多态:运行时,实际调用的是子类覆盖后的版本。

  1. 更好的做法:使用虚方法或抽象方法
    Godot 4 支持 func attack() -> void: 空实现,也可以更严谨地写:
go 复制代码
gdscript
func attack() -> void:
    assert(false, "子类必须实现 attack() 方法")

或者用 push_error。这样如果你忘记在子类中覆盖,运行时会收到明确的错误提示。

总结

基类声明空函数是为了让基类能够统一触发攻击,同时将攻击的具体实现留给子类。如果不在基类声明,基类代码就无法调用 attack(),导致通用逻辑被迫重复到每个子类中,失去继承的意义。

这就像一张表格:基类填好了"什么条件下攻击"(触发时机),但"攻击的具体动作"这一栏留白,让子类各自填写。

physics_process如果基类不写这个attack 那是不是不用谢attack 然后子类继承

我们仔细分析用户问题:用户问的是"physics_process如果基类不写这个attack 那是不是不用谢attack 然后子类继承"。这里"谢"应该是"写"的笔误。用户想知道:如果在基类的 _physics_process 中不调用 attack(),那么基类是不是就不用写 attack() 函数,然后让子类自己写自己的攻击逻辑(但子类可能也需要检测碰撞并调用自己的攻击)?

需要解释:如果基类不调用 attack(),那基类就完全不负责攻击触发,那么每个子类需要自己实现碰撞检测和攻击逻辑,这样通用代码就无法复用。所以标准做法是基类调用一个虚函数(如 attack()),子类覆盖。如果基类不写 attack() 也不调用,那确实不用写,但那样子类也无法利用基类的碰撞检测来统一触发攻击。

用户问"是不是不用写attack 然后子类继承",可以回答:是的,基类可以不写 attack(),但这样基类的 _physics_process 中也就不应该有攻击触发逻辑。子类需要各自实现完整的碰撞检测和攻击,代码重复。所以不推荐。

需要给出清晰解释。

你的理解是对的:如果基类的 _physics_process 里完全不调用 attack(),那基类确实不需要声明 attack() 函数。

但这样做的代价是:基类无法统一处理"攻击时机"(比如碰撞到玩家时攻击)。每个子类必须自己重写 _physics_process,在里面写碰撞检测和攻击逻辑,导致大量重复代码。

两种方案对比

方案 基类写法 子类写法 代码复用

基类调用 attack()(标准做法) _physics_process 中检测碰撞并调用 attack();基类声明空 attack() 只需重写 attack(),无需关心碰撞检测 高(碰撞检测只写一次)

基类不写 attack() _physics_process 中不处理攻击 每个子类重写 _physics_process,自己检测碰撞并实现攻击 低(碰撞检测代码重复)

示例:不写 attack() 会导致什么?

基类 Enemy.gd(只负责移动)

go 复制代码
gdscript
func _physics_process(delta):
    # 只有移动逻辑,完全不处理攻击
    var direction = (player.global_position - global_position).normalized()
    velocity = direction * speed
    move_and_slide()
go 复制代码
近战子类 MeleeZombie.gd

gdscript
func _physics_process(delta):
    # 必须先调用父类方法,让移动生效
    super._physics_process(delta)
    # 然后自己检测碰撞
    for i in get_slide_collision_count():
        var collision = get_slide_collision(i)
        if collision.get_collider() == player:
            # 自己的攻击逻辑
            player.take_damage(1)

远程子类 RangedZombie.gd 也需要把上述碰撞检测代码再写一遍。如果将来要修改攻击触发条件(比如增加攻击范围、增加冷却),每个子类都得改。

结论

如果你不写 attack(),那基类的 _physics_process 里就不该有任何攻击相关的代码。 这样是可以工作的,但每个子类都得重复实现碰撞检测。

标准做法仍然推荐在基类里声明 attack() 并在 _physics_process 中调用它,这样碰撞检测只写一次,子类只需关心"怎么攻击"。

所以回到你的问题:"是不是不用写 attack,然后子类继承?"

→ 技术上可以,但会丢失代码复用,不推荐。 标准做法是需要写(声明)的。

相关推荐
天人合一peng1 天前
unity 生成标记根据背景色标记变色
unity·游戏引擎
OSwich1 天前
【 Godot 4 学习笔记】数组(Array)
笔记·学习·godot
天人合一peng1 天前
unity 生成标记根据背景色变色为明显的颜色
unity·游戏引擎
魔士于安1 天前
Unity 超市总动员 超市收银台 超市货架 超市购物手推车 超市常见商品
游戏·unity·游戏引擎·贴图·模型
CandyU21 天前
Unity —— 数据持久化
unity·游戏引擎
zh路西法1 天前
【Unity实现Oneshot胶卷显形】游戏窗口化与Win32API的使用
游戏·unity·游戏引擎
迪捷软件1 天前
显控系统虚拟仿真的工程化路径
游戏引擎·cocos2d
Swift社区1 天前
传统游戏引擎 vs 鸿蒙 System 架构
架构·游戏引擎·harmonyos
mxwin2 天前
Unity Shader 半透明物体为什么不能写入深度缓冲?
unity·游戏引擎·shader
晚枫歌F2 天前
三层时间轮的实现
网络·unity·游戏引擎