class_name StorytellerBanner extends CanvasLayer ## Phase 15 — Non-blocking ambient banner for BANNER-display EventDef events. ## ## Anchored top-center just below the top bar (~60 px Y offset). ## Shows: title (bold), body, optional "Go there" button. ## Auto-dismisses after 6 sec via Timer; tap-to-dismiss-early also supported. ## ## Multiple banners queue — only one is visible at a time. On dismiss the ## next queued event is shown automatically. ## ## On dismiss: calls Storyteller.resolve_current(0). ## Subscribes to EventBus.storyteller_event_fired in _ready; ignores MODAL events. const AUTO_DISMISS_SEC: float = 6.0 const TILE_SIZE_PX: int = 16 ## Internal queue of pending EventDef refs (BANNER display only). var _queue: Array = [] ## The EventDef currently on screen (null when idle). var _current_event = null # ── node refs ─────────────────────────────────────────────────────────────── var _root: Control = null var _title_label: Label = null var _body_label: Label = null var _go_there_btn: Button = null var _dismiss_btn: Button = null var _timer: Timer = null func _ready() -> void: layer = 15 # above world (0), above top-bar (10), below modal (20) _build_ui() _root.visible = false EventBus.storyteller_event_fired.connect(_on_event_fired) Audit.log("storyteller_ui", "StorytellerBanner ready") func _exit_tree() -> void: if EventBus.storyteller_event_fired.is_connected(_on_event_fired): EventBus.storyteller_event_fired.disconnect(_on_event_fired) # ── UI construction ────────────────────────────────────────────────────────── func _build_ui() -> void: # Outer anchor — top-center strip. _root = Control.new() _root.name = "BannerRoot" _root.set_anchors_preset(Control.PRESET_TOP_WIDE) _root.custom_minimum_size = Vector2(0, 80) _root.offset_top = 60 # sit just below the 48 px top bar _root.offset_bottom = 140 _root.mouse_filter = Control.MOUSE_FILTER_PASS add_child(_root) # Panel background centred horizontally. var panel := PanelContainer.new() panel.name = "Panel" panel.set_anchors_preset(Control.PRESET_CENTER_TOP) panel.custom_minimum_size = Vector2(480, 80) # Offset so it stays centred regardless of screen width. panel.offset_left = -240 panel.offset_right = 240 panel.offset_top = 0 panel.offset_bottom = 80 panel.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND _root.add_child(panel) # Tap-anywhere-to-dismiss — GuiInput on the panel itself. panel.gui_input.connect(_on_panel_gui_input) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 4) panel.add_child(vbox) _title_label = Label.new() _title_label.name = "TitleLabel" _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(_title_label) _body_label = Label.new() _body_label.name = "BodyLabel" _body_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _body_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(_body_label) var btn_row := HBoxContainer.new() btn_row.alignment = BoxContainer.ALIGNMENT_CENTER btn_row.add_theme_constant_override("separation", 8) vbox.add_child(btn_row) _go_there_btn = Button.new() _go_there_btn.name = "GoThereBtn" _go_there_btn.text = Strings.t(&"ui.go_there") _go_there_btn.custom_minimum_size = Vector2(96, 48) _go_there_btn.focus_mode = Control.FOCUS_NONE _go_there_btn.visible = false _go_there_btn.pressed.connect(_on_go_there_pressed) btn_row.add_child(_go_there_btn) _dismiss_btn = Button.new() _dismiss_btn.name = "DismissBtn" _dismiss_btn.text = Strings.t(&"ui.dismiss") _dismiss_btn.custom_minimum_size = Vector2(96, 48) _dismiss_btn.focus_mode = Control.FOCUS_NONE _dismiss_btn.pressed.connect(_on_dismiss_pressed) btn_row.add_child(_dismiss_btn) # Auto-dismiss timer. _timer = Timer.new() _timer.name = "AutoDismiss" _timer.one_shot = true _timer.wait_time = AUTO_DISMISS_SEC _timer.timeout.connect(_on_dismiss_pressed) add_child(_timer) # ── event handling ─────────────────────────────────────────────────────────── func _on_event_fired(event) -> void: # Only handle banner-display events. if event.display != EventDef.Display.BANNER: return _queue.append(event) if _current_event == null: _show_next() func _show_next() -> void: if _queue.is_empty(): _root.visible = false _current_event = null return _current_event = _queue.pop_front() var ev = _current_event _title_label.text = ev.title _body_label.text = _substitute_pawn(ev.body) var has_focus: bool = ev.focus_tile != Vector2i(-1, -1) _go_there_btn.visible = has_focus _root.visible = true _timer.start() Audit.log("storyteller_ui", "banner shown: %s" % ev.id) func _on_dismiss_pressed() -> void: if not _timer.is_stopped(): _timer.stop() Storyteller.resolve_current(0) Audit.log("storyteller_ui", "banner dismissed: %s" % (_current_event.id if _current_event != null else "(none)")) _current_event = null _show_next() func _on_go_there_pressed() -> void: if _current_event == null: return _pan_camera(_current_event.focus_tile) # Also dismiss so the banner clears after the pan. _on_dismiss_pressed() func _on_panel_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: _on_dismiss_pressed() elif event is InputEventScreenTouch and event.pressed: _on_dismiss_pressed() # ── helpers ────────────────────────────────────────────────────────────────── func _pan_camera(tile: Vector2i) -> void: var cam = get_node_or_null("/root/Main/World/CameraRig") if cam == null: Audit.log("storyteller_ui", "pan_camera: CameraRig not found") return if cam.has_method("pan_to_tile"): cam.pan_to_tile(tile) else: # Fallback: instant snap to tile centre. cam.position = Vector2(tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2, tile.y * TILE_SIZE_PX + TILE_SIZE_PX / 2) func _substitute_pawn(body: String) -> String: if not "%pawn%" in body: return body var name: String = "Settler" if not World.pawns.is_empty(): name = World.pawns[0].pawn_name return body.replace("%pawn%", name)