Files
security-game/src/game/ui/hud.gd
T
zwitschi 76eb5e3a79
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Successful in 4m39s
Fix mouse look swallowing by the HUD
Move guard look handling to the _input stage, recapture the cursor on
window-focus return and when clicking back into gameplay, and make HUD controls
mouse-transparent. Add regression tests for yaw/pitch and HUD mouse filters.
Verified under Xvfb with real relative mouse motion. Headless 237/237, Python
94/94, movement smoke 60 fps.
2026-11-01 20:38:36 +01:00

648 lines
24 KiB
GDScript

class_name Hud
extends CanvasLayer
## Minimal on-screen HUD: top status bar, target info, action hints,
## event log, access inspection panel, and a debug overlay.
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
const T_VIGNETTE := preload("res://assets/textures/ai/game_ready/vignette.png")
const T_ICON_FLASH := preload("res://assets/textures/ai/game_ready/icons/flashlight_on.png")
const T_ICON_DOOR := preload("res://assets/textures/ai/game_ready/icons/door_locked.png")
const T_ICON_WAND := preload("res://assets/textures/ai/game_ready/icons/wand.png")
const T_ICON_REC := preload("res://assets/textures/ai/game_ready/icons/rec_dot.png")
const T_CARD_ID := preload("res://assets/textures/ai/game_ready/cards/id_card.png")
const T_CARD_WRIST := preload("res://assets/textures/ai/game_ready/cards/wristband.png")
const T_CARD_TICKET := preload("res://assets/textures/ai/game_ready/cards/ticket.png")
const T_CARD_CRED := preload("res://assets/textures/ai/game_ready/cards/credential.png")
const T_CARD_GUEST := preload("res://assets/textures/ai/game_ready/cards/guest_list.png")
const CARD_TEXTURES := {
Rules.AccessKind.ID: T_CARD_ID,
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
Rules.AccessKind.TICKET: T_CARD_TICKET,
Rules.AccessKind.CREDENTIAL: T_CARD_CRED,
Rules.AccessKind.GUEST_LIST: T_CARD_GUEST,
}
var top_label: Label
var time_label: Label
var target_label: Label
var nearby_label: Label
var hint_label: Label
var log_label: Label
var debug_label: Label
var inspect_panel: PanelContainer
var inspect_text: Label
var inspect_meta: Label
var radio_cooldown_label: Label
var mode_label: Label
var vignette_rect: TextureRect
var cam_clock: Label
var icon_row: HBoxContainer
var flashlight_icon: TextureRect
var door_icon: TextureRect
var wand_icon: TextureRect
var wand_panel: PanelContainer
var wand_text: Label
var wand_meta: Label
var escalation_label: Label
var precision_label: Label
var _target_chip: PanelContainer = null
var _escalation_segments: Array[ColorRect] = []
var _controls_label: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
var _cam_overlay: Control = null
var _wand_art_layer: Control = null
var _wand_art := UiArt.new()
var _inspect_art_layer: Control = null
var _inspect_art := UiArt.new()
var _body_camera_on := false
var _font_scale := 1.0
var _base_font_sizes: Dictionary = {}
func _init() -> void:
name = "HUD"
layer = 10
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_bottom", 12)
add_child(margin)
var vbox := VBoxContainer.new()
margin.add_child(vbox)
var top := HBoxContainer.new()
vbox.add_child(top)
top_label = Label.new()
top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(top_label)
time_label = Label.new()
top.add_child(time_label)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
vbox.add_child(spacer)
# Target name sits in a small boxed chip just below the crosshair, matching
# the targeting mockup rather than pushing the whole HUD column down.
_target_chip = PanelContainer.new()
_target_chip.name = "TargetChip"
_target_chip.set_anchors_preset(Control.PRESET_CENTER)
_target_chip.offset_left = -150
_target_chip.offset_right = 150
_target_chip.offset_top = 38
_target_chip.offset_bottom = 78
_target_chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
_target_chip.visible = false
var chip_style := StyleBoxFlat.new()
chip_style.bg_color = Color(0.06, 0.075, 0.09, 0.82)
chip_style.border_color = Color(0.82, 0.84, 0.86, 0.3)
chip_style.border_width_left = 1
chip_style.border_width_top = 1
chip_style.border_width_right = 1
chip_style.border_width_bottom = 1
chip_style.corner_radius_top_left = 8
chip_style.corner_radius_top_right = 8
chip_style.corner_radius_bottom_left = 8
chip_style.corner_radius_bottom_right = 8
chip_style.content_margin_left = 12
chip_style.content_margin_right = 12
chip_style.content_margin_top = 6
chip_style.content_margin_bottom = 6
_target_chip.add_theme_stylebox_override("panel", chip_style)
add_child(_target_chip)
target_label = Label.new()
target_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
target_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
target_label.add_theme_font_size_override("font_size", 17)
_target_chip.add_child(target_label)
nearby_label = Label.new()
nearby_label.add_theme_font_size_override("font_size", 12)
nearby_label.modulate = Color(0.92, 0.95, 1.0)
# A shadow keeps the hint readable over the bright sidewalk and the sky.
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
vbox.add_child(nearby_label)
hint_label = Label.new()
hint_label.modulate = Color(1, 0.9, 0.6)
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint_label.custom_minimum_size = Vector2(0, 40)
vbox.add_child(hint_label)
var spacer2 := Control.new()
spacer2.custom_minimum_size = Vector2(0, 4)
vbox.add_child(spacer2)
log_label = Label.new()
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
log_label.custom_minimum_size = Vector2(0, 120)
log_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(log_label)
var bottom_spacer := Control.new()
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(bottom_spacer)
var controls := Label.new()
controls.text = Strings.lookup("hud.controls")
controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85)
controls.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(controls)
_controls_label = controls
# --- emergency radio cooldown indicator ---
radio_cooldown_label = Label.new()
radio_cooldown_label.text = ""
radio_cooldown_label.add_theme_font_size_override("font_size", 13)
radio_cooldown_label.modulate = Color(1, 0.55, 0.45)
vbox.add_child(radio_cooldown_label)
# --- door / flashlight state ---
mode_label = Label.new()
mode_label.text = "Door: open · Light: off"
mode_label.add_theme_font_size_override("font_size", 13)
mode_label.modulate = Color(0.85, 0.85, 0.9)
precision_label = Label.new()
precision_label.text = Strings.lookup("hud.precision")
precision_label.add_theme_font_size_override("font_size", 12)
precision_label.modulate = Color(0.55, 0.85, 1.0)
precision_label.visible = false
vbox.add_child(precision_label)
vbox.add_child(mode_label)
# --- escalation level indicator (color-coded) ---
escalation_label = Label.new()
escalation_label.text = "Escalation: Observation"
escalation_label.add_theme_font_size_override("font_size", 13)
escalation_label.modulate = Color(0.6, 0.9, 0.6)
vbox.add_child(escalation_label)
var esc_row := HBoxContainer.new()
esc_row.add_theme_constant_override("separation", 4)
vbox.add_child(esc_row)
for i in 5:
var segment := ColorRect.new()
segment.custom_minimum_size = Vector2(26, 7)
segment.color = Color(0.2, 0.22, 0.26, 0.8)
esc_row.add_child(segment)
_escalation_segments.append(segment)
# --- escalation vignette (screen-space tint) ---
vignette_rect = TextureRect.new()
vignette_rect.texture = T_VIGNETTE
vignette_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
vignette_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
vignette_rect.modulate = Color(1, 1, 1, 0)
vignette_rect.show_behind_parent = false
add_child(vignette_rect)
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
# it or issues a formal warning.
_cam_overlay = Control.new()
_cam_overlay.name = "BodyCameraOverlay"
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_cam_overlay.visible = false
add_child(_cam_overlay)
var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
# Leave the top-right shift/rep/crowd block clear; the REC row sits below it.
cam_box.position = Vector2(-200, 92)
cam_box.alignment = BoxContainer.ALIGNMENT_END
_cam_overlay.add_child(cam_box)
var rec_row := HBoxContainer.new()
rec_row.alignment = BoxContainer.ALIGNMENT_END
cam_box.add_child(rec_row)
var rec_icon := TextureRect.new()
rec_icon.texture = T_ICON_REC
rec_icon.custom_minimum_size = Vector2(22, 22)
rec_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
rec_row.add_child(rec_icon)
var rec_label := Label.new()
rec_label.text = "REC"
rec_label.add_theme_font_size_override("font_size", 15)
rec_label.modulate = Color(0.95, 0.35, 0.35)
rec_row.add_child(rec_label)
cam_clock = Label.new()
cam_clock.text = "SHIFT 00:00:00"
cam_clock.add_theme_font_size_override("font_size", 12)
cam_clock.modulate = Color(0.95, 0.95, 0.95)
cam_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
cam_box.add_child(cam_clock)
# corner brackets
var bracket_color := Color(0.9, 0.9, 0.92, 0.55)
_add_corner_brackets(bracket_color)
# --- mode icons next to the door/light label ---
icon_row = HBoxContainer.new()
vbox.add_child(icon_row)
flashlight_icon = _make_icon(T_ICON_FLASH)
flashlight_icon.visible = false
icon_row.add_child(flashlight_icon)
door_icon = _make_icon(T_ICON_DOOR)
door_icon.visible = false
icon_row.add_child(door_icon)
wand_icon = _make_icon(T_ICON_WAND)
wand_icon.visible = false
icon_row.add_child(wand_icon)
# --- inspect panel (popup center) ---
_inspect_art_layer = Control.new()
_inspect_art_layer.name = "InspectArt"
_inspect_art_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
_inspect_art_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_inspect_art_layer.visible = false
add_child(_inspect_art_layer)
if not _inspect_art.add_screen_backdrop(_inspect_art_layer, "access_inspection",
Rect2(0.220, 0.026, 0.474, 0.935)):
var inspect_dim := ColorRect.new()
inspect_dim.color = Color(0, 0, 0, 0.55)
inspect_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_inspect_art_layer.add_child(inspect_dim)
var inspect_center := CenterContainer.new()
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(inspect_center)
inspect_panel = PanelContainer.new()
inspect_panel.visible = false
inspect_panel.modulate.a = 1.0
inspect_panel.custom_minimum_size = Vector2(520, 0)
var inspect_style := StyleBoxFlat.new()
inspect_style.bg_color = Color(0, 0, 0, 0)
inspect_style.content_margin_left = 28
inspect_style.content_margin_right = 28
inspect_style.content_margin_top = 20
inspect_style.content_margin_bottom = 20
inspect_panel.add_theme_stylebox_override("panel", inspect_style)
var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox)
inspect_text = Label.new()
inspect_text.add_theme_font_size_override("font_size", 15)
inspect_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
ivbox.add_child(inspect_text)
inspect_meta = Label.new()
inspect_meta.add_theme_font_size_override("font_size", 12)
inspect_meta.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
inspect_meta.modulate = Color(0.8, 0.85, 1.0)
ivbox.add_child(inspect_meta)
var insp_hint := Label.new()
insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12)
insp_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
ivbox.add_child(insp_hint)
inspect_center.add_child(inspect_panel)
# --- wand screening panel (popup center) ---
_wand_art_layer = Control.new()
_wand_art_layer.name = "WandArt"
_wand_art_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
_wand_art_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_wand_art_layer.visible = false
add_child(_wand_art_layer)
if not _wand_art.add_screen_backdrop(_wand_art_layer, "wand_screening",
Rect2(0.236, 0.094, 0.453, 0.884)):
var wand_dim := ColorRect.new()
wand_dim.color = Color(0, 0, 0, 0.55)
wand_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_wand_art_layer.add_child(wand_dim)
var wand_center := CenterContainer.new()
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wand_center)
wand_panel = PanelContainer.new()
wand_panel.visible = false
wand_panel.custom_minimum_size = Vector2(440, 0)
var wand_style := StyleBoxFlat.new()
wand_style.bg_color = Color(0, 0, 0, 0)
wand_style.content_margin_left = 26
wand_style.content_margin_right = 26
wand_style.content_margin_top = 18
wand_style.content_margin_bottom = 18
wand_panel.add_theme_stylebox_override("panel", wand_style)
var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox)
wand_text = Label.new()
wand_text.add_theme_font_size_override("font_size", 15)
wvbox.add_child(wand_text)
wand_meta = Label.new()
wand_meta.add_theme_font_size_override("font_size", 12)
wand_meta.modulate = Color(0.8, 0.85, 1.0)
wvbox.add_child(wand_meta)
var wand_hint := Label.new()
wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint)
wand_center.add_child(wand_panel)
# --- debug overlay ---
debug_label = Label.new()
debug_label.visible = false
debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
debug_label.position = Vector2(16, 0)
debug_label.add_theme_font_size_override("font_size", 11)
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(debug_label)
_ignore_mouse(self)
## HUD controls are presentation-only. Leaving the full-screen containers on the
## default MOUSE_FILTER_STOP can swallow mouse motion/buttons before the guard's
## captured-look input sees them. Make every non-gameplay HUD control inert to
## the mouse; modal menus are separate CanvasLayers and keep their own filters.
func _ignore_mouse(node: Node) -> void:
for child in node.get_children():
if child is Control:
(child as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
_ignore_mouse(child)
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
top_label.text = scenario.display_name if scenario else "Gig Line"
var mins := int(coordinator.elapsed_s) / 60
var secs := int(coordinator.elapsed_s) % 60
time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [
mins, secs,
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"),
coordinator.reputation.rating,
coordinator.crowd.mood * 100.0,
]
if cam_clock:
var total := int(coordinator.elapsed_s)
cam_clock.text = "SHIFT %02d:%02d:%02d" % [total / 3600, (total / 60) % 60, total % 60]
_update_radio_cooldown(coordinator)
func _update_radio_cooldown(coordinator: Object) -> void:
var remaining: float = coordinator.radio.emergency_cooldown_remaining
if remaining > 0.0:
radio_cooldown_label.text = Strings.fmt("hud.cooldown", [remaining])
else:
radio_cooldown_label.text = ""
func set_target_label(text: String) -> void:
target_label.text = text
if _target_chip:
_target_chip.visible = text.strip_edges() != ""
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
func set_nearby_hint(count: int) -> void:
if nearby_label == null:
return
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
var _flashlight := false
var _door_locked := false
func set_door(locked: bool) -> void:
_door_locked = locked
_refresh_mode_label()
func set_flashlight(on: bool) -> void:
_flashlight = on
_refresh_mode_label()
func _refresh_mode_label() -> void:
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
if icon_row:
flashlight_icon.visible = _flashlight
door_icon.visible = _door_locked
func set_wand_icon(visible: bool) -> void:
if wand_icon:
wand_icon.visible = visible
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
func set_body_camera(on: bool) -> void:
_body_camera_on = on
if _cam_overlay:
_cam_overlay.visible = on
func is_body_camera_on() -> bool:
return _body_camera_on
## Scale the whole HUD text block (accessibility settings).
## Resize HUD text by changing font sizes (not Control.scale, which overflows
## anchored labels and breaks the right-aligned layout).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
_font_scale = clamped
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
for label in scaled:
if label == null:
continue
if not _base_font_sizes.has(label):
_base_font_sizes[label] = label.get_theme_font_size("font_size")
var base: int = int(_base_font_sizes[label])
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * clamped))))
func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new()
tr.texture = texture
tr.custom_minimum_size = Vector2(20, 20)
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tr.modulate = Color(1, 1, 1, 0.9)
return tr
func _add_corner_brackets(color: Color) -> void:
const LEN := 34.0
const TH := 3.0
const OFF := 10.0
var viewport := get_viewport()
var vp: Vector2 = viewport.get_visible_rect().size if viewport else Vector2(1280, 720)
var corners := [
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
]
var parent: Node = _cam_overlay if _cam_overlay != null else self
for i in 4:
var c: Vector2 = corners[i]
var flip_x: bool = (i % 2) == 1
var flip_y: bool = i >= 2
var h := ColorRect.new()
h.color = color
h.size = Vector2(LEN, TH)
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(h)
var v := ColorRect.new()
v.color = color
v.size = Vector2(TH, LEN)
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(v)
func set_action_hint(text: String) -> void:
hint_label.text = text
## Precision-walk state chip next to the door/light indicators.
func set_precision(active: bool) -> void:
if precision_label:
precision_label.visible = active
## Swap the control legend for the device the player is actually using.
func set_input_device(device: String) -> void:
if _controls_label == null:
return
if device == "gamepad":
_controls_label.text = Strings.lookup("hud.controls_gamepad")
else:
_controls_label.text = Strings.lookup("hud.controls")
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises.
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
## the cue does not rely on hue alone (accessibility).
static func escalation_meter(level: int) -> String:
var clamped := clampi(level, 0, 5)
var filled := ""
for i in 5:
filled += "#" if i < clamped else "-"
return "[%s] L%d/5" % [filled, clamped]
func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
var tint := Color(0.6, 0.9, 0.6)
match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
tint = Color(0.6, 0.9, 0.6)
Rules.Level.FORMAL_WARNING:
tint = Color(0.95, 0.9, 0.4)
Rules.Level.BLOCKING:
tint = Color(1.0, 0.65, 0.3)
Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY:
tint = Color(1.0, 0.35, 0.3)
escalation_label.modulate = tint
for i in _escalation_segments.size():
_escalation_segments[i].color = tint if i < clampi(level, 0, 5) else Color(0.2, 0.22, 0.26, 0.8)
# Screen-space vignette follows the escalation color.
if vignette_rect:
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
## Dev helper: drop queued log lines so a staged screenshot shows only the
## lines that belong to the moment.
func clear_log() -> void:
_log_lines.clear()
log_label.text = ""
func log(text: String) -> void:
_log_lines.append(text)
if _log_lines.size() > 8:
_log_lines.pop_front()
log_label.text = "\n".join(_log_lines)
func toggle_debug() -> void:
_debug_enabled = not _debug_enabled
debug_label.visible = _debug_enabled
func refresh_debug(coordinator: Object, npcs: Array) -> void:
if not _debug_enabled:
return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]]
for npc in npcs:
var c: Object = npc.get_npc_controller()
if c == null:
continue
var profile: Object = c.profile
lines.append(" [%s] %s intent=%s state=%s esc=%d" % [
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
])
debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
var granted: bool = result.get("granted", false)
inspect_text.text = "ACCESS INSPECTION\n\nAccess check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
_clear_card_row()
var card_row := HBoxContainer.new()
card_row.name = "INSPECT_CARD_ROW"
card_row.alignment = BoxContainer.ALIGNMENT_CENTER
card_row.add_theme_constant_override("separation", 6)
var added_card := false
for artifact in artifacts:
var tex: Texture2D = CARD_TEXTURES.get(artifact.kind, null) as Texture2D
if tex == null:
continue
var card := TextureRect.new()
card.texture = tex
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
card.custom_minimum_size = Vector2(92, 58)
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
card.tooltip_text = artifact.summary()
if artifact.attributes.size() > 0:
card.tooltip_text += "\n" + str(artifact.attributes)
card_row.add_child(card)
added_card = true
if added_card:
var content := inspect_panel.get_child(0) as VBoxContainer
content.add_child(card_row)
var lines: Array[String] = []
for artifact in artifacts:
lines.append(" • " + artifact.summary())
if result.get("reasons", []).size() > 0:
lines.append("Reasons: " + ", ".join(result.get("reasons", [])))
if result.get("issues", []).size() > 0:
lines.append("Issues: " + "; ".join(result.get("issues", [])))
inspect_meta.text = "\n".join(lines)
inspect_panel.visible = true
if _inspect_art_layer:
_inspect_art_layer.visible = true
func hide_inspection() -> void:
_clear_card_row()
inspect_panel.visible = false
if _inspect_art_layer:
_inspect_art_layer.visible = false
## Remove previously added artifact cards so reopening rebuilds them.
func _clear_card_row() -> void:
if inspect_panel == null:
return
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
if row:
row.get_parent().remove_child(row)
row.queue_free()
func show_wand(controller: Object, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
var alert: bool = result.get("alert", false)
wand_text.text = "Wand check — %s\nAlert: %s" % [npc_display, "YES" if alert else "none"]
var lines: Array[String] = []
if alert:
var item: String = result.get("item", "")
if result.get("prohibited", false):
lines.append("The wand alerts on a suspicious item (%s)." % item)
lines.append("Ask the guest to identify it before deciding.")
else:
lines.append("The wand alerts on a harmless item (%s)." % item)
lines.append("Likely a false alarm — keys, belt, or phone.")
else:
lines.append("No alert. The guest appears clear.")
wand_meta.text = "\n".join(lines)
wand_panel.visible = true
if _wand_art_layer:
_wand_art_layer.visible = true
func hide_wand() -> void:
wand_panel.visible = false
if _wand_art_layer:
_wand_art_layer.visible = false