class_name StorytellerModal extends CanvasLayer ## Phase 15 — Blocking center-screen dialog for MODAL-display EventDef events. ## ## Visible only when a modal event is active; hidden otherwise. ## Choice layout: ## 0 choices → single "Dismiss" button (choice index 0) ## 1 choice → labelled button + Dismiss (choice 0 / 1) ## 2 choices → both as labelled buttons (choice 0 / 1) ## ## Optional "Go there" button if focus_tile != Vector2i(-1, -1). ## Tapping "Go there" pans camera AND closes the modal. ## ## Subscribes to EventBus.storyteller_event_fired in _ready; ignores BANNER events. ## Auto-pause is handled by Storyteller before the signal — no re-pause here. const TILE_SIZE_PX: int = 16 var _current_event = null # ── node refs ──────────────────────────────────────────────────────────────── var _dim: ColorRect = null # full-screen dim behind panel var _panel: PanelContainer = null var _title_label: Label = null var _body_label: Label = null var _choice_row: HBoxContainer = null var _go_there_btn: Button = null func _ready() -> void: layer = 20 # above banner (15), above top-bar (10) _build_ui() _set_visible(false) EventBus.storyteller_event_fired.connect(_on_event_fired) # Also hide on external resolve (test scripts, ghost-state auto-fire chain). EventBus.storyteller_event_resolved.connect(_on_event_resolved) Audit.log("storyteller_ui", "StorytellerModal ready") func _exit_tree() -> void: if EventBus.storyteller_event_fired.is_connected(_on_event_fired): EventBus.storyteller_event_fired.disconnect(_on_event_fired) if EventBus.storyteller_event_resolved.is_connected(_on_event_resolved): EventBus.storyteller_event_resolved.disconnect(_on_event_resolved) func _on_event_resolved(_event, _choice_index: int) -> void: _set_visible(false) # ── UI construction ────────────────────────────────────────────────────────── func _build_ui() -> void: # Full-screen dim layer (semi-transparent black). _dim = ColorRect.new() _dim.name = "Dim" _dim.set_anchors_preset(Control.PRESET_FULL_RECT) _dim.color = Color(0.0, 0.0, 0.0, 0.45) _dim.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_dim) # Centred dialog panel. _panel = PanelContainer.new() _panel.name = "Dialog" _panel.set_anchors_preset(Control.PRESET_CENTER) _panel.custom_minimum_size = Vector2(400, 200) _panel.offset_left = -200 _panel.offset_right = 200 _panel.offset_top = -100 _panel.offset_bottom = 100 add_child(_panel) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 12) _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) # "Go there" row — always present, shown/hidden per-event. _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(120, 48) _go_there_btn.focus_mode = Control.FOCUS_NONE _go_there_btn.pressed.connect(_on_go_there_pressed) var go_row := HBoxContainer.new() go_row.alignment = BoxContainer.ALIGNMENT_CENTER go_row.add_child(_go_there_btn) vbox.add_child(go_row) # Choice-button row — children rebuilt each event. _choice_row = HBoxContainer.new() _choice_row.name = "ChoiceRow" _choice_row.alignment = BoxContainer.ALIGNMENT_CENTER _choice_row.add_theme_constant_override("separation", 12) vbox.add_child(_choice_row) # ── event handling ─────────────────────────────────────────────────────────── func _on_event_fired(event) -> void: if event.display != EventDef.Display.MODAL: return _current_event = event _populate(event) _set_visible(true) Audit.log("storyteller_ui", "modal shown: %s" % event.id) func _populate(event) -> void: _title_label.text = event.title _body_label.text = _substitute_pawn(event.body) var has_focus: bool = event.focus_tile != Vector2i(-1, -1) _go_there_btn.visible = has_focus # Clear old choice buttons. for child in _choice_row.get_children(): child.queue_free() # Build new choice buttons based on choices array length. var choices: Array[String] = event.choices match choices.size(): 0: # No choices — single Dismiss. _add_choice_btn(Strings.t(&"ui.dismiss"), 0) 1: # One named choice + Dismiss (dismiss = index 1). _add_choice_btn(choices[0], 0) _add_choice_btn(Strings.t(&"ui.dismiss"), 1) _: # Two (or more) choices — render first two, indexed. _add_choice_btn(choices[0], 0) _add_choice_btn(choices[1], 1) func _add_choice_btn(label: String, choice_index: int) -> void: var btn := Button.new() btn.text = label btn.custom_minimum_size = Vector2(120, 48) btn.focus_mode = Control.FOCUS_NONE btn.pressed.connect(func() -> void: _on_choice(choice_index)) _choice_row.add_child(btn) func _on_choice(choice_index: int) -> void: Audit.log("storyteller_ui", "modal choice %d: %s" % [choice_index, _current_event.id if _current_event != null else "(none)"]) Storyteller.resolve_current(choice_index) _current_event = null _set_visible(false) func _on_go_there_pressed() -> void: if _current_event == null: return _pan_camera(_current_event.focus_tile) # Close modal after pan — player is navigating there. _on_choice(0) # ── helpers ────────────────────────────────────────────────────────────────── func _set_visible(v: bool) -> void: if _dim != null: _dim.visible = v if _panel != null: _panel.visible = v 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: 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)