Align Unity-style object and hierarchy scripting APIs
This commit is contained in:
265
docs/plan/C#脚本模块下一阶段计划.md
Normal file
265
docs/plan/C#脚本模块下一阶段计划.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# C#脚本模块下一阶段计划
|
||||
|
||||
日期:2026-04-03
|
||||
|
||||
## 1. 当前阶段判断
|
||||
|
||||
C# 脚本模块已经完成了第一阶段的核心闭环,不再是“从 0 到 1 的原型验证”状态,而是进入了“从可运行走向可长期使用”的第二阶段。
|
||||
|
||||
当前已经完成的关键能力包括:
|
||||
|
||||
1. `ScriptComponent + MonoBehaviour` 基本运行时模型已经建立
|
||||
2. `MonoScriptRuntime` 已经能加载程序集、发现脚本类、创建托管实例、调用生命周期
|
||||
3. `Awake / OnEnable / Start / FixedUpdate / Update / LateUpdate / OnDisable / OnDestroy` 已经打通
|
||||
4. `Debug.Log / LogWarning / LogError` 已经打通到 native logger
|
||||
5. `Time`、`Input`、`GameObject`、`Transform`、`Behaviour.enabled`、部分内建组件包装已经可用
|
||||
6. 脚本字段存储、默认值、覆盖值、运行时值三层模型已经建立
|
||||
7. Play / Pause / Resume / Step 已经能驱动脚本生命周期
|
||||
8. editor 已经具备脚本类选择、字段 Inspector 编辑、项目脚本程序集重建与重载入口
|
||||
|
||||
这意味着下一阶段的重点,不再是“脚本能不能跑”,而是:
|
||||
|
||||
1. Unity 风格 API 是否足够严格一致
|
||||
2. 项目级脚本工作流是否足够稳定
|
||||
3. 字段系统是否能支撑真实项目使用
|
||||
|
||||
---
|
||||
|
||||
## 2. 下一阶段总目标
|
||||
|
||||
下一阶段的总目标是:
|
||||
|
||||
把当前“可运行的 C# 脚本运行时”收口成“命名、语义、工作流都更接近 Unity 的第一版可用脚本模块”。
|
||||
|
||||
具体聚焦三件事:
|
||||
|
||||
1. **Unity API 严格对齐**
|
||||
2. **项目脚本程序集链路收口**
|
||||
3. **脚本字段系统扩展**
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一优先级:Unity API 严格对齐
|
||||
|
||||
这是当前最高优先级。
|
||||
|
||||
原因:
|
||||
|
||||
1. 你已经明确要求 API 命名必须和 Unity C# API 保持严格一致
|
||||
2. 运行时主链路已经可用,当前最需要尽快收口的是 API 契约
|
||||
3. 如果 API 名字、属性名、行为语义先发散,后面越做越难回收
|
||||
|
||||
本阶段需要重点检查和补齐以下几类接口:
|
||||
|
||||
### 3.1 基础对象层
|
||||
|
||||
优先补齐 Unity 风格的基础对象模型:
|
||||
|
||||
1. `Object`
|
||||
2. `Component`
|
||||
3. `Behaviour`
|
||||
4. `MonoBehaviour`
|
||||
|
||||
重点不是“再做一套功能”,而是统一 API 入口和语义边界。
|
||||
|
||||
### 3.2 GameObject / Component 常用 API
|
||||
|
||||
优先补齐最常用、最影响脚本编写体验的 Unity 接口:
|
||||
|
||||
1. `GetComponent<T>()`
|
||||
2. `TryGetComponent<T>(out T)`
|
||||
3. `AddComponent<T>()`
|
||||
4. `GetComponents<T>()`
|
||||
5. `GetComponentInChildren<T>()`
|
||||
6. `GetComponentInParent<T>()`
|
||||
7. `CompareTag`
|
||||
8. `tag`
|
||||
9. `layer`
|
||||
|
||||
其中前四项优先级最高。
|
||||
|
||||
### 3.3 Object 静态入口
|
||||
|
||||
优先补齐 Unity 风格最核心的对象静态方法:
|
||||
|
||||
1. `Object.Destroy`
|
||||
2. `Object.Instantiate`
|
||||
|
||||
这一层非常关键,因为 Unity 用户的很多使用习惯都是围绕 `Object` 静态 API 建立的。
|
||||
|
||||
### 3.4 Transform 常用缺口补齐
|
||||
|
||||
当前 `Transform` 已经有较完整的空间变换接口,但还需要继续检查 Unity 一致性,重点看:
|
||||
|
||||
1. 属性命名
|
||||
2. 重载形态
|
||||
3. `childCount` / `GetChild`
|
||||
4. `parent` / `SetParent`
|
||||
5. `Translate` / `Rotate` / `LookAt`
|
||||
6. 常用便捷属性是否缺失
|
||||
|
||||
原则是:
|
||||
|
||||
1. 已有 API 尽量不发散出自定义命名
|
||||
2. 优先补 Unity 常见重载
|
||||
|
||||
---
|
||||
|
||||
## 4. 第二优先级:项目脚本程序集链路收口
|
||||
|
||||
当前 editor 已经具备:
|
||||
|
||||
1. `project/Assets` 扫描
|
||||
2. `GameScripts.dll` 编译
|
||||
3. `Rebuild Scripts`
|
||||
4. `Reload Scripts`
|
||||
5. Inspector 脚本类发现
|
||||
|
||||
但这一层还没有完全稳定,尤其是:
|
||||
|
||||
1. 项目程序集输出目录依赖当前工作区状态
|
||||
2. 测试环境下 `project/Library/ScriptAssemblies` 还不稳定
|
||||
3. editor / 测试 / 项目目录三者之间还缺少更彻底的收口
|
||||
|
||||
这一阶段要完成的目标是:
|
||||
|
||||
1. `project/Assets/*.cs -> Library/ScriptAssemblies/*.dll` 变成稳定链路
|
||||
2. editor 重建后总能看到最新脚本类
|
||||
3. 对应测试不依赖手工残留文件
|
||||
4. 项目程序集相关测试可以稳定进入常规回归集
|
||||
|
||||
这部分的目标不是继续扩 API,而是把工程化链路做稳。
|
||||
|
||||
---
|
||||
|
||||
## 5. 第三优先级:脚本字段系统扩展
|
||||
|
||||
当前字段系统已经支持:
|
||||
|
||||
1. `float`
|
||||
2. `double`
|
||||
3. `bool`
|
||||
4. `int32`
|
||||
5. `uint64`
|
||||
6. `string`
|
||||
7. `Vector2`
|
||||
8. `Vector3`
|
||||
9. `Vector4`
|
||||
10. `GameObject` 引用
|
||||
|
||||
下一阶段应继续向 Unity 常用字段模型推进,优先顺序建议如下:
|
||||
|
||||
1. `enum`
|
||||
2. `SerializeField` 支持
|
||||
3. private 可序列化字段
|
||||
4. 组件引用字段
|
||||
5. 数组 / List
|
||||
6. 资源引用字段
|
||||
|
||||
其中最优先的是:
|
||||
|
||||
1. `enum`
|
||||
2. `[SerializeField]`
|
||||
3. 组件引用字段
|
||||
|
||||
因为这三项最直接决定脚本是否能进入“真实可写”阶段。
|
||||
|
||||
---
|
||||
|
||||
## 6. editor 集成边界
|
||||
|
||||
这一阶段 editor 不再是完全不碰,但仍然不是主战场。
|
||||
|
||||
原则是:
|
||||
|
||||
1. 只做支撑脚本模块使用所必需的 editor 收口
|
||||
2. 不把主要精力放在复杂编辑器体验优化上
|
||||
3. 继续优先保证 runtime、程序集链路、字段模型稳定
|
||||
|
||||
本阶段 editor 侧只建议继续做以下范围:
|
||||
|
||||
1. 脚本类发现与重建流程稳定性
|
||||
2. Inspector 字段编辑最小闭环
|
||||
3. 类型新增后对应字段控件补齐
|
||||
|
||||
以下内容暂时不进入主线:
|
||||
|
||||
1. 热重载
|
||||
2. 托管调试器
|
||||
3. 完整 Console/Exception 调试体验
|
||||
4. 高级脚本模板与自动生成工具
|
||||
|
||||
---
|
||||
|
||||
## 7. 本阶段不做的内容
|
||||
|
||||
为避免范围继续膨胀,下一阶段先明确不做:
|
||||
|
||||
1. 域重载 / 热重载
|
||||
2. CoreCLR 切换
|
||||
3. IL2CPP / AOT
|
||||
4. 完整 Unity 级别资源 API
|
||||
5. 复杂序列化对象图
|
||||
6. 完整调试器接入
|
||||
|
||||
这些都可以进入后续阶段,但不应抢占当前优先级。
|
||||
|
||||
---
|
||||
|
||||
## 8. 建议执行顺序
|
||||
|
||||
建议严格按下面顺序推进:
|
||||
|
||||
1. 先做 **Unity API 命名与契约收口**
|
||||
2. 再做 **项目脚本程序集链路稳定化**
|
||||
3. 再做 **字段系统扩展**
|
||||
4. 再做 **新增字段类型对应的 Inspector 支持**
|
||||
5. 最后再评估热重载、调试器、异常体验
|
||||
|
||||
原因很简单:
|
||||
|
||||
1. API 契约不先收口,后面所有脚本都会建立在不稳定接口上
|
||||
2. 程序集链路不稳定,项目脚本工作流就不可靠
|
||||
3. 字段系统是把脚本从“可跑”推进到“可用”的关键
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
本阶段完成后,至少应满足:
|
||||
|
||||
1. 核心托管 API 的命名与 Unity 保持一致,不再出现明显自定义发散
|
||||
2. `Object / GameObject / Component / Behaviour / MonoBehaviour / Transform` 的主干接口基本齐全
|
||||
3. 项目脚本程序集能稳定重建、重载、发现
|
||||
4. 项目级脚本程序集测试可以稳定跑通
|
||||
5. Inspector 能编辑扩展后的核心字段类型
|
||||
6. 脚本模块可以支撑真实项目写出第一批常规 gameplay 脚本
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前结论
|
||||
|
||||
脚本模块现在已经完成了“第一阶段:核心运行时落地”。
|
||||
|
||||
下一阶段不应该再围绕“能不能跑”展开,而应该围绕:
|
||||
|
||||
1. **Unity API 严格一致**
|
||||
2. **项目工作流稳定**
|
||||
3. **字段系统可用于真实开发**
|
||||
|
||||
后续执行时,默认按这个优先级推进。
|
||||
## 阶段进展 2026-04-03
|
||||
|
||||
- 已完成第一步 Unity API 对齐收口:
|
||||
- 新增 `XCEngine.Object` 基类。
|
||||
- 新增 `Object.Destroy(Object)` 静态销毁入口。
|
||||
- 新增 `GameObject / Component.GetComponentInChildren<T>()`。
|
||||
- 新增 `GameObject / Component.GetComponentInParent<T>()`。
|
||||
- 已补齐对应 Mono internal call 与 native 销毁路径。
|
||||
- 已新增运行时回归用例,验证层级查找、自身命中、组件销毁、GameObject 销毁。
|
||||
- 已通过 `MonoScriptRuntimeTest.*` 与 `ProjectScriptAssemblyTest.*` 相关整组验证。
|
||||
|
||||
- 下一步建议继续做第二批 Unity API 对齐:
|
||||
- `GetComponents<T>()`
|
||||
- `Object.Instantiate`
|
||||
- `tag / CompareTag / layer`
|
||||
@@ -67,6 +67,7 @@ public:
|
||||
size_t GetManagedInstanceCount() const { return m_instances.size(); }
|
||||
MonoObject* GetManagedInstanceObject(const ScriptComponent* component) const;
|
||||
MonoObject* CreateManagedComponentWrapper(MonoClass* componentClass, uint64_t gameObjectUUID);
|
||||
bool DestroyManagedObject(MonoObject* managedObject);
|
||||
|
||||
bool TryGetFieldValue(
|
||||
const ScriptComponent* component,
|
||||
|
||||
@@ -109,18 +109,8 @@ MonoScriptRuntime* GetActiveMonoScriptRuntime() {
|
||||
return dynamic_cast<MonoScriptRuntime*>(ScriptEngine::Get().GetRuntime());
|
||||
}
|
||||
|
||||
ManagedComponentTypeInfo ResolveManagedComponentTypeInfo(MonoReflectionType* reflectionType) {
|
||||
ManagedComponentTypeInfo ResolveManagedComponentTypeInfo(MonoClass* monoClass) {
|
||||
ManagedComponentTypeInfo typeInfo;
|
||||
if (!reflectionType) {
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
MonoType* monoType = mono_reflection_type_get_type(reflectionType);
|
||||
if (!monoType) {
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
MonoClass* monoClass = mono_class_from_mono_type(monoType);
|
||||
if (!monoClass) {
|
||||
return typeInfo;
|
||||
}
|
||||
@@ -163,6 +153,19 @@ ManagedComponentTypeInfo ResolveManagedComponentTypeInfo(MonoReflectionType* ref
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
ManagedComponentTypeInfo ResolveManagedComponentTypeInfo(MonoReflectionType* reflectionType) {
|
||||
if (!reflectionType) {
|
||||
return {};
|
||||
}
|
||||
|
||||
MonoType* monoType = mono_reflection_type_get_type(reflectionType);
|
||||
if (!monoType) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return ResolveManagedComponentTypeInfo(mono_class_from_mono_type(monoType));
|
||||
}
|
||||
|
||||
Components::GameObject* FindGameObjectByUUIDRecursive(Components::GameObject* gameObject, uint64_t uuid) {
|
||||
if (!gameObject) {
|
||||
return nullptr;
|
||||
@@ -231,28 +234,32 @@ ScriptComponent* FindScriptComponentByUUID(uint64_t scriptComponentUUID) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool HasNativeComponent(Components::GameObject* gameObject, ManagedComponentKind componentKind) {
|
||||
Components::Component* FindNativeComponent(Components::GameObject* gameObject, ManagedComponentKind componentKind) {
|
||||
if (!gameObject) {
|
||||
return false;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
switch (componentKind) {
|
||||
case ManagedComponentKind::Transform:
|
||||
return gameObject->GetTransform() != nullptr;
|
||||
return gameObject->GetTransform();
|
||||
case ManagedComponentKind::Camera:
|
||||
return gameObject->GetComponent<Components::CameraComponent>() != nullptr;
|
||||
return gameObject->GetComponent<Components::CameraComponent>();
|
||||
case ManagedComponentKind::Light:
|
||||
return gameObject->GetComponent<Components::LightComponent>() != nullptr;
|
||||
return gameObject->GetComponent<Components::LightComponent>();
|
||||
case ManagedComponentKind::MeshFilter:
|
||||
return gameObject->GetComponent<Components::MeshFilterComponent>() != nullptr;
|
||||
return gameObject->GetComponent<Components::MeshFilterComponent>();
|
||||
case ManagedComponentKind::MeshRenderer:
|
||||
return gameObject->GetComponent<Components::MeshRendererComponent>() != nullptr;
|
||||
return gameObject->GetComponent<Components::MeshRendererComponent>();
|
||||
case ManagedComponentKind::Script:
|
||||
case ManagedComponentKind::Unknown:
|
||||
return false;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return false;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool HasNativeComponent(Components::GameObject* gameObject, ManagedComponentKind componentKind) {
|
||||
return FindNativeComponent(gameObject, componentKind) != nullptr;
|
||||
}
|
||||
|
||||
Components::Component* AddOrGetNativeComponent(Components::GameObject* gameObject, ManagedComponentKind componentKind) {
|
||||
@@ -309,6 +316,87 @@ ScriptComponent* FindMatchingScriptComponent(
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ScriptComponent* FindMatchingScriptComponentInChildren(
|
||||
Components::GameObject* gameObject,
|
||||
const ManagedComponentTypeInfo& typeInfo) {
|
||||
if (!gameObject) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (ScriptComponent* component = FindMatchingScriptComponent(gameObject, typeInfo)) {
|
||||
return component;
|
||||
}
|
||||
|
||||
for (Components::GameObject* child : gameObject->GetChildren()) {
|
||||
if (ScriptComponent* component = FindMatchingScriptComponentInChildren(child, typeInfo)) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ScriptComponent* FindMatchingScriptComponentInParent(
|
||||
Components::GameObject* gameObject,
|
||||
const ManagedComponentTypeInfo& typeInfo) {
|
||||
while (gameObject) {
|
||||
if (ScriptComponent* component = FindMatchingScriptComponent(gameObject, typeInfo)) {
|
||||
return component;
|
||||
}
|
||||
|
||||
gameObject = gameObject->GetParent();
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Components::Component* FindNativeComponentInChildren(
|
||||
Components::GameObject* gameObject,
|
||||
ManagedComponentKind componentKind) {
|
||||
if (!gameObject) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (Components::Component* component = FindNativeComponent(gameObject, componentKind)) {
|
||||
return component;
|
||||
}
|
||||
|
||||
for (Components::GameObject* child : gameObject->GetChildren()) {
|
||||
if (Components::Component* component = FindNativeComponentInChildren(child, componentKind)) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Components::Component* FindNativeComponentInParent(
|
||||
Components::GameObject* gameObject,
|
||||
ManagedComponentKind componentKind) {
|
||||
while (gameObject) {
|
||||
if (Components::Component* component = FindNativeComponent(gameObject, componentKind)) {
|
||||
return component;
|
||||
}
|
||||
|
||||
gameObject = gameObject->GetParent();
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool DestroyNativeComponentInstance(Components::GameObject* gameObject, Components::Component* component) {
|
||||
if (!gameObject || !component || component == gameObject->GetTransform()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (component->IsEnabled() && gameObject->IsActiveInHierarchy()) {
|
||||
component->OnDisable();
|
||||
}
|
||||
|
||||
component->OnDestroy();
|
||||
return gameObject->RemoveComponent(component);
|
||||
}
|
||||
|
||||
Components::CameraComponent* FindCameraComponent(uint64_t gameObjectUUID) {
|
||||
Components::GameObject* gameObject = FindGameObjectByUUID(gameObjectUUID);
|
||||
return gameObject ? gameObject->GetComponent<Components::CameraComponent>() : nullptr;
|
||||
@@ -520,6 +608,72 @@ MonoObject* InternalCall_GameObject_GetComponent(uint64_t gameObjectUUID, MonoRe
|
||||
return runtime->CreateManagedComponentWrapper(typeInfo.monoClass, gameObjectUUID);
|
||||
}
|
||||
|
||||
MonoObject* InternalCall_GameObject_GetComponentInChildren(uint64_t gameObjectUUID, MonoReflectionType* componentType) {
|
||||
Components::GameObject* gameObject = FindGameObjectByUUID(gameObjectUUID);
|
||||
if (!gameObject) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
MonoScriptRuntime* runtime = GetActiveMonoScriptRuntime();
|
||||
if (!runtime) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ManagedComponentTypeInfo typeInfo = ResolveManagedComponentTypeInfo(componentType);
|
||||
if (typeInfo.kind == ManagedComponentKind::Script) {
|
||||
ScriptComponent* component = FindMatchingScriptComponentInChildren(gameObject, typeInfo);
|
||||
if (!component) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!runtime->HasManagedInstance(component)) {
|
||||
ScriptEngine::Get().OnScriptComponentEnabled(component);
|
||||
}
|
||||
|
||||
return runtime->GetManagedInstanceObject(component);
|
||||
}
|
||||
|
||||
Components::Component* component = FindNativeComponentInChildren(gameObject, typeInfo.kind);
|
||||
if (!component || !typeInfo.monoClass) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return runtime->CreateManagedComponentWrapper(typeInfo.monoClass, component->GetGameObject()->GetUUID());
|
||||
}
|
||||
|
||||
MonoObject* InternalCall_GameObject_GetComponentInParent(uint64_t gameObjectUUID, MonoReflectionType* componentType) {
|
||||
Components::GameObject* gameObject = FindGameObjectByUUID(gameObjectUUID);
|
||||
if (!gameObject) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
MonoScriptRuntime* runtime = GetActiveMonoScriptRuntime();
|
||||
if (!runtime) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ManagedComponentTypeInfo typeInfo = ResolveManagedComponentTypeInfo(componentType);
|
||||
if (typeInfo.kind == ManagedComponentKind::Script) {
|
||||
ScriptComponent* component = FindMatchingScriptComponentInParent(gameObject, typeInfo);
|
||||
if (!component) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!runtime->HasManagedInstance(component)) {
|
||||
ScriptEngine::Get().OnScriptComponentEnabled(component);
|
||||
}
|
||||
|
||||
return runtime->GetManagedInstanceObject(component);
|
||||
}
|
||||
|
||||
Components::Component* component = FindNativeComponentInParent(gameObject, typeInfo.kind);
|
||||
if (!component || !typeInfo.monoClass) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return runtime->CreateManagedComponentWrapper(typeInfo.monoClass, component->GetGameObject()->GetUUID());
|
||||
}
|
||||
|
||||
MonoObject* InternalCall_GameObject_AddComponent(uint64_t gameObjectUUID, MonoReflectionType* componentType) {
|
||||
Components::GameObject* gameObject = FindGameObjectByUUID(gameObjectUUID);
|
||||
if (!gameObject) {
|
||||
@@ -588,6 +742,15 @@ void InternalCall_GameObject_Destroy(uint64_t gameObjectUUID) {
|
||||
scene->DestroyGameObject(gameObject);
|
||||
}
|
||||
|
||||
void InternalCall_Object_Destroy(MonoObject* object) {
|
||||
MonoScriptRuntime* runtime = GetActiveMonoScriptRuntime();
|
||||
if (!runtime || !object) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime->DestroyManagedObject(object);
|
||||
}
|
||||
|
||||
mono_bool InternalCall_Behaviour_GetEnabled(uint64_t scriptComponentUUID) {
|
||||
ScriptComponent* component = FindScriptComponentByUUID(scriptComponentUUID);
|
||||
return (component && component->IsEnabled()) ? 1 : 0;
|
||||
@@ -1241,10 +1404,13 @@ void RegisterInternalCalls() {
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_SetActive", reinterpret_cast<const void*>(&InternalCall_GameObject_SetActive));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_HasComponent", reinterpret_cast<const void*>(&InternalCall_GameObject_HasComponent));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_GetComponent", reinterpret_cast<const void*>(&InternalCall_GameObject_GetComponent));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_GetComponentInChildren", reinterpret_cast<const void*>(&InternalCall_GameObject_GetComponentInChildren));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_GetComponentInParent", reinterpret_cast<const void*>(&InternalCall_GameObject_GetComponentInParent));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_AddComponent", reinterpret_cast<const void*>(&InternalCall_GameObject_AddComponent));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_Find", reinterpret_cast<const void*>(&InternalCall_GameObject_Find));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_Create", reinterpret_cast<const void*>(&InternalCall_GameObject_Create));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::GameObject_Destroy", reinterpret_cast<const void*>(&InternalCall_GameObject_Destroy));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::Object_Destroy", reinterpret_cast<const void*>(&InternalCall_Object_Destroy));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::Behaviour_GetEnabled", reinterpret_cast<const void*>(&InternalCall_Behaviour_GetEnabled));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::Behaviour_SetEnabled", reinterpret_cast<const void*>(&InternalCall_Behaviour_SetEnabled));
|
||||
mono_add_internal_call("XCEngine.InternalCalls::Transform_GetLocalPosition", reinterpret_cast<const void*>(&InternalCall_Transform_GetLocalPosition));
|
||||
@@ -2224,6 +2390,73 @@ MonoObject* MonoScriptRuntime::CreateManagedGameObject(uint64_t gameObjectUUID)
|
||||
return managedObject;
|
||||
}
|
||||
|
||||
bool MonoScriptRuntime::DestroyManagedObject(MonoObject* managedObject) {
|
||||
if (!m_initialized || !managedObject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SetCurrentDomain();
|
||||
|
||||
MonoClass* monoClass = mono_object_get_class(managedObject);
|
||||
if (!monoClass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (monoClass == m_gameObjectClass) {
|
||||
uint64_t gameObjectUUID = 0;
|
||||
mono_field_get_value(managedObject, m_managedGameObjectUUIDField, &gameObjectUUID);
|
||||
|
||||
Components::Scene* scene = GetInternalCallScene();
|
||||
Components::GameObject* gameObject = FindGameObjectByUUID(gameObjectUUID);
|
||||
if (!scene || !gameObject || gameObject->GetScene() != scene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
scene->DestroyGameObject(gameObject);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (monoClass != m_componentClass && !mono_class_is_subclass_of(monoClass, m_componentClass, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t gameObjectUUID = 0;
|
||||
mono_field_get_value(managedObject, m_gameObjectUUIDField, &gameObjectUUID);
|
||||
|
||||
Components::GameObject* gameObject = FindGameObjectByUUID(gameObjectUUID);
|
||||
if (!gameObject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (monoClass == m_behaviourClass || mono_class_is_subclass_of(monoClass, m_behaviourClass, false)) {
|
||||
uint64_t scriptComponentUUID = 0;
|
||||
mono_field_get_value(managedObject, m_scriptComponentUUIDField, &scriptComponentUUID);
|
||||
|
||||
if (scriptComponentUUID != 0) {
|
||||
ScriptComponent* component = FindScriptComponentByUUID(scriptComponentUUID);
|
||||
return DestroyNativeComponentInstance(gameObject, component);
|
||||
}
|
||||
}
|
||||
|
||||
const ManagedComponentTypeInfo typeInfo = ResolveManagedComponentTypeInfo(monoClass);
|
||||
switch (typeInfo.kind) {
|
||||
case ManagedComponentKind::Camera:
|
||||
return DestroyNativeComponentInstance(gameObject, gameObject->GetComponent<Components::CameraComponent>());
|
||||
case ManagedComponentKind::Light:
|
||||
return DestroyNativeComponentInstance(gameObject, gameObject->GetComponent<Components::LightComponent>());
|
||||
case ManagedComponentKind::MeshFilter:
|
||||
return DestroyNativeComponentInstance(gameObject, gameObject->GetComponent<Components::MeshFilterComponent>());
|
||||
case ManagedComponentKind::MeshRenderer:
|
||||
return DestroyNativeComponentInstance(gameObject, gameObject->GetComponent<Components::MeshRendererComponent>());
|
||||
case ManagedComponentKind::Transform:
|
||||
case ManagedComponentKind::Script:
|
||||
case ManagedComponentKind::Unknown:
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MonoScriptRuntime::TryExtractGameObjectReference(
|
||||
MonoObject* managedObject,
|
||||
GameObjectReference& outReference) const {
|
||||
|
||||
@@ -87,6 +87,7 @@ set(XCENGINE_SCRIPT_CORE_SOURCES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/MeshFilter.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/MeshRenderer.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/MonoBehaviour.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/Object.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/Quaternion.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/Space.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/XCEngine.ScriptCore/Time.cs
|
||||
@@ -107,6 +108,7 @@ set(XCENGINE_GAME_SCRIPT_SOURCES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/LifecycleProbe.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/MeshComponentProbe.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/MeshRendererEdgeCaseProbe.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/ObjectApiProbe.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/TickLogProbe.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/TransformConversionProbe.cs
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameScripts/TransformMotionProbe.cs
|
||||
|
||||
111
managed/GameScripts/ObjectApiProbe.cs
Normal file
111
managed/GameScripts/ObjectApiProbe.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using XCEngine;
|
||||
|
||||
namespace Gameplay
|
||||
{
|
||||
public sealed class ObjectApiMarkerProbe : MonoBehaviour
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class ObjectApiDestroyTargetProbe : MonoBehaviour
|
||||
{
|
||||
public int DisableCount;
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
DisableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ObjectApiProbe : MonoBehaviour
|
||||
{
|
||||
public bool FoundSelfScriptViaParentLookup;
|
||||
public bool FoundSelfScriptViaGameObjectParentLookup;
|
||||
public bool FoundSelfTransformViaChildrenLookup;
|
||||
public bool FoundSelfTransformViaGameObjectChildrenLookup;
|
||||
public bool FoundCameraInParent;
|
||||
public bool FoundCameraInParentViaGameObject;
|
||||
public bool FoundMarkerInParent;
|
||||
public bool FoundMeshRendererInChildren;
|
||||
public bool FoundMeshRendererInChildrenViaGameObject;
|
||||
public bool FoundTargetScriptInChildren;
|
||||
public string ObservedParentCameraHostName = string.Empty;
|
||||
public string ObservedChildMeshRendererHostName = string.Empty;
|
||||
public string ObservedChildScriptHostName = string.Empty;
|
||||
public bool ChildCameraMissingAfterDestroy;
|
||||
public bool ChildScriptMissingAfterDestroy;
|
||||
public bool ChildGameObjectMissingAfterDestroy;
|
||||
public int ObservedChildScriptDisableCount = -1;
|
||||
public int ObservedChildCountAfterDestroy = -1;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
ObjectApiProbe selfViaParent = GetComponentInParent<ObjectApiProbe>();
|
||||
ObjectApiProbe selfViaGameObjectParent = gameObject.GetComponentInParent<ObjectApiProbe>();
|
||||
Transform selfViaChildren = GetComponentInChildren<Transform>();
|
||||
Transform selfViaGameObjectChildren = gameObject.GetComponentInChildren<Transform>();
|
||||
|
||||
FoundSelfScriptViaParentLookup = selfViaParent == this;
|
||||
FoundSelfScriptViaGameObjectParentLookup = selfViaGameObjectParent == this;
|
||||
FoundSelfTransformViaChildrenLookup = selfViaChildren != null
|
||||
&& selfViaChildren.GameObjectUUID == GameObjectUUID;
|
||||
FoundSelfTransformViaGameObjectChildrenLookup = selfViaGameObjectChildren != null
|
||||
&& selfViaGameObjectChildren.GameObjectUUID == GameObjectUUID;
|
||||
|
||||
Camera parentCamera = GetComponentInParent<Camera>();
|
||||
Camera parentCameraViaGameObject = gameObject.GetComponentInParent<Camera>();
|
||||
ObjectApiMarkerProbe parentMarker = GetComponentInParent<ObjectApiMarkerProbe>();
|
||||
MeshRenderer childMeshRenderer = GetComponentInChildren<MeshRenderer>();
|
||||
MeshRenderer childMeshRendererViaGameObject = gameObject.GetComponentInChildren<MeshRenderer>();
|
||||
ObjectApiDestroyTargetProbe childScript = GetComponentInChildren<ObjectApiDestroyTargetProbe>();
|
||||
|
||||
FoundCameraInParent = parentCamera != null;
|
||||
FoundCameraInParentViaGameObject = parentCameraViaGameObject != null;
|
||||
FoundMarkerInParent = parentMarker != null;
|
||||
FoundMeshRendererInChildren = childMeshRenderer != null;
|
||||
FoundMeshRendererInChildrenViaGameObject = childMeshRendererViaGameObject != null;
|
||||
FoundTargetScriptInChildren = childScript != null;
|
||||
|
||||
if (parentCamera != null)
|
||||
{
|
||||
ObservedParentCameraHostName = parentCamera.gameObject.name;
|
||||
}
|
||||
|
||||
if (childMeshRenderer != null)
|
||||
{
|
||||
ObservedChildMeshRendererHostName = childMeshRenderer.gameObject.name;
|
||||
}
|
||||
|
||||
if (childScript != null)
|
||||
{
|
||||
ObservedChildScriptHostName = childScript.gameObject.name;
|
||||
}
|
||||
|
||||
Transform firstChild = transform.childCount > 0 ? transform.GetChild(0) : null;
|
||||
GameObject child = firstChild != null ? firstChild.gameObject : null;
|
||||
if (child == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Camera childCamera = child.GetComponent<Camera>();
|
||||
if (childCamera != null)
|
||||
{
|
||||
Destroy(childCamera);
|
||||
}
|
||||
|
||||
ChildCameraMissingAfterDestroy = child.GetComponent<Camera>() == null;
|
||||
|
||||
if (childScript != null)
|
||||
{
|
||||
Destroy(childScript);
|
||||
ObservedChildScriptDisableCount = childScript.DisableCount;
|
||||
}
|
||||
|
||||
ChildScriptMissingAfterDestroy = child.GetComponent<ObjectApiDestroyTargetProbe>() == null;
|
||||
|
||||
Destroy(child);
|
||||
ChildGameObjectMissingAfterDestroy = GameObject.Find("Child") == null;
|
||||
ObservedChildCountAfterDestroy = transform.childCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Reflection;
|
||||
|
||||
namespace XCEngine
|
||||
{
|
||||
public abstract class Component
|
||||
public abstract class Component : Object
|
||||
{
|
||||
internal ulong m_gameObjectUUID;
|
||||
|
||||
@@ -34,6 +34,16 @@ namespace XCEngine
|
||||
return GameObject.GetComponent<T>();
|
||||
}
|
||||
|
||||
public T GetComponentInChildren<T>() where T : Component
|
||||
{
|
||||
return GameObject.GetComponentInChildren<T>();
|
||||
}
|
||||
|
||||
public T GetComponentInParent<T>() where T : Component
|
||||
{
|
||||
return GameObject.GetComponentInParent<T>();
|
||||
}
|
||||
|
||||
public T AddComponent<T>() where T : Component
|
||||
{
|
||||
return GameObject.AddComponent<T>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace XCEngine
|
||||
{
|
||||
public sealed class GameObject
|
||||
public sealed class GameObject : Object
|
||||
{
|
||||
private readonly ulong m_uuid;
|
||||
|
||||
@@ -67,6 +67,16 @@ namespace XCEngine
|
||||
return InternalCalls.GameObject_GetComponent(UUID, typeof(T)) as T;
|
||||
}
|
||||
|
||||
public T GetComponentInChildren<T>() where T : Component
|
||||
{
|
||||
return InternalCalls.GameObject_GetComponentInChildren(UUID, typeof(T)) as T;
|
||||
}
|
||||
|
||||
public T GetComponentInParent<T>() where T : Component
|
||||
{
|
||||
return InternalCalls.GameObject_GetComponentInParent(UUID, typeof(T)) as T;
|
||||
}
|
||||
|
||||
public T AddComponent<T>() where T : Component
|
||||
{
|
||||
return InternalCalls.GameObject_AddComponent(UUID, typeof(T)) as T;
|
||||
|
||||
@@ -86,6 +86,12 @@ namespace XCEngine
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern Component GameObject_GetComponent(ulong gameObjectUUID, Type componentType);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern Component GameObject_GetComponentInChildren(ulong gameObjectUUID, Type componentType);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern Component GameObject_GetComponentInParent(ulong gameObjectUUID, Type componentType);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern Component GameObject_AddComponent(ulong gameObjectUUID, Type componentType);
|
||||
|
||||
@@ -98,6 +104,9 @@ namespace XCEngine
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern void GameObject_Destroy(ulong gameObjectUUID);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern void Object_Destroy(global::XCEngine.Object obj);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall)]
|
||||
internal static extern bool Behaviour_GetEnabled(ulong scriptComponentUUID);
|
||||
|
||||
|
||||
19
managed/XCEngine.ScriptCore/Object.cs
Normal file
19
managed/XCEngine.ScriptCore/Object.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace XCEngine
|
||||
{
|
||||
public abstract class Object
|
||||
{
|
||||
protected Object()
|
||||
{
|
||||
}
|
||||
|
||||
public static void Destroy(Object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InternalCalls.Object_Destroy(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1920,6 +1920,89 @@ TEST_F(MonoScriptRuntimeTest, TransformHierarchyApiExposesParentChildAndReparent
|
||||
EXPECT_EQ(reparentTarget->GetChildCount(), 1u);
|
||||
}
|
||||
|
||||
TEST_F(MonoScriptRuntimeTest, UnityObjectApiSupportsHierarchyLookupAndDestroy) {
|
||||
Scene* runtimeScene = CreateScene("MonoRuntimeScene");
|
||||
GameObject* root = runtimeScene->CreateGameObject("Root");
|
||||
GameObject* host = runtimeScene->CreateGameObject("Host", root);
|
||||
GameObject* child = runtimeScene->CreateGameObject("Child", host);
|
||||
|
||||
root->AddComponent<CameraComponent>();
|
||||
child->AddComponent<CameraComponent>();
|
||||
child->AddComponent<MeshRendererComponent>();
|
||||
|
||||
AddScript(root, "Gameplay", "ObjectApiMarkerProbe");
|
||||
AddScript(child, "Gameplay", "ObjectApiDestroyTargetProbe");
|
||||
ScriptComponent* hostProbe = AddScript(host, "Gameplay", "ObjectApiProbe");
|
||||
|
||||
engine->OnRuntimeStart(runtimeScene);
|
||||
engine->OnUpdate(0.016f);
|
||||
|
||||
bool foundSelfScriptViaParentLookup = false;
|
||||
bool foundSelfScriptViaGameObjectParentLookup = false;
|
||||
bool foundSelfTransformViaChildrenLookup = false;
|
||||
bool foundSelfTransformViaGameObjectChildrenLookup = false;
|
||||
bool foundCameraInParent = false;
|
||||
bool foundCameraInParentViaGameObject = false;
|
||||
bool foundMarkerInParent = false;
|
||||
bool foundMeshRendererInChildren = false;
|
||||
bool foundMeshRendererInChildrenViaGameObject = false;
|
||||
bool foundTargetScriptInChildren = false;
|
||||
bool childCameraMissingAfterDestroy = false;
|
||||
bool childScriptMissingAfterDestroy = false;
|
||||
bool childGameObjectMissingAfterDestroy = false;
|
||||
int32_t observedChildScriptDisableCount = -1;
|
||||
int32_t observedChildCountAfterDestroy = -1;
|
||||
std::string observedParentCameraHostName;
|
||||
std::string observedChildMeshRendererHostName;
|
||||
std::string observedChildScriptHostName;
|
||||
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundSelfScriptViaParentLookup", foundSelfScriptViaParentLookup));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundSelfScriptViaGameObjectParentLookup", foundSelfScriptViaGameObjectParentLookup));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundSelfTransformViaChildrenLookup", foundSelfTransformViaChildrenLookup));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundSelfTransformViaGameObjectChildrenLookup", foundSelfTransformViaGameObjectChildrenLookup));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundCameraInParent", foundCameraInParent));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundCameraInParentViaGameObject", foundCameraInParentViaGameObject));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundMarkerInParent", foundMarkerInParent));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundMeshRendererInChildren", foundMeshRendererInChildren));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundMeshRendererInChildrenViaGameObject", foundMeshRendererInChildrenViaGameObject));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "FoundTargetScriptInChildren", foundTargetScriptInChildren));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ChildCameraMissingAfterDestroy", childCameraMissingAfterDestroy));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ChildScriptMissingAfterDestroy", childScriptMissingAfterDestroy));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ChildGameObjectMissingAfterDestroy", childGameObjectMissingAfterDestroy));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ObservedChildScriptDisableCount", observedChildScriptDisableCount));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ObservedChildCountAfterDestroy", observedChildCountAfterDestroy));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ObservedParentCameraHostName", observedParentCameraHostName));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ObservedChildMeshRendererHostName", observedChildMeshRendererHostName));
|
||||
EXPECT_TRUE(runtime->TryGetFieldValue(hostProbe, "ObservedChildScriptHostName", observedChildScriptHostName));
|
||||
|
||||
EXPECT_TRUE(foundSelfScriptViaParentLookup);
|
||||
EXPECT_TRUE(foundSelfScriptViaGameObjectParentLookup);
|
||||
EXPECT_TRUE(foundSelfTransformViaChildrenLookup);
|
||||
EXPECT_TRUE(foundSelfTransformViaGameObjectChildrenLookup);
|
||||
EXPECT_TRUE(foundCameraInParent);
|
||||
EXPECT_TRUE(foundCameraInParentViaGameObject);
|
||||
EXPECT_TRUE(foundMarkerInParent);
|
||||
EXPECT_TRUE(foundMeshRendererInChildren);
|
||||
EXPECT_TRUE(foundMeshRendererInChildrenViaGameObject);
|
||||
EXPECT_TRUE(foundTargetScriptInChildren);
|
||||
EXPECT_TRUE(childCameraMissingAfterDestroy);
|
||||
EXPECT_TRUE(childScriptMissingAfterDestroy);
|
||||
EXPECT_TRUE(childGameObjectMissingAfterDestroy);
|
||||
EXPECT_EQ(observedChildScriptDisableCount, 1);
|
||||
EXPECT_EQ(observedChildCountAfterDestroy, 0);
|
||||
EXPECT_EQ(observedParentCameraHostName, "Root");
|
||||
EXPECT_EQ(observedChildMeshRendererHostName, "Child");
|
||||
EXPECT_EQ(observedChildScriptHostName, "Child");
|
||||
|
||||
EXPECT_EQ(runtimeScene->Find("Child"), nullptr);
|
||||
EXPECT_EQ(host->GetChildCount(), 0u);
|
||||
EXPECT_EQ(host->GetComponentInChildren<CameraComponent>(), nullptr);
|
||||
EXPECT_EQ(host->GetComponentInChildren<MeshRendererComponent>(), nullptr);
|
||||
EXPECT_EQ(host->GetComponents<ScriptComponent>().size(), 1u);
|
||||
EXPECT_EQ(root->GetComponents<ScriptComponent>().size(), 1u);
|
||||
EXPECT_EQ(runtime->GetManagedInstanceCount(), 2u);
|
||||
}
|
||||
|
||||
TEST_F(MonoScriptRuntimeTest, TransformSpaceApiReadsAndWritesWorldAndLocalValues) {
|
||||
Scene* runtimeScene = CreateScene("MonoRuntimeScene");
|
||||
GameObject* parent = runtimeScene->CreateGameObject("Parent");
|
||||
|
||||
Reference in New Issue
Block a user