diff --git a/editor/limbo_ai_editor_plugin.cpp b/editor/limbo_ai_editor_plugin.cpp index 960f7e1..5212125 100644 --- a/editor/limbo_ai_editor_plugin.cpp +++ b/editor/limbo_ai_editor_plugin.cpp @@ -809,7 +809,7 @@ void LimboAIEditor::_on_visibility_changed() { } void LimboAIEditor::_on_header_pressed() { - task_tree->deselect(); + task_tree->clear_selection(); #ifdef LIMBOAI_MODULE if (task_tree->get_bt().is_valid()) { task_tree->get_bt()->editor_set_section_unfold("blackboard_plan", true); @@ -842,35 +842,80 @@ void LimboAIEditor::_on_history_forward() { EDIT_RESOURCE(history[idx_history]); } -void LimboAIEditor::_on_task_dragged(Ref p_task, Ref p_to_task, int p_type) { - ERR_FAIL_COND(p_type < -1 || p_type > 1); - ERR_FAIL_COND(p_type != 0 && p_to_task->get_parent().is_null()); +void LimboAIEditor::_on_tasks_dragged(const TypedArray &p_tasks, Ref p_to_task, int p_to_pos) { + ERR_FAIL_COND(p_to_task.is_null()); + if (p_tasks.is_empty()) { + return; + } - if (p_task == p_to_task) { + // Filter tasks + Vector> tasks_list; + int no_effect = 0; + for (int i = 0; i < p_tasks.size(); i++) { + Ref task = p_tasks[i]; + // Count tasks that don't change position + if (task == p_to_task) { + if (Math::abs(task->get_index() - p_to_pos) <= 1) { + ++no_effect; + } + } + + // Remove descendants of selected + bool remove = false; + for (int s_idx = 0; s_idx < p_tasks.size(); s_idx++) { + Ref selected = p_tasks[s_idx]; + if (task->is_descendant_of(selected)) { + remove = true; + break; + } + } + if (!remove) { + tasks_list.push_back(task); + } + } + if (tasks_list.is_empty() || p_tasks.size() == no_effect) { return; } EditorUndoRedoManager *undo_redo = _new_undo_redo_action(TTR("Drag BT Task")); - undo_redo->add_do_method(p_task->get_parent().ptr(), LW_NAME(remove_child), p_task); - if (p_type == 0) { - undo_redo->add_do_method(p_to_task.ptr(), LW_NAME(add_child), p_task); - undo_redo->add_undo_method(p_to_task.ptr(), LW_NAME(remove_child), p_task); - } else { - int drop_idx = p_to_task->get_index(); - if (p_to_task->get_parent() == p_task->get_parent() && drop_idx > p_task->get_index()) { + // Apply changes in the task hierarchy. + int drop_idx = p_to_pos; + for (const Ref &task : tasks_list) { + if (task->get_parent() == p_to_task && drop_idx > task->get_index()) { drop_idx -= 1; } - if (p_type == -1) { - undo_redo->add_do_method(p_to_task->get_parent().ptr(), LW_NAME(add_child_at_index), p_task, drop_idx); - undo_redo->add_undo_method(p_to_task->get_parent().ptr(), LW_NAME(remove_child), p_task); - } else if (p_type == 1) { - undo_redo->add_do_method(p_to_task->get_parent().ptr(), LW_NAME(add_child_at_index), p_task, drop_idx + 1); - undo_redo->add_undo_method(p_to_task->get_parent().ptr(), LW_NAME(remove_child), p_task); + if (task == p_to_task) { + if (Math::abs(task->get_index() - p_to_pos) <= 1) { + ++drop_idx; + continue; + } } + + undo_redo->add_do_method(task->get_parent().ptr(), LW_NAME(remove_child), task); + + undo_redo->add_do_method(p_to_task.ptr(), LW_NAME(add_child_at_index), task, drop_idx); + undo_redo->add_undo_method(p_to_task.ptr(), LW_NAME(remove_child), task); + + ++drop_idx; } - undo_redo->add_undo_method(p_task->get_parent().ptr(), "add_child_at_index", p_task, p_task->get_index()); + // Readd tasks in later undo action so indexes match the old order. + drop_idx = p_to_pos; + for (const Ref &task : tasks_list) { + if (task->get_parent() == p_to_task && drop_idx > task->get_index()) { + drop_idx -= 1; + } + if (task == p_to_task) { + if (Math::abs(task->get_index() - p_to_pos) <= 1) { + ++drop_idx; + continue; + } + } + + undo_redo->add_undo_method(task->get_parent().ptr(), LW_NAME(add_child_at_index), task, task->get_index()); + ++drop_idx; + } _commit_action_with_update(undo_redo); } @@ -1383,7 +1428,7 @@ void LimboAIEditor::_notification(int p_what) { load_btn->connect(LW_NAME(pressed), callable_mp(this, &LimboAIEditor::_popup_file_dialog).bind(load_dialog)); task_tree->connect("rmb_pressed", callable_mp(this, &LimboAIEditor::_on_tree_rmb)); task_tree->connect("task_selected", callable_mp(this, &LimboAIEditor::_on_tree_task_selected)); - task_tree->connect("task_dragged", callable_mp(this, &LimboAIEditor::_on_task_dragged)); + task_tree->connect("tasks_dragged", callable_mp(this, &LimboAIEditor::_on_tasks_dragged)); task_tree->connect("task_activated", callable_mp(this, &LimboAIEditor::_on_tree_task_activated)); task_tree->connect("probability_clicked", callable_mp(this, &LimboAIEditor::_action_selected).bind(ACTION_EDIT_PROBABILITY)); task_tree->connect("visibility_changed", callable_mp(this, &LimboAIEditor::_on_visibility_changed)); diff --git a/editor/limbo_ai_editor_plugin.h b/editor/limbo_ai_editor_plugin.h index 5f53cbf..d2fb61b 100644 --- a/editor/limbo_ai_editor_plugin.h +++ b/editor/limbo_ai_editor_plugin.h @@ -239,7 +239,7 @@ private: void _on_save_pressed(); void _on_history_back(); void _on_history_forward(); - void _on_task_dragged(Ref p_task, Ref p_to_task, int p_type); + void _on_tasks_dragged(const TypedArray &p_tasks, Ref p_to_task, int p_to_pos); void _on_resources_reload(const PackedStringArray &p_resources); void _on_filesystem_changed(); void _on_new_script_pressed(); diff --git a/editor/task_tree.cpp b/editor/task_tree.cpp index 7f60119..5ae7831 100644 --- a/editor/task_tree.cpp +++ b/editor/task_tree.cpp @@ -21,11 +21,18 @@ #ifdef LIMBOAI_MODULE #include "core/object/script_language.h" #include "editor/themes/editor_scale.h" +#include "scene/gui/box_container.h" +#include "scene/gui/texture_rect.h" +#include "scene/gui/label.h" #endif // LIMBOAI_MODULE #ifdef LIMBOAI_GDEXTENSION #include #include +#include +#include +#include +#include using namespace godot; #endif // LIMBOAI_GDEXTENSION @@ -101,10 +108,7 @@ void TaskTree::_update_item(TreeItem *p_item) { } void TaskTree::_update_tree() { - Ref sel; - if (tree->get_selected()) { - sel = tree->get_selected()->get_metadata(0); - } + Vector> selection = get_selected_tasks(); tree->clear(); if (bt.is_null()) { @@ -117,9 +121,8 @@ void TaskTree::_update_tree() { updating_tree = false; } - TreeItem *item = _find_item(sel); - if (item) { - item->select(0); + for (const Ref &task : selection) { + add_selection(task); } } @@ -225,6 +228,22 @@ void TaskTree::update_task(const Ref &p_task) { } } +void TaskTree::add_selection(const Ref &p_task) { + ERR_FAIL_COND(p_task.is_null()); + TreeItem *item = _find_item(p_task); + if (item) { + item->select(0); + } +} + +void TaskTree::remove_selection(const Ref &p_task) { + ERR_FAIL_COND(p_task.is_null()); + TreeItem *item = _find_item(p_task); + if (item) { + item->deselect(0); + } +} + Ref TaskTree::get_selected() const { if (tree->get_selected()) { return tree->get_selected()->get_metadata(0); @@ -232,10 +251,29 @@ Ref TaskTree::get_selected() const { return nullptr; } -void TaskTree::deselect() { - TreeItem *sel = tree->get_selected(); - if (sel) { - sel->deselect(0); +Vector> TaskTree::get_selected_tasks() const { + Vector> selected_tasks; + TreeItem *next = tree->get_next_selected(nullptr); + while (next) { + Ref task = next->get_metadata(0); + if (task.is_valid()) { + selected_tasks.push_back(task); + } + next = tree->get_next_selected(next); + } + + return selected_tasks; +} + +void TaskTree::clear_selection() { + Vector selected_tasks; + TreeItem *next = tree->get_next_selected(nullptr); + while (next) { + Ref task = next->get_metadata(0); + if (task.is_valid()) { + remove_selection(task); + } + next = tree->get_next_selected(next); } } @@ -280,9 +318,50 @@ bool TaskTree::selected_has_probability() const { Variant TaskTree::_get_drag_data_fw(const Point2 &p_point) { if (editable && tree->get_item_at_position(p_point)) { + TypedArray selected_tasks; + Vector> icons; + TreeItem *next = tree->get_next_selected(nullptr); + while (next) { + Ref task = next->get_metadata(0); + if (task.is_valid()) { + selected_tasks.push_back(task); + icons.push_back(next->get_icon(0)); + } + next = tree->get_next_selected(next); + } + + if (selected_tasks.is_empty()) { + return Variant(); + } + + VBoxContainer *vb = memnew(VBoxContainer); + int list_max = 10; + float opacity_step = 1.0f / list_max; + float opacity_item = 1.0f; + for (int i = 0; i < selected_tasks.size(); i++) { + Ref task = Object::cast_to(selected_tasks[i]); + if (i < list_max) { + HBoxContainer *hb = memnew(HBoxContainer); + TextureRect *tf = memnew(TextureRect); + int icon_size = get_theme_constant(LW_NAME(class_icon_size), LW_NAME(Editor)); + tf->set_custom_minimum_size(Size2(icon_size, icon_size)); + tf->set_stretch_mode(TextureRect::STRETCH_KEEP_ASPECT_CENTERED); + tf->set_expand_mode(TextureRect::EXPAND_IGNORE_SIZE); + tf->set_texture(icons[i]); + hb->add_child(tf); + Label *label = memnew(Label); + label->set_text(task->get_task_name()); + hb->add_child(label); + vb->add_child(hb); + hb->set_modulate(Color(1, 1, 1, opacity_item)); + opacity_item -= opacity_step; + } + } + set_drag_preview(vb); + Dictionary drag_data; drag_data["type"] = "task"; - drag_data["task"] = tree->get_item_at_position(p_point)->get_metadata(0); + drag_data["tasks"] = selected_tasks; tree->set_drop_mode_flags(Tree::DROP_MODE_INBETWEEN | Tree::DROP_MODE_ON_ITEM); return drag_data; } @@ -295,7 +374,7 @@ bool TaskTree::_can_drop_data_fw(const Point2 &p_point, const Variant &p_data) c } Dictionary d = p_data; - if (!d.has("type") || !d.has("task")) { + if (!d.has("type") || !d.has("tasks")) { return false; } @@ -305,27 +384,79 @@ bool TaskTree::_can_drop_data_fw(const Point2 &p_point, const Variant &p_data) c return false; } - if (!item->get_parent() && section != 0) { // before/after root item + if (!item->get_parent() && section != 0) { // Before/after root item. return false; } if (String(d["type"]) == "task") { - Ref task = d["task"]; - const Ref to_task = item->get_metadata(0); - if (task != to_task && !to_task->is_descendant_of(task)) { - return true; + TypedArray tasks = d["tasks"]; + if (tasks.is_empty()) { + return false; // No tasks. + } + for (int i = 0; i < tasks.size(); i++) { + Ref task = tasks[i]; + const Ref to_task = item->get_metadata(0); + if (to_task->is_descendant_of(task) || task == to_task || + (task == to_task && task->get_index() + section >= to_task->get_index() && !item->is_collapsed() && item->get_child_count() > 0)) { + return false; // Don't drop as child of itself. + } } } - return false; + return true; } void TaskTree::_drop_data_fw(const Point2 &p_point, const Variant &p_data) { Dictionary d = p_data; TreeItem *item = tree->get_item_at_position(p_point); - if (item && d.has("task")) { - Ref task = d["task"]; - emit_signal(LW_NAME(task_dragged), task, item->get_metadata(0), tree->get_drop_section_at_position(p_point)); + int type = tree->get_drop_section_at_position(p_point); + ERR_FAIL_NULL(item); + ERR_FAIL_COND(type < -1 || type > 1); + + if (item && d.has("tasks")) { + TypedArray tasks = d["tasks"]; + int to_pos = -1; + Ref to_task = item->get_metadata(0); + ERR_FAIL_COND(to_task.is_null()); + + // The drop behavior depends on the TreeItem's state. + // Normalize and emit the parent task and position instead of exposing TreeItem. + switch (type) { + case 0: // Drop as last child of target. + to_pos = to_task->get_child_count(); + break; + case -1: // Drop above target. + ERR_FAIL_COND_MSG(to_task->get_parent().is_null(), "Cannot perform drop above the root task!"); + to_pos = MAX(0, to_task->get_index() - 1); + to_task = to_task->get_parent(); + break; + case 1: // Drop below target. + if (item->get_child_count() == 0) { + to_pos = to_task->get_index() + 1; + to_task = to_task->get_parent(); + break; + } + + if (to_task->get_parent().is_null() || !item->is_collapsed()) { // Insert as first child of target. + to_pos = 0; + } else { // Insert as sibling of target. + TreeItem *lower_sibling = nullptr; + for (int i = to_task->get_index() + 1; i < to_task->get_parent()->get_child_count(); i++) { + TreeItem *c = item->get_parent()->get_child(i); + if (c->is_visible_in_tree()) { + lower_sibling = c; + break; + } + } + if (lower_sibling) { + to_pos = lower_sibling->get_index(); + } + + to_task = to_task->get_parent(); + } + break; + } + emit_signal(LW_NAME(tasks_dragged), tasks, to_task, to_pos); } } @@ -385,7 +516,7 @@ void TaskTree::_notification(int p_what) { case NOTIFICATION_READY: { tree->connect("item_mouse_selected", callable_mp(this, &TaskTree::_on_item_mouse_selected)); // Note: CONNECT_DEFERRED is needed to avoid double updates with set_allow_reselect(true), which breaks folding/unfolding. - tree->connect("item_selected", callable_mp(this, &TaskTree::_on_item_selected), CONNECT_DEFERRED); + tree->connect("multi_selected", callable_mp(this, &TaskTree::_on_item_selected).unbind(3), CONNECT_DEFERRED); tree->connect("item_activated", callable_mp(this, &TaskTree::_on_item_activated)); tree->connect("item_collapsed", callable_mp(this, &TaskTree::_on_item_collapsed)); } break; @@ -401,8 +532,10 @@ void TaskTree::_bind_methods() { ClassDB::bind_method(D_METHOD("get_bt"), &TaskTree::get_bt); ClassDB::bind_method(D_METHOD("update_tree"), &TaskTree::update_tree); ClassDB::bind_method(D_METHOD("update_task", "task"), &TaskTree::update_task); + ClassDB::bind_method(D_METHOD("add_selection", "task"), &TaskTree::add_selection); + ClassDB::bind_method(D_METHOD("remove_selection", "task"), &TaskTree::remove_selection); ClassDB::bind_method(D_METHOD("get_selected"), &TaskTree::get_selected); - ClassDB::bind_method(D_METHOD("deselect"), &TaskTree::deselect); + ClassDB::bind_method(D_METHOD("clear_selection"), &TaskTree::clear_selection); ClassDB::bind_method(D_METHOD("_get_drag_data_fw"), &TaskTree::_get_drag_data_fw); ClassDB::bind_method(D_METHOD("_can_drop_data_fw"), &TaskTree::_can_drop_data_fw); @@ -413,8 +546,7 @@ void TaskTree::_bind_methods() { ADD_SIGNAL(MethodInfo("task_selected")); ADD_SIGNAL(MethodInfo("task_activated")); ADD_SIGNAL(MethodInfo("probability_clicked")); - ADD_SIGNAL(MethodInfo("task_dragged", - PropertyInfo(Variant::OBJECT, "task", PROPERTY_HINT_RESOURCE_TYPE, "BTTask"), + ADD_SIGNAL(MethodInfo("tasks_dragged", PropertyInfo(Variant::ARRAY, "tasks", PROPERTY_HINT_ARRAY_TYPE, RESOURCE_TYPE_HINT("BTTask")), PropertyInfo(Variant::OBJECT, "to_task", PROPERTY_HINT_RESOURCE_TYPE, "BTTask"), PropertyInfo(Variant::INT, "type"))); } @@ -432,6 +564,7 @@ TaskTree::TaskTree() { tree->set_anchor(SIDE_BOTTOM, ANCHOR_END); tree->set_allow_rmb_select(true); tree->set_allow_reselect(true); + tree->set_select_mode(Tree::SelectMode::SELECT_MULTI); tree->set_drag_forwarding(callable_mp(this, &TaskTree::_get_drag_data_fw), callable_mp(this, &TaskTree::_can_drop_data_fw), callable_mp(this, &TaskTree::_drop_data_fw)); } diff --git a/editor/task_tree.h b/editor/task_tree.h index cbea5b5..373f15d 100644 --- a/editor/task_tree.h +++ b/editor/task_tree.h @@ -90,8 +90,11 @@ public: Ref get_bt() const { return bt; } void update_tree() { _update_tree(); } void update_task(const Ref &p_task); + void add_selection(const Ref &p_task); + void remove_selection(const Ref &p_task); Ref get_selected() const; - void deselect(); + Vector> get_selected_tasks() const; + void clear_selection(); Rect2 get_selected_probability_rect() const; double get_selected_probability_weight() const; diff --git a/util/limbo_string_names.cpp b/util/limbo_string_names.cpp index 0f5bab8..5b0d80a 100644 --- a/util/limbo_string_names.cpp +++ b/util/limbo_string_names.cpp @@ -78,6 +78,7 @@ LimboStringNames::LimboStringNames() { HeaderSmall = SN("HeaderSmall"); Help = SN("Help"); icon_max_width = SN("icon_max_width"); + class_icon_size = SN("class_icon_size"); id_pressed = SN("id_pressed"); Info = SN("Info"); item_collapsed = SN("item_collapsed"); @@ -127,7 +128,7 @@ LimboStringNames::LimboStringNames() { task_activated = SN("task_activated"); task_button_pressed = SN("task_button_pressed"); task_button_rmb = SN("task_button_rmb"); - task_dragged = SN("task_dragged"); + tasks_dragged = SN("tasks_dragged"); task_meta = SN("task_meta"); task_selected = SN("task_selected"); text_changed = SN("text_changed"); diff --git a/util/limbo_string_names.h b/util/limbo_string_names.h index 21de30e..a9237fc 100644 --- a/util/limbo_string_names.h +++ b/util/limbo_string_names.h @@ -94,6 +94,7 @@ public: StringName HeaderSmall; StringName Help; StringName icon_max_width; + StringName class_icon_size; StringName id_pressed; StringName Info; StringName item_collapsed; @@ -143,7 +144,7 @@ public: StringName task_activated; StringName task_button_pressed; StringName task_button_rmb; - StringName task_dragged; + StringName tasks_dragged; StringName task_meta; StringName task_selected; StringName text_changed;