a8ae331f51
- NpcVisual emits position_changed signal during _physics_process - SimulationManager.synchronize_npc_position() writes active position into SimNPC state on movement, arrival, and navigation failure - WorldViewManager forwards position_changed and syncs before despawn/reload - SimNPC stores travel_target_position and has_travel_target as versioned state; NPCStateRecord v2 persists them with v1 migration - WorldViewManager.spawn_npc_visual() resumes travel for traveling NPCs via SimulationManager.request_current_travel() - WorldViewManager.despawn_npc_visual() syncs position before removal - WorldViewManager.reload_npc_visual() spawns a visual from persisted state without rerolling target selection - tests/npc_visual_lifecycle_test.gd proves unload/reload preserves action, target, reservation, position, RNG state, and checksum - simulation_state_serialization_test adds legacy v1 NPC migration test that does not invent an active travel target
188 lines
4.5 KiB
GDScript
188 lines
4.5 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
|
|
|
|
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]
|
|
|
|
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
|
|
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")
|