class_name HintOverlay extends CanvasLayer ## Phase 19 — Top-center hint banner for the onboarding tour. ## ## Layer 22: above StorytellerModal (20) and DaySummaryCard (19) so hints are ## always visible. Non-blocking — only the banner itself catches input; the ## world below remains fully interactive while the hint is up. ## ## API: ## show_hint(hint_id, message) — populate text and slide in. ## hide_hint() — slide out and emit hint_dismissed. ## ## Registers itself with HintSystem in _ready so the autoload never calls ## find_child() on the tree. const BANNER_W: int = 640 const BANNER_H: int = 80 const SLIDE_DUR: float = 0.30 # seconds for the slide-in/out tween # Accent gold — matches MedievalTheme.C_ACCENT_GOLD. const C_ACCENT_GOLD: Color = Color(0.92, 0.75, 0.22, 1.0) # ── node refs ───────────────────────────────────────────────────────────────── var _root_ctrl: Control = null # MOUSE_FILTER_IGNORE, full-rect anchor var _panel: PanelContainer = null # the visible banner var _icon_lbl: Label = null var _msg_lbl: Label = null var _got_it_btn: Button = null # ── state ───────────────────────────────────────────────────────────────────── var _current_hint_id: StringName = &"" var _tween: Tween = null func _ready() -> void: layer = 22 process_mode = Node.PROCESS_MODE_ALWAYS _build_ui() _set_panel_visible(false) # Register with the HintSystem autoload so it can call show_hint/hide_hint. if Engine.has_singleton("HintSystem") or has_node("/root/HintSystem"): var hs: Node = get_node_or_null("/root/HintSystem") if hs != null and hs.has_method("register_overlay"): hs.register_overlay(self) Audit.log("hint_overlay", "HintOverlay ready (layer %d)" % layer) func _exit_tree() -> void: pass # signal connections owned by HintSystem are cleaned up when it exits # ── UI construction ─────────────────────────────────────────────────────────── func _build_ui() -> void: # Transparent full-rect root — MOUSE_FILTER_IGNORE so world stays interactive. _root_ctrl = Control.new() _root_ctrl.name = "Root" _root_ctrl.set_anchors_preset(Control.PRESET_FULL_RECT) _root_ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_root_ctrl) # Banner PanelContainer — top-center, fixed size. _panel = PanelContainer.new() _panel.name = "Banner" _panel.custom_minimum_size = Vector2(BANNER_W, BANNER_H) # Anchor to top-center. _panel.set_anchors_preset(Control.PRESET_TOP_WIDE) # Center horizontally: offset left/right to BANNER_W wide, centred. _panel.anchor_left = 0.5 _panel.anchor_right = 0.5 _panel.anchor_top = 0.0 _panel.anchor_bottom = 0.0 _panel.offset_left = -BANNER_W / 2.0 _panel.offset_right = BANNER_W / 2.0 _panel.offset_top = 0.0 _panel.offset_bottom = BANNER_H _panel.mouse_filter = Control.MOUSE_FILTER_STOP # banner catches its own taps _panel.gui_input.connect(_on_banner_input) _root_ctrl.add_child(_panel) # Inner HBox: icon | message (expanding) | button var hbox := HBoxContainer.new() hbox.alignment = BoxContainer.ALIGNMENT_CENTER hbox.add_theme_constant_override("separation", 12) _panel.add_child(hbox) # Icon label — bulb emoji as procedural indicator, gold-tinted. _icon_lbl = Label.new() _icon_lbl.name = "Icon" _icon_lbl.text = "💡" _icon_lbl.add_theme_font_size_override("font_size", 20) _icon_lbl.add_theme_color_override("font_color", C_ACCENT_GOLD) _icon_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER hbox.add_child(_icon_lbl) # Message label — auto-wraps, expands to fill. _msg_lbl = Label.new() _msg_lbl.name = "Message" _msg_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _msg_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL _msg_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER hbox.add_child(_msg_lbl) # "Got it" button — right side. _got_it_btn = Button.new() _got_it_btn.name = "GotItBtn" _got_it_btn.text = Strings.t(&"hint.got_it") _got_it_btn.custom_minimum_size = Vector2(80, 40) _got_it_btn.focus_mode = Control.FOCUS_NONE _got_it_btn.pressed.connect(hide_hint) hbox.add_child(_got_it_btn) # ── public API ──────────────────────────────────────────────────────────────── ## Populate the banner with hint_id + message text then slide it in from above. func show_hint(hint_id: StringName, message: String) -> void: _current_hint_id = hint_id _msg_lbl.text = message _set_panel_visible(true) var reduce_motion: bool = bool(GameState.settings.get("accessibility_reduce_motion", false)) if reduce_motion: # Snap to final position immediately. _panel.offset_top = 0.0 _panel.offset_bottom = BANNER_H return # Slide in from above: start fully off-screen, tween to resting position. _panel.offset_top = -BANNER_H _panel.offset_bottom = 0.0 if _tween != null and _tween.is_running(): _tween.kill() _tween = create_tween() _tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) _tween.tween_property(_panel, "offset_top", 0.0, SLIDE_DUR) _tween.parallel().tween_property(_panel, "offset_bottom", float(BANNER_H), SLIDE_DUR) ## Slide the banner out upward and emit hint_dismissed with the current hint_id. func hide_hint() -> void: var id: StringName = _current_hint_id var reduce_motion: bool = bool(GameState.settings.get("accessibility_reduce_motion", false)) if reduce_motion: _set_panel_visible(false) EventBus.hint_dismissed.emit(id) return if _tween != null and _tween.is_running(): _tween.kill() _tween = create_tween() _tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) _tween.tween_property(_panel, "offset_top", -float(BANNER_H), SLIDE_DUR) _tween.parallel().tween_property(_panel, "offset_bottom", 0.0, SLIDE_DUR) _tween.tween_callback(_on_slide_out_finished.bind(id)) # ── internal callbacks ──────────────────────────────────────────────────────── func _on_slide_out_finished(id: StringName) -> void: _set_panel_visible(false) EventBus.hint_dismissed.emit(id) func _on_banner_input(event: InputEvent) -> void: # Tap anywhere on the banner also dismisses it (touch-friendly). if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: hide_hint() # ── helpers ─────────────────────────────────────────────────────────────────── func _set_panel_visible(v: bool) -> void: if _panel != null: _panel.visible = v # Keep root_ctrl always present but invisible panel blocks nothing. if _root_ctrl != null: _root_ctrl.visible = true # always: root is MOUSE_FILTER_IGNORE anyway