Files
gamedev-the-steward/player/npc/NpcVisual.gd
T

261 lines
6.8 KiB
GDScript

extends CharacterBody3D
signal arrived_at_target(sim_id: int)
signal navigation_failed(sim_id: int)
signal position_changed(sim_id: int, active_position: Vector3)
const DEBUG_LOGS := true
@export var move_speed := 3.0
@export var rotation_speed := 8.0
@export var waypoint_reached_distance := 0.35
@export var stuck_timeout := 1.5
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
@onready var body_mesh: MeshInstance3D = $MeshInstance3D
@onready var profession_prop: MeshInstance3D = $ProfessionProp
@onready var profession_label: Label3D = $ProfessionLabel
@onready var carried_food_visual: MeshInstance3D = $CarriedFood
var has_reported_arrival := false
var sim_id: int = -1
var npc_name: String = ""
var profession: StringName
var current_path: PackedVector3Array = []
var path_index := 0
var current_target := Vector3.INF
var arrival_distance := 0.6
var stuck_time := 0.0
var path_request_id := 0
var path_pending := false
var is_dead_visual := false
var dead_material := StandardMaterial3D.new()
func debug_log(message: String) -> void:
if DEBUG_LOGS:
print("[NPCVisual] ", name, " | ", message)
func _ready() -> void:
dead_material.albedo_color = Color(0.25, 0.25, 0.25, 1.0)
func setup_from_sim(npc: SimNPC) -> void:
sim_id = npc.id
npc_name = npc.npc_name
profession = npc.profession
name = "NPC_%s_%s" % [sim_id, npc_name]
_apply_profession_presentation()
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
func _apply_profession_presentation() -> void:
var definition := SimulationDefinitions.get_profession(profession)
if definition == null:
profession_label.text = npc_name
profession_prop.visible = false
return
var body_material := StandardMaterial3D.new()
body_material.albedo_color = definition.visual_color
body_material.roughness = 0.82
body_mesh.material_override = body_material
var prop_material := StandardMaterial3D.new()
prop_material.albedo_color = definition.prop_color
prop_material.roughness = 0.78
profession_prop.material_override = prop_material
profession_prop.mesh = _create_profession_prop_mesh()
profession_prop.visible = profession_prop.mesh != null
profession_label.text = "%s\n%s" % [npc_name, definition.display_name]
func _create_profession_prop_mesh() -> PrimitiveMesh:
profession_prop.position = Vector3(0.48, 0.85, 0.0)
profession_prop.rotation_degrees = Vector3.ZERO
match profession:
SimulationIds.PROFESSION_FARMER:
var basket := SphereMesh.new()
basket.radius = 0.24
basket.height = 0.34
return basket
SimulationIds.PROFESSION_WOODCUTTER:
var tool := BoxMesh.new()
tool.size = Vector3(0.12, 0.72, 0.16)
profession_prop.position = Vector3(0.48, 0.92, 0.0)
profession_prop.rotation_degrees.z = -18.0
return tool
SimulationIds.PROFESSION_GUARD:
var shield := CylinderMesh.new()
shield.top_radius = 0.3
shield.bottom_radius = 0.3
shield.height = 0.12
profession_prop.rotation_degrees.x = 90.0
return shield
SimulationIds.PROFESSION_SCHOLAR:
var book := BoxMesh.new()
book.size = Vector3(0.42, 0.1, 0.3)
return book
SimulationIds.PROFESSION_WANDERER:
var pack := SphereMesh.new()
pack.radius = 0.28
pack.height = 0.5
profession_prop.position = Vector3(-0.42, 0.86, 0.0)
return pack
return null
func set_carried_food_visible(is_visible: bool) -> void:
carried_food_visual.visible = is_visible and not is_dead_visual
func set_target_position(pos: Vector3) -> void:
path_request_id += 1
var request_id := path_request_id
current_target = pos
has_reported_arrival = false
stuck_time = 0.0
path_pending = false
if global_position.distance_to(pos) <= arrival_distance:
current_path = PackedVector3Array()
path_index = 0
debug_log("Already close to target, will report arrival")
return
nav_agent.target_position = pos
path_pending = true
await get_tree().physics_frame
if request_id != path_request_id or is_dead_visual:
return
current_path = NavigationServer3D.map_get_path(
get_world_3d().navigation_map, global_position, pos, true
)
path_pending = false
path_index = 0
if current_path.is_empty():
debug_log("Path is empty")
_report_navigation_failure_once()
else:
debug_log("New target: %s Path points: %s" % [pos, current_path.size()])
func _physics_process(delta: float) -> void:
if is_dead_visual:
velocity = Vector3.ZERO
move_and_slide()
return
if current_target == Vector3.INF:
velocity = Vector3.ZERO
move_and_slide()
return
if path_pending:
velocity = Vector3.ZERO
move_and_slide()
return
if global_position.distance_to(current_target) <= arrival_distance:
_report_arrival_once()
velocity = Vector3.ZERO
move_and_slide()
return
if current_path.is_empty() or path_index >= current_path.size():
velocity = Vector3.ZERO
move_and_slide()
_report_navigation_failure_once()
return
var next_pos := current_path[path_index]
var flat_next_pos := Vector3(next_pos.x, global_position.y, next_pos.z)
var to_next := flat_next_pos - global_position
if to_next.length() <= waypoint_reached_distance:
path_index += 1
return
var direction := to_next.normalized()
var prev_pos := global_position
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
velocity.y = 0.0
move_and_slide()
if not global_position.is_equal_approx(prev_pos):
position_changed.emit(sim_id, global_position)
var minimum_progress := move_speed * delta * 0.1
if global_position.distance_squared_to(prev_pos) < minimum_progress * minimum_progress:
stuck_time += delta
if stuck_time >= stuck_timeout:
debug_log("Movement remained blocked")
_report_navigation_failure_once()
return
else:
stuck_time = 0.0
var target_angle := atan2(direction.x, direction.z)
rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta)
func _report_arrival_once() -> void:
if has_reported_arrival:
return
has_reported_arrival = true
current_path = PackedVector3Array()
path_index = 0
debug_log("Arrived at target")
arrived_at_target.emit(sim_id)
func _report_navigation_failure_once() -> void:
if has_reported_arrival:
return
has_reported_arrival = true
current_path = PackedVector3Array()
path_index = 0
current_target = Vector3.INF
path_pending = false
velocity = Vector3.ZERO
debug_log("Navigation failed")
navigation_failed.emit(sim_id)
func apply_dead_visual_state() -> void:
is_dead_visual = true
carried_food_visual.visible = false
profession_prop.visible = false
path_request_id += 1
velocity = Vector3.ZERO
current_path = PackedVector3Array()
path_index = 0
current_target = Vector3.INF
path_pending = false
has_reported_arrival = true
scale = Vector3(0.8, 0.25, 1.2)
rotation_degrees.x = 90.0
for child in get_children():
if child is MeshInstance3D:
child.material_override = dead_material
collision_layer = 0
collision_mask = 0
debug_log("Applied dead visual state")