Implement control pass: focus cone, action wheel, named inputs, boot fixes
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped

- 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:
zwitschi committed 2026-10-31 15:58:01 +01:00
1 parent 69484866db
commit f11b441c07
28 files changed
+1433 -249

No files matched your search

+272 -35
View File
@@ -6,6 +6,7 @@ extends Node
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const DoorState := preload("res://src/core/door_state.gd")
const WandScreener := preload("res://src/core/wand_screener.gd")
@@ -19,6 +20,7 @@ const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const Hud := preload("res://src/game/ui/hud.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
@@ -57,8 +59,10 @@ const PHASE_LIGHTING := {
},
}
var hud: Hud = null
var radial: ActionRadial = null
var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null
var _focused_avatar: Node3D = null
var audio: AudioFeedback = null
var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new()
@@ -88,6 +92,9 @@ var _door_rng := RandomNumberGenerator.new()
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
const NEARBY_RADIUS := 12.0
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
## events evict the oldest unfocused NPC so authored moments still spawn.
const MAX_LIVE_NPCS := 16
var _nearby_index := -1
func _ready() -> void:
@@ -102,7 +109,10 @@ func _ready() -> void:
guard = _build_guard()
scene_root.add_child(guard)
guard.global_position = Vector3(0, 1.6, -2.5)
# Stand on the landing in front of the door, facing the street where guests
# arrive. The building volume now starts behind the door (world_builder).
guard.global_position = Vector3(0, 0.1, -2.0)
guard.rotation.y = PI
guard.picked_target.connect(_on_guard_target)
guard.cleared_target.connect(_on_guard_clear)
@@ -126,6 +136,9 @@ func _build_guard() -> GuardController:
g.name = "Guard"
var head := Node3D.new()
head.name = "Head"
# Eye height. The guard root sits on the floor, so this keeps the camera at
# ~1.7 m while gravity/floor snapping handles the steps (ADR-0007).
head.position = Vector3(0, 1.6, 0)
g.add_child(head)
var camera := Camera3D.new()
camera.name = "Camera3D"
@@ -185,20 +198,52 @@ var inspector_current_events: Array = []
func _build_ui() -> void:
hud = Hud.new()
add_child(hud)
_add_crosshair()
audio = AudioFeedback.new()
add_child(audio)
radial = ActionRadial.new()
add_child(radial)
radial.confirmed.connect(_on_radial_confirmed)
radial.cancelled.connect(_cancel_radial)
radio_panel = RadioPanel.new()
add_child(radio_panel)
radio_panel.hide()
radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func():
radio_panel.hide()
hud.set_action_hint(""))
hud.set_action_hint("")
_refresh_freeze())
shift_review_panel = ShiftReview.new()
add_child(shift_review_panel)
shift_review_panel.hide()
shift_review_panel.restart_requested.connect(restart_shift)
func _add_crosshair() -> void:
var layer := CanvasLayer.new()
layer.layer = 9
layer.name = "Crosshair"
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(center)
var box := Control.new()
box.custom_minimum_size = Vector2(16, 16)
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.add_child(box)
var h := ColorRect.new()
h.color = Color(1, 1, 1, 0.75)
h.size = Vector2(12, 2)
h.position = Vector2(2, 7)
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(h)
var v := ColorRect.new()
v.color = Color(1, 1, 1, 0.75)
v.size = Vector2(2, 12)
v.position = Vector2(7, 2)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(v)
add_child(layer)
func _ensure_lighting() -> void:
_sun = DirectionalLight3D.new()
_sun.name = "Sun"
@@ -239,7 +284,12 @@ func _apply_phase_lighting() -> void:
if world_builder:
world_builder.set_night_mode(cfg.get("night", false))
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
_purge_npcs()
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
return
if npcs.size() >= MAX_LIVE_NPCS:
_evict_oldest_npc()
npc_counter += 1
var avatar := NpcAvatar.new()
avatar.name = "Npc_%d" % npc_counter
@@ -250,10 +300,35 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos)
var spawn_profile: NpcProfile = profile.duplicate(true) if profile else profile
var override: Dictionary = event.get("override_access", {})
var seed_value := npc_counter * 991 + 17
if spawn_profile and selected_scenario.access_policy.wand_screening_required:
var items: Array[String] = []
for item in AccessLoadout.roll_carried_items(seed_value, override):
items.append(str(item))
spawn_profile.carried_items = items
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
scene_root.add_child(avatar)
npcs.append(avatar)
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
func _purge_npcs() -> void:
for i in range(npcs.size() - 1, -1, -1):
if not is_instance_valid(npcs[i]):
npcs.remove_at(i)
## Make room for a scripted event by removing the oldest NPC the player is not
## currently focused on.
func _evict_oldest_npc() -> void:
for i in npcs.size():
var npc: Node = npcs[i]
if npc == targeted_npc or npc == _focused_avatar:
continue
npcs.remove_at(i)
npc.queue_free()
return
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
## entrance and resolves the incident with command/restraint/evacuation/arrest.
@@ -291,16 +366,27 @@ func _on_shift_ended(review: Dictionary) -> void:
if Save:
Save.save_shift_result(review)
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
_refresh_freeze()
func _on_guard_target(target: Node3D) -> void:
targeted_npc = target
_set_focused_avatar(target)
_begin_incident(target)
func _on_guard_clear() -> void:
targeted_npc = null
_set_focused_avatar(null)
hud.set_target_label("")
hud.set_action_hint("")
func _set_focused_avatar(avatar: Node3D) -> void:
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
_focused_avatar.set_focus_visual(false, false)
_focused_avatar = avatar
var menu_open := radial != null and radial.is_open
if avatar and avatar.has_method("set_focus_visual"):
avatar.set_focus_visual(true, menu_open)
func _begin_incident(avatar: Node3D) -> void:
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
if controller == null:
@@ -309,8 +395,7 @@ func _begin_incident(avatar: Node3D) -> void:
current_incident_active = true
current_npc_truth = _read_npc_truth(controller)
var profile: Object = controller.profile
var label := "[%s] %s — %s" % [
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
var label := "%s — %s" % [
profile.display_name if profile else "?",
controller.intent,
]
@@ -327,20 +412,62 @@ func _begin_incident(avatar: Node3D) -> void:
# ------------------------------------------------------------------ input ---
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
_handle_key(event.keycode)
if event is InputEventKey and event.echo:
return
if event.is_action_pressed("cancel"):
_handle_cancel(event is InputEventMouseButton)
return
if event is InputEventMouseButton and event.pressed:
_handle_mouse_button(event)
return
if event is InputEventKey and event.pressed:
_handle_key(event)
func _handle_key(keycode: Key) -> void:
func _handle_mouse_button(event: InputEventMouseButton) -> void:
if event.button_index != MOUSE_BUTTON_LEFT:
return
if radial and radial.is_open:
return
if not _modal_blocks_radial() and targeted_npc != null:
_open_radial()
func _modal_blocks_radial() -> bool:
if inspection_open or wand_open:
return true
if radio_panel and radio_panel.visible:
return true
if shift_review_panel and shift_review_panel.visible:
return true
return false
func _handle_key(event: InputEvent) -> void:
var keycode: Key = event.keycode
if radial and radial.is_open:
if event.is_action_pressed("confirm"):
_confirm_radial()
return
match keycode:
KEY_A, KEY_LEFT:
radial.step(-1)
KEY_D, KEY_RIGHT:
radial.step(1)
return
if event.is_action_pressed("action_menu"):
if targeted_npc != null and not _modal_blocks_radial():
_open_radial()
return
if event.is_action_pressed("inspect"):
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_inspection()
return
if event.is_action_pressed("wand"):
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_wand_screening()
return
match keycode:
KEY_E:
if targeted_npc != null and not inspection_open:
_toggle_inspection()
KEY_Q:
if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
KEY_W:
if targeted_npc != null and not inspection_open:
_toggle_wand_screening()
KEY_TAB:
_cycle_nearby_target()
KEY_F:
@@ -383,6 +510,113 @@ func _handle_action_key(keycode: Key) -> void:
_:
pass
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse
## capture; RMB does nothing so it never grabs the mouse by accident.
func _handle_cancel(from_mouse: bool) -> void:
if _close_top_modal():
return
if from_mouse:
return
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 _close_top_modal() -> bool:
if radial and radial.is_open:
_cancel_radial()
return true
if inspection_open:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return true
if wand_open:
hud.hide_wand()
wand_open = false
_refresh_freeze()
return true
if radio_panel and radio_panel.visible:
radio_panel.hide()
hud.set_action_hint("")
_refresh_freeze()
return true
return false
func _open_radial() -> void:
if targeted_npc == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var controller: Object = _targeted_controller()
var p_name := "Person"
if controller and controller.profile:
p_name = str(controller.profile.display_name)
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
radial.open_menu(p_name, wand_ok)
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, true)
_refresh_freeze()
func _confirm_radial() -> void:
if radial == null or not radial.is_open:
return
var slice := radial.confirm_selected()
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
if slice != "":
_on_radial_confirmed(slice)
func _cancel_radial() -> void:
if radial == null or not radial.is_open:
return
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
func _on_radial_confirmed(slice_id: String) -> void:
if radial and radial.is_open:
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
match slice_id:
"raise_hand":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
"point":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
"head_shake":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
"polite":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
"inspect":
_toggle_inspection()
"wand":
_toggle_wand_screening()
"warning":
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
"block":
_apply_interaction(Rules.Level.BLOCKING, "blocking")
"radio":
_open_radio(false)
func _refresh_freeze() -> void:
var freeze := inspection_open or wand_open
if radial and radial.is_open:
freeze = true
if radio_panel and radio_panel.visible:
freeze = true
if shift_review_panel and shift_review_panel.visible:
freeze = true
if guard:
guard.input_locked = freeze
if freeze:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
## Toggle the guard's flashlight. In evening/late phases it widens NPC
## perception radius (PLAN.md: Level 3 — flashlight at night).
func _toggle_flashlight() -> void:
@@ -429,8 +663,13 @@ func _cycle_nearby_target() -> void:
_nearby_index = (_nearby_index + 1) % nearby.size()
var next_target: Node3D = nearby[_nearby_index]
targeted_npc = next_target
_set_focused_avatar(next_target)
_begin_incident(next_target)
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
var controller: Object = _targeted_controller()
var who: String = next_target.name
if controller and controller.profile and controller.profile.display_name != "":
who = str(controller.profile.display_name)
hud.log(Strings.fmt("hud.targeting", [who]))
func _apply_interaction(level: int, action_name: String) -> void:
if targeted_npc == null:
@@ -506,9 +745,11 @@ func _open_radio(emergency: bool) -> void:
return
radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.")
_refresh_freeze()
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
hud.set_action_hint("")
_refresh_freeze()
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
if not result.get("ok", false):
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
@@ -540,14 +781,16 @@ func _toggle_inspection() -> void:
if inspection_open:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return
var controller: Object = _targeted_controller()
if controller == null:
return
inspection_open = true
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
hud.show_inspection(controller, artifacts, result)
_refresh_freeze()
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
var line := _door_context_line()
if line != "":
@@ -565,6 +808,7 @@ func _toggle_wand_screening() -> void:
hud.hide_wand()
hud.set_wand_icon(false)
wand_open = false
_refresh_freeze()
return
if not selected_scenario.equipment.has("wand"):
hud.log(Strings.lookup("hud.no_wand"))
@@ -579,6 +823,7 @@ func _toggle_wand_screening() -> void:
var result: Dictionary = wand_screener.screen(items)
wand_open = true
hud.show_wand(controller, result)
_refresh_freeze()
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
if wand_line != "":
hud.log(wand_line)
@@ -589,6 +834,7 @@ func decide_wand(allowed: bool) -> void:
if controller == null:
hud.hide_wand()
wand_open = false
_refresh_freeze()
return
var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else []
@@ -624,6 +870,7 @@ func decide_wand(allowed: bool) -> void:
controller.screened = true
hud.hide_wand()
wand_open = false
_refresh_freeze()
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
_wand_cooldown = WAND_COOLDOWN_S
@@ -632,15 +879,17 @@ func decide_access(allowed: bool) -> void:
if controller == null:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return
# Screening-before-entry: at wand venues, a guest must be screened first.
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
hud.log(Strings.lookup("hud.screen_first"))
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
var granted: bool = result.get("granted", false)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"decided": allowed,
@@ -672,6 +921,7 @@ func decide_access(allowed: bool) -> void:
audio.play_deny()
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
## ID / ticket check lines). Returns "" when the venue has no matching lines.
@@ -685,23 +935,10 @@ func _door_context_line() -> String:
return ""
func _artifacts_for(controller: Object) -> Array:
var profile: Object = controller.profile
var npc_type: int = profile.npc_type if profile else -1
var out: Array = []
match npc_type:
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"}))
Rules.NpcType.TOURIST:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
return out
var profile: NpcProfile = controller.profile as NpcProfile
var override: Dictionary = controller.access_override
var policy = selected_scenario.access_policy if selected_scenario else null
return AccessLoadout.build(profile, policy, override)
func _read_npc_truth(controller: Object) -> Dictionary:
var profile: Object = controller.profile