C++中嵌入C#作脚本引擎(一)
C++中嵌入C#作脚本引擎(二)

编辑器内的工作流

实现思路

上一篇讨论的内容都是在运行时的,其工作流是:引擎从文件加载场景,在Mono Runtime中创建脚本实例,创建时调用OnCreate方法,之后每帧运行时调用OnUpdate方法,脚本实例销毁时调用OnDestroy方法。

而在编辑器中,情况没有那么简单。在编辑器中,有编辑和运行两个模式,当我们在编辑场景而没有运行场景时,为一个Entity添加脚本,我们并不希望OnCreate方法直接调用,而是在点击"Play"按钮后调用,并开始运行脚本。但在编辑模式中,要能够对Entity上已挂载的脚本的属性进行修改,在运行时,脚本内的对应属性就应该是修改过的版本。由此产生了两种设计思路。

第一种:在编辑模式下,为Entity添加脚本,实际并不在Mono Runtime中创建脚本实例,而是由Mono Runtime中加载的脚本类类型获取反射信息,并用C++的自定义数据结构来存储数据,在编辑器中修改的也是该数据。点击"Play"按钮后,Mono Runtime中创建脚本实例,并根据自定义数据结构存储的数据,给实例的属性赋值,此时编辑器内修改的是实际的脚本实例的数据。这种设计的优点在于只用维护一个Mono Domain;但是缺点也很显著:在编辑模式下没有脚本实例,无法实现一些编辑器特有的脚本功能;还有一个很大的问题在于重加载,运行模式下重新加载Assembly会直接破坏当前状态。

第二种:在Mono Runtime中维护两个Domain,每个Domain独立加载Assembly,数据也是独立的。在编辑模式下,添加的脚本在Editor Domain实例化,编辑器修改的已经是Mono Runtime里的数据;点击"Play"按钮后,将Editor Domain的数据“拷贝”一份到新创建的Runtime Domain(可以通过序列化实现),在Runtime Domain中创建一样的脚本实例,并调用OnCreate等方法开始运行,运行结束后,Runtime Domain销毁。这种设计解决了第一种方案的缺陷,其难点在于两个Domain状态的管理。想要实现像Unity那样灵活的脚本控制,必须通过第二种方法,本文正要介绍这种方法的实现思路。

实现细节

首先,要维护两个Mono Domain,每个Domain的部分操作是共通的,如上一篇文章所说的加载程序集,获取脚本类的反射信息等。我们可以将这些操作封装为类。

值得一提的是,我们虽不用创建第一种思路中用来存数据的自定义数据结构,但是依然要在C++端保存Mono Runtime中各个类、实例、属性的指针,目的是快速访问,而不用频繁调用诸如mono_class_from_namemono_class_get_fields等方法,只有在给Mono对象存取值的时候,会用到mono_field_get_valuemono_field_set_value,而这两个是效率相对较高的方法,总而言之就是减少调用mono库方法的频率。

由此,我们定义ScriptDomainScriptClassScriptInstance等类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
using ScriptInstanceMap = std::unordered_map<std::string, Ref<ScriptInstance>>;
using EntityMap = std::unordered_map<UUID, ScriptInstanceMap>;
using ScriptClassMap = std::unordered_map<std::string, Ref<ScriptClass>>;

class ScriptDomain
{
public:
ScriptDomain(const std::string &name);
~ScriptDomain();
void LoadCoreAssembly(const Path &path);
void LoadAppAssembly(const Path &path);
void SetCurrent();
Ref<ScriptInstance> InstantiateScriptClass(const Ref<ScriptClass> &scriptClass, UUID uuid);

MonoDomain *GetHandle() const { return handle; }
MonoAssembly *GetCoreAssembly() const { return coreAssembly; }
MonoImage *GetCoreAssemblyImage() const { return coreAssemblyImage; }
MonoAssembly *GetAppAssembly() const { return appAssembly; }
MonoImage *GetAppAssemblyImage() const { return appAssemblyImage; }
ScriptClassMap &GetScriptClasses() { return scriptClasses; }
EntityMap &GetEntities() { return entities; }

private:
Ref<ScriptClass> RegisterCoreClass(const std::string &namespaceStr, const std::string &nameStr);
void RegisterAppClass(const std::string &namespaceStr, const std::string &nameStr);

MonoDomain *handle;
std::string name;

MonoAssembly *coreAssembly;
MonoImage *coreAssemblyImage;

MonoAssembly *appAssembly;
MonoImage *appAssemblyImage;

Ref<ScriptClass> entityClass;

ScriptClassMap scriptClasses;
EntityMap entities;
};

enum class ScriptFieldType
{
Unknown,
Bool, Char,
Int16, Int32, Int64,
UInt8, UInt16, UInt32, UInt64,
Float, Double,
Vector2, Vector3, Vector4,
Entity
};

struct ScriptField
{
ScriptFieldType type;
MonoClassField *handle;
};

struct ScriptMethod
{
MonoMethod *handle;
bool isStatic;
};

class ScriptClass
{
public:
friend class ScriptInstance;
friend class ScriptDomain;

MonoObject *Instantiate() const;
ScriptField GetField(const std::string &name) const;
ScriptMethod GetMethod(const std::string &name) const;
MonoObject *InvokeStaticMethod(const std::string &name, void **params) const;
std::unordered_map<std::string, ScriptField> GetFields() const { return fields; }
std::unordered_map<std::string, ScriptMethod> GetMethods() const { return methods; }
MonoClass *GetHandle() const { return monoClass; }

private:
template <typename T, typename... Args>
friend Ref<T> MakeRef(Args &&...args);

ScriptClass(MonoClass *monoClass, const std::string &classNamespace, const std::string &className);
std::string classNamespace;
std::string className;
std::unordered_map<std::string, ScriptField> fields;
std::unordered_map<std::string, ScriptMethod> methods;
MonoClass *monoClass = nullptr;
};

class ScriptInstance
{
public:
ScriptInstance(MonoObject *instance, const Ref<ScriptClass> &scriptClass);
MonoObject *InvokeMethod(const std::string &name, void **params);
MonoObject *InvokeMethod(ScriptMethod method, void **params);

void TryInvokeOnCreate();
void TryInvokeOnUpdate(float timestep);
void TryInvokeOnDestroy();

Ref<ScriptClass> GetScriptClass() const { return scriptClass; }

template <typename T>
T GetFieldValue(const std::string &name)
{
auto it = scriptClass->fields.find(name);
if (it == scriptClass->fields.end())
return T{};
ScriptField &field = it->second;
mono_field_get_value(instance, field.handle, fieldValueBuffer);
return *(T *)fieldValueBuffer;
}

template <typename T>
void SetFieldValue(const std::string &name, const T &value)
{
auto it = scriptClass->fields.find(name);
if (it == scriptClass->fields.end())
return;
ScriptField &field = it->second;
mono_field_set_value(instance, field.handle, (void *)&value);
}

MonoObject *GetHandle() const { return instance; }

private:
Ref<ScriptClass> scriptClass;
MonoObject *instance;
inline static char fieldValueBuffer[16];
};

其中Script Domain加载Core和App程序集的方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
void ScriptDomain::LoadCoreAssembly(const Path &path)
{
mono_domain_set(handle, false); // 加载前一定要设置domain为当前操作的domain!
coreAssembly = mono_domain_assembly_open(handle, path.string().c_str());
if (!coreAssembly)
{
Log::CoreError("Failed to Load Core Assembly: {}", path.string());
}
coreAssemblyImage = mono_assembly_get_image(coreAssembly);
if (!coreAssemblyImage)
{
Log::CoreError("Failed to Load Core Assembly Image: {}", path.string());
}

entityClass = RegisterCoreClass("Zafkiel", "Entity");

ScriptGlue::AddInternalCalls(); // Internal Calls 都在 CoreAssembly 中
}

void ScriptDomain::LoadAppAssembly(const Path &path)
{
if (!coreAssembly)
{
Log::CoreError("Need to Load Core Assembly first!");
return;
}
mono_domain_set(handle, false);
appAssembly = mono_domain_assembly_open(handle, path.string().c_str());
if (!appAssembly)
{
Log::CoreError("Failed to App Assembly: {}", path.string());
}
appAssemblyImage = mono_assembly_get_image(appAssembly);
if (!appAssemblyImage)
{
Log::CoreError("Failed to App Assembly Image: {}", path.string());
}

scriptClasses.clear();
const MonoTableInfo *typeDefinitionsTable = mono_image_get_table_info(appAssemblyImage, MONO_TABLE_TYPEDEF);
size_t numTypes = mono_table_info_get_rows(typeDefinitionsTable);
// 通过反射信息得到所有类型信息
for (size_t i = 0; i < numTypes; i++)
{
uint32_t cols[MONO_TYPEDEF_SIZE];
mono_metadata_decode_row(typeDefinitionsTable, i, cols, MONO_TYPEDEF_SIZE);

std::string namespaceStr = mono_metadata_string_heap(appAssemblyImage, cols[MONO_TYPEDEF_NAMESPACE]);
std::string nameStr = mono_metadata_string_heap(appAssemblyImage, cols[MONO_TYPEDEF_NAME]);

RegisterAppClass(namespaceStr, nameStr);
}
}

Ref<ScriptClass> ScriptDomain::RegisterCoreClass(const std::string &namespaceStr, const std::string &nameStr)
{
auto monoClass = mono_class_from_name(coreAssemblyImage, namespaceStr.c_str(), nameStr.c_str());
return MakeRef<ScriptClass>(monoClass, namespaceStr, nameStr);
}

void ScriptDomain::RegisterAppClass(const std::string &namespaceStr, const std::string &nameStr)
{
auto monoClass = mono_class_from_name(appAssemblyImage, namespaceStr.c_str(), nameStr.c_str());
bool isEntity = mono_class_is_subclass_of(monoClass, entityClass->GetHandle(), false);
if (isEntity)
{
std::string fullName = !namespaceStr.empty() ? std::format("{}.{}", namespaceStr, nameStr) : nameStr;
scriptClasses[fullName] = MakeRef<ScriptClass>(monoClass, namespaceStr, nameStr);
}
}

ScriptClass需要获取类型中的所有字段和方法,对应实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ScriptClass::ScriptClass(MonoClass *monoClass, const std::string &classNamespace, const std::string &className)
: monoClass(monoClass), classNamespace(classNamespace), className(className)
{
void *iterator = nullptr;
while (auto field = mono_class_get_fields(monoClass, &iterator))
{
std::string fieldName = mono_field_get_name(field);
auto fieldType = mono_field_get_type(field);
std::string typeName = mono_type_get_name(fieldType);
ScriptFieldType type = stringToScriptFieldType.contains(typeName) ? stringToScriptFieldType[typeName] : ScriptFieldType::Unknown;
fields[fieldName] = ScriptField{type, field};
}
iterator = nullptr;
while (auto method = mono_class_get_methods(monoClass, &iterator))
{
std::string methodName = mono_method_get_name(method);
uint32_t flags;
mono_method_get_flags(method, &flags);
bool isStatic = flags & MONO_METHOD_ATTR_STATIC;
methods[methodName] = ScriptMethod{method, isStatic};
}
}

ScriptInstance中,通过存储的方法指针来调用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
MonoObject *ScriptInstance::InvokeMethod(ScriptMethod method, void **params)
{
if (method.isStatic)
{
Log::CoreError("Method is Static!");
return nullptr;
}
MonoObject *exc = nullptr;
MonoObject *ret = mono_runtime_invoke(method.handle, instance, params, &exc);
if (exc)
{
MonoString *excMonoStr = mono_object_to_string(exc, nullptr);
std::string excStr = MonoStringToCppString(excMonoStr);
Log::CoreError("Invoke Method Exception: {}", excStr);
return nullptr;
}
return ret;
}

接下来的重点在于Editor Domain和Runtime Domain的关系。

在刚打开编辑器时,脚本引擎创建Editor Domain,并加载程序集。

1
2
3
scriptEngine->CreateEditorDomain();
scriptEngine->LoadEditorCoreAssembly();
scriptEngine->LoadEditorAppAssembly();

脚本引擎内维护一个变量isRuntime,表明当前是编辑模式还是运行模式。之后脚本引擎提供的接口,其数据的存取会相应地指向Editor Domain / Runtime Domain。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class EditorScriptEngine 
{
public:
Ref<ScriptDomain> EditorScriptEngine::GetActiveDomain() const
{
return isRuntime ? runtimeDomain : editorDomain;
}

ScriptClassMap &GetScriptClasses() { return GetActiveDomain()->GetScriptClasses(); }
const ScriptClassMap &GetScriptClasses() const { return GetActiveDomain()->GetScriptClasses(); }

EntityMap &GetEntities() { return GetActiveDomain()->GetEntities(); }
const EntityMap &GetEntities() const { return GetActiveDomain()->GetEntities(); }

bool HasScriptInstance(UUID uuid, const std::string &scriptName) const
{
auto &entities = GetEntities();
auto it = entities.find(uuid);
return it != entities.end() && it->second.contains(scriptName);
}

Ref<ScriptInstance> GetScriptInstance(UUID uuid, const std::string &scriptName) const
{
auto &entities = GetEntities();
if (auto entity = entities.find(uuid); entity != entities.end())
if (auto it = entity->second.find(scriptName); it != entity->second.end())
return it->second;
Log::CoreError("entity script doesn't exist: {} {}", (uint64_t)uuid, scriptName);
return nullptr;
}

Ref<ScriptInstance> AddScriptInstance(UUID uuid, const std::string &scriptName)
{
auto &scriptClasses = GetScriptClasses();
auto it = scriptClasses.find(scriptName);
if (it == scriptClasses.end())
{
Log::CoreError("Cannot Find Script Class: {}", scriptName);
return nullptr;
}
auto instance = GetActiveDomain()->InstantiateScriptClass(it->second, uuid);
GetEntities()[uuid][scriptName] = instance;
return instance;
}

void RemoveScriptInstance(UUID uuid, const std::string &scriptName)
{
auto &entities = GetEntities();
if (auto entity = entities.find(uuid); entity != entities.end())
entity->second.erase(scriptName);
else
Log::CoreError("Entity Instance {} - {} doesn't exist!", (uint64_t)uuid, scriptName);
}
};

在切换到运行模式时,我们需要“拷贝”原来的场景,得到新场景,同时创建Runtime Domain,并转移数据。“拷贝”的过程通过序列化实现,不论是序列化成文本格式还是二进制格式,这里不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (isPlaying) // Edit -> Play
{
auto worldData = Serialize(Editor::GetEditorScene()->GetWorld()); // 序列化当前场景
Ref<Scene> newScene = MakeRef<Scene>();
Engine::SetActiveScene(newScene);

Editor::GetScriptEngine()->OnRuntimeInit(); // 创建Runtime Domain
Deserialize<World>(worldData, newScene->GetWorld()); // 反序列化
Editor::GetScriptEngine()->OnRuntimeStart(); // 调用脚本的OnCreate方法
}
else // Play -> Edit
{
Engine::SetActiveScene(Editor::GetEditorScene());
Editor::GetScriptEngine()->OnRuntimeStop(); // 调用脚本的OnDestroy方法,并销毁Runtime Domain
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void EditorScriptEngine::OnRuntimeInit()
{
CreateRuntimeDomain();
SwitchToRuntime();
LoadRuntimeCoreAssembly();
LoadRuntimeAppAssembly();
}
void EditorScriptEngine::OnRuntimeStart()
{
for (auto &[uuid, entity] : runtimeDomain->GetEntities())
{
for (auto &[scriptName, instance] : entity)
{
instance->TryInvokeOnCreate();
}
}
}
void EditorScriptEngine::OnRuntimeUpdate(float timestep)
{
for (auto &[uuid, entity] : runtimeDomain->GetEntities())
{
for (auto &[scriptName, instance] : entity)
{
instance->TryInvokeOnUpdate(timestep);
}
}
}
void EditorScriptEngine::OnRuntimeStop()
{
for (auto &[uuid, entity] : runtimeDomain->GetEntities())
{
for (auto &[scriptName, instance] : entity)
{
instance->TryInvokeOnDestroy();
}
}
UnloadRuntimeDomain();
SwitchToEditor();
}

调试Mono

在使用csc编译C#脚本时,可以通过传入参数-debug:portable,在生成dll文件的目录生成同名的.pdb调试文件。我们需要在引擎中加载该文件来使用mono的调试功能。

首先,在创建Mono Runtime之前,准备好如下参数传递给Mono:

1
2
3
4
5
6
7
8
9
std::vector<const char *> argv = {
"--debugger-agent=transport=dt_socket,address=localhost:55555,server=y,suspend=n,loglevel=3,logfile=MonoDebugger.log",
"--soft-breakpoints"};
mono_jit_parse_options(argv.size(), (char **)argv.data());
mono_debug_init(MONO_DEBUG_FORMAT_MONO);

rootDomain = mono_jit_init("ZafkielJITRuntime");

mono_domain_set(rootDomain, true);

详细解释一下传入参数的含义:

  • --debugger-agent为主参数,表示要启动调试器代理;
  • transport=dt_socket指定传输协议,使用 TCP/IP 套接字 进行通信,支持远程调试;
  • address=localhost:55555指定调试器代理监听连接的地址和端口,这里我们本地调试,端口任意;
  • server=y表示程序作为服务器启动,并等待调试器连接;
  • suspend=n表示程序启动时不会暂停等待调试器连接,而是在运行时随时连接。这是合理的,因为编辑器刚启动时在Editor Domain,而在编辑模式下我们不会启用调试,只会在运行模式下调试。
  • loglevel=3设置日志详细程度;
  • logfile=MonoDebugger.log指定调试日志输出的文件,对于诊断问题很有帮助。
  • --soft-breakpoints指定使用软件断点,这样我们就可以像正常调试一样,在IDE中插入断点让代码中断了。

在创建Runtime Domain时,添加一行代码,表示该domain要支持调试。

1
2
3
4
5
void EditorScriptEngine::CreateRuntimeDomain()
{
runtimeDomain = MakeRef<ScriptDomain>("Runtime Domain");
mono_debug_domain_create(runtimeDomain->GetHandle());
}

之后加载程序集时,对于加载的每个.dll文件,找到对应的.pdb文件并加载,以加载CoreAssembly为例:

1
2
3
4
5
6
7
8
9
10
11
12
void EditorScriptEngine::LoadRuntimeCoreAssembly()
{
runtimeDomain->LoadCoreAssembly("ScriptCore.dll");

Path pdbPath = "ScriptCore.pdb";
if (std::filesystem::exists(pdbPath))
{
Buffer pdbFileData = FileSystem::ReadBytes(pdbPath); // 二进制格式读取文件
mono_debug_open_image_from_memory(runtimeDomain->GetCoreAssemblyImage(), pdbFileData.data(), pdbFileData.size());
pdbFileData.clear();
}
}

这样就完成了调试功能的支持。

关于特定IDE调试Mono的尝试

在Vscode中,有Mono Debug这个插件,理论上安装插件后,在.vscode/launch.json文件中添加:

1
2
3
4
5
6
7
8
9
10
11
12
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Mono",
"request": "attach",
"type": "mono",
"address": "localhost",
"port": 55555,
}
]
}

就应该能调试,但笔者测试时发现由于该插件长久未更新,与引擎内Mono连接时,出现了协议版本不匹配的问题导致无法调试,截至本文写作时依然不行。

而在JetBrains Rider中,在运行/调试配置中,添加"Mono 远程"配置,指定与引擎中一样的地址和端口,就可以成功连接并调试。

脚本热重载

脚本重加载

脚本重加载是在编辑器运行的过程中,通过卸载Domain并创建新的Domain,实现程序集的更新。这个功能无论在编辑模式还是运行模式都应该支持。注意,重新加载的Domain永远是Editor Domain,在运行模式下,Runtime Domain是不能也不应该打断的。

在重新加载的过程中,原Editor Domain中已经有的数据依然需要“拷贝”到新的Editor Domain中(改变了的字段暂不考虑保留其值),因此又涉及到序列化来转移数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void EditorScriptEngine::ReloadEditorDomain()
{
bool currentIsRuntime = isRuntime;
SwitchToEditor(); // 先切换到Editor Domain进行卸载
auto data = Serialize(Editor::GetEditorScene()->GetWorld());
UnloadEditorDomain();
CompileScripts(); // 重新编译脚本,更新程序集
CreateEditorDomain();
LoadEditorCoreAssembly();
LoadEditorAppAssembly();
SwitchToEditor();
Editor::GetEditorScene()->GetWorld() = Deserialize<World>(data);
if (currentIsRuntime) SwitchToRuntime(); // 原来在什么Domain就切换回去
}

在程序内控制C#脚本的编译,暂时没有什么优越的方法,笔者尝试过在Mono Runtime中,调用C#的编译器,但是各种找不到库,大概是Mono和.Net的兼容问题。因此只能采用调用控制台命令的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void EditorScriptEngine::CompileScripts()
{
const Path &libraryDir = Editor::GetProject()->GetLibraryDirectory();
Path appAssemblyPath = libraryDir / "AppAssembly.dll";
if (!std::filesystem::exists(libraryDir))
{
std::filesystem::create_directory(libraryDir);
}
Path sourcePath = Editor::GetProject()->GetAssetDirectory() / "scripts" / "*.cs";
std::string cmd = std::format("csc -target:library -debug:portable -r:ScriptCore.dll -out:{} {}",
appAssemblyPath.string(), sourcePath.string());

std::system(cmd.c_str());
}

但是这样还有一个问题,在运行模式下,我们不想在重加载时改变Runtime Domain,但是直接覆写AppAssembly.dll出现了问题——Mono Domain在加载程序集时,并不是直接将其拷贝进内存,而是链接到原文件(不同平台有差异),会锁定文件,因此对原文件进行覆写会直接导致程序崩溃。解决方案也很简单,在编辑器一次运行内,给新创建的程序集一个不断递增的尾号,这样就不会覆写原来的程序集了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void EditorScriptEngine::CompileScripts()
{
const Path &libraryDir = Editor::GetProject()->GetLibraryDirectory();
Path appAssemblyPath = libraryDir / std::format("AppAssembly_{}.dll", assemblyIndex++);
if (!std::filesystem::exists(libraryDir))
{
std::filesystem::create_directory(libraryDir);
}
Path sourcePath = Editor::GetProject()->GetAssetDirectory() / "scripts" / "*.cs";
std::string cmd = std::format("csc -target:library -debug:portable -r:ScriptCore.dll -out:{} {}",
appAssemblyPath.string(), sourcePath.string());

std::system(cmd.c_str());
}

文件监视器实现热重载

在实现重加载后,通过一个文件监视器就可以轻松实现热重载,即修改C#文件后,程序检测到并自动进行重加载。

网上有很多开源的文件监视器库,可以随意选择,笔者使用filewatch这个单头文件库来实现。

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void EditorScriptEngine::WatchScriptFiles(const Path &scriptDir)
{
scriptFileWatcher = std::make_unique<filewatch::FileWatch<std::filesystem::path>>(
scriptDir, [this](const std::filesystem::path &file, const filewatch::Event event_type) {
if (!scriptReloadPending && event_type == filewatch::Event::modified)
{
scriptReloadPending = true;
using namespace std::chrono_literals;
std::this_thread::sleep_for(100ms);

Engine::SubmitToMainThread([&]() {
ReloadEditorDomain();
});
} });
}

由于修改文件时,系统往往会同时产生不止一个事件,因此需要完成去重的工作。这里通过维护一个scriptReloadPending,检测到第一个修改事件后,直到完成重加载之前,对后续的修改事件不再响应,保险起见,可以像代码中一样,等待一小段时间后再执行重加载。

由于文件监视器不在主线程上运行,不能在检测到修改之后直接重加载,因为这时候主线程内的行为未知,如果仍在使用Domain中的数据,可能直接崩溃,因此需要将重加载加入队列,在主线程的合适时机再执行。

在创建脚本引擎时,调用该WatchScriptFiles方法,即启用文件监视。


参考教程:C# Scripting! // Game Engine series (Cherno游戏引擎系列教程)