19. 插件开发
2026/4/14大约 13 分钟
插件开发
你有没有发现,Godot 编辑器本身就是一个用 Godot 做出来的程序?这意味着你可以用 Godot 自己来扩展 Godot——这就是插件开发。
打个比方:Godot 编辑器就像一个毛坯房,插件就像装修方案。你可以给编辑器加新面板、新工具、新菜单、新功能,让它变成适合你工作流的"定制版编辑器"。
为什么开发插件
| 场景 | 插件能做什么 |
|---|---|
| 重复劳动太多 | 写一个一键生成 UI 的工具,不用每次手动拖节点 |
| 团队协作 | 写一个场景检查器,自动检测命名规范和标签设置 |
| 美术工具 | 写一个批量导入纹理并自动切割 Sprite Sheet 的工具 |
| 数据分析 | 写一个面板,实时显示游戏中的各种统计数据 |
| 快捷操作 | 写一个批量重命名节点、批量设置属性的脚本 |
插件的核心价值
把重复的工作自动化。 如果你发现自己在编辑器里反复做同样的操作超过 3 次,就该考虑写个插件了。
EditorPlugin 基础——创建自定义面板
所有 Godot 编辑器插件的起点都是 EditorPlugin 类。它提供了钩子(Hook),让你可以在编辑器的特定时机注入自定义代码。
创建插件的第一步
- 在 Godot 编辑器中,点击菜单 Project -> Project Settings -> Plugins
- 点击 Create New Plugin
- 填写插件信息:
- Name:插件名称(比如
my_custom_tool) - Subfolder:子目录名
- Description:插件描述
- Author:作者名
- Version:版本号
- Name:插件名称(比如
- 点击 Create,Godot 会自动生成插件的基本文件结构
插件文件结构
addons/
└── my_custom_tool/
├── plugin.cfg # 插件配置文件(是否启用等)
└── plugin.gd # 插件主脚本最简单的插件
C
using Godot;
[Tool]
public partial class MyCustomTool : EditorPlugin
{
// 插件被启用时调用
public override void _EnterTree()
{
GD.Print("我的自定义工具插件已启用!");
// 在这里添加自定义功能
// 比如:添加面板、添加菜单项、添加快捷键等
}
// 插件被禁用时调用
public override void _ExitTree()
{
GD.Print("我的自定义工具插件已禁用!");
// 在这里清理所有添加的东西
// 不清理的话,禁用插件后编辑器里会留下"幽灵"
}
}GDScript
@tool
extends EditorPlugin
# 插件被启用时调用
func _enter_tree() -> void:
print("我的自定义工具插件已启用!")
# 在这里添加自定义功能
# 比如:添加面板、添加菜单项、添加快捷键等
# 插件被禁用时调用
func _exit_tree() -> void:
print("我的自定义工具插件已禁用!")
# 在这里清理所有添加的东西
# 不清理的话,禁用插件后编辑器里会留下"幽灵"注意
脚本文件第一行必须有 @tool 注解(GDScript)或 [Tool] 特性(C#),表示这个脚本在编辑器模式下也能运行。没有这个注解,插件代码只会在游戏运行时执行,在编辑器里不起作用。
添加自定义底部面板
Godot 编辑器的底部有 Output、Debug 等面板。你可以添加自己的面板。
C
using Godot;
[Tool]
public partial class GameDataPanel : EditorPlugin
{
private Control _panelControl;
public override void _EnterTree()
{
// 创建面板 UI(加载一个场景文件)
_panelControl = GD.Load<PackedScene>(
"res://addons/game_data_panel/GameDataPanel.tscn"
).Instantiate<Control>();
// 添加到底部面板
AddControlToBottomPanel(_panelControl, "游戏数据");
GD.Print("游戏数据面板已添加");
}
public override void _ExitTree()
{
// 移除面板(必须做!)
if (_panelControl != null)
{
RemoveControlFromBottomPanel(_panelControl);
_panelControl.QueueFree();
}
}
}GDScript
@tool
extends EditorPlugin
var _panel_control: Control
func _enter_tree() -> void:
# 创建面板 UI(加载一个场景文件)
_panel_control = load(
"res://addons/game_data_panel/GameDataPanel.tscn"
).instantiate() as Control
# 添加到底部面板
add_control_to_bottom_panel(_panel_control, "游戏数据")
print("游戏数据面板已添加")
func _exit_tree() -> void:
# 移除面板(必须做!)
if _panel_control:
remove_control_from_bottom_panel(_panel_control)
_panel_control.queue_free()添加自定义侧边栏面板
如果你想创建一个和 Inspector(右侧面板)类似的面板,可以用侧边栏:
C
using Godot;
[Tool]
public partial class QuickActionsPanel : EditorPlugin
{
private Control _panelControl;
public override void _EnterTree()
{
_panelControl = GD.Load<PackedScene>(
"res://addons/quick_actions/QuickActionsPanel.tscn"
).Instantiate<Control>();
// 添加到侧边栏(左侧,和 Scene 树并列)
AddControlToDock(DockSlot.LeftUl, _panelControl);
}
public override void _ExitTree()
{
if (_panelControl != null)
{
RemoveControlFromDocks(_panelControl);
_panelControl.QueueFree();
}
}
}GDScript
@tool
extends EditorPlugin
var _panel_control: Control
func _enter_tree() -> void:
_panel_control = load(
"res://addons/quick_actions/QuickActionsPanel.tscn"
).instantiate() as Control
# 添加到侧边栏(左侧,和 Scene 树并列)
add_control_to_dock(DOCK_SLOT_LEFT_UL, _panel_control)
func _exit_tree() -> void:
if _panel_control:
remove_control_from_docks(_panel_control)
_panel_control.queue_free()自定义 Inspector 属性
Godot 的 Inspector 面板可以显示自定义属性。你可以用 @export 和特性来控制属性的显示方式。
常用的导出特性
C
using Godot;
[Tool]
public partial class EnemyConfig : Resource
{
// 基础导出
[Export] public string EnemyName { get; set; } = "哥布林";
// 带范围的数值(滑块)
[Export(PropertyHint.Range, "1,100")]
public int MaxHp { get; set; } = 50;
// 带范围和步进的浮点数
[Export(PropertyHint.Range, "0.5,10,0.5")]
public float MoveSpeed { get; set; } = 2.0f;
// 下拉选择
[Export(PropertyHint.Enum, "普通,精英,Boss")]
public int EnemyType { get; set; } = 0;
// 多选枚举
[Export(PropertyHint.Flags, "火,冰,雷,毒,圣")]
public int ElementalWeakness { get; set; } = 0;
// 颜色选择器
[Export] public Color BodyColor { get; set; } = Colors.Green;
// 文件路径(指定类型)
[Export] public Texture2D Icon { get; set; }
// 多行文本
[Export(PropertyHint.MultilineText)]
public string Description { get; set; } = "一只普通的哥布林";
// 节点路径
[Export] public NodePath AttackTarget { get; set; }
// 资源类型限制
[Export] public EnemyDropTable DropTable { get; set; }
// 分组
[ExportGroup("战斗属性")]
[Export(PropertyHint.Range, "1,999")]
public int Attack { get; set; } = 10;
[Export(PropertyHint.Range, "0,100")]
public int Defense { get; set; } = 5;
[ExportGroup("掉落配置")]
[Export(PropertyHint.Range, "0,1,0.01")]
public float DropRate { get; set; } = 0.3f;
[Export]
public PackedScene[] PossibleDrops { get; set; } = new PackedScene[0];
}GDScript
@tool
extends Resource
class_name EnemyConfig
# 基础导出
@export var enemy_name: String = "哥布林"
# 带范围的数值(滑块)
@export_range(1, 100) var max_hp: int = 50
# 带范围和步进的浮点数
@export_range(0.5, 10, 0.5) var move_speed: float = 2.0
# 下拉选择
@export_enum("普通", "精英", "Boss") var enemy_type: int = 0
# 多选枚举
@export_flags("火", "冰", "雷", "毒", "圣") var elemental_weakness: int = 0
# 颜色选择器
@export var body_color: Color = Color.GREEN
# 文件路径
@export var icon: Texture2D
# 多行文本
@export_multiline var description: String = "一只普通的哥布林"
# 节点路径
@export_node_path("CharacterBody2D") var attack_target: NodePath
# 资源类型限制
@export var drop_table: EnemyDropTable
# 分组
@export_group("战斗属性")
@export_range(1, 999) var attack: int = 10
@export_range(0, 100) var defense: int = 5
@export_group("掉落配置")
@export_range(0, 1, 0.01) var drop_rate: float = 0.3
@export var possible_drops: Array[PackedScene] = []自定义 Inspector 绘制
如果你想要更复杂的 Inspector UI(比如带按钮、图表、预览窗口),可以重写编辑器插件的 _InspectorPlugin。
C
using Godot;
[Tool]
public partial class CustomInspectorPlugin : EditorInspectorPlugin
{
public override bool _CanHandle(GodotObject @object)
{
// 只处理 EnemyConfig 类型的资源
return @object is EnemyConfig;
}
public override bool _ParseProperty(GodotObject @object, Variant.Type type,
string name, PropertyHint hintType, string hintString,
bool wide, PropertyUsageFlags usage, bool readOnly)
{
// 如果属性名是 "drop_table",用自定义的编辑器替换默认的
if (name == "DropTable")
{
var editor = new DropTableEditor();
AddPropertyEditor(name, editor);
return true; // 表示我们处理了这个属性
}
return false; // 其他属性用默认的 Inspector
}
}
// 自定义的 DropTable 编辑器
[Tool]
public partial class DropTableEditor : EditorProperty
{
private Button _editButton;
public override void _EnterTree()
{
_editButton = new Button { Text = "编辑掉落表..." };
_editButton.Pressed += OnButtonPressed;
AddChild(_editButton);
}
private void OnButtonPressed()
{
// 打开一个自定义的掉落表编辑窗口
var window = GD.Load<PackedScene>(
"res://addons/custom_inspector/DropTableEditorWindow.tscn"
).Instantiate<Window>();
EditorInterface.Singleton.GetBaseControl().AddChild(window);
window.PopupCentered(new Vector2I(600, 400));
}
}GDScript
@tool
extends EditorInspectorPlugin
func _can_handle(object: Object) -> bool:
# 只处理 EnemyConfig 类型的资源
return object is EnemyConfig
func _parse_property(object: Object, type: Variant.Type, name: String,
hint_type: PropertyHint, hint_string: String, usage_flags: int,
wide: bool) -> bool:
# 如果属性名是 "drop_table",用自定义编辑器替换
if name == "drop_table":
var editor = DropTableEditor.new()
add_property_editor(name, editor)
return true
return false
# 自定义的 DropTable 编辑器
class DropTableEditor extends EditorProperty:
var _edit_button: Button
func _enter_tree() -> void:
_edit_button = Button.new()
_edit_button.text = "编辑掉落表..."
_edit_button.pressed.connect(_on_button_pressed)
add_child(_edit_button)
func _on_button_pressed() -> void:
# 打开一个自定义的掉落表编辑窗口
var window = load(
"res://addons/custom_inspector/DropTableEditorWindow.tscn"
).instantiate() as Window
EditorInterface.get_base_control().add_child(window)
window.popup_centered(Vector2i(600, 400))自动化工具——批量处理场景和资源
插件最常见的用途之一就是批量处理——一次性修改多个文件,省去手动操作。
批量重命名节点
C
using Godot;
using System.Linq;
[Tool]
public partial class BatchRenamePlugin : EditorPlugin
{
public override void _EnterTree()
{
// 添加一个工具菜单项
AddToolMenuItem("批量重命名节点/驼峰转下划线", Callable.From(BatchRenameCamelToSnake));
AddToolMenuItem("批量重命名节点/添加前缀", Callable.From(AddPrefixToSelected));
}
public override void _ExitTree()
{
RemoveToolMenuItem("批量重命名节点/驼峰转下划线");
RemoveToolMenuItem("批量重命名节点/添加前缀");
}
// 驼峰命名转下划线命名:PlayerController -> player_controller
private void BatchRenameCamelToSnake()
{
var selected = EditorInterface.Singleton.GetSelectedNodes();
int renamed = 0;
foreach (Node node in selected)
{
string newName = CamelToSnake(node.Name);
if (newName != node.Name)
{
node.Name = newName;
renamed++;
}
}
EditorInterface.Singleton.GetInspector().Refresh();
GD.Print($"批量重命名完成,共重命名 {renamed} 个节点");
}
// 给选中的节点添加前缀
private void AddPrefixToSelected()
{
var selected = EditorInterface.Singleton.GetSelectedNodes();
string prefix = "UI_";
foreach (Node node in selected)
{
if (!node.Name.ToString().StartsWith(prefix))
{
node.Name = prefix + node.Name;
}
}
EditorInterface.Singleton.GetInspector().Refresh();
GD.Print($"已为 {selected.Count} 个节点添加前缀 {prefix}");
}
// 驼峰转下划线的辅助方法
private string CamelToSnake(StringName input)
{
string str = input.ToString();
var result = new System.Text.StringBuilder();
foreach (char c in str)
{
if (char.IsUpper(c) && result.Length > 0)
{
result.Append('_');
}
result.Append(char.ToLower(c));
}
return result.ToString();
}
}GDScript
@tool
extends EditorPlugin
func _enter_tree() -> void:
# 添加工具菜单项
add_tool_menu_item("批量重命名节点/驼峰转下划线", _batch_rename_camel_to_snake)
add_tool_menu_item("批量重命名节点/添加前缀", _add_prefix_to_selected)
func _exit_tree() -> void:
remove_tool_menu_item("批量重命名节点/驼峰转下划线")
remove_tool_menu_item("批量重命名节点/添加前缀")
# 驼峰转下划线:PlayerController -> player_controller
func _batch_rename_camel_to_snake() -> void:
var selected := EditorInterface.get_selected_nodes()
var renamed := 0
for node in selected:
var new_name := _camel_to_snake(node.name)
if new_name != node.name:
node.name = new_name
renamed += 1
EditorInterface.get_inspector().refresh()
print("批量重命名完成,共重命名 %d 个节点" % renamed)
# 给选中的节点添加前缀
func _add_prefix_to_selected() -> void:
var selected := EditorInterface.get_selected_nodes()
var prefix := "UI_"
for node in selected:
if not node.name.begins_with(prefix):
node.name = prefix + node.name
EditorInterface.get_inspector().refresh()
print("已为 %d 个节点添加前缀 %s" % [selected.size(), prefix])
# 驼峰转下划线辅助方法
func _camel_to_snake(input: String) -> String:
var result := ""
for i in range(input.length()):
var c := input[i]
if c >= 'A' and c <= 'Z' and result.length() > 0:
result += "_"
result += c.to_lower()
return result批量检查场景规范
C
using Godot;
using System.Linq;
[Tool]
public partial class SceneCheckerPlugin : EditorPlugin
{
public override void _EnterTree()
{
AddToolMenuItem("场景检查/检查选中场景", Callable.From(CheckSelectedScene));
AddToolMenuItem("场景检查/检查所有场景", Callable.From(CheckAllScenes));
}
public override void _ExitTree()
{
RemoveToolMenuItem("场景检查/检查选中场景");
RemoveToolMenuItem("场景检查/检查所有场景");
}
private void CheckSelectedScene()
{
var currentScene = EditorInterface.Singleton.GetCurrentScene();
if (currentScene == null)
{
GD.PrintErr("没有打开的场景");
return;
}
var issues = AnalyzeScene(currentScene);
PrintReport(currentScene.Name, issues);
}
private void CheckAllScenes()
{
string scenesDir = "res://scenes/";
var dir = DirAccess.Open(scenesDir);
if (dir == null)
{
GD.PrintErr("找不到场景目录");
return;
}
dir.ListDirBegin();
int totalIssues = 0;
while (true)
{
string file = dir.GetNext();
if (string.IsNullOrEmpty(file)) break;
if (!file.EndsWith(".tscn") && !file.EndsWith(".scn")) continue;
var scene = GD.Load<PackedScene>(scenesDir + file);
if (scene == null) continue;
var instance = scene.Instantiate();
var issues = AnalyzeScene(instance);
totalIssues += issues.Count;
PrintReport(file, issues);
instance.QueueFree();
}
GD.Print($"\n检查完成!共发现 {totalIssues} 个问题");
}
private System.Collections.Generic.List<string> AnalyzeScene(Node root)
{
var issues = new System.Collections.Generic.List<string>();
foreach (Node child in root.GetChildren())
{
// 检查 1:节点命名是否用下划线
if (child.Name.ToString().Contains(" "))
{
issues.Add($"节点 '{child.Name}' 包含空格,建议用下划线");
}
// 检查 2:CollisionShape 是否有父物理体
if (child is CollisionShape2D && child.GetParent() is not PhysicsBody2D)
{
issues.Add($"CollisionShape2D '{child.Name}' 的父节点不是物理体");
}
// 检查 3:Sprite 是否有纹理
if (child is Sprite2D sprite && sprite.Texture == null)
{
issues.Add($"Sprite2D '{child.Name}' 没有设置纹理");
}
// 递归检查子节点
issues.AddRange(AnalyzeScene(child));
}
return issues;
}
private void PrintReport(string sceneName, System.Collections.Generic.List<string> issues)
{
if (issues.Count == 0)
{
GD.Print($"[OK] {sceneName}:无问题");
}
else
{
GD.Print($"[问题] {sceneName}:发现 {issues.Count} 个问题");
foreach (var issue in issues)
{
GD.Print($" - {issue}");
}
}
}
}GDScript
@tool
extends EditorPlugin
func _enter_tree() -> void:
add_tool_menu_item("场景检查/检查选中场景", _check_selected_scene)
add_tool_menu_item("场景检查/检查所有场景", _check_all_scenes)
func _exit_tree() -> void:
remove_tool_menu_item("场景检查/检查选中场景")
remove_tool_menu_item("场景检查/检查所有场景")
func _check_selected_scene() -> void:
var current_scene := EditorInterface.get_current_scene()
if not current_scene:
push_error("没有打开的场景")
return
var issues := _analyze_scene(current_scene)
_print_report(current_scene.name, issues)
func _check_all_scenes() -> void:
var scenes_dir := "res://scenes/"
var dir := DirAccess.open(scenes_dir)
if not dir:
push_error("找不到场景目录")
return
dir.list_dir_begin()
var total_issues := 0
while true:
var file := dir.get_next()
if file == "":
break
if not file.ends_with(".tscn") and not file.ends_with(".scn"):
continue
var scene := load(scenes_dir + file) as PackedScene
if not scene:
continue
var instance := scene.instantiate()
var issues := _analyze_scene(instance)
total_issues += issues.size()
_print_report(file, issues)
instance.queue_free()
print("\n检查完成!共发现 %d 个问题" % total_issues)
func _analyze_scene(root: Node) -> Array[String]:
var issues: Array[String] = []
for child in root.get_children():
# 检查 1:节点命名是否用下划线
if " " in child.name:
issues.append("节点 '%s' 包含空格,建议用下划线" % child.name)
# 检查 2:CollisionShape 是否有父物理体
if child is CollisionShape2D and not child.get_parent() is PhysicsBody2D:
issues.append("CollisionShape2D '%s' 的父节点不是物理体" % child.name)
# 检查 3:Sprite 是否有纹理
if child is Sprite2D and not child.texture:
issues.append("Sprite2D '%s' 没有设置纹理" % child.name)
# 递归检查子节点
issues.append_array(_analyze_scene(child))
return issues
func _print_report(scene_name: String, issues: Array[String]) -> void:
if issues.is_empty():
print("[OK] %s:无问题" % scene_name)
else:
print("[问题] %s:发现 %d 个问题" % [scene_name, issues.size()])
for issue in issues:
print(" - %s" % issue)导入插件——自定义资源格式
Godot 支持通过插件自定义资源导入流程。比如你可以让 Godot 直接导入你自己的数据格式(比如 CSV、JSON、自定义二进制格式)。
C
using Godot;
using System.IO;
[Tool]
public partial class CsvImportPlugin : EditorImportPlugin
{
public override string _GetImporterName() => "csv_to_resource";
public override string _GetVisibleName() => "CSV 表格数据";
public override string[] _GetRecognizedExtensions() => new[] { "csv" };
public override string _GetSaveExtension() => "tres";
public override string _GetResourceType() => "Resource";
public override float _GetPriority() => 1.0f;
public override int _GetPresetCount() => 1;
public override string _GetPresetName(int presetIndex) => "默认";
public override Godot.Collections.Array<Godot.Collections.Dictionary> _GetImportOptions(
string path, int presetIndex)
{
return new Godot.Collections.Array<Godot.Collections.Dictionary>
{
new() { { "name", "delimiter" }, { "default_value", "," } },
new() { { "name", "has_header" }, { "default_value", true } },
new() { { "name", "encoding" }, { "default_value", "utf-8" } }
};
}
public override Error _Import(string sourceFile, string savePath,
Godot.Collections.Array<Godot.Collections.Dictionary> options,
Godot.Collections.Array<string> platformVariants,
Godot.Collections.Array<string> genFiles)
{
try
{
string delimiter = (string)options[0]["value"];
bool hasHeader = (bool)options[1]["value"];
var lines = File.ReadAllLines(sourceFile);
if (lines.Length == 0) return Error.Failed;
// 解析 CSV
var tableResource = new TableResource();
if (hasHeader)
{
var headers = lines[0].Split(delimiter);
tableResource.SetHeaders(headers);
for (int i = 1; i < lines.Length; i++)
{
var values = lines[i].Split(delimiter);
tableResource.AddRow(values);
}
}
else
{
for (int i = 0; i < lines.Length; i++)
{
var values = lines[i].Split(delimiter);
tableResource.AddRow(values);
}
}
// 保存为 .tres 资源
var saveError = ResourceSaver.Save(tableResource, savePath + "." + _GetSaveExtension());
return saveError;
}
catch (System.Exception e)
{
GD.PrintErr($"CSV 导入失败:{e.Message}");
return Error.Failed;
}
}
}GDScript
@tool
extends EditorImportPlugin
func _get_importer_name() -> String:
return "csv_to_resource"
func _get_visible_name() -> String:
return "CSV 表格数据"
func _get_recognized_extensions() -> PackedStringArray:
return PackedStringArray(["csv"])
func _get_save_extension() -> String:
return "tres"
func _get_resource_type() -> String:
return "Resource"
func _get_priority() -> float:
return 1.0
func _get_preset_count() -> int:
return 1
func _get_preset_name(_preset_index: int) -> String:
return "默认"
func _get_import_options(_path: String, _preset_index: int) -> Array[Dictionary]:
return [
{"name": "delimiter", "default_value": ","},
{"name": "has_header", "default_value": true},
{"name": "encoding", "default_value": "utf-8"}
]
func _import(source_file: String, save_path: String,
options: Array[Dictionary], _platform_variants: Array[String],
_gen_files: Array[String]) -> Error:
var delimiter: String = options[0]["value"]
var has_header: bool = options[1]["value"]
var file := FileAccess.open(source_file, FileAccess.READ)
if not file:
push_error("无法打开文件:%s" % source_file)
return FAILED
var table_resource := TableResource.new()
var line := file.get_csv_line(delimiter)
if has_header and not line.is_empty():
table_resource.set_headers(line)
line = file.get_csv_line(delimiter)
while not file.eof_reached():
if not line.is_empty():
table_resource.add_row(line)
line = file.get_csv_line(delimiter)
file.close()
var full_path := save_path + "." + _get_save_extension()
return ResourceSaver.save(table_resource, full_path)插件发布与分享
当你做好了一个实用的插件,可以分享给其他开发者使用。
发布前的检查清单
| 检查项 | 说明 |
|---|---|
_ExitTree 清理 | 禁用插件后不能留下任何 UI 元素 |
| 错误处理 | 用户的场景结构可能和你不一样,要处理好异常情况 |
| 文档说明 | README 里写清楚插件的功能、使用方法、依赖 |
| 版本兼容 | 标注支持的 Godot 版本 |
| 插件配置 | plugin.cfg 中设置正确的名称和描述 |
plugin.cfg 示例
[plugin]
name="我的批量工具"
description="批量重命名节点、检查场景规范的编辑器工具"
author="你的名字"
version="1.0.0"
script="plugin.gd"分享渠道
| 渠道 | 说明 |
|---|---|
| GitHub | 最通用,可以版本管理 |
| Godot Asset Library | Godot 官方资源库,直接在编辑器里搜索安装 |
| 个人网站/博客 | 适合小众插件 |
最终建议
- 从简单开始:先写一个能跑的插件,再逐步添加功能
_ExitTree必须清理干净:不清理会导致编辑器出 Bug- 多用
EditorInterface:它是你和编辑器交互的主要接口 - 自动化插件最有价值:能帮团队节省时间的插件最受欢迎
- 分享前多测试:在不同的项目、不同的系统上测试你的插件
- 写好文档:别人不知道怎么用,再好的插件也没人用
