6. 小兵与防御塔
2026/4/15大约 7 分钟
6. 小兵与防御塔:战场的推进力量
6.1 小兵系统概述
小兵(Minion)是 MOBA 游戏中最基础的战斗单位。它们定时从基地出发,沿着三条路前进,自动攻击遇到的敌人。小兵是游戏经济的核心来源——"补刀"(给小兵最后一击)是玩家获取金币的主要方式。
小兵属性
| 属性 | 近战小兵 | 远程小兵 | 炮车 |
|---|---|---|---|
| 生命值 | 400 | 280 | 800 |
| 攻击力 | 12 | 20 | 40 |
| 攻击速度 | 1.0 | 0.8 | 0.6 |
| 移动速度 | 3.5 | 3.5 | 3.0 |
| 攻击范围 | 1.5 | 5.0 | 5.0 |
| 击杀金币 | 20 | 15 | 40 |
刷兵规则
每 30 秒刷新一波小兵,从双方基地同时出发。每波小兵的组成:
| 游戏时间 | 每波组成 |
|---|---|
| 0:00 - 5:00 | 3 近战 + 1 远程 |
| 5:00 - 15:00 | 3 近战 + 2 远程 |
| 15:00 之后 | 3 近战 + 2 远程 + 1 炮车 |
6.2 小兵生成器
C#
// MinionSpawner.cs
using Godot;
public partial class MinionSpawner : Node
{
[Export] public int TeamId { get; set; } = 0; // 0=蓝方, 1=红方
[Export] public float SpawnInterval { get; set; } = 30.0f;
private float _timer;
private float _gameTime;
private LaneManager _laneManager;
private PackedScene _meleeScene;
private PackedScene _rangedScene;
private PackedScene _siegeScene;
public override void _Ready()
{
_laneManager = GetNode<LaneManager>("/root/Main/World/LaneManager");
_meleeScene = GD.Load<PackedScene>("res://scenes/units/minion_melee.tscn");
_rangedScene = GD.Load<PackedScene>("res://scenes/units/minion_ranged.tscn");
_siegeScene = GD.Load<PackedScene>("res://scenes/units/minion_siege.tscn");
}
public override void _Process(double delta)
{
_gameTime += (float)delta;
_timer += (float)delta;
if (_timer >= SpawnInterval)
{
_timer = 0;
SpawnWave();
}
}
private void SpawnWave()
{
string[] lanes = { "top", "mid", "bot" };
foreach (var lane in lanes)
{
var lanePath = _laneManager.GetLane(lane);
if (lanePath == null) continue;
var waypoints = TeamId == 0
? lanePath.BlueWaypoints
: lanePath.RedWaypoints;
if (waypoints == null || waypoints.Length == 0) continue;
Vector3 spawnPos = waypoints[0];
// 根据游戏时间决定出兵组成
SpawnMinion(_meleeScene, spawnPos, lane, waypoints, 3);
SpawnMinion(_rangedScene, spawnPos, lane, waypoints, 2);
if (_gameTime > 900) // 15分钟后出炮车
{
SpawnMinion(_siegeScene, spawnPos, lane, waypoints, 1);
}
}
}
private void SpawnMinion(PackedScene scene, Vector3 pos,
string lane, Vector3[] waypoints, int count)
{
for (int i = 0; i < count; i++)
{
var minion = scene.Instantiate<Node3D>();
// 稍微偏移一下,避免重叠
minion.Position = pos + new Vector3(
(float)GD.RandRange(-1, 1), 0,
(float)GD.RandRange(-1, 1));
minion.Set("team_id", TeamId);
minion.Set("lane_name", lane);
minion.Set("waypoints", waypoints);
GetTree().Root.GetNode("Main/World/Units").AddChild(minion);
}
}
}GDScript
# minion_spawner.gd
extends Node
@export var team_id: int = 0 # 0=蓝方, 1=红方
@export var spawn_interval: float = 30.0
var timer: float = 0
var game_time: float = 0
var lane_manager: Node
var melee_scene: PackedScene
var ranged_scene: PackedScene
var siege_scene: PackedScene
func _ready():
lane_manager = get_node("/root/Main/World/LaneManager")
melee_scene = load("res://scenes/units/minion_melee.tscn")
ranged_scene = load("res://scenes/units/minion_ranged.tscn")
siege_scene = load("res://scenes/units/minion_siege.tscn")
func _process(delta):
game_time += delta
timer += delta
if timer >= spawn_interval:
timer = 0
spawn_wave()
func spawn_wave():
var lanes = ["top", "mid", "bot"]
for lane in lanes:
var lane_path = lane_manager.get_lane(lane)
if lane_path == null:
continue
var waypoints = lane_path.blue_waypoints if team_id == 0 else lane_path.red_waypoints
if waypoints.is_empty():
continue
var spawn_pos = waypoints[0]
spawn_minion(melee_scene, spawn_pos, lane, waypoints, 3)
spawn_minion(ranged_scene, spawn_pos, lane, waypoints, 2)
if game_time > 900:
spawn_minion(siege_scene, spawn_pos, lane, waypoints, 1)
func spawn_minion(scene: PackedScene, pos: Vector3, lane: String,
waypoints: Array, count: int):
for i in count:
var minion = scene.instantiate()
minion.position = pos + Vector3(randf_range(-1, 1), 0, randf_range(-1, 1))
minion.set("team_id", team_id)
minion.set("lane_name", lane)
minion.set("waypoints", waypoints)
get_tree().root.get_node("Main/World/Units").add_child(minion)6.3 小兵 AI
小兵的 AI 逻辑很简单:沿路线前进 → 看到敌人就打 → 打完继续走。
C#
// Minion.cs
using Godot;
public partial class Minion : CharacterBody3D
{
public int TeamId { get; set; }
public string LaneName { get; set; }
public Vector3[] Waypoints { get; set; }
private int _currentWaypoint = 0;
private Node3D _target;
private float _hp;
private float _maxHp;
private float _attackDamage;
private float _attackRange;
private float _attackCooldown;
private float _attackTimer;
private float _moveSpeed = 3.5f;
public override void _Ready()
{
AddToGroup("minions");
AddToGroup(TeamId == 0 ? "blue_units" : "red_units");
_attackTimer = 0;
_currentWaypoint = 1; // 从第一个路径点之后开始
}
public override void _PhysicsProcess(double delta)
{
_attackTimer -= (float)delta;
// 查找最近的敌方单位
_target = FindNearestEnemy();
if (_target != null)
{
var dist = GlobalPosition.DistanceTo(_target.GlobalPosition);
if (dist <= _attackRange)
{
// 在攻击范围内,停下来打
if (_attackTimer <= 0)
{
Attack();
_attackTimer = _attackCooldown;
}
}
else
{
// 不在范围内,走向目标
MoveTo(_target.GlobalPosition, (float)delta);
}
}
else
{
// 没有敌人,沿路线前进
FollowWaypoints((float)delta);
}
}
private void FollowWaypoints(float delta)
{
if (_currentWaypoint >= Waypoints.Length) return;
var target = Waypoints[_currentWaypoint];
var dist = GlobalPosition.DistanceTo(target);
if (dist < 1.0f)
{
_currentWaypoint++;
return;
}
MoveTo(target, delta);
}
private void MoveTo(Vector3 target, float delta)
{
var direction = (target - GlobalPosition).Normalized();
Velocity = new Vector3(direction.X * _moveSpeed, 0, direction.Z * _moveSpeed);
LookAt(new Vector3(target.X, GlobalPosition.Y, target.Z), Vector3.Up);
MoveAndSlide();
}
private void Attack()
{
if (_target != null && _target.HasMethod("TakeDamage"))
{
_target.Call("TakeDamage", _attackDamage, "physical", this);
}
}
private Node3D FindNearestEnemy()
{
Node3D nearest = null;
float minDist = _attackRange * 3; // 搜索范围为攻击范围的3倍
foreach (var node in GetTree().GetNodesInGroup("units"))
{
var unit = node as Node3D;
if (unit == null || unit == this) continue;
// 检查是否敌方
int unitTeam = (int)unit.Get("team_id");
if (unitTeam == TeamId) continue;
var dist = GlobalPosition.DistanceTo(unit.GlobalPosition);
if (dist < minDist)
{
minDist = dist;
nearest = unit;
}
}
return nearest;
}
public void TakeDamage(float damage, string type, Node3D source)
{
_hp -= damage;
if (_hp <= 0)
{
// 死亡:给击杀者金币
if (source != null && source.IsInGroup("heroes"))
{
source.Call("GainGold", 20);
source.Call("GainExp", 30);
}
QueueFree();
}
}
}GDScript
# minion.gd
extends CharacterBody3D
var team_id: int
var lane_name: String
var waypoints: Array[Vector3]
var current_waypoint: int = 1
var target: Node3D
var hp: float
var max_hp: float
var attack_damage: float
var attack_range: float
var attack_cooldown: float
var attack_timer: float = 0
var move_speed: float = 3.5
func _ready():
add_to_group("minions")
add_to_group("blue_units" if team_id == 0 else "red_units")
func _physics_process(delta):
attack_timer -= delta
target = find_nearest_enemy()
if target:
var dist = global_position.distance_to(target.global_position)
if dist <= attack_range:
if attack_timer <= 0:
attack()
attack_timer = attack_cooldown
else:
move_to_pos(target.global_position, delta)
else:
follow_waypoints(delta)
func follow_waypoints(delta: float):
if current_waypoint >= waypoints.size():
return
var wp = waypoints[current_waypoint]
if global_position.distance_to(wp) < 1.0:
current_waypoint += 1
return
move_to_pos(wp, delta)
func move_to_pos(pos: Vector3, delta: float):
var direction = (pos - global_position).normalized()
velocity = Vector3(direction.x * move_speed, 0, direction.z * move_speed)
look_at(Vector3(pos.x, global_position.y, pos.z), Vector3.UP)
move_and_slide()
func attack():
if target and target.has_method("take_damage"):
target.take_damage(attack_damage, "physical", self)
func find_nearest_enemy() -> Node3D:
var nearest: Node3D = null
var min_dist = attack_range * 3
for node in get_tree().get_nodes_in_group("units"):
var unit = node as Node3D
if unit == null or unit == self:
continue
if unit.team_id == team_id:
continue
var dist = global_position.distance_to(unit.global_position)
if dist < min_dist:
min_dist = dist
nearest = unit
return nearest
func take_damage(damage: float, type: String, source: Node3D):
hp -= damage
if hp <= 0:
if source and source.is_in_group("heroes"):
source.gain_gold(20)
source.gain_exp(30)
queue_free()6.4 防御塔系统
防御塔是 MOBA 游戏的战略核心。推掉对方的塔,你的小兵才能继续前进。
防御塔属性
| 属性 | 外塔 | 内塔 | 高地塔 | 水晶 |
|---|---|---|---|---|
| 生命值 | 3000 | 3500 | 4000 | 5000 |
| 攻击力 | 150 | 180 | 200 | 150 |
| 攻击范围 | 8.0 | 8.5 | 9.0 | 7.0 |
| 攻击速度 | 0.83 | 0.83 | 0.83 | 0.83 |
| 护甲 | 50 | 60 | 80 | 100 |
防御塔目标优先级
防御塔按以下顺序选择攻击目标:
1. 正在攻击己方英雄的敌方英雄(最高优先级)
2. 距离最近的敌方小兵
3. 距离最近的敌方英雄
4. 距离最近的敌方召唤物C#
// Tower.cs
using Godot;
using System.Collections.Generic;
using System.Linq;
public partial class Tower : StaticBody3D
{
[Export] public int TeamId { get; set; }
[Export] public string TowerType { get; set; } = "outer"; // outer, inner, base, crystal
[Export] public float AttackRange { get; set; } = 8.0f;
[Export] public float AttackDamage { get; set; } = 150;
[Export] public float AttackCooldown { get; set; } = 1.2f;
[Export] public float MaxHp { get; set; } = 3000;
private float _hp;
private float _attackTimer;
private Node3D _currentTarget;
private bool _isDestroyed;
public override void _Ready()
{
_hp = MaxHp;
_attackTimer = 0;
AddToGroup("towers");
AddToGroup(TeamId == 0 ? "blue_units" : "red_units");
}
public override void _Process(double delta)
{
if (_isDestroyed) return;
_attackTimer -= (float)delta;
if (_attackTimer <= 0)
{
_currentTarget = SelectTarget();
if (_currentTarget != null)
{
FireAtTarget();
_attackTimer = AttackCooldown;
}
}
}
// 防御塔目标选择(按优先级)
private Node3D SelectTarget()
{
var enemiesInRange = GetEnemiesInRange();
if (enemiesInRange.Count == 0) return null;
// 优先级1:正在攻击己方英雄的敌方英雄
var attackingHero = enemiesInRange
.FirstOrDefault(e => e.IsInGroup("heroes") && IsAttackingAllyHero(e));
if (attackingHero != null) return attackingHero;
// 优先级2:距离最近的小兵
var nearestMinion = enemiesInRange
.Where(e => e.IsInGroup("minions"))
.OrderBy(e => GlobalPosition.DistanceTo(e.GlobalPosition))
.FirstOrDefault();
if (nearestMinion != null) return nearestMinion;
// 优先级3:距离最近的英雄
var nearestHero = enemiesInRange
.Where(e => e.IsInGroup("heroes"))
.OrderBy(e => GlobalPosition.DistanceTo(e.GlobalPosition))
.FirstOrDefault();
return nearestHero;
}
private List<Node3D> GetEnemiesInRange()
{
var result = new List<Node3D>();
var spaceState = GetWorld3D().DirectSpaceState;
var shape = new SphereShape3D();
shape.Radius = AttackRange;
var query = new PhysicsShapeQueryParameters3D();
query.Shape = shape;
query.Transform = new Transform3D(Basis.Identity, GlobalPosition);
var intersections = spaceState.IntersectShape(query);
foreach (var dict in intersections)
{
var collider = dict["collider"].AsGodotObject() as Node3D;
if (collider != null && collider != this)
{
int unitTeam = (int)collider.Get("team_id");
if (unitTeam != TeamId)
result.Add(collider);
}
}
return result;
}
private void FireAtTarget()
{
// 创建塔的攻击弹道
var projectileScene = GD.Load<PackedScene>("res://scenes/effects/tower_projectile.tscn");
var projectile = projectileScene.Instantiate<Node3D>();
projectile.Position = GlobalPosition + Vector3.Up * 3; // 从塔顶发射
GetTree().Root.AddChild(projectile);
if (projectile.HasMethod("Launch"))
{
projectile.Call("Launch", _currentTarget.GlobalPosition,
15.0f, AttackDamage, "physical", this, TeamId);
}
}
public void TakeDamage(float damage, string type, Node3D source)
{
if (_isDestroyed) return;
_hp -= damage;
if (_hp <= 0)
{
Destroy();
}
}
private void Destroy()
{
_isDestroyed = true;
// 给对方英雄金币奖励
foreach (var node in GetTree().GetNodesInGroup("heroes"))
{
var hero = node as Node3D;
if (hero != null && (int)hero.Get("team_id") != TeamId)
{
hero.Call("GainGold", 150);
hero.Call("GainExp", 200);
}
}
// 播放摧毁特效
// TODO: 添加爆炸特效
// 变成残骸(不可攻击但视觉上可见)
// 实际项目中可以切换模型
SetPhysicsProcess(false);
}
private bool IsAttackingAllyHero(Node3D enemy)
{
// 简化实现:检查敌人是否正在攻击范围内有己方英雄
// 实际实现需要检查敌人的攻击目标
return false;
}
}GDScript
# tower.gd
extends StaticBody3D
@export var team_id: int
@export var tower_type: String = "outer" # outer, inner, base, crystal
@export var attack_range: float = 8.0
@export var attack_damage: float = 150
@export var attack_cooldown: float = 1.2
@export var max_hp: float = 3000
var hp: float
var attack_timer: float = 0
var current_target: Node3D
var is_destroyed: bool = false
func _ready():
hp = max_hp
add_to_group("towers")
add_to_group("blue_units" if team_id == 0 else "red_units")
func _process(delta):
if is_destroyed:
return
attack_timer -= delta
if attack_timer <= 0:
current_target = select_target()
if current_target:
fire_at_target()
attack_timer = attack_cooldown
func select_target() -> Node3D:
var enemies = get_enemies_in_range()
if enemies.is_empty():
return null
# 优先级1:正在攻击己方英雄的敌方英雄
for enemy in enemies:
if enemy.is_in_group("heroes") and is_attacking_ally_hero(enemy):
return enemy
# 优先级2:距离最近的小兵
var minions = enemies.filter(func(e): return e.is_in_group("minions"))
if not minions.is_empty():
minions.sort_custom(func(a, b):
return global_position.distance_to(a.global_position) < \
global_position.distance_to(b.global_position))
return minions[0]
# 优先级3:距离最近的英雄
var heroes = enemies.filter(func(e): return e.is_in_group("heroes"))
if not heroes.is_empty():
heroes.sort_custom(func(a, b):
return global_position.distance_to(a.global_position) < \
global_position.distance_to(b.global_position))
return heroes[0]
return null
func get_enemies_in_range() -> Array:
var result = []
var space_state = get_world_3d().direct_space_state
var shape = SphereShape3D.new()
shape.radius = attack_range
var query = PhysicsShapeQueryParameters3D.new()
query.shape = shape
query.transform = Transform3D(Basis.IDENTITY, global_position)
var intersections = space_state.intersect_shape(query)
for dict in intersections:
var collider = dict["collider"] as Node3D
if collider and collider != self:
var unit_team = collider.get("team_id")
if unit_team != team_id:
result.append(collider)
return result
func fire_at_target():
var projectile_scene = load("res://scenes/effects/tower_projectile.tscn")
var projectile = projectile_scene.instantiate()
projectile.position = global_position + Vector3.UP * 3
get_tree().root.add_child(projectile)
if projectile.has_method("launch"):
projectile.launch(current_target.global_position, 15.0,
attack_damage, "physical", self, team_id)
func take_damage(damage: float, type: String, source: Node3D):
if is_destroyed:
return
hp -= damage
if hp <= 0:
destroy()
func destroy():
is_destroyed = true
for node in get_tree().get_nodes_in_group("heroes"):
var hero = node as Node3D
if hero and hero.get("team_id") != team_id:
hero.gain_gold(150)
hero.gain_exp(200)
set_physics_process(false)
func is_attacking_ally_hero(_enemy: Node3D) -> bool:
return false # 简化实现章节导航
| 上一章 | 下一章 |
|---|---|
| ← 5. 战斗与技能系统 | 7. 装备与经济系统 → |
