// // 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 #include #include #include #include #include #include namespace { // 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用; // 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。 // 会话级缓存:目录条目几十量级,避免每帧递归扫描 const std::vector& AssetModelFiles() { static std::vector 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") { 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); 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(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; const bool has = reg.all_of(selected); if (has) { ImGui::BeginDisabled(true); } if (ImGui::MenuItem(label)) { reg.emplace(selected); } if (has) { ImGui::EndDisabled(); } }; menuItem(static_cast(nullptr), "Mesh Renderer"); menuItem(static_cast(nullptr), "Light"); menuItem(static_cast(nullptr), "Camera"); menuItem(static_cast(nullptr), "Animation"); menuItem(static_cast(nullptr), "Name"); menuItem(static_cast(nullptr), "RigidBody"); menuItem(static_cast(nullptr), "Joint"); menuItem(static_cast(nullptr), "Character Controller"); menuItem(static_cast(nullptr), "Script"); ImGui::EndPopup(); } ImGui::Separator(); // ---- Transform(结构性组件:不可删、无启停)---- if (auto* t = reg.try_get(selected)) { if (ComponentHeader("Transform", nullptr, false, nullptr)) { glm::vec3 pos = t->GetPosition(); if (DragVec3Row("position", pos, 0.05f)) { t->SetPosition(pos); } glm::vec3 rot = t->GetRotation(); // 派生欧拉(度) for (int i = 0; i < 3; ++i) { if (rot[i] == 0.0f) rot[i] = 0.0f; // 清 -0.0(atan2 副产物) } // "%.1f" = UE Details 默认显示精度(反解噪声 ~0.02° @ pitch 90°, // 存储端 quat 精确;手输 Ctrl+Click 仍全精度写入) if (DragVec3Row("rotation", rot, 0.5f, -3.4e38f, 3.4e38f, "%.1f")) { t->SetRotation(rot); } 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(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 opts; opts.push_back(""); 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(k) + 1; } } ImGui::SetNextItemWidth(-1.0f); if (ImGui::Combo("##model_slot", &sel, opts.data(), static_cast(opts.size()))) { mr->model = (sel <= 0) ? Engine::ModelRef{} : ctx.assets->LoadModel(s_ModelFiles[static_cast(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(); ImGui::Text("model: valid (%u clips, %u bones)", static_cast(clips.size()), static_cast(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(selected); } } // ---- Light ---- if (auto* light = reg.try_get(selected)) { bool removeLight = false; if (ComponentHeader("Light", &light->active, true, &removeLight)) { if (!light->active) { ImGui::BeginDisabled(true); } int type = static_cast(light->type); if (ImGui::Combo("type", &type, "Directional\0Point\0")) { light->type = static_cast(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(selected); } } // ---- Camera ---- if (auto* cam = reg.try_get(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().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( selected)) { ctx.editorCamera->AlignTo(*tr); } } if (!cam->active) { ImGui::EndDisabled(); } } if (removeCam) { reg.remove(selected); } } // ---- RigidBody(Jolt 刚体:动态体 Transform 由物理每帧写回)---- // type/collider/mass 变更 → PhysicsWorld 重建 body;其余参数即时生效 if (auto* rb = reg.try_get(selected)) { bool removeRb = false; if (ComponentHeader("RigidBody", &rb->active, true, &removeRb)) { if (!rb->active) { ImGui::BeginDisabled(true); } // 冲突提示:同实体挂 CharacterController 会同时建 body 与 // CharacterVirtual,两个"运动主体"互相打架 if (reg.all_of(selected)) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! conflicts with Character Controller " "(disable or remove one)"); } int type = static_cast(rb->type); if (ImGui::Combo("type", &type, "Static\0Dynamic\0Kinematic\0")) { rb->type = static_cast(type); } int collider = static_cast(rb->collider); if (ImGui::Combo("collider", &collider, "Box\0Sphere\0Mesh\0Capsule\0Cylinder\0Compound\0")) { rb->collider = static_cast(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(rb->compound.size())); ImGui::SameLine(); if (ImGui::Button("+ add")) { rb->compound.emplace_back(); } int remove = -1; for (int i = 0; i < static_cast(rb->compound.size()); ++i) { auto& c = rb->compound[i]; ImGui::PushID(i); ImGui::Separator(); int sh = static_cast(c.shape); if (ImGui::Combo("shape", &sh, "Box\0Sphere\0Capsule\0")) { c.shape = static_cast(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(entt::to_integral(selected)), s_debugImpulse); } } if (!rb->active) { ImGui::EndDisabled(); } } if (removeRb) { reg.remove(selected); } } // ---- Joint(约束:两端 body 就绪后由 PhysicsWorld 对账创建)---- if (auto* jc = reg.try_get(selected)) { bool removeJc = false; if (ComponentHeader("Joint", &jc->active, true, &removeJc)) { if (!jc->active) { ImGui::BeginDisabled(true); } // 依赖提示:约束挂在两端刚体上,本体无 RigidBody 无法创建 if (!reg.all_of(selected)) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! requires a RigidBody on this entity"); } int type = static_cast(jc->type); if (ImGui::Combo("type", &type, "Fixed\0Hinge\0Ball\0")) { jc->type = static_cast(type); } // target 实体下拉:实体名列表,选中态按 UUID 匹配(变更写回) std::string items; std::vector uuids; int current = -1; reg.view().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(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(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(selected); } } // ---- CharacterController(参数编辑;状态在 PhysicsWorld 运行时)---- if (auto* cc = reg.try_get(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(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(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(selected); } } // ---- Script(按名解析注册表脚本;实例生命周期在 ScriptRuntime)---- if (auto* sc = reg.try_get(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(sc->items.size()); ++i) { auto& item = sc->items[static_cast(i)]; ImGui::PushID(i); // 去重下拉(DisallowMultipleComponent 语义):可选项 = // "" + 未挂名 + 自身名(已挂的别人的名不出现,结构上 // 选不出重复);自身名置于末尾保证已挂项正常回显 std::vector options; options.push_back(""); 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(k); } } ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x - 64.0f); if (ImGui::Combo("##script", &cur, options.data(), static_cast(options.size()))) { item.name = (cur <= 0) ? "" : options[static_cast(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(selected); } } // ---- Animation ---- if (auto* anim = reg.try_get(selected)) { bool removeAnim = false; if (ComponentHeader("Animation", &anim->active, true, &removeAnim)) { if (!anim->active) { ImGui::BeginDisabled(true); } const auto* mr = reg.try_get(selected); // 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 即无效) Engine::Model* model = (mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model) : nullptr; // ---- 外部动画并入(自愈重放 + attach 槽)---- // 重放:attachedAnimPaths 里未并入的源(重启后 Model 缓存是 // 空白)幂等重 attach;解析失败的路径会话内不再重试 if (model != nullptr) { static std::unordered_set 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 opts; opts.push_back(""); 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(opts.size()))) { const std::string& path = files[static_cast(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 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(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(clips.size())) { ImGui::Text("t: %.2f / %.2f s", anim->time, clips[anim->clipIndex].duration); } } else { ImGui::TextDisabled("(no animation clips on model)"); } if (!anim->active) { ImGui::EndDisabled(); } } if (removeAnim) { reg.remove(selected); } } ImGui::End(); }