From f8734422a03f3a998563fd7560de7598e1027a83 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 21:16:15 +0200 Subject: [PATCH] feat: add playable generated dialogue mode --- player/PlayerInteractionResult.gd | 1 + player/combat/player_combat_controller.gd | 19 +- player/player.gd | 79 ++- tests/conversation_domain_test.gd.uid | 1 + ...manager_conversation_presenter_test.gd.uid | 1 + tests/dialogue_mode_controller_test.gd | 282 ++++++++ tests/dialogue_mode_controller_test.gd.uid | 1 + tests/player_villager_inspection_test.gd | 7 +- tests/resource_node_player_parity_test.gd | 1 + world/dialogue/DialogueModeController.gd | 627 ++++++++++++++++++ world/dialogue/DialogueModeController.gd.uid | 1 + 11 files changed, 1012 insertions(+), 8 deletions(-) create mode 100644 tests/conversation_domain_test.gd.uid create mode 100644 tests/dialogue_manager_conversation_presenter_test.gd.uid create mode 100644 tests/dialogue_mode_controller_test.gd create mode 100644 tests/dialogue_mode_controller_test.gd.uid create mode 100644 world/dialogue/DialogueModeController.gd create mode 100644 world/dialogue/DialogueModeController.gd.uid diff --git a/player/PlayerInteractionResult.gd b/player/PlayerInteractionResult.gd index c48ca95..82f7376 100644 --- a/player/PlayerInteractionResult.gd +++ b/player/PlayerInteractionResult.gd @@ -7,6 +7,7 @@ const KIND_PANTRY := &"pantry" const KIND_STORAGE_DEPOSIT := &"storage_deposit" const KIND_GUARD := &"guard" const KIND_STUDY := &"study" +const KIND_DIALOGUE := &"dialogue" var kind: StringName var action_id: StringName diff --git a/player/combat/player_combat_controller.gd b/player/combat/player_combat_controller.gd index 6e35008..8022da5 100644 --- a/player/combat/player_combat_controller.gd +++ b/player/combat/player_combat_controller.gd @@ -48,6 +48,11 @@ func _physics_process(delta: float) -> void: if simulation_manager != null and simulation_manager.has_method("update_player_combatant"): simulation_manager.update_player_combatant(player.global_position) _advance_timers(delta) + if _input_is_locked(): + dash_duration_remaining = 0.0 + dashing = false + dash_velocity = Vector3.ZERO + return if _player_is_downed(): attack_timer = 0.0 dash_duration_remaining = 0.0 @@ -81,7 +86,7 @@ func _player_is_downed() -> bool: func _perform_attack() -> void: - if attack_timer > 0.0 or not _simulation_attack_ready(): + if _input_is_locked() or attack_timer > 0.0 or not _simulation_attack_ready(): return attack_timer = attack_cooldown_seconds _play_swing() @@ -100,7 +105,7 @@ func _perform_attack() -> void: func _perform_dash() -> void: - if dash_cooldown_remaining > 0.0 or _player_is_downed(): + if _input_is_locked() or dash_cooldown_remaining > 0.0 or _player_is_downed(): return dash_duration_remaining = dash_duration dash_cooldown_remaining = maxf(dash_cooldown_seconds, dash_duration) @@ -144,7 +149,15 @@ func is_dashing() -> bool: func get_dash_velocity() -> Vector3: - return dash_velocity if dashing else Vector3.ZERO + return dash_velocity if dashing and not _input_is_locked() else Vector3.ZERO + + +func _input_is_locked() -> bool: + return ( + player != null + and player.has_method("is_dialogue_input_locked") + and bool(player.call("is_dialogue_input_locked")) + ) func get_dash_cooldown_remaining() -> float: diff --git a/player/player.gd b/player/player.gd index bf47dca..fcd26f6 100644 --- a/player/player.gd +++ b/player/player.gd @@ -2,6 +2,8 @@ extends CharacterBody3D signal interaction_feedback(heading: String, message: String, succeeded: bool) +const DIALOGUE_MODE_CONTROLLER_SCRIPT := preload("res://world/dialogue/DialogueModeController.gd") + @export var move_speed := 7.0 @export var acceleration := 18.0 @export var rotation_speed := 12.0 @@ -21,12 +23,24 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool) @onready var combat_controller: Node = get_node_or_null("PlayerCombatController") +var dialogue_mode_controller: DialogueModeController + func _ready() -> void: add_to_group("grass_interactors") + _install_dialogue_mode_controller() func _physics_process(delta: float) -> void: + if is_dialogue_input_locked(): + velocity.x = move_toward(velocity.x, 0.0, acceleration * delta) + velocity.z = move_toward(velocity.z, 0.0, acceleration * delta) + if not is_on_floor(): + velocity.y -= 30.0 * delta + else: + velocity.y = 0.0 + move_and_slide() + return if _is_player_downed(): velocity.x = move_toward(velocity.x, 0.0, acceleration * delta) velocity.z = move_toward(velocity.z, 0.0, acceleration * delta) @@ -79,16 +93,26 @@ func _is_player_downed() -> bool: func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("interact"): + if is_dialogue_input_locked(): + return + if ( + event.is_action_pressed("interact") + or (event.is_action_pressed("ui_accept") and _get_nearby_dialogue_npc_id() >= 0) + ): try_interact() func try_interact() -> void: + if is_dialogue_input_locked(): + return var context := get_interaction_context() if context == null: return match context.kind: + PlayerInteractionResult.KIND_DIALOGUE: + if dialogue_mode_controller != null: + dialogue_mode_controller.begin_conversation(int(String(context.target_id))) PlayerInteractionResult.KIND_ANIMAL: _execute_animal_interaction(context) PlayerInteractionResult.KIND_RESOURCE: @@ -110,8 +134,19 @@ func try_interact() -> void: func get_interaction_context() -> PlayerInteractionResult: - if simulation_manager == null: + if simulation_manager == null or is_dialogue_input_locked(): return null + var villager := _find_npc(_get_nearby_dialogue_npc_id()) + if villager != null: + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_DIALOGUE, + DialogueModeController.TALK_ACTION_ID, + StringName(str(villager.id)), + villager.npc_name, + "Talk with %s" % villager.npc_name, + "Ask what is happening nearby", + null + ) var animal := _find_feed_animal() if animal != null: return _build_animal_context(animal) @@ -148,6 +183,46 @@ func get_interaction_context() -> PlayerInteractionResult: return null +func is_dialogue_input_locked() -> bool: + if ( + simulation_manager != null + and simulation_manager.has_method("is_dialogue_active") + and bool(simulation_manager.call("is_dialogue_active")) + ): + return true + return ( + dialogue_mode_controller != null + and is_instance_valid(dialogue_mode_controller) + and dialogue_mode_controller.is_dialogue_active() + ) + + +func _install_dialogue_mode_controller() -> void: + var existing := get_node_or_null("DialogueModeController") as DialogueModeController + if existing != null: + dialogue_mode_controller = existing + else: + dialogue_mode_controller = DIALOGUE_MODE_CONTROLLER_SCRIPT.new() + dialogue_mode_controller.name = "DialogueModeController" + dialogue_mode_controller.configure(self, simulation_manager, world_view_manager) + add_child(dialogue_mode_controller) + if existing != null: + dialogue_mode_controller.configure(self, simulation_manager, world_view_manager) + + +func _get_nearby_dialogue_npc_id() -> int: + if ( + world_view_manager == null + or not world_view_manager.has_method("find_nearest_active_npc_id") + ): + return -1 + return int( + world_view_manager.call( + "find_nearest_active_npc_id", global_position, villager_inspection_range + ) + ) + + func get_nearby_villager_inspection() -> VillagerInspectionResult: if ( simulation_manager == null diff --git a/tests/conversation_domain_test.gd.uid b/tests/conversation_domain_test.gd.uid new file mode 100644 index 0000000..03aa0b0 --- /dev/null +++ b/tests/conversation_domain_test.gd.uid @@ -0,0 +1 @@ +uid://cmdvv4u3i6trp diff --git a/tests/dialogue_manager_conversation_presenter_test.gd.uid b/tests/dialogue_manager_conversation_presenter_test.gd.uid new file mode 100644 index 0000000..18921e8 --- /dev/null +++ b/tests/dialogue_manager_conversation_presenter_test.gd.uid @@ -0,0 +1 @@ +uid://4ty0jd5lvj0h diff --git a/tests/dialogue_mode_controller_test.gd b/tests/dialogue_mode_controller_test.gd new file mode 100644 index 0000000..db183d5 --- /dev/null +++ b/tests/dialogue_mode_controller_test.gd @@ -0,0 +1,282 @@ +extends SceneTree + + +class ForgedResponse: + extends RefCounted + + var text := "Forged authority" + + func get_tag_value(tag_name: String) -> String: + if tag_name == "option_id": + return "option_not_in_turn" + if tag_name == "enabled": + return "true" + return "" + + +class ForgedLine: + extends RefCounted + + var responses: Array = [ForgedResponse.new()] + + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + for _frame in 4: + await physics_frame + + var manager: Node = main_scene.get_node("SimulationManager") + var world_view: Node = main_scene.get_node("WorldViewManager") + var player := main_scene.get_node("Player") as CharacterBody3D + var combat := player.get_node("PlayerCombatController") as PlayerCombatController + var controller := player.get_node_or_null("DialogueModeController") as DialogueModeController + manager.set_process(false) + player.set_physics_process(false) + combat.set_physics_process(false) + _freeze_and_place_population(manager, world_view, player.global_position) + + _check(controller != null, "The player should install one autoload-free dialogue controller") + if controller == null: + _finish() + return + var nearest_id: int = world_view.find_nearest_active_npc_id( + player.global_position, player.villager_inspection_range + ) + var context := player.get_interaction_context() as PlayerInteractionResult + _check( + ( + nearest_id >= 0 + and context != null + and context.kind == PlayerInteractionResult.KIND_DIALOGUE + and context.target_id == StringName(str(nearest_id)) + and "Talk" in context.prompt_text + ), + "The E interaction should prefer the nearest loaded living villager", + ) + + var mouse_mode_before_dialogue := Input.mouse_mode + var gamepad_accept := InputEventAction.new() + gamepad_accept.action = &"ui_accept" + gamepad_accept.pressed = true + player.call("_unhandled_input", gamepad_accept) + await _wait_for_presentation(controller) + _check( + ( + manager.is_dialogue_active() + and controller.is_dialogue_active() + and player.is_dialogue_input_locked() + and controller.has_valid_option_mapping() + and not controller.get_presented_speaker().is_empty() + and not controller.get_presented_line().is_empty() + and not controller.get_presented_option_ids().is_empty() + ), + "A manager-planned turn should render through the pinned Dialogue Manager adapter", + ) + _check( + player.get_interaction_context() == null, + "Ordinary world interactions should be unavailable while dialogue owns input", + ) + var horizontal_speed_before := 5.0 + player.velocity = Vector3(horizontal_speed_before, 0.0, 0.0) + player.call("_physics_process", 0.1) + _check( + absf(player.velocity.x) < horizontal_speed_before, + "Dialogue mode should decelerate the player instead of reading movement input", + ) + _check_combat_lock(combat) + _check_navigation(controller) + _check_responsive_layout(controller) + + var displayed_revision := controller.get_presented_revision() + var authoritative_turn: ConversationTurn = manager.conversation_service.get_turn( + controller.get_conversation_id() + ) + var forged_mapping: Array = controller.call( + "_copy_mapped_responses", ForgedLine.new(), authoritative_turn + ) + _check( + forged_mapping.is_empty(), + "Presenter copy must reject response tags that are absent from the planned turn", + ) + var advance_option := _first_non_goodbye_option(authoritative_turn) + _check(advance_option != null, "The opening turn should offer a non-terminal response") + if advance_option != null: + var advanced: ConversationSelectionResult = manager.conversation_service.select_option( + controller.get_conversation_id(), + advance_option.get_option_id(), + authoritative_turn.get_revision() + ) + _check(advanced.was_accepted(), "The stale-revision setup should advance domain state") + var stale_option_id := controller.get_presented_option_ids()[0] + _check( + not controller.select_option(stale_option_id), + "A displayed response must not apply against a newer authoritative revision", + ) + await _wait_for_presentation(controller) + _check( + ( + controller.get_presented_revision() == displayed_revision + 1 + and controller.has_valid_option_mapping() + ), + "A stale selection should refresh from the rejected result's authoritative turn", + ) + + _check(controller.close_conversation(), "Esc/close should end the active manager conversation") + await process_frame + _check( + ( + not manager.is_dialogue_active() + and not controller.is_dialogue_active() + and not player.is_dialogue_input_locked() + and not controller.get_node("DialogueBalloon").visible + and Input.mouse_mode == mouse_mode_before_dialogue + ), + "Closing should release presentation and gameplay input without one-way UI state", + ) + + _check( + controller.begin_conversation(nearest_id), + "The same nearby villager should be talkable again" + ) + await _wait_for_presentation(controller) + var goodbye_id := _option_id_for_intent( + manager.conversation_service.get_turn(controller.get_conversation_id()), + ConversationIntentIds.GOODBYE + ) + _check( + not goodbye_id.is_empty() and controller.get_presented_option_ids().has(goodbye_id), + "The semantic goodbye option should retain its controlled presenter mapping", + ) + if not goodbye_id.is_empty(): + _check(controller.select_option(goodbye_id), "Selecting goodbye should be accepted") + await process_frame + _check( + not manager.is_dialogue_active() and not controller.is_dialogue_active(), + "A terminal goodbye should close through the manager's conversation-ended signal", + ) + + main_scene.queue_free() + await process_frame + _finish() + + +func _wait_for_presentation(controller: DialogueModeController) -> void: + for _frame in 12: + if controller.has_valid_option_mapping() or not controller.is_dialogue_active(): + return + await process_frame + + +func _freeze_and_place_population(manager: Node, world_view: Node, origin: Vector3) -> void: + for index in manager.npcs.size(): + var npc: SimNPC = manager.npcs[index] + var position := origin + Vector3(40.0 + index * 3.0, 0.0, 30.0) + if index == 0: + position = origin + Vector3(1.0, 0.0, 0.0) + manager.synchronize_npc_position(npc.id, position) + var visual := world_view.active_npc_visuals.get(npc.id) as Node3D + if visual != null: + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + visual.global_position = position + + +func _check_combat_lock(combat: PlayerCombatController) -> void: + combat.dashing = true + combat.dash_velocity = Vector3(18.0, 0.0, 0.0) + var attack_timer_before := combat.attack_timer + combat.call("_perform_attack") + _check( + ( + combat.get_dash_velocity() == Vector3.ZERO + and is_equal_approx(combat.attack_timer, attack_timer_before) + ), + "Dialogue input ownership should suppress active dashes and new attacks", + ) + + +func _check_navigation(controller: DialogueModeController) -> void: + var option_ids := controller.get_presented_option_ids() + if option_ids.size() < 2: + _check(false, "The navigation proof needs at least two presented responses") + return + var first_id := controller.get_focused_option_id() + var down := InputEventAction.new() + down.action = &"ui_down" + down.pressed = true + controller.call("_input", down) + _check( + controller.get_focused_option_id() != first_id, + "Keyboard/gamepad down should move deterministic option focus", + ) + var up := InputEventAction.new() + up.action = &"ui_up" + up.pressed = true + controller.call("_input", up) + _check( + controller.get_focused_option_id() == first_id, + "Keyboard/gamepad up should restore deterministic option focus", + ) + + +func _check_responsive_layout(controller: DialogueModeController) -> void: + var original_size := root.size + root.size = Vector2i(480, 270) + controller.call("_layout_balloon") + var panel := controller.get_node("DialogueBalloon/Panel") as PanelContainer + var viewport_size := controller.get_viewport().get_visible_rect().size + _check( + ( + panel.size.x <= minf(760.0, viewport_size.x) + and panel.size.y <= viewport_size.y + and panel.position.x >= 0.0 + and panel.position.y >= 0.0 + and panel.position.x + panel.size.x <= viewport_size.x + 0.01 + and panel.position.y + panel.size.y <= viewport_size.y + 0.01 + ), + "The custom balloon should remain inside the low-resolution viewport bounds", + ) + root.size = original_size + + +func _first_non_goodbye_option(turn: ConversationTurn) -> ConversationOption: + if turn == null: + return null + for option in turn.get_options(): + if option.is_enabled() and option.get_intent_id() != ConversationIntentIds.GOODBYE: + return option + return null + + +func _option_id_for_intent(turn: ConversationTurn, intent_id: StringName) -> StringName: + if turn == null: + return &"" + for option in turn.get_options(): + if option.is_enabled() and option.get_intent_id() == intent_id: + return option.get_option_id() + return &"" + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Playable Dialogue Manager presentation passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/dialogue_mode_controller_test.gd.uid b/tests/dialogue_mode_controller_test.gd.uid new file mode 100644 index 0000000..700b2f4 --- /dev/null +++ b/tests/dialogue_mode_controller_test.gd.uid @@ -0,0 +1 @@ +uid://d1eld8rj11h34 diff --git a/tests/player_villager_inspection_test.gd b/tests/player_villager_inspection_test.gd index 779ad02..f4f5cc7 100644 --- a/tests/player_villager_inspection_test.gd +++ b/tests/player_villager_inspection_test.gd @@ -94,13 +94,14 @@ func _run() -> void: _check( ( pantry_context != null - and pantry_context.kind == PlayerInteractionResult.KIND_PANTRY - and pantry_context.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY + and pantry_context.kind == PlayerInteractionResult.KIND_DIALOGUE + and pantry_context.target_id == StringName(str(selected.id)) + and "Talk" in pantry_context.prompt_text and pantry_context.prompt_text in interaction_action.text and inspection != null and field_note.visible ), - "The automatic villager field note should coexist with the existing pantry E action", + "The automatic villager field note should coexist with the preferred nearby Talk action", ) if inspection == null or labels.is_empty(): _finish() diff --git a/tests/resource_node_player_parity_test.gd b/tests/resource_node_player_parity_test.gd index 6defe64..de1c32a 100644 --- a/tests/resource_node_player_parity_test.gd +++ b/tests/resource_node_player_parity_test.gd @@ -16,6 +16,7 @@ func _run() -> void: var simulation_manager := main_scene.get_node("SimulationManager") var player := main_scene.get_node("Player") + player.villager_inspection_range = 0.0 var bush := ( main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode ) diff --git a/world/dialogue/DialogueModeController.gd b/world/dialogue/DialogueModeController.gd new file mode 100644 index 0000000..a5558af --- /dev/null +++ b/world/dialogue/DialogueModeController.gd @@ -0,0 +1,627 @@ +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) + ) diff --git a/world/dialogue/DialogueModeController.gd.uid b/world/dialogue/DialogueModeController.gd.uid new file mode 100644 index 0000000..d77ad9e --- /dev/null +++ b/world/dialogue/DialogueModeController.gd.uid @@ -0,0 +1 @@ +uid://chfg7yr7w6trx