Files
gamedev-the-steward/world/dialogue/DialogueModeController.gd
T
2026-08-12 21:16:15 +02:00

628 lines
20 KiB
GDScript

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)
)