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 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 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 _debug_enabled := false var _log_lines: Array[String] = [] var _cam_overlay: Control = null var _body_camera_on := false 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_label = Label.new() target_label.add_theme_font_size_override("font_size", 16) vbox.add_child(target_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) vbox.add_child(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) 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) # --- 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) cam_box.position = Vector2(-180, 50) 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) --- 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 var ivbox := VBoxContainer.new() inspect_panel.add_child(ivbox) inspect_text = Label.new() inspect_text.add_theme_font_size_override("font_size", 15) ivbox.add_child(inspect_text) inspect_meta = Label.new() inspect_meta.add_theme_font_size_override("font_size", 12) 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) ivbox.add_child(insp_hint) inspect_center.add_child(inspect_panel) # --- wand screening panel (popup center) --- 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 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) func set_shift_state(coordinator: Object, scenario: Resource) -> void: top_label.text = scenario.display_name if scenario else "Step 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 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). func set_font_scale(scale: float) -> void: var clamped := clampf(scale, 0.75, 1.75) if top_label: top_label.scale = Vector2(clamped, clamped) if log_label: log_label.scale = Vector2(clamped, clamped) if target_label: target_label.scale = Vector2(clamped, clamped) if hint_label: hint_label.scale = Vector2(clamped, 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 ## Color-coded escalation feedback (PLAN.md: Polish and Accessibility). ## Green → yellow → orange → red as the escalation level rises. func set_escalation(level: int) -> void: var name: String = Rules.LEVEL_NAMES.get(level, "Observation") escalation_label.text = Strings.fmt("hud.escalation_prefix", [name]) 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 # 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) 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 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.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 func hide_inspection() -> void: _clear_card_row() inspect_panel.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 func hide_wand() -> void: wand_panel.visible = false