- Venue-correct access artifacts (AccessLoadout) and screened-aware verify - Cone focus on NPC heads with billboarded ring + display name (ADR-0008) - Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel - Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions - Fix GameManager parse error (radial typed as ActionRadial) - Fix first-person camera height and guard spawning inside the building - Move building volume back so the door/awning/marquee are visible - Pull the event scheduler once per simulated second; cap live NPCs - Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
124 lines
3.6 KiB
GDScript
124 lines
3.6 KiB
GDScript
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
|