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,然后子类继承?"

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

相关推荐
mxwin9 小时前
Unity Shader 跨平台兼容性:处理纹理坐标翻转与精度差异
unity·游戏引擎
王家视频教程图书馆9 小时前
godot 下载地址
游戏引擎·godot
small-pudding13 小时前
Unity URP + Compute Shader 路径追踪器实战:从可用到可优化
unity·游戏引擎
weixin_4239950014 小时前
unity 物体转向鼠标点击方向2d和3d
unity·计算机外设·游戏引擎
mxwin14 小时前
Unity URP 下 Shader 变体 (Variants):multi_compile 与 shader_feature的关键字管理及变体爆炸防控策略
unity·游戏引擎
RReality15 小时前
【Unity Shader URP】全息扫描线(Hologram Scanline)源码+脚本控制
ui·unity·游戏引擎·图形渲染
渔民小镇17 小时前
一次编写到处对接 —— 为 Godot/Unity/React 生成统一交互接口
java·分布式·游戏·unity·godot
RReality1 天前
【Unity Shader URP】序列帧动画(Sprite Sheet)实战教程
unity·游戏引擎
mxwin1 天前
Unity URP 多线程渲染:理解 Shader 变体对加载时间的影响
unity·游戏引擎·shader