GameTech/Godot 4.7 互动教程
目录示例游戏
第四部分 · 3D 实战 · 第 17 章

FPS(下):武器、敌人与导航

给控制器装上武器:两种射线检测、碰撞层规划、后坐力和散布、弹孔与命中反馈;再做会看、会追、会开枪的敌人:视线检测、状态机、运行时烘焙导航网格、NavigationAgent3D 寻路与避障,最后用 HUD 把信息告诉玩家。

上一章的控制器只能走路。本章补上"射击"的那一半:开枪时子弹打到了哪里、为什么连射会越来越散、打中之后玩家怎么知道;敌人怎么发现你、怎么绕过箱子追过来、怎么还击。

打开显示导航网格和路径和显示射线,再把时间缩放拖到 0.3。蓝色半透明的区域是烘焙出来的导航网格,橙色折线是每个敌人当前的路径;黄线是你的子弹,绿线 / 红线是敌人的视线(看得见 / 被挡住)。慢动作下连射一梭子,注意黄线是怎么越来越散的。

射线检测:子弹打到了哪里

FPS 里大部分枪是"瞬间命中"(hitscan)的:开枪的瞬间从相机位置沿视线方向发出一条射线,第一个碰到的东西就是被打中的目标。不需要真的生成一颗子弹去飞。Godot 有两种发射线的方式,示例里都实现了,可以用射线检测方式参数切换,效果完全一样。

方式一:直接查询物理空间

weapons/weapon_manager.gd第 142–147 行在工程中查看 ↗
## 方式一:PhysicsDirectSpaceState3D.intersect_ray —— 不需要节点,随时查询一次。
## 适合"一次性"的检测:开枪、霰弹枪的每颗弹丸、敌人的视线。
func _cast_query(from: Vector3, to: Vector3) -> Dictionary:
	var space := get_world_3d().direct_space_state
	var query := PhysicsRayQueryParameters3D.create(from, to, HIT_MASK, [player.get_rid()])
	return space.intersect_ray(query)   # 没打中返回 {};打中有 position、normal、collider 等字段

get_world_3d().direct_space_state 拿到当前世界的物理空间,PhysicsRayQueryParameters3D.create(起点, 终点, 碰撞掩码, 排除列表) 描述一次查询,intersect_ray() 返回一个字典:没打中是空字典,打中了有 position(命中点)、normal(命中面的法线)、collider(被打中的节点)等字段。排除列表里放的是玩家自己的 RID,否则射线从胶囊内部出发会先打中自己。

方式二:RayCast3D 节点

weapons/weapon_manager.gd第 152–166 行在工程中查看 ↗
## 方式二:RayCast3D 节点 —— 在场景里摆好(这里是 Camera3D/AimRay),
## 它每个物理帧自动检测一次;临时改了方向就调用 force_raycast_update() 立即刷新。
## 适合"持续"的检测:脚下是否有地面、准星指着什么。
func _cast_node(from: Vector3, to: Vector3) -> Dictionary:
	aim_ray.collision_mask = HIT_MASK
	aim_ray.global_position = from
	aim_ray.target_position = aim_ray.to_local(to)
	aim_ray.force_raycast_update()
	if not aim_ray.is_colliding():
		return {}
	return {
		"position": aim_ray.get_collision_point(),
		"normal": aim_ray.get_collision_normal(),
		"collider": aim_ray.get_collider(),
	}

RayCast3D 是摆在场景里的节点,enabled 时每个物理帧自动检测一次,结果用 is_colliding()、get_collision_point() 等方法取。临时改了方向后要调用 force_raycast_update() 立即刷新(enabled = false 时也能用)。

intersect_rayRayCast3D
需要节点不需要需要,跟着父节点移动
适合一次性的检测:开枪、霰弹枪的每颗弹丸、敌人的视线持续的检测:脚下有没有地面、准星指着什么、激光瞄准器
在编辑器里可见否是(调试时方便)
调用时机必须在 _physics_process 里(物理空间只在那时安全可读)自动更新的结果随时可读

射线不只用来开枪。第一人称游戏有个常见问题:枪的模型比玩家的碰撞胶囊伸得更远,贴着墙时枪管会插进墙里。示例的处理方法是每个物理帧从相机往前打一条 0.75 米的短射线,只检测"世界"层,离墙越近,枪越往回收、枪口越往上抬:

weapons/weapon_manager.gd第 245–255 行防穿墙:一条很短的射线在工程中查看 ↗
## 防穿墙:枪模型比玩家的碰撞胶囊伸得更远,贴墙时枪管会插进墙里。
## 每个物理帧从相机往前打一条很短的射线,离墙越近,枪越往回收、枪口越往上抬。
const WALL_REACH := 0.75   # 枪口到相机的大致距离(米)


func _check_wall() -> void:
	var from := camera.global_position
	var to := from - camera.global_basis.z * WALL_REACH
	var query := PhysicsRayQueryParameters3D.create(from, to, 1, [player.get_rid()])   # 只检测第 1 层"世界"
	var hit := get_world_3d().direct_space_state.intersect_ray(query)
	_wall_target = 0.0 if hit.is_empty() else 1.0 - from.distance_to(hit.position) / WALL_REACH

算出来的 _wall_target 在 _process 里平滑插值后加到枪的位置和旋转上。物理查询放在 _physics_process,视觉插值放在 _process,这正是上表最后一行说的分工。另一种做法是用单独的 SubViewport 渲染武器,让它永远画在最上层,但那样枪不会被场景灯光照到,还要多渲染一遍。

在 _process 或 _input 里调用 direct_space_state.intersect_ray() 可能读到不一致的物理状态,Godot 文档要求在 _physics_process 里做空间查询。示例的开火逻辑因此放在 _physics_process 里,用 Input.is_action_pressed 轮询扳机。

碰撞层规划

射线和物体都靠碰撞层(layer,"我是什么")和碰撞掩码(mask,"我要检测什么")来过滤。项目一开始就该规划好,并在项目设置里起名字:

project.godot第 92–97 行project.godot 里给 3D 物理层起的名字在工程中查看 ↗
[layer_names]

3d_physics/layer_1="世界"
3d_physics/layer_2="玩家"
3d_physics/layer_3="敌人"
3d_physics/layer_4="碎片"
谁layer(我是)mask(我检测)原因
墙、地面、箱子1 世界无静态物体不需要主动检测
玩家2 玩家1 + 3被墙和敌人挡住
敌人3 敌人1 + 2 + 3敌人之间也互相挡
死亡碎片4 碎片1 + 4不挡玩家、不挡子弹,只落在地上
玩家的子弹(射线)—1 + 3打墙和敌人,不打自己、不打碎片
敌人的子弹 / 视线—1 + 2不会被其他敌人挡住视线,也不会误伤队友

代码里掩码是一个整数,第 n 层对应第 n-1 位:第 1 层 = 1,第 3 层 = 1 << 2 = 4,所以"世界 + 敌人"是 1 | 4 = 5。示例在 weapon_manager.gd 开头定义了 HIT_MASK 常量并写了注释,比到处写魔法数字 5 好读得多。

开火:散布与后坐力

weapons/weapon_manager.gd第 89–112 行一次开火的完整流程在工程中查看 ↗
func _try_fire() -> void:
	var w := weapons[current]
	if _cooldown > 0.0 or _reload_left > 0.0 or _switch_left > 0.0:
		return
	if ammo[current] <= 0:
		reload()
		return
	ammo[current] -= 1
	_cooldown = 1.0 / w.fire_rate
	var spread := current_spread()
	var from := camera.global_position
	for i in w.pellets:
		var dir := _random_in_cone(spread)
		var to := from + dir * w.max_range
		var hit := _cast_query(from, to) if hit_method == "intersect_ray" else _cast_node(from, to)
		_apply_hit(hit, from, to, dir, w)
	bloom = minf(bloom + w.spread_per_shot, w.max_spread)
	# 后坐力:视角上跳 recoil 度,左右随机偏一点;Player 里会自动恢复
	player.add_recoil(w.recoil, randf_range(-0.35, 0.35) * w.recoil)
	_kick = 1.0
	models[current].flash()
	_emit_ammo()
	if ammo[current] == 0:
		reload()

如果每发子弹都精确地打在准星正中,游戏就只比谁瞄得快了。射击游戏用两个机制让"怎么开枪"变得有讲究:

  • 散布(spread):子弹在以准星为中心的一个圆锥里随机偏离。圆锥的半角 = 基础散布 + 连射累积 + 移动惩罚,跳起来再加,蹲下打折。连射时每发累积一点(spread_per_shot),停火后按 spread_recover 度/秒恢复。所以"点射"比"扫射"准,"站定蹲下"比"边跑边打"准。
  • 后坐力(recoil):每开一枪视角往上跳一点,左右随机偏一点。这是真的改变了你的瞄准方向,所以连射时要往下压鼠标才能把准星留在目标上。视角会自动恢复(上一章的两级平滑)。
weapons/weapon_manager.gd第 117–137 行在工程中查看 ↗
## 当前散布(度)= 基础散布 + 连射累积 + 移动惩罚,空中再加,蹲下打折。
func current_spread() -> float:
	var w := weapons[current]
	var s := w.spread + bloom
	var speed := Vector2(player.velocity.x, player.velocity.z).length()
	s += speed / player.walk_speed * w.move_spread
	if not player.is_on_floor():
		s += 3.0
	if player.is_crouching():
		s *= 0.6
	return s


## 在半角为 spread_deg 的圆锥里随机取一个方向。
## 先在距离相机 1 米的平面上取一个半径为 tan(spread) 的圆盘内的点,
## 半径用 sqrt(randf()),点在圆盘里才是均匀分布的(直接用 randf() 会扎堆在中心)。
func _random_in_cone(spread_deg: float) -> Vector3:
	var r := tan(deg_to_rad(spread_deg)) * sqrt(randf())
	var a := randf() * TAU
	var local := Vector3(cos(a) * r, sin(a) * r, -1.0).normalized()
	return camera.global_basis * local

在圆锥里取随机方向有一个小坑:在距离相机 1 米的平面上,取一个半径为 tan(散布) 的圆盘内的点。如果半径直接用 randf() × R,点会扎堆在圆心附近(因为小半径的圆周短,同样多的点挤在更小的面积里);用 sqrt(randf()) × R 才是均匀分布。下面的小部件可以对比两种写法,也可以模拟一次连射。

把后坐力拖到 4,按住左键扫射:准星飞快地往天上跑,得用力往下压鼠标。把连射散布增量拖到 0、基础散布拖到 0:每一发都精确落在准星上,变成"激光枪"。最后把射速拖到 25 体验一下"水管"。注意 HUD 上的准星也会跟着散布张开。

命中反馈

weapons/weapon_manager.gd第 171–185 行打中了什么,就给什么反馈在工程中查看 ↗
func _apply_hit(hit: Dictionary, from: Vector3, to: Vector3, dir: Vector3, w: WeaponData) -> void:
	var end: Vector3 = hit.get("position", to)
	if DebugDraw.show_rays:
		DebugDraw.line(from + camera.global_basis.y * -0.08, end, Color(1.0, 0.85, 0.2), 1.2)
	if hit.is_empty():
		return
	var target: Object = hit.collider
	if target.has_method("take_damage"):
		# 鸭子类型:谁有 take_damage 方法,谁就能被打。不需要知道它是敌人还是油桶
		var result: Dictionary = target.take_damage(w.damage, end, dir)
		hit_landed.emit(result.get("killed", false), result.get("headshot", false))
		Effects.spark(end, hit.normal, Color(1.0, 0.35, 0.25))
	else:
		Effects.bullet_hole(end, hit.normal)
		Effects.spark(end, hit.normal, Color(1.0, 0.8, 0.45))

这里用的是"鸭子类型":不管打中的是什么,只要它有 take_damage 方法,就当成可以受伤的目标。以后加一个可以打爆的油桶,只要给它写一个 take_damage,武器代码一行都不用改。

打中之后,玩家需要在几十毫秒内知道"打中了",示例叠了好几层反馈:

  • 准星上闪一个白色的 ×(命中标记),击杀时变成更大的红色 ×。
  • 命中点溅出火花(CPUParticles3D,对象池循环使用 8 个)。
  • 敌人身上叠一层半透明白色材质(material_overlay)闪一下,同时被往后推一点。
  • 伤害数字从命中点飘起来,爆头用更大的黄色字。
fx/effects.gd第 98–118 行伤害数字:Label3D + 补间在工程中查看 ↗
## 伤害数字:Label3D 设成 billboard(永远面向相机)、no_depth_test(不被墙挡住),
## 往上飘 0.8 米并淡出,然后自己删掉。爆头用更大的黄色字。
static func damage_number(pos: Vector3, amount: float, headshot: bool) -> void:
	if not _instance:
		return
	var label := Label3D.new()
	label.text = ("爆头 %d" if headshot else "%d") % roundi(amount)
	label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	label.no_depth_test = true
	label.fixed_size = true                  # 远近都一样大
	label.pixel_size = 0.0012
	label.font_size = 40 if headshot else 30
	label.outline_size = 10
	label.modulate = Color(1.0, 0.85, 0.2) if headshot else Color(1, 1, 1)
	label.outline_modulate = Color(0.1, 0.05, 0.05, 0.8)
	_instance.add_child(label)
	label.global_position = pos + Vector3(randf_range(-0.2, 0.2), 0.2, 0)
	var tw := label.create_tween().set_parallel()
	tw.tween_property(label, "global_position:y", label.global_position.y + 0.8, 0.7).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
	tw.tween_property(label, "modulate:a", 0.0, 0.7).set_delay(0.25)
	tw.chain().tween_callback(label.queue_free)

弹孔:Compatibility 下不能用 Decal

Forward+ 里做弹孔的标准方法是 Decal 节点:一个投影盒子,把贴图"喷"到盒子范围内的所有表面上。第 15 章实测过,Decal 在 Compatibility 渲染器里完全不显示,也没有任何警告。替代方案是在命中点放一个贴着表面的小四边形:

fx/effects.gd第 36–59 行在工程中查看 ↗
## 弹孔。Compatibility 渲染器不支持 Decal 节点(实测放上去什么都不显示),
## 所以用一个贴着表面的小四边形代替:位置沿法线抬起 5 毫米防止和墙面"打架"(z-fighting),
## 朝向让四边形的 +Z(正面)对准法线。用对象池循环复用,最多同时存在 48 个。
static func bullet_hole(pos: Vector3, normal: Vector3) -> void:
	if _instance:
		_instance._add_hole(pos, normal)


func _add_hole(pos: Vector3, normal: Vector3) -> void:
	var hole: MeshInstance3D
	if _holes.size() < MAX_HOLES:
		hole = MeshInstance3D.new()
		hole.mesh = _hole_mesh
		hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		add_child(hole)
		_holes.append(hole)
	else:
		hole = _holes[_next_hole]        # 池满了就覆盖最旧的那个
		_next_hole = (_next_hole + 1) % MAX_HOLES
	var up := Vector3.UP if absf(normal.y) < 0.99 else Vector3.RIGHT
	# looking_at 让 -Z 指向给定方向;我们要 +Z 朝外(= 法线),所以让 -Z 指向 -normal
	hole.global_basis = Basis.looking_at(-normal, up)
	hole.rotate_object_local(Vector3.BACK, randf() * TAU)
	hole.global_position = pos + normal * 0.005

要点有三个。朝向:QuadMesh 的正面是 +Z,所以让它的 +Z 对准命中面的法线,即让 -Z 指向 -normal。位置:沿法线抬起 5 毫米,否则和墙面在同一个深度上,会闪烁(z-fighting)。数量:用对象池,最多 48 个,满了就挪用最旧的那个,不会越打越卡。这种做法的缺点是弹孔贴在墙角时会"悬空"出一截,Decal 没有这个问题。

敌人 AI:感知、视线、状态机

敌人的行为用一个最简单的有限状态机描述:

状态做什么什么时候离开
PATROL 巡逻在导航网格上随便找个点,慢慢走过去看见玩家 → 追击;被打 → 追击
CHASE 追击全速走向"最后一次看见玩家的位置"看得见且进入射程 → 攻击;跟丢 6 秒 → 巡逻
ATTACK 攻击站定,面向玩家,蓄力后开枪看不见或跑远了 → 追击
DEAD 死亡爆开成碎片,移出场景—
enemy/enemy.gd第 64–108 行在工程中查看 ↗
func _physics_process(delta: float) -> void:
	if state == State.DEAD:
		return
	# 感知不用每帧做:每 0.15 秒打一次射线就够了,还能省性能
	_think -= delta
	if _think <= 0.0:
		_think = 0.15
		can_see = _can_see_player()
		if can_see:
			last_known = _player.global_position
			_lost_time = 0.0
	var dist := global_position.distance_to(_player.global_position)
	match state:
		State.PATROL:
			if can_see:
				_enter(State.CHASE)
			elif agent.is_navigation_finished():
				_pick_patrol_point()
		State.CHASE:
			if can_see and dist < attack_range:
				_enter(State.ATTACK)
			else:
				agent.target_position = last_known
				if not can_see:
					_lost_time += delta
					if _lost_time > 6.0 and agent.is_navigation_finished():
						_enter(State.PATROL)        # 跟丢了,回去巡逻
		State.ATTACK:
			if not can_see or dist > attack_range * 1.25 or _player.is_dead:
				_enter(State.CHASE)
			else:
				_attack(delta)
	_move()
	_update_visuals(delta)


func _enter(new_state: State) -> void:
	state = new_state
	match new_state:
		State.CHASE:
			agent.target_position = last_known
		State.ATTACK:
			_fire_timer = maxf(_fire_timer, WINDUP + 0.1)
		State.PATROL:
			_pick_patrol_point()

"看见"不是简单的距离判断,示例分三道关:

enemy/enemy.gd第 113–133 行距离 → 视野扇形 → 视线射线在工程中查看 ↗
## 三道关:距离够近 → 在视野扇形里(已经在追的时候不限角度)→ 视线射线没被墙挡住。
func _can_see_player() -> bool:
	if _player.is_dead:
		return false
	var from := eye.global_position
	var target := _player.head.global_position
	var to := target - from
	if to.length() > sight_range:
		return false
	if state == State.PATROL:
		var forward := -global_basis.z                   # -Z 是前方
		var flat := Vector3(to.x, 0.0, to.z)
		if forward.angle_to(flat) > deg_to_rad(view_angle / 2.0):
			return false
	var query := PhysicsRayQueryParameters3D.create(from, target, SHOT_MASK, [get_rid()])
	var hit := get_world_3d().direct_space_state.intersect_ray(query)
	var seen: bool = not hit.is_empty() and hit.collider == _player
	if DebugDraw.show_rays:
		var end: Vector3 = hit.get("position", target)
		DebugDraw.line(from, end, Color(0.3, 1.0, 0.4) if seen else Color(1.0, 0.3, 0.3, 0.6), 0.15)
	return seen
  1. 距离:超过 sight_range 直接看不见。
  2. 视野角:巡逻时只能看见前方 110° 的扇形,从背后接近不会被发现。已经在追击时不限角度(它知道你在附近)。
  3. 视线:从敌人的眼睛向玩家的头发一条射线,掩码只有"世界 + 玩家"。第一个打中的是玩家才算看见;打中墙就是被挡住了。

感知每 0.15 秒做一次而不是每帧:人的反应时间本来就有 0.2 秒左右,而且 10 个敌人每帧各打一条射线也是没必要的开销。最后一次看见的位置(last_known)很关键:玩家躲到箱子后面,敌人会追到他消失的地方去找,而不是隔着箱子知道他的实时位置。

enemy/enemy.gd第 179–198 行开枪:蓄力提示 + 带误差的瞄准在工程中查看 ↗
func _attack(delta: float) -> void:
	_fire_timer -= delta
	if _fire_timer > 0.0:
		return
	_fire_timer = fire_interval
	var from := muzzle.global_position
	# 命中率:距离越远、玩家跑得越快,瞄准点的随机偏移越大
	var chest := _player.global_position + Vector3.UP * 1.2
	var player_speed := Vector2(_player.velocity.x, _player.velocity.z).length()
	var miss_radius := 0.2 + from.distance_to(chest) * 0.025 + player_speed * 0.09
	var aim := chest + Vector3(randf_range(-1, 1), randf_range(-1, 1), randf_range(-1, 1)).normalized() * randf() * miss_radius
	var to := from + (aim - from).normalized() * (sight_range + 5.0)
	var query := PhysicsRayQueryParameters3D.create(from, to, SHOT_MASK, [get_rid()])
	var hit := get_world_3d().direct_space_state.intersect_ray(query)
	var end: Vector3 = hit.get("position", to)
	Effects.tracer(from, end)
	if not hit.is_empty() and hit.collider == _player:
		_player.take_damage(shot_damage, global_position)
	elif not hit.is_empty():
		Effects.spark(end, hit.normal, Color(1.0, 0.5, 0.3))

敌人的射击也是射线,但故意不准:瞄准点在玩家胸口附近随机偏移,偏移半径随距离和玩家的移动速度变大。开火前 0.45 秒面罩会变亮,给玩家一个躲的机会。每一枪都画一条发光的弹道,再配合 HUD 上的伤害方向指示,玩家能马上知道子弹从哪来。

把敌人视野距离拖到 5,敌人几乎是"瞎"的,要走到跟前才发现你;拖到 50,整个竞技场都在它视野里,只能靠掩体挡住视线。打开显示射线能直接看到每个敌人的视线是绿(看见)还是红(被挡)。

导航:NavigationServer3D、导航网格与 NavigationAgent3D

敌人要绕过箱子、走坡道上平台,需要寻路。Godot 的 3D 寻路由几部分组成:

  • 导航网格(NavigationMesh):一组凸多边形,覆盖所有"能走的地方"。
  • NavigationRegion3D:把一块导航网格放进导航地图。它还负责烘焙:解析子节点的几何体,算出导航网格。
  • NavigationServer3D:在后台管理所有地图、区域和代理,负责把相邻区域连接起来、计算路径和避障。
  • NavigationAgent3D:挂在角色身上,设置 target_position 后,它会向服务器要一条路径,每帧告诉你"下一个拐点在哪"。

运行时烘焙

示例的关卡是代码生成的,所以导航网格也在运行时烘焙。烘焙的原理是把场景体素化(切成 cell_size × cell_height 的小格子),找出所有"上面有足够空间站人、坡度不太陡"的格子,按 agent_radius 往内收缩,再把可走区域拆成凸多边形。

world/level.gd第 245–268 行在工程中查看 ↗
## 运行时烘焙导航网格。NavigationMesh 的几个关键参数:
##   agent_radius:把障碍物"加粗"这么多,敌人的中心沿网格走就不会蹭墙
##   agent_height / agent_max_climb / agent_max_slope:多矮的洞钻不过去、多高的台阶迈得上、多陡的坡爬得上
##   cell_size / cell_height:体素大小,必须和导航地图的设置一致(默认 0.25)
func _bake() -> void:
	var nm := NavigationMesh.new()
	nm.agent_radius = 0.5
	nm.agent_height = 1.75                        # 取 cell_height 的整数倍,否则会被向上取整并打警告
	nm.agent_max_climb = 0.5
	nm.agent_max_slope = 40.0
	nm.cell_size = 0.25
	nm.cell_height = 0.25
	nm.geometry_parsed_geometry_type = NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS
	nm.geometry_collision_mask = 1                 # 只把"世界"层的碰撞体当作地形
	navigation_mesh = nm
	# CSG 的布尔运算在本帧末尾才执行,等一帧再取它的形状,然后再等物理服务器登记好新碰撞体
	await get_tree().process_frame
	for csg in _csg_walls:
		_csg_to_static_body(csg)
	await get_tree().physics_frame
	var t0 := Time.get_ticks_msec()
	bake_navigation_mesh(false)                   # false = 在主线程同步烘焙(Web 版没有线程)
	print("导航网格烘焙完成:%d 个多边形,用时 %d ms" % [nm.get_polygon_count(), Time.get_ticks_msec() - t0])
	baked.emit()
  • agent_radius 是最重要的参数:烘焙时把障碍物"加粗"这么多,代理的中心只要待在网格里,身体就不会蹭到墙。所以导航网格的边缘离墙有 0.5 米的空隙(打开显示导航网格能看到)。
  • agent_height:比这个矮的空间钻不过去。示例的拱门高 2.2 米以上,所以能穿过。
  • agent_max_climb / agent_max_slope:能迈上多高的台阶、能爬多陡的坡。平台两侧的坡道约 18°,所以敌人能走上平台;集装箱顶上也会生成一小块网格,但和地面不连通,敌人到不了。
  • geometry_parsed_geometry_type 选 STATIC_COLLIDERS:只解析碰撞体。解析可见网格(MESH_INSTANCES)需要把网格数据从显卡读回来,运行时烘焙会很慢,引擎会打出警告。
  • Web 版没有多线程,所以 bake_navigation_mesh(false) 在主线程同步完成。这个 40×40 米的关卡烘焙只要 5–15 毫秒。
烘焙完成后不能立刻查询。NavigationServer3D 要等到下一次同步(下一个物理帧)才会更新地图,在那之前 map_get_closest_point() 等查询只会返回原点。示例的刷怪代码一开始就踩了这个坑:所有敌人都出生在 (0, 0, 0),被物理引擎挤到了平台顶上。修正方法是等到查询能返回有效结果再开始刷怪(spawner.gd 的 start())。

为什么用导航网格而不是网格寻路

2D 游戏里常见的寻路是把地图切成方格跑 A*。导航网格本质上也是在图上跑 A*,只是图的节点从"每个小方格"变成了"每个凸多边形"。下面的小部件把同一张地图分别用两种方式表示:

可以看到三点区别:

  1. 节点数:方格 A* 要展开几百个格子,导航网格(这里用矩形合并模拟凸多边形)只有十几个多边形,搜索快得多,尤其是在开阔的大场景里。
  2. 路径形状:方格路径只能走 8 个方向,是锯齿状的,要再做一次平滑。导航网格在多边形之间穿过"门户"(相邻多边形的公共边),再用漏斗算法(funnel / string pulling)拉直,得到的是贴着拐角的最短折线。
  3. 代理半径:方格寻路要自己处理"角色有体积"的问题;导航网格在烘焙时就把障碍物加粗了,寻路时代理可以当成一个点。

沿路径移动和避障

enemy/enemy.gd第 145–174 行NavigationAgent3D:取下一个拐点 → 期望速度 → 避障 → move_and_slide在工程中查看 ↗
func _move() -> void:
	var desired := Vector3.ZERO
	if state != State.ATTACK and not agent.is_navigation_finished():
		var next := agent.get_next_path_position()       # 路径上的下一个拐点
		var dir := next - global_position
		dir.y = 0.0
		var speed := move_speed * (0.5 if state == State.PATROL else 1.0)
		desired = dir.normalized() * speed
	agent.max_speed = move_speed
	if agent.avoidance_enabled:
		agent.velocity = desired          # 交给避障系统,它算出不撞别人的速度后发 velocity_computed 信号
	else:
		_on_velocity_computed(desired)
	if DebugDraw.show_nav and state != State.ATTACK:
		DebugDraw.path(agent.get_current_navigation_path(), Color(1.0, 0.6, 0.1))


func _on_velocity_computed(safe_velocity: Vector3) -> void:
	if state == State.DEAD:
		return
	var delta := get_physics_process_delta_time()
	velocity.x = safe_velocity.x + _knock.x
	velocity.z = safe_velocity.z + _knock.z
	velocity.y = 0.0 if is_on_floor() else velocity.y - 20.0 * delta
	_knock = _knock.lerp(Vector3.ZERO, 1.0 - exp(-8.0 * delta))
	move_and_slide()
	# 转向:攻击时面向玩家,否则面向移动方向。atan2(-x, -z) 求出让 -Z 对准某方向的偏航角
	var face := _player.global_position - global_position if state == State.ATTACK else safe_velocity
	if Vector2(face.x, face.z).length() > 0.1:
		rotation.y = lerp_angle(rotation.y, atan2(-face.x, -face.z), 1.0 - exp(-10.0 * delta))

流程是:设好 target_position,每个物理帧调用 get_next_path_position() 拿到路径上的下一个拐点,朝它算出期望速度。如果开了 avoidance_enabled,不要直接用这个速度移动,而是把它赋给 agent.velocity;服务器会参考附近其他代理的位置和速度(RVO 算法),算出一个不会相撞的安全速度,通过 velocity_computed 信号回传,在回调里再 move_and_slide()。

避障参数在 enemy.tscn 的 NavigationAgent3D 上:radius(避障用的半径,可以比碰撞体略大)、neighbor_distance(考虑多远以内的其他代理)、max_speed(安全速度的上限,示例每帧同步成 move_speed)。避障只管代理之间互相让路,不管墙,墙由导航网格负责。

把敌人数量拖到 12,再把敌人速度拖到 6,躲到平台上。打开导航显示,可以看到敌人从两侧坡道涌上来,彼此会让开而不是叠成一团。

死亡:爆开成碎片

fx/effects.gd第 142–169 行在工程中查看 ↗
## 敌人死亡时"爆开":把身体上的每个网格换成一个 RigidBody3D 碎片,给一个向外的冲量。
## 碎片在第 4 层(碎片层),只和世界、碎片碰撞,不会挡住玩家和子弹。3 秒后缩小消失。
static func explode(parts: Array[MeshInstance3D], center: Vector3, push: Vector3) -> void:
	if not _instance:
		return
	for part in parts:
		var body := RigidBody3D.new()
		body.collision_layer = 8
		body.collision_mask = 1 | 8
		var shape := CollisionShape3D.new()
		var box := BoxShape3D.new()
		box.size = part.get_aabb().size.max(Vector3.ONE * 0.08)
		shape.shape = box
		shape.position = part.get_aabb().get_center()
		body.add_child(shape)
		var mi := MeshInstance3D.new()
		mi.mesh = part.mesh
		mi.material_override = part.get_active_material(0)
		body.add_child(mi)
		_instance.add_child(body)
		body.global_transform = part.global_transform
		var out := (part.global_position - center).normalized()
		body.apply_central_impulse((out * 2.5 + push * 2.0 + Vector3.UP * 2.5) * body.mass)
		body.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8))
		var tw := body.create_tween()
		tw.tween_interval(3.0)
		tw.tween_property(mi, "scale", Vector3.ZERO, 0.4)
		tw.tween_callback(body.queue_free)

敌人是用方块拼的,死亡时把每个方块换成一个 RigidBody3D,给一个从身体中心往外、再加上子弹方向和向上分量的冲量,加上随机角速度,碎片就会四散滚落。碎片放在第 4 层"碎片",只和世界、碎片碰撞,玩家可以直接走过去,子弹也会穿过。3 秒后缩小消失。

HUD:准星、伤害方向

HUD 是一个 CanvasLayer 下的 Control,图形部分在 _draw() 里画,每帧 queue_redraw()。它不接收鼠标(mouse_filter = IGNORE),否则会挡住游戏的输入。

ui/hud.gd第 94–111 行准星的张开程度 = 散布角换算成像素在工程中查看 ↗
## 散布是"角度",准星是"像素"。相机的垂直视野角是 fov,屏幕半高对应 fov/2,
## 所以偏离中心 θ 角的点,在屏幕上离中心 tan(θ) / tan(fov/2) × 半高 个像素。
func spread_to_pixels(spread_deg: float) -> float:
	var half_fov := deg_to_rad(player.camera.fov / 2.0)
	return tan(deg_to_rad(spread_deg)) / tan(half_fov) * (size.y / 2.0)


func _draw_crosshair(c: Vector2) -> void:
	var gap := 4.0 + spread_to_pixels(weapons.current_spread())
	var length := 9.0
	var col := Color(1, 1, 1, 0.9)
	var shadow := Color(0, 0, 0, 0.5)
	for d: Vector2 in [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT]:
		var a := c + d * gap
		var b := c + d * (gap + length)
		draw_line(a + Vector2(1, 1), b + Vector2(1, 1), shadow, 2.0)
		draw_line(a, b, col, 2.0)
	draw_circle(c, 1.5, col)

准星的四根线离中心的距离,正好是"散布圆锥在屏幕上的半径"。用第 15 章的投影公式:偏离视线 θ 的点在屏幕上离中心 tan(θ) / tan(fov/2) × 半屏高 个像素。所以准星不是装饰,它如实反映了子弹可能落在多大的范围里;改 FOV 时准星的像素大小也会跟着变。

ui/hud.gd第 126–134 行伤害方向指示:把攻击者的位置换到玩家自己的坐标系里在工程中查看 ↗
## 伤害方向指示:把"攻击者位置"转换到玩家自己的坐标系里(乘 basis 的逆),
## 只看水平面:local.x 是右,-local.z 是前。atan2 得到相对正前方的角度,顺时针为正。
func _draw_damage_marks(c: Vector2) -> void:
	for m: Array in _damage_marks:
		var local: Vector3 = player.global_basis.inverse() * (m[0] - player.global_position)
		var angle := atan2(local.x, -local.z)
		var screen_angle := angle - PI / 2.0        # draw_arc 的 0 度在右边,屏幕"上"是 -90°
		var col := Color(1.0, 0.2, 0.15, clampf(m[1], 0.0, 1.0) * 0.85)
		draw_arc(c, 110.0, screen_angle - 0.32, screen_angle + 0.32, 20, col, 7.0, true)

伤害方向指示要回答"子弹从我的哪个方向来"。把"攻击者位置 - 玩家位置"乘以玩家 basis 的逆,就得到了攻击者在玩家局部坐标里的位置:x 是右,-z 是前。atan2(x, -z) 就是相对正前方的角度(顺时针为正),在屏幕上对应的方向画一段红色圆弧。

霰弹枪一次打出 9 颗弹丸,每颗都要做一次射线检测。用哪种方式更合适?

  1. 场景里放 9 个 RayCast3D 节点
  2. 循环 9 次调用 direct_space_state.intersect_ray()
  3. 用一个 Area3D 检测圆锥范围
  4. 只检测一次,伤害乘 9

一次性的、方向各不相同的检测,用 intersect_ray 最直接,不需要维护节点。示例里用一个 RayCast3D 循环改方向再 force_raycast_update() 也能做到,结果一样。

敌人的视线射线掩码是"世界 + 玩家"(1 | 2),不包含"敌人"层。这样设计的效果是?

  1. 敌人看不见玩家
  2. 敌人的视线会被墙忽略
  3. 敌人可以打中队友
  4. 其他敌人不会挡住视线,敌人的子弹也不会打中队友

掩码决定射线"能碰到什么"。不包含敌人层,射线就会穿过其他敌人,既不挡视线也不会误伤。

烘焙导航网格时把 agent_radius 从 0.5 改成 1.5,会发生什么?

  1. 网格边缘离墙更远,窄通道(比如两个障碍之间不到 3 米的缝)会被堵死
  2. 敌人移动速度变快
  3. 导航网格的多边形变成三角形
  4. 敌人之间的避障距离变大

agent_radius 是烘焙时障碍物被加粗的距离。加粗得多,窄的地方两边加粗后连在一起,就没有可走区域了。避障用的是 NavigationAgent3D 自己的 radius,和烘焙参数无关。

从 Godot 3 迁移的常见坑:Navigation 节点已经删除,寻路由 NavigationServer3D + NavigationRegion3D + NavigationAgent3D 完成;get_simple_path() 换成了 NavigationServer3D.map_get_path() 或直接用 agent;intersect_ray(from, to, exclude, mask) 改成了接收一个 PhysicsRayQueryParameters3D 对象;RayCast 的 cast_to 改名为 target_position。

小结

  • 瞬间命中的武器就是射线检测:一次性检测用 intersect_ray,持续检测用 RayCast3D,都要在物理帧里做。
  • 先规划碰撞层,给每层起名;掩码 = 各层位值的按位或。
  • 散布是圆锥内的随机方向(半径用 sqrt 才均匀),连射累积、停火恢复;后坐力真正改变视角。
  • Compatibility 下 Decal 无效,弹孔用沿法线偏移的小四边形 + 对象池。
  • 敌人感知 = 距离 + 视野角 + 视线射线;状态机在巡逻、追击、攻击之间切换,追的是"最后看见的位置"。
  • 导航网格在运行时烘焙(解析静态碰撞体),agent_radius 决定离墙多远;烘焙后等下一次同步再查询;避障通过 velocity_computed 回传安全速度。
  • HUD 的准星大小来自散布角的投影,伤害方向来自"攻击者在玩家局部坐标里的角度"。