From 3403822a52d4d5b7f89059a0350c31fb086638d7 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Sat, 3 Oct 2026 18:02:49 +0800 Subject: [PATCH] feat(engine): C++ native script system (ScriptBehavior + registry) ScriptComponent stores only a script name string (POD, serializable); instances are reconciled by ScriptRuntime each frame (PhysicsWorld pattern): orphan/rename cleanup with OnDestroy, per-name instantiation from a file-level self-registering registry (ENGINE_REGISTER_SCRIPT), OnCreate on bind, OnUpdate after physics step on scaled dt. Engine framerate loop only sees the base interface, so future Lua/C# behaviors plug in as foreign ScriptBehavior implementations with zero engine changes. Ships a Spinner sample script and Inspector script picker. --- App/CMakeLists.txt | 7 +- App/editor/InspectorPanel.cpp | 39 ++++++ App/scripts/Spinner.cpp | 38 ++++++ Engine/include/Engine/Core/Application.h | 3 + Engine/include/Engine/ECS/Components.h | 9 ++ Engine/include/Engine/Scripting/Script.h | 115 ++++++++++++++++++ .../include/Engine/Scripting/ScriptRuntime.h | 42 +++++++ Engine/src/Core/Application.cpp | 8 ++ Engine/src/Scripting/ScriptRuntime.cpp | 75 ++++++++++++ Engine/src/Serialization/SceneSerializer.cpp | 14 +++ 10 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 App/scripts/Spinner.cpp create mode 100644 Engine/include/Engine/Scripting/Script.h create mode 100644 Engine/include/Engine/Scripting/ScriptRuntime.h create mode 100644 Engine/src/Scripting/ScriptRuntime.cpp diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index efa036b..84bf4ff 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -1,9 +1,12 @@ -# App 层源文件:main + editor/(docking 编辑器,纯 App 代码) +# App 层源文件:main + editor/(docking 编辑器)+ scripts/(C++ 原生脚本) file(GLOB EDITOR_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/editor/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/editor/*.h) -add_executable(HelloVulkan main.cpp ${EDITOR_SOURCES}) +file(GLOB SCRIPT_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/*.cpp) + +add_executable(HelloVulkan main.cpp ${EDITOR_SOURCES} ${SCRIPT_SOURCES}) target_include_directories(HelloVulkan PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/editor) diff --git a/App/editor/InspectorPanel.cpp b/App/editor/InspectorPanel.cpp index 90b0c3a..66af60e 100644 --- a/App/editor/InspectorPanel.cpp +++ b/App/editor/InspectorPanel.cpp @@ -9,6 +9,7 @@ #include "Engine/ECS/Components.h" #include "Engine/Renderer/Model.h" #include "Engine/Physics/PhysicsWorld.h" // Apply Impulse 调试(GetPhysics 门面) +#include "Engine/Scripting/Script.h" // 脚本下拉(ScriptRegistry::Names) #include @@ -126,6 +127,11 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) { reg.emplace(selected); } + if (!reg.all_of(selected) && + ImGui::MenuItem("Script")) + { + reg.emplace(selected); + } ImGui::EndPopup(); } ImGui::Separator(); @@ -428,6 +434,39 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) } } + // ---- Script(按名解析注册表脚本;实例生命周期在 ScriptRuntime)---- + if (auto* sc = reg.try_get(selected)) + { + if (ImGui::CollapsingHeader("Script", ImGuiTreeNodeFlags_DefaultOpen)) + { + // 下拉列出全部已注册脚本(注册序);"" = 清空(对账自动销毁实例) + const auto& names = Engine::Scripting::ScriptRegistry::Names(); + std::vector items; + items.push_back(""); + for (const auto& n : names) + { + items.push_back(n.c_str()); + } + int cur = 0; + for (size_t i = 0; i < names.size(); ++i) + { + if (names[i] == sc->scriptName) + { + cur = static_cast(i) + 1; + } + } + if (ImGui::Combo("script", &cur, items.data(), + static_cast(items.size()))) + { + sc->scriptName = (cur <= 0) ? "" : names[static_cast(cur) - 1]; + } + if (sc->scriptName.empty()) + { + ImGui::TextDisabled("no script assigned"); + } + } + } + // ---- Animation ---- if (auto* anim = reg.try_get(selected)) { diff --git a/App/scripts/Spinner.cpp b/App/scripts/Spinner.cpp new file mode 100644 index 0000000..3af4af7 --- /dev/null +++ b/App/scripts/Spinner.cpp @@ -0,0 +1,38 @@ +// +// Spinner.cpp:C++ 原生脚本示范——Y 轴匀速自转。 +// +// 写脚本三步: +// 1. 新建 .cpp 放本目录(CMake 自动进构建) +// 2. 继承 ScriptBehavior,覆写 Name/OnCreate/OnUpdate/OnDestroy +// 3. 文件级 ENGINE_REGISTER_SCRIPT(类名) 自注册——Inspector 下拉 +// 与场景序列化按该名解析 +// + +#include + +#include + +namespace { + + class Spinner final : public Engine::Scripting::ScriptBehavior { + public: + const char* Name() const override { return "Spinner"; } + + void OnCreate() override { + // 实体与组件已就位;需要别的组件时用 Get()(不存在会炸) + } + + void OnUpdate(float dt) override { + // 四元数复合绕 Y(避免每帧欧拉↔四元数往返累积失真) + const glm::quat step = glm::angleAxis( + glm::radians(m_Speed * dt), glm::vec3(0.0f, 1.0f, 0.0f)); + Transform().SetRotationQuat(step * Transform().GetRotationQuat()); + } + + private: + float m_Speed = 90.0f; // 度/秒 + }; + + ENGINE_REGISTER_SCRIPT(Spinner) + +} // namespace diff --git a/Engine/include/Engine/Core/Application.h b/Engine/include/Engine/Core/Application.h index 5a6cb90..e4b3327 100644 --- a/Engine/include/Engine/Core/Application.h +++ b/Engine/include/Engine/Core/Application.h @@ -36,6 +36,7 @@ namespace Engine { class Renderer; class ImGuiLayer; class PhysicsWorld; + namespace Scripting { class ScriptRuntime; } namespace ECS { class Scene; } namespace Assets { class AssetManager; } @@ -73,6 +74,7 @@ namespace Engine { ECS::Scene& GetScene() const { return *m_Scene; } Assets::AssetManager& GetAssets() const { return *m_Assets; } PhysicsWorld& GetPhysics() const { return *m_Physics; } + Scripting::ScriptRuntime& GetScripts() const { return *m_Scripts; } u64 GetFrameCount() const { return m_FrameCount; } // ImGui相关 @@ -120,6 +122,7 @@ namespace Engine { Scope m_Renderer; Scope m_Assets; Scope m_Physics; // Jolt 物理(Run 起循环时创建) + Scope m_Scripts; // 脚本生命周期(Run 起循环时创建) // 注意成员序:ImGuiLayer 必须后声明(先于 Renderer/Window 析构, // 其后端销毁需要 Device 与 GLFWwindow 存活) Scope m_ImGuiLayer; diff --git a/Engine/include/Engine/ECS/Components.h b/Engine/include/Engine/ECS/Components.h index e08a674..a648940 100644 --- a/Engine/include/Engine/ECS/Components.h +++ b/Engine/include/Engine/ECS/Components.h @@ -274,6 +274,15 @@ namespace Engine::ECS { // 实体原点 = 脚底(胶囊底端),符合角色 pivot 习惯 }; + // 脚本组件:只存脚本类名(注册表键,POD 可序列化)。C++ 原生脚本经 + // ENGINE_REGISTER_SCRIPT 自注册;将来 Lua/C# 脚本同键解析。 + // 实例生命周期由 ScriptRuntime 对账管理(不进组件,保持可拷贝) + struct ScriptComponent { + std::string scriptName; + + bool operator==(const ScriptComponent&) const = default; + }; + } // namespace Engine::ECS #endif //HELLOVULKAN_COMPONENTS_H diff --git a/Engine/include/Engine/Scripting/Script.h b/Engine/include/Engine/Scripting/Script.h new file mode 100644 index 0000000..11aa733 --- /dev/null +++ b/Engine/include/Engine/Scripting/Script.h @@ -0,0 +1,115 @@ +// +// Script.h:C++ 原生脚本设施(行为基类 + 按名注册表)。 +// +// 设计参照 UE:脚本 = 挂在实体上的行为对象 + 统一基类生命周期 +//(OnCreate/OnUpdate/OnDestroy,BeginPlay/Tick 语义)。引擎帧循环只认 +// 基类接口——将来接 Lua/C# 时各实现一种 foreign behavior(内部持 +// VM 状态),引擎零改动。 +// +// 组件只存 scriptName 字符串(POD,序列化天然支持:Lua/C# 同样按名 +// 解析);实例由 ScriptRuntime 对账式管理(照 PhysicsWorld 模式), +// 不进组件——组件可拷贝/可比较。 +// +// 脚本作者注意: +// OnCreate/OnUpdate 阶段实体与组件已就位(假定所需组件存在); +// OnDestroy 只做自身清理——可能因实体销毁触发,句柄已失效。 +// + +#ifndef HELLOVULKAN_SCRIPT_H +#define HELLOVULKAN_SCRIPT_H + +#include +#include +#include + +#include +#include +#include + +namespace Engine { + namespace Scripting { + + class ScriptBehavior { + public: + virtual ~ScriptBehavior() = default; + + // 注册表键(= 组件 scriptName),须与 ENGINE_REGISTER_SCRIPT 名一致 + virtual const char* Name() const = 0; + + // 生命周期(吃 scaled dt:编辑器暂停 timeScale=0 时自然停摆) + virtual void OnCreate() {} + virtual void OnUpdate(f32 dt) {} + virtual void OnDestroy() {} + + protected: + // 便捷访问:绑定实体的 TransformComponent + ECS::TransformComponent& Transform() { + return m_Scene->GetRegistry().get(m_Entity); + } + + template + T& Get() { + return m_Scene->GetRegistry().get(m_Entity); + } + + bool Valid() const { return m_Scene != nullptr && m_Entity != entt::null; } + + private: + friend class ScriptRuntime; + void Bind(ECS::Scene& scene, entt::entity e) { + m_Scene = &scene; + m_Entity = e; + } + + ECS::Scene* m_Scene = nullptr; + entt::entity m_Entity = entt::null; + }; + + // ---- 按名注册表:脚本类文件级自注册,运行时按名实例化 ---- + class ScriptRegistry { + public: + using Factory = Scope (*)(); + + static void Register(const std::string& name, Factory f) { + Map()[name] = f; + Names().push_back(name); // Inspector 下拉用(保持注册序) + } + + static Scope Create(const std::string& name) { + auto& m = Map(); + const auto it = m.find(name); + return it != m.end() ? it->second() : nullptr; + } + + static bool Contains(const std::string& name) { + return Map().count(name) != 0; + } + + static std::vector& Names() { + static std::vector v; + return v; + } + + private: + static std::unordered_map& Map() { + static std::unordered_map m; + return m; + } + }; + + } // namespace Scripting +} // namespace Engine + +// 文件级自注册:脚本 cpp 里写一行即可,Inspector 下拉与序列化按 +// 类名(字符串)解析。T 须默认可构造并继承 ScriptBehavior +#define ENGINE_REGISTER_SCRIPT(T) \ + static const bool s_##T##_registered = [] { \ + ::Engine::Scripting::ScriptRegistry::Register( \ + #T, []() -> ::Engine::Scope< \ + ::Engine::Scripting::ScriptBehavior> { \ + return ::Engine::CreateScope(); \ + }); \ + return true; \ + }(); + +#endif //HELLOVULKAN_SCRIPT_H diff --git a/Engine/include/Engine/Scripting/ScriptRuntime.h b/Engine/include/Engine/Scripting/ScriptRuntime.h new file mode 100644 index 0000000..4954ebf --- /dev/null +++ b/Engine/include/Engine/Scripting/ScriptRuntime.h @@ -0,0 +1,42 @@ +// +// ScriptRuntime.h:脚本生命周期驱动。对账式管理(组件加/删/改名 ↔ +// 实例建/毁),照 PhysicsWorld.SyncScene 模式——每帧对账,编辑器操作 +// 即时生效,场景加载/实体销毁自动消化。 +// + +#ifndef HELLOVULKAN_SCRIPT_RUNTIME_H +#define HELLOVULKAN_SCRIPT_RUNTIME_H + +#include +#include +#include + +#include + +namespace Engine { + namespace Scripting { + + // 实例簿记:行为对象 + OnCreate 是否已调 + struct ScriptInstance { + Scope behavior; + bool created = false; + }; + + class ScriptRuntime { + public: + explicit ScriptRuntime(ECS::Scene& scene) : m_Scene(scene) {} + + // 每帧调用(吃 scaled dt):对账 → OnCreate(新实例)→ OnUpdate + void Update(f32 dt); + + private: + void Sync(); + + ECS::Scene& m_Scene; + std::unordered_map m_Instances; // entityBits + }; + + } // namespace Scripting +} // namespace Engine + +#endif //HELLOVULKAN_SCRIPT_RUNTIME_H diff --git a/Engine/src/Core/Application.cpp b/Engine/src/Core/Application.cpp index 46e347f..769cb68 100644 --- a/Engine/src/Core/Application.cpp +++ b/Engine/src/Core/Application.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -102,6 +103,9 @@ namespace Engine { // 物理调试图元 → 渲染侧(PhysicsDebugPass 每帧上传绘制) m_Renderer->SetPhysicsDebugSource(&m_Physics->GetDebugFrame()); + // 脚本生命周期(对账式,组件增删/改名即时生效) + m_Scripts = CreateScope(*m_Scene); + while (m_Running) { const auto frameStart = std::chrono::steady_clock::now(); @@ -121,6 +125,10 @@ namespace Engine { // SyncScene 对账仍跑(拖静态体照常同步) m_Physics->Step(frame.Dt()); + // 脚本更新(物理之后、业务 OnUpdate 之前,Unity FixedUpdate→Update + // 语义;吃 scaled dt——timeScale=0 暂停时 dt=0 脚本自然停摆) + m_Scripts->Update(frame.Dt()); + OnUpdate(frame); // 延迟销毁在渲染前统一 flush,保证渲染遍历时池结构不变 diff --git a/Engine/src/Scripting/ScriptRuntime.cpp b/Engine/src/Scripting/ScriptRuntime.cpp new file mode 100644 index 0000000..94f27a8 --- /dev/null +++ b/Engine/src/Scripting/ScriptRuntime.cpp @@ -0,0 +1,75 @@ +// +// ScriptRuntime.cpp:脚本对账与生命周期驱动。 +// + +#include + +#include +#include + +namespace Engine { + namespace Scripting { + + void ScriptRuntime::Update(f32 dt) { + Sync(); + + auto& reg = m_Scene.GetRegistry(); + for (auto& [bits, inst] : m_Instances) { + const auto e = static_cast(bits); + if (!reg.valid(e) || !reg.any_of(e)) { + continue; // 延迟销毁队列里,下帧 Sync 清理 + } + if (!inst.created) { + inst.behavior->OnCreate(); + inst.created = true; + } + inst.behavior->OnUpdate(dt); + } + } + + void ScriptRuntime::Sync() { + auto& reg = m_Scene.GetRegistry(); + + // 1. 孤儿/改名清理:实体或组件没了、脚本名变了 → OnDestroy + 删除 + for (auto it = m_Instances.begin(); it != m_Instances.end();) { + const auto e = static_cast(it->first); + const ECS::ScriptComponent* sc = + reg.valid(e) ? reg.try_get(e) : nullptr; + const bool stale = !sc || !it->second.behavior || + it->second.behavior->Name() != sc->scriptName; + if (stale) { + if (it->second.behavior) { + it->second.behavior->OnDestroy(); + } + it = m_Instances.erase(it); + } else { + ++it; + } + } + + // 2. 缺失实例化:按名查注册表(未知名警告——场景含未注册脚本时 + // 弱前向兼容,跳过不影响其余实体) + const auto view = reg.view(); + for (auto [e, sc] : view.each()) { + const u32 bits = static_cast(entt::to_integral(e)); + if (m_Instances.contains(bits) || sc.scriptName.empty()) { + continue; + } + auto behavior = ScriptRegistry::Create(sc.scriptName); + if (!behavior) { + ENGINE_WARN("Script '{}' not registered (entity {})", + sc.scriptName, bits); + continue; + } + behavior->Bind(m_Scene, e); + + ScriptInstance inst; + inst.behavior = std::move(behavior); + inst.behavior->OnCreate(); // Unity Awake 语义:实例化即回调 + inst.created = true; + m_Instances.emplace(bits, std::move(inst)); + } + } + + } // namespace Scripting +} // namespace Engine diff --git a/Engine/src/Serialization/SceneSerializer.cpp b/Engine/src/Serialization/SceneSerializer.cpp index afa45ff..3cdbf22 100644 --- a/Engine/src/Serialization/SceneSerializer.cpp +++ b/Engine/src/Serialization/SceneSerializer.cpp @@ -343,6 +343,14 @@ namespace Engine::Serialization { out << YAML::EndMap; } + // 脚本只存类名(注册表键);未注册名字加载后运行时警告跳过 + if (const auto* sc = reg.try_get(e)) { + out << YAML::Key << "Script" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "ScriptName" << YAML::Value + << sc->scriptName; + out << YAML::EndMap; + } + out << YAML::EndMap; // Components out << YAML::EndMap; // Entity } @@ -628,6 +636,12 @@ namespace Engine::Serialization { cc.stepHeight = n["StepHeight"].as(cc.stepHeight); // 状态(速度/着地)由 PhysicsWorld 对账重建,不存 } + + // 脚本:只存类名;未注册名字由 ScriptRuntime 运行时警告跳过 + if (const YAML::Node n = comps["Script"]) { + auto& sc = handle.AddComponent(); + sc.scriptName = n["ScriptName"].as(""); + } } // 加载在 OnImGuiRender 段,错过本帧 UpdateTransforms: