在 GDScript 里写一个 class_name HealthBar extends ProgressBar,保存之后,工程里任何脚本能直接 HealthBar.new(),编辑器的新建节点对话框里也会出现 HealthBar。这个"全局可见"不是编译期符号表,而是运行时一张注册表查出来的——这张表由 ScriptServer 管理。
和 ClassDB 划清边界
引擎里有两个"类注册中心",职责经常被混。ClassDB(一-1 那篇的主角)管的是原生 C++ 类:注册发生在引擎启动时的 register_module_types,内容是 C++ 类的 MethodBind 表、属性表、信号表。ScriptServer 管的是脚本类:GDScript 的 class_name、C# 的 [GlobalClass]、GDExtension 注册进来的脚本类,全部登记在 ScriptServer 这里。
判断标准很简单:类的实现在引擎二进制里 → ClassDB;实现是一个资源文件(.gd、.cs)→ ScriptServer。Create New Node 对话框里两类混在一起显示,底下是分两路查的。
注册链:从保存文件到进表
以 GDScript 为例,按下保存后:
- 编辑器导入管线发现文件变化,触发脚本重编译(热重载那篇讲过这条链)。
GDScriptAnalyzer在语义分析阶段发现class_name Foo extends Bar声明,向ScriptServer::add_global_class("Foo", 路径, "GDScript", "Bar")发起注册。- ScriptServer 查全局表:名字已存在 → 返回冲突,编辑器在脚本编辑器里标红 "Class 'Foo' already exists";不存在 → 写入。
核心数据结构就是一张哈希表,value 只有三个字段:
// core/object/script_language.h,结构简写
class GlobalClassCache {
HashMap<StringName, GlobalScriptClass> classes;
};
struct GlobalScriptClass {
StringName base; // 父类名,用于图标继承和兼容性检查
StringName language; // "GDScript" / "C#"
String path; // 脚本资源路径
};
注意表里没有字段顺序、没有命名空间、没有泛型参数——全局类就是个平铺的名字空间。两个不同目录下的脚本写同名 class_name 一定冲突,因为这张表不感知目录结构。大型工程里给类名加前缀(UIHealthBar 而不是 HealthBar)不是洁癖,是在这张平表里买唯一性。
持久化与冷启动
注册表不是每次启动全量重扫重建的。编辑器首次扫描工程时把结果写进 .godot/global_script_class_cache.cfg,之后启动直接读这个缓存,只有文件变化才增量更新。一万个脚本文件的工程冷启动能撑住,靠的就是这个缓存加 FileSystem 的 mtime 对比,而不是每次开机把全工程 .gd 文件都 parse 一遍。
代价是缓存可能过期:手动从外面拷文件进工程、git 合并冲突解决不当,都可能让 cfg 里的条目和磁盘对不上。排查编辑器里"明明有这个类却标红"的第一步永远是删掉 .godot/global_script_class_cache.cfg 让编辑器重建——这和资源 UID 缓存的故障模式是同一类。
谁在用这张表
消费方有三类,全在编辑器侧:新建节点对话框按名字列脚本类、脚本编辑器的代码补全、检查器里给导出的脚本类型属性画下拉框。运行时不查这张表——运行时拿的是具体的 Script 资源引用,实例化走 Script::instance,和全局名字无关。这意味着全局类数量再多也只拖编辑器,不影响发布版的运行时性能;但每个 class_name 都在编辑器里买了一个全局名字,名字本身会变成需要维护的 API 面。