Files
gamedev-the-steward/player/npc/NpcVisual.gd
T
2026-07-14 23:54:22 +02:00

523 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)
const CONCERN_BASE_POSITION := Vector3(-0.4, 2.72, 0.0)
@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 opportunity_concern_root: Node3D = $OpportunityConcernRoot
@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 concern_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
if opportunity_concern_root.visible:
concern_phase = fmod(concern_phase + delta * 1.65, TAU)
opportunity_concern_root.position.y = (CONCERN_BASE_POSITION.y + sin(concern_phase) * 0.045)
opportunity_concern_root.rotation.y = sin(concern_phase * 0.7) * 0.16
var concern_scale := 0.96 + sin(concern_phase) * 0.04
opportunity_concern_root.scale = Vector3.ONE * concern_scale
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()
set_opportunity_concern_visible(false)
_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_opportunity_concern_visible(is_visible: bool) -> void:
if opportunity_concern_root == null:
return
opportunity_concern_root.visible = is_visible and not is_dead_visual
opportunity_concern_root.position = CONCERN_BASE_POSITION
opportunity_concern_root.rotation = Vector3.ZERO
opportunity_concern_root.scale = Vector3.ONE
if is_visible:
concern_phase = 0.0
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()
set_opportunity_concern_visible(false)
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)