Files
Dawn-Godot/addons/madtalk/madtalk_editor.gd
2025-08-31 17:53:17 -05:00

592 lines
19 KiB
GDScript

@tool
extends Control
@export var dialog_data: Resource = preload("res://addons/madtalk/runtime/madtalk_data.tres")
var current_sheet = null
# Scene templates
var DialogNode_template = preload("res://addons/madtalk/components/DialogNode.tscn")
var SideBar_SheetItem_template = preload("res://addons/madtalk/components/SideBar_SheetItem.tscn")
# Scene nodes
@onready var graph_area = get_node("GraphArea")
@onready var sidebar_sheetlist = get_node("SideBar/Content/SheetsScroll/VBox")
@onready var sidebar_current_panel = get_node("SideBar/Content/CurrentPanel")
@onready var SideBar_sheet_id = get_node("SideBar/Content/CurrentPanel/SheetIDLabel")
@onready var SideBar_sheet_desc = get_node("SideBar/Content/CurrentPanel/DescEdit")
@onready var SideBar_search = get_node("SideBar/Content/SearchEdit")
@onready var graph_area_popup = get_node("PopupMenu")
@onready var popup_delete_node = get_node("DialogDeleteNodePopup")
@onready var dialog_sheet_edit = get_node("DialogSheetEdit")
@onready var dialog_sheet_rename_error_popup = get_node("DialogSheetRenameError")
@onready var dialog_sheet_create_popup = get_node("DialogSheetCreated")
@onready var dialog_export = get_node("DialogExport")
@onready var dialog_import = get_node("DialogImport")
# Maps sequence ids to graph nodes
var sequence_map: Dictionary = {}
# Holds the node being deleted when user presses X
var deleting_node = null
# Holds the item object being dragged
var dragging_object = null
var hovering_object = null
func _ready() -> void:
pass
call_deferred("setup")
func setup():
if dialog_data.sheets.size() == 0:
create_new_sheet()
else:
open_sheet(dialog_data.sheets.keys()[0])
dialog_export.setup(dialog_data, current_sheet)
dialog_import.setup(dialog_data, current_sheet)
# Opens a sheet for the first time, or reopens (updates area content)
func open_sheet(sheet_id: String) -> void:
# FAILSAFE: We ignore this call if the sheet id is invalid
if not sheet_id in dialog_data.sheets:
print("Invalid sheet id \"%s\"" % sheet_id)
return
# Clear all current content in the graph area
for dialog_node in graph_area.get_children():
if dialog_node is DialogGraphNode:
graph_area.remove_child(dialog_node)
dialog_node.queue_free()
sequence_map.clear()
# Prepare new sheet
current_sheet = sheet_id
var sheet_data = dialog_data.sheets[sheet_id]
# First we build all nodes *without* updating them
for node_data in sheet_data.nodes:
var new_node = create_node_instance(node_data, false)
sequence_map[node_data.sequence_id] = new_node
# After we have all node instances available for connections,
# we update them
for sequence_id in sequence_map:
sequence_map[sequence_id].update_from_data()
graph_area.scroll_offset.y -= 1
update_sidebar()
rebuild_connections()
await get_tree().process_frame
graph_area.scroll_offset.y += 1
graph_area.queue_redraw()
func reopen_current_sheet():
open_sheet(current_sheet)
# Creates the visual representation of a node
# Does not modify the data structure
func create_node_instance(node_data: Resource, update_now: bool = true) -> DialogGraphNode:
var new_node: GraphNode = DialogNode_template.instantiate()
new_node.name = "DialogNode_ID%d" % node_data.sequence_id
new_node.main_editor = self
graph_area.add_child(new_node)
new_node.position_offset = node_data.position
new_node.connections_changed.connect(_on_node_connections_changed)
new_node.mouse_entered.connect(_on_sequence_mouse_entered.bind(new_node))
new_node.mouse_exited.connect(_on_sequence_mouse_exited.bind(new_node))
#new_node.connect("close_request", Callable(self, "_on_node_close_request").bind(new_node))
new_node.data = node_data # Assign the reference, not a copy
# Any changes to this node will reflect back in
# the main Resource
#new_node.show_close = (node_data.sequence_id != 0)
if (node_data.sequence_id != 0):
var new_close_btn = Button.new()
new_close_btn.text = " X "
new_close_btn.focus_mode = Control.FOCUS_NONE
new_node.get_titlebar_hbox().add_child(new_close_btn)
new_close_btn.pressed.connect(_on_node_close_request.bind(new_node))
# During sheet building not all nodes are ready so updating connections
# will fail. In such a case we skip this task and update all nodes at once
# later
if (update_now):
new_node.update_from_data()
return new_node
# Creates a new node, optionally creating the visual GraphNode
func create_new_node(graph_position: Vector2 = Vector2(0,0), create_visual_instance = false) -> DialogNodeData:
if not current_sheet:
return null
var sheet_data = dialog_data.sheets[current_sheet]
# Find next available sequence id
var next_available_id = sheet_data.next_sequence_id
for this_node in sheet_data.nodes:
if this_node.sequence_id >= next_available_id:
next_available_id = this_node.sequence_id+1
var new_data = DialogNodeData.new()
new_data.resource_scene_unique_id = Resource.generate_scene_unique_id()
new_data.position = graph_position
new_data.sequence_id = next_available_id
new_data.items = [] # New Array to avoid sharing references
new_data.options = [] # New Array to avoid sharing references
sheet_data.nodes.append(new_data)
sheet_data.next_sequence_id = next_available_id+1
# create_visual_instance is true when the node is created from user
# interaction ("New sequence" button). It is false when the data is being
# created procedurally and instances will be created later by open_sheet()
# DEPRECATED: now all methods call here with create_visual_instance=false
# and call open_sheet() afterwards
if create_visual_instance:
create_node_instance(new_data, true)
rebuild_connections() # Should not be needed but reduntant calls are harmless
return new_data
# Creates a new sheet, set as current, and returns the name of the sheet
func create_new_sheet() -> String:
# Find a suitable available name
var sheet_num = 1
var new_sheet_name = "new_sheet_1"
while new_sheet_name in dialog_data.sheets:
sheet_num += 1
new_sheet_name = "new_sheet_%d" % sheet_num
# Create the new sheet
var new_sheet_data = DialogSheetData.new() # default next_sequence_id=0
new_sheet_data.resource_scene_unique_id = Resource.generate_scene_unique_id()
new_sheet_data.sheet_id = new_sheet_name
new_sheet_data.nodes = [] # Forces a new array to avoid reference sharing
dialog_data.sheets[new_sheet_name] = new_sheet_data
current_sheet = new_sheet_name
# All sheets need at least one node with ID=0
# Create a node data item without creating the GraphNode instance, as
# it will be created later by open_sheet()
create_new_node(Vector2(0,0), false)
# Update sidebar and open sheet
update_sidebar()
open_sheet(new_sheet_name)
#rebuild_connections()
return new_sheet_name
# Connections are not build directly from UI
# Instead they are rebuilt from the Resource data objects every time
# This is the safest way to make sure there is never any difference between
# the visual representation and the underlying data
func rebuild_connections() -> void:
graph_area.clear_connections()
for sequence_id in sequence_map:
var dialog_node = sequence_map[sequence_id]
var sequence_data = dialog_node.data
# For each item in this sequence
for item_data in sequence_data.items:
# Do we have a connection?
if (item_data.connected_to_id > -1) and (item_data.port_index > -1):
var target_node = get_dialognode_by_id(item_data.connected_to_id)
if target_node:
graph_area.connect_node(dialog_node.name, item_data.port_index, target_node.name, 0)
# For each option in this sequence
for opt_data in sequence_data.options:
# Do we have a connection?
if (opt_data.connected_to_id > -1) and (opt_data.port_index > -1):
var target_node = get_dialognode_by_id(opt_data.connected_to_id)
if target_node:
graph_area.connect_node(dialog_node.name, opt_data.port_index, target_node.name, 0)
# If we have a continue option at the end
if sequence_data.continue_sequence_id > -1:
var target_node = get_dialognode_by_id(sequence_data.continue_sequence_id)
if target_node:
graph_area.connect_node(dialog_node.name, sequence_data.continue_port_index, target_node.name, 0)
# Given a sequence id, returns the corresponding GraphNode object
func get_dialognode_by_id(id: int) -> DialogGraphNode:
if not id in sequence_map:
print("Error: node ID %s not found in sequence map" % id)
return null
return sequence_map[id]
func update_sidebar():
# === Update current sheet
if current_sheet:
var sheet_data = dialog_data.sheets[current_sheet]
SideBar_sheet_id.text = sheet_data.sheet_id
SideBar_sheet_desc.text = sheet_data.sheet_description
sidebar_current_panel.show()
else:
sidebar_current_panel.hide()
# === Update list
# Remove old items
for old_item in sidebar_sheetlist.get_children():
sidebar_sheetlist.remove_child(old_item)
old_item.queue_free()
# Add new items
var search_term = SideBar_search.text
for this_sheet_id in dialog_data.sheets:
var new_item_data = dialog_data.sheets[this_sheet_id]
# If there is no search, or search shows up in either id or description:
if (search_term == "") or (search_term in this_sheet_id) or (search_term in new_item_data.sheet_description):
var new_item = SideBar_SheetItem_template.instantiate()
sidebar_sheetlist.add_child(new_item)
new_item.get_node("Panel/SheetLabel").text = new_item_data.sheet_id
new_item.get_node("Panel/DescriptionLabel").text = new_item_data.sheet_description
new_item.get_node("Panel/BtnOpen").connect("pressed", Callable(self, "_on_SideBar_Item_open").bind(new_item_data.sheet_id))
func _save_external_data():
var res_path = dialog_data.resource_path
ResourceSaver.save(dialog_data, res_path, 0)
# ==============================================================================
# UI CALLBACKS
## Distributes the input event to the appropriate method
func _on_GraphEdit_gui_input(event: InputEvent) -> void:
if (event is InputEventMouseButton) and (event.pressed):
match event.button_index:
MOUSE_BUTTON_LEFT:
_on_GraphEdit_left_click(event)
MOUSE_BUTTON_RIGHT:
_on_GraphEdit_right_click(event)
#if (event is InputEventMouseMotion):
# print( (graph_area.get_local_mouse_position() + graph_area.scroll_offset)/graph_area.zoom )
## Handles left clicks
func _on_GraphEdit_left_click(event: InputEvent) -> void:
# event.position is screen coordinate, not taking scroll into account
# graph_position is in node local coordinates
var graph_position = event.position + graph_area.scroll_offset
#print("LEFT CLICK: " + str(graph_position))
#print(graph_area.scroll_offset)
## Handles right clicks
func _on_GraphEdit_right_click(event: InputEvent) -> void:
# event.position is screen coordinate, not taking scroll into account
# graph_position is in node local coordinates
var graph_position = event.position + graph_area.scroll_offset
var cursor_position = Vector2(get_viewport().get_mouse_position() if get_viewport().gui_embed_subwindows else DisplayServer.mouse_get_position())
graph_area_popup.popup(Rect2(cursor_position, Vector2(10,10)))
# When a node item (message, condition, effect) is mouse-hovered
# Also happens if dragging started on another object
func _on_item_mouse_entered(obj: Control) -> void:
hovering_object = obj
if (dragging_object != null) and (dragging_object != obj):
obj.modulate.a = 0.7
obj.dragdrop_line.show()
# When a node item (message, condition, effect) loses mouse hover
# Also happens if dragging started on another object
func _on_item_mouse_exited(obj: Control) -> void:
if hovering_object == obj:
hovering_object = null
if dragging_object != null:
obj.modulate.a = 1.0
obj.dragdrop_line.hide()
func _on_sequence_mouse_entered(obj: Control):
hovering_object = obj
if dragging_object != null:
obj.modulate.a = 0.7
func _on_sequence_mouse_exited(obj: Control):
if hovering_object == obj:
hovering_object = null
if dragging_object != null:
obj.modulate.a = 1.0
# When the mouse is pressed down on a node item, which counts as
# start dragging it.
func _on_item_drag_started(obj: Control) -> void:
dragging_object = obj
# When the mouse is released after dragging an item. The obj argument
# contains the object being dragged, not the one under the cursor
func _on_item_drag_ended(obj: Control) -> void:
if (dragging_object != hovering_object):
move_item_by_instance(dragging_object, hovering_object)
if hovering_object and is_instance_valid(hovering_object):
hovering_object.modulate.a = 1.0
if not hovering_object is DialogGraphNode:
hovering_object.dragdrop_line.hide()
hovering_object = null
dragging_object = null
func move_item_by_instance(source_inst: Control, dest_inst):
if (not source_inst) or (not is_instance_valid(source_inst)):
return
if (not dest_inst) or (not is_instance_valid(dest_inst)):
return
var source_seq = source_inst.sequence_node
var data_seq_origin = source_seq.data
var source_index = data_seq_origin.items.find(source_inst.data)
var source_item_data = data_seq_origin.items[source_index]
var dest_seq
var data_seq_dest
var dest_index
if dest_inst is DialogGraphNode:
# Dragging onto a sequence header
dest_seq = dest_inst
data_seq_dest = dest_seq.data
dest_index = data_seq_dest.items.size()
else:
# Dragging onto another item
dest_seq = dest_inst.sequence_node
data_seq_dest = dest_seq.data
dest_index = data_seq_dest.items.find(dest_inst.data)
# There are special cases if the node is being reordered inside the same sequence
if (data_seq_origin == data_seq_dest):
if (dest_index == (source_index+1)):
# If the user dropped on the item immediately below, no operation is needed
return
elif (dest_index > source_index):
# If item is being moved below, removing it first will shift indices
# below that point, causing the reinsert to have an unintended
# extra offset of 1. So counteract is needed
dest_index -= 1
data_seq_origin.items.remove_at(source_index)
data_seq_dest.items.insert(dest_index, source_item_data)
source_seq.update_from_data()
dest_seq.update_from_data()
await get_tree().create_timer(0.02).timeout
call_deferred("rebuild_connections")
func _on_GraphArea_connection_request(from, from_slot, to, to_slot):
# Get the required data
var from_node = graph_area.get_node(NodePath(from))
var from_data = from_node.get_data_by_port(from_slot)
var to_node = graph_area.get_node(NodePath(to))
# to_slot is always 0 in this application
var to_sequence_id = to_node.data.sequence_id
# Make the connection in the underlying data resources
if from_data is DialogNodeData:
# This is a simple continue
from_data.continue_sequence_id = to_sequence_id
else:
# This is a branching
from_data.connected_to_id = to_sequence_id
rebuild_connections()
func _on_GraphArea_disconnection_request(from, from_slot, to, to_slot):
# Get the required data
var from_node = graph_area.get_node(NodePath(from))
var from_data = from_node.get_data_by_port(from_slot)
# Make the connection in the underlying data resources
if from_data is DialogNodeData:
# This is a simple continue
from_data.continue_sequence_id = -1
else:
# This is a branching
from_data.connected_to_id = -1
rebuild_connections()
func _on_node_connections_changed() -> void:
rebuild_connections()
func _on_node_close_request(node_object) -> void:
deleting_node = node_object
popup_delete_node.popup_centered()
func _on_SideBar_SearchEdit_text_changed(new_text) -> void:
update_sidebar()
func _on_GraphArea_PopupMenu_id_pressed(id) -> void:
match id:
0:
# Create new node
# graph_area_popup.rect_position is screen coordinate, not taking scroll into account
# graph_position is in node local coordinates
#var cursor_position = Vector2(graph_area_popup.position)
#var graph_position = Vector2(cursor_position) + Vector2(graph_area.scroll_offset)
#var graph_position = Vector2(graph_area_popup.position) + Vector2(graph_area.scroll_offset)
var graph_position = Vector2((graph_area.get_local_mouse_position() + graph_area.scroll_offset)/graph_area.zoom)
create_new_node(graph_position - Vector2(100,10), false)
open_sheet(current_sheet)
func _on_DialogDeleteNodePopup_confirmed() -> void:
if (not current_sheet) or (not deleting_node):
return
var sheet_data = dialog_data.sheets[current_sheet]
var node_data = deleting_node.data
sheet_data.nodes.erase(node_data)
# Reopens the sheet to update area
open_sheet(current_sheet)
func _on_BtnEditSheet_pressed() -> void:
if not current_sheet:
return
var sheet_data = dialog_data.sheets[current_sheet]
dialog_sheet_edit.open(sheet_data)
func _on_DialogSheetEdit_sheet_saved(sheet_id, sheet_desc, delete_word) -> void:
if not current_sheet:
return
# Is the user requesting to delete the sheet?
if delete_word == "delete":
dialog_data.sheets[current_sheet].nodes = [] # Discards references to node data
dialog_data.sheets[current_sheet] = null
dialog_data.sheets.erase(current_sheet)
# If this was the last sheet, we create a new one
if dialog_data.sheets.size() == 0:
# This method creates a new sheet, sets as current and returns the
# new sheet name
var _new_sheet = create_new_sheet()
# Otherwise select some other sheet
else:
current_sheet = dialog_data.sheets.keys()[0]
open_sheet(current_sheet)
# Hide the window
dialog_sheet_edit.hide()
update_sidebar()
# Stop here since the old sheet is no longer valid
return
# Otherwise the user is editting the sheet
var sheet_data = dialog_data.sheets[current_sheet]
# Check if the sheet is being renamed and if the name does not collide
if sheet_id != sheet_data.sheet_id:
# User wants to rename
# Check if this id is invalid or being used
if (sheet_id == "") or (sheet_id in dialog_data.sheets):
# Show an error message instead
dialog_sheet_rename_error_popup.popup_centered()
# Stop here and don't make any changes
return
else:
# Rename the sheet
dialog_data.sheets.erase(current_sheet)
sheet_data.sheet_id = sheet_id
dialog_data.sheets[sheet_id] = sheet_data
current_sheet = sheet_id
# Change description
sheet_data.sheet_description = sheet_desc
# Editting sheet details (ID, description) does not change node content
# so calling open_sheet() is not required
# Update current sheet panel and listing
update_sidebar()
# Hide window
dialog_sheet_edit.hide()
#var mtdefs = MTDefs.new()
#mtdefs.debug_resource(dialog_data)
func _on_SideBar_Item_open(sheet_id) -> void:
open_sheet(sheet_id)
func _on_BtnNewSheet_pressed() -> void:
create_new_sheet()
# Open sheet edit window
_on_BtnEditSheet_pressed()
# Inform user about successful sheet creation and edit window
dialog_sheet_create_popup.popup_centered()
func _on_BtnSaveDB_pressed():
var res_path = dialog_data.resource_path
ResourceSaver.save(dialog_data, res_path, 0)
func _on_ImportExport_BtnExport_pressed() -> void:
dialog_export.set_current_sheet(current_sheet, true)
dialog_export.refresh_export_sheet_list()
dialog_export.popup_centered()
func _on_ImportExport_BtnImportSheet_pressed() -> void:
dialog_import.set_current_sheet(current_sheet)
dialog_import.reset_and_show()
func _on_dialog_import_import_executed(destination_sheet: String) -> void:
if destination_sheet != "":
open_sheet(destination_sheet)
else:
reopen_current_sheet()