class_name RadioPanel extends CanvasLayer ## Radio report form (PLAN.md Level 4 assist / Level 5 emergency). ## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent ## accidental activation. signal submitted(report: Dictionary, emergency: bool) signal cancelled() 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 UiStyles := preload("res://src/game/ui/ui_styles.gd") const UiFocus := preload("res://src/game/ui/ui_focus.gd") var panel: PanelContainer var npc_name_label: Label var location_edit: LineEdit var desc_edit: LineEdit var weapons_check: CheckBox var danger_option: OptionButton var action_button: Button var cancel_button: Button var _focus_controls: Array[Control] = [] var hold_hint: Label var status_label: Label var _emergency := false var _npc_id := "" var _holding := false var _hold_time := 0.0 const HOLD_NEEDED := 1.6 var _truth := {"weapons_seen": false, "danger_level": 0} var _art := UiArt.new() var _art_layer: Control = null func _init() -> void: name = "RadioPanel" layer = 20 _art_layer = Control.new() _art_layer.name = "RadioArt" _art_layer.set_anchors_preset(Control.PRESET_FULL_RECT) _art_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_art_layer) _paint_backdrop() var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) center.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(center) panel = PanelContainer.new() panel.custom_minimum_size = Vector2(560, 0) var panel_style := StyleBoxFlat.new() panel_style.bg_color = Color(0, 0, 0, 0) panel_style.content_margin_left = 28 panel_style.content_margin_right = 28 panel_style.content_margin_top = 22 panel_style.content_margin_bottom = 22 panel.add_theme_stylebox_override("panel", panel_style) center.add_child(panel) var vbox := VBoxContainer.new() panel.add_child(vbox) vbox.add_theme_constant_override("separation", 8) var title := Label.new() title.name = "RadioTitle" title.add_theme_font_size_override("font_size", 18) vbox.add_child(title) npc_name_label = Label.new() vbox.add_child(npc_name_label) location_edit = LineEdit.new() location_edit.placeholder_text = Strings.lookup("ui.radio_location_placeholder") location_edit.text = Strings.lookup("ui.radio_location_default") vbox.add_child(location_edit) desc_edit = LineEdit.new() desc_edit.placeholder_text = Strings.lookup("ui.radio_description_placeholder") vbox.add_child(desc_edit) weapons_check = CheckBox.new() weapons_check.text = Strings.lookup("ui.radio_weapon") vbox.add_child(weapons_check) var danger_row := HBoxContainer.new() vbox.add_child(danger_row) var danger_label := Label.new() danger_label.text = Strings.lookup("ui.radio_danger") danger_row.add_child(danger_label) danger_option = OptionButton.new() for i in 4: danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i) danger_option.select(1) danger_row.add_child(danger_option) hold_hint = Label.new() hold_hint.text = "" hold_hint.modulate = Color(1, 0.85, 0.55) vbox.add_child(hold_hint) status_label = Label.new() status_label.text = "" vbox.add_child(status_label) var buttons := HBoxContainer.new() vbox.add_child(buttons) action_button = Button.new() action_button.text = Strings.lookup("ui.radio_send_assist") action_button.button_down.connect(func(): _start_hold()) action_button.button_up.connect(func(): _stop_hold(false)) action_button.pressed.connect(_on_action_pressed) UiStyles.apply_button(action_button, 16) buttons.add_child(action_button) cancel_button = Button.new() cancel_button.text = Strings.lookup("ui.radio_cancel") cancel_button.pressed.connect(func(): cancelled.emit()) UiStyles.apply_button(cancel_button, 16) buttons.add_child(cancel_button) _focus_controls = [location_edit, desc_edit, weapons_check, danger_option, action_button, cancel_button] UiFocus.link(_focus_controls, false) var _title_label: Label func _paint_backdrop() -> void: if _art_layer == null: return for child in _art_layer.get_children(): _art_layer.remove_child(child) child.queue_free() var art_name := "emergency_radio" if _emergency else "radio_report" var rect := Rect2(0.120, 0.058, 0.697, 0.882) if _emergency else Rect2(0.176, 0.033, 0.585, 0.923) if not _art.add_screen_backdrop(_art_layer, art_name, rect): var dim := ColorRect.new() dim.color = Color(0, 0, 0, 0.55) dim.set_anchors_preset(Control.PRESET_FULL_RECT) _art_layer.add_child(dim) func open(controller: Object, emergency: bool, truth: Dictionary) -> void: _emergency = emergency _paint_backdrop() _truth = truth _npc_id = controller.npc_id if controller else "" npc_name_label.text = Strings.fmt("ui.radio_target", [controller.profile.display_name if controller else "?"]) var title: Label = panel.find_child("RadioTitle", true, false) title.text = Strings.lookup("ui.radio_title_emergency") if emergency else Strings.lookup("ui.radio_title_assist") weapons_check.button_pressed = truth.get("weapons_seen", false) danger_option.select(truth.get("danger_level", 1)) hold_hint.text = Strings.fmt("ui.radio_hold_hint", [HOLD_NEEDED]) if emergency else "" status_label.text = "" action_button.text = Strings.lookup("ui.radio_send_emergency") if emergency else Strings.lookup("ui.radio_send_assist") _holding = false _hold_time = 0.0 visible = true UiFocus.link(_focus_controls, false) action_button.grab_focus() ## Non-emergency requests are a normal click. Emergency calls require the ## press-and-hold path, so the click release must not submit early. func _on_action_pressed() -> void: if not _emergency: _submit() func _start_hold() -> void: _holding = _emergency _hold_time = 0.0 func _stop_hold(cancelled_early: bool) -> void: if _holding: _holding = false if not cancelled_early and _hold_time >= HOLD_NEEDED: _submit() else: status_label.text = Strings.lookup("ui.radio_cancelled") func _process(delta: float) -> void: if not visible: return if _holding: _hold_time += delta status_label.text = Strings.fmt("ui.radio_holding", [minf(1.0, _hold_time / HOLD_NEEDED) * 100.0]) if _hold_time >= HOLD_NEEDED: var was := _holding _holding = false if was: _submit() func _submit() -> void: if not visible: return var report := { "npc_id": _npc_id, "location": location_edit.text, "description": desc_edit.text, "behavior": desc_edit.text, "weapons_seen": weapons_check.button_pressed, "danger_level": danger_option.get_selected_id(), } visible = false submitted.emit(report, _emergency) func _danger_name(level: int) -> String: match level: 0: return "none" 1: return "low" 2: return "elevated" 3: return "immediate threat" _: return "unknown" func _unhandled_input(event: InputEvent) -> void: if not visible: return if event.is_action_pressed("ui_cancel"): cancelled.emit() get_viewport().set_input_as_handled()