- 动画:HumanoidRig 三层架构(standard 标准槽 / dialects 方言映射 / mapper 配对), 指/趾统一走世界增量两遍式烘焙;AnimationAttach 拆为纯算法 + facade;修正 HumanoidBasis 行走基朝向与跨骨架去重、根运动通道 - 物理:CharacterController 角色互碰撞 / shapeOffset / 接触事件;PhysicsWorld 接触合成与 字符冲量判定 - 渲染与材质:Model 多材质按材质拆子 mesh 与贴图路径解析、DDS 直传、GLB 内嵌纹理落盘、 不透明通道 alpha 固定 1.0、glTF FlipUVs 对齐;SceneSerializer 支持 meshIndex 与材质覆盖 - 脚本:FractureLib 抽为共享库(HostView 桥接)、WallFracture 参数直绑、新增 ThirdPersonCamera / Follower / WASDMove 玩法;移除 GameDirector / PlayerController / Target - 编辑器:新增 GamepadPanel、LogPanel(LogRing),改进 Inspector / 材质 / 统计 / 视口面板 - 依赖:vendored ufbx(FBX 5.x/6.x/7.x 全版本解析,替代 assimp 对 FBX 6.1 的拒读)
2136 lines
94 KiB
C++
2136 lines
94 KiB
C++
//
|
||
// InspectorPanel.cpp:组件编辑实现。
|
||
//
|
||
// Transform 编辑必须走 Set* 入口(标脏 → 帧循环统一 Compose 重算
|
||
// world),直接改私有成员是编译错误——面板天然遵守引擎约束。
|
||
//
|
||
|
||
#include "InspectorPanel.h"
|
||
#include "MaterialAssetUtil.h" // .material 枚举 + Create Override(与材质编辑器共享)
|
||
#include "MaterialEditorPanel.h" // 指派/编辑分离:双击槽位开独立面板
|
||
|
||
#include <functional> // ComponentHeader 组件专属菜单项
|
||
#include "Engine/ECS/Components.h"
|
||
#include "Engine/Core/Types.h"
|
||
#include "Engine/Animation/AnimationController.h" // Controller 模式 UI(参数/状态枚举)
|
||
#include "Engine/Renderer/Model.h"
|
||
#include "Engine/Renderer/MaterialAsset.h" // 材质覆盖槽(.material 资产)
|
||
#include "Engine/Physics/PhysicsWorld.h" // Apply Impulse 调试(GetPhysics 门面)
|
||
#include "Engine/Scripting/Script.h" // 脚本下拉(ScriptRegistry::Names)
|
||
#include "Engine/Scripting/ScriptRuntime.h" // 参数编辑 → RefreshParams
|
||
#include "Engine/Serialization/SceneSerializer.h" // ResolveProcedural(程序化入口)
|
||
#include "Engine/Core/Log.h" // Create Override 失败告警
|
||
|
||
#include <imgui.h>
|
||
|
||
#include <algorithm>
|
||
#include <cctype>
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
|
||
#include "ControllerEditorPanel.h"
|
||
#include <unordered_set>
|
||
#include <vector>
|
||
#include <type_traits>
|
||
|
||
namespace
|
||
{
|
||
// 材质槽循环索引用(u32 定义于 namespace Engine,App 层显式引入)
|
||
using Engine::u32;
|
||
|
||
// 脚本参数对齐 schema:缺失 → 声明默认补齐;类型不符 → 重置默认
|
||
//(换脚本类/手编档防呆)。返回对齐后的参数项指针(每帧幂等)
|
||
Engine::Scripting::ScriptParam* EnsureParam(
|
||
Engine::ECS::ScriptComponent::Item& item,
|
||
const Engine::Scripting::ParamBuilder::Desc& d)
|
||
{
|
||
auto it = std::find_if(
|
||
item.params.begin(), item.params.end(),
|
||
[&](const Engine::Scripting::ScriptParam& p)
|
||
{ return p.name == d.name; });
|
||
if (it == item.params.end())
|
||
{
|
||
item.params.push_back({d.name, d.def});
|
||
return &item.params.back();
|
||
}
|
||
if (it->value.Type() != d.type)
|
||
{
|
||
it->value = d.def;
|
||
}
|
||
return &*it;
|
||
}
|
||
|
||
// 递归收集 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;
|
||
}
|
||
|
||
// 递归收集 assets/ 下动画控制器资产(.animctrl,Controller 槽用;
|
||
// 会话级缓存,Create 新文件后经 InvalidateControllerFiles 失效)
|
||
static std::vector<std::string> s_CtrlFiles;
|
||
static bool s_CtrlFilesCached = false;
|
||
void InvalidateControllerFiles() { s_CtrlFilesCached = false; }
|
||
const std::vector<std::string>& AssetControllerFiles()
|
||
{
|
||
if (!s_CtrlFilesCached)
|
||
{
|
||
s_CtrlFilesCached = true;
|
||
s_CtrlFiles.clear();
|
||
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 == ".animctrl")
|
||
{
|
||
s_CtrlFiles.push_back(entry.path().generic_string());
|
||
}
|
||
}
|
||
std::sort(s_CtrlFiles.begin(), s_CtrlFiles.end());
|
||
}
|
||
}
|
||
return s_CtrlFiles;
|
||
}
|
||
|
||
// 递归收集 assets/ 下环境贴图(.hdr,Skybox 资产槽用;会话级缓存同上)
|
||
const std::vector<std::string>& AssetEnvFiles()
|
||
{
|
||
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 == ".hdr")
|
||
{
|
||
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;
|
||
}
|
||
|
||
// MeshRenderer 材质槽列表(UE Material Slots 语义):内建材质只读,
|
||
// 覆盖槽 = .material 资产引用,可换绑/还原。参数/纹理"编辑"在独立
|
||
// Material Editor 面板(双击槽位球打开)——指派与编辑分离,
|
||
// 本行只做指派(UE Details 材质槽交互)
|
||
void DrawMaterialSlots(const EditorContext& ctx,
|
||
const Engine::Model& model,
|
||
Engine::ECS::MeshRendererComponent& mr,
|
||
entt::entity owner,
|
||
Engine::Assets::AssetManager& assets,
|
||
Engine::Renderer& renderer)
|
||
{
|
||
const auto& meshes = model.GetMeshes();
|
||
const auto& infos = model.GetMeshMaterialInfos();
|
||
ImGui::SeparatorText("Material Slots");
|
||
constexpr float kPreviewSize = 48.0f;
|
||
|
||
for (u32 i = 0; i < meshes.size(); ++i)
|
||
{
|
||
// 「mesh」下拉的过滤:选了具体子 mesh 就只列该槽位(与渲染/
|
||
// 物理的子引用语义一致);All meshes(-1)= 全列
|
||
if (mr.meshIndex >= 0 && static_cast<int>(i) != mr.meshIndex)
|
||
{
|
||
continue;
|
||
}
|
||
ImGui::PushID(static_cast<int>(i));
|
||
|
||
const std::string meshName =
|
||
(i < infos.size() && !infos[i].name.empty())
|
||
? infos[i].name
|
||
: "Slot " + std::to_string(i);
|
||
|
||
// ---- 材质球预览(行左列 48²,tile = 池分配)----
|
||
// 材质来源与渲染收集期寻址一致:覆盖资产 > 内建。
|
||
// UE FAssetThumbnailPool 语义:只对滚进视口的行发幂等请求
|
||
//(IsRectVisible 探测,132 槽模型只处理可见行),池按
|
||
// serial+参数版本判缓存——渲染一次跨帧持久,EditParams bump
|
||
// 版本后下帧重渲该 tile;-1 = 首渲未完成/池满(占位排队)
|
||
auto it = mr.materialOverrides.find(i);
|
||
Engine::Material* slotMat = nullptr;
|
||
const char* slotSource = "built-in";
|
||
if (it != mr.materialOverrides.end())
|
||
{
|
||
if (Engine::MaterialAsset* asset =
|
||
assets.FindMaterialAsset(it->second))
|
||
{
|
||
slotMat = asset->GetMaterial();
|
||
slotSource = it->second.c_str();
|
||
}
|
||
}
|
||
if (!slotMat)
|
||
{
|
||
slotMat = meshes[i]->GetMaterial();
|
||
}
|
||
|
||
// 可见性探测先行:请求只来自可见行(池 LRU 换页的前提)
|
||
const bool rowVisible =
|
||
ImGui::IsRectVisible(ImVec2(kPreviewSize, kPreviewSize));
|
||
Engine::i32 tile = -1;
|
||
if (slotMat && rowVisible)
|
||
{
|
||
tile = renderer.RequestMaterialPreview(slotMat);
|
||
}
|
||
|
||
// ---- 球 + 拖放目标(Content Browser 拖 .material 指派)----
|
||
void* atlasTex = renderer.GetMaterialPreviewTexture();
|
||
if (atlasTex && tile >= 0)
|
||
{
|
||
const float uvStep =
|
||
1.0f / float(Engine::Passes::kPreviewTilesPerSide);
|
||
const float u0 =
|
||
float(tile % Engine::Passes::kPreviewTilesPerSide) * uvStep;
|
||
const float v0 =
|
||
float(tile / Engine::Passes::kPreviewTilesPerSide) * uvStep;
|
||
ImGui::Image(atlasTex, ImVec2(kPreviewSize, kPreviewSize),
|
||
ImVec2(u0, v0),
|
||
ImVec2(u0 + uvStep, v0 + uvStep));
|
||
}
|
||
else
|
||
{
|
||
// atlas 未就绪(首帧前)/ tile 首渲排队中:占位保持行高
|
||
ImGui::Dummy(ImVec2(kPreviewSize, kPreviewSize));
|
||
}
|
||
if (ImGui::IsItemHovered())
|
||
{
|
||
ImGui::SetTooltip("%s\nSource: %s\nDouble-click to edit",
|
||
meshName.c_str(), slotSource);
|
||
}
|
||
|
||
// ---- 双击球 = 打开 Material Editor(指派/编辑分离)----
|
||
if (ctx.materialEditor && ImGui::IsItemHovered() &&
|
||
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))
|
||
{
|
||
if (it != mr.materialOverrides.end())
|
||
{
|
||
ctx.materialEditor->OpenAsset(it->second);
|
||
}
|
||
else
|
||
{
|
||
ctx.materialEditor->OpenBuiltin(owner, i, meshName);
|
||
}
|
||
}
|
||
if (ImGui::BeginDragDropTarget())
|
||
{
|
||
if (const ImGuiPayload* pl = ImGui::AcceptDragDropPayload(
|
||
kMaterialDragPayload))
|
||
{
|
||
const std::string dropped(
|
||
static_cast<const char*>(pl->Data));
|
||
if (assets.LoadMaterialAsset(dropped).Valid())
|
||
{
|
||
mr.materialOverrides[i] = dropped;
|
||
}
|
||
it = mr.materialOverrides.find(i);
|
||
}
|
||
ImGui::EndDragDropTarget();
|
||
}
|
||
|
||
ImGui::SameLine();
|
||
ImGui::BeginGroup();
|
||
ImGui::TextUnformatted(meshName.c_str());
|
||
|
||
if (it == mr.materialOverrides.end())
|
||
{
|
||
// 内建只读:一键生成覆盖资产(成功即自动进编辑器)
|
||
if (i < infos.size() &&
|
||
ImGui::SmallButton("Create Override"))
|
||
{
|
||
const std::string rel =
|
||
EditorMaterialUtil::CreateMaterialOverride(
|
||
assets.Models().Path(mr.model), infos[i], i, mr,
|
||
assets);
|
||
if (!rel.empty() && ctx.materialEditor)
|
||
{
|
||
ctx.materialEditor->OpenAsset(rel);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
const std::string& cur = it->second;
|
||
|
||
// 资产文件下拉(当前路径不在枚举里也插入显示,防错位)
|
||
const auto& files =
|
||
EditorMaterialUtil::AssetMaterialFiles();
|
||
std::vector<const char*> opts;
|
||
opts.push_back("<none>");
|
||
bool curListed = false;
|
||
for (const auto& f : files)
|
||
{
|
||
opts.push_back(f.c_str());
|
||
curListed |= (f == cur);
|
||
}
|
||
if (!curListed)
|
||
{
|
||
opts.insert(opts.begin() + 1, cur.c_str());
|
||
}
|
||
int sel = -1;
|
||
for (int k = 0; k < static_cast<int>(opts.size()); ++k)
|
||
{
|
||
if (std::strcmp(opts[static_cast<size_t>(k)],
|
||
cur.c_str()) == 0)
|
||
{
|
||
sel = k;
|
||
}
|
||
}
|
||
|
||
const float revertW = ImGui::CalcTextSize("Revert").x +
|
||
ImGui::GetStyle().FramePadding.x * 2.0f;
|
||
ImGui::SetNextItemWidth(
|
||
ImGui::GetContentRegionAvail().x - revertW -
|
||
ImGui::GetStyle().ItemSpacing.x);
|
||
if (ImGui::Combo("##mat", &sel, opts.data(),
|
||
static_cast<int>(opts.size())))
|
||
{
|
||
if (sel <= 0)
|
||
{
|
||
mr.materialOverrides.erase(i);
|
||
}
|
||
else
|
||
{
|
||
const char* chosen = opts[static_cast<size_t>(sel)];
|
||
if (assets.LoadMaterialAsset(chosen).Valid())
|
||
{
|
||
mr.materialOverrides[i] = chosen;
|
||
}
|
||
}
|
||
it = mr.materialOverrides.find(i);
|
||
}
|
||
ImGui::SameLine();
|
||
if (ImGui::SmallButton("Revert"))
|
||
{
|
||
mr.materialOverrides.erase(i);
|
||
it = mr.materialOverrides.end();
|
||
}
|
||
|
||
// 覆盖资产未加载(罕见):就地补救(编辑器打开会自动 Load)
|
||
if (it != mr.materialOverrides.end() &&
|
||
assets.FindMaterialAsset(it->second) == nullptr)
|
||
{
|
||
ImGui::TextDisabled(" override not loaded");
|
||
if (ImGui::SmallButton("Retry Load"))
|
||
{
|
||
assets.LoadMaterialAsset(it->second);
|
||
}
|
||
}
|
||
}
|
||
|
||
ImGui::EndGroup();
|
||
|
||
ImGui::PopID();
|
||
}
|
||
|
||
ImGui::TextDisabled(
|
||
"double-click a sphere to edit; edits on shared .material "
|
||
"apply to all entities referencing it");
|
||
}
|
||
|
||
// 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 = 显示"...",
|
||
// 菜单内含 Remove;extraMenu(可空)= 组件专属菜单项(如 Camera 的
|
||
// 视角联动),画在 Remove 之前
|
||
bool ComponentHeader(const char* label, bool* active, bool removable,
|
||
bool* removeFlag,
|
||
const std::function<void()>& extraMenu = {})
|
||
{
|
||
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)
|
||
{
|
||
// "..." 菜单(UE Details 组件头语义):移除等组件操作收进
|
||
// 下拉,头部不再放裸删除钮(防误触)
|
||
ImGui::SameLine(x);
|
||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f);
|
||
if (ImGui::Button("...", ImVec2(24.0f, rowH - 4.0f)))
|
||
{
|
||
ImGui::OpenPopup("comp_menu");
|
||
}
|
||
if (ImGui::BeginPopup("comp_menu"))
|
||
{
|
||
if (extraMenu) extraMenu();
|
||
if (ImGui::MenuItem("Remove Component"))
|
||
{
|
||
*removeFlag = true;
|
||
}
|
||
ImGui::EndPopup();
|
||
}
|
||
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("Details");
|
||
|
||
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::SkyboxComponent*>(nullptr), "Skybox");
|
||
menuItem(static_cast<Engine::ECS::SkyLightComponent*>(nullptr),
|
||
"Sky 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]);
|
||
// 模型换绑后 mesh 下标语义失效:覆盖表显式清空
|
||
//(残留会错挂到新模型的槽位上)
|
||
mr->materialOverrides.clear();
|
||
}
|
||
ImGui::Spacing();
|
||
// 程序化模型入口:伪资产不在磁盘(下拉框列不到),由
|
||
// 弹窗组键生成 → ResolveProcedural(与场景反解同一
|
||
// 键格式唯一定义点;确定性键必然命中缓存)
|
||
if (ImGui::SmallButton("Procedural..."))
|
||
{
|
||
ImGui::OpenPopup("##proc_model");
|
||
}
|
||
if (ImGui::BeginPopup("##proc_model"))
|
||
{
|
||
static int shape = 0; // 0=cube 1=plane 2=transparent plane
|
||
static float half = 0.5f;
|
||
static float col[3] = {0.55f, 0.36f, 0.28f};
|
||
static float alpha = 0.5f;
|
||
ImGui::RadioButton("Cube", &shape, 0);
|
||
ImGui::SameLine();
|
||
ImGui::RadioButton("Plane", &shape, 1);
|
||
ImGui::SameLine();
|
||
ImGui::RadioButton("Trans.Plane", &shape, 2);
|
||
// 固定宽度而非 -1:弹窗是 AlwaysAutoResize,-1(撑满可用
|
||
// 宽度)会拿上一帧宽度当基准 → 逐帧膨胀直到撑满屏幕
|
||
ImGui::SetNextItemWidth(180.0f);
|
||
ImGui::SliderFloat("half size", &half, 0.01f, 10.0f,
|
||
"%.2f");
|
||
ImGui::ColorEdit3("color", col);
|
||
if (shape == 2)
|
||
{
|
||
ImGui::SetNextItemWidth(180.0f);
|
||
ImGui::SliderFloat("alpha", &alpha, 0.0f, 1.0f,
|
||
"%.2f");
|
||
}
|
||
if (ImGui::Button("Create & Assign"))
|
||
{
|
||
const int r = int(col[0] * 255.0f + 0.5f);
|
||
const int g = int(col[1] * 255.0f + 0.5f);
|
||
const int b = int(col[2] * 255.0f + 0.5f);
|
||
char key[96];
|
||
if (shape == 2)
|
||
{
|
||
const int a = int(alpha * 255.0f + 0.5f);
|
||
std::snprintf(
|
||
key, sizeof(key),
|
||
"procedural://transparent-plane/%.3g/%d,%d,%d,%d",
|
||
half, r, g, b, a);
|
||
}
|
||
else
|
||
{
|
||
std::snprintf(
|
||
key, sizeof(key),
|
||
"procedural://%s/%.3g/%d,%d,%d",
|
||
shape == 0 ? "cube" : "plane", half, r, g, b);
|
||
}
|
||
if (auto ref = Engine::Serialization::
|
||
SceneSerializer::ResolveProcedural(
|
||
*ctx.assets, key);
|
||
ref.Valid())
|
||
{
|
||
mr->model = ref;
|
||
mr->materialOverrides.clear();
|
||
ImGui::CloseCurrentPopup();
|
||
}
|
||
}
|
||
ImGui::EndPopup();
|
||
}
|
||
}
|
||
// 无效句柄不进 GetModel(缓存对无效/悬空句柄 FATAL,是 bug 信号)
|
||
if (mr->model.Valid())
|
||
{
|
||
if (const Engine::Model* model = ctx.assets->GetModel(mr->model);
|
||
model != nullptr)
|
||
{
|
||
// 单 mesh 子引用下拉(-1 = 整模型全部):名字用
|
||
// GetMeshMaterialInfos(材质名优先,空回退 Slot N)
|
||
{
|
||
const auto& infos = model->GetMeshMaterialInfos();
|
||
const int sel = mr->meshIndex + 1; // 0 = All
|
||
std::string preview = "All meshes";
|
||
if (sel > 0 &&
|
||
static_cast<size_t>(sel - 1) < infos.size()) {
|
||
preview = infos[static_cast<size_t>(sel) - 1]
|
||
.name.empty()
|
||
? "Slot " + std::to_string(sel - 1)
|
||
: infos[static_cast<size_t>(sel) - 1].name;
|
||
}
|
||
if (ImGui::BeginCombo("mesh", preview.c_str()))
|
||
{
|
||
if (ImGui::Selectable("All meshes", sel <= 0)) {
|
||
mr->meshIndex = -1;
|
||
}
|
||
for (size_t i = 0; i < infos.size(); ++i)
|
||
{
|
||
const std::string label =
|
||
(infos[i].name.empty()
|
||
? "Slot " + std::to_string(i)
|
||
: infos[i].name) +
|
||
"##" + std::to_string(i);
|
||
const bool cur =
|
||
mr->meshIndex == static_cast<int>(i);
|
||
if (ImGui::Selectable(label.c_str(), cur)) {
|
||
mr->meshIndex = static_cast<int>(i);
|
||
}
|
||
}
|
||
ImGui::EndCombo();
|
||
}
|
||
}
|
||
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");
|
||
}
|
||
// ---- 材质槽(UE Material Slots:内建只读 + .material 覆盖)----
|
||
if (mr->model.Valid())
|
||
{
|
||
if (const Engine::Model* model = ctx.assets->GetModel(mr->model);
|
||
model != nullptr && !model->GetMeshes().empty())
|
||
{
|
||
DrawMaterialSlots(ctx, *model, *mr, selected, *ctx.assets,
|
||
*ctx.renderer);
|
||
}
|
||
}
|
||
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("Cast Shadows", &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);
|
||
}
|
||
}
|
||
|
||
// ---- Skybox(UE Skybox Actor 语义:环境贴图 = 实体资产槽)----
|
||
if (auto* sb = reg.try_get<Engine::ECS::SkyboxComponent>(selected))
|
||
{
|
||
bool removeSb = false;
|
||
if (ComponentHeader("Skybox", &sb->active, true, &removeSb))
|
||
{
|
||
if (!sb->active)
|
||
{
|
||
ImGui::BeginDisabled(true);
|
||
}
|
||
// 环境贴图资产槽(模型槽同款:下拉列 assets/ 下 .hdr,选中
|
||
// 即加载;空 = 无环境,只露清屏色)。渲染侧由 Renderer 按
|
||
// 帧数据差量同步(路径变化才重载),此处零额外同步
|
||
{
|
||
const auto& s_EnvFiles = AssetEnvFiles();
|
||
std::vector<const char*> opts;
|
||
opts.push_back("<none>");
|
||
for (const auto& f : s_EnvFiles)
|
||
{
|
||
opts.push_back(f.c_str());
|
||
}
|
||
int sel = 0;
|
||
for (size_t k = 0; k < s_EnvFiles.size(); ++k)
|
||
{
|
||
if (s_EnvFiles[k] == sb->envPath)
|
||
{
|
||
sel = static_cast<int>(k) + 1;
|
||
}
|
||
}
|
||
ImGui::SetNextItemWidth(-1.0f);
|
||
if (ImGui::Combo("##env_slot", &sel, opts.data(),
|
||
static_cast<int>(opts.size())))
|
||
{
|
||
sb->envPath = (sel <= 0)
|
||
? std::string{}
|
||
: s_EnvFiles[static_cast<size_t>(sel) - 1];
|
||
}
|
||
ImGui::TextDisabled("(empty = clear color only)");
|
||
}
|
||
if (!sb->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeSb)
|
||
{
|
||
reg.remove<Engine::ECS::SkyboxComponent>(selected);
|
||
}
|
||
}
|
||
|
||
// ---- SkyLight(UE SkyLight 语义:全局环境光)----
|
||
if (auto* sl = reg.try_get<Engine::ECS::SkyLightComponent>(selected))
|
||
{
|
||
bool removeSl = false;
|
||
if (ComponentHeader("Sky Light", &sl->active, true, &removeSl))
|
||
{
|
||
if (!sl->active)
|
||
{
|
||
ImGui::BeginDisabled(true);
|
||
}
|
||
ImGui::ColorEdit4("Ambient", &sl->ambientColor.x);
|
||
if (!sl->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeSl)
|
||
{
|
||
reg.remove<Engine::ECS::SkyLightComponent>(selected);
|
||
}
|
||
}
|
||
|
||
// ---- Camera ----
|
||
if (auto* cam = reg.try_get<Engine::ECS::CameraComponent>(selected))
|
||
{
|
||
bool removeCam = false;
|
||
// "..." 菜单组件专属项(双向同步,均只取位置+朝向,缩放不动):
|
||
// ① 编辑器相机 → 飞到本实体机位(编辑器视角立即从该相机处看)
|
||
// ② 本实体 → 摆到编辑器当前机位(摆实体相机,省手动对位)
|
||
// SetWorldPose 沿父链反解 local(层级实体不破坏父关系)
|
||
auto camMenu = [&]()
|
||
{
|
||
if (ImGui::MenuItem("Move Editor Camera Here") && ctx.editorCamera)
|
||
{
|
||
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
|
||
selected))
|
||
{
|
||
ctx.editorCamera->AlignTo(*tr);
|
||
}
|
||
}
|
||
if (ImGui::MenuItem("Move to Editor Camera Position") &&
|
||
ctx.editorCamera)
|
||
{
|
||
const auto& ed = ctx.editorCamera->GetTransform();
|
||
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
|
||
selected))
|
||
{
|
||
ctx.scene->SetWorldPose(selected, ed.GetWorldPosition(),
|
||
ed.GetWorldRotationQuat(),
|
||
tr->GetWorldScale());
|
||
}
|
||
}
|
||
};
|
||
if (ComponentHeader("Camera", &cam->active, true, &removeCam, camMenu))
|
||
{
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- 投影参数(UE/Unity Camera 语义):模式二选一,fov 与
|
||
// ortho size 按模式互斥显示;near/far 成对写回(SetClip
|
||
// 内部保证 far > near)----
|
||
const char* projModes[] = {"Perspective", "Orthographic"};
|
||
int pm = static_cast<int>(cam->camera.GetProjectionMode());
|
||
if (ImGui::Combo("Projection", &pm, projModes, 2))
|
||
{
|
||
cam->camera.SetProjectionMode(
|
||
static_cast<Engine::Camera::ProjectionMode>(pm));
|
||
}
|
||
if (cam->camera.GetProjectionMode() ==
|
||
Engine::Camera::ProjectionMode::Perspective)
|
||
{
|
||
float fov = cam->camera.GetFovDeg();
|
||
if (ImGui::DragFloat("FOV (deg)", &fov, 0.1f, 1.0f, 179.0f,
|
||
"%.1f"))
|
||
{
|
||
cam->camera.SetFovDeg(fov);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
float orthoSize = cam->camera.GetOrthoSize();
|
||
if (ImGui::DragFloat("Ortho Size", &orthoSize, 0.1f, 0.01f,
|
||
10000.0f, "%.2f"))
|
||
{
|
||
cam->camera.SetOrthoSize(orthoSize);
|
||
}
|
||
}
|
||
float nearZ = cam->camera.GetNearZ();
|
||
float farZ = cam->camera.GetFarZ();
|
||
const bool nearChanged = ImGui::DragFloat(
|
||
"Near Z", &nearZ, 0.01f, 0.001f, 1000.0f, "%.3f");
|
||
const bool farChanged = ImGui::DragFloat(
|
||
"Far Z", &farZ, 1.0f, 0.1f, 100000.0f, "%.1f");
|
||
if (nearChanged || farChanged)
|
||
{
|
||
cam->camera.SetClip(nearZ, farZ);
|
||
}
|
||
|
||
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)");
|
||
}
|
||
// ---- Body:类型与碰撞体选择(变更 → PhysicsWorld 重建 body)----
|
||
ImGui::SeparatorText("Body");
|
||
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);
|
||
}
|
||
// ---- Shape:碰撞体尺寸(随类型切换;× Transform 缩放烘焙)----
|
||
ImGui::SeparatorText("Shape");
|
||
if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Box)
|
||
{
|
||
ImGui::DragFloat3("Half Extents", &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("Half Height", &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("Half Height", &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("Half Extents", &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("Half Height", &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");
|
||
}
|
||
// ---- Simulation:动态体物理参数 ----
|
||
ImGui::SeparatorText("Simulation");
|
||
ImGui::DragFloat("Mass", &rb->mass, 0.1f, 0.1f, 1000.0f, "%.1f");
|
||
ImGui::SliderFloat("Gravity Scale", &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("Is Trigger", &rb->isTrigger);
|
||
// ---- Debug:遥测与驱动调试(冲量瞬态不序列化)----
|
||
if (rb->bodyValid &&
|
||
rb->type == Engine::ECS::RigidBodyComponent::BodyType::Dynamic)
|
||
{
|
||
ImGui::SeparatorText("Debug");
|
||
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");
|
||
}
|
||
// ---- Constraint:类型与目标体 ----
|
||
ImGui::SeparatorText("Constraint");
|
||
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)];
|
||
}
|
||
|
||
// ---- Anchors:各体局部系锚点 ----
|
||
ImGui::SeparatorText("Anchors");
|
||
ImGui::DragFloat3("Anchor A", &jc->anchorA.x, 0.05f);
|
||
ImGui::DragFloat3("Anchor B", &jc->anchorB.x, 0.05f);
|
||
// ---- Hinge:转轴与角度限位(度)----
|
||
if (jc->type == Engine::ECS::JointComponent::Type::Hinge)
|
||
{
|
||
ImGui::SeparatorText("Hinge");
|
||
ImGui::DragFloat3("Hinge Axis", &jc->hingeAxis.x, 0.05f);
|
||
ImGui::Checkbox("Enable Limit", &jc->enableLimit);
|
||
if (jc->enableLimit)
|
||
{
|
||
ImGui::DragFloat("Limit Min", &jc->limitMin, 1.0f, -180.0f, 0.0f);
|
||
ImGui::DragFloat("Limit Max", &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)");
|
||
}
|
||
// ---- Body:胶囊体(实体原点 = 脚底,总高 = 2*(halfHeight + radius))----
|
||
ImGui::SeparatorText("Body");
|
||
ImGui::DragFloat("Radius", &cc->radius, 0.02f, 0.05f, 5.0f);
|
||
ImGui::DragFloat("Half Height", &cc->capsuleHalfHeight, 0.02f, 0.0f, 5.0f);
|
||
ImGui::DragFloat("Mass", &cc->mass, 1.0f, 1.0f, 500.0f, "%.0f");
|
||
// 形状附加偏移:叠加在脚底约定之上(模型 pivot 对齐用)
|
||
ImGui::DragFloat3("Shape Offset", &cc->shapeOffset.x, 0.02f, -5.0f, 5.0f);
|
||
// ---- Movement:扫掠运动学参数 ----
|
||
ImGui::SeparatorText("Movement");
|
||
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);
|
||
ImGui::DragFloat("Gravity Scale", &cc->gravityScale, 0.1f, 0.0f, 10.0f);
|
||
|
||
// ---- Debug:遥测(只读,运行时状态在 PhysicsWorld)----
|
||
if (auto& phy = ctx.app->GetPhysics(); phy.IsEnabled())
|
||
{
|
||
ImGui::SeparatorText("Debug");
|
||
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;
|
||
}
|
||
// ---- 参数区(按脚本 schema 自动生成;缺省补齐 + 类型
|
||
// 防呆;编辑 → RefreshParams 重灌实例成员,PIE 当帧生效)----
|
||
if (!item.name.empty() &&
|
||
!Engine::Scripting::ScriptRegistry::Schema(item.name)
|
||
.empty())
|
||
{
|
||
const auto& schema =
|
||
Engine::Scripting::ScriptRegistry::Schema(item.name);
|
||
ImGui::Indent();
|
||
bool anyEdited = false;
|
||
for (const auto& d : schema)
|
||
{
|
||
auto* p = EnsureParam(item, d);
|
||
bool changed = false;
|
||
// 局部变量禁用 f32(glm::f32 歧义前科),用 float
|
||
switch (d.type)
|
||
{
|
||
case Engine::Scripting::ParamType::F32:
|
||
{
|
||
float v = std::get<float>(p->value.v);
|
||
if (ImGui::DragFloat(d.name.c_str(), &v, 0.1f))
|
||
{
|
||
p->value = Engine::Scripting::ParamValue{v};
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
case Engine::Scripting::ParamType::I32:
|
||
{
|
||
int v = std::get<Engine::i32>(p->value.v);
|
||
if (ImGui::DragInt(d.name.c_str(), &v))
|
||
{
|
||
p->value = Engine::Scripting::ParamValue{
|
||
static_cast<Engine::i32>(v)};
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
case Engine::Scripting::ParamType::Bool:
|
||
changed = ImGui::Checkbox(
|
||
d.name.c_str(), &std::get<bool>(p->value.v));
|
||
break;
|
||
case Engine::Scripting::ParamType::String:
|
||
{
|
||
char buf[256] = {};
|
||
std::snprintf(buf, sizeof(buf), "%s",
|
||
std::get<std::string>(p->value.v)
|
||
.c_str());
|
||
if (ImGui::InputText(d.name.c_str(), buf,
|
||
sizeof(buf)))
|
||
{
|
||
p->value = Engine::Scripting::ParamValue{
|
||
std::string(buf)};
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
case Engine::Scripting::ParamType::Vec2:
|
||
{
|
||
const glm::vec2 g =
|
||
std::get<glm::vec2>(p->value.v);
|
||
float v[2] = {g.x, g.y};
|
||
if (ImGui::DragFloat2(d.name.c_str(), v, 0.1f))
|
||
{
|
||
p->value = Engine::Scripting::ParamValue{
|
||
glm::vec2(v[0], v[1])};
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
case Engine::Scripting::ParamType::Vec3:
|
||
{
|
||
const glm::vec3 g =
|
||
std::get<glm::vec3>(p->value.v);
|
||
float v[3] = {g.x, g.y, g.z};
|
||
if (ImGui::DragFloat3(d.name.c_str(), v, 0.1f))
|
||
{
|
||
p->value = Engine::Scripting::ParamValue{
|
||
glm::vec3(v[0], v[1], v[2])};
|
||
changed = true;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (changed)
|
||
{
|
||
anyEdited = true;
|
||
}
|
||
}
|
||
if (anyEdited)
|
||
{
|
||
ctx.app->GetScripts().RefreshParams(
|
||
static_cast<uint32_t>(entt::to_integral(selected)));
|
||
}
|
||
ImGui::Unindent();
|
||
}
|
||
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 ----
|
||
// attach 遥测(会话级 static:最近一次并入的骨骼匹配结果,
|
||
// 面板内显示"matched/skipped")
|
||
static std::string s_LastAttachFile;
|
||
static unsigned s_LastAttachMatched = 0, s_LastAttachSkipped = 0;
|
||
static unsigned s_LastAttachRetargeted = 0;
|
||
static std::vector<Engine::Animation::RetargetRow> s_LastAttachRows;
|
||
// 时间拖动条的定格记忆:拖住瞬间是否在播(松手恢复用)
|
||
static bool s_ScrubWasPlaying = false;
|
||
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;
|
||
|
||
// ---- 骨骼可视化开关(Endfield-Poser 三件套)----全局编辑器
|
||
// 设置(经 Renderer 门面),非实体数据、不落场景序列化。
|
||
// 开 = 3D 骨架线 + 视口关节圆点;All Nodes = 显示全部节点
|
||
// 层级(普通节点暗色),关 = 仅骨骼链;圆点大小按手感调
|
||
{
|
||
bool show = ctx.renderer->GetBoneWiresVisible();
|
||
if (ImGui::Checkbox("Show Bones", &show))
|
||
{
|
||
ctx.renderer->SetBoneWiresVisible(show);
|
||
}
|
||
ImGui::SameLine();
|
||
bool allNodes = ctx.renderer->GetBoneAllNodes();
|
||
if (ImGui::Checkbox("All Nodes", &allNodes))
|
||
{
|
||
ctx.renderer->SetBoneAllNodes(allNodes);
|
||
}
|
||
ImGui::SameLine();
|
||
float px = ctx.renderer->GetBoneJointPx();
|
||
ImGui::SetNextItemWidth(130.0f);
|
||
if (ImGui::SliderFloat("##bone_joint_px", &px, 2.0f, 14.0f,
|
||
"joint %.0fpx"))
|
||
{
|
||
ctx.renderer->SetBoneJointPx(px);
|
||
}
|
||
}
|
||
|
||
// ---- 外部动画并入(自愈重放 + 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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Mode:Direct(单 clip 直控)/ Controller(.animctrl
|
||
// 状态机)----Unity Animator 语义:Controller 下播放由
|
||
// 状态机调度(Direct 字段不参与求值),切回 Direct 配置
|
||
// 不丢
|
||
{
|
||
ImGui::SeparatorText("Mode");
|
||
using AnimMode = Engine::ECS::AnimationComponent::AnimMode;
|
||
int modeSel =
|
||
anim->mode == AnimMode::Controller ? 1 : 0;
|
||
ImGui::SetNextItemWidth(-1.0f);
|
||
if (ImGui::Combo("##anim_mode", &modeSel,
|
||
"Direct\0Controller\0"))
|
||
{
|
||
anim->mode =
|
||
modeSel == 1 ? AnimMode::Controller : AnimMode::Direct;
|
||
}
|
||
if (anim->mode == AnimMode::Controller)
|
||
{
|
||
// ---- Controller 资产槽(指派即预载)----
|
||
const auto& cfiles = AssetControllerFiles();
|
||
std::vector<const char*> copts;
|
||
copts.push_back("<none>");
|
||
for (const auto& f : cfiles)
|
||
{
|
||
copts.push_back(f.c_str());
|
||
}
|
||
int csel = 0;
|
||
for (size_t k = 0; k < cfiles.size(); ++k)
|
||
{
|
||
if (cfiles[k] == anim->controllerPath)
|
||
{
|
||
csel = static_cast<int>(k) + 1;
|
||
}
|
||
}
|
||
ImGui::SetNextItemWidth(-1.0f);
|
||
if (ImGui::Combo("##anim_ctrl_slot", &csel, copts.data(),
|
||
static_cast<int>(copts.size())))
|
||
{
|
||
anim->currentState.clear(); // 换绑 → 首帧回 entry
|
||
if (csel == 0)
|
||
{
|
||
anim->controllerPath.clear();
|
||
}
|
||
else
|
||
{
|
||
const std::string& path =
|
||
cfiles[static_cast<size_t>(csel) - 1];
|
||
anim->controllerPath = path;
|
||
ctx.assets->LoadController(path);
|
||
// 参数默认值初始化(只补缺键——调好的值不覆盖)
|
||
if (const auto* ctrl =
|
||
ctx.assets->FindController(path))
|
||
{
|
||
for (const auto& p : ctrl->GetParams())
|
||
{
|
||
if (p.type ==
|
||
Engine::Animation::ControllerParam::
|
||
Type::Bool)
|
||
{
|
||
anim->boolParams.emplace(p.name,
|
||
p.defBool);
|
||
}
|
||
else if (p.type ==
|
||
Engine::Animation::
|
||
ControllerParam::Type::Float)
|
||
{
|
||
anim->floatParams.emplace(p.name,
|
||
p.defFloat);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 双击槽 = 打开 Controller Editor(Unity 双击
|
||
// Controller 资产打开 Animator 窗口语义)
|
||
if (csel > 0 && ImGui::IsItemHovered() &&
|
||
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) &&
|
||
ctx.controllerEditor != nullptr)
|
||
{
|
||
ctx.controllerEditor->Open(anim->controllerPath);
|
||
}
|
||
// 拖放指派:AssetBrowser 的 .animctrl 拖进来
|
||
//(对齐材质槽交互)
|
||
if (ImGui::BeginDragDropTarget())
|
||
{
|
||
if (const ImGuiPayload* pl =
|
||
ImGui::AcceptDragDropPayload(
|
||
kControllerDragPayload))
|
||
{
|
||
const std::string path(
|
||
static_cast<const char*>(pl->Data));
|
||
anim->currentState.clear(); // 首帧回 entry
|
||
anim->controllerPath = path;
|
||
ctx.assets->LoadController(path);
|
||
if (const auto* c =
|
||
ctx.assets->FindController(path))
|
||
{
|
||
for (const auto& p : c->GetParams())
|
||
{
|
||
if (p.type ==
|
||
Engine::Animation::
|
||
ControllerParam::Type::Float)
|
||
{
|
||
anim->floatParams.emplace(
|
||
p.name, p.defFloat);
|
||
}
|
||
else if (p.type ==
|
||
Engine::Animation::
|
||
ControllerParam::Type::
|
||
Bool)
|
||
{
|
||
anim->boolParams.emplace(p.name,
|
||
p.defBool);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ImGui::EndDragDropTarget();
|
||
}
|
||
const auto* ctrl =
|
||
anim->controllerPath.empty()
|
||
? nullptr
|
||
: ctx.assets->FindController(
|
||
anim->controllerPath);
|
||
// ---- Create / Reload 工具行:.animctrl = 纯文本
|
||
// YAML 资产——Create 从当前模型 clip 自动生成
|
||
// 可跑模板;外部编辑器改完点 Reload 热重载 ----
|
||
{
|
||
static char s_NewName[96] = "my_controller";
|
||
static bool s_CreateOk = true;
|
||
ImGui::SetNextItemWidth(150.0f);
|
||
ImGui::InputText("##ctrl_new_name", s_NewName,
|
||
sizeof(s_NewName));
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Create") && s_NewName[0] != '\0')
|
||
{
|
||
// 模板 = Idle(单 clip)+ Locomotion(2D 方向
|
||
// 树:原点 + 4 方向 walk 占位)+ Speed 过渡
|
||
// ——生成即有方向盘,打开 Controller Editor
|
||
// 再逐点指定 clip;无模型时用占位名(clip 查
|
||
// 不到 = rest 兜底)
|
||
std::vector<std::string> clips;
|
||
if (model != nullptr)
|
||
{
|
||
for (const auto& c : model->GetClips())
|
||
{
|
||
clips.push_back(c.name);
|
||
}
|
||
}
|
||
if (clips.empty())
|
||
{
|
||
clips.push_back("clip_name");
|
||
}
|
||
const auto clipAt = [&](size_t i) {
|
||
return clips.empty()
|
||
? "clip_name"
|
||
: clips[i % clips.size()];
|
||
};
|
||
std::string yaml =
|
||
"# 动画控制器:状态/参数/过渡全由本文件声明,"
|
||
"引擎零硬编码。\n"
|
||
"# 纯文本 YAML —— 也可在 Controller Editor "
|
||
"面板可视化编辑后 Save。\n"
|
||
"# 条件语法:\"参数 op 值\"(> < >= <= == "
|
||
"!=;值 = 数字或 true/false)\n"
|
||
"Version: 1\n"
|
||
"Params:\n"
|
||
" - {name: MoveX, type: float, default: 0}\n"
|
||
" - {name: MoveZ, type: float, default: 0}\n"
|
||
" - {name: Speed, type: float, default: 0}\n"
|
||
"States:\n"
|
||
" Idle:\n"
|
||
" clip: \"" +
|
||
clips[0] + "\"\n"
|
||
" loop: true\n"
|
||
" Locomotion:\n"
|
||
" blendTree:\n"
|
||
" type: 2DDirectional\n"
|
||
" x: MoveX\n"
|
||
" z: MoveZ\n"
|
||
" children:\n"
|
||
" - {clip: \"" +
|
||
clips[0] + "\", at: [0, 0]}\n"
|
||
" - {clip: \"" +
|
||
clipAt(1) + "\", at: [0, 1]}\n"
|
||
" - {clip: \"" +
|
||
clipAt(2) + "\", at: [1, 0]}\n"
|
||
" - {clip: \"" +
|
||
clipAt(3) + "\", at: [0, -1]}\n"
|
||
" - {clip: \"" +
|
||
clipAt(4) + "\", at: [-1, 0]}\n"
|
||
"Transitions:\n"
|
||
" - {from: Idle, to: Locomotion, cond: "
|
||
"\"Speed > 0.1\", fade: 0.25}\n"
|
||
" - {from: Locomotion, to: Idle, cond: "
|
||
"\"Speed < 0.05\", fade: 0.3}\n";
|
||
const std::string path =
|
||
std::string("assets/animations/") +
|
||
s_NewName + ".animctrl";
|
||
std::filesystem::create_directories(
|
||
"assets/animations");
|
||
std::ofstream f(path, std::ios::binary |
|
||
std::ios::trunc);
|
||
s_CreateOk = static_cast<bool>(f);
|
||
if (s_CreateOk)
|
||
{
|
||
f.write(yaml.data(),
|
||
static_cast<std::streamsize>(
|
||
yaml.size()));
|
||
f.close();
|
||
InvalidateControllerFiles();
|
||
anim->controllerPath = path;
|
||
anim->currentState.clear(); // 首帧回 entry
|
||
ctx.assets->LoadController(path);
|
||
// 参数默认值初始化(只补缺键,同槽指派)
|
||
if (const auto* c =
|
||
ctx.assets->FindController(path))
|
||
{
|
||
for (const auto& p : c->GetParams())
|
||
{
|
||
if (p.type ==
|
||
Engine::Animation::
|
||
ControllerParam::Type::Float)
|
||
{
|
||
anim->floatParams.emplace(
|
||
p.name, p.defFloat);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ImGui::SameLine();
|
||
if (!anim->controllerPath.empty() &&
|
||
ImGui::SmallButton("Reload"))
|
||
{
|
||
ctx.assets->ReloadController(
|
||
anim->controllerPath);
|
||
anim->currentState.clear(); // 回 entry
|
||
}
|
||
if (!s_CreateOk)
|
||
{
|
||
ImGui::TextColored(
|
||
ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
|
||
"! create failed (disk?)");
|
||
}
|
||
}
|
||
if (ctrl == nullptr)
|
||
{
|
||
if (!anim->controllerPath.empty())
|
||
{
|
||
ImGui::TextColored(
|
||
ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
|
||
"! controller failed to load");
|
||
}
|
||
else
|
||
{
|
||
ImGui::TextDisabled("(no controller assigned)");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
const auto& order = ctrl->GetStateOrder();
|
||
// ---- 当前状态(Parameters 之前:先看"现在在哪",
|
||
// 再调参数)。过渡中显示 来源 -> 目标(百分比)----
|
||
if (!anim->transFrom.empty())
|
||
{
|
||
const float pct =
|
||
glm::min(anim->transElapsed /
|
||
anim->transFade,
|
||
1.0f) *
|
||
100.0f;
|
||
ImGui::Text("state: %s -> %s (%.0f%%)",
|
||
anim->transFrom.c_str(),
|
||
anim->currentState.c_str(), pct);
|
||
}
|
||
else
|
||
{
|
||
ImGui::Text("state: %s",
|
||
anim->currentState.c_str());
|
||
}
|
||
// ---- Edit:结构编辑全在独立 Controller
|
||
// Editor 面板(材质面板同范式——Details
|
||
// 只留指派与运行状态)----
|
||
if (ctx.controllerEditor != nullptr &&
|
||
ImGui::SmallButton("Edit..."))
|
||
{
|
||
ctx.controllerEditor->Open(anim->controllerPath);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
|
||
if (mr == nullptr)
|
||
{
|
||
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
|
||
"! requires a MeshRenderer component");
|
||
}
|
||
else if (model == nullptr)
|
||
{
|
||
ImGui::TextDisabled("(no model assigned)");
|
||
}
|
||
else
|
||
{
|
||
// ---- Skeleton 资产信息(只读)----骨骼数 = palette
|
||
// 槽位数;节点数 = 蒙皮数学的全层级表(骨骼 ⊂ 节点,
|
||
// 两数不同是正常的)。选中实体时视口内画骨架线框
|
||
ImGui::Text("bones: %u nodes: %u",
|
||
static_cast<unsigned>(model->GetBoneCount()),
|
||
static_cast<unsigned>(model->GetNodes().size()));
|
||
|
||
// ---- Clips:动画资产槽(列全部模型容器文件——无法静态
|
||
// 区分"模型 fbx"与"纯动画 fbx",一并列出,无 clip 的会
|
||
// 警告跳过)----
|
||
ImGui::SeparatorText("Clips");
|
||
const auto& files = AssetModelFiles();
|
||
// 显示真名(AssetDisplayName:文件 stem,导入资产库的
|
||
// 哈希 "asset" 读同目录 pathname 取原始名);重名者补
|
||
// 父目录区分——不再把哈希路径直接摆给用户(= 显示成 id)
|
||
std::vector<std::string> labels;
|
||
labels.reserve(files.size());
|
||
for (const auto& f : files)
|
||
{
|
||
std::string label = Engine::Model::AssetDisplayName(f);
|
||
if (std::find(labels.begin(), labels.end(), label) !=
|
||
labels.end())
|
||
{
|
||
label += " (" +
|
||
std::filesystem::path(f)
|
||
.parent_path()
|
||
.filename()
|
||
.string() +
|
||
")";
|
||
}
|
||
labels.push_back(std::move(label));
|
||
}
|
||
std::vector<const char*> opts;
|
||
opts.push_back("<attach animation file...>");
|
||
for (const auto& l : labels)
|
||
{
|
||
opts.push_back(l.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);
|
||
}
|
||
// attach 遥测(骨骼匹配结果,面板下方显示)
|
||
s_LastAttachFile =
|
||
Engine::Model::AssetDisplayName(path);
|
||
s_LastAttachMatched =
|
||
res.channelsMatched + res.channelsRetargeted;
|
||
s_LastAttachRetargeted = res.channelsRetargeted;
|
||
s_LastAttachSkipped = res.channelsSkipped;
|
||
// 重定向明细快照(映射表调试显示)
|
||
s_LastAttachRows = res.retargetRows;
|
||
}
|
||
// 已并入的源文件清单(x = detach:剥离模型 clip 表 +
|
||
// 移除持久化列表;一帧只删一个,下帧重绘自然继续)
|
||
for (size_t i = 0; i < anim->attachedAnimPaths.size(); ++i)
|
||
{
|
||
const auto& p = anim->attachedAnimPaths[i];
|
||
ImGui::BulletText("%s",
|
||
Engine::Model::AssetDisplayName(p)
|
||
.c_str());
|
||
ImGui::SameLine();
|
||
ImGui::PushID(static_cast<int>(i));
|
||
const bool doDetach = ImGui::SmallButton("x");
|
||
ImGui::PopID();
|
||
if (doDetach)
|
||
{
|
||
ctx.assets->DetachAnimationClips(mr->model, p);
|
||
anim->attachedAnimPaths.erase(
|
||
anim->attachedAnimPaths.begin() +
|
||
static_cast<ptrdiff_t>(i));
|
||
break;
|
||
}
|
||
}
|
||
// 最近一次 attach 的骨骼匹配遥测(同名直映 + Humanoid
|
||
// 槽位重定向分列;跳过 = 手指/道具等无对应骨骼)。
|
||
// 注意:重启后自愈重放路径幂等命中返回全 0,那里不更新
|
||
// 遥测避免误导
|
||
if (!s_LastAttachFile.empty())
|
||
{
|
||
ImGui::TextColored(
|
||
ImVec4(0.55f, 0.75f, 1.0f, 1.0f),
|
||
"%s: %u matched (%u retargeted), %u skipped",
|
||
s_LastAttachFile.c_str(),
|
||
static_cast<unsigned>(s_LastAttachMatched),
|
||
static_cast<unsigned>(s_LastAttachRetargeted),
|
||
static_cast<unsigned>(s_LastAttachSkipped));
|
||
}
|
||
// 重定向明细表(调试黑盒白盒化):源骨骼 → 槽位 → 目标
|
||
// 骨骼。行错了 = 哪根骨乱转一眼可查(Unity Configure
|
||
// Avatar 的只读版)
|
||
if (!s_LastAttachRows.empty())
|
||
{
|
||
if (ImGui::TreeNode("Retarget Map"))
|
||
{
|
||
for (const auto& r : s_LastAttachRows)
|
||
{
|
||
ImGui::BulletText("%s [%s] -> %s",
|
||
r.srcName.c_str(), r.slot,
|
||
r.dstName.c_str());
|
||
}
|
||
ImGui::TreePop();
|
||
}
|
||
}
|
||
if (!model->HasAnimations())
|
||
{
|
||
ImGui::TextDisabled("(no animation clips on model)");
|
||
}
|
||
}
|
||
if (model != nullptr && model->HasAnimations())
|
||
{
|
||
const auto& clips = model->GetClips();
|
||
// ---- Playback:播放控制。Direct 专属控件(clip 选择/
|
||
// playing/loop/时间拖动条)只在 Direct 模式渲染——
|
||
// Controller 模式下播放由状态机调度,这些字段不参
|
||
// 与求值(Unity Animator 语义:Controller 挂上后
|
||
// Animation 组件的 clip 字段灰置)----
|
||
const bool ctrlMode =
|
||
anim->mode ==
|
||
Engine::ECS::AnimationComponent::AnimMode::Controller;
|
||
if (!ctrlMode)
|
||
{
|
||
ImGui::SeparatorText("Playback");
|
||
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);
|
||
if (anim->clipIndex >= 0 &&
|
||
anim->clipIndex <
|
||
static_cast<int>(clips.size()))
|
||
{
|
||
// 时间拖动条(Unity Animation 窗口 scrub 语义):
|
||
// 拖住定格播放头逐帧看姿态,松手恢复拖前播放状
|
||
// 态。暂停时 AnimationSystem 照样求值(只有
|
||
// playing 才推进播放头),所以拖动即实时预览
|
||
const float dur =
|
||
clips[anim->clipIndex].duration;
|
||
ImGui::SliderFloat("Time", &anim->time, 0.0f, dur,
|
||
"t: %.2f / %.2f s");
|
||
if (ImGui::IsItemActivated())
|
||
{
|
||
s_ScrubWasPlaying = anim->playing;
|
||
anim->playing = false;
|
||
}
|
||
if (ImGui::IsItemDeactivatedAfterEdit() &&
|
||
s_ScrubWasPlaying)
|
||
{
|
||
anim->playing = true;
|
||
}
|
||
}
|
||
}
|
||
// 两模式通用:全局速度 / 过渡时长(Controller 的
|
||
// CrossFade 与 Direct 的 crossfade 共用 fade)
|
||
ImGui::SliderFloat("Speed", &anim->speed, -2.0f, 2.0f);
|
||
ImGui::SliderFloat("Fade", &anim->fadeDuration, 0.0f, 1.0f);
|
||
// 根运动(Unity Animator 同名开关):关 = 原地播放,
|
||
// 位移归脚本/物理驱动(CharacterController 角色用)
|
||
ImGui::Checkbox("Apply Root Motion",
|
||
&anim->applyRootMotion);
|
||
// 动画数据表:每个 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();
|
||
}
|
||
}
|
||
if (!anim->active)
|
||
{
|
||
ImGui::EndDisabled();
|
||
}
|
||
}
|
||
if (removeAnim)
|
||
{
|
||
reg.remove<Engine::ECS::AnimationComponent>(selected);
|
||
}
|
||
}
|
||
|
||
ImGui::End();
|
||
}
|