class_name HelpModal extends CanvasLayer ## Phase 19 — Static reference modal opened via Settings → Help button. ## ## Layer 20 (same as StorytellerModal — mutually exclusive in practice; the ## Settings menu sits above both and dims the world before Help opens). ## ## Five sections: Controls / Verbs / Priorities / Storyteller / Tips. ## Tab row across the top of the content area; active tab tinted amber. ## ScrollContainer per-section so long copy doesn't overflow. ## ## Opened by EventBus.help_requested signal (emitted by SettingsMenu). ## Closed by: X button, tapping the dim backdrop, or Escape. ## ## CanvasLayer.visible is toggled (not just inner Control) so the dim ## MOUSE_FILTER_STOP backing doesn't eat world input when the modal is hidden. ## PROCESS_MODE_ALWAYS so it remains accessible while the sim is paused. const LAYER_ORDER: int = 20 const MODAL_W: int = 720 const MODAL_H: int = 560 const TAB_COUNT: int = 5 const AMBER: Color = Color(1.0, 0.75, 0.2, 1.0) # ── tab indices ────────────────────────────────────────────────────────────── const TAB_CONTROLS: int = 0 const TAB_VERBS: int = 1 const TAB_PRIORITIES: int = 2 const TAB_STORYTELLER: int = 3 const TAB_TIPS: int = 4 # ── state ──────────────────────────────────────────────────────────────────── var _active_tab: int = TAB_CONTROLS # ── node refs ──────────────────────────────────────────────────────────────── var _dim: ColorRect = null var _panel: PanelContainer = null var _tab_btns: Array[Button] = [] var _scroll: ScrollContainer = null var _content_box: VBoxContainer = null func _ready() -> void: layer = LAYER_ORDER process_mode = Node.PROCESS_MODE_ALWAYS _build_ui() _set_visible(false) EventBus.help_requested.connect(_on_help_requested) Audit.log("help_modal", "HelpModal ready (layer %d)" % layer) func _exit_tree() -> void: if EventBus.help_requested.is_connected(_on_help_requested): EventBus.help_requested.disconnect(_on_help_requested) # ── public API ──────────────────────────────────────────────────────────────── func open() -> void: _active_tab = TAB_CONTROLS _set_visible(true) _refresh_tabs() call_deferred("_show_section") Audit.log("help_modal", "opened") func close() -> void: _set_visible(false) Audit.log("help_modal", "closed") # ── UI construction ─────────────────────────────────────────────────────────── func _build_ui() -> void: # Dim backdrop — MOUSE_FILTER_STOP so click-outside dismisses. _dim = ColorRect.new() _dim.name = "Dim" _dim.set_anchors_preset(Control.PRESET_FULL_RECT) _dim.color = Color(0.0, 0.0, 0.0, 0.55) _dim.mouse_filter = Control.MOUSE_FILTER_STOP _dim.gui_input.connect(_on_dim_input) add_child(_dim) # Centered modal panel. _panel = PanelContainer.new() _panel.name = "HelpDialog" _panel.set_anchors_preset(Control.PRESET_CENTER) _panel.custom_minimum_size = Vector2(MODAL_W, MODAL_H) _panel.offset_left = -MODAL_W / 2 _panel.offset_right = MODAL_W / 2 _panel.offset_top = -MODAL_H / 2 _panel.offset_bottom = MODAL_H / 2 add_child(_panel) var outer := VBoxContainer.new() outer.add_theme_constant_override("separation", 0) _panel.add_child(outer) # ── Header row: title label + close button ──────────────────────────────── var header := HBoxContainer.new() header.name = "Header" header.add_theme_constant_override("separation", 8) header.custom_minimum_size = Vector2(0, 48) outer.add_child(header) var title_lbl := Label.new() title_lbl.name = "Title" title_lbl.text = Strings.t(&"ui.help.title") title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL title_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title_lbl.add_theme_font_size_override("font_size", 22) header.add_child(title_lbl) var close_btn := Button.new() close_btn.name = "CloseBtn" close_btn.text = Strings.t(&"ui.help.close") close_btn.custom_minimum_size = Vector2(48, 48) close_btn.focus_mode = Control.FOCUS_NONE close_btn.pressed.connect(close) header.add_child(close_btn) outer.add_child(HSeparator.new()) # ── Tab row ─────────────────────────────────────────────────────────────── var tab_row := HBoxContainer.new() tab_row.name = "TabRow" tab_row.add_theme_constant_override("separation", 4) outer.add_child(tab_row) var tab_keys: Array[StringName] = [ &"ui.help.tab.controls", &"ui.help.tab.verbs", &"ui.help.tab.priorities", &"ui.help.tab.storyteller", &"ui.help.tab.tips", ] _tab_btns.clear() for i in range(TAB_COUNT): var btn := Button.new() btn.name = "Tab%d" % i btn.text = Strings.t(tab_keys[i]) btn.custom_minimum_size = Vector2(0, 36) btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL btn.focus_mode = Control.FOCUS_NONE var idx: int = i # capture for lambda btn.pressed.connect(func() -> void: _on_tab_pressed(idx)) tab_row.add_child(btn) _tab_btns.append(btn) outer.add_child(HSeparator.new()) # ── Scrollable content area ─────────────────────────────────────────────── _scroll = ScrollContainer.new() _scroll.name = "ContentScroll" _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED outer.add_child(_scroll) _content_box = VBoxContainer.new() _content_box.name = "ContentBox" _content_box.add_theme_constant_override("separation", 8) _content_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL _scroll.add_child(_content_box) # ── tab switching ───────────────────────────────────────────────────────────── func _on_tab_pressed(idx: int) -> void: _active_tab = idx _refresh_tabs() call_deferred("_show_section") func _refresh_tabs() -> void: for i in range(_tab_btns.size()): if i == _active_tab: _tab_btns[i].modulate = AMBER else: _tab_btns[i].modulate = Color.WHITE func _show_section() -> void: # Clear old content. for child in _content_box.get_children(): child.queue_free() # Reset scroll position. _scroll.scroll_vertical = 0 var heading: String var body: String match _active_tab: TAB_CONTROLS: heading = Strings.t(&"help.controls.heading") body = Strings.t(&"help.controls.body") TAB_VERBS: heading = Strings.t(&"help.verbs.heading") body = Strings.t(&"help.verbs.body") TAB_PRIORITIES: heading = Strings.t(&"help.priorities.heading") body = Strings.t(&"help.priorities.body") TAB_STORYTELLER: heading = Strings.t(&"help.storyteller.heading") body = Strings.t(&"help.storyteller.body") TAB_TIPS: heading = Strings.t(&"help.tips.heading") body = Strings.t(&"help.tips.body") _: heading = "" body = "" var heading_lbl := Label.new() heading_lbl.name = "SectionHeading" heading_lbl.text = heading heading_lbl.add_theme_font_size_override("font_size", 16) _content_box.add_child(heading_lbl) _content_box.add_child(HSeparator.new()) var body_lbl := Label.new() body_lbl.name = "SectionBody" body_lbl.text = body body_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART body_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL _content_box.add_child(body_lbl) # ── input handling ──────────────────────────────────────────────────────────── func _unhandled_input(event: InputEvent) -> void: if not visible: return if event.is_action_pressed("cancel"): close() get_viewport().set_input_as_handled() func _on_dim_input(event: InputEvent) -> void: # Clicking the dim backdrop closes the modal. if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: close() # ── signal handler ──────────────────────────────────────────────────────────── func _on_help_requested() -> void: open() # ── visibility ──────────────────────────────────────────────────────────────── func _set_visible(v: bool) -> void: # Toggle CanvasLayer.visible so the dim (MOUSE_FILTER_STOP) does not eat # world input when the modal is hidden. visible = v if _dim != null: _dim.visible = v if _panel != null: _panel.visible = v