- TransformComponent TRS = local (relative to parent); cached world SRT composed hierarchically (parent pos/rot/scale, no matrix decomposition shear under non-uniform parent scale) - HierarchyComponent (parent + children, lazily attached); Scene:: SetParent (keepWorld, cycle-safe) / GetParent / IsDescendantOf / SetWorldPose (world->local inverse, used by physics writeback & gizmo) - UpdateTransforms: DFS from roots, recompose only dirty-or-dirty- ancestor subtrees (parents before children) - DestroyEntity: descendants buried with parent (UE cascade) - Serialization v2: optional Parent uuid key, two-pass relink on load - Hierarchy panel: tree view, drag-drop reparent / drop-to-root detach, Create Child / Detach context menu; Inspector shows parent name - Consumers moved to world-space reads: lights, camera pos, physics bodies/characters/joints/collider baking, gizmo, F-focus, AlignTo
995 lines
41 KiB
C++
995 lines
41 KiB
C++
//
|
||
// InspectorPanel.cpp:组件编辑实现。
|
||
//
|
||
// Transform 编辑必须走 Set* 入口(标脏 → 帧循环统一 Compose 重算
|
||
// world),直接改私有成员是编译错误——面板天然遵守引擎约束。
|
||
//
|
||
|
||
#include "InspectorPanel.h"
|
||
#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 <imgui.h>
|
||
|
||
#include <algorithm>
|
||
#include <cstdio>
|
||
#include <filesystem>
|
||
#include <unordered_set>
|
||
#include <vector>
|
||
#include <type_traits>
|
||
|
||
namespace
|
||
{
|
||
// 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用;
|
||
// 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。
|
||
// 会话级缓存:目录条目几十量级,避免每帧递归扫描
|
||
const std::vector<std::string>& AssetModelFiles()
|
||
{
|
||
static std::vector<std::string> s_Files;
|
||
static bool s_Cached = false;
|
||
if (!s_Cached)
|
||
{
|
||
s_Cached = true;
|
||
namespace fs = std::filesystem;
|
||
if (fs::exists("assets"))
|
||
{
|
||
for (const auto& entry :
|
||
fs::recursive_directory_iterator("assets"))
|
||
{
|
||
if (!entry.is_regular_file())
|
||
{
|
||
continue;
|
||
}
|
||
std::string ext = entry.path().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")
|
||
{
|
||
s_Files.push_back(entry.path().generic_string());
|
||
}
|
||
}
|
||
std::sort(s_Files.begin(), s_Files.end());
|
||
}
|
||
}
|
||
return s_Files;
|
||
}
|
||
|
||
// InputText 需要 char 缓冲;提交时写回 std::string
|
||
bool InputTextString(const char* label, std::string& str)
|
||
{
|
||
char buf[256];
|
||
std::snprintf(buf, sizeof(buf), "%s", str.c_str());
|
||
if (ImGui::InputText(label, buf, sizeof(buf)))
|
||
{
|
||
str = buf;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// UE 风变换行:label + 三轴色 DragFloat(轴色对齐 Gizmo:X 红 /
|
||
// Y 绿 / Z 蓝)。拖拽粗调;Ctrl+Click 进输入框键入精确值。
|
||
// 任一轴变化返回 true。fmt = 显示格式(旋转行传 "%.1f" 对齐
|
||
// UE Details 默认 1 位小数——欧拉显示端有固有反解噪声,见
|
||
// TransformComponent::GetRotation 注释)
|
||
bool DragVec3Row(const char* label, glm::vec3& v, float speed,
|
||
float mn = -3.4e38f, float mx = 3.4e38f,
|
||
const char* fmt = "%.2f", bool* editing = nullptr)
|
||
{
|
||
ImGui::TextUnformatted(label);
|
||
ImGui::SameLine(72.0f); // 固定标签列宽 → 三行输入框对齐
|
||
ImGui::PushID(label);
|
||
|
||
constexpr ImVec4 axisCol[3] = {
|
||
{0.72f, 0.24f, 0.24f, 1.00f}, // X 红
|
||
{0.30f, 0.68f, 0.34f, 1.00f}, // Y 绿
|
||
{0.24f, 0.40f, 0.88f, 1.00f}, // Z 蓝
|
||
};
|
||
const float boxW =
|
||
(ImGui::GetContentRegionAvail().x
|
||
- 2.0f * ImGui::GetStyle().ItemInnerSpacing.x) / 3.0f;
|
||
|
||
bool changed = false;
|
||
for (int i = 0; i < 3; ++i)
|
||
{
|
||
ImGui::PushStyleColor(ImGuiCol_FrameBg, axisCol[i]);
|
||
ImGui::SetNextItemWidth(boxW);
|
||
changed |= ImGui::DragFloat(
|
||
i == 0 ? "##x" : i == 1 ? "##y" : "##z",
|
||
&v[i], speed, mn, mx, fmt);
|
||
if (editing != nullptr)
|
||
{
|
||
// 拖拽中/输入框打开中(含无拖动的按住帧)
|
||
*editing |= ImGui::IsItemActive();
|
||
}
|
||
ImGui::PopStyleColor();
|
||
if (i < 2) ImGui::SameLine();
|
||
}
|
||
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)
|
||
{
|
||
ImGui::Begin("Inspector");
|
||
|
||
auto& reg = ctx.scene->GetRegistry();
|
||
if (selected == entt::null || !reg.valid(selected))
|
||
{
|
||
ImGui::TextDisabled("(no entity selected)");
|
||
ImGui::End();
|
||
return;
|
||
}
|
||
|
||
// ---- Name ----
|
||
if (auto* name = reg.try_get<Engine::ECS::NameComponent>(selected))
|
||
{
|
||
InputTextString("Name", name->name);
|
||
}
|
||
ImGui::Separator();
|
||
|
||
// ---- Add Component(已挂载项置灰:Unity Add Component 语义)----
|
||
if (ImGui::Button("Add Component"))
|
||
{
|
||
ImGui::OpenPopup("add_component");
|
||
}
|
||
if (ImGui::BeginPopup("add_component"))
|
||
{
|
||
auto menuItem = [&](auto typeTag, const char* label)
|
||
{
|
||
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::MeshRendererComponent*>(nullptr),
|
||
"Mesh Renderer");
|
||
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(结构性组件:不可删、无启停)----
|
||
if (auto* t = reg.try_get<Engine::ECS::TransformComponent>(selected))
|
||
{
|
||
if (ComponentHeader("Transform", nullptr, false, nullptr))
|
||
{
|
||
// 父级提示(UE Details 语义:此处编辑的是 Relative 值)
|
||
if (ctx.scene) {
|
||
const Engine::ECS::Entity p = ctx.scene->GetParent(selected);
|
||
if (p != entt::null) {
|
||
std::string pname = "(unnamed)";
|
||
if (const auto* n = reg.try_get<Engine::ECS::NameComponent>(p)) {
|
||
pname = n->name;
|
||
}
|
||
ImGui::TextDisabled("parent: %s", pname.c_str());
|
||
}
|
||
}
|
||
glm::vec3 pos = t->GetPosition();
|
||
if (DragVec3Row("position", pos, 0.05f))
|
||
{
|
||
t->SetPosition(pos);
|
||
}
|
||
// ---- rotation 行:编辑 mirror(UE TransformDetails 会话语义)----
|
||
// 每帧回读 quat 反解 + wrap 会让拖动乱跳(360 → 0 折返)。
|
||
// UE 做法 = 编辑会话内显示本地累计值(正拖无限增大、反拖
|
||
// 无限减小、永不折返),只有"非编辑来源"的姿态改动(Gizmo、
|
||
// 物理、重选)才刷新显示。ImGui 无状态 → static mirror:
|
||
// 切换实体重置;非编辑态用 quat 位级等价检测判定外部改动
|
||
static unsigned s_MirrorEntity = 0;
|
||
static glm::vec3 s_RotMirror(0.0f);
|
||
static bool s_MirrorValid = false;
|
||
static bool s_RotEditingLast = false; // 上一帧是否在编辑
|
||
const unsigned entityId =
|
||
static_cast<unsigned>(entt::to_integral(selected));
|
||
if (entityId != s_MirrorEntity)
|
||
{
|
||
s_MirrorEntity = entityId;
|
||
s_MirrorValid = false;
|
||
}
|
||
bool editingRot = false;
|
||
if (s_MirrorValid)
|
||
{
|
||
if (!s_RotEditingLast)
|
||
{
|
||
// 非编辑帧才检测外部改动(编辑帧 mirror 领先 quat 一个
|
||
// 拖动增量,等价检测必触发误重置)。编辑态取上一帧
|
||
// ——检测先于本帧 DragVec3Row 执行。
|
||
// 判据 = 逐分量相等:QuatFromEuler 与 SetRotation 是
|
||
// 同一函数,mirror 未被外部动过时位级相同(== 无浮点
|
||
// 噪声问题,比 dot 阈值严——Gizmo 极慢拖 0.05°/帧
|
||
// 的 quat 差 ~1e-7,dot 阈值法会漏检导致显示不跟手)
|
||
const glm::quat mq =
|
||
Engine::ECS::TransformComponent::QuatFromEuler(s_RotMirror);
|
||
const glm::quat& aq = t->GetRotationQuat();
|
||
const bool untouched = mq.x == aq.x && mq.y == aq.y &&
|
||
mq.z == aq.z && mq.w == aq.w;
|
||
if (!untouched)
|
||
{
|
||
s_RotMirror = t->GetRotation(); // 外部改动 → 刷新
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
s_RotMirror = t->GetRotation();
|
||
s_MirrorValid = true;
|
||
}
|
||
glm::vec3 rot = s_RotMirror;
|
||
// "%.1f" = UE Details 默认显示精度。mirror 值直显(无 wrap、
|
||
// 无限累计);Ctrl+Click 手输全精度写入
|
||
if (DragVec3Row("rotation", rot, 0.5f, -3.4e38f, 3.4e38f,
|
||
"%.1f", &editingRot))
|
||
{
|
||
t->SetRotation(rot);
|
||
s_RotMirror = rot;
|
||
}
|
||
s_RotEditingLast = editingRot;
|
||
glm::vec3 scl = t->GetScale();
|
||
if (DragVec3Row("scale", scl, 0.02f, 0.001f, 100.0f))
|
||
{
|
||
t->SetScale(scl);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- MeshRenderer ----
|
||
if (auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected))
|
||
{
|
||
bool removeMr = false;
|
||
if (ComponentHeader("MeshRenderer", &mr->active, true, &removeMr))
|
||
{
|
||
if (!mr->active)
|
||
{
|
||
ImGui::BeginDisabled(true); // 禁用 = 内容置灰(Unity 语义)
|
||
}
|
||
const std::string cur = ctx.assets->Models().Path(mr->model);
|
||
// 模型资产槽(UE Details 资产槽语义):下拉列 assets/ 下全部
|
||
// 模型文件,选中即 LoadModel 应用;已加载路径缓存去重复用
|
||
{
|
||
const auto& s_ModelFiles = AssetModelFiles();
|
||
std::vector<const char*> opts;
|
||
opts.push_back("<none>");
|
||
for (const auto& f : s_ModelFiles)
|
||
{
|
||
opts.push_back(f.c_str());
|
||
}
|
||
int sel = 0;
|
||
for (size_t k = 0; k < s_ModelFiles.size(); ++k)
|
||
{
|
||
if (s_ModelFiles[k] == cur)
|
||
{
|
||
sel = static_cast<int>(k) + 1;
|
||
}
|
||
}
|
||
ImGui::SetNextItemWidth(-1.0f);
|
||
if (ImGui::Combo("##model_slot", &sel, opts.data(),
|
||
static_cast<int>(opts.size())))
|
||
{
|
||
mr->model = (sel <= 0)
|
||
? Engine::ModelRef{}
|
||
: ctx.assets->LoadModel(s_ModelFiles[static_cast<size_t>(sel) - 1]);
|
||
}
|
||
ImGui::Spacing();
|
||
}
|
||
// 无效句柄不进 GetModel(缓存对无效/悬空句柄 FATAL,是 bug 信号)
|
||
if (mr->model.Valid())
|
||
{
|
||
if (const Engine::Model* model = ctx.assets->GetModel(mr->model);
|
||
model != nullptr)
|
||
{
|
||
const auto& clips = model->GetClips();
|
||
if (model->GetMeshes().empty())
|
||
{
|
||
// 纯动画容器(无网格):不能作为渲染模型,提示
|
||
// 挂到 MeshRenderer 没有视觉效果
|
||
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
|
||
"model: animation-only "
|
||
"(%u clips, %u bones, no meshes)",
|
||
static_cast<unsigned>(clips.size()),
|
||
model->GetBoneCount());
|
||
}
|
||
else
|
||
{
|
||
ImGui::Text("model: valid (%u meshes, %u clips, %u bones)",
|
||
static_cast<unsigned>(model->GetMeshes().size()),
|
||
static_cast<unsigned>(clips.size()),
|
||
model->GetBoneCount());
|
||
}
|
||
}
|
||
else
|
||
{
|
||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
|
||
"model: INVALID (dangling ref)");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
ImGui::TextDisabled("no model assigned");
|
||
}
|
||
if (!mr->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeMr)
|
||
{
|
||
reg.remove<Engine::ECS::MeshRendererComponent>(selected);
|
||
}
|
||
}
|
||
|
||
// ---- Light ----
|
||
if (auto* light = reg.try_get<Engine::ECS::LightComponent>(selected))
|
||
{
|
||
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"))
|
||
{
|
||
light->type = static_cast<Engine::ECS::LightComponent::Type>(type);
|
||
}
|
||
ImGui::ColorEdit3("color", &light->color.x);
|
||
ImGui::DragFloat("intensity", &light->color.a, 0.05f, 0.0f, 100.0f);
|
||
if (light->type == Engine::ECS::LightComponent::Type::Directional)
|
||
{
|
||
ImGui::SliderFloat3("direction", &light->direction.x, -1.0f, 1.0f);
|
||
ImGui::Checkbox("castShadow", &light->castShadow);
|
||
}
|
||
else
|
||
{
|
||
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))
|
||
{
|
||
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
|
||
//(引擎取第一个 primary 作为游戏形态渲染视角)
|
||
reg.view<Engine::ECS::CameraComponent>().each(
|
||
[&](entt::entity e, Engine::ECS::CameraComponent& c)
|
||
{
|
||
if (e != selected)
|
||
{
|
||
c.primary = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 对齐跳转:把本实体位姿复制给编辑器相机,Scene view 立即
|
||
// 从该位置观看(只取位姿,不接管渲染——渲染源分派在
|
||
// SceneRenderer,编辑器形态恒用编辑器相机)
|
||
if (ImGui::Button("Align Viewport to Camera") && ctx.editorCamera)
|
||
{
|
||
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
|
||
selected))
|
||
{
|
||
ctx.editorCamera->AlignTo(*tr);
|
||
}
|
||
}
|
||
if (!cam->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeCam)
|
||
{
|
||
reg.remove<Engine::ECS::CameraComponent>(selected);
|
||
}
|
||
}
|
||
|
||
// ---- RigidBody(Jolt 刚体:动态体 Transform 由物理每帧写回)----
|
||
// type/collider/mass 变更 → PhysicsWorld 重建 body;其余参数即时生效
|
||
if (auto* rb = reg.try_get<Engine::ECS::RigidBodyComponent>(selected))
|
||
{
|
||
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"))
|
||
{
|
||
rb->type = static_cast<Engine::ECS::RigidBodyComponent::BodyType>(type);
|
||
}
|
||
int collider = static_cast<int>(rb->collider);
|
||
if (ImGui::Combo("collider", &collider,
|
||
"Box\0Sphere\0Mesh\0Capsule\0Cylinder\0Compound\0"))
|
||
{
|
||
rb->collider = static_cast<Engine::ECS::RigidBodyComponent::ColliderType>(collider);
|
||
}
|
||
if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Box)
|
||
{
|
||
ImGui::DragFloat3("halfExtents", &rb->boxHalfExtents.x, 0.05f, 0.01f, 500.0f);
|
||
}
|
||
else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Sphere)
|
||
{
|
||
ImGui::DragFloat("radius", &rb->sphereRadius, 0.05f, 0.01f, 500.0f);
|
||
}
|
||
else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Capsule)
|
||
{
|
||
// 半高 = 圆柱段半高(不含半球端盖),总高 = 2*(halfHeight + radius)
|
||
ImGui::DragFloat("radius", &rb->capsuleRadius, 0.05f, 0.01f, 500.0f);
|
||
ImGui::DragFloat("halfHeight", &rb->capsuleHalfHeight, 0.05f, 0.0f, 500.0f);
|
||
}
|
||
else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Cylinder)
|
||
{
|
||
ImGui::DragFloat("radius", &rb->cylinderRadius, 0.05f, 0.01f, 500.0f);
|
||
ImGui::DragFloat("halfHeight", &rb->cylinderHalfHeight, 0.05f, 0.01f, 500.0f);
|
||
}
|
||
else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Compound)
|
||
{
|
||
// 子形状列表:尺寸/位置变更 → PhysicsWorld 重建 body(既有模式)
|
||
ImGui::Text("children: %d", static_cast<int>(rb->compound.size()));
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("+ add"))
|
||
{
|
||
rb->compound.emplace_back();
|
||
}
|
||
int remove = -1;
|
||
for (int i = 0; i < static_cast<int>(rb->compound.size()); ++i)
|
||
{
|
||
auto& c = rb->compound[i];
|
||
ImGui::PushID(i);
|
||
ImGui::Separator();
|
||
int sh = static_cast<int>(c.shape);
|
||
if (ImGui::Combo("shape", &sh, "Box\0Sphere\0Capsule\0"))
|
||
{
|
||
c.shape = static_cast<Engine::ECS::RigidBodyComponent::CompoundChild::Shape>(sh);
|
||
}
|
||
if (c.shape == Engine::ECS::RigidBodyComponent::CompoundChild::Shape::Box)
|
||
{
|
||
ImGui::DragFloat3("halfExtents", &c.boxHalfExtents.x, 0.05f, 0.01f, 500.0f);
|
||
}
|
||
else
|
||
{
|
||
ImGui::DragFloat("radius", &c.radius, 0.05f, 0.01f, 500.0f);
|
||
if (c.shape == Engine::ECS::RigidBodyComponent::CompoundChild::Shape::Capsule)
|
||
{
|
||
ImGui::DragFloat("halfHeight", &c.capsuleHalfHeight, 0.05f, 0.0f, 500.0f);
|
||
}
|
||
}
|
||
ImGui::DragFloat3("offset", &c.position.x, 0.05f);
|
||
if (ImGui::Button("remove"))
|
||
{
|
||
remove = i;
|
||
}
|
||
ImGui::PopID();
|
||
}
|
||
if (remove >= 0)
|
||
{
|
||
rb->compound.erase(rb->compound.begin() + remove);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Mesh:跟随 MeshRenderer 视觉网格(Static=精确网格,
|
||
// Dynamic=凸包近似),无尺寸可调
|
||
ImGui::TextDisabled("follows MeshRenderer geometry");
|
||
}
|
||
ImGui::DragFloat("mass", &rb->mass, 0.1f, 0.1f, 1000.0f, "%.1f");
|
||
ImGui::SliderFloat("gravityScale", &rb->gravityScale, -1.0f, 5.0f);
|
||
ImGui::SliderFloat("friction", &rb->friction, 0.0f, 2.0f);
|
||
ImGui::SliderFloat("restitution", &rb->restitution, 0.0f, 1.0f);
|
||
ImGui::Checkbox("isTrigger", &rb->isTrigger);
|
||
if (rb->bodyValid &&
|
||
rb->type == Engine::ECS::RigidBodyComponent::BodyType::Dynamic)
|
||
{
|
||
ImGui::Text("vel: %.2f, %.2f, %.2f m/s",
|
||
rb->velocity.x, rb->velocity.y, rb->velocity.z);
|
||
|
||
// 驱动调试(冲量瞬态不序列化):直接推一把看响应
|
||
static glm::vec3 s_debugImpulse{0.0f, 5.0f, 0.0f};
|
||
ImGui::DragFloat3("impulse", &s_debugImpulse.x, 0.1f);
|
||
if (ImGui::Button("Apply Impulse"))
|
||
{
|
||
ctx.app->GetPhysics().AddImpulse(
|
||
static_cast<uint32_t>(entt::to_integral(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))
|
||
{
|
||
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"))
|
||
{
|
||
jc->type = static_cast<Engine::ECS::JointComponent::Type>(type);
|
||
}
|
||
|
||
// target 实体下拉:实体名列表,选中态按 UUID 匹配(变更写回)
|
||
std::string items;
|
||
std::vector<Engine::UUID> uuids;
|
||
int current = -1;
|
||
reg.view<Engine::ECS::IdComponent, Engine::ECS::NameComponent>().each(
|
||
[&](entt::entity e, const Engine::ECS::IdComponent& id,
|
||
const Engine::ECS::NameComponent& n)
|
||
{
|
||
if (e == selected)
|
||
{
|
||
return; // 自连无意义
|
||
}
|
||
if (id.uuid == jc->target)
|
||
{
|
||
current = static_cast<int>(uuids.size());
|
||
}
|
||
uuids.push_back(id.uuid);
|
||
items.append(n.name);
|
||
items.push_back('\0');
|
||
});
|
||
items.push_back('\0'); // ImGui Combo 双 \0 结尾
|
||
if (ImGui::Combo("target", ¤t, items.c_str()) && current >= 0)
|
||
{
|
||
jc->target = uuids[static_cast<size_t>(current)];
|
||
}
|
||
|
||
ImGui::DragFloat3("anchor A", &jc->anchorA.x, 0.05f);
|
||
ImGui::DragFloat3("anchor B", &jc->anchorB.x, 0.05f);
|
||
if (jc->type == Engine::ECS::JointComponent::Type::Hinge)
|
||
{
|
||
ImGui::DragFloat3("hinge axis", &jc->hingeAxis.x, 0.05f);
|
||
ImGui::Checkbox("enable limit", &jc->enableLimit);
|
||
if (jc->enableLimit)
|
||
{
|
||
ImGui::DragFloat("min deg", &jc->limitMin, 1.0f, -180.0f, 0.0f);
|
||
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))
|
||
{
|
||
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);
|
||
ImGui::DragFloat("mass", &cc->mass, 1.0f, 1.0f, 500.0f, "%.0f");
|
||
ImGui::DragFloat("max slope deg", &cc->maxSlopeAngle, 1.0f, 1.0f, 89.0f);
|
||
ImGui::DragFloat("step height", &cc->stepHeight, 0.02f, 0.0f, 2.0f);
|
||
|
||
// 遥测(只读):运行时状态在 PhysicsWorld 的 CharacterController
|
||
if (auto& phy = ctx.app->GetPhysics(); phy.IsEnabled())
|
||
{
|
||
const uint32_t bits =
|
||
static_cast<uint32_t>(entt::to_integral(selected));
|
||
ImGui::Text("grounded: %s",
|
||
phy.IsCharacterGrounded(bits) ? "yes" : "no");
|
||
const glm::vec3 v = phy.GetCharacterVelocity(bits);
|
||
ImGui::Text("vel: %.2f, %.2f, %.2f m/s", v.x, v.y, v.z);
|
||
|
||
// 跳跃调试按钮
|
||
if (ImGui::Button("Jump"))
|
||
{
|
||
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))
|
||
{
|
||
bool removeSc = false;
|
||
if (ComponentHeader("Script", &sc->active, true, &removeSc))
|
||
{
|
||
if (!sc->active)
|
||
{
|
||
ImGui::BeginDisabled(true);
|
||
}
|
||
// 多脚本列表(Unity 语义):每行 = 脚本下拉 + 独立启停 + 删除;
|
||
// 实例生命周期由 ScriptRuntime 对账(删行/改名 → 下帧 OnDestroy)
|
||
const auto& names = Engine::Scripting::ScriptRegistry::Names();
|
||
int removeIdx = -1;
|
||
for (int i = 0; i < static_cast<int>(sc->items.size()); ++i)
|
||
{
|
||
auto& item = sc->items[static_cast<size_t>(i)];
|
||
ImGui::PushID(i);
|
||
// 去重下拉(DisallowMultipleComponent 语义):可选项 =
|
||
// "<none>" + 未挂名 + 自身名(已挂的别人的名不出现,结构上
|
||
// 选不出重复);自身名置于末尾保证已挂项正常回显
|
||
std::vector<const char*> options;
|
||
options.push_back("<none>");
|
||
for (const auto& n : names)
|
||
{
|
||
if (n == item.name)
|
||
{
|
||
continue;
|
||
}
|
||
const bool attached = std::any_of(
|
||
sc->items.begin(), sc->items.end(),
|
||
[&](const Engine::ECS::ScriptComponent::Item& it)
|
||
{ return it.name == n; });
|
||
if (!attached)
|
||
{
|
||
options.push_back(n.c_str());
|
||
}
|
||
}
|
||
if (!item.name.empty())
|
||
{
|
||
options.push_back(item.name.c_str());
|
||
}
|
||
int cur = 0;
|
||
for (size_t k = 0; k < options.size(); ++k)
|
||
{
|
||
if (item.name == options[k])
|
||
{
|
||
cur = static_cast<int>(k);
|
||
}
|
||
}
|
||
ImGui::SetNextItemWidth(
|
||
ImGui::GetContentRegionAvail().x - 64.0f);
|
||
if (ImGui::Combo("##script", &cur, options.data(),
|
||
static_cast<int>(options.size())))
|
||
{
|
||
item.name = (cur <= 0) ? "" : options[static_cast<size_t>(cur)];
|
||
}
|
||
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 (removeIdx >= 0)
|
||
{
|
||
sc->items.erase(sc->items.begin() + removeIdx);
|
||
}
|
||
// 已注册脚本全部挂上 → 禁止再加(去重语义,无意义项)
|
||
const bool allAttached = !names.empty() &&
|
||
std::all_of(names.begin(), names.end(),
|
||
[&](const std::string& n)
|
||
{
|
||
return std::any_of(
|
||
sc->items.begin(), sc->items.end(),
|
||
[&](const Engine::ECS::ScriptComponent::Item& it)
|
||
{ return it.name == n; });
|
||
});
|
||
if (allAttached)
|
||
{
|
||
ImGui::BeginDisabled(true);
|
||
ImGui::Button("+ Add Script");
|
||
ImGui::EndDisabled();
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("all registered scripts attached");
|
||
}
|
||
else if (ImGui::Button("+ Add Script"))
|
||
{
|
||
sc->items.push_back({});
|
||
}
|
||
if (!sc->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeSc)
|
||
{
|
||
reg.remove<Engine::ECS::ScriptComponent>(selected);
|
||
}
|
||
}
|
||
|
||
// ---- Animation ----
|
||
if (auto* anim = reg.try_get<Engine::ECS::AnimationComponent>(selected))
|
||
{
|
||
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);
|
||
// 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 <none> 即无效)
|
||
Engine::Model* model =
|
||
(mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model)
|
||
: nullptr;
|
||
|
||
// ---- 外部动画并入(自愈重放 + attach 槽)----
|
||
// 重放:attachedAnimPaths 里未并入的源(重启后 Model 缓存是
|
||
// 空白)幂等重 attach;解析失败的路径会话内不再重试
|
||
if (model != nullptr)
|
||
{
|
||
static std::unordered_set<std::string> s_AttachFailed;
|
||
for (const auto& p : anim->attachedAnimPaths)
|
||
{
|
||
const bool done = std::any_of(
|
||
model->GetClips().begin(), model->GetClips().end(),
|
||
[&](const Engine::AnimationClip& c)
|
||
{ return c.sourcePath == p; });
|
||
if (!done && !s_AttachFailed.contains(p))
|
||
{
|
||
const auto res =
|
||
ctx.assets->AttachAnimationClips(mr->model, p);
|
||
if (res.clipsAdded == 0)
|
||
{
|
||
s_AttachFailed.insert(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
|
||
if (mr == nullptr)
|
||
{
|
||
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
|
||
"! requires a MeshRenderer component");
|
||
}
|
||
else if (model != nullptr)
|
||
{
|
||
// 动画资产槽:列全部模型容器文件(无法静态区分"模型
|
||
// fbx"与"纯动画 fbx",一并列出,无 clip 的会警告跳过)
|
||
const auto& files = AssetModelFiles();
|
||
std::vector<const char*> opts;
|
||
opts.push_back("<attach animation file...>");
|
||
for (const auto& f : files)
|
||
{
|
||
opts.push_back(f.c_str());
|
||
}
|
||
int sel = 0;
|
||
ImGui::SetNextItemWidth(-1.0f);
|
||
if (ImGui::Combo("##anim_slot", &sel, opts.data(),
|
||
static_cast<int>(opts.size())))
|
||
{
|
||
const std::string& path = files[static_cast<size_t>(sel) - 1];
|
||
const auto res =
|
||
ctx.assets->AttachAnimationClips(mr->model, path);
|
||
if (res.clipsAdded > 0 &&
|
||
std::none_of(anim->attachedAnimPaths.begin(),
|
||
anim->attachedAnimPaths.end(),
|
||
[&](const std::string& s)
|
||
{ return s == path; }))
|
||
{
|
||
anim->attachedAnimPaths.push_back(path);
|
||
}
|
||
}
|
||
// 已并入的源文件清单
|
||
for (const auto& p : anim->attachedAnimPaths)
|
||
{
|
||
ImGui::BulletText("%s", std::filesystem::path(p)
|
||
.filename()
|
||
.string()
|
||
.c_str());
|
||
}
|
||
}
|
||
if (model != nullptr && model->HasAnimations())
|
||
{
|
||
const auto& clips = model->GetClips();
|
||
std::vector<const char*> clipNames;
|
||
clipNames.reserve(clips.size());
|
||
for (const auto& c : clips)
|
||
{
|
||
clipNames.push_back(c.name.c_str());
|
||
}
|
||
int cur = anim->clipIndex;
|
||
if (ImGui::Combo("clip", &cur, clipNames.data(),
|
||
static_cast<int>(clipNames.size())))
|
||
{
|
||
anim->Play(cur); // 触发 crossfade(fade 秒)
|
||
}
|
||
ImGui::Checkbox("playing", &anim->playing);
|
||
ImGui::SameLine();
|
||
ImGui::Checkbox("loop", &anim->loop);
|
||
ImGui::SliderFloat("speed", &anim->speed, -2.0f, 2.0f);
|
||
ImGui::SliderFloat("fade", &anim->fadeDuration, 0.0f, 1.0f);
|
||
if (anim->clipIndex >= 0 &&
|
||
anim->clipIndex < static_cast<int>(clips.size()))
|
||
{
|
||
ImGui::Text("t: %.2f / %.2f s", anim->time,
|
||
clips[anim->clipIndex].duration);
|
||
}
|
||
// 动画数据表:每个 clip 的时长与有效轨道数(attach 进来的
|
||
// 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼
|
||
// 命名不同源被跳过)
|
||
if (ImGui::TreeNode("clip data"))
|
||
{
|
||
ImGui::Columns(3, nullptr, false);
|
||
ImGui::SetColumnWidth(0, 150.0f);
|
||
ImGui::SetColumnWidth(1, 90.0f);
|
||
ImGui::TextUnformatted("name");
|
||
ImGui::NextColumn();
|
||
ImGui::TextUnformatted("dur (s)");
|
||
ImGui::NextColumn();
|
||
ImGui::TextUnformatted("channels");
|
||
ImGui::NextColumn();
|
||
ImGui::Separator();
|
||
for (const auto& c : clips)
|
||
{
|
||
const bool curClip =
|
||
&c == &clips[static_cast<size_t>(
|
||
std::clamp(anim->clipIndex, 0,
|
||
static_cast<int>(clips.size()) - 1))];
|
||
if (curClip)
|
||
{
|
||
ImGui::TextColored(ImVec4(0.3f, 0.85f, 0.4f, 1.0f),
|
||
">%s", c.name.c_str());
|
||
}
|
||
else
|
||
{
|
||
ImGui::TextUnformatted(c.name.c_str());
|
||
}
|
||
ImGui::NextColumn();
|
||
ImGui::Text("%.2f", c.duration);
|
||
ImGui::NextColumn();
|
||
ImGui::Text("%u", static_cast<unsigned>(c.channels.size()));
|
||
ImGui::NextColumn();
|
||
}
|
||
ImGui::Columns(1);
|
||
ImGui::TreePop();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
ImGui::TextDisabled("(no animation clips on model)");
|
||
}
|
||
if (!anim->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeAnim)
|
||
{
|
||
reg.remove<Engine::ECS::AnimationComponent>(selected);
|
||
}
|
||
}
|
||
|
||
ImGui::End();
|
||
}
|