4. 自动战斗系统
2026/4/13大约 11 分钟
自动战斗系统
自动攻击:玩家只需要跑
在割草游戏中,最神奇的事情是:你什么都不用按,武器自己就会打。你只需要操心一件事——往哪里跑、躲避哪个方向的敌人。攻击的事,完全交给自动战斗系统。
这一章我们要实现完整的自动战斗系统,包括:武器基类、旋转刀片武器、弹幕武器、火焰AOE武器等。
自动攻击的设计思路
核心问题:武器怎么知道打谁?
想象你是一个自动哨塔。你的工作规则很简单:
- 每隔一段时间"看一眼"周围
- 找到最近的敌人
- 朝它开火
这就是自动攻击的基本逻辑。不同的武器只是在"怎么开火"上有区别:
| 武器类型 | 攻击方式 | 比喻 |
|---|---|---|
| 旋转刀片 | 围绕角色旋转的刀片 | 像直升机螺旋桨 |
| 弹幕 | 朝最近的敌人发射子弹 | 像自动炮塔 |
| 火焰AOE | 角色周围持续燃烧区域 | 像一个火炉 |
| 闪电链 | 从敌人跳到另一个敌人 | 像静电球 |
武器的共同属性
不管什么武器,都有这些基本属性:
| 属性 | 含义 | 举例 |
|---|---|---|
| 伤害 | 每次攻击打掉多少血 | 刀片每转一圈打10点 |
| 攻击间隔 | 两次攻击之间等多久 | 弹幕每0.3秒射一发 |
| 攻击范围 | 能打到多远的敌人 | 火焰AOE半径2个单位 |
| 等级 | 武器的强化等级 | 等级1射1发,等级5射5发 |
| 最大等级 | 能升到几级 | 通常最多8级 |
武器基类设计
所有武器都继承自同一个基类。就像所有动物都是"生物"的子类一样——它们都有"生命值"和"移动"这些共同特征,但具体怎么移动(飞、跑、游)各不相同。
C
// WeaponBase.cs
// 所有武器的基类,定义了武器的通用行为
using Godot;
using System.Collections.Generic;
public partial class WeaponBase : Node3D
{
// === 武器基本属性 ===
[Export] public string WeaponName = "基础武器";
[Export] public float BaseDamage = 10f;
[Export] public float BaseCooldown = 1.0f; // 攻击间隔(秒)
[Export] public float BaseRange = 3f; // 攻击范围
[Export] public int MaxLevel = 8; // 最大等级
// === 运行时状态 ===
public int CurrentLevel { get; private set; } = 1;
public bool IsEquipped { get; set; } = true;
// 冷却计时器
protected float _cooldownTimer = 0f;
// 引用
protected Node3D _player;
protected Node3D _enemyContainer;
/// <summary>
/// 当前等级的实际伤害(子类可以重写)
/// </summary>
public virtual float GetDamage()
{
return BaseDamage * (1 + (CurrentLevel - 1) * 0.2f);
}
/// <summary>
/// 当前等级的实际冷却时间
/// </summary>
public virtual float GetCooldown()
{
// 每级减少5%冷却,最多减少50%
float reduction = Mathf.Min((CurrentLevel - 1) * 0.05f, 0.5f);
return BaseCooldown * (1 - reduction);
}
/// <summary>
/// 当前等级的实际攻击范围
/// </summary>
public virtual float GetRange()
{
return BaseRange * (1 + (CurrentLevel - 1) * 0.1f);
}
public override void _Ready()
{
_player = GetParent()?.GetParent() as Node3D;
_enemyContainer = GetTree().CurrentScene
.GetNodeOrNull<Node3D>("Enemies");
}
public override void _PhysicsProcess(double delta)
{
if (!IsEquipped) return;
// 更新冷却计时器
_cooldownTimer -= (float)delta;
// 冷却结束,执行攻击
if (_cooldownTimer <= 0)
{
PerformAttack();
_cooldownTimer = GetCooldown();
}
}
/// <summary>
/// 执行攻击(子类必须重写,实现各自的攻击逻辑)
/// </summary>
protected virtual void PerformAttack()
{
// 基类不做任何事,由子类实现
}
/// <summary>
/// 升级武器
/// </summary>
public virtual bool LevelUp()
{
if (CurrentLevel >= MaxLevel) return false;
CurrentLevel++;
OnLevelUp();
return true;
}
/// <summary>
/// 升级时的回调(子类可以重写,添加额外效果)
/// </summary>
protected virtual void OnLevelUp()
{
GD.Print($"{WeaponName} 升级到 Lv.{CurrentLevel}!");
}
/// <summary>
/// 找到范围内最近的敌人
/// </summary>
protected Node3D FindNearestEnemy()
{
if (_enemyContainer == null || _player == null)
return null;
float range = GetRange();
Node3D nearest = null;
float minDistance = range;
foreach (Node3D child in _enemyContainer.GetChildren())
{
float distance = _player.GlobalPosition.DistanceTo(
child.GlobalPosition);
if (distance < minDistance)
{
minDistance = distance;
nearest = child;
}
}
return nearest;
}
/// <summary>
/// 获取范围内的所有敌人
/// </summary>
protected List<Node3D> GetEnemiesInRange()
{
var result = new List<Node3D>();
if (_enemyContainer == null || _player == null)
return result;
float range = GetRange();
foreach (Node3D child in _enemyContainer.GetChildren())
{
float distance = _player.GlobalPosition.DistanceTo(
child.GlobalPosition);
if (distance <= range)
{
result.Add(child);
}
}
return result;
}
}GDScript
# weapon_base.gd
# 所有武器的基类,定义了武器的通用行为
extends Node3D
# === 武器基本属性 ===
@export var weapon_name: String = "基础武器"
@export var base_damage: float = 10.0
@export var base_cooldown: float = 1.0 # 攻击间隔(秒)
@export var base_range: float = 3.0 # 攻击范围
@export var max_level: int = 8 # 最大等级
# === 运行时状态 ===
var current_level: int = 1
var is_equipped: bool = true
# 冷却计时器
var _cooldown_timer: float = 0.0
# 引用
var _player: Node3D
var _enemy_container: Node3D
## 当前等级的实际伤害(子类可以重写)
func get_damage() -> float:
return base_damage * (1 + (current_level - 1) * 0.2)
## 当前等级的实际冷却时间
func get_cooldown() -> float:
# 每级减少5%冷却,最多减少50%
var reduction = minf((current_level - 1) * 0.05, 0.5)
return base_cooldown * (1 - reduction)
## 当前等级的实际攻击范围
func get_range() -> float:
return base_range * (1 + (current_level - 1) * 0.1)
func _ready():
_player = get_parent().get_parent() as Node3D
_enemy_container = get_tree().current_scene.get_node_or_null("Enemies")
func _physics_process(delta):
if not is_equipped:
return
# 更新冷却计时器
_cooldown_timer -= delta
# 冷却结束,执行攻击
if _cooldown_timer <= 0:
perform_attack()
_cooldown_timer = get_cooldown()
## 执行攻击(子类必须重写,实现各自的攻击逻辑)
func perform_attack():
# 基类不做任何事,由子类实现
pass
## 升级武器
func level_up() -> bool:
if current_level >= max_level:
return false
current_level += 1
on_level_up()
return true
## 升级时的回调(子类可以重写,添加额外效果)
func on_level_up():
print("%s 升级到 Lv.%d!" % [weapon_name, current_level])
## 找到范围内最近的敌人
func find_nearest_enemy() -> Node3D:
if _enemy_container == null or _player == null:
return null
var range_val = get_range()
var nearest: Node3D = null
var min_distance: float = range_val
for child in _enemy_container.get_children():
var distance = _player.global_position.distance_to(
child.global_position)
if distance < min_distance:
min_distance = distance
nearest = child
return nearest
## 获取范围内的所有敌人
func get_enemies_in_range() -> Array:
var result = []
if _enemy_container == null or _player == null:
return result
var range_val = get_range()
for child in _enemy_container.get_children():
var distance = _player.global_position.distance_to(
child.global_position)
if distance <= range_val:
result.append(child)
return result关于继承
武器基类就像一个"模板"。它定义了所有武器都有的功能(冷却计时、寻找敌人、升级),但把"具体怎么攻击"留给每种武器自己去实现。这就是面向对象编程中的继承思想。
旋转刀片武器
旋转刀片是最经典的割草武器。一个(或多个)刀片围绕角色旋转,碰到敌人就造成伤害。
实现思路
- 创建一个 Area3D 节点(可以检测和谁碰到了)
- 让它围绕玩家旋转
- 碰到敌人时造成伤害,并给敌人一个"无敌时间"防止同一刀片反复伤害
C
// SpinningBlade.cs
// 旋转刀片武器:围绕角色旋转的刀片
using Godot;
public partial class SpinningBlade : WeaponBase
{
// 刀片配置
[Export] public float RotationSpeed = 360f; // 旋转速度(度/秒)
[Export] public float BladeRadius = 1.5f; // 旋转半径
// 刀片节点引用
private Area3D _bladeArea;
private MeshInstance3D _bladeMesh;
// 已受伤的敌人(防止重复伤害)
private HashSet<Node3D> _hitEnemies = new();
public override void _Ready()
{
base._Ready();
WeaponName = "旋转刀片";
BaseDamage = 15f;
BaseCooldown = 0.5f;
BaseRange = 2f;
// 创建刀片Area3D
CreateBlade();
}
private void CreateBlade()
{
_bladeArea = new Area3D();
_bladeArea.Name = "BladeArea";
// 碰撞形状(圆形,表示刀片的伤害范围)
var shape = new CircleShape2D();
shape.Radius = 0.3f;
// 注意:3D中用SphereShape3D
var collisionShape = new CollisionShape3D();
var sphere = new SphereShape3D();
sphere.Radius = 0.3f;
collisionShape.Shape = sphere;
_bladeArea.AddChild(collisionShape);
// 刀片外观(简单的方块代表刀片)
_bladeMesh = new MeshInstance3D();
var box = new BoxMesh();
box.Size = new Vector3(0.6f, 0.1f, 0.1f);
var material = new StandardMaterial3D();
material.AlbedoColor = new Color(0.9f, 0.9f, 0.9f); // 银白色
box.SurfaceSetMaterial(0, material);
_bladeMesh.Mesh = box;
_bladeArea.AddChild(_bladeMesh);
AddChild(_bladeArea);
// 连接碰撞信号
_bladeArea.BodyEntered += OnBladeHit;
}
protected override void PerformAttack()
{
// 旋转刀片不需要"攻击"动作,它在持续旋转
// 但我们需要在每次"攻击节拍"清空已击中列表
// 这样刀片转一圈可以再次伤害同一个敌人
_hitEnemies.Clear();
}
public override void _PhysicsProcess(double delta)
{
base._PhysicsProcess(delta);
if (_player == null) return;
// 让刀片围绕玩家旋转
float angle = (float)(Time.GetTicksMsec() / 1000.0 * RotationSpeed);
float rad = Mathf.DegToRad(angle);
// 计算刀片位置(以玩家为中心的圆周运动)
Vector3 offset = new Vector3(
Mathf.Cos(rad) * BladeRadius,
0.5f, // 稍微抬高一点
Mathf.Sin(rad) * BladeRadius
);
_bladeArea.GlobalPosition = _player.GlobalPosition + offset;
// 刀片面向运动方向(切线方向)
_bladeArea.Rotation = new Vector3(
0,
-rad + Mathf.Pi / 2,
0
);
}
private void OnBladeHit(Node3D body)
{
// 防止重复伤害同一个敌人
if (_hitEnemies.Contains(body)) return;
_hitEnemies.Add(body);
// 对敌人造成伤害
if (body.HasMethod("TakeDamage"))
{
body.Call("TakeDamage", GetDamage());
}
}
protected override void OnLevelUp()
{
base.OnLevelUp();
// 每升2级增加旋转速度
RotationSpeed += 30f;
// 每升3级增加旋转半径
if (CurrentLevel % 3 == 0)
{
BladeRadius += 0.2f;
}
}
}GDScript
# spinning_blade.gd
# 旋转刀片武器:围绕角色旋转的刀片
extends "res://scripts/weapons/weapon_base.gd"
# 刀片配置
@export var rotation_speed: float = 360.0 # 旋转速度(度/秒)
@export var blade_radius: float = 1.5 # 旋转半径
# 刀片节点引用
var _blade_area: Area3D
var _blade_mesh: MeshInstance3D
# 已受伤的敌人(防止重复伤害)
var _hit_enemies = []
func _ready():
super._ready()
weapon_name = "旋转刀片"
base_damage = 15.0
base_cooldown = 0.5
base_range = 2.0
# 创建刀片Area3D
create_blade()
func create_blade():
_blade_area = Area3D.new()
_blade_area.name = "BladeArea"
# 碰撞形状(球形)
var collision_shape = CollisionShape3D.new()
var sphere = SphereShape3D.new()
sphere.radius = 0.3
collision_shape.shape = sphere
_blade_area.add_child(collision_shape)
# 刀片外观(简单的方块代表刀片)
_blade_mesh = MeshInstance3D.new()
var box = BoxMesh.new()
box.size = Vector3(0.6, 0.1, 0.1)
var material = StandardMaterial3D.new()
material.albedo_color = Color(0.9, 0.9, 0.9) # 银白色
box.surface_set_material(0, material)
_blade_mesh.mesh = box
_blade_area.add_child(_blade_mesh)
add_child(_blade_area)
# 连接碰撞信号
_blade_area.body_entered.connect(on_blade_hit)
func perform_attack():
# 旋转刀片不需要"攻击"动作,它在持续旋转
# 但我们需要在每次"攻击节拍"清空已击中列表
_hit_enemies.clear()
func _physics_process(delta):
super._physics_process(delta)
if _player == null:
return
# 让刀片围绕玩家旋转
var angle = Time.get_ticks_msec() / 1000.0 * rotation_speed
var rad = deg_to_rad(angle)
# 计算刀片位置(以玩家为中心的圆周运动)
var offset = Vector3(
cos(rad) * blade_radius,
0.5, # 稍微抬高一点
sin(rad) * blade_radius
)
_blade_area.global_position = _player.global_position + offset
# 刀片面向运动方向(切线方向)
_blade_area.rotation = Vector3(0, -rad + PI / 2, 0)
func on_blade_hit(body: Node3D):
# 防止重复伤害同一个敌人
if body in _hit_enemies:
return
_hit_enemies.append(body)
# 对敌人造成伤害
if body.has_method("take_damage"):
body.take_damage(get_damage())
func on_level_up():
super.on_level_up()
# 每升2级增加旋转速度
rotation_speed += 30.0
# 每升3级增加旋转半径
if current_level % 3 == 0:
blade_radius += 0.2弹幕武器
弹幕武器会自动朝最近的敌人发射子弹。升级后可以同时发射多发子弹。
实现思路
- 找到最近的敌人
- 生成一颗子弹(RigidBody3D 或 Area3D)
- 子弹朝敌人方向飞行
- 子弹碰到敌人造成伤害后消失
C
// BulletWeapon.cs
// 弹幕武器:自动朝最近敌人发射子弹
using Godot;
public partial class BulletWeapon : WeaponBase
{
[Export] public PackedScene BulletScene;
[Export] public float BulletSpeed = 15f;
// 每级增加的弹幕数量
private int GetBulletCount()
{
return 1 + (CurrentLevel - 1) / 2; // 每2级+1发
}
public override void _Ready()
{
base._Ready();
WeaponName = "弹幕";
BaseDamage = 8f;
BaseCooldown = 0.4f;
BaseRange = 8f;
}
protected override void PerformAttack()
{
if (_player == null) return;
Node3D target = FindNearestEnemy();
if (target == null) return;
int count = GetBulletCount();
float spreadAngle = 15f; // 多发子弹的散布角度
for (int i = 0; i < count; i++)
{
// 计算朝向目标的角度
Vector3 direction = (target.GlobalPosition
- _player.GlobalPosition).Normalized();
// 多发子弹时添加散布
if (count > 1)
{
float angleOffset = (i - (count - 1) / 2f) * spreadAngle;
direction = direction.Rotated(
Vector3.Up,
Mathf.DegToRad(angleOffset)
);
}
SpawnBullet(direction);
}
}
private void SpawnBullet(Vector3 direction)
{
var bullet = BulletScene.Instantiate<Node3D>();
bullet.GlobalPosition = _player.GlobalPosition;
// 设置子弹速度和伤害
if (bullet is Bullet bulletScript)
{
bulletScript.Initialize(direction, BulletSpeed, GetDamage());
}
// 添加到弹幕容器
var container = GetTree().CurrentScene
.GetNodeOrNull<Node3D>("Projectiles");
container?.AddChild(bullet);
}
}
// Bullet.cs - 子弹脚本
public partial class Bullet : Area3D
{
private Vector3 _direction;
private float _speed;
private float _damage;
private float _lifetime = 3f;
public void Initialize(Vector3 dir, float spd, float dmg)
{
_direction = dir;
_speed = spd;
_damage = dmg;
}
public override void _PhysicsProcess(double delta)
{
// 子弹朝目标方向飞行
GlobalPosition += _direction * _speed * (float)delta;
_lifetime -= (float)delta;
// 超时自动销毁
if (_lifetime <= 0)
QueueFree();
}
private void OnBodyEntered(Node3D body)
{
// 碰到敌人造成伤害
if (body.HasMethod("TakeDamage"))
{
body.Call("TakeDamage", _damage);
}
QueueFree(); // 子弹消失
}
}GDScript
# bullet_weapon.gd
# 弹幕武器:自动朝最近敌人发射子弹
extends "res://scripts/weapons/weapon_base.gd"
@export var bullet_scene: PackedScene
@export var bullet_speed: float = 15.0
## 每级增加的弹幕数量
func get_bullet_count() -> int:
return 1 + (current_level - 1) / 2 # 每2级+1发
func _ready():
super._ready()
weapon_name = "弹幕"
base_damage = 8.0
base_cooldown = 0.4
base_range = 8.0
func perform_attack():
if _player == null:
return
var target = find_nearest_enemy()
if target == null:
return
var count = get_bullet_count()
var spread_angle = 15.0 # 多发子弹的散布角度
for i in range(count):
# 计算朝向目标的角度
var direction = (target.global_position \
- _player.global_position).normalized()
# 多发子弹时添加散布
if count > 1:
var angle_offset = (i - (count - 1) / 2.0) * spread_angle
direction = direction.rotated(
Vector3.UP,
deg_to_rad(angle_offset)
)
spawn_bullet(direction)
func spawn_bullet(direction: Vector3):
var bullet = bullet_scene.instantiate()
bullet.global_position = _player.global_position
# 设置子弹速度和伤害
if bullet.has_method("initialize"):
bullet.initialize(direction, bullet_speed, get_damage())
# 添加到弹幕容器
var container = get_tree().current_scene \
.get_node_or_null("Projectiles")
if container:
container.add_child(bullet)武器升级效果一览
每种武器升级后都有不同的效果提升:
| 武器 | 等级1 | 等级4 | 等级8(满级) |
|---|---|---|---|
| 旋转刀片 | 1把刀片,慢速旋转 | 1把刀片,快速旋转 | 1把刀片,超快旋转+大范围 |
| 弹幕 | 1发,单方向 | 2发,小散布 | 4发,扇形散射 |
| 火焰AOE | 小范围,低伤害 | 中范围,中伤害 | 大范围,高伤害+减速 |
| 闪电链 | 连1个敌人 | 连2个敌人 | 连5个敌人 |
升级平衡性
武器升级不能太强也不能太弱。如果一把武器满级后太强,玩家就没有动力去尝试其他武器了。建议每把武器都有"短板",需要另一把武器来弥补。
总结
本章我们实现了:
- 武器基类 — 定义了所有武器的通用行为(冷却、寻找敌人、升级)
- 旋转刀片 — 围绕角色旋转的近战武器
- 弹幕武器 — 朝敌人发射子弹的远程武器
- 武器升级系统 — 每种武器有不同的升级路线
有了自动战斗系统,角色现在可以自己打怪了。但如果没有敌人,这些武器就毫无用处。下一章,我们要让敌人从四面八方涌来。
