extends Control # Dialogue box — D-061: bottom screen, max 20% height, no portraits. # InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled. # NPC speech top, player response options below, left-aligned. # Max 3 visible options. No close button — walk-away (WASD) or option select only. # Auto-pause in single-player when dialogue is open (D-061). # Sprint 7: UI skeleton with mock data. Server wiring deferred to #305. signal option_selected(index: int, text: String) signal dialogue_dismissed # Walk-away or conversation end @onready var panel: PanelContainer = $PanelContainer @onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech @onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer var _is_showing: bool = false var _active_tween: Tween = null var _option_buttons: Array[Button] = [] var _npc_name: String = "" var _dialogue_id: int = 0 # Tracks current dialogue to prevent consume-once race const FADE_IN: float = 0.2 const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280) # D-064: movement actions that trigger walk-away const _WALK_AWAY_ACTIONS: Array[StringName] = [ &"move_north", &"move_south", &"move_east", &"move_west", &"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest", ] func _ready() -> void: panel.modulate.a = 0.0 visible = false _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE _update_layout() get_viewport().size_changed.connect(_update_layout) func _unhandled_input(event: InputEvent) -> void: if not _is_showing: return # D-064: WASD during dialogue → walk-away, 300ms fade if event is InputEventKey and event.pressed: for action in _WALK_AWAY_ACTIONS: if event.is_action_pressed(action): hide_dialogue() dialogue_dismissed.emit() return # Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport. func _update_layout() -> void: var vp := get_viewport_rect().size var max_h := vp.y * MAX_HEIGHT_RATIO var w := minf(MAX_WIDTH_PX, vp.x * 0.65) panel.offset_left = -w / 2.0 panel.offset_right = w / 2.0 panel.offset_top = -max_h # Show dialogue with NPC speech and response options. # npc_name: who is speaking (displayed as prefix) # speech: the NPC's dialogue text # options: Array of Strings — player response choices (max 3 shown) func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void: _npc_name = npc_name _dialogue_id += 1 # NPC speech — name prefix in bold if npc_name.is_empty(): npc_speech.text = speech else: npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech] # Clear old option buttons _clear_options() # Build response option buttons (max 3) var count := mini(options.size(), MAX_OPTIONS) for i in range(count): var btn := Button.new() btn.text = options[i] btn.alignment = HORIZONTAL_ALIGNMENT_LEFT btn.flat = true btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND # Insert-styled colors from shared palette (D-048/D-056) btn.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) btn.add_theme_color_override("font_hover_color", Constants.INSERT_COLOR_HOVER) btn.add_theme_color_override("font_pressed_color", Constants.INSERT_COLOR_ACTIVE) var idx := i btn.pressed.connect(func(): _on_option_pressed(idx)) options_container.add_child(btn) _option_buttons.append(btn) # Show with fade visible = true mouse_filter = Control.MOUSE_FILTER_STOP _is_showing = true # D-061: auto-pause in single-player when dialogue opens SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}) if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN) # Hide dialogue with fade (D-064: 300ms) func hide_dialogue() -> void: if not _is_showing: return _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE # D-061: unpause when dialogue closes SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}) if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT) _active_tween.tween_callback(func(): visible = false _clear_options() ) func is_dialogue_active() -> bool: return _is_showing func get_dialogue_id() -> int: return _dialogue_id func _on_option_pressed(index: int) -> void: if index < _option_buttons.size(): option_selected.emit(index, _option_buttons[index].text) hide_dialogue() func _clear_options() -> void: for btn in _option_buttons: if is_instance_valid(btn): btn.queue_free() _option_buttons.clear()