From ab7a43a68666177a291891336b637fe028ab2212 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Sun, 4 Oct 2026 00:43:06 +0800 Subject: [PATCH] feat(editor): multi-script entities + asset browser panel ScriptComponent now holds a list of {name, active} items (Unity semantics: one entity, many scripts, each with its own enable bit; component-level active stays as the master switch). ScriptRuntime reconciles instances by name per entity -- rename/remove destroys only the affected instances, untouched ones keep ticking. Scenes persist Script.Items; old single-script key still loads. AssetBrowserPanel (lightweight Unity Project window): fixed roots (assets/scenes) as tabs, recursive directory tree, file list with type filter (models/textures/scenes) and name substring filter, double-click preloads models/HDRs into the asset caches (path- deduped). Scene files point to the File menu for now; drag & drop is a later iteration. Docked with Stats by default in fresh layouts. --- App/editor/AssetBrowserPanel.cpp | 240 ++++++++++++++++++ App/editor/AssetBrowserPanel.h | 40 +++ App/editor/Editor.cpp | 5 + App/editor/Editor.h | 3 + App/editor/InspectorPanel.cpp | 61 +++-- Engine/include/Engine/ECS/Components.h | 17 +- .../include/Engine/Scripting/ScriptRuntime.h | 11 +- Engine/src/Scripting/ScriptRuntime.cpp | 113 ++++++--- Engine/src/Serialization/SceneSerializer.cpp | 32 ++- 9 files changed, 459 insertions(+), 63 deletions(-) create mode 100644 App/editor/AssetBrowserPanel.cpp create mode 100644 App/editor/AssetBrowserPanel.h diff --git a/App/editor/AssetBrowserPanel.cpp b/App/editor/AssetBrowserPanel.cpp new file mode 100644 index 0000000..d3052cb --- /dev/null +++ b/App/editor/AssetBrowserPanel.cpp @@ -0,0 +1,240 @@ +// +// AssetBrowserPanel.cpp:项目资产浏览器实现。 +// + +#include "AssetBrowserPanel.h" + +#include + +#include + +#include +#include +#include +#include + +namespace { + + // 固定根:下标与 Editor 无耦合,仅面板内部使用 + constexpr const char* kRootNames[] = {"Assets", "Scenes"}; + constexpr const char* kRootPaths[] = {"assets", "scenes"}; + + // 类型过滤(下标即身份):0 = All + constexpr const char* kTypeNames[] = {"All", "Models", "Textures", + "Scenes"}; + + // 资产类别:按扩展名分派(过滤用) + enum class Kind { Model, Texture, Scene, Other }; + + Kind KindOf(const std::filesystem::path& p) + { + std::string ext = p.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" || + ext == ".obj") + { + return Kind::Model; + } + if (ext == ".hdr" || ext == ".png" || ext == ".jpg" || + ext == ".jpeg") + { + return Kind::Texture; + } + if (ext == ".scene") + { + return Kind::Scene; + } + return Kind::Other; + } + + bool PassFilter(int typeFilter, Kind kind) + { + switch (typeFilter) + { + case 1: return kind == Kind::Model; + case 2: return kind == Kind::Texture; + case 3: return kind == Kind::Scene; + default: return true; + } + } + + // 列表条目前缀符号(ImGui 默认字体无图标字形,用 ASCII 标签) + const char* KindTag(Kind kind) + { + switch (kind) + { + case Kind::Model: return "[M]"; + case Kind::Texture: return "[T]"; + case Kind::Scene: return "[S]"; + default: return "[ ]"; + } + } + + // 相对路径规范化为正斜杠(AssetManager 键与场景文件统一 POSIX 风格) + std::string ToAssetPath(const std::filesystem::path& p) + { + std::string s = p.generic_string(); + std::replace(s.begin(), s.end(), '\\', '/'); + return s; + } +} // namespace + +void AssetBrowserPanel::Draw(const EditorContext& ctx) +{ + if (!ImGui::Begin("Asset Browser")) + { + ImGui::End(); + return; + } + + // ---- 顶栏:根切换 + 类型过滤 + 名字过滤 ---- + if (ImGui::BeginTabBar("##roots")) + { + for (int i = 0; i < 2; ++i) + { + if (ImGui::BeginTabItem(kRootNames[i])) + { + m_Root = i; + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + ImGui::Combo("##type", &m_TypeFilter, kTypeNames, 4); + ImGui::SameLine(); + ImGui::SetNextItemWidth(140.0f); + ImGui::InputTextWithHint("##name", "filter", m_NameFilter, + sizeof(m_NameFilter)); + ImGui::Separator(); + + const std::filesystem::path root(kRootPaths[m_Root]); + + // ---- 左:目录树 | 右:文件列表 ---- + const float treeWidth = 180.0f; + ImGui::BeginChild("##tree", ImVec2(treeWidth, 0.0f), + ImGuiChildFlags_ResizeX | ImGuiChildFlags_Borders); + if (std::filesystem::exists(root)) + { + ImGui::SetNextItemOpen(true, ImGuiCond_Once); + DrawDirTree(ctx, root); + } + else + { + ImGui::TextDisabled("(no '%s' dir)", kRootPaths[m_Root]); + } + ImGui::EndChild(); + + ImGui::SameLine(); + ImGui::BeginChild("##files", ImVec2(0.0f, 0.0f), + ImGuiChildFlags_Borders); + if (std::filesystem::exists(root)) + { + DrawFileList(ctx, root); + } + ImGui::EndChild(); + + ImGui::End(); +} + +void AssetBrowserPanel::DrawDirTree(const EditorContext& ctx, + const std::filesystem::path& dir) +{ + for (const auto& entry : std::filesystem::directory_iterator(dir)) + { + if (!entry.is_directory()) + { + continue; + } + const std::string name = entry.path().filename().string(); + if (ImGui::TreeNode(name.c_str())) + { + DrawDirTree(ctx, entry.path()); + ImGui::TreePop(); + } + } +} + +void AssetBrowserPanel::DrawFileList(const EditorContext& ctx, + const std::filesystem::path& dir) +{ + // 收集 + 过滤 + 排序(名字序;目录迭代序不稳定) + struct Item + { + std::filesystem::path path; + Kind kind; + }; + std::vector items; + const std::string filter = m_NameFilter; + for (const auto& entry : std::filesystem::directory_iterator(dir)) + { + if (entry.is_directory()) + { + continue; + } + const Kind kind = KindOf(entry.path()); + if (!PassFilter(m_TypeFilter, kind)) + { + continue; + } + if (!filter.empty()) + { + const std::string name = entry.path().filename().string(); + if (name.find(filter) == std::string::npos) + { + continue; + } + } + items.push_back({entry.path(), kind}); + } + std::sort(items.begin(), items.end(), + [](const Item& a, const Item& b) + { + return a.path.filename().string() < + b.path.filename().string(); + }); + + for (const auto& item : items) + { + const std::string name = item.path.filename().string(); + const std::string assetPath = ToAssetPath(item.path); + ImGui::PushID(name.c_str()); + + ImGui::TextUnformatted(KindTag(item.kind)); + ImGui::SameLine(); + ImGui::Selectable(name.c_str()); + + // 双击预热:model/HDR 进缓存(路径去重,重复双击零成本); + // 场景加载含环境恢复/选中清理流程,提示走 File 菜单 + if (ImGui::IsItemHovered() && + ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + { + if (item.kind == Kind::Model && ctx.assets) + { + const auto ref = ctx.assets->LoadModel(assetPath); + ENGINE_INFO("AssetBrowser: model '{}' loaded (valid={})", + assetPath, ref.Valid()); + } + else if (item.kind == Kind::Texture && ctx.assets) + { + const auto ref = ctx.assets->LoadEnvironment(assetPath); + ENGINE_INFO("AssetBrowser: env '{}' loaded (valid={})", + assetPath, ref.Valid()); + } + else if (item.kind == Kind::Scene) + { + ENGINE_INFO("AssetBrowser: load scenes via File menu ({})", + assetPath); + } + } + + // tooltip:完整相对路径(面板窄时文件名截断仍可辨识) + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", assetPath.c_str()); + } + ImGui::PopID(); + } +} diff --git a/App/editor/AssetBrowserPanel.h b/App/editor/AssetBrowserPanel.h new file mode 100644 index 0000000..e759281 --- /dev/null +++ b/App/editor/AssetBrowserPanel.h @@ -0,0 +1,40 @@ +// +// AssetBrowserPanel:项目资产浏览器(Unity Project 窗口简化版)。 +// +// 左侧目录树 + 右侧文件列表(类型过滤 + 名字过滤)。与 AssetManager +// 的集成点是"双击预热":model/HDR 进缓存去重(重复双击零成本), +// .scene 提示走 File 菜单(加载含环境恢复/选中清理等流程,不在面板 +// 重复)。拖拽到视口/Inspector 属后续迭代。 +// +// 目录每帧扫描:项目级目录条目有限(几十量级),filesystem 迭代开销 +// 可忽略;资产规模上来后再加缓存脏标。 +// + +#ifndef HELLOVULKAN_ASSETBROWSERPANEL_H +#define HELLOVULKAN_ASSETBROWSERPANEL_H + +#include "EditorContext.h" + +#include +#include + +class AssetBrowserPanel { +public: + void Draw(const EditorContext& ctx); + +private: + // 目录树节点(递归);右侧列表画 dir 下的文件 + void DrawDirTree(const EditorContext& ctx, + const std::filesystem::path& dir); + void DrawFileList(const EditorContext& ctx, + const std::filesystem::path& dir); + + // 固定根(下标即身份):项目资产 / 场景文件 + int m_Root = 0; + // 类型过滤(0 = All;其余见 kTypeNames) + int m_TypeFilter = 0; + // 名字子串过滤(空 = 不过滤) + char m_NameFilter[64] = ""; +}; + +#endif //HELLOVULKAN_ASSETBROWSERPANEL_H diff --git a/App/editor/Editor.cpp b/App/editor/Editor.cpp index 7e8c0d8..f1ad388 100644 --- a/App/editor/Editor.cpp +++ b/App/editor/Editor.cpp @@ -10,6 +10,7 @@ // #include "Editor.h" +#include "AssetBrowserPanel.h" #include "HierarchyPanel.h" #include "InspectorPanel.h" #include "ViewportPanel.h" @@ -40,6 +41,7 @@ void Editor::Init(const EditorContext& ctx) m_Inspector = std::make_unique(); m_Viewport = std::make_unique(); m_Stats = std::make_unique(); + m_AssetBrowser = std::make_unique(); } void Editor::Draw() @@ -82,6 +84,7 @@ void Editor::Draw() } } if (m_ShowStats) m_Stats->Draw(m_Ctx); + if (m_ShowAssetBrowser) m_AssetBrowser->Draw(m_Ctx); DrawQuitModal(); DrawSceneModals(); @@ -154,6 +157,7 @@ void Editor::DrawMenuBar() ImGui::MenuItem("Inspector", nullptr, &m_ShowInspector); ImGui::MenuItem("Viewport", nullptr, &m_ShowViewport); ImGui::MenuItem("Stats", nullptr, &m_ShowStats); + ImGui::MenuItem("Asset Browser", nullptr, &m_ShowAssetBrowser); ImGui::EndMenu(); } @@ -184,6 +188,7 @@ void Editor::DrawDockSpace() ImGui::DockBuilderDockWindow("Hierarchy", dockLeft); ImGui::DockBuilderDockWindow("Stats", dockStats); + ImGui::DockBuilderDockWindow("Asset Browser", dockStats); ImGui::DockBuilderDockWindow("Inspector", dockRight); ImGui::DockBuilderDockWindow("Viewport", dockCenter); diff --git a/App/editor/Editor.h b/App/editor/Editor.h index 8a953ee..2009c89 100644 --- a/App/editor/Editor.h +++ b/App/editor/Editor.h @@ -26,6 +26,7 @@ class HierarchyPanel; class InspectorPanel; class ViewportPanel; class StatsPanel; +class AssetBrowserPanel; class Editor { public: @@ -86,6 +87,7 @@ private: std::unique_ptr m_Inspector; std::unique_ptr m_Viewport; std::unique_ptr m_Stats; + std::unique_ptr m_AssetBrowser; entt::entity m_Selected{entt::null}; @@ -94,6 +96,7 @@ private: bool m_ShowInspector = true; bool m_ShowViewport = true; bool m_ShowStats = true; + bool m_ShowAssetBrowser = true; // 默认布局只建一次(imgui.ini 已有布局则跳过) bool m_LayoutBuilt = false; diff --git a/App/editor/InspectorPanel.cpp b/App/editor/InspectorPanel.cpp index 33c0906..da6e3f8 100644 --- a/App/editor/InspectorPanel.cpp +++ b/App/editor/InspectorPanel.cpp @@ -566,37 +566,58 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) { ImGui::BeginDisabled(true); } - // 下拉列出全部已注册脚本(注册序);"" = 清空(对账自动销毁实例) + // 多脚本列表(Unity 语义):每行 = 脚本下拉 + 独立启停 + 删除; + // 实例生命周期由 ScriptRuntime 对账(删行/改名 → 下帧 OnDestroy) const auto& names = Engine::Scripting::ScriptRegistry::Names(); - std::vector items; - items.push_back(""); + std::vector options; + options.push_back(""); for (const auto& n : names) { - items.push_back(n.c_str()); + options.push_back(n.c_str()); } - int cur = 0; - for (size_t i = 0; i < names.size(); ++i) + int removeIdx = -1; + for (int i = 0; i < static_cast(sc->items.size()); ++i) { - if (names[i] == sc->scriptName) + auto& item = sc->items[static_cast(i)]; + ImGui::PushID(i); + int cur = 0; + for (size_t k = 0; k < names.size(); ++k) { - cur = static_cast(i) + 1; + if (names[k] == item.name) + { + cur = static_cast(k) + 1; + } + } + ImGui::SetNextItemWidth( + ImGui::GetContentRegionAvail().x - 64.0f); + if (ImGui::Combo("##script", &cur, options.data(), + static_cast(options.size()))) + { + item.name = (cur <= 0) ? "" : names[static_cast(cur) - 1]; + } + ImGui::SameLine(); + ImGui::Checkbox("##en", &item.active); + ImGui::SameLine(); + if (ImGui::Button("-", ImVec2(24.0f, 0.0f))) + { + removeIdx = i; + } + ImGui::PopID(); + // 已填名但注册表查无此类:场景加载早于脚本注册/改名都会走到这 + if (!item.name.empty() && cur == 0) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), + "! not registered: %s", + item.name.c_str()); } } - if (ImGui::Combo("script", &cur, items.data(), - static_cast(items.size()))) + if (removeIdx >= 0) { - sc->scriptName = (cur <= 0) ? "" : names[static_cast(cur) - 1]; + sc->items.erase(sc->items.begin() + removeIdx); } - if (sc->scriptName.empty()) + if (ImGui::Button("+ Add Script")) { - ImGui::TextDisabled("no script assigned"); - } - // 已填名但注册表查无此类:场景加载早于脚本注册/改名都会走到这 - else if (cur == 0) - { - ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), - "! script not registered: %s", - sc->scriptName.c_str()); + sc->items.push_back({}); } if (!sc->active) { diff --git a/Engine/include/Engine/ECS/Components.h b/Engine/include/Engine/ECS/Components.h index f0f8205..1eda4ee 100644 --- a/Engine/include/Engine/ECS/Components.h +++ b/Engine/include/Engine/ECS/Components.h @@ -282,12 +282,21 @@ namespace Engine::ECS { // 实体原点 = 脚底(胶囊底端),符合角色 pivot 习惯 }; - // 脚本组件:只存脚本类名(注册表键,POD 可序列化)。C++ 原生脚本经 - // ENGINE_REGISTER_SCRIPT 自注册;将来 Lua/C# 脚本同键解析。 + // 脚本组件:多脚本挂载(Unity 语义——一个 GameObject 挂 N 个 + // MonoBehaviour,各自有 enabled)。每项 = 脚本类名(注册表键, + // 将来 Lua/C# 同键解析)+ 独立启停位;组件级 active 是总开关。 // 实例生命周期由 ScriptRuntime 对账管理(不进组件,保持可拷贝) struct ScriptComponent { - bool active = true; // 禁用 = 实例保留但停 OnUpdate(Unity disable) - std::string scriptName; + // 单个脚本项 + struct Item { + std::string name; + bool active = true; // 禁用 = 实例保留但停 OnUpdate + + bool operator==(const Item&) const = default; + }; + + bool active = true; // 组件总开关(全部停 tick,实例保留) + std::vector items; bool operator==(const ScriptComponent&) const = default; }; diff --git a/Engine/include/Engine/Scripting/ScriptRuntime.h b/Engine/include/Engine/Scripting/ScriptRuntime.h index 784edbb..cb1ea70 100644 --- a/Engine/include/Engine/Scripting/ScriptRuntime.h +++ b/Engine/include/Engine/Scripting/ScriptRuntime.h @@ -1,8 +1,11 @@ // -// ScriptRuntime.h:脚本生命周期驱动。对账式管理(组件加/删/改名 ↔ +// ScriptRuntime.h:脚本生命周期驱动。对账式管理(组件项加/删/改名 ↔ // 实例建/毁),照 PhysicsWorld.SyncScene 模式——每帧对账,编辑器操作 // 即时生效,场景加载/实体销毁自动消化。 // +// 多脚本:一个实体可挂任意多个脚本项(ScriptComponent::items),实例 +// 按名字与 items 对账——重命名/删除走 OnDestroy 清理,未动的实例不重建。 +// #ifndef HELLOVULKAN_SCRIPT_RUNTIME_H #define HELLOVULKAN_SCRIPT_RUNTIME_H @@ -12,6 +15,7 @@ #include #include +#include namespace Engine { @@ -37,8 +41,9 @@ namespace Engine { void Sync(); ECS::Scene& m_Scene; - Application* m_App; // 注入脚本上下文 - std::unordered_map m_Instances; // entityBits + Application* m_App; // 注入脚本上下文 + // entityBits → 实例列表(成员按名字与 items 对齐,无序) + std::unordered_map> m_Instances; }; } // namespace Scripting diff --git a/Engine/src/Scripting/ScriptRuntime.cpp b/Engine/src/Scripting/ScriptRuntime.cpp index 2f12127..ae44136 100644 --- a/Engine/src/Scripting/ScriptRuntime.cpp +++ b/Engine/src/Scripting/ScriptRuntime.cpp @@ -1,5 +1,5 @@ // -// ScriptRuntime.cpp:脚本对账与生命周期驱动。 +// ScriptRuntime.cpp:脚本对账与生命周期驱动(多脚本挂载)。 // #include @@ -7,6 +7,8 @@ #include #include +#include + namespace Engine { namespace Scripting { @@ -20,7 +22,7 @@ namespace Engine { Sync(); auto& reg = m_Scene.GetRegistry(); - for (auto& [bits, inst] : m_Instances) { + for (auto& [bits, list] : m_Instances) { const auto e = static_cast(bits); const ECS::ScriptComponent* sc = reg.valid(e) ? reg.try_get(e) : nullptr; @@ -28,57 +30,106 @@ namespace Engine { continue; // 延迟销毁队列里,下帧 Sync 清理 } if (!sc->active) { - continue; // 禁用 = 保留实例停 tick(Unity disable 语义) + continue; // 组件总开关:全部实例停 tick(Unity disable) } - if (!inst.created) { - inst.behavior->OnCreate(); - inst.created = true; + for (auto& inst : list) { + if (!inst.behavior) { + continue; + } + // per-script 启停:按名查项(对账保证存在;新项要 + // 下帧 Sync 才有实例,这里查不到就跳过) + const auto item = std::find_if( + sc->items.begin(), sc->items.end(), + [&](const ECS::ScriptComponent::Item& it) { + return it.name == inst.behavior->Name(); + }); + if (item == sc->items.end() || !item->active) { + continue; // 禁用 = 实例保留停 OnUpdate + } + if (!inst.created) { + inst.behavior->OnCreate(); + inst.created = true; + } + inst.behavior->OnUpdate(dt); } - inst.behavior->OnUpdate(dt); } } void ScriptRuntime::Sync() { auto& reg = m_Scene.GetRegistry(); - // 1. 孤儿/改名清理:实体或组件没了、脚本名变了 → OnDestroy + 删除 + // 1. 孤儿/改名清理:实体或组件没了 → 整组 OnDestroy; + // 实例名不在 items 里(改名/移除)→ 单个 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(); + reg.valid(e) ? reg.try_get(e) + : nullptr; + if (!sc) { + for (auto& inst : it->second) { + if (inst.behavior) { + inst.behavior->OnDestroy(); + } } it = m_Instances.erase(it); + continue; + } + auto& list = it->second; + for (auto li = list.begin(); li != list.end();) { + const bool keep = li->behavior && + std::any_of(sc->items.begin(), sc->items.end(), + [&](const ECS::ScriptComponent::Item& item) { + return item.name == li->behavior->Name(); + }); + if (keep) { + ++li; + continue; + } + if (li->behavior) { + li->behavior->OnDestroy(); + } + li = list.erase(li); + } + if (list.empty()) { + it = m_Instances.erase(it); } else { ++it; } } - // 2. 缺失实例化:按名查注册表(未知名警告——场景含未注册脚本时 - // 弱前向兼容,跳过不影响其余实体) + // 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, m_App); + auto& list = m_Instances[bits]; + for (const auto& item : sc.items) { + if (item.name.empty()) { + continue; + } + const bool exists = std::any_of( + list.begin(), list.end(), + [&](const ScriptInstance& inst) { + return inst.behavior && + inst.behavior->Name() == item.name; + }); + if (exists) { + continue; + } + auto behavior = ScriptRegistry::Create(item.name); + if (!behavior) { + ENGINE_WARN("Script '{}' not registered (entity {})", + item.name, bits); + continue; + } + behavior->Bind(m_Scene, e, m_App); - ScriptInstance inst; - inst.behavior = std::move(behavior); - inst.behavior->OnCreate(); // Unity Awake 语义:实例化即回调 - inst.created = true; - m_Instances.emplace(bits, std::move(inst)); + ScriptInstance inst; + inst.behavior = std::move(behavior); + inst.behavior->OnCreate(); // Unity Awake 语义:实例化即回调 + inst.created = true; + list.push_back(std::move(inst)); + } } } diff --git a/Engine/src/Serialization/SceneSerializer.cpp b/Engine/src/Serialization/SceneSerializer.cpp index 52052db..f2977ba 100644 --- a/Engine/src/Serialization/SceneSerializer.cpp +++ b/Engine/src/Serialization/SceneSerializer.cpp @@ -350,12 +350,20 @@ 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::Key << "Active" << YAML::Value << sc->active; + out << YAML::Key << "Items" << YAML::Value << YAML::BeginSeq; + for (const auto& item : sc->items) { + // flow map 单行一项,与场景格式定案一致 + out << YAML::BeginMap; + out << YAML::Key << "Name" << YAML::Value << item.name; + out << YAML::Key << "Active" << YAML::Value << item.active; + out << YAML::EndMap; + } + out << YAML::EndSeq; out << YAML::EndMap; } @@ -655,11 +663,25 @@ namespace Engine::Serialization { // 状态(速度/着地)由 PhysicsWorld 对账重建,不存 } - // 脚本:只存类名;未注册名字由 ScriptRuntime 运行时警告跳过 + // 脚本:优先读多脚本 Items;无 Items 回退旧单脚本键(向前兼容) if (const YAML::Node n = comps["Script"]) { auto& sc = handle.AddComponent(); - sc.scriptName = n["ScriptName"].as(""); sc.active = n["Active"].as(true); + if (const YAML::Node items = n["Items"]) { + for (const auto& it : items) { + ECS::ScriptComponent::Item item; + item.name = it["Name"].as(""); + item.active = it["Active"].as(true); + sc.items.push_back(std::move(item)); + } + } else { + // 旧格式:Script: {ScriptName: ..., Active: ...} + ECS::ScriptComponent::Item item; + item.name = n["ScriptName"].as(""); + if (!item.name.empty()) { + sc.items.push_back(std::move(item)); + } + } } }