更新自研插件

This commit is contained in:
2026-10-04 19:27:54 +08:00
parent 604612baac
commit a17800db9c
7 changed files with 275 additions and 147 deletions
+223 -12
View File
@@ -11,26 +11,55 @@ extends EditorPlugin
## Every change goes through the editor's undo system, so Ctrl+Z reverts an
## injection and Ctrl+Shift+Z re-applies it.
##
## SCOPE - this plugin touches no editor UI beyond its own menu entries.
## SCOPE - a tool menu and one confirmation dialog. No dock panel, no overlay.
##
## There is no dock panel, no dialog, no overlay and no per-frame work. Earlier
## versions had all four, and each of them caused trouble that could not be
## reproduced or diagnosed from here:
## Earlier versions did more, and every extra piece caused trouble that could not
## be reproduced or diagnosed from here:
##
## * a per-frame scan of the whole edited scene (heavy on a large scene), and a
## SceneTree.node_added hook that made it run even more often;
## * `AcceptDialog` / `PopupPanel` / a hand-built overlay, every one of which
## opened thousands of pixels too tall;
## * a dock panel whose wrapping labels widened its dock column and pushed the
## editor's own docks and the viewport out of the window.
## * a dock panel, which brought its own layout problems and only ever mirrored
## Project Settings > mesh_collider anyway.
##
## So discovery is now explicit (a menu action) and reports to the Output panel,
## and all configuration lives in Project Settings > mesh_collider. Nothing here
## participates in the editor's layout.
## So configuration has exactly one home - Project Settings > mesh_collider, with
## the menu's "打开项目设置" to get there - and the scene is watched on a 0.4 s
## timer whose walk is skipped outright whenever the tree has not changed at all
## (see `POLL_SECONDS`). The one dialog left is given an explicit size instead of
## being allowed to size itself to its label.
const Collider := preload("mesh_collider.gd")
const Settings := preload("mesh_collider_settings.gd")
## Seconds between two looks at the edited scene.
##
## Godot gives a plugin no signal for "a model was just dragged in" - the editor
## handles the drop internally and never tells anyone - so the scene has to be
## watched. An earlier version of this plugin scanned every frame and made the
## editor sluggish on a large scene; this one is bounded two ways:
##
## * `SceneTree.get_node_count()` is O(1), and an unchanged count means nothing
## was added or removed anywhere, so the walk is skipped outright - an idle
## editor does no work at all;
## * the walk only ever visits the edited scene.
const POLL_SECONDS := 0.4
var _timer: Timer = null
## Instance ids of the model instances already present in the edited scene.
## Anything in here has been seen, and will not be asked about again.
var _known: Dictionary = {}
## The scene the entries in `_known` belong to.
var _scene_root: Node = null
var _last_node_count := -1
var _dialog: ConfirmationDialog = null
var _message: Label = null
var _remember: CheckBox = null
## Models waiting for the dialog's answer. A further drop while the dialog is
## open joins this batch instead of opening a second dialog.
var _pending: Array[Node] = []
const MENU_SCAN := "Mesh Collider: 扫描场景中的模型 / Scan scene for models"
const MENU_ADD := "Mesh Collider: 给选中模型加碰撞 / Add collision to selected"
const MENU_REMOVE := "Mesh Collider: 移除选中模型的碰撞 / Remove collision"
@@ -43,8 +72,15 @@ func _enter_tree() -> void:
add_tool_menu_item(MENU_ADD, _add_pressed)
add_tool_menu_item(MENU_REMOVE, _remove_pressed)
add_tool_menu_item(MENU_SETTINGS, _open_settings)
_timer = Timer.new()
_timer.wait_time = POLL_SECONDS
_timer.autostart = true
_timer.timeout.connect(_poll)
add_child(_timer)
print("[MeshCollider] ready. All actions are under 项目 > 工具; settings are in "
+ "Project Settings > mesh_collider. No dock panel, no dialogs.")
+ "Project Settings > mesh_collider.")
func _exit_tree() -> void:
@@ -52,6 +88,13 @@ func _exit_tree() -> void:
remove_tool_menu_item(MENU_ADD)
remove_tool_menu_item(MENU_REMOVE)
remove_tool_menu_item(MENU_SETTINGS)
if _timer != null:
_timer.stop()
_timer.queue_free()
_timer = null
if _dialog != null:
_dialog.queue_free()
_dialog = null
# ---------------------------------------------------------------------------
@@ -107,7 +150,8 @@ func _scan_pressed() -> void:
_add_all(pending)
else:
print("[MeshCollider] 未改动场景。选中模型后用 项目 > 工具 > "
+ "『给选中模型加碰撞』处理,或打开设置里的『扫描后自动添加』。")
+ "『给选中模型加碰撞』处理,或把 项目设置 → mesh_collider 里的 "
+ "auto_add_collision 打开。")
func _add_all(pending: Array[Node]) -> void:
@@ -220,6 +264,158 @@ func _find_added_body(model: Node) -> Node:
return null
# ---------------------------------------------------------------------------
# Drag-in detection
# ---------------------------------------------------------------------------
func _poll() -> void:
var root: Node = get_editor_interface().get_edited_scene_root()
if root != _scene_root:
# A different scene is open. Whatever we knew belonged to the old one, and
# what is already in the new one is taken as the baseline: opening a scene
# is not "dragging a model in", and prompting for every model of a freshly
# opened scene would be intolerable.
_scene_root = root
_known.clear()
if root != null:
var ignored: Array[Node] = []
_collect_new_models(root, ignored)
return
if root == null:
return
# Nothing was added or removed anywhere in the editor, so the scene cannot
# have gained a model. This is the O(1) check that makes the timer free when
# the editor is idle.
var count := get_tree().get_node_count()
if count == _last_node_count:
return
_last_node_count = count
var fresh: Array[Node] = []
_collect_new_models(root, fresh)
if fresh.is_empty():
return
if Settings.get_bool(Settings.KEY_ASK):
_ask(fresh)
elif Settings.get_bool(Settings.KEY_AUTO):
_add_all(fresh)
# Neither: asking was turned off and auto is off too, so a drop is left alone
# and stays reachable under 项目 > 工具.
## Record every model instance under `node` we have not seen before, and append
## the ones that still need collision to `out`. Recording happens whether or not
## a model is acted on, so a model the user declined is not offered twice.
func _collect_new_models(node: Node, out: Array[Node]) -> void:
for child in node.get_children():
if child is Node3D and not _known.has(child.get_instance_id()):
_known[child.get_instance_id()] = true
if _is_pending_model(child):
out.append(child)
_collect_new_models(child, out)
# ---------------------------------------------------------------------------
# The drag-in dialog
# ---------------------------------------------------------------------------
## Ask about a batch of freshly dragged-in models. The answer applies to the whole
## batch, so dropping five files asks once.
func _ask(models: Array[Node]) -> void:
if _dialog == null:
_build_dialog()
if _dialog.visible:
_pending.append_array(models)
return
_pending = models.duplicate()
_remember.button_pressed = false
_message.text = _ask_text(_pending)
# An explicit width, because a dialog that sizes itself to its label is how an
# earlier version of this plugin ended up opening thousands of pixels tall.
_dialog.popup_centered(Vector2i(440, 0))
func _ask_text(models: Array[Node]) -> String:
if models.size() == 1:
return ("拖入的模型『%s』还没有碰撞体。\n现在给它加上吗?"
% models[0].name)
return ("拖入的 %d 个模型还没有碰撞体。\n现在给它们都加上吗?" % models.size())
func _build_dialog() -> void:
_dialog = ConfirmationDialog.new()
_dialog.title = "Mesh Collider"
_dialog.ok_button_text = "添加碰撞"
_dialog.cancel_button_text = "不添加"
_dialog.exclusive = true
# Godot draws control text through the editor's translation domain, which
# would rewrite our own strings with whatever the editor's locale calls them.
# Off for the dialog, so it always says what this file says.
_dialog.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
# The message and the checkbox go into a container of our own rather than into
# `dialog_text` plus a bare `add_child`. An `AcceptDialog` stretches any extra
# child over its whole content area instead of laying it out under the label,
# so the checkbox ends up drawn on top of the message.
#
# The cost is one empty line: `dialog_text` stays empty but the dialog's own
# label still reserves its height, and hiding it does not help - AcceptDialog
# computes its minimum size without looking at visibility.
var body := VBoxContainer.new()
body.name = "Body"
body.add_theme_constant_override("separation", 10)
_message = Label.new()
_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_message.custom_minimum_size = Vector2(360, 0)
_message.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
body.add_child(_message)
_remember = CheckBox.new()
_remember.text = "不再询问"
_remember.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
_remember.tooltip_text = ("记住这次的选择:选『添加碰撞』以后直接加,"
+ "选『不添加』以后不再加也不再问。\n"
+ "想恢复询问,去 项目设置 → mesh_collider 把 ask_before_adding 重新打开。")
body.add_child(_remember)
_dialog.add_child(body)
add_child(_dialog)
_dialog.confirmed.connect(_on_ask_confirmed)
_dialog.canceled.connect(_on_ask_canceled)
func _on_ask_confirmed() -> void:
_finish_ask(true)
func _on_ask_canceled() -> void:
_finish_ask(false)
func _finish_ask(accepted: bool) -> void:
var batch := _pending
_pending = []
if accepted:
_add_all(batch)
if not _remember.button_pressed:
return
# "不再询问" is an explicit decision that has to outlive the editor session, so
# this one is written out now; everything else is left to the editor's own
# save. Two settings are enough to remember it - stop asking, and let
# `auto_add_collision` carry the answer that was given.
Settings.set_value(Settings.KEY_ASK, false)
Settings.set_value(Settings.KEY_AUTO, accepted)
var err := ProjectSettings.save()
if err == OK:
print("[MeshCollider] 已记住选择:%s,以后不再询问。"
% ("拖入模型时直接加碰撞" if accepted else "拖入模型时不加碰撞"))
else:
push_warning("[MeshCollider] 保存设置失败,错误码 %d。" % err)
# ---------------------------------------------------------------------------
# Menu actions
# ---------------------------------------------------------------------------
@@ -253,7 +449,22 @@ func _selected_models() -> Array[Node]:
return out
## Open Project Settings > mesh_collider.
##
## 4.7 has no API for this. `EditorInterface` offers nothing, and the obvious
## `set_main_screen_editor("Project")` fails outright - "The editor name
## 'Project' was not found" - because Project Settings is a dialog, not one of
## the main screens. So the editor's own dialog node is looked up by class and
## popped up. If a future editor renames or moves it, this degrades to printing
## where the settings live rather than breaking.
func _open_settings() -> void:
get_editor_interface().call_deferred("set_main_screen_editor", "Project")
var stack: Array[Node] = [get_tree().root]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node.get_class() == "ProjectSettingsEditor":
node.call("popup_centered_clamped", Vector2i(1100, 760))
return
for child in node.get_children():
stack.append(child)
print("[MeshCollider] 设置项在 项目设置 > mesh_collider / "
+ "settings are under Project Settings > mesh_collider")