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.
This commit is contained in:
2026-10-04 00:43:06 +08:00
parent 74a2044441
commit ab7a43a686
9 changed files with 459 additions and 63 deletions
+240
View File
@@ -0,0 +1,240 @@
//
// AssetBrowserPanel.cpp:项目资产浏览器实现。
//
#include "AssetBrowserPanel.h"
#include <Engine/Core/Log.h>
#include <imgui.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <vector>
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<Item> 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();
}
}
+40
View File
@@ -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 <filesystem>
#include <string>
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
+5
View File
@@ -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<InspectorPanel>();
m_Viewport = std::make_unique<ViewportPanel>();
m_Stats = std::make_unique<StatsPanel>();
m_AssetBrowser = std::make_unique<AssetBrowserPanel>();
}
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);
+3
View File
@@ -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<InspectorPanel> m_Inspector;
std::unique_ptr<ViewportPanel> m_Viewport;
std::unique_ptr<StatsPanel> m_Stats;
std::unique_ptr<AssetBrowserPanel> 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;
+41 -20
View File
@@ -566,37 +566,58 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
{
ImGui::BeginDisabled(true);
}
// 下拉列出全部已注册脚本(注册序);"<none>" = 清空(对账自动销毁实例)
// 多脚本列表(Unity 语义):每行 = 脚本下拉 + 独立启停 + 删除;
// 实例生命周期由 ScriptRuntime 对账(删行/改名 → 下帧 OnDestroy)
const auto& names = Engine::Scripting::ScriptRegistry::Names();
std::vector<const char*> items;
items.push_back("<none>");
std::vector<const char*> options;
options.push_back("<none>");
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<int>(sc->items.size()); ++i)
{
if (names[i] == sc->scriptName)
auto& item = sc->items[static_cast<size_t>(i)];
ImGui::PushID(i);
int cur = 0;
for (size_t k = 0; k < names.size(); ++k)
{
cur = static_cast<int>(i) + 1;
if (names[k] == item.name)
{
cur = static_cast<int>(k) + 1;
}
}
ImGui::SetNextItemWidth(
ImGui::GetContentRegionAvail().x - 64.0f);
if (ImGui::Combo("##script", &cur, options.data(),
static_cast<int>(options.size())))
{
item.name = (cur <= 0) ? "" : names[static_cast<size_t>(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<int>(items.size())))
if (removeIdx >= 0)
{
sc->scriptName = (cur <= 0) ? "" : names[static_cast<size_t>(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)
{