Improving NPC behavior

Godot Version

4.7.1

Question

I’m searching for advice to improve behavior of NPC to feel more organic and extendable.

I don’t have much experience with it and from tutorials and old projects I only worked with Raycast and RaycastShape, which works but makes it a bit too robotic.

The purpose of NPC is having routine around the map for going from “barrack” to “camp” , from there go to “treasure/bank” to exchange with other npc, other npc will do the same in reverse.

Current code

extends CharacterBody3D
@onready var navigation_agent_3d: NavigationAgent3D = $NavigationAgent3D
var gravity: float = 9.8
const SPEED = 5.0
var player
var spawn_positon : Vector3


func _ready() -> void:
	player = get_tree().get_first_node_in_group("Player")
	print(player)
	spawn_positon = global_position


func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity.y -= gravity * delta

	if player_is_visible():
		navigation_agent_3d.target_position = player.global_position
		var next_position = navigation_agent_3d.get_next_path_position()
		var direction = global_position.direction_to(next_position)
		look_at_target(direction)
		velocity.x = direction.x * SPEED
		velocity.z = direction.z * SPEED
	else:
		velocity.x = move_toward(velocity.x, 0, SPEED)
		velocity.z = move_toward(velocity.z, 0, SPEED)

	move_and_slide()

# Main Logic with raycast from NPC to Player (no offsets for now)
func player_is_visible() -> bool:
	if player == null:
		return false

	var space_state = get_world_3d().direct_space_state
	var from = global_position 
	var to = player.global_position 
	var query = PhysicsRayQueryParameters3D.create(from, to)
	query.exclude = [self]
	var result = space_state.intersect_ray(query)
	if result.is_empty():
		return true
	else:
		return result.collider == player

func look_at_target(direction: Vector3) -> void:
	var adjusted_direction = direction
	adjusted_direction.y = 0
	if adjusted_direction.length_squared() < 0.0001:
		return
	look_at(global_position + adjusted_direction, Vector3.UP, true)

It detects player and keep making raycast from npc to player and if there is no collider it follows player.

simple change of condition logic made a bit more challenging it for player

func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity.y -= gravity * delta

	if player_is_visible():
		navigation_agent_3d.target_position = player.global_position
		var next_position = navigation_agent_3d.get_next_path_position()
		var direction = global_position.direction_to(next_position)
		look_at_target(direction)
		velocity.x = direction.x * SPEED
		velocity.z = direction.z * SPEED
	else:
		var next_position = navigation_agent_3d.get_next_path_position()
		var direction = global_position.direction_to(next_position)
		look_at_target(direction)
		velocity.x = direction.x * SPEED
		velocity.z = direction.z * SPEED

	move_and_slide()

ok this could be further improved as I figure out the physic frame on first frame haven’t collided with wall

extends CharacterBody3D
@onready var navigation_agent_3d: NavigationAgent3D = $NavigationAgent3D
var gravity: float = 9.8
const SPEED = 5.0
var player
var game_ready = false
func _ready() -> void:
	player = get_tree().get_first_node_in_group("Player")
	await get_tree().physics_frame
	await get_tree().physics_frame
	game_ready = true


func _physics_process(delta: float) -> void:
	if game_ready == true:
		if not is_on_floor():
			velocity.y -= gravity * delta
		
		if player_is_visible():
			navigation_agent_3d.target_position = player.global_position
			var next_position = navigation_agent_3d.get_next_path_position()
			var direction = global_position.direction_to(next_position)
			look_at_target(direction)
			velocity.x = direction.x * SPEED
			velocity.z = direction.z * SPEED
		else:
			var next_position = navigation_agent_3d.get_next_path_position()
			var direction = global_position.direction_to(next_position)
			look_at_target(direction)
			velocity.x = direction.x * SPEED
			velocity.z = direction.z * SPEED

		move_and_slide()
	else:
		pass

# Main Logic with raycast from NPC to Player (no offsets for now)
func player_is_visible() -> bool:
	if player == null:
		return false

	var space_state = get_world_3d().direct_space_state
	var from = global_position 
	var to = player.global_position 
	var query = PhysicsRayQueryParameters3D.create(from, to)
	query.exclude = [self]
	var result = space_state.intersect_ray(query)
	if result.is_empty():
		return true
	else:
		return result.collider == player

func look_at_target(direction: Vector3) -> void:
	var adjusted_direction = direction
	adjusted_direction.y = 0
	if adjusted_direction.length_squared() < 0.0001:
		return
	look_at(global_position + adjusted_direction, Vector3.UP, true)