- Link every modal's enabled controls into a wrapping UiFocus ring and give each screen a default focus target. - Add gamepad A/B bindings for ui_accept/ui_cancel; include Start menu footer actions and Continue in its focus ring. - Skip hidden/disabled controls, close focus traps via per-modal cancel behavior, and return review to the menu on cancel. - Record the focus audit and add regression coverage.
218 lines
6.8 KiB
GDScript
218 lines
6.8 KiB
GDScript
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()
|