6. AI 敌人
2026/4/14大约 8 分钟
AI 敌人
单人游戏需要 AI 队友和敌人来填充战场。这一章我们实现一个简单但有效的 AI 系统——敌人能在地图上巡逻、发现玩家后追击和射击、甚至能安放和拆除炸弹。
AI 行为设计
CS 的 AI 不需要太复杂(毕竟 CS 主要是多人游戏),但需要能做以下几件事:
| 行为 | 说明 | 优先级 |
|---|---|---|
| 巡逻 | 在关键点之间来回走动 | 最低(默认行为) |
| 追击 | 发现敌人后追上去 | 高 |
| 射击 | 看到敌人时开枪 | 最高 |
| 安放炸弹 | T 方 AI 到达炸弹点后安放 C4 | 特定条件 |
| 拆除炸弹 | CT 方 AI 在炸弹安放后去拆除 | 特定条件 |
| 撤退 | 血量低时寻找掩体 | 中 |
简单状态机
我们用一个简单的状态机来管理 AI 的行为切换:
┌─────────┐
┌─────────│ 巡逻 │◄────────┐
│ └────┬────┘ │
│ │ │
没发现敌人 发现敌人 失去视线
│ │ │
│ ┌────▼────┐ │
│ │ 追击 ├──────────┘
│ └────┬────┘
│ │ 进入射击距离
│ ┌────▼────┐
│ │ 射击 │
│ └────┬────┘
│ │ 血量低
│ ┌────▼────┐
└────────►│ 撤退 │
└─────────┘
特殊行为(在对应状态下触发):
- T方携带C4 + 在炸弹点 → 安放炸弹
- CT方 + 炸弹已安放 → 拆除炸弹AI 角色场景
AIPlayer (CharacterBody3D) ← AI 角色根节点
├── CollisionShape3D ← 碰撞体(和玩家一样)
├── NavigationAgent3D ← 导航代理(寻路核心)
├── RayCast3D ← 视线检测(前方是否能看到敌人)
├── Head (Node3D) ← 头部(AI 的"眼睛")
│ └── MeshInstance3D ← 头部模型(可选)
├── BodyMesh (MeshInstance3D) ← 身体模型
├── WeaponHolder (Node3D) ← 武器挂载点
├── AudioStreamPlayer3D ← 音效
└── Hitbox (Area3D) ← 受击判定
├── HeadHitbox (CollisionShape3D)
├── BodyHitbox (CollisionShape3D)
└── LegHitbox (CollisionShape3D)AI 控制器实现
C#
// Scripts/Characters/AIController.cs
using Godot;
using System.Collections.Generic;
public enum AIState
{
Patrol, // 巡逻
Chase, // 追击
Attack, // 攻击
PlantBomb, // 安放炸弹
DefuseBomb, // 拆除炸弹
Dead // 已死亡
}
/// <summary>
/// AI 控制器。
///
/// AI 的"大脑"——决定当前该做什么,然后执行。
/// 使用简单状态机:巡逻 → 发现敌人 → 追击 → 射击。
/// </summary>
public partial class AIController : CharacterBody3D
{
[Export] public float MoveSpeed { get; set; } = 5.0f;
[Export] public float ChaseSpeed { get; set; } = 6.5f;
[Export] public float SightRange { get; set; } = 30f;
[Export] public float SightAngle { get; set; } = 90f; // 视野角度
[Export] public float AttackRange { get; set; } = 25f;
[Export] public float FireRate { get; set; } = 0.3f;
[Export] public float Accuracy { get; set; } = 0.7f; // 射击精度 0-1
[Export] public string Team { get; set; } = "Terrorist"; // 或 "CounterTerrorist"
[Export] public bool HasBomb { get; set; } = false;
private NavigationAgent3D _navAgent;
private AIState _state = AIState.Patrol;
private Node3D _target;
private float _fireTimer;
// 巡逻路径点
private List<Vector3> _patrolPoints = new();
private int _currentPatrolIndex;
// 炸弹相关
private bool _isPlanting;
private float _plantTimer;
private bool _isDefusing;
private float _defuseTimer;
public override void _Ready()
{
_navAgent = GetNode<NavigationAgent3D>("NavigationAgent3D");
// 如果有巡逻路径点,开始巡逻
if (_patrolPoints.Count > 0)
{
_navAgent.TargetPosition = _patrolPoints[0];
}
// 连接导航信号
_navAgent.NavigationFinished += OnNavigationFinished;
}
public override void _PhysicsProcess(double delta)
{
if (_state == AIState.Dead) return;
var dt = (float)delta;
UpdatePerception(); // 感知环境(发现敌人)
UpdateState(dt); // 更新状态
ExecuteBehavior(dt); // 执行当前行为
}
/// <summary>
/// 感知:检测视野内是否有敌人。
///
/// AI 的"眼睛"——用射线检测前方是否有敌人。
/// 只有在视野范围内且没有被墙挡住才能"看到"。
/// </summary>
private void UpdatePerception()
{
if (_state == AIState.Dead) return;
// 寻找最近的敌人
var nearestEnemy = FindNearestEnemy();
if (nearestEnemy != null)
{
var direction = (nearestEnemy.GlobalPosition - GlobalPosition).Normalized();
var forward = -GlobalTransform.Basis.Z;
var angle = Mathf.RadToDeg(forward.AngleTo(direction));
// 检查是否在视野角度内
if (angle <= SightAngle / 2)
{
// 检查距离
var distance = GlobalPosition.DistanceTo(nearestEnemy.GlobalPosition);
if (distance <= SightRange)
{
// 射线检测:确保没被墙挡住
if (HasLineOfSight(nearestEnemy))
{
_target = nearestEnemy;
// 根据距离切换状态
if (distance <= AttackRange)
_state = AIState.Attack;
else
_state = AIState.Chase;
return;
}
}
}
}
// 没看到敌人
if (_state is AIState.Chase or AIState.Attack)
{
// 失去目标,回到巡逻
_target = null;
_state = AIState.Patrol;
}
}
/// <summary>
/// 用射线检测是否有视线(中间没有障碍物)。
/// </summary>
private bool HasLineOfSight(Node3D target)
{
var spaceState = GetWorld3D().DirectSpaceState;
var query = PhysicsRayQueryParameters3D.Create(
GlobalPosition + Vector3.Up * 1.5f, // 从眼睛高度发射
target.GlobalPosition + Vector3.Up * 1.5f
);
query.Exclude = new Godot.Collections.Rid[] { GetRid() };
var result = spaceState.IntersectRay(query);
if (result.Count == 0) return true;
// 如果射线命中的是目标本身,说明有视线
var hitCollider = (Node3D)result["collider"];
return hitCollider == target || hitCollider.GetParent() == target;
}
private Node3D FindNearestEnemy()
{
Node3D nearest = null;
float nearestDist = float.MaxValue;
string enemyGroup = Team == "Terrorist" ? "counter_terrorist" : "terrorist";
foreach (var node in GetTree().GetNodesInGroup(enemyGroup))
{
if (node is Node3D enemy)
{
var dist = GlobalPosition.DistanceTo(enemy.GlobalPosition);
if (dist < nearestDist)
{
nearestDist = dist;
nearest = enemy;
}
}
}
return nearest;
}
/// <summary>
/// 更新状态——处理特殊行为(安放/拆除炸弹)。
/// </summary>
private void UpdateState(float delta)
{
// T 方携带炸弹时的特殊行为
if (Team == "Terrorist" && HasBomb && _state == AIState.Patrol)
{
// 检查是否在炸弹点附近
var nearestSite = FindNearestBombSite();
if (nearestSite != null)
{
var dist = GlobalPosition.DistanceTo(nearestSite.GlobalPosition);
if (dist < 5f)
{
_state = AIState.PlantBomb;
}
else
{
_navAgent.TargetPosition = nearestSite.GlobalPosition;
}
}
}
// CT 方:炸弹已安放时去拆除
if (Team == "CounterTerrorist" && _state == AIState.Patrol)
{
var bomb = GetTree().GetFirstNodeInGroup("planted_bomb");
if (bomb is Node3D bombNode)
{
_state = AIState.DefuseBomb;
_navAgent.TargetPosition = bombNode.GlobalPosition;
}
}
}
/// <summary>
/// 执行当前行为。
/// </summary>
private void ExecuteBehavior(float delta)
{
switch (_state)
{
case AIState.Patrol:
MoveAlongPath(MoveSpeed);
break;
case AIState.Chase:
if (_target != null)
{
_navAgent.TargetPosition = _target.GlobalPosition;
MoveAlongPath(ChaseSpeed);
FaceTarget(_target);
}
break;
case AIState.Attack:
if (_target != null)
{
FaceTarget(_target);
TryShoot(delta);
}
break;
case AIState.PlantBomb:
HandlePlantBomb(delta);
break;
case AIState.DefuseBomb:
HandleDefuseBomb(delta);
break;
}
}
private void MoveAlongPath(float speed)
{
if (_navAgent.IsNavigationFinished()) return;
var nextPos = _navAgent.GetNextPathPosition();
var direction = (nextPos - GlobalPosition);
direction.Y = 0;
direction = direction.Normalized();
Velocity = direction * speed;
MoveAndSlide();
}
private void FaceTarget(Node3D target)
{
var direction = (target.GlobalPosition - GlobalPosition);
direction.Y = 0;
LookAt(GlobalPosition + direction, Vector3.Up);
}
private void TryShoot(float delta)
{
_fireTimer -= delta;
if (_fireTimer > 0) return;
if (_target == null) return;
if (!HasLineOfSight(_target)) return;
_fireTimer = FireRate;
// AI 射击:根据精度计算命中
if (GD.Randf() <= Accuracy)
{
// 命中!发射命中信号
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
float damage = 25f; // AI 简化伤害
gameEvents.EmitSignal(GameEvents.SignalName.PlayerHit, _target, damage, "body");
}
// 播放射击音效(让玩家知道 AI 在开枪)
// audioPlayer.Play();
}
private void HandlePlantBomb(float delta)
{
if (!HasBomb) return;
_plantTimer += delta;
if (_plantTimer >= 3.2f) // 3.2 秒安放时间
{
HasBomb = false;
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.BombPlanted, GlobalPosition);
_state = AIState.Patrol;
}
}
private void HandleDefuseBomb(float delta)
{
var bomb = GetTree().GetFirstNodeInGroup("planted_bomb");
if (bomb is not Node3D bombNode) return;
var dist = GlobalPosition.DistanceTo(bombNode.GlobalPosition);
if (dist < 3f)
{
_defuseTimer += delta;
if (_defuseTimer >= 10f) // 10 秒拆除(无拆弹钳)
{
var gameEvents = GetNode<GameEvents>("/root/GameEvents");
gameEvents.EmitSignal(GameEvents.SignalName.BombDefused);
_state = AIState.Patrol;
}
}
else
{
// 走向炸弹
_navAgent.TargetPosition = bombNode.GlobalPosition;
MoveAlongPath(ChaseSpeed);
}
}
private Node3D FindNearestBombSite()
{
Node3D nearest = null;
float nearestDist = float.MaxValue;
foreach (var node in GetTree().GetNodesInGroup("bomb_site"))
{
if (node is Node3D site)
{
var dist = GlobalPosition.DistanceTo(site.GlobalPosition);
if (dist < nearestDist)
{
nearestDist = dist;
nearest = site;
}
}
}
return nearest;
}
private void OnNavigationFinished()
{
if (_state != AIState.Patrol) return;
if (_patrolPoints.Count == 0) return;
_currentPatrolIndex = (_currentPatrolIndex + 1) % _patrolPoints.Count;
_navAgent.TargetPosition = _patrolPoints[_currentPatrolIndex];
}
/// <summary>
/// 设置巡逻路径点。
/// </summary>
public void SetPatrolPoints(List<Vector3> points)
{
_patrolPoints = points;
if (points.Count > 0)
{
_navAgent.TargetPosition = points[0];
}
}
}GDScript
# Scripts/Characters/AIController.gd
extends CharacterBody3D
enum AIState {
PATROL, # 巡逻
CHASE, # 追击
ATTACK, # 攻击
PLANT_BOMB, # 安放炸弹
DEFUSE_BOMB,# 拆除炸弹
DEAD # 已死亡
}
@export var move_speed: float = 5.0
@export var chase_speed: float = 6.5
@export var sight_range: float = 30.0
@export var sight_angle: float = 90.0 # 视野角度
@export var attack_range: float = 25.0
@export var fire_rate: float = 0.3
@export var accuracy: float = 0.7 # 射击精度 0-1
@export var team: String = "Terrorist" # 或 "CounterTerrorist"
@export var has_bomb: bool = false
var _nav_agent: NavigationAgent3D
var _state: AIState = AIState.PATROL
var _target: Node3D
var _fire_timer: float = 0.0
# 巡逻路径点
var _patrol_points: Array[Vector3] = []
var _current_patrol_index: int = 0
# 炸弹相关
var _is_planting: bool = false
var _plant_timer: float = 0.0
var _is_defusing: bool = false
var _defuse_timer: float = 0.0
func _ready():
_nav_agent = $NavigationAgent3D as NavigationAgent3D
# 如果有巡逻路径点,开始巡逻
if _patrol_points.size() > 0:
_nav_agent.target_position = _patrol_points[0]
# 连接导航信号
_nav_agent.navigation_finished.connect(_on_navigation_finished)
func _physics_process(delta: float):
if _state == AIState.DEAD:
return
_update_perception() # 感知环境(发现敌人)
_update_state(delta) # 更新状态
_execute_behavior(delta) # 执行当前行为
## 感知:检测视野内是否有敌人。
func _update_perception():
if _state == AIState.DEAD:
return
# 寻找最近的敌人
var nearest_enemy = _find_nearest_enemy()
if nearest_enemy:
var direction = (nearest_enemy.global_position - global_position).normalized()
var forward = -global_transform.basis.z
var angle = rad_to_deg(forward.angle_to(direction))
# 检查是否在视野角度内
if angle <= sight_angle / 2.0:
var distance = global_position.distance_to(nearest_enemy.global_position)
if distance <= sight_range:
# 射线检测:确保没被墙挡住
if _has_line_of_sight(nearest_enemy):
_target = nearest_enemy
# 根据距离切换状态
if distance <= attack_range:
_state = AIState.ATTACK
else:
_state = AIState.CHASE
return
# 没看到敌人
if _state == AIState.CHASE or _state == AIState.ATTACK:
_target = null
_state = AIState.PATROL
## 用射线检测是否有视线。
func _has_line_of_sight(target: Node3D) -> bool:
var space_state = get_world_3d().direct_space_state
var query = PhysicsRayQueryParameters3D.create(
global_position + Vector3.UP * 1.5,
target.global_position + Vector3.UP * 1.5
)
query.exclude = [get_rid()]
var result = space_state.intersect_ray(query)
if result.is_empty():
return true
var hit_collider = result["collider"] as Node3D
return hit_collider == target or hit_collider.get_parent() == target
func _find_nearest_enemy() -> Node3D:
var nearest: Node3D = null
var nearest_dist: float = INF
var enemy_group = "counter_terrorist" if team == "Terrorist" else "terrorist"
for node in get_tree().get_nodes_in_group(enemy_group):
var enemy = node as Node3D
if enemy:
var dist = global_position.distance_to(enemy.global_position)
if dist < nearest_dist:
nearest_dist = dist
nearest = enemy
return nearest
## 更新状态。
func _update_state(delta: float):
# T 方携带炸弹时的特殊行为
if team == "Terrorist" and has_bomb and _state == AIState.PATROL:
var nearest_site = _find_nearest_bomb_site()
if nearest_site:
var dist = global_position.distance_to(nearest_site.global_position)
if dist < 5.0:
_state = AIState.PLANT_BOMB
else:
_nav_agent.target_position = nearest_site.global_position
# CT 方:炸弹已安放时去拆除
if team == "CounterTerrorist" and _state == AIState.PATROL:
var bomb = get_tree().get_first_node_in_group("planted_bomb")
if bomb is Node3D:
_state = AIState.DEFUSE_BOMB
_nav_agent.target_position = bomb.global_position
## 执行当前行为。
func _execute_behavior(delta: float):
match _state:
AIState.PATROL:
_move_along_path(move_speed)
AIState.CHASE:
if _target:
_nav_agent.target_position = _target.global_position
_move_along_path(chase_speed)
_face_target(_target)
AIState.ATTACK:
if _target:
_face_target(_target)
_try_shoot(delta)
AIState.PLANT_BOMB:
_handle_plant_bomb(delta)
AIState.DEFUSE_BOMB:
_handle_defuse_bomb(delta)
func _move_along_path(speed: float):
if _nav_agent.is_navigation_finished():
return
var next_pos = _nav_agent.get_next_path_position()
var direction = next_pos - global_position
direction.y = 0
direction = direction.normalized()
velocity = direction * speed
move_and_slide()
func _face_target(target: Node3D):
var direction = target.global_position - global_position
direction.y = 0
look_at(global_position + direction, Vector3.UP)
func _try_shoot(delta: float):
_fire_timer -= delta
if _fire_timer > 0:
return
if not _target:
return
if not _has_line_of_sight(_target):
return
_fire_timer = fire_rate
# AI 射击:根据精度计算命中
if randf() <= accuracy:
var game_events = get_node("/root/GameEvents") as GameEvents
var damage: float = 25.0 # AI 简化伤害
game_events.player_hit.emit(_target, damage, "body")
func _handle_plant_bomb(delta: float):
if not has_bomb:
return
_plant_timer += delta
if _plant_timer >= 3.2: # 3.2 秒安放时间
has_bomb = false
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.bomb_planted.emit(global_position)
_state = AIState.PATROL
func _handle_defuse_bomb(delta: float):
var bomb = get_tree().get_first_node_in_group("planted_bomb")
if bomb is not Node3D:
return
var bomb_node = bomb as Node3D
var dist = global_position.distance_to(bomb_node.global_position)
if dist < 3.0:
_defuse_timer += delta
if _defuse_timer >= 10.0: # 10 秒拆除(无拆弹钳)
var game_events = get_node("/root/GameEvents") as GameEvents
game_events.bomb_defused.emit()
_state = AIState.PATROL
else:
_nav_agent.target_position = bomb_node.global_position
_move_along_path(chase_speed)
func _find_nearest_bomb_site() -> Node3D:
var nearest: Node3D = null
var nearest_dist: float = INF
for node in get_tree().get_nodes_in_group("bomb_site"):
var site = node as Node3D
if site:
var dist = global_position.distance_to(site.global_position)
if dist < nearest_dist:
nearest_dist = dist
nearest = site
return nearest
func _on_navigation_finished():
if _state != AIState.PATROL:
return
if _patrol_points.is_empty():
return
_current_patrol_index = (_current_patrol_index + 1) % _patrol_points.size()
_nav_agent.target_position = _patrol_points[_current_patrol_index]
## 设置巡逻路径点。
func set_patrol_points(points: Array[Vector3]):
_patrol_points = points
if points.size() > 0:
_nav_agent.target_position = points[0]AI 难度调节
通过调整参数可以轻松改变 AI 的难度:
| 参数 | 简单 | 普通 | 困难 |
|---|---|---|---|
Accuracy | 0.3 | 0.6 | 0.85 |
ReactionTime | 0.5s | 0.2s | 0.05s |
SightRange | 20 | 30 | 50 |
FireRate | 0.5 | 0.3 | 0.15 |
MoveSpeed | 4.0 | 5.0 | 7.0 |
小结
这一章实现了 AI 敌人系统:
- 状态机:巡逻 → 追击 → 射击,加上安放/拆除炸弹的特殊状态
- 感知系统:视野角度 + 距离 + 射线视线检测
- 导航寻路:使用 Godot 的 NavigationAgent3D 自动寻路
- 阵营行为:T 方会安放炸弹,CT 方会拆除炸弹
- 难度调节:通过参数调整 AI 的精度和反应速度
下一章我们实现回合系统——CS 的核心节奏。
