FPS(下):武器、敌人与导航
给控制器装上武器:两种射线检测、碰撞层规划、后坐力和散布、弹孔与命中反馈;再做会看、会追、会开枪的敌人:视线检测、状态机、运行时烘焙导航网格、NavigationAgent3D 寻路与避障,最后用 HUD 把信息告诉玩家。
上一章的控制器只能走路。本章补上"射击"的那一半:开枪时子弹打到了哪里、为什么连射会越来越散、打中之后玩家怎么知道;敌人怎么发现你、怎么绕过箱子追过来、怎么还击。
射线检测:子弹打到了哪里
FPS 里大部分枪是"瞬间命中"(hitscan)的:开枪的瞬间从相机位置沿视线方向发出一条射线,第一个碰到的东西就是被打中的目标。不需要真的生成一颗子弹去飞。Godot 有两种发射线的方式,示例里都实现了,可以用射线检测方式参数切换,效果完全一样。
方式一:直接查询物理空间
## 方式一: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 节点
## 方式二: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_ray | RayCast3D | |
|---|---|---|
| 需要节点 | 不需要 | 需要,跟着父节点移动 |
| 适合 | 一次性的检测:开枪、霰弹枪的每颗弹丸、敌人的视线 | 持续的检测:脚下有没有地面、准星指着什么、激光瞄准器 |
| 在编辑器里可见 | 否 | 是(调试时方便) |
| 调用时机 | 必须在 _physics_process 里(物理空间只在那时安全可读) | 自动更新的结果随时可读 |
射线不只用来开枪。第一人称游戏有个常见问题:枪的模型比玩家的碰撞胶囊伸得更远,贴着墙时枪管会插进墙里。示例的处理方法是每个物理帧从相机往前打一条 0.75 米的短射线,只检测"世界"层,离墙越近,枪越往回收、枪口越往上抬:
## 防穿墙:枪模型比玩家的碰撞胶囊伸得更远,贴墙时枪管会插进墙里。
## 每个物理帧从相机往前打一条很短的射线,离墙越近,枪越往回收、枪口越往上抬。
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,"我要检测什么")来过滤。项目一开始就该规划好,并在项目设置里起名字:
[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 好读得多。
开火:散布与后坐力
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):每开一枪视角往上跳一点,左右随机偏一点。这是真的改变了你的瞄准方向,所以连射时要往下压鼠标才能把准星留在目标上。视角会自动恢复(上一章的两级平滑)。
## 当前散布(度)= 基础散布 + 连射累积 + 移动惩罚,空中再加,蹲下打折。
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 才是均匀分布。下面的小部件可以对比两种写法,也可以模拟一次连射。
命中反馈
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)闪一下,同时被往后推一点。 - 伤害数字从命中点飘起来,爆头用更大的黄色字。
## 伤害数字: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 渲染器里完全不显示,也没有任何警告。替代方案是在命中点放一个贴着表面的小四边形:
## 弹孔。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 死亡 | 爆开成碎片,移出场景 | — |
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()"看见"不是简单的距离判断,示例分三道关:
## 三道关:距离够近 → 在视野扇形里(已经在追的时候不限角度)→ 视线射线没被墙挡住。
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- 距离:超过
sight_range直接看不见。 - 视野角:巡逻时只能看见前方 110° 的扇形,从背后接近不会被发现。已经在追击时不限角度(它知道你在附近)。
- 视线:从敌人的眼睛向玩家的头发一条射线,掩码只有"世界 + 玩家"。第一个打中的是玩家才算看见;打中墙就是被挡住了。
感知每 0.15 秒做一次而不是每帧:人的反应时间本来就有 0.2 秒左右,而且 10 个敌人每帧各打一条射线也是没必要的开销。最后一次看见的位置(last_known)很关键:玩家躲到箱子后面,敌人会追到他消失的地方去找,而不是隔着箱子知道他的实时位置。
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 上的伤害方向指示,玩家能马上知道子弹从哪来。
导航:NavigationServer3D、导航网格与 NavigationAgent3D
敌人要绕过箱子、走坡道上平台,需要寻路。Godot 的 3D 寻路由几部分组成:
- 导航网格(
NavigationMesh):一组凸多边形,覆盖所有"能走的地方"。 - NavigationRegion3D:把一块导航网格放进导航地图。它还负责烘焙:解析子节点的几何体,算出导航网格。
- NavigationServer3D:在后台管理所有地图、区域和代理,负责把相邻区域连接起来、计算路径和避障。
- NavigationAgent3D:挂在角色身上,设置
target_position后,它会向服务器要一条路径,每帧告诉你"下一个拐点在哪"。
运行时烘焙
示例的关卡是代码生成的,所以导航网格也在运行时烘焙。烘焙的原理是把场景体素化(切成 cell_size × cell_height 的小格子),找出所有"上面有足够空间站人、坡度不太陡"的格子,按 agent_radius 往内收缩,再把可走区域拆成凸多边形。
## 运行时烘焙导航网格。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*,只是图的节点从"每个小方格"变成了"每个凸多边形"。下面的小部件把同一张地图分别用两种方式表示:
可以看到三点区别:
- 节点数:方格 A* 要展开几百个格子,导航网格(这里用矩形合并模拟凸多边形)只有十几个多边形,搜索快得多,尤其是在开阔的大场景里。
- 路径形状:方格路径只能走 8 个方向,是锯齿状的,要再做一次平滑。导航网格在多边形之间穿过"门户"(相邻多边形的公共边),再用漏斗算法(funnel / string pulling)拉直,得到的是贴着拐角的最短折线。
- 代理半径:方格寻路要自己处理"角色有体积"的问题;导航网格在烘焙时就把障碍物加粗了,寻路时代理可以当成一个点。
沿路径移动和避障
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)。避障只管代理之间互相让路,不管墙,墙由导航网格负责。
死亡:爆开成碎片
## 敌人死亡时"爆开":把身体上的每个网格换成一个 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),否则会挡住游戏的输入。
## 散布是"角度",准星是"像素"。相机的垂直视野角是 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 时准星的像素大小也会跟着变。
## 伤害方向指示:把"攻击者位置"转换到玩家自己的坐标系里(乘 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 颗弹丸,每颗都要做一次射线检测。用哪种方式更合适?
- 场景里放 9 个 RayCast3D 节点
- 循环 9 次调用 direct_space_state.intersect_ray()
- 用一个 Area3D 检测圆锥范围
- 只检测一次,伤害乘 9
一次性的、方向各不相同的检测,用 intersect_ray 最直接,不需要维护节点。示例里用一个 RayCast3D 循环改方向再 force_raycast_update() 也能做到,结果一样。
敌人的视线射线掩码是"世界 + 玩家"(1 | 2),不包含"敌人"层。这样设计的效果是?
- 敌人看不见玩家
- 敌人的视线会被墙忽略
- 敌人可以打中队友
- 其他敌人不会挡住视线,敌人的子弹也不会打中队友
掩码决定射线"能碰到什么"。不包含敌人层,射线就会穿过其他敌人,既不挡视线也不会误伤。
烘焙导航网格时把 agent_radius 从 0.5 改成 1.5,会发生什么?
- 网格边缘离墙更远,窄通道(比如两个障碍之间不到 3 米的缝)会被堵死
- 敌人移动速度变快
- 导航网格的多边形变成三角形
- 敌人之间的避障距离变大
agent_radius 是烘焙时障碍物被加粗的距离。加粗得多,窄的地方两边加粗后连在一起,就没有可走区域了。避障用的是 NavigationAgent3D 自己的 radius,和烘焙参数无关。
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 的准星大小来自散布角的投影,伤害方向来自"攻击者在玩家局部坐标里的角度"。