501 lines
16 KiB
GDScript
501 lines
16 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 TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0)
|
|
const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
|
|
|
|
@export var debug_logs := false
|
|
|
|
@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 head_mesh: MeshInstance3D = $Head
|
|
@onready var hair_mesh: MeshInstance3D = $Hair
|
|
@onready var left_arm: MeshInstance3D = $ArmLeft
|
|
@onready var right_arm: MeshInstance3D = $ArmRight
|
|
@onready var left_leg: MeshInstance3D = $LegLeft
|
|
@onready var right_leg: MeshInstance3D = $LegRight
|
|
@onready var profession_prop: MeshInstance3D = $ProfessionProp
|
|
@onready var profession_label: Label3D = $ProfessionLabel
|
|
@onready var carried_food_visual: MeshInstance3D = $CarriedFood
|
|
@onready var carried_wood_visual: Node3D = $CarriedWood
|
|
@onready var task_glyph_root: Node3D = $TaskGlyphRoot
|
|
@onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph
|
|
@onready var trust_reaction_root: Node3D = $TrustReactionRoot
|
|
@onready var trust_reaction_visuals: Array[MeshInstance3D] = [
|
|
$TrustReactionRoot/Halo,
|
|
$TrustReactionRoot/MoteLeft,
|
|
$TrustReactionRoot/MoteCenter,
|
|
$TrustReactionRoot/MoteRight,
|
|
]
|
|
|
|
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()
|
|
var debug_overlay_visible := true
|
|
var walk_phase := 0.0
|
|
var glyph_phase := 0.0
|
|
var trust_reaction_tween: Tween
|
|
|
|
|
|
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)
|
|
_stop_trust_gain_reaction()
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if is_dead_visual:
|
|
return
|
|
glyph_phase = fmod(glyph_phase + delta * 2.2, TAU)
|
|
if task_glyph_root.visible:
|
|
task_glyph_root.position.y = 2.35 + sin(glyph_phase) * 0.045
|
|
task_glyph_root.rotation.y = glyph_phase * 0.35
|
|
|
|
var horizontal_speed := Vector2(velocity.x, velocity.z).length()
|
|
var blend := minf(delta * 9.0, 1.0)
|
|
if horizontal_speed > 0.1:
|
|
walk_phase = fmod(walk_phase + delta * horizontal_speed * 3.8, TAU)
|
|
var swing := sin(walk_phase)
|
|
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 28.0, blend)
|
|
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 28.0, blend)
|
|
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 20.0, blend)
|
|
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 20.0, blend)
|
|
var step_bob := absf(sin(walk_phase * 2.0)) * 0.035
|
|
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend)
|
|
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend)
|
|
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + step_bob, blend)
|
|
else:
|
|
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend)
|
|
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend)
|
|
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend)
|
|
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend)
|
|
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend)
|
|
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend)
|
|
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend)
|
|
|
|
|
|
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]
|
|
_stop_trust_gain_reaction()
|
|
_apply_personal_details()
|
|
_apply_profession_presentation()
|
|
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
|
|
set_carried_wood_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD) > 0.0)
|
|
set_task_presentation(npc.current_task, npc.task_state)
|
|
|
|
|
|
func _apply_personal_details() -> void:
|
|
var hair_colors := [
|
|
Color(0.16, 0.09, 0.055, 1.0),
|
|
Color(0.29, 0.16, 0.075, 1.0),
|
|
Color(0.09, 0.065, 0.05, 1.0),
|
|
Color(0.42, 0.27, 0.12, 1.0),
|
|
]
|
|
var hair_material := StandardMaterial3D.new()
|
|
hair_material.albedo_color = hair_colors[maxi(sim_id, 0) % hair_colors.size()]
|
|
hair_material.roughness = 0.9
|
|
hair_mesh.material_override = hair_material
|
|
|
|
|
|
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
|
|
left_arm.material_override = body_material
|
|
right_arm.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]
|
|
profession_label.visible = debug_overlay_visible
|
|
|
|
|
|
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_carried_wood_visible(is_visible: bool) -> void:
|
|
carried_wood_visual.visible = is_visible and not is_dead_visual
|
|
|
|
|
|
func set_task_presentation(action_id: StringName, task_state: StringName) -> void:
|
|
if task_glyph_root == null or task_glyph == null:
|
|
return
|
|
if is_dead_visual or not _should_show_task_glyph(action_id, task_state):
|
|
task_glyph_root.visible = false
|
|
return
|
|
|
|
task_glyph.mesh = _create_task_glyph_mesh(action_id)
|
|
task_glyph.material_override = _create_task_glyph_material(action_id)
|
|
task_glyph_root.visible = task_glyph.mesh != null
|
|
|
|
|
|
func set_debug_overlay_visible(is_visible: bool) -> void:
|
|
debug_overlay_visible = is_visible
|
|
if profession_label != null:
|
|
profession_label.visible = is_visible and not is_dead_visual
|
|
|
|
|
|
func play_trust_gain_reaction() -> void:
|
|
if is_dead_visual or trust_reaction_root == null:
|
|
return
|
|
_stop_trust_gain_reaction()
|
|
trust_reaction_root.visible = true
|
|
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION
|
|
trust_reaction_root.rotation = Vector3.ZERO
|
|
trust_reaction_root.scale = TRUST_REACTION_START_SCALE
|
|
_set_trust_reaction_transparency(1.0)
|
|
|
|
trust_reaction_tween = create_tween()
|
|
trust_reaction_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
|
|
trust_reaction_tween.tween_property(trust_reaction_root, "scale", Vector3.ONE, 0.22)
|
|
trust_reaction_tween.parallel().tween_method(_set_trust_reaction_transparency, 1.0, 0.0, 0.22)
|
|
trust_reaction_tween.tween_property(
|
|
trust_reaction_root,
|
|
"position",
|
|
TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.16, 0.0),
|
|
0.68
|
|
)
|
|
trust_reaction_tween.parallel().tween_property(trust_reaction_root, "rotation:y", 0.32, 0.68)
|
|
trust_reaction_tween.tween_property(
|
|
trust_reaction_root,
|
|
"position",
|
|
TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.44, 0.0),
|
|
0.55
|
|
)
|
|
trust_reaction_tween.parallel().tween_property(
|
|
trust_reaction_root, "scale", Vector3.ONE * 1.16, 0.55
|
|
)
|
|
trust_reaction_tween.parallel().tween_method(_set_trust_reaction_transparency, 0.0, 1.0, 0.55)
|
|
trust_reaction_tween.tween_callback(_finish_trust_gain_reaction)
|
|
|
|
|
|
func _set_trust_reaction_transparency(value: float) -> void:
|
|
for visual in trust_reaction_visuals:
|
|
visual.transparency = clampf(value, 0.0, 1.0)
|
|
|
|
|
|
func _finish_trust_gain_reaction() -> void:
|
|
trust_reaction_root.visible = false
|
|
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION
|
|
trust_reaction_root.rotation = Vector3.ZERO
|
|
trust_reaction_root.scale = TRUST_REACTION_START_SCALE
|
|
_set_trust_reaction_transparency(1.0)
|
|
trust_reaction_tween = null
|
|
|
|
|
|
func _stop_trust_gain_reaction() -> void:
|
|
if trust_reaction_tween != null:
|
|
trust_reaction_tween.kill()
|
|
trust_reaction_tween = null
|
|
if trust_reaction_root == null:
|
|
return
|
|
trust_reaction_root.visible = false
|
|
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION
|
|
trust_reaction_root.rotation = Vector3.ZERO
|
|
trust_reaction_root.scale = TRUST_REACTION_START_SCALE
|
|
_set_trust_reaction_transparency(1.0)
|
|
|
|
|
|
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 to_next := 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
|
|
if not is_on_floor():
|
|
velocity.y -= 30.0 * delta
|
|
else:
|
|
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
|
|
_stop_trust_gain_reaction()
|
|
carried_food_visual.visible = false
|
|
carried_wood_visual.visible = false
|
|
profession_prop.visible = false
|
|
profession_label.visible = false
|
|
task_glyph_root.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 find_children("*", "MeshInstance3D", true, false):
|
|
(child as MeshInstance3D).material_override = dead_material
|
|
|
|
collision_layer = 0
|
|
collision_mask = 0
|
|
debug_log("Applied dead visual state")
|
|
|
|
|
|
func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool:
|
|
if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
|
|
return false
|
|
return (
|
|
action_id
|
|
not in [
|
|
SimulationIds.ACTION_IDLE,
|
|
SimulationIds.ACTION_DEAD,
|
|
SimulationIds.ACTION_WANDER,
|
|
]
|
|
)
|
|
|
|
|
|
func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
|
|
task_glyph.rotation_degrees = Vector3.ZERO
|
|
match action_id:
|
|
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
|
|
var food := SphereMesh.new()
|
|
food.radius = 0.18
|
|
food.height = 0.26
|
|
return food
|
|
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD:
|
|
var wood := BoxMesh.new()
|
|
wood.size = Vector3(0.18, 0.42, 0.18)
|
|
task_glyph.rotation_degrees = Vector3(0, 0, -18)
|
|
return wood
|
|
SimulationIds.ACTION_DEPOSIT_FOOD:
|
|
var crate := BoxMesh.new()
|
|
crate.size = Vector3(0.34, 0.22, 0.34)
|
|
return crate
|
|
SimulationIds.ACTION_PATROL:
|
|
var shield := CylinderMesh.new()
|
|
shield.top_radius = 0.2
|
|
shield.bottom_radius = 0.2
|
|
shield.height = 0.1
|
|
task_glyph.rotation_degrees = Vector3(90, 0, 0)
|
|
return shield
|
|
SimulationIds.ACTION_STUDY:
|
|
var book := BoxMesh.new()
|
|
book.size = Vector3(0.36, 0.08, 0.24)
|
|
return book
|
|
SimulationIds.ACTION_REST, SimulationIds.ACTION_SLEEP:
|
|
var rest := SphereMesh.new()
|
|
rest.radius = 0.2
|
|
rest.height = 0.12
|
|
return rest
|
|
return null
|
|
|
|
|
|
func _create_task_glyph_material(action_id: StringName) -> StandardMaterial3D:
|
|
var material := StandardMaterial3D.new()
|
|
material.roughness = 0.65
|
|
material.emission_enabled = true
|
|
material.emission_energy_multiplier = 0.12
|
|
var color := _get_task_glyph_color(action_id)
|
|
material.albedo_color = color
|
|
material.emission = color
|
|
return material
|
|
|
|
|
|
func _get_task_glyph_color(action_id: StringName) -> Color:
|
|
match action_id:
|
|
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
|
|
return Color(0.95, 0.42, 0.25, 1.0)
|
|
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD:
|
|
return Color(0.58, 0.34, 0.16, 1.0)
|
|
SimulationIds.ACTION_DEPOSIT_FOOD:
|
|
return Color(0.95, 0.72, 0.32, 1.0)
|
|
SimulationIds.ACTION_PATROL:
|
|
return Color(0.36, 0.58, 0.95, 1.0)
|
|
SimulationIds.ACTION_STUDY:
|
|
return Color(0.62, 0.46, 0.9, 1.0)
|
|
SimulationIds.ACTION_REST, SimulationIds.ACTION_SLEEP:
|
|
return Color(0.4, 0.78, 0.72, 1.0)
|
|
return Color(0.9, 0.9, 0.85, 1.0)
|