Implement control pass: focus cone, action wheel, named inputs, boot fixes
- 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.
This commit is contained in:
1 parent
69484866db
commit
f11b441c07
28 files changed
+1433
-249
No files matched your search
@@ -1,35 +1,47 @@
|
||||
class_name GuardController
|
||||
extends CharacterBody3D
|
||||
## First-person guard controller: WASD movement, mouse look, interact raycast.
|
||||
## 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))
|
||||
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
# Movement relative to the head's yaw only.
|
||||
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()
|
||||
@@ -41,28 +53,49 @@ func _physics_process(_delta: float) -> void:
|
||||
Input.get_axis("move_left", "move_right"),
|
||||
Input.get_axis("move_forward", "move_back")
|
||||
)
|
||||
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
|
||||
velocity.x = direction.x * move_speed
|
||||
velocity.z = direction.z * move_speed
|
||||
velocity.y -= 9.8 * _delta
|
||||
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:
|
||||
var space := get_world_3d().direct_space_state
|
||||
var from := camera.global_position
|
||||
var to := from + -camera.global_transform.basis.z * 8.0
|
||||
var query := PhysicsRayQueryParameters3D.create(from, to)
|
||||
query.collide_with_areas = true
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = [get_rid()]
|
||||
var hit := space.intersect_ray(query)
|
||||
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 hit:
|
||||
var obj: Object = hit.get("collider")
|
||||
var parent_avatar: Node3D = _find_avatar(obj)
|
||||
if parent_avatar != null:
|
||||
new_target = parent_avatar
|
||||
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:
|
||||
@@ -70,14 +103,21 @@ func _update_target() -> void:
|
||||
else:
|
||||
cleared_target.emit()
|
||||
|
||||
func _find_avatar(obj: Object) -> Node3D:
|
||||
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
|
||||
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
|
||||
if n == null:
|
||||
return null
|
||||
var current: Node = n
|
||||
while current != null:
|
||||
if current.has_method("get_npc_controller"):
|
||||
return current as Node3D
|
||||
current = current.get_parent()
|
||||
return null
|
||||
while n != null:
|
||||
if n.is_in_group("npc"):
|
||||
return true
|
||||
n = n.get_parent()
|
||||
return false
|
||||
Reference in new issue
Block a user