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