Once an NPC reaches its intent waypoint it now does something instead of standing in an idle T-pose: - sit: the rig settles 0.34 m into a seat and the legs swing forward on the steps - photo/film: influencers and tourists raise their camera prop briefly (a sine pulse, so it reads as taking a shot rather than a permanently raised arm) - perform: performers sway the sign board they carry Pose selection lives in NpcAvatar.theater_pose() (intent + arrived); the math is in pure helpers (crouch_for_pose, prop_lift_for_pose, sway_for_pose) so it is headless-testable. Prop/leg nodes are captured at build time and animated from their rest offsets. Performers also get a hand-held board and post. Tests/docs - Headless suite 120 -> 127 (test_npc_theater.gd). - Verified visually with a staged sit/photo/perform lineup in front of the entrance. - README, TODO section moved to DONE, MEMORY. Version 0.2.7.
525 lines
19 KiB
GDScript
525 lines
19 KiB
GDScript
class_name NpcAvatar
|
|
extends CharacterBody3D
|
|
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
|
|
## Gives the controller a body, color-coded by archetype, and simple movement.
|
|
|
|
const NPCController := preload("res://src/core/npc_controller.gd")
|
|
const Rules := preload("res://src/core/rules.gd")
|
|
|
|
var npc: NPCController = null
|
|
var spawn_position: Vector3 = Vector3.ZERO
|
|
var target_position: Vector3 = Vector3.ZERO
|
|
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
|
var awaiting_interaction := false
|
|
var arrived := false
|
|
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
|
|
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
|
|
var idle_s := 0.0
|
|
var lifetime_s := 90.0
|
|
var ephemeral := true
|
|
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
|
|
## incident, "flee" clears away from it, "" is normal navigation.
|
|
var reaction_mode := ""
|
|
var reaction_point := Vector3.ZERO
|
|
var _reaction_timer := 0.0
|
|
const RUBBERNECK_S := 12.0
|
|
const FLEE_S := 20.0
|
|
const FLEE_SPEED := 2.4
|
|
var agent: NavigationAgent3D = null
|
|
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
|
var _nav_usable := false
|
|
var _nav_try_frames := 0
|
|
|
|
const TYPE_COLORS := {
|
|
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
|
|
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
|
|
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
|
|
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
|
|
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
|
|
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
|
|
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
|
|
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
|
|
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
|
|
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
|
|
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
|
|
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
|
|
Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20),
|
|
}
|
|
|
|
func _init() -> void:
|
|
npc = NPCController.new()
|
|
|
|
func get_npc_controller() -> NPCController:
|
|
return npc
|
|
|
|
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
|
|
var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
|
|
npc.setup(id, profile, intent, seed_value)
|
|
npc.access_override = p_override
|
|
_visual_seed = seed_value
|
|
spawn_position = p_spawn
|
|
target_position = p_target
|
|
door_position = p_door
|
|
position = p_spawn
|
|
npc.fsm.state_changed.connect(_on_state_changed)
|
|
add_to_group("npc")
|
|
_build_body(profile.npc_type)
|
|
_nav_usable = false
|
|
_nav_try_frames = 0
|
|
agent = NavigationAgent3D.new()
|
|
agent.name = "NavAgent"
|
|
agent.path_desired_distance = 0.4
|
|
agent.target_desired_distance = 4.0
|
|
agent.radius = 0.35
|
|
agent.height = 1.2
|
|
add_child(agent)
|
|
|
|
# Normalize intent targets: approach the entrance, stop near the door.
|
|
var collider := CollisionShape3D.new()
|
|
var shape := CapsuleShape3D.new()
|
|
shape.radius = 0.35
|
|
var height_scale: float = _rig.scale.y if _rig else 1.0
|
|
shape.height = 1.6 * height_scale
|
|
collider.shape = shape
|
|
collider.position = Vector3(0, 0.8 * height_scale, 0)
|
|
add_child(collider)
|
|
|
|
var _visual_seed := 0
|
|
var _rig: Node3D = null
|
|
var _head: Node3D = null
|
|
var _bob_phase := 0.0
|
|
var _guard_ref: Node3D = null
|
|
var _ring: MeshInstance3D = null
|
|
var _name_tag: Label3D = null
|
|
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
|
var _photo_prop: MeshInstance3D = null
|
|
var _sign_prop: MeshInstance3D = null
|
|
var _legs: Array[MeshInstance3D] = []
|
|
var _theater_t := 0.0
|
|
var _photo_prop_base_y := 0.0
|
|
var _sign_prop_base_y := 0.0
|
|
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
|
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
|
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
|
|
|
func _build_body(npc_type: int) -> void:
|
|
_rig = Node3D.new()
|
|
_rig.name = "Rig"
|
|
add_child(_rig)
|
|
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
|
# Per-seed variation: slight height and brightness differences so crowds
|
|
# read as individuals (deterministic LCG, no RNG side effects).
|
|
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
|
var height_scale := 0.92 + 0.16 * frac
|
|
_rig.scale.y = height_scale
|
|
var shader_delta := 0.12 * frac
|
|
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
|
var limbs_color := base_color.darkened(0.25)
|
|
# Torso
|
|
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
|
# Legs
|
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
|
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
|
|
# Arms
|
|
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
|
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
|
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
|
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
|
|
_head = Node3D.new()
|
|
_head.name = "Head"
|
|
_head.position = Vector3(0, 1.62, 0)
|
|
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
|
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
|
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
|
|
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
|
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
|
_rig.add_child(_head)
|
|
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
|
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
|
_build_focus_ring()
|
|
_add_props(npc_type)
|
|
|
|
func _build_focus_ring() -> void:
|
|
_ring = MeshInstance3D.new()
|
|
_ring.name = "FocusRing"
|
|
var torus := TorusMesh.new()
|
|
torus.inner_radius = 0.26
|
|
torus.outer_radius = 0.34
|
|
torus.rings = 12
|
|
torus.ring_segments = 16
|
|
_ring.mesh = torus
|
|
var mat := StandardMaterial3D.new()
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
|
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
_ring.material_override = mat
|
|
_ring.position = Vector3(0, 0.28, 0)
|
|
_ring.visible = false
|
|
_head.add_child(_ring)
|
|
_name_tag = Label3D.new()
|
|
_name_tag.name = "NameTag"
|
|
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
|
_name_tag.font_size = 48
|
|
_name_tag.pixel_size = 0.004
|
|
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
|
_name_tag.position = Vector3(0, 0.58, 0)
|
|
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
|
_name_tag.visible = false
|
|
_head.add_child(_name_tag)
|
|
|
|
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
|
if _ring:
|
|
_ring.visible = focused
|
|
var mat := _ring.material_override as StandardMaterial3D
|
|
if mat:
|
|
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85)
|
|
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
|
if _name_tag:
|
|
_name_tag.visible = focused
|
|
|
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := CapsuleMesh.new()
|
|
mesh.radius = radius
|
|
mesh.height = height
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.rotation_degrees.z = z_rot
|
|
mi.material_override = _material(color)
|
|
parent.add_child(mi)
|
|
return mi
|
|
|
|
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := SphereMesh.new()
|
|
mesh.radius = radius
|
|
mesh.height = radius * 2.0
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.material_override = _material(color)
|
|
parent.add_child(mi)
|
|
|
|
func _material(color: Color) -> StandardMaterial3D:
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = color
|
|
return mat
|
|
|
|
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
|
func _add_props(npc_type: int) -> void:
|
|
var chest_y := 1.25
|
|
match npc_type:
|
|
Rules.NpcType.INFLUENCER:
|
|
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
|
_photo_prop_base_y = _photo_prop.position.y
|
|
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
|
Rules.NpcType.DELIVERY:
|
|
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
|
Rules.NpcType.PERFORMER:
|
|
var hat := _material(Color(0.85, 0.2, 0.25))
|
|
var cone := MeshInstance3D.new()
|
|
var cm := CylinderMesh.new()
|
|
cm.top_radius = 0.02
|
|
cm.bottom_radius = 0.22
|
|
cm.height = 0.4
|
|
cone.mesh = cm
|
|
cone.material_override = hat
|
|
cone.position = Vector3(0, 0.26, 0)
|
|
_head.add_child(cone)
|
|
# Hat in one hand and a small board in the other: the performer plays
|
|
# to the sidewalk once at the waypoint.
|
|
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
|
|
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
|
|
_sign_prop_base_y = _sign_prop.position.y
|
|
Rules.NpcType.PROTESTER:
|
|
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
|
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
|
_sign_prop_base_y = _sign_prop.position.y
|
|
Rules.NpcType.TOURIST:
|
|
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
|
_photo_prop_base_y = _photo_prop.position.y
|
|
Rules.NpcType.CONFUSED_VISITOR:
|
|
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
|
|
|
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := BoxMesh.new()
|
|
mesh.size = size
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.material_override = _material(color)
|
|
parent.add_child(mi)
|
|
return mi
|
|
|
|
## Rubberneck: idle people stop and turn toward the incident. Returns false when
|
|
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
|
|
func start_rubberneck(at: Vector3) -> bool:
|
|
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
|
|
return false
|
|
# Someone actually waiting at the entrance is being dealt with, not a bystander.
|
|
# Avatars live under the identity SceneRoot, so `position` matches world space
|
|
# and also works before the node enters the tree (tests).
|
|
if awaiting_interaction and (position - door_position).length() < 3.0:
|
|
return false
|
|
if reaction_mode == "flee":
|
|
return false
|
|
reaction_mode = "watch"
|
|
reaction_point = at
|
|
_reaction_timer = RUBBERNECK_S
|
|
velocity = Vector3.ZERO
|
|
return true
|
|
|
|
## Panic: clear away from the incident (usually the door) in a straight line.
|
|
func start_flee(from: Vector3) -> void:
|
|
if npc.leave_requested or leave_requested_by_lifetime:
|
|
return
|
|
reaction_mode = "flee"
|
|
reaction_point = from
|
|
_reaction_timer = FLEE_S
|
|
var away := global_position - from
|
|
away.y = 0.0
|
|
if away.length() < 0.5:
|
|
away = Vector3(0.0, 0.0, 1.0)
|
|
target_position = global_position + away.normalized() * 25.0
|
|
target_position.y = 0.5
|
|
arrived = false
|
|
awaiting_interaction = false
|
|
|
|
func reaction_is_active() -> bool:
|
|
return reaction_mode != ""
|
|
|
|
func _tick_reaction(delta: float) -> void:
|
|
if reaction_mode == "":
|
|
return
|
|
_reaction_timer -= delta
|
|
if reaction_mode == "flee":
|
|
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
|
|
reaction_mode = ""
|
|
queue_free()
|
|
return
|
|
if _reaction_timer <= 0.0:
|
|
reaction_mode = ""
|
|
|
|
## Turn the body toward the reaction point without moving.
|
|
func _face_reaction_point(delta: float) -> void:
|
|
var to_point := reaction_point - global_position
|
|
to_point.y = 0.0
|
|
if to_point.length() < 0.1:
|
|
return
|
|
var target_yaw := atan2(-to_point.x, -to_point.z)
|
|
rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0))
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if npc == null or npc.fsm == null:
|
|
return
|
|
_advance_fsm(delta)
|
|
_tick_reaction(delta)
|
|
if reaction_mode == "watch":
|
|
velocity = Vector3.ZERO
|
|
_face_reaction_point(delta)
|
|
_animate(delta)
|
|
return
|
|
if reaction_mode == "flee":
|
|
_move_toward_target(delta)
|
|
_animate(delta)
|
|
return
|
|
_move_toward_target(delta)
|
|
_animate(delta)
|
|
|
|
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
|
## Theater pose for the current intent once the NPC has stopped walking.
|
|
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
|
|
## "film" for a slow pan, otherwise "idle".
|
|
func theater_pose() -> String:
|
|
if not arrived:
|
|
return "idle"
|
|
match npc.intent:
|
|
"sit":
|
|
return "sit"
|
|
"photo":
|
|
return "photo"
|
|
"film":
|
|
return "film"
|
|
"perform":
|
|
return "perform"
|
|
_:
|
|
return "idle"
|
|
|
|
## How far the rig drops for a seated pose. Pure so it is headless-testable.
|
|
static func crouch_for_pose(pose: String) -> float:
|
|
return -0.34 if pose == "sit" else 0.0
|
|
|
|
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
|
|
## it reads as taking a photo rather than a permanently raised arm.
|
|
static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
|
if pose != "photo" and pose != "film":
|
|
return 0.0
|
|
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
|
|
|
## Sway amount for a performer playing to the sidewalk.
|
|
static func sway_for_pose(pose: String, phase: float) -> float:
|
|
if pose != "perform":
|
|
return 0.0
|
|
return sin(phase * 2.2) * 0.09
|
|
|
|
func _animate(delta: float) -> void:
|
|
if _rig == null:
|
|
return
|
|
var speed := Vector2(velocity.x, velocity.z).length()
|
|
var pose := theater_pose()
|
|
var crouch := crouch_for_pose(pose)
|
|
if speed > 0.3:
|
|
_bob_phase += delta * (6.0 + speed)
|
|
_rig.position.y = sin(_bob_phase) * 0.035
|
|
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
|
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
|
# Idle theater: seated, photographing, filming, or performing.
|
|
_theater_t += delta
|
|
_bob_phase = 0.0
|
|
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
|
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
|
var lift := prop_lift_for_pose(pose, _theater_t)
|
|
if _photo_prop:
|
|
_photo_prop.position.y = _photo_prop_base_y + lift
|
|
_photo_prop.rotation.x = -lift * 1.4
|
|
var sway := sway_for_pose(pose, _theater_t)
|
|
if _sign_prop:
|
|
_sign_prop.rotation.z = sway
|
|
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
|
|
if _legs.size() == 2:
|
|
# Negative pitch swings the legs forward (toward the way the body faces).
|
|
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
|
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
|
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
|
else:
|
|
_bob_phase = 0.0
|
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
|
if _ring and _ring.visible:
|
|
var cam := get_viewport().get_camera_3d() if get_viewport() else null
|
|
if cam:
|
|
_ring.look_at(cam.global_position, Vector3.UP)
|
|
# Attention cue: head turns toward the guard when awareness was raised.
|
|
if _head != null and npc.guard_noticed:
|
|
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
|
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
|
|
if _guard_ref:
|
|
var to_guard := (_guard_ref.global_position - global_position)
|
|
to_guard.y = 0.0
|
|
var world_yaw := atan2(to_guard.x, to_guard.z)
|
|
var fwd := -_rig.global_transform.basis.z
|
|
var parent_yaw := atan2(fwd.x, fwd.z)
|
|
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
|
|
|
func _advance_fsm(delta: float) -> void:
|
|
# Routine traffic despawns after a while once it has finished its walk, so
|
|
# the crowd cap is not permanently occupied by idle standers.
|
|
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
|
|
idle_s += delta
|
|
if idle_s >= lifetime_s:
|
|
leave_requested_by_lifetime = true
|
|
return
|
|
# Returning-trespasser: an NPC directed away may circle back to the door.
|
|
if npc.tick_return(delta):
|
|
arrived = false
|
|
awaiting_interaction = false
|
|
target_position = door_position
|
|
return
|
|
if npc.leave_requested or leave_requested_by_lifetime:
|
|
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
|
away.y = 0.5
|
|
target_position = away
|
|
if (global_position - away).length() < 2.0:
|
|
queue_free()
|
|
return
|
|
if arrived and not awaiting_interaction:
|
|
awaiting_interaction = true
|
|
|
|
var leave_requested_by_lifetime := false
|
|
|
|
func _move_toward_target(delta: float) -> void:
|
|
if agent == null:
|
|
_move_direct(delta)
|
|
return
|
|
# Re-assigning the target every physics frame invalidates the path, so
|
|
# get_next_path_position() keeps returning the current position and the
|
|
# avatar never moves. Only update the agent when the target changes.
|
|
if agent.target_position != target_position:
|
|
agent.target_position = target_position
|
|
if agent.is_navigation_finished():
|
|
if (global_position - target_position).length() < 3.0:
|
|
_arrive()
|
|
else:
|
|
# Path finished but we are far away: navigation is unusable, fall back.
|
|
_move_direct(delta)
|
|
return
|
|
# Learn whether the navmesh actually serves this spot.
|
|
if not _nav_usable:
|
|
_nav_try_frames += 1
|
|
if agent.is_target_reachable():
|
|
_nav_usable = true
|
|
elif _nav_try_frames > 10:
|
|
_move_direct(delta)
|
|
return
|
|
var next := agent.get_next_path_position()
|
|
# Godot reports the current position as the first path point (on the navmesh
|
|
# plane, so below the body) and only advances past it once the agent is within
|
|
# path_desired_distance. Steering at the point we are standing on gives a
|
|
# zero-length direction, and steering at any point merely "far enough" away can
|
|
# pick the old start behind us. Follow the first path point that is genuinely
|
|
# closer to the destination than we are.
|
|
var path := agent.get_current_navigation_path()
|
|
if path.size() >= 2:
|
|
var goal: Vector3 = path[path.size() - 1]
|
|
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
|
|
for i in range(1, path.size()):
|
|
var candidate: Vector3 = path[i]
|
|
candidate.y = global_position.y
|
|
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
|
|
if candidate_to_goal < here_to_goal - 0.05:
|
|
next = candidate
|
|
break
|
|
var dir := (next - global_position)
|
|
dir.y = 0.0
|
|
if dir.length() < 0.2:
|
|
velocity = Vector3.ZERO
|
|
return
|
|
dir = dir.normalized()
|
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
|
velocity.x = dir.x * speed
|
|
velocity.z = dir.z * speed
|
|
velocity.y -= 9.8 * delta
|
|
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
|
move_and_slide()
|
|
|
|
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
|
func _move_direct(delta: float) -> void:
|
|
var to_target := target_position - global_position
|
|
to_target.y = 0.0
|
|
var dist := to_target.length()
|
|
if dist < 0.7:
|
|
_arrive()
|
|
return
|
|
var dir := to_target.normalized()
|
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
|
velocity.x = dir.x * speed
|
|
velocity.z = dir.z * speed
|
|
velocity.y -= 9.8 * delta
|
|
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
|
move_and_slide()
|
|
|
|
func _arrive() -> void:
|
|
arrived = true
|
|
velocity = Vector3.ZERO
|
|
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
|
|
target_position = door_position
|
|
if (global_position - door_position).length() < 0.8:
|
|
awaiting_interaction = true
|
|
|
|
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
|
|
match to_state:
|
|
"LEAVING":
|
|
npc.leave_requested = true
|
|
"THREATENING", "FORCING_ENTRY":
|
|
# Advance toward the door posture; keep it simple for the prototype.
|
|
pass
|