class_name DialogueModeController extends CanvasLayer signal mode_started(conversation_id: StringName, revision: int) signal turn_presented(conversation_id: StringName, revision: int) signal mode_ended(conversation_id: StringName) signal presentation_failed(reason: StringName) const TALK_ACTION_ID := &"talk" const MIN_PANEL_WIDTH := 280.0 const MAX_PANEL_WIDTH := 760.0 const MIN_PANEL_HEIGHT := 210.0 const MAX_PANEL_HEIGHT := 460.0 var _player: Node3D var _simulation_manager: Node var _world_view_manager: Node var _presenter: ConversationPresenter var _dialogue_runtime: Object var _conversation_id: StringName var _presented_turn: ConversationTurn var _presentation_request := 0 var _presented_option_ids: Array[StringName] = [] var _option_buttons: Array[Button] = [] var _focused_option_index := -1 var _mapping_valid := false var _mouse_mode_before_dialogue := Input.MOUSE_MODE_CAPTURED var _owns_visible_mouse := false var _root_control: Control var _backdrop: ColorRect var _panel: PanelContainer var _speaker_label: Label var _line_label: Label var _options_scroll: ScrollContainer var _options_box: VBoxContainer var _status_label: Label var _close_button: Button func configure( player: Node3D, simulation_manager: Node, world_view_manager: Node, presenter: ConversationPresenter = null, dialogue_runtime: Object = null ) -> void: _player = player _simulation_manager = simulation_manager _world_view_manager = world_view_manager _presenter = presenter _dialogue_runtime = dialogue_runtime _connect_manager_signals() func _ready() -> void: layer = 20 _build_balloon() _resolve_dependencies() _connect_manager_signals() set_process_input(false) _root_control.visible = false get_viewport().size_changed.connect(_layout_balloon) _layout_balloon() func _exit_tree() -> void: _presentation_request += 1 if ( is_dialogue_active() and _simulation_manager != null and is_instance_valid(_simulation_manager) and _simulation_manager.has_method("end_player_conversation") ): _simulation_manager.call( "end_player_conversation", _conversation_id, get_presented_revision() ) _restore_mouse_mode() func begin_nearest_conversation() -> bool: if _player == null or not is_instance_valid(_player): return false return begin_conversation(_nearest_active_npc_id()) func begin_conversation(npc_id: int) -> bool: _resolve_dependencies() if ( npc_id < 0 or is_dialogue_active() or _simulation_manager == null or not _simulation_manager.has_method("begin_player_conversation") or npc_id != _nearest_active_npc_id() ): return false var started_id := StringName(_simulation_manager.call("begin_player_conversation", npc_id)) return not started_id.is_empty() func close_conversation() -> bool: if not is_dialogue_active(): return false if ( _simulation_manager == null or not is_instance_valid(_simulation_manager) or not _simulation_manager.has_method("end_player_conversation") ): _close_local(_conversation_id) return false var expected_revision := get_presented_revision() var ended := bool( _simulation_manager.call("end_player_conversation", _conversation_id, expected_revision) ) if not ended: _show_status("The conversation changed. Choose again or press close once more.", true) return ended func select_option(option_id: StringName) -> bool: if ( not is_dialogue_active() or not _mapping_valid or _presented_turn == null or not _presented_option_ids.has(option_id) or _simulation_manager == null or not _simulation_manager.has_method("select_player_conversation_option") ): return false var option := _presented_turn.get_option(option_id) if option == null or not option.is_enabled(): return false var expected_revision := _presented_turn.get_revision() _set_options_enabled(false) var result := ( _simulation_manager.call( "select_player_conversation_option", _conversation_id, option_id, expected_revision ) as ConversationSelectionResult ) if result == null: _show_status("That response is unavailable.", true) _set_options_enabled(true) return false if result.was_accepted(): return true var current_turn := result.get_turn() if current_turn != null and current_turn.is_valid(): _show_status("The conversation changed; choices were refreshed.", false) _queue_turn(current_turn) else: _show_status("That response is no longer available.", true) _set_options_enabled(true) return false func is_dialogue_active() -> bool: return not _conversation_id.is_empty() func get_conversation_id() -> StringName: return _conversation_id func get_presented_revision() -> int: return _presented_turn.get_revision() if _presented_turn != null else -1 func get_presented_option_ids() -> Array[StringName]: return _presented_option_ids.duplicate() func get_presented_line() -> String: return _line_label.text if _line_label != null else "" func get_presented_speaker() -> String: return _speaker_label.text if _speaker_label != null else "" func has_valid_option_mapping() -> bool: return _mapping_valid func get_focused_option_id() -> StringName: if _focused_option_index < 0 or _focused_option_index >= _presented_option_ids.size(): return &"" return _presented_option_ids[_focused_option_index] func _input(event: InputEvent) -> void: if not is_dialogue_active() or event.is_echo(): return if event.is_action_pressed("ui_cancel"): close_conversation() get_viewport().set_input_as_handled() return if event.is_action_pressed("ui_down"): _move_option_focus(1) get_viewport().set_input_as_handled() return if event.is_action_pressed("ui_up"): _move_option_focus(-1) get_viewport().set_input_as_handled() return if event.is_action_pressed("ui_accept") and _focused_option_index >= 0: select_option(_presented_option_ids[_focused_option_index]) get_viewport().set_input_as_handled() func _resolve_dependencies() -> void: if _simulation_manager == null and is_inside_tree(): _simulation_manager = get_tree().get_first_node_in_group("simulation_manager") if _dialogue_runtime == null and is_inside_tree(): _dialogue_runtime = get_tree().root.get_node_or_null("DialogueManager") if _presenter == null: _presenter = DialogueManagerConversationPresenter.new(_dialogue_runtime) func _connect_manager_signals() -> void: if _simulation_manager == null or not is_instance_valid(_simulation_manager): return _connect_once(&"conversation_started", _on_conversation_started) _connect_once(&"conversation_turn_changed", _on_conversation_turn_changed) _connect_once(&"conversation_ended", _on_conversation_ended) _connect_once(&"state_restored", _on_state_restored) func _connect_once(signal_name: StringName, callback: Callable) -> void: if not _simulation_manager.has_signal(signal_name): return if not _simulation_manager.is_connected(signal_name, callback): _simulation_manager.connect(signal_name, callback) func _on_conversation_started(conversation_id: StringName, turn: ConversationTurn) -> void: if turn == null or not turn.is_valid() or turn.get_conversation_id() != conversation_id: return _conversation_id = conversation_id _capture_mouse_mode() _root_control.visible = true set_process_input(true) mode_started.emit(conversation_id, turn.get_revision()) _queue_turn(turn) func _on_conversation_turn_changed(conversation_id: StringName, turn: ConversationTurn) -> void: if conversation_id != _conversation_id or turn == null or not turn.is_valid(): return _queue_turn(turn) func _on_conversation_ended(conversation_id: StringName) -> void: if conversation_id == _conversation_id: _close_local(conversation_id) func _on_state_restored() -> void: if is_dialogue_active(): _close_local(_conversation_id) func _queue_turn(turn: ConversationTurn) -> void: _presentation_request += 1 var request := _presentation_request _presented_turn = turn.copy() _mapping_valid = false _presented_option_ids.clear() _clear_option_buttons() _speaker_label.text = "Conversation" _line_label.text = "Listening…" _show_status("", false) call_deferred("_present_turn", _presented_turn.copy(), request) func _present_turn(turn: ConversationTurn, request: int) -> void: _resolve_dependencies() if request != _presentation_request or not is_dialogue_active(): return if _presenter == null or _dialogue_runtime == null: _fail_presentation(&"runtime_unavailable") return var presentation_context := _build_presentation_context(turn) var resource: Variant = _presenter.present_turn(turn, presentation_context) if not _resource_matches_turn(resource, turn): _release_ephemeral_resource(resource, null) _fail_presentation(&"resource_mismatch") return var line: Variant = await _dialogue_runtime.call("get_next_dialogue_line", resource, "start") if request != _presentation_request or not is_dialogue_active(): _release_ephemeral_resource(resource, line) return if not _line_matches_turn(line, turn): _release_ephemeral_resource(resource, line) _fail_presentation(&"line_mismatch") return var mapped := _copy_mapped_responses(line, turn) var speaker_text := String(line.get("character")) var line_text := String(line.get("text")) _release_ephemeral_resource(resource, line) if mapped.is_empty() and not turn.is_terminal(): _fail_presentation(&"option_mapping_invalid") return _speaker_label.text = speaker_text if not speaker_text.is_empty() else "Villager" _line_label.text = line_text for copied_response in mapped: _add_option_button( StringName(copied_response["option_id"]), String(copied_response["text"]) ) _mapping_valid = true _focus_option(0) turn_presented.emit(_conversation_id, turn.get_revision()) func _resource_matches_turn(resource: Variant, turn: ConversationTurn) -> bool: return ( resource is Resource and resource.has_meta("conversation_id") and resource.has_meta("conversation_revision") and String(resource.get_meta("conversation_id")) == String(turn.get_conversation_id()) and int(resource.get_meta("conversation_revision")) == turn.get_revision() ) func _line_matches_turn(line: Variant, turn: ConversationTurn) -> bool: return ( line is Object and line.has_method("get_tag_value") and line.get("responses") is Array and ( String(line.call("get_tag_value", "conversation_id")) == String(turn.get_conversation_id()) ) and int(String(line.call("get_tag_value", "revision"))) == turn.get_revision() ) func _copy_mapped_responses(line: Variant, turn: ConversationTurn) -> Array[Dictionary]: var expected_ids: Array[StringName] = [] for option in turn.get_options(): if option.is_enabled(): expected_ids.append(option.get_option_id()) var mapped: Array[Dictionary] = [] var seen: Dictionary = {} for response in line.get("responses"): if response is not Object or not response.has_method("get_tag_value"): return [] var option_id := StringName(response.call("get_tag_value", "option_id")) var option := turn.get_option(option_id) if ( option == null or not option.is_enabled() or seen.has(option_id) or String(response.call("get_tag_value", "enabled")) != "true" ): return [] seen[option_id] = true mapped.append({"option_id": option_id, "text": String(response.get("text"))}) if mapped.size() != expected_ids.size(): return [] for expected_id in expected_ids: if not seen.has(expected_id): return [] return mapped func _build_presentation_context(turn: ConversationTurn) -> Dictionary: var entity_names := {} for entity in [turn.get_speaker(), turn.get_listener()]: var label := _entity_display_name(entity) entity_names[String(entity.get_entity_id())] = label entity_names[entity.index_key()] = label var topic_labels := {} for topic_id in turn.get_causal_topic_ids(): topic_labels[String(topic_id)] = String(topic_id).replace("_", " ") return {"entity_names": entity_names, "topic_labels": topic_labels} func _entity_display_name(entity: WorldEntityRef) -> String: if String(entity.get_entity_type()) == "player": return "You" var entity_id := String(entity.get_entity_id()) if String(entity.get_entity_type()) == "npc" and entity_id.is_valid_int(): var npc_name := _npc_name(int(entity_id)) if not npc_name.is_empty(): return npc_name return entity_id.replace("_", " ").capitalize() func _npc_name(npc_id: int) -> String: if _simulation_manager == null or not ("npcs" in _simulation_manager): return "" for npc in _simulation_manager.get("npcs"): if int(npc.get("id")) == npc_id: return String(npc.get("npc_name")) return "" func _nearest_active_npc_id() -> int: if ( _player == null or _world_view_manager == null or not _world_view_manager.has_method("find_nearest_active_npc_id") ): return -1 var maximum_distance := float(_player.get("villager_inspection_range")) return int( _world_view_manager.call( "find_nearest_active_npc_id", _player.global_position, maximum_distance ) ) func _fail_presentation(reason: StringName) -> void: _mapping_valid = false _clear_option_buttons() _speaker_label.text = "Conversation unavailable" _line_label.text = "The dialogue could not be displayed safely." _show_status("Close and try speaking again.", true) presentation_failed.emit(reason) func _close_local(conversation_id: StringName) -> void: _presentation_request += 1 _conversation_id = &"" _presented_turn = null _presented_option_ids.clear() _mapping_valid = false _focused_option_index = -1 _clear_option_buttons() set_process_input(false) if _root_control != null: _root_control.visible = false _restore_mouse_mode() mode_ended.emit(conversation_id) func _capture_mouse_mode() -> void: _mouse_mode_before_dialogue = Input.mouse_mode _owns_visible_mouse = Input.mouse_mode != Input.MOUSE_MODE_VISIBLE if _owns_visible_mouse: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE func _restore_mouse_mode() -> void: if _owns_visible_mouse and Input.mouse_mode == Input.MOUSE_MODE_VISIBLE: Input.mouse_mode = _mouse_mode_before_dialogue _owns_visible_mouse = false func _release_ephemeral_resource(resource: Variant, line: Variant) -> void: if line is Object: var extra_states: Variant = line.get("extra_game_states") if extra_states is Array: extra_states.clear() var responses: Variant = line.get("responses") if responses is Array: responses.clear() if resource is Resource: var lines: Variant = resource.get("lines") if lines is Dictionary: resource.set("lines", {}) resource.set_script(null) func _build_balloon() -> void: _root_control = Control.new() _root_control.name = "DialogueBalloon" _root_control.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _root_control.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_root_control) _backdrop = ColorRect.new() _backdrop.name = "Backdrop" _backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _backdrop.color = Color(0.025, 0.018, 0.012, 0.28) _backdrop.mouse_filter = Control.MOUSE_FILTER_STOP _root_control.add_child(_backdrop) _panel = PanelContainer.new() _panel.name = "Panel" _panel.mouse_filter = Control.MOUSE_FILTER_STOP var panel_style := StyleBoxFlat.new() panel_style.bg_color = Color(0.075, 0.055, 0.037, 0.97) panel_style.border_color = Color(0.75, 0.52, 0.25, 0.92) panel_style.set_border_width_all(2) panel_style.set_corner_radius_all(10) panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.45) panel_style.shadow_size = 8 _panel.add_theme_stylebox_override("panel", panel_style) _root_control.add_child(_panel) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 18) margin.add_theme_constant_override("margin_top", 14) margin.add_theme_constant_override("margin_right", 18) margin.add_theme_constant_override("margin_bottom", 12) _panel.add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 8) margin.add_child(content) _speaker_label = Label.new() _speaker_label.name = "Speaker" _speaker_label.add_theme_color_override("font_color", Color(0.95, 0.72, 0.38)) _speaker_label.add_theme_font_size_override("font_size", 18) content.add_child(_speaker_label) _line_label = Label.new() _line_label.name = "Line" _line_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _line_label.add_theme_color_override("font_color", Color(0.96, 0.91, 0.82)) _line_label.add_theme_font_size_override("font_size", 16) _line_label.size_flags_vertical = Control.SIZE_EXPAND_FILL content.add_child(_line_label) _options_scroll = ScrollContainer.new() _options_scroll.name = "OptionsScroll" _options_scroll.custom_minimum_size.y = 76.0 _options_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _options_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL content.add_child(_options_scroll) _options_box = VBoxContainer.new() _options_box.name = "Options" _options_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL _options_box.add_theme_constant_override("separation", 5) _options_scroll.add_child(_options_box) _status_label = Label.new() _status_label.name = "Status" _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status_label.add_theme_font_size_override("font_size", 13) content.add_child(_status_label) var footer := HBoxContainer.new() footer.alignment = BoxContainer.ALIGNMENT_END content.add_child(footer) var hint := Label.new() hint.text = "↑/↓ Navigate · Enter Select · Esc Close" hint.add_theme_color_override("font_color", Color(0.68, 0.62, 0.53)) hint.add_theme_font_size_override("font_size", 12) hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL footer.add_child(hint) _close_button = Button.new() _close_button.name = "Close" _close_button.text = "Close" _close_button.focus_mode = Control.FOCUS_NONE _close_button.pressed.connect(close_conversation) footer.add_child(_close_button) func _layout_balloon() -> void: if _panel == null: return var viewport_size := get_viewport().get_visible_rect().size var panel_width := minf(MAX_PANEL_WIDTH, maxf(MIN_PANEL_WIDTH, viewport_size.x - 24.0)) var available_height := maxf(viewport_size.y - 24.0, 120.0) var panel_height := minf( MAX_PANEL_HEIGHT, maxf(minf(MIN_PANEL_HEIGHT, available_height), viewport_size.y * 0.5) ) panel_height = minf(panel_height, available_height) _panel.size = Vector2(panel_width, panel_height) _panel.position = Vector2( (viewport_size.x - panel_width) * 0.5, maxf(12.0, viewport_size.y - panel_height - 18.0) ) func _add_option_button(option_id: StringName, copy: String) -> void: var button := Button.new() button.text = copy button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.add_theme_font_size_override("font_size", 15) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.set_meta("option_id", option_id) button.pressed.connect(select_option.bind(option_id)) _options_box.add_child(button) _option_buttons.append(button) _presented_option_ids.append(option_id) func _clear_option_buttons() -> void: for button in _option_buttons: if button != null and is_instance_valid(button): if button.get_parent() != null: button.get_parent().remove_child(button) button.queue_free() _option_buttons.clear() _focused_option_index = -1 func _set_options_enabled(enabled: bool) -> void: for button in _option_buttons: if button != null and is_instance_valid(button): button.disabled = not enabled func _move_option_focus(direction: int) -> void: if _option_buttons.is_empty(): return _focus_option(posmod(_focused_option_index + direction, _option_buttons.size())) func _focus_option(index: int) -> void: if _option_buttons.is_empty(): _focused_option_index = -1 return _focused_option_index = clampi(index, 0, _option_buttons.size() - 1) var button := _option_buttons[_focused_option_index] if button != null and is_instance_valid(button): button.grab_focus() _options_scroll.ensure_control_visible(button) func _show_status(message: String, is_error: bool) -> void: if _status_label == null: return _status_label.text = message _status_label.visible = not message.is_empty() _status_label.add_theme_color_override( "font_color", Color(0.96, 0.5, 0.36) if is_error else Color(0.82, 0.72, 0.56) )