feat(ecs): per-component enable/disable + inspector rework

Add `active` flag to the 8 behavior components (Unity enabled / UE
SetActive semantics; structural Id/Name/Transform excluded). Consumers
skip disabled components: renderer (mesh/light/anim), primary camera
election, physics reconciliation (body/constraint/character destroyed
and rebuilt on toggle), scripts keep instance but stop ticking.

Inspector: component headers now carry an enable checkbox and a remove
button, Add-Component menu greys out attached entries, and conflict
hints added (RigidBody <-> CharacterController mutual exclusion, Joint
requires RigidBody, Animation requires MeshRenderer, unregistered
script name). Scenes persist Active per component (default true).
This commit is contained in:
2026-10-03 18:45:03 +08:00
parent cad0481bc0
commit b6d0868e15
7 changed files with 280 additions and 65 deletions
+214 -54
View File
@@ -15,6 +15,7 @@
#include <cstdio>
#include <vector>
#include <type_traits>
namespace
{
@@ -64,6 +65,44 @@ namespace
ImGui::PopID();
return changed;
}
// 组件区块头(Unity 语义):折叠标题 + 右缘"启用"勾选与"删除"按钮。
// active 传 nullptr = 无启停(结构性组件);removable = 显示删除钮,
// 点击时置 *removeFlag,调用方在区块末尾统一移除(避免边画边删)
bool ComponentHeader(const char* label, bool* active, bool removable,
bool* removeFlag)
{
ImGui::PushID(label);
bool open;
if (active != nullptr)
{
open = ImGui::CollapsingHeader(
label, ImGuiTreeNodeFlags_DefaultOpen |
ImGuiTreeNodeFlags_AllowOverlap);
const float rowH = ImGui::GetItemRectSize().y;
float x = ImGui::GetWindowWidth() - 32.0f;
if (removable)
{
ImGui::SameLine(x);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f);
if (ImGui::Button("-", ImVec2(24.0f, rowH - 4.0f)))
{
*removeFlag = true;
}
x -= 30.0f;
}
ImGui::SameLine(x);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f);
ImGui::Checkbox("##active", active);
}
else
{
open = ImGui::CollapsingHeader(label,
ImGuiTreeNodeFlags_DefaultOpen);
}
ImGui::PopID();
return open;
}
}
void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
@@ -85,61 +124,47 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
}
ImGui::Separator();
// ---- Add Component ----
// ---- Add Component(已挂载项置灰:Unity Add Component 语义)----
if (ImGui::Button("Add Component"))
{
ImGui::OpenPopup("add_component");
}
if (ImGui::BeginPopup("add_component"))
{
if (!reg.all_of<Engine::ECS::LightComponent>(selected) &&
ImGui::MenuItem("Light"))
auto menuItem = [&](auto typeTag, const char* label)
{
reg.emplace<Engine::ECS::LightComponent>(selected);
}
if (!reg.all_of<Engine::ECS::CameraComponent>(selected) &&
ImGui::MenuItem("Camera"))
{
reg.emplace<Engine::ECS::CameraComponent>(selected);
}
if (!reg.all_of<Engine::ECS::AnimationComponent>(selected) &&
ImGui::MenuItem("Animation"))
{
reg.emplace<Engine::ECS::AnimationComponent>(selected);
}
if (!reg.all_of<Engine::ECS::NameComponent>(selected) &&
ImGui::MenuItem("Name"))
{
reg.emplace<Engine::ECS::NameComponent>(selected);
}
if (!reg.all_of<Engine::ECS::RigidBodyComponent>(selected) &&
ImGui::MenuItem("RigidBody"))
{
reg.emplace<Engine::ECS::RigidBodyComponent>(selected);
}
if (!reg.all_of<Engine::ECS::JointComponent>(selected) &&
ImGui::MenuItem("Joint"))
{
reg.emplace<Engine::ECS::JointComponent>(selected);
}
if (!reg.all_of<Engine::ECS::CharacterControllerComponent>(selected) &&
ImGui::MenuItem("Character Controller"))
{
reg.emplace<Engine::ECS::CharacterControllerComponent>(selected);
}
if (!reg.all_of<Engine::ECS::ScriptComponent>(selected) &&
ImGui::MenuItem("Script"))
{
reg.emplace<Engine::ECS::ScriptComponent>(selected);
}
using T = std::remove_pointer_t<decltype(typeTag)>;
const bool has = reg.all_of<T>(selected);
if (has)
{
ImGui::BeginDisabled(true);
}
if (ImGui::MenuItem(label))
{
reg.emplace<T>(selected);
}
if (has)
{
ImGui::EndDisabled();
}
};
menuItem(static_cast<Engine::ECS::LightComponent*>(nullptr), "Light");
menuItem(static_cast<Engine::ECS::CameraComponent*>(nullptr), "Camera");
menuItem(static_cast<Engine::ECS::AnimationComponent*>(nullptr), "Animation");
menuItem(static_cast<Engine::ECS::NameComponent*>(nullptr), "Name");
menuItem(static_cast<Engine::ECS::RigidBodyComponent*>(nullptr), "RigidBody");
menuItem(static_cast<Engine::ECS::JointComponent*>(nullptr), "Joint");
menuItem(static_cast<Engine::ECS::CharacterControllerComponent*>(nullptr),
"Character Controller");
menuItem(static_cast<Engine::ECS::ScriptComponent*>(nullptr), "Script");
ImGui::EndPopup();
}
ImGui::Separator();
// ---- Transform ----
// ---- Transform(结构性组件:不可删、无启停)----
if (auto* t = reg.try_get<Engine::ECS::TransformComponent>(selected))
{
if (ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen))
if (ComponentHeader("Transform", nullptr, false, nullptr))
{
glm::vec3 pos = t->GetPosition();
if (DragVec3Row("position", pos, 0.05f))
@@ -162,9 +187,13 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
// ---- MeshRenderer ----
if (auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected))
{
if (ImGui::CollapsingHeader("MeshRenderer",
ImGuiTreeNodeFlags_DefaultOpen))
bool removeMr = false;
if (ComponentHeader("MeshRenderer", &mr->active, true, &removeMr))
{
if (!mr->active)
{
ImGui::BeginDisabled(true); // 禁用 = 内容置灰(Unity 语义)
}
if (const Engine::Model* model = ctx.assets->GetModel(mr->model);
model != nullptr)
{
@@ -179,14 +208,27 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"model: INVALID (dangling ref)");
}
if (!mr->active)
{
ImGui::EndDisabled();
}
}
if (removeMr)
{
reg.remove<Engine::ECS::MeshRendererComponent>(selected);
}
}
// ---- Light ----
if (auto* light = reg.try_get<Engine::ECS::LightComponent>(selected))
{
if (ImGui::CollapsingHeader("Light", ImGuiTreeNodeFlags_DefaultOpen))
bool removeLight = false;
if (ComponentHeader("Light", &light->active, true, &removeLight))
{
if (!light->active)
{
ImGui::BeginDisabled(true);
}
int type = static_cast<int>(light->type);
if (ImGui::Combo("type", &type, "Directional\0Point\0"))
{
@@ -204,14 +246,27 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::DragFloat("radius", &light->radius, 0.1f, 0.0f, 100.0f);
ImGui::TextDisabled("(position = entity Transform)");
}
if (!light->active)
{
ImGui::EndDisabled();
}
}
if (removeLight)
{
reg.remove<Engine::ECS::LightComponent>(selected);
}
}
// ---- Camera ----
if (auto* cam = reg.try_get<Engine::ECS::CameraComponent>(selected))
{
if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen))
bool removeCam = false;
if (ComponentHeader("Camera", &cam->active, true, &removeCam))
{
if (!cam->active)
{
ImGui::BeginDisabled(true);
}
if (ImGui::Checkbox("primary", &cam->primary) && cam->primary)
{
// primary 互斥:启用本相机时清掉其余实体的 primary
@@ -237,6 +292,14 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ctx.editorCamera->AlignTo(*tr);
}
}
if (!cam->active)
{
ImGui::EndDisabled();
}
}
if (removeCam)
{
reg.remove<Engine::ECS::CameraComponent>(selected);
}
}
@@ -244,8 +307,21 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
// type/collider/mass 变更 → PhysicsWorld 重建 body;其余参数即时生效
if (auto* rb = reg.try_get<Engine::ECS::RigidBodyComponent>(selected))
{
if (ImGui::CollapsingHeader("RigidBody", ImGuiTreeNodeFlags_DefaultOpen))
bool removeRb = false;
if (ComponentHeader("RigidBody", &rb->active, true, &removeRb))
{
if (!rb->active)
{
ImGui::BeginDisabled(true);
}
// 冲突提示:同实体挂 CharacterController 会同时建 body 与
// CharacterVirtual,两个"运动主体"互相打架
if (reg.all_of<Engine::ECS::CharacterControllerComponent>(selected))
{
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! conflicts with Character Controller "
"(disable or remove one)");
}
int type = static_cast<int>(rb->type);
if (ImGui::Combo("type", &type, "Static\0Dynamic\0Kinematic\0"))
{
@@ -347,14 +423,33 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
s_debugImpulse);
}
}
if (!rb->active)
{
ImGui::EndDisabled();
}
}
if (removeRb)
{
reg.remove<Engine::ECS::RigidBodyComponent>(selected);
}
}
// ---- Joint(约束:两端 body 就绪后由 PhysicsWorld 对账创建)----
if (auto* jc = reg.try_get<Engine::ECS::JointComponent>(selected))
{
if (ImGui::CollapsingHeader("Joint", ImGuiTreeNodeFlags_DefaultOpen))
bool removeJc = false;
if (ComponentHeader("Joint", &jc->active, true, &removeJc))
{
if (!jc->active)
{
ImGui::BeginDisabled(true);
}
// 依赖提示:约束挂在两端刚体上,本体无 RigidBody 无法创建
if (!reg.all_of<Engine::ECS::RigidBodyComponent>(selected))
{
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! requires a RigidBody on this entity");
}
int type = static_cast<int>(jc->type);
if (ImGui::Combo("type", &type, "Fixed\0Hinge\0Ball\0"))
{
@@ -399,15 +494,34 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::DragFloat("max deg", &jc->limitMax, 1.0f, 0.0f, 180.0f);
}
}
if (!jc->active)
{
ImGui::EndDisabled();
}
}
if (removeJc)
{
reg.remove<Engine::ECS::JointComponent>(selected);
}
}
// ---- CharacterController(参数编辑;状态在 PhysicsWorld 运行时)----
if (auto* cc = reg.try_get<Engine::ECS::CharacterControllerComponent>(selected))
{
if (ImGui::CollapsingHeader("Character Controller",
ImGuiTreeNodeFlags_DefaultOpen))
bool removeCc = false;
if (ComponentHeader("Character Controller", &cc->active, true, &removeCc))
{
if (!cc->active)
{
ImGui::BeginDisabled(true);
}
// 冲突提示:同实体挂 RigidBody 会同时建 CharacterVirtual 与 body
if (reg.all_of<Engine::ECS::RigidBodyComponent>(selected))
{
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! conflicts with RigidBody "
"(disable or remove one)");
}
// 实体原点 = 脚底(胶囊底端),总高 = 2*(halfHeight + radius)
ImGui::DragFloat("radius", &cc->radius, 0.02f, 0.05f, 5.0f);
ImGui::DragFloat("halfHeight", &cc->capsuleHalfHeight, 0.02f, 0.0f, 5.0f);
@@ -431,14 +545,27 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
phy.Jump(bits, 5.0f);
}
}
if (!cc->active)
{
ImGui::EndDisabled();
}
}
if (removeCc)
{
reg.remove<Engine::ECS::CharacterControllerComponent>(selected);
}
}
// ---- Script(按名解析注册表脚本;实例生命周期在 ScriptRuntime)----
if (auto* sc = reg.try_get<Engine::ECS::ScriptComponent>(selected))
{
if (ImGui::CollapsingHeader("Script", ImGuiTreeNodeFlags_DefaultOpen))
bool removeSc = false;
if (ComponentHeader("Script", &sc->active, true, &removeSc))
{
if (!sc->active)
{
ImGui::BeginDisabled(true);
}
// 下拉列出全部已注册脚本(注册序);"<none>" = 清空(对账自动销毁实例)
const auto& names = Engine::Scripting::ScriptRegistry::Names();
std::vector<const char*> items;
@@ -464,20 +591,45 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
{
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());
}
if (!sc->active)
{
ImGui::EndDisabled();
}
}
if (removeSc)
{
reg.remove<Engine::ECS::ScriptComponent>(selected);
}
}
// ---- Animation ----
if (auto* anim = reg.try_get<Engine::ECS::AnimationComponent>(selected))
{
if (ImGui::CollapsingHeader("Animation",
ImGuiTreeNodeFlags_DefaultOpen))
bool removeAnim = false;
if (ComponentHeader("Animation", &anim->active, true, &removeAnim))
{
if (!anim->active)
{
ImGui::BeginDisabled(true);
}
const auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected);
const Engine::Model* model =
mr ? ctx.assets->GetModel(mr->model) : nullptr;
if (model != nullptr && model->HasAnimations())
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
if (mr == nullptr)
{
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! requires a MeshRenderer component");
}
else if (model != nullptr && model->HasAnimations())
{
const auto& clips = model->GetClips();
std::vector<const char*> clipNames;
@@ -508,6 +660,14 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
{
ImGui::TextDisabled("(no animation clips on model)");
}
if (!anim->active)
{
ImGui::EndDisabled();
}
}
if (removeAnim)
{
reg.remove<Engine::ECS::AnimationComponent>(selected);
}
}