class_name GuardController extends CharacterBody3D ## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008). signal picked_target(target: Node) signal cleared_target() const NpcFocus := preload("res://src/game/npc_focus.gd") var move_speed: float = 3.2 var look_sensitivity: float = 0.0022 var accel: float = 12.0 var is_ready := false var targeted_npc: Node3D = null var input_locked := false var _miss_frames := 0 const MISS_GRACE := 8 @onready var camera: Camera3D = $Head/Camera3D @onready var head: Node3D = $Head func _ready() -> void: floor_snap_length = 0.3 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) is_ready = true func _unhandled_input(event: InputEvent) -> void: if input_locked: return if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: rotate_y(-event.relative.x * look_sensitivity) head.rotate_x(-event.relative.y * look_sensitivity) head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) func _physics_process(_delta: float) -> void: if input_locked: velocity.x = 0.0 velocity.z = 0.0 if not is_on_floor(): velocity.y -= 9.8 * _delta else: velocity.y = 0.0 move_and_slide() return var forward := -global_transform.basis.z forward.y = 0.0 forward = forward.normalized() var right := global_transform.basis.x right.y = 0.0 right = right.normalized() var input_dir := Vector2( Input.get_axis("move_left", "move_right"), Input.get_axis("move_forward", "move_back") ) if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: input_dir = Vector2.ZERO var desired := (forward * input_dir.y + right * input_dir.x) if desired.length() > 1.0: desired = desired.normalized() desired *= move_speed velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed) velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed) if is_on_floor(): velocity.y = 0.0 else: velocity.y -= 9.8 * _delta move_and_slide() _update_target() func _update_target() -> void: if camera == null: return var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else [] var heads: Array = [] var avatars: Array = [] for node in npcs: if not (node is Node3D) or not is_instance_valid(node): continue var avatar := node as Node3D var head_node := avatar.get_node_or_null("Rig/Head") as Node3D var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0) heads.append(pos) avatars.append(avatar) var cam_origin := camera.global_position var cam_forward := -camera.global_transform.basis.z var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads) var new_target: Node3D = null if idx >= 0: var candidate: Node3D = avatars[idx] if _head_visible(cam_origin, heads[idx]): new_target = candidate if new_target == null and targeted_npc != null: _miss_frames += 1 if _miss_frames < MISS_GRACE: return else: _miss_frames = 0 if new_target != targeted_npc: targeted_npc = new_target if new_target != null: picked_target.emit(new_target) else: cleared_target.emit() func _head_visible(from: Vector3, to: Vector3) -> bool: var space := get_world_3d().direct_space_state if space == null: return true var query := PhysicsRayQueryParameters3D.create(from, to) query.collide_with_areas = false query.collide_with_bodies = true query.exclude = [get_rid()] var hit := space.intersect_ray(query) if hit.is_empty(): return true var obj: Object = hit.get("collider") var n := obj as Node while n != null: if n.is_in_group("npc"): return true n = n.get_parent() return false