diff --git a/Apps/MetaCorePlayer/main.cpp b/Apps/MetaCorePlayer/main.cpp index 1a73300..9ace87b 100644 --- a/Apps/MetaCorePlayer/main.cpp +++ b/Apps/MetaCorePlayer/main.cpp @@ -578,6 +578,12 @@ int main(int argc, char* argv[]) { errors << "MetaCorePlayer: failed to initialize runtime UI\n"; } else { runtimeUi.AttachToViewportRenderer(viewportRenderer); + (void)runtimeUi.RegisterProvider( + "scene", + [&scriptRuntime](std::string_view path) { return scriptRuntime.ReadSceneUiBinding(path); }, + [&scriptRuntime](std::string_view path, const MetaCore::MetaCoreRuntimeUiValue& value) { + return scriptRuntime.WriteSceneUiBinding(path, value); + }); } for (const std::string& error : runtimeUi.GetErrors()) { errors << "MetaCorePlayer UI: " << error << '\n'; @@ -658,7 +664,22 @@ int main(int argc, char* argv[]) { scriptRuntime.PublishToObject(event.SceneObjectId, "MetaCore.UI", payload); } }); - runtimeInteraction.SetEventCallback([&scriptRuntime](const MetaCore::MetaCorePointerEvent& event) { + runtimeInteraction.SetEventCallback([&scriptRuntime, &runtimeUi](const MetaCore::MetaCorePointerEvent& event) { + if (event.WorldSpaceUi) { + using PointerType = MetaCore::MetaCoreRuntimeUiWorldPointerType; + std::optional type; + if (event.Type == MetaCore::MetaCorePointerEventType::Enter || event.Type == MetaCore::MetaCorePointerEventType::Move) + type = PointerType::Move; + else if (event.Type == MetaCore::MetaCorePointerEventType::Down) type = PointerType::Down; + else if (event.Type == MetaCore::MetaCorePointerEventType::Up) type = PointerType::Up; + else if (event.Type == MetaCore::MetaCorePointerEventType::Scroll) type = PointerType::Scroll; + else if (event.Type == MetaCore::MetaCorePointerEventType::Leave || + event.Type == MetaCore::MetaCorePointerEventType::Cancel) type = PointerType::Cancel; + if (type.has_value()) + (void)runtimeUi.ProcessWorldPointer( + event.TargetObjectId, event.SurfaceUv, *type, event.Button, event.ScrollDelta); + return; + } MetaCoreScriptValueV1 type{}; type.Type = MetaCoreScriptAbiValue_Int; type.IntValue = static_cast(event.Type); MetaCoreScriptValueV1 button{}; button.Type = MetaCoreScriptAbiValue_Int; button.IntValue = event.Button; MetaCoreScriptValueV1 screen{}; screen.Type = MetaCoreScriptAbiValue_Vec3; screen.Vec3[0] = event.ScreenPosition.x; screen.Vec3[1] = event.ScreenPosition.y; diff --git a/SandboxProject/Assets/UI/main_menu.rml.mcasset b/SandboxProject/Assets/UI/main_menu.rml.mcasset index 04f0cbf..0041238 100644 Binary files a/SandboxProject/Assets/UI/main_menu.rml.mcasset and b/SandboxProject/Assets/UI/main_menu.rml.mcasset differ diff --git a/Source/MetaCoreEditor/Private/MetaCoreBuiltinCoreServicesModule.cpp b/Source/MetaCoreEditor/Private/MetaCoreBuiltinCoreServicesModule.cpp index 06c4819..ab5a362 100644 --- a/Source/MetaCoreEditor/Private/MetaCoreBuiltinCoreServicesModule.cpp +++ b/Source/MetaCoreEditor/Private/MetaCoreBuiltinCoreServicesModule.cpp @@ -5458,6 +5458,9 @@ void MetaCoreWriteEmbeddedTextureRuntimePackages( if (filename.length() > 10 && filename.compare(filename.length() - 10, 10, ".mcui.json") == 0) { return "UiDocumentImporter"; } + if (filename.ends_with(".mcuitheme.json")) return "UiThemeImporter"; + if (filename.ends_with(".mcuifont.json")) return "UiFontFamilyImporter"; + if (filename.ends_with(".mculoc.json")) return "UiLocalizationImporter"; const std::string extension = path.extension().string(); if (extension == ".mcanim" || extension == ".mcavatar" || extension == ".mcanimator" || extension == ".mctimeline") return "AnimationAssetImporter"; @@ -5499,6 +5502,9 @@ void MetaCoreWriteEmbeddedTextureRuntimePackages( if (filename.length() > 10 && filename.compare(filename.length() - 10, 10, ".mcui.json") == 0) { return "ui_document"; } + if (filename.ends_with(".mcuitheme.json")) return "ui_theme"; + if (filename.ends_with(".mcuifont.json")) return "ui_font_family"; + if (filename.ends_with(".mculoc.json")) return "ui_localization"; const std::string extension = path.extension().string(); if (extension == ".mcanim") return "animation_clip"; @@ -8386,6 +8392,36 @@ bool MetaCoreBuiltinCookService::CookAsset(const MetaCoreAssetGuid& assetGuid) { writeSuccess = PackageService_->WritePackage(absoluteCookedPath, std::move(package)); } } + } else if (record->Type == "ui_theme") { + isJsonAsset = true; + if (auto doc = MetaCoreSceneSerializer::LoadUiThemeFromJson(absoluteSourcePath)) { + if (auto payload = MetaCoreSerializeToBytes(*doc, registry)) { + writeSuccess = PackageService_->WritePackage(absoluteCookedPath, MetaCoreBuildTypedPackage( + MetaCorePackageType::Asset, assetGuid, record->RelativePath.filename().string(), + "MetaCoreUiThemeDocument", + MetaCoreHashBytes(std::span(payload->data(), payload->size())), *payload)); + } + } + } else if (record->Type == "ui_font_family") { + isJsonAsset = true; + if (auto doc = MetaCoreSceneSerializer::LoadUiFontFamilyFromJson(absoluteSourcePath)) { + if (auto payload = MetaCoreSerializeToBytes(*doc, registry)) { + writeSuccess = PackageService_->WritePackage(absoluteCookedPath, MetaCoreBuildTypedPackage( + MetaCorePackageType::Asset, assetGuid, record->RelativePath.filename().string(), + "MetaCoreUiFontFamilyDocument", + MetaCoreHashBytes(std::span(payload->data(), payload->size())), *payload)); + } + } + } else if (record->Type == "ui_localization") { + isJsonAsset = true; + if (auto doc = MetaCoreSceneSerializer::LoadUiLocalizationTableFromJson(absoluteSourcePath)) { + if (auto payload = MetaCoreSerializeToBytes(*doc, registry)) { + writeSuccess = PackageService_->WritePackage(absoluteCookedPath, MetaCoreBuildTypedPackage( + MetaCorePackageType::Asset, assetGuid, record->RelativePath.filename().string(), + "MetaCoreUiLocalizationTableDocument", + MetaCoreHashBytes(std::span(payload->data(), payload->size())), *payload)); + } + } } if (!isJsonAsset) { @@ -8910,6 +8946,147 @@ bool MetaCoreBuiltinCookService::CookRuntimeUiAssets() { std::ofstream output(path, std::ios::binary | std::ios::trunc); return output.is_open() && static_cast(output.write(text.data(), static_cast(text.size()))); }; + const auto loadTheme = [&](const MetaCoreAssetGuid& guid) -> std::optional { + if (!guid.IsValid()) return std::nullopt; + const auto record = AssetDatabaseService_->FindAssetByGuid(guid); + if (!record.has_value() || record->Type != "ui_theme") return std::nullopt; + const std::filesystem::path path = project.RootPath / record->RelativePath; + if (path.extension() == ".json") return MetaCoreSceneSerializer::LoadUiThemeFromJson(path); + const auto package = PackageService_->ReadPackage(path); + return package.has_value() + ? MetaCoreReadTypedPackagePayload(*package, registry, "MetaCoreUiThemeDocument") + : std::nullopt; + }; + const auto loadFontFamily = [&](const MetaCoreAssetGuid& guid) -> std::optional { + if (!guid.IsValid()) return std::nullopt; + const auto record = AssetDatabaseService_->FindAssetByGuid(guid); + if (!record.has_value() || record->Type != "ui_font_family") return std::nullopt; + const std::filesystem::path path = project.RootPath / record->RelativePath; + if (path.extension() == ".json") return MetaCoreSceneSerializer::LoadUiFontFamilyFromJson(path); + const auto package = PackageService_->ReadPackage(path); + return package.has_value() + ? MetaCoreReadTypedPackagePayload(*package, registry, "MetaCoreUiFontFamilyDocument") + : std::nullopt; + }; + const auto loadLocalization = [&](const MetaCoreAssetGuid& guid) -> std::optional { + if (!guid.IsValid()) return std::nullopt; + const auto record = AssetDatabaseService_->FindAssetByGuid(guid); + if (!record.has_value() || record->Type != "ui_localization") return std::nullopt; + const std::filesystem::path path = project.RootPath / record->RelativePath; + if (path.extension() == ".json") return MetaCoreSceneSerializer::LoadUiLocalizationTableFromJson(path); + const auto package = PackageService_->ReadPackage(path); + return package.has_value() + ? MetaCoreReadTypedPackagePayload(*package, registry, "MetaCoreUiLocalizationTableDocument") + : std::nullopt; + }; + const auto buildAtlas = [&](const MetaCoreUiDocument& document, MetaCoreUiCompileResources& resources, + std::vector& pixels) { + struct Image { + MetaCoreAssetGuid Guid{}; + int Width = 0; + int Height = 0; + std::vector Pixels{}; + }; + std::vector imageGuids; + for (const auto& node : document.Nodes) + if (node.Type == MetaCoreUiNodeType::Image && node.Style.ImageAssetGuid.IsValid()) + imageGuids.push_back(node.Style.ImageAssetGuid); + std::sort(imageGuids.begin(), imageGuids.end(), [](const auto& lhs, const auto& rhs) { + return lhs.ToString() < rhs.ToString(); + }); + imageGuids.erase(std::unique(imageGuids.begin(), imageGuids.end()), imageGuids.end()); + if (imageGuids.empty()) return true; + + std::vector images; + for (const auto guid : imageGuids) { + const auto record = AssetDatabaseService_->FindAssetByGuid(guid); + if (!record.has_value() || record->Type != "texture") return false; + std::filesystem::path path = !record->SourcePath.empty() ? record->SourcePath : record->RelativePath; + if (path.is_relative()) path = project.RootPath / path; + int width = 0; + int height = 0; + int channels = 0; + stbi_uc* decoded = stbi_load(path.string().c_str(), &width, &height, &channels, STBI_rgb_alpha); + if (decoded == nullptr || width <= 0 || height <= 0 || width > 4094 || height > 4094) { + stbi_image_free(decoded); + return false; + } + Image image; + image.Guid = guid; + image.Width = width; + image.Height = height; + image.Pixels.assign(decoded, decoded + static_cast(width) * static_cast(height) * 4U); + stbi_image_free(decoded); + images.push_back(std::move(image)); + } + + constexpr int maximumAtlasSize = 4096; + int x = 1; + int y = 1; + int rowHeight = 0; + int usedWidth = 1; + resources.AtlasEntries.clear(); + for (const auto& image : images) { + if (x + image.Width + 1 > maximumAtlasSize) { + x = 1; + y += rowHeight + 1; + rowHeight = 0; + } + if (y + image.Height + 1 > maximumAtlasSize) return false; + resources.AtlasEntries.push_back({image.Guid, x, y, image.Width, image.Height}); + usedWidth = std::max(usedWidth, x + image.Width + 1); + rowHeight = std::max(rowHeight, image.Height); + x += image.Width + 1; + } + const int usedHeight = y + rowHeight + 1; + const auto nextPowerOfTwo = [](int value) { + int result = 1; + while (result < value) result *= 2; + return result; + }; + resources.AtlasWidth = nextPowerOfTwo(usedWidth); + resources.AtlasHeight = nextPowerOfTwo(usedHeight); + resources.AtlasFilename = "atlas.tga"; + pixels.assign(static_cast(resources.AtlasWidth) * + static_cast(resources.AtlasHeight) * 4U, 0); + for (std::size_t imageIndex = 0; imageIndex < images.size(); ++imageIndex) { + const auto& image = images[imageIndex]; + const auto& placement = resources.AtlasEntries[imageIndex]; + for (int row = 0; row < image.Height; ++row) { + const auto sourceOffset = static_cast(row) * static_cast(image.Width) * 4U; + const auto targetOffset = (static_cast(placement.Y + row) * + static_cast(resources.AtlasWidth) + static_cast(placement.X)) * 4U; + std::copy_n(image.Pixels.data() + sourceOffset, static_cast(image.Width) * 4U, + pixels.data() + targetOffset); + } + } + return true; + }; + const auto writeAtlas = [](const std::filesystem::path& path, int width, int height, + const std::vector& pixels) { + if (width <= 0 || height <= 0 || width > 65535 || height > 65535 || + pixels.size() != static_cast(width) * static_cast(height) * 4U) return false; + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) return false; + std::array header{}; + header[2] = 2; + header[12] = static_cast(width & 0xff); + header[13] = static_cast((width >> 8) & 0xff); + header[14] = static_cast(height & 0xff); + header[15] = static_cast((height >> 8) & 0xff); + header[16] = 32; + header[17] = 0x28; + output.write(reinterpret_cast(header.data()), static_cast(header.size())); + std::vector bgra(pixels.size()); + for (std::size_t index = 0; index < pixels.size(); index += 4) { + bgra[index] = pixels[index + 2]; + bgra[index + 1] = pixels[index + 1]; + bgra[index + 2] = pixels[index]; + bgra[index + 3] = pixels[index + 3]; + } + output.write(reinterpret_cast(bgra.data()), static_cast(bgra.size())); + return output.good(); + }; std::optional startupScene; if (!project.StartupScenePath.empty()) { const std::filesystem::path scenePath = project.RootPath / project.StartupScenePath; @@ -8944,22 +9121,44 @@ bool MetaCoreBuiltinCookService::CookRuntimeUiAssets() { document = MetaCoreReadTypedPackagePayload(*package, registry, "MetaCoreUiDocument"); } if (!document.has_value()) return false; - const MetaCoreCompiledUiDocument compiled = MetaCoreCompileUiDocument(*document); + MetaCoreUiCompileResources resources; + if (document->ThemeAssetGuid.IsValid()) { + resources.Theme = loadTheme(document->ThemeAssetGuid); + if (!resources.Theme.has_value()) return false; + } + if (document->FontFamilyAssetGuid.IsValid()) { + resources.FontFamily = loadFontFamily(document->FontFamilyAssetGuid); + if (!resources.FontFamily.has_value()) return false; + } + if (document->LocalizationAssetGuid.IsValid()) { + resources.Localization = loadLocalization(document->LocalizationAssetGuid); + if (!resources.Localization.has_value()) return false; + } + std::vector atlasPixels; + if (!buildAtlas(*document, resources, atlasPixels)) return false; + const MetaCoreCompiledUiDocument compiled = MetaCoreCompileUiDocument(*document, resources); if (!compiled.Succeeded) return false; const std::filesystem::path outputDirectory = targetUiRoot / "Compiled" / guid.ToString(); std::filesystem::create_directories(outputDirectory / "Assets", errorCode); if (errorCode || !writeText(outputDirectory / "document.rml", compiled.Rml) || !writeText(outputDirectory / "document.rcss", compiled.Rcss)) return false; + if (!compiled.LocalizationJson.empty() && + !writeText(outputDirectory / "localization.json", compiled.LocalizationJson)) return false; + if (!resources.AtlasEntries.empty() && !writeAtlas( + outputDirectory / "Assets" / resources.AtlasFilename, + resources.AtlasWidth, resources.AtlasHeight, atlasPixels)) return false; for (const MetaCoreAssetGuid dependency : compiled.Dependencies) { const auto dependencyRecord = AssetDatabaseService_->FindAssetByGuid(dependency); if (!dependencyRecord.has_value()) return false; - if (dependencyRecord->Type != "texture") continue; + if (dependencyRecord->Type != "texture" && dependencyRecord->Type != "asset") continue; + if (dependencyRecord->Type == "texture" && !resources.AtlasEntries.empty()) continue; std::filesystem::path dependencySource = !dependencyRecord->SourcePath.empty() ? dependencyRecord->SourcePath : dependencyRecord->RelativePath; if (dependencySource.is_relative()) dependencySource = project.RootPath / dependencySource; errorCode.clear(); - std::filesystem::copy_file(dependencySource, outputDirectory / "Assets" / (dependency.ToString() + ".png"), + const std::string extension = dependencyRecord->Type == "texture" ? ".png" : ".font"; + std::filesystem::copy_file(dependencySource, outputDirectory / "Assets" / (dependency.ToString() + extension), std::filesystem::copy_options::overwrite_existing, errorCode); if (errorCode) return false; } @@ -8976,8 +9175,14 @@ bool MetaCoreBuiltinCookService::CookRuntimeUiAssets() { entry.InstanceId = object.Id; entry.SceneObjectId = object.Id; entry.RenderMode = static_cast(object.UiRenderer->RenderMode); - entry.TargetWidth = 0; - entry.TargetHeight = 0; + entry.PixelsPerUnit = object.UiRenderer->PixelsPerUnit; + entry.WorldSize = object.UiRenderer->WorldSize; + if (object.UiRenderer->RenderMode == MetaCoreUiRenderMode::WorldSpace) { + const auto surface = MetaCoreComputeUiWorldSurfaceLayout( + object.UiRenderer->WorldSize, object.UiRenderer->PixelsPerUnit); + entry.TargetWidth = surface.TargetWidth; + entry.TargetHeight = surface.TargetHeight; + } cookedManifest.Documents.push_back(std::move(entry)); } } @@ -10935,6 +11140,14 @@ public: editorContext.GetActiveAnimationRuntime(), Input_ ? &Input_->GetRuntimeUi() : nullptr ); Runtime_->Start(); + if (Input_) { + (void)Input_->GetRuntimeUi().RegisterProvider( + "scene", + [this](std::string_view path) { return Runtime_ ? Runtime_->ReadSceneUiBinding(path) : std::nullopt; }, + [this](std::string_view path, const MetaCoreRuntimeUiValue& value) { + return Runtime_ && Runtime_->WriteSceneUiBinding(path, value); + }); + } editorContext.SetActiveScriptRuntime(Runtime_.get()); if (Physics_ && Physics_->GetOrCreate(editorContext)) { Physics_->GetOrCreate(editorContext)->SetEventCallback([this](const MetaCorePhysicsContactEvent& event) { @@ -10956,6 +11169,21 @@ public: }); Input_->GetInteraction().SetEventCallback([this](const MetaCorePointerEvent& event) { if (!Runtime_) return; + if (event.WorldSpaceUi) { + using PointerType = MetaCoreRuntimeUiWorldPointerType; + std::optional type; + if (event.Type == MetaCorePointerEventType::Enter || event.Type == MetaCorePointerEventType::Move) + type = PointerType::Move; + else if (event.Type == MetaCorePointerEventType::Down) type = PointerType::Down; + else if (event.Type == MetaCorePointerEventType::Up) type = PointerType::Up; + else if (event.Type == MetaCorePointerEventType::Scroll) type = PointerType::Scroll; + else if (event.Type == MetaCorePointerEventType::Leave || + event.Type == MetaCorePointerEventType::Cancel) type = PointerType::Cancel; + if (type.has_value()) + (void)Input_->GetRuntimeUi().ProcessWorldPointer( + event.TargetObjectId, event.SurfaceUv, *type, event.Button, event.ScrollDelta); + return; + } MetaCoreScriptValueV1 type{}; type.Type = MetaCoreScriptAbiValue_Int; type.IntValue = static_cast(event.Type); MetaCoreScriptValueV1 button{}; button.Type = MetaCoreScriptAbiValue_Int; button.IntValue = event.Button; MetaCoreScriptValueV1 screen{}; screen.Type = MetaCoreScriptAbiValue_Vec3; screen.Vec3[0] = event.ScreenPosition.x; screen.Vec3[1] = event.ScreenPosition.y; @@ -11005,6 +11233,7 @@ public: void OnPlayStop(MetaCoreEditorContext& editorContext) override { editorContext.SetActiveScriptRuntime(nullptr); if (Input_) { + (void)Input_->GetRuntimeUi().UnregisterProvider("scene"); Input_->GetRuntime().Unsubscribe(InputSubscription_); Input_->GetInteraction().SetEventCallback({}); Input_->GetRuntimeUi().SetEventCallback({}); diff --git a/Source/MetaCoreEditor/Private/MetaCoreBuiltinEditorModule.cpp b/Source/MetaCoreEditor/Private/MetaCoreBuiltinEditorModule.cpp index ebd0aa8..e10b549 100644 --- a/Source/MetaCoreEditor/Private/MetaCoreBuiltinEditorModule.cpp +++ b/Source/MetaCoreEditor/Private/MetaCoreBuiltinEditorModule.cpp @@ -4516,6 +4516,7 @@ public: ImGui::TextDisabled("选择或新建一个 .mcui UI 资产。"); return; } + const MetaCoreUiDocument historyBefore = Document_; const float hierarchyWidth = 230.0F; const float inspectorWidth = 330.0F; @@ -4531,6 +4532,16 @@ public: ImGui::BeginChild("NativeUiInspector", ImVec2(inspectorWidth, 0.0F), true); DrawNativeInspector(editorContext, *assetDatabaseService); ImGui::EndChild(); + if (!HistoryApplying_) { + const auto beforeBytes = MetaCoreSerializeToBytes(historyBefore, reflectionRegistry->GetTypeRegistry()); + const auto afterBytes = MetaCoreSerializeToBytes(Document_, reflectionRegistry->GetTypeRegistry()); + if (beforeBytes.has_value() && afterBytes.has_value() && *beforeBytes != *afterBytes) { + UndoHistory_.push_back(historyBefore); + if (UndoHistory_.size() > 100) UndoHistory_.erase(UndoHistory_.begin()); + RedoHistory_.clear(); + Dirty_ = true; + } + } } [[nodiscard]] bool HasDirtyDocument() const override { @@ -4577,14 +4588,30 @@ private: SaveNativeDocument(editorContext, assetDatabaseService, packageService, reflectionRegistry); } ImGui::SameLine(); + ImGui::BeginDisabled(UndoHistory_.empty()); + if (ImGui::Button("撤销")) { + RedoHistory_.push_back(Document_); + Document_ = std::move(UndoHistory_.back()); + UndoHistory_.pop_back(); + HistoryApplying_ = true; + Dirty_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(RedoHistory_.empty()); + if (ImGui::Button("重做")) { + UndoHistory_.push_back(Document_); + Document_ = std::move(RedoHistory_.back()); + RedoHistory_.pop_back(); + HistoryApplying_ = true; + Dirty_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); if (ImGui::Button("重新加载")) { ReloadNativeDocument(editorContext, assetDatabaseService, packageService, reflectionRegistry); } ImGui::SameLine(); - if (ImGui::Button("导出到运行时")) { - ExportNativeDocument(editorContext, assetDatabaseService); - } - ImGui::SameLine(); const char* placementModes[] = {"2D 屏幕", "3D 场景"}; int placementMode = static_cast(PlacementRenderMode_); ImGui::SetNextItemWidth(92.0F); @@ -4600,6 +4627,7 @@ private: ImGui::SameLine(); ImGui::TextColored(ImVec4(0.95F, 0.72F, 0.32F, 1.0F), "未保存"); } + HistoryApplying_ = false; } void DrawNativeHierarchyNode(const std::string& nodeId) { @@ -4734,11 +4762,14 @@ private: const MetaCoreUiNodeDocument& node, const ImVec2& rectMin, const ImVec2& rectMax, - const std::string& label + const std::string& label, + ImFont* font, + float fontSize, + float canvasScale ) const { - const ImVec2 textSize = ImGui::CalcTextSize(label.c_str()); - const float paddingX = std::max(3.0F, node.Style.Padding.x); - const float paddingY = std::max(3.0F, node.Style.Padding.y); + const ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0F, label.c_str()); + const float paddingX = std::max(1.0F, node.Style.Padding.x * canvasScale); + const float paddingY = std::max(1.0F, node.Style.Padding.y * canvasScale); float x = rectMin.x + paddingX; float y = rectMin.y + paddingY; @@ -4815,6 +4846,8 @@ private: const bool isButton = node.Type == MetaCoreUiNodeType::Button; const bool isImage = node.Type == MetaCoreUiNodeType::Image; const bool isPanel = node.Type == MetaCoreUiNodeType::Panel; + ImFont* const previewFont = ImGui::GetFont(); + const float previewFontSize = std::max(1.0F, node.Style.FontSize * scale); const ImVec2 mousePosition = ImGui::GetMousePos(); const bool nodeHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && @@ -4888,8 +4921,12 @@ private: drawList->AddLine(visualMin, visualMax, IM_COL32(220, 230, 245, 128), 1.0F); drawList->AddLine(ImVec2(visualMax.x, visualMin.y), ImVec2(visualMin.x, visualMax.y), IM_COL32(220, 230, 245, 128), 1.0F); const std::string imageLabel = MetaCoreNativeUiTextureLabel(assetDatabaseService, node.Style.ImageAssetGuid); - const ImVec2 labelPos = ComputeNativeTextPosition(node, visualMin, visualMax, imageLabel); - drawList->AddText(labelPos, IM_COL32(235, 240, 248, 230), imageLabel.c_str()); + const ImVec2 labelPos = ComputeNativeTextPosition( + node, visualMin, visualMax, imageLabel, previewFont, previewFontSize, scale + ); + drawList->AddText( + previewFont, previewFontSize, labelPos, IM_COL32(235, 240, 248, 230), imageLabel.c_str() + ); } else if (isText || isButton) { const std::string label = node.Text.empty() ? node.Name : node.Text; if (!label.empty()) { @@ -4897,7 +4934,10 @@ private: ? glm::vec3(0.55F, 0.58F, 0.64F) : node.Style.TextColor; const ImU32 textColor = MetaCoreUiColorToU32(textColorValue, 1.0F); - drawList->AddText(ComputeNativeTextPosition(node, visualMin, visualMax, label), textColor, label.c_str()); + const ImVec2 textPosition = ComputeNativeTextPosition( + node, visualMin, visualMax, label, previewFont, previewFontSize, scale + ); + drawList->AddText(previewFont, previewFontSize, textPosition, textColor, label.c_str()); } } @@ -5308,8 +5348,33 @@ private: Dirty_ |= ImGui::DragFloat("最小缩放", &Document_.MinScale, 0.05F, 0.1F, 8.0F); Dirty_ |= ImGui::DragFloat("最大缩放", &Document_.MaxScale, 0.05F, Document_.MinScale, 8.0F); Dirty_ |= ImGui::Checkbox("使用安全区", &Document_.UseSafeArea); + Dirty_ |= ImGui::DragFloat("最小宽高比", &Document_.MinAspectRatio, 0.01F, 0.0F, 10.0F); + Dirty_ |= ImGui::DragFloat("最大宽高比", &Document_.MaxAspectRatio, 0.01F, 0.0F, 10.0F); Dirty_ |= DrawNativeString("默认 Locale", Document_.DefaultLocale, 64); Dirty_ |= DrawNativeString("Fallback Locale", Document_.FallbackLocale, 64); + const auto drawResource = [&](const char* label, const char* type, MetaCoreAssetGuid& guid) { + std::string preview = "无"; + if (const auto selected = assetDatabaseService.FindAssetByGuid(guid); selected.has_value()) + preview = selected->RelativePath.generic_string(); + bool changed = false; + if (ImGui::BeginCombo(label, preview.c_str())) { + if (ImGui::Selectable("无", !guid.IsValid())) { guid = {}; changed = true; } + for (const auto& record : assetDatabaseService.GetAssetRegistry()) { + if (record.Type != type) continue; + const bool selected = record.Guid == guid; + if (ImGui::Selectable(record.RelativePath.generic_string().c_str(), selected)) { + guid = record.Guid; + changed = true; + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + return changed; + }; + Dirty_ |= drawResource("主题资产", "ui_theme", Document_.ThemeAssetGuid); + Dirty_ |= drawResource("字体族资产", "ui_font_family", Document_.FontFamilyAssetGuid); + Dirty_ |= drawResource("本地化资产", "ui_localization", Document_.LocalizationAssetGuid); Dirty_ |= ImGui::ColorEdit3("UI 背景颜色", &Document_.BackgroundColor.x); if (ImGui::SliderFloat("UI 背景透明度", &Document_.BackgroundAlpha, 0.0F, 1.0F, "%.2f")) { Document_.BackgroundAlpha = std::clamp(Document_.BackgroundAlpha, 0.0F, 1.0F); @@ -5440,7 +5505,31 @@ private: Dirty_ |= ImGui::Checkbox("只读", &node->ReadOnly); Dirty_ |= ImGui::DragInt("最大长度", &node->MaxLength, 1.0F, 0, 4096); } - if (isList) Dirty_ |= ImGui::DragInt("选中索引", &node->SelectedIndex, 1.0F, -1, 100000); + if (isList) { + Dirty_ |= ImGui::DragInt("选中索引", &node->SelectedIndex, 1.0F, -1, 100000); + Dirty_ |= DrawNativeString("行模板节点 ID", node->ItemTemplateNodeId, 128); + if (ImGui::Button("添加静态项")) { + const std::string id = "item-" + std::to_string(node->StaticItems.size() + 1); + node->StaticItems.push_back({id, "Item", id, {}}); + Dirty_ = true; + } + for (std::size_t itemIndex = 0; itemIndex < node->StaticItems.size(); ++itemIndex) { + ImGui::PushID(static_cast(itemIndex)); + ImGui::SeparatorText("静态项"); + auto& item = node->StaticItems[itemIndex]; + Dirty_ |= DrawNativeString("项 ID", item.Id, 96); + Dirty_ |= DrawNativeString("项文本", item.Text, 192); + Dirty_ |= DrawNativeString("项值", item.Value, 128); + Dirty_ |= DrawNativeString("项本地化 Key", item.LocalizationKey, 128); + if (ImGui::SmallButton("删除静态项")) { + node->StaticItems.erase(node->StaticItems.begin() + static_cast(itemIndex)); + Dirty_ = true; + ImGui::PopID(); + break; + } + ImGui::PopID(); + } + } if (isList || isScroll) Dirty_ |= ImGui::Checkbox("裁剪子节点", &node->RectTransform.ClipChildren); ImGui::Separator(); @@ -5476,6 +5565,14 @@ private: Dirty_ = true; } Dirty_ |= DrawNativeString("Format", binding.Format, 96); + const char* converterLabels[] = {"", "not", "to_string", "uppercase", "lowercase", "to_bool", "to_int", "to_double"}; + int converterIndex = 0; + for (int candidate = 1; candidate < IM_ARRAYSIZE(converterLabels); ++candidate) + if (binding.Converter == converterLabels[candidate]) converterIndex = candidate; + if (ImGui::Combo("Converter", &converterIndex, converterLabels, IM_ARRAYSIZE(converterLabels))) { + binding.Converter = converterLabels[converterIndex]; + Dirty_ = true; + } Dirty_ |= DrawNativeString("Fallback", binding.Fallback, 128); if (ImGui::SmallButton("删除绑定")) { node->Bindings.erase(node->Bindings.begin() + static_cast(index)); @@ -5486,6 +5583,42 @@ private: ImGui::PopID(); } } + ImGui::SeparatorText("动效"); + if (ImGui::Button("添加动效")) { + node->Animations.push_back({"animation-" + std::to_string(node->Animations.size() + 1), + MetaCoreUiAnimationTrigger::Show, MetaCoreUiAnimationProperty::Opacity, + {0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}, 0.2F, 0.0F, MetaCoreUiEasing::EaseOut}); + Dirty_ = true; + } + for (std::size_t animationIndex = 0; animationIndex < node->Animations.size(); ++animationIndex) { + ImGui::PushID(static_cast(animationIndex)); + auto& animation = node->Animations[animationIndex]; + Dirty_ |= DrawNativeString("动效 ID", animation.Id, 96); + const char* triggerLabels[] = {"Show", "Hide", "Hover", "Pressed", "Focus", "Custom"}; + int trigger = static_cast(animation.Trigger); + if (ImGui::Combo("触发", &trigger, triggerLabels, IM_ARRAYSIZE(triggerLabels))) { + animation.Trigger = static_cast(std::clamp(trigger, 0, 5)); + Dirty_ = true; + } + const char* propertyLabels[] = {"Opacity", "Translation", "Scale", "Color"}; + int property = static_cast(animation.Property); + if (ImGui::Combo("属性", &property, propertyLabels, IM_ARRAYSIZE(propertyLabels))) { + animation.Property = static_cast(std::clamp(property, 0, 3)); + Dirty_ = true; + } + Dirty_ |= ImGui::DragFloat3("起始值", &animation.From.x, 0.01F); + Dirty_ |= ImGui::DragFloat3("结束值", &animation.To.x, 0.01F); + Dirty_ |= ImGui::DragFloat("时长", &animation.DurationSeconds, 0.01F, 0.0F, 60.0F); + Dirty_ |= ImGui::DragFloat("延迟", &animation.DelaySeconds, 0.01F, 0.0F, 60.0F); + if (ImGui::SmallButton("删除动效")) { + node->Animations.erase(node->Animations.begin() + static_cast(animationIndex)); + Dirty_ = true; + ImGui::PopID(); + break; + } + ImGui::Separator(); + ImGui::PopID(); + } if (ImGui::Button("删除节点")) { DeleteNativeNode(node->Id); Dirty_ = true; @@ -5678,6 +5811,8 @@ private: LoadedRecord_ = record; Document_ = *document; SelectedNodeId_ = Document_.RootNodeIds.empty() ? std::string{} : Document_.RootNodeIds.front(); + UndoHistory_.clear(); + RedoHistory_.clear(); Dirty_ = false; return true; } @@ -5722,6 +5857,8 @@ private: Document_.Name = NewUiNameBuffer_.data()[0] != '\0' ? NewUiNameBuffer_.data() : createdPath.stem().string(); Document_.ReferenceWidth = 1920; Document_.ReferenceHeight = 1080; + UndoHistory_.clear(); + RedoHistory_.clear(); SelectedNodeId_.clear(); AddNativeNode(MetaCoreUiNodeType::Panel); SaveNativeDocument(editorContext, assetDatabaseService, packageService, reflectionRegistry); @@ -5744,119 +5881,6 @@ private: } } - [[nodiscard]] MetaCoreNativeUiImageSourceMap PrepareNativeImageRuntimeSources( - MetaCoreEditorContext& editorContext, - MetaCoreIAssetDatabaseService& assetDatabaseService, - const std::filesystem::path& generatedDirectory - ) { - MetaCoreNativeUiImageSourceMap imageSources; - const MetaCoreProjectDescriptor& project = assetDatabaseService.GetProjectDescriptor(); - const std::filesystem::path imageDirectoryRelative = generatedDirectory / "Images"; - const std::filesystem::path imageDirectoryAbsolute = project.UiPath / imageDirectoryRelative; - std::error_code errorCode; - std::filesystem::create_directories(imageDirectoryAbsolute, errorCode); - if (errorCode) { - editorContext.AddConsoleMessage(MetaCoreLogLevel::Warning, "UI", "无法创建 UI 图片导出目录"); - return imageSources; - } - - std::unordered_set copiedGuids; - for (const MetaCoreUiNodeDocument& node : Document_.Nodes) { - if (node.Type != MetaCoreUiNodeType::Image || !node.Style.ImageAssetGuid.IsValid()) { - continue; - } - if (!copiedGuids.insert(node.Style.ImageAssetGuid).second) { - continue; - } - - const std::filesystem::path sourcePath = - MetaCoreNativeUiResolveTextureSourcePath(assetDatabaseService, node.Style.ImageAssetGuid); - if (sourcePath.empty()) { - editorContext.AddConsoleMessage( - MetaCoreLogLevel::Warning, - "UI", - "跳过缺失的 UI 图片贴图: " + node.Style.ImageAssetGuid.ToString() - ); - continue; - } - - std::filesystem::path extension = sourcePath.extension(); - if (extension.empty()) { - extension = ".png"; - } - const std::filesystem::path targetFilename = node.Style.ImageAssetGuid.ToString() + extension.string(); - const std::filesystem::path targetAbsolute = imageDirectoryAbsolute / targetFilename; - errorCode.clear(); - if (!std::filesystem::equivalent(sourcePath, targetAbsolute, errorCode)) { - errorCode.clear(); - std::filesystem::copy_file(sourcePath, targetAbsolute, std::filesystem::copy_options::overwrite_existing, errorCode); - if (errorCode) { - editorContext.AddConsoleMessage( - MetaCoreLogLevel::Warning, - "UI", - "复制 UI 图片失败: " + sourcePath.generic_string() - ); - continue; - } - } - imageSources[node.Style.ImageAssetGuid] = std::filesystem::path("Images") / targetFilename; - } - return imageSources; - } - - void ExportNativeDocument(MetaCoreEditorContext& editorContext, MetaCoreIAssetDatabaseService& assetDatabaseService) { - if (!LoadedRecord_.has_value()) { - return; - } - const MetaCoreProjectDescriptor& project = assetDatabaseService.GetProjectDescriptor(); - std::filesystem::path generatedDirectory = std::filesystem::path("Generated"); - std::filesystem::path rmlPath = generatedDirectory / (Document_.Name.empty() ? LoadedRecord_->RelativePath.stem() : std::filesystem::path(Document_.Name)); - rmlPath.replace_extension(".rml"); - std::filesystem::path rcssPath = rmlPath; - rcssPath.replace_extension(".rcss"); - const MetaCoreNativeUiImageSourceMap imageSources = - PrepareNativeImageRuntimeSources(editorContext, assetDatabaseService, generatedDirectory); - const MetaCoreCompiledUiDocument compiled = MetaCoreCompileUiDocument(Document_); - if (!compiled.Succeeded) { - for (const MetaCoreUiDiagnostic& diagnostic : compiled.Diagnostics) - editorContext.AddConsoleMessage(MetaCoreLogLevel::Error, "UI", diagnostic.NodeId + ": " + diagnostic.Message); - return; - } - std::string rml = compiled.Rml; - const std::string generatedStylesheet = "document.rcss"; - const std::string actualStylesheet = rcssPath.filename().generic_string(); - if (const std::size_t position = rml.find(generatedStylesheet); position != std::string::npos) - rml.replace(position, generatedStylesheet.size(), actualStylesheet); - for (const auto& [guid, imagePath] : imageSources) { - const std::string generatedPath = "Assets/" + guid.ToString() + ".png"; - std::size_t position = 0; - while ((position = rml.find(generatedPath, position)) != std::string::npos) { - const std::string actualPath = imagePath.generic_string(); - rml.replace(position, generatedPath.size(), actualPath); - position += actualPath.size(); - } - } - const bool wroteRml = MetaCoreWriteTextFile(project.UiPath / rmlPath, rml); - const bool wroteRcss = MetaCoreWriteTextFile(project.UiPath / rcssPath, compiled.Rcss); - if (!wroteRml || !wroteRcss) { - editorContext.AddConsoleMessage(MetaCoreLogLevel::Error, "UI", "导出运行时 RML 失败"); - return; - } - - MetaCoreRuntimeUiManifest& manifest = editorContext.AccessRuntimeUiManifestDocument(); - if (!MetaCoreRuntimeUiManifestHasPath(manifest, rmlPath)) { - manifest.Documents.push_back(MetaCoreRuntimeUiDocumentEntry{ - MetaCoreBuildRuntimeUiDocumentId(manifest, rmlPath), - rmlPath, - manifest.Documents.empty() ? 0 : manifest.Documents.back().Layer + 10, - true, - true - }); - } - (void)editorContext.SaveRuntimeDataConfig(); - editorContext.AddConsoleMessage(MetaCoreLogLevel::Info, "UI", "已导出 UI 到运行时 RML"); - } - void AddNativeDocumentToScene( MetaCoreEditorContext& editorContext, MetaCoreIAssetDatabaseService& assetDatabaseService, @@ -5899,6 +5923,11 @@ private: transform.Position = glm::vec3(0.0F, 0.0F, 0.0F); transform.RotationEulerDegrees = glm::vec3(0.0F, 0.0F, 0.0F); transform.Scale = glm::vec3(1.0F, 1.0F, 1.0F); + MetaCoreColliderComponent collider; + collider.Shape = MetaCoreColliderShape::Box; + collider.Size = glm::vec3(uiRenderer.WorldSize.x, uiRenderer.WorldSize.y, 0.01F); + collider.IsTrigger = true; + uiObject.AddComponent(collider); } editorContext.SelectOnly(createdObjectId); @@ -5921,6 +5950,9 @@ private: float CanvasZoom_ = 1.0F; bool SnapToGrid_ = true; float GridSize_ = 8.0F; + std::vector UndoHistory_{}; + std::vector RedoHistory_{}; + bool HistoryApplying_ = false; }; struct MetaCoreGeneratedResourceListEntry { diff --git a/Source/MetaCoreRender/Private/MetaCoreEditorViewportRenderer.cpp b/Source/MetaCoreRender/Private/MetaCoreEditorViewportRenderer.cpp index 53a274e..fdc9f87 100644 --- a/Source/MetaCoreRender/Private/MetaCoreEditorViewportRenderer.cpp +++ b/Source/MetaCoreRender/Private/MetaCoreEditorViewportRenderer.cpp @@ -151,6 +151,12 @@ void MetaCoreEditorViewportRenderer::SetRuntimeOverlayRenderCallback( FilamentSceneBridge_.SetRuntimeOverlayRenderCallback(std::move(callback)); } +void MetaCoreEditorViewportRenderer::SetRuntimeWorldUiRenderCallback( + MetaCoreFilamentSceneBridge::RuntimeWorldUiRenderCallback callback +) { + FilamentSceneBridge_.SetRuntimeWorldUiRenderCallback(std::move(callback)); +} + uint32_t MetaCoreEditorViewportRenderer::GetFilamentGLTextureId() const { return FilamentSceneBridge_.GetGLTextureId(); } diff --git a/Source/MetaCoreRender/Private/MetaCoreFilamentSceneBridge.cpp b/Source/MetaCoreRender/Private/MetaCoreFilamentSceneBridge.cpp index 20def54..bc368ca 100644 --- a/Source/MetaCoreRender/Private/MetaCoreFilamentSceneBridge.cpp +++ b/Source/MetaCoreRender/Private/MetaCoreFilamentSceneBridge.cpp @@ -468,6 +468,9 @@ public: void SetRuntimeOverlayRenderCallback(MetaCoreFilamentSceneBridge::RuntimeOverlayRenderCallback callback) { RuntimeOverlayRenderCallback_ = std::move(callback); } + void SetRuntimeWorldUiRenderCallback(MetaCoreFilamentSceneBridge::RuntimeWorldUiRenderCallback callback) { + RuntimeWorldUiRenderCallback_ = std::move(callback); + } [[nodiscard]] bool VerifyEditorGridVisibleForTesting() const { return EditorGridEntityInScene_ && EditorGridAxisEntityInScene_; @@ -2697,6 +2700,8 @@ public: } if (Renderer_->beginFrame(SwapChain_)) { + if (RuntimeWorldUiRenderCallback_ && Scene_ != nullptr) + RuntimeWorldUiRenderCallback_(*Engine_, *Renderer_, *Scene_); const bool renderEditorGrid = GridView_ != nullptr && (EditorGridEntityInScene_ || EditorGridAxisEntityInScene_); const auto renderScenePass = [&](bool outputToRenderTarget) { @@ -4161,6 +4166,7 @@ private: filament::View* UIView_ = nullptr; MetaCoreImGuiHelper* ImGuiHelper_ = nullptr; MetaCoreFilamentSceneBridge::RuntimeOverlayRenderCallback RuntimeOverlayRenderCallback_{}; + MetaCoreFilamentSceneBridge::RuntimeWorldUiRenderCallback RuntimeWorldUiRenderCallback_{}; GLuint GLTextureId_ = 0; struct PendingRenderTargetResource { @@ -4229,6 +4235,10 @@ void MetaCoreFilamentSceneBridge::SetRuntimeOverlayRenderCallback(RuntimeOverlay Impl_->SetRuntimeOverlayRenderCallback(std::move(callback)); } +void MetaCoreFilamentSceneBridge::SetRuntimeWorldUiRenderCallback(RuntimeWorldUiRenderCallback callback) { + Impl_->SetRuntimeWorldUiRenderCallback(std::move(callback)); +} + void MetaCoreFilamentSceneBridge::RenderAll() { Impl_->RenderAll(); } diff --git a/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreEditorViewportRenderer.h b/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreEditorViewportRenderer.h index 6be0cb4..b912631 100644 --- a/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreEditorViewportRenderer.h +++ b/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreEditorViewportRenderer.h @@ -28,6 +28,7 @@ public: void SetRenderDebugView(MetaCoreRenderDebugView debugView); [[nodiscard]] MetaCoreRenderDebugView GetRenderDebugView() const; void SetRuntimeOverlayRenderCallback(MetaCoreFilamentSceneBridge::RuntimeOverlayRenderCallback callback); + void SetRuntimeWorldUiRenderCallback(MetaCoreFilamentSceneBridge::RuntimeWorldUiRenderCallback callback); [[nodiscard]] uint32_t GetFilamentGLTextureId() const; [[nodiscard]] void* GetFilamentTexturePointer() const; [[nodiscard]] MetaCoreVisibilityStatistics GetRenderingStatistics() const; diff --git a/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreFilamentSceneBridge.h b/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreFilamentSceneBridge.h index c59806e..133efd5 100644 --- a/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreFilamentSceneBridge.h +++ b/Source/MetaCoreRender/Public/MetaCoreRender/MetaCoreFilamentSceneBridge.h @@ -15,6 +15,7 @@ namespace filament { class Engine; class Renderer; +class Scene; } namespace MetaCore { @@ -42,6 +43,11 @@ public: uint32_t width, uint32_t height )>; + using RuntimeWorldUiRenderCallback = std::function; MetaCoreFilamentSceneBridge(); ~MetaCoreFilamentSceneBridge(); @@ -57,6 +63,7 @@ public: void SetRenderDebugView(MetaCoreRenderDebugView debugView); [[nodiscard]] MetaCoreRenderDebugView GetRenderDebugView() const; void SetRuntimeOverlayRenderCallback(RuntimeOverlayRenderCallback callback); + void SetRuntimeWorldUiRenderCallback(RuntimeWorldUiRenderCallback callback); void RenderAll(); [[nodiscard]] bool RequestPick(std::uint32_t x, std::uint32_t y, PickCallback callback); [[nodiscard]] uint32_t GetGLTextureId() const; diff --git a/Source/MetaCoreRuntimeData/Public/MetaCoreRuntimeData/MetaCoreRuntimeDataProject.h b/Source/MetaCoreRuntimeData/Public/MetaCoreRuntimeData/MetaCoreRuntimeDataProject.h index d827967..011bb98 100644 --- a/Source/MetaCoreRuntimeData/Public/MetaCoreRuntimeData/MetaCoreRuntimeDataProject.h +++ b/Source/MetaCoreRuntimeData/Public/MetaCoreRuntimeData/MetaCoreRuntimeDataProject.h @@ -172,6 +172,12 @@ struct MetaCoreRuntimeUiDocumentEntry { MC_PROPERTY() std::int32_t TargetHeight = 0; + + MC_PROPERTY() + float PixelsPerUnit = 1000.0F; + + MC_PROPERTY() + glm::vec3 WorldSize{1.92F, 1.08F, 0.0F}; }; MC_STRUCT() diff --git a/Source/MetaCoreRuntimeInput/Private/MetaCoreRuntimeInteraction.cpp b/Source/MetaCoreRuntimeInput/Private/MetaCoreRuntimeInteraction.cpp index b281069..f2c2941 100644 --- a/Source/MetaCoreRuntimeInput/Private/MetaCoreRuntimeInteraction.cpp +++ b/Source/MetaCoreRuntimeInput/Private/MetaCoreRuntimeInteraction.cpp @@ -6,8 +6,10 @@ #include "MetaCoreRender/MetaCoreEditorViewportRenderer.h" #include "MetaCoreRender/MetaCoreRenderTypes.h" #include "MetaCoreScene/MetaCoreScene.h" +#include "MetaCoreScene/MetaCoreTransformUtils.h" #include +#include #include #include #include @@ -39,13 +41,24 @@ public: MetaCoreId Captured = 0; glm::vec2 PressPosition{0.0F}; glm::vec2 PreviousPosition{0.0F}; + glm::vec3 HoverWorld{0.0F}; + glm::vec2 HoverSurfaceUv{-1.0F}; + bool HoverIsWorldUi = false; bool Dragging = false; - void Emit(MetaCorePointerEventType type, MetaCoreId target, glm::vec2 position, glm::vec3 world = {}, std::uint32_t button = 0) { - if (Callback && target != 0) Callback({type, target, button, position, position - PreviousPosition, world}); + void Emit(MetaCorePointerEventType type, MetaCoreId target, glm::vec2 position, glm::vec3 world = {}, + std::uint32_t button = 0, glm::vec2 surfaceUv = {-1.0F, -1.0F}, bool worldSpaceUi = false, + float scrollDelta = 0.0F) { + if (Callback && target != 0) + Callback({type, target, button, position, position - PreviousPosition, world, surfaceUv, worldSpaceUi, scrollDelta}); } bool IsInteractive(MetaCoreScene& scene, MetaCoreId id, std::uint32_t mask) const { auto object = scene.FindGameObject(id); + if (object && object.HasComponent()) { + const auto& ui = object.GetComponent(); + if (ui.RenderMode == MetaCoreUiRenderMode::WorldSpace && ui.Visible && ui.InputEnabled) + return (mask & (MetaCoreInteraction_Hover | MetaCoreInteraction_Click)) != 0; + } return object && object.HasComponent() && object.GetComponent().Enabled && object.HasComponent() && object.GetComponent().Visible && (object.GetComponent().EventMask & mask) != 0; @@ -63,6 +76,9 @@ void MetaCoreRuntimeInteraction::Cancel() { if (Impl_->Captured) Impl_->Emit(MetaCorePointerEventType::Cancel, Impl_->Captured, Impl_->PreviousPosition); if (Impl_->Hovered) Impl_->Emit(MetaCorePointerEventType::Leave, Impl_->Hovered, Impl_->PreviousPosition); Impl_->Captured = Impl_->Hovered = 0; Impl_->Dragging = false; + Impl_->HoverWorld = {}; + Impl_->HoverSurfaceUv = {-1.0F, -1.0F}; + Impl_->HoverIsWorldUi = false; ++Impl_->InteractionRevision; } @@ -110,21 +126,41 @@ void MetaCoreRuntimeInteraction::Update(MetaCoreScene& scene, const PickRequest& std::sort(completed.begin(), completed.end(), [](const auto& a, const auto& b){ return a.Sequence < b.Sequence; }); const auto processResult = [&](const Impl::Completed& result) { if (result.SceneRevision != scene.GetRevision() || result.InteractionRevision != Impl_->InteractionRevision) return; + const auto worldUi = [&]() -> std::pair { + auto object = scene.FindGameObject(result.Object); + if (!object || !object.HasComponent()) return {{-1.0F, -1.0F}, false}; + const auto& ui = object.GetComponent(); + if (ui.RenderMode != MetaCoreUiRenderMode::WorldSpace || !ui.Visible || !ui.InputEnabled) + return {{-1.0F, -1.0F}, false}; + const auto buildWorld = [&](const auto& self, MetaCoreGameObject current) -> glm::mat4 { + const glm::mat4 local = MetaCoreBuildTransformMatrix(current.GetComponent()); + const auto parent = scene.FindGameObject(current.GetParentId()); + return parent ? self(self, parent) * local : local; + }; + const glm::vec4 local = glm::inverse(buildWorld(buildWorld, object)) * glm::vec4(result.World, 1.0F); + if (ui.WorldSize.x <= 0.0F || ui.WorldSize.y <= 0.0F) return {{-1.0F, -1.0F}, false}; + const glm::vec2 uv{local.x / ui.WorldSize.x + 0.5F, 0.5F - local.y / ui.WorldSize.y}; + if (uv.x < 0.0F || uv.y < 0.0F || uv.x > 1.0F || uv.y > 1.0F) return {{-1.0F, -1.0F}, false}; + return {uv, true}; + }(); if (result.Kind == Impl::QueryKind::Hover) { if (result.Sequence != Impl_->LatestHoverSequence) return; const MetaCoreId next = Impl_->IsInteractive(scene, result.Object, MetaCoreInteraction_Hover) ? result.Object : 0; - if (next != Impl_->Hovered) { if (Impl_->Hovered) Impl_->Emit(MetaCorePointerEventType::Leave, Impl_->Hovered, result.Position); Impl_->Hovered = next; if (next) Impl_->Emit(MetaCorePointerEventType::Enter, next, result.Position, result.World); } - else if (next) Impl_->Emit(MetaCorePointerEventType::Move, next, result.Position, result.World); + if (next != Impl_->Hovered) { if (Impl_->Hovered) Impl_->Emit(MetaCorePointerEventType::Leave, Impl_->Hovered, result.Position); Impl_->Hovered = next; if (next) Impl_->Emit(MetaCorePointerEventType::Enter, next, result.Position, result.World, 0, worldUi.first, worldUi.second); } + else if (next) Impl_->Emit(MetaCorePointerEventType::Move, next, result.Position, result.World, 0, worldUi.first, worldUi.second); + Impl_->HoverWorld = result.World; + Impl_->HoverSurfaceUv = worldUi.first; + Impl_->HoverIsWorldUi = worldUi.second; } else if (result.Kind == Impl::QueryKind::Down) { if (Impl_->IsInteractive(scene, result.Object, MetaCoreInteraction_Click | MetaCoreInteraction_Drag)) { Impl_->Captured = result.Object; Impl_->PressPosition = result.Position; Impl_->Dragging = false; - Impl_->Emit(MetaCorePointerEventType::Down, result.Object, result.Position, result.World); + Impl_->Emit(MetaCorePointerEventType::Down, result.Object, result.Position, result.World, 0, worldUi.first, worldUi.second); } } else if (result.Kind == Impl::QueryKind::Up && Impl_->Captured) { const MetaCoreId target = Impl_->Captured; - Impl_->Emit(MetaCorePointerEventType::Up, target, result.Position, result.World); + Impl_->Emit(MetaCorePointerEventType::Up, target, result.Position, result.World, 0, worldUi.first, worldUi.second); if (Impl_->Dragging) Impl_->Emit(MetaCorePointerEventType::DragEnd, target, result.Position, result.World); - else if (result.Object == target && Impl_->IsInteractive(scene, target, MetaCoreInteraction_Click)) Impl_->Emit(MetaCorePointerEventType::Click, target, result.Position, result.World); + else if (result.Object == target && Impl_->IsInteractive(scene, target, MetaCoreInteraction_Click)) Impl_->Emit(MetaCorePointerEventType::Click, target, result.Position, result.World, 0, worldUi.first, worldUi.second); Impl_->Captured = 0; Impl_->Dragging = false; } }; @@ -140,6 +176,9 @@ void MetaCoreRuntimeInteraction::Update(MetaCoreScene& scene, const PickRequest& Impl_->PointerSequenceOrder.pop_front(); } const glm::vec2 cursor = input.GetCursorPosition(); + if (Impl_->Hovered != 0 && std::abs(input.GetMouseWheelDelta()) > 0.001F) + Impl_->Emit(MetaCorePointerEventType::Scroll, Impl_->Hovered, cursor, Impl_->HoverWorld, + 0, Impl_->HoverSurfaceUv, Impl_->HoverIsWorldUi, input.GetMouseWheelDelta()); if (Impl_->Captured && !Impl_->IsInteractive(scene, Impl_->Captured, MetaCoreInteraction_Click | MetaCoreInteraction_Drag)) Cancel(); if (!input.IsWindowFocused() || mouseConsumed || cursor.x < viewport.Left || cursor.y < viewport.Top || cursor.x >= viewport.Left + viewport.Width || cursor.y >= viewport.Top + viewport.Height) { Cancel(); Impl_->PreviousPosition = cursor; return; } if (Impl_->Captured && input.IsMouseButtonDown(MetaCoreMouseButton::Left)) { diff --git a/Source/MetaCoreRuntimeInput/Public/MetaCoreRuntimeInput/MetaCoreRuntimeInteraction.h b/Source/MetaCoreRuntimeInput/Public/MetaCoreRuntimeInput/MetaCoreRuntimeInteraction.h index e84c139..178a88d 100644 --- a/Source/MetaCoreRuntimeInput/Public/MetaCoreRuntimeInput/MetaCoreRuntimeInteraction.h +++ b/Source/MetaCoreRuntimeInput/Public/MetaCoreRuntimeInput/MetaCoreRuntimeInteraction.h @@ -20,7 +20,7 @@ class MetaCoreEditorViewportRenderer; struct MetaCoreViewportRect; struct MetaCoreSceneView; -enum class MetaCorePointerEventType : std::uint8_t { Enter, Move, Leave, Down, Up, Click, DragStart, Drag, DragEnd, Cancel }; +enum class MetaCorePointerEventType : std::uint8_t { Enter, Move, Leave, Down, Up, Click, Scroll, DragStart, Drag, DragEnd, Cancel }; struct MetaCorePointerEvent { MetaCorePointerEventType Type = MetaCorePointerEventType::Move; MetaCoreId TargetObjectId = 0; @@ -28,6 +28,9 @@ struct MetaCorePointerEvent { glm::vec2 ScreenPosition{0.0F}; glm::vec2 ScreenDelta{0.0F}; glm::vec3 WorldPosition{0.0F}; + glm::vec2 SurfaceUv{-1.0F}; + bool WorldSpaceUi = false; + float ScrollDelta = 0.0F; }; struct MetaCoreInteractionPickResult { MetaCoreId ObjectId = 0; diff --git a/Source/MetaCoreRuntimeUi/Private/Materials/rml_ui.mat b/Source/MetaCoreRuntimeUi/Private/Materials/rml_ui.mat index 104660f..13d4bd3 100644 --- a/Source/MetaCoreRuntimeUi/Private/Materials/rml_ui.mat +++ b/Source/MetaCoreRuntimeUi/Private/Materials/rml_ui.mat @@ -4,7 +4,7 @@ material { blending : transparent, transparency : default, depthWrite : false, - depthCulling : false, + depthCulling : true, doubleSided : true, requires : [ uv0, color ], parameters : [ diff --git a/Source/MetaCoreRuntimeUi/Private/MetaCoreRuntimeUiSystem.cpp b/Source/MetaCoreRuntimeUi/Private/MetaCoreRuntimeUiSystem.cpp index 26ec6b5..83aa6b9 100644 --- a/Source/MetaCoreRuntimeUi/Private/MetaCoreRuntimeUiSystem.cpp +++ b/Source/MetaCoreRuntimeUi/Private/MetaCoreRuntimeUiSystem.cpp @@ -6,8 +6,11 @@ #include "stb/stb_image.h" #include +#include +#include #include +#include #include #include @@ -15,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +32,7 @@ #include #include +#include #include #include #include @@ -38,6 +43,7 @@ #include #include #include +#include #include #include @@ -85,6 +91,101 @@ namespace { return stream.str(); } +[[nodiscard]] std::optional ParseUiValue(std::string_view text, MetaCoreUiInputValueType type) { + MetaCoreRuntimeUiValue result; + if (type == MetaCoreUiInputValueType::Text) { + result.Type = MetaCoreRuntimeUiValueType::String; + result.StringValue = std::string(text); + return result; + } + const std::string owned(text); + char* end = nullptr; + if (type == MetaCoreUiInputValueType::Integer) { + const long long value = std::strtoll(owned.c_str(), &end, 10); + if (end == owned.c_str() || *end != '\0') return std::nullopt; + result.Type = MetaCoreRuntimeUiValueType::Int64; + result.Int64Value = static_cast(value); + return result; + } + const double value = std::strtod(owned.c_str(), &end); + if (end == owned.c_str() || *end != '\0' || !std::isfinite(value)) return std::nullopt; + result.Type = MetaCoreRuntimeUiValueType::Double; + result.DoubleValue = value; + return result; +} + +[[nodiscard]] std::optional ConvertUiValue( + const MetaCoreRuntimeUiValue& source, + std::string_view converter +) { + if (converter.empty()) return source; + MetaCoreRuntimeUiValue result; + if (converter == "not" && source.Type == MetaCoreRuntimeUiValueType::Bool) { + result.Type = MetaCoreRuntimeUiValueType::Bool; + result.BoolValue = !source.BoolValue; + return result; + } + if (converter == "to_string") { + result.Type = MetaCoreRuntimeUiValueType::String; + result.StringValue = FormatUiValue(source, {}); + return result; + } + if ((converter == "uppercase" || converter == "lowercase") && + source.Type == MetaCoreRuntimeUiValueType::String) { + result = source; + std::transform(result.StringValue.begin(), result.StringValue.end(), result.StringValue.begin(), + [&](unsigned char value) { + return static_cast(converter == "uppercase" ? std::toupper(value) : std::tolower(value)); + }); + return result; + } + if (converter == "to_bool") { + result.Type = MetaCoreRuntimeUiValueType::Bool; + if (source.Type == MetaCoreRuntimeUiValueType::Bool) result.BoolValue = source.BoolValue; + else if (source.Type == MetaCoreRuntimeUiValueType::Int64) result.BoolValue = source.Int64Value != 0; + else if (source.Type == MetaCoreRuntimeUiValueType::Double) result.BoolValue = source.DoubleValue != 0.0; + else if (source.Type == MetaCoreRuntimeUiValueType::String && + (source.StringValue == "true" || source.StringValue == "1")) result.BoolValue = true; + else if (source.Type != MetaCoreRuntimeUiValueType::String || + (source.StringValue != "false" && source.StringValue != "0")) return std::nullopt; + return result; + } + if (converter == "to_int" || converter == "to_double") { + double numeric = 0.0; + if (source.Type == MetaCoreRuntimeUiValueType::Int64) numeric = static_cast(source.Int64Value); + else if (source.Type == MetaCoreRuntimeUiValueType::Double) numeric = source.DoubleValue; + else if (source.Type == MetaCoreRuntimeUiValueType::Bool) numeric = source.BoolValue ? 1.0 : 0.0; + else if (source.Type == MetaCoreRuntimeUiValueType::String) { + char* end = nullptr; + numeric = std::strtod(source.StringValue.c_str(), &end); + if (end == source.StringValue.c_str() || *end != '\0' || !std::isfinite(numeric)) return std::nullopt; + } else return std::nullopt; + if (converter == "to_int") { + result.Type = MetaCoreRuntimeUiValueType::Int64; + result.Int64Value = static_cast(numeric); + } else { + result.Type = MetaCoreRuntimeUiValueType::Double; + result.DoubleValue = numeric; + } + return result; + } + return std::nullopt; +} + +[[nodiscard]] bool UiValuesEqual(const MetaCoreRuntimeUiValue& lhs, const MetaCoreRuntimeUiValue& rhs) { + if (lhs.Type != rhs.Type) return false; + switch (lhs.Type) { + case MetaCoreRuntimeUiValueType::Bool: return lhs.BoolValue == rhs.BoolValue; + case MetaCoreRuntimeUiValueType::Int64: return lhs.Int64Value == rhs.Int64Value; + case MetaCoreRuntimeUiValueType::Double: return lhs.DoubleValue == rhs.DoubleValue; + case MetaCoreRuntimeUiValueType::String: return lhs.StringValue == rhs.StringValue; + case MetaCoreRuntimeUiValueType::Vec3: return lhs.Vec3Value == rhs.Vec3Value; + case MetaCoreRuntimeUiValueType::AssetRef: return lhs.AssetValue == rhs.AssetValue; + case MetaCoreRuntimeUiValueType::GameObjectRef: return lhs.ObjectValue == rhs.ObjectValue; + } + return false; +} + [[nodiscard]] MetaCoreRuntimeUiValue ToUiValue(const MetaCoreRuntimeDataValue& value) { MetaCoreRuntimeUiValue result; result.Type = static_cast(value.Type); @@ -222,7 +323,9 @@ public: void RenderGeometry(Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation, Rml::TextureHandle texture) override { if (!Geometries_.contains(geometry)) return; - DrawCommands_.push_back({geometry, translation, texture, ScissorEnabled_, Scissor_}); + DrawCommand command{geometry, translation, texture, ScissorEnabled_, Scissor_}; + if (ActiveWorldInstance_ != 0) WorldDrawCommands_[ActiveWorldInstance_].push_back(command); + else DrawCommands_.push_back(command); } void ReleaseGeometry(Rml::CompiledGeometryHandle geometry) override { Geometries_.erase(geometry); } @@ -273,15 +376,25 @@ public: void EnableScissorRegion(bool enable) override { ScissorEnabled_ = enable; } void SetScissorRegion(Rml::Rectanglei region) override { Scissor_ = region; } - void BeginFrame() { DrawCommands_.clear(); } + void BeginFrame() { DrawCommands_.clear(); WorldDrawCommands_.clear(); ActiveWorldInstance_ = 0; } + void BeginWorld(std::uint64_t instanceId) { ActiveWorldInstance_ = instanceId; } + void EndWorld() { ActiveWorldInstance_ = 0; } - void Render(filament::Engine& engine, filament::Renderer& renderer, uint32_t width, uint32_t height) { + void Render(filament::Engine& engine, filament::Renderer& renderer, uint32_t width, uint32_t height, + filament::RenderTarget* renderTarget = nullptr) { if (width == 0 || height == 0 || DrawCommands_.empty()) return; EnsureFilamentResources(engine); if (Scene_ == nullptr || View_ == nullptr || Material_ == nullptr || Camera_ == nullptr) return; ClearFrameResources(); View_->setViewport({0, 0, width, height}); + View_->setRenderTarget(renderTarget); + if (renderTarget != nullptr) { + filament::Renderer::ClearOptions clear; + clear.clear = true; + clear.clearColor = {0.0F, 0.0F, 0.0F, 0.0F}; + renderer.setClearOptions(clear); + } Camera_->setProjection(filament::Camera::Projection::ORTHO, 0.0, static_cast(width), static_cast(height), 0.0, 0.0, 1.0); @@ -337,11 +450,56 @@ public: builder.build(engine, FrameRenderable_); Scene_->addEntity(FrameRenderable_); renderer.render(View_); + View_->setRenderTarget(nullptr); + } + + struct WorldSurface { + std::uint64_t InstanceId = 0; + std::uint64_t SceneObjectId = 0; + std::int32_t Width = 1; + std::int32_t Height = 1; + glm::vec3 WorldSize{1.0F}; + }; + + void RenderWorldSurfaces( + filament::Engine& engine, + filament::Renderer& renderer, + filament::Scene& mainScene, + const std::vector& surfaces, + const std::function& worldMatrixProvider + ) { + EnsureFilamentResources(engine); + std::unordered_set active; + for (const WorldSurface& surface : surfaces) { + const auto commands = WorldDrawCommands_.find(surface.InstanceId); + if (commands == WorldDrawCommands_.end() || commands->second.empty()) continue; + active.insert(surface.InstanceId); + WorldTarget& target = EnsureWorldTarget(engine, mainScene, surface); + std::swap(DrawCommands_, commands->second); + Render(engine, renderer, static_cast(surface.Width), + static_cast(surface.Height), target.Target); + std::swap(DrawCommands_, commands->second); + glm::mat4 world(1.0F); + if (worldMatrixProvider(surface.SceneObjectId, world) && target.SurfaceEntity) { + auto& transforms = engine.getTransformManager(); + const auto instance = transforms.getInstance(target.SurfaceEntity); + if (instance) transforms.setTransform(instance, + *reinterpret_cast(glm::value_ptr(world))); + } + } + for (auto iterator = WorldTargets_.begin(); iterator != WorldTargets_.end();) { + if (!active.contains(iterator->first)) { + DestroyWorldTarget(iterator->second); + iterator = WorldTargets_.erase(iterator); + } else ++iterator; + } } void Shutdown() { if (Engine_ == nullptr) return; ClearFrameResources(); + for (auto& [_, target] : WorldTargets_) DestroyWorldTarget(target); + WorldTargets_.clear(); for (auto& [_, texture] : Textures_) { if (texture.FilamentTexture != nullptr) Engine_->destroy(texture.FilamentTexture); } @@ -384,6 +542,19 @@ private: filament::Texture* FilamentTexture = nullptr; }; + struct WorldTarget { + filament::Texture* Color = nullptr; + filament::RenderTarget* Target = nullptr; + filament::VertexBuffer* VertexBuffer = nullptr; + filament::IndexBuffer* IndexBuffer = nullptr; + filament::MaterialInstance* MaterialInstance = nullptr; + filament::Scene* MainScene = nullptr; + utils::Entity SurfaceEntity{}; + std::int32_t Width = 0; + std::int32_t Height = 0; + glm::vec3 WorldSize{0.0F}; + }; + [[nodiscard]] Rml::TextureHandle StoreTexture(Texture texture) { const Rml::TextureHandle handle = NextTextureHandle_++; Textures_.emplace(handle, std::move(texture)); @@ -452,6 +623,84 @@ private: WhiteTexture_->setImage(*Engine_, 0, std::move(descriptor)); } + WorldTarget& EnsureWorldTarget(filament::Engine& engine, filament::Scene& mainScene, const WorldSurface& surface) { + auto& target = WorldTargets_[surface.InstanceId]; + if (target.Target != nullptr && (target.Width != surface.Width || target.Height != surface.Height || + target.WorldSize != surface.WorldSize || target.MainScene != &mainScene)) { + DestroyWorldTarget(target); + } + if (target.Target != nullptr) return target; + target.Width = surface.Width; + target.Height = surface.Height; + target.WorldSize = surface.WorldSize; + target.MainScene = &mainScene; + target.Color = filament::Texture::Builder() + .width(static_cast(surface.Width)) + .height(static_cast(surface.Height)) + .levels(1) + .format(filament::Texture::InternalFormat::RGBA8) + .usage(filament::Texture::Usage::COLOR_ATTACHMENT | filament::Texture::Usage::SAMPLEABLE) + .sampler(filament::Texture::Sampler::SAMPLER_2D) + .build(engine); + target.Target = filament::RenderTarget::Builder() + .texture(filament::RenderTarget::AttachmentPoint::COLOR, target.Color) + .build(engine); + const float halfWidth = std::max(0.001F, surface.WorldSize.x) * 0.5F; + const float halfHeight = std::max(0.001F, surface.WorldSize.y) * 0.5F; + const std::array vertices{{ + {{-halfWidth, -halfHeight}, {0.0F, 1.0F}, {255, 255, 255, 255}}, + {{ halfWidth, -halfHeight}, {1.0F, 1.0F}, {255, 255, 255, 255}}, + {{ halfWidth, halfHeight}, {1.0F, 0.0F}, {255, 255, 255, 255}}, + {{-halfWidth, halfHeight}, {0.0F, 0.0F}, {255, 255, 255, 255}} + }}; + const std::array indices{{0, 1, 2, 0, 2, 3}}; + target.VertexBuffer = filament::VertexBuffer::Builder().vertexCount(4).bufferCount(1) + .attribute(filament::VertexAttribute::POSITION, 0, filament::VertexBuffer::AttributeType::FLOAT2, 0, sizeof(GpuVertex)) + .attribute(filament::VertexAttribute::UV0, 0, filament::VertexBuffer::AttributeType::FLOAT2, sizeof(float) * 2, sizeof(GpuVertex)) + .attribute(filament::VertexAttribute::COLOR, 0, filament::VertexBuffer::AttributeType::UBYTE4, sizeof(float) * 4, sizeof(GpuVertex)) + .normalized(filament::VertexAttribute::COLOR).build(engine); + target.IndexBuffer = filament::IndexBuffer::Builder().indexCount(6) + .bufferType(filament::IndexBuffer::IndexType::UINT).build(engine); + auto* vertexCopy = new std::array(vertices); + auto* indexCopy = new std::array(indices); + target.VertexBuffer->setBufferAt(engine, 0, filament::VertexBuffer::BufferDescriptor( + vertexCopy, sizeof(*vertexCopy), [](void* buffer, std::size_t, void*) { + delete static_cast*>(buffer); + }, nullptr)); + target.IndexBuffer->setBuffer(engine, filament::IndexBuffer::BufferDescriptor( + indexCopy, sizeof(*indexCopy), [](void* buffer, std::size_t, void*) { + delete static_cast*>(buffer); + }, nullptr)); + target.MaterialInstance = Material_->createInstance(); + target.MaterialInstance->setParameter("albedo", target.Color, filament::TextureSampler( + filament::TextureSampler::MinFilter::LINEAR, filament::TextureSampler::MagFilter::LINEAR)); + target.SurfaceEntity = utils::EntityManager::get().create(); + filament::RenderableManager::Builder(1).boundingBox({{0.0F, 0.0F, 0.0F}, {halfWidth, halfHeight, 0.01F}}) + .culling(false).castShadows(false).receiveShadows(false) + .geometry(0, filament::RenderableManager::PrimitiveType::TRIANGLES, + target.VertexBuffer, target.IndexBuffer, 0, 6) + .material(0, target.MaterialInstance).build(engine, target.SurfaceEntity); + engine.getTransformManager().create(target.SurfaceEntity); + mainScene.addEntity(target.SurfaceEntity); + return target; + } + + void DestroyWorldTarget(WorldTarget& target) { + if (Engine_ == nullptr) return; + if (target.MainScene != nullptr && target.SurfaceEntity) target.MainScene->remove(target.SurfaceEntity); + if (target.SurfaceEntity) { + Engine_->getRenderableManager().destroy(target.SurfaceEntity); + Engine_->getTransformManager().destroy(target.SurfaceEntity); + utils::EntityManager::get().destroy(target.SurfaceEntity); + } + if (target.MaterialInstance != nullptr) Engine_->destroy(target.MaterialInstance); + if (target.VertexBuffer != nullptr) Engine_->destroy(target.VertexBuffer); + if (target.IndexBuffer != nullptr) Engine_->destroy(target.IndexBuffer); + if (target.Target != nullptr) Engine_->destroy(target.Target); + if (target.Color != nullptr) Engine_->destroy(target.Color); + target = {}; + } + [[nodiscard]] bool CreateFrameGeometry(filament::Engine& engine, const Geometry& geometry, Rml::Vector2f translation) { if (geometry.Vertices.empty() || geometry.Indices.empty()) return false; std::vector translatedVertices = geometry.Vertices; @@ -515,6 +764,9 @@ private: std::unordered_map Geometries_; std::unordered_map Textures_; std::vector DrawCommands_; + std::unordered_map> WorldDrawCommands_; + std::uint64_t ActiveWorldInstance_ = 0; + std::unordered_map WorldTargets_; Rml::CompiledGeometryHandle NextGeometryHandle_ = 1; Rml::TextureHandle NextTextureHandle_ = 1; bool ScissorEnabled_ = false; @@ -566,11 +818,28 @@ private: class MetaCoreRuntimeUiSystem::Impl { public: + struct Provider { + ProviderReadCallback Reader{}; + ProviderWriteCallback Writer{}; + }; + struct DocumentState { MetaCoreRuntimeUiDocumentEntry Entry{}; + Rml::Context* Context = nullptr; + std::string ContextName{}; Rml::ElementDocument* Document = nullptr; + std::string DefaultLocale{"en-US"}; + std::string FallbackLocale{"en-US"}; + std::unordered_map> Localization{}; + MetaCoreUiDocument CanvasSettings{}; }; + static std::pair SplitProviderPath(std::string_view path) { + const std::size_t separator = path.find('.'); + if (separator == std::string_view::npos) return {std::string(path), {}}; + return {std::string(path.substr(0, separator)), path.substr(separator + 1)}; + } + class EventListener final : public Rml::EventListener { public: EventListener(Impl& owner, std::string documentId) : Owner_(owner), DocumentId_(std::move(documentId)) {} @@ -593,22 +862,54 @@ public: uiEvent.Type = event.GetType(); uiEvent.Value = target->GetAttribute("value", ""); uiEvent.EventType = EventTypeFromRml(uiEvent.Type); + const std::string kind = target->GetAttribute("data-metacore-kind", ""); + if (uiEvent.EventType == MetaCoreRuntimeUiEventType::ValueChanged && kind == "list") { + uiEvent.EventType = MetaCoreRuntimeUiEventType::SelectionChanged; + if (const auto* select = dynamic_cast(target)) + uiEvent.Value = std::to_string(select->GetSelection()); + else uiEvent.Value = target->GetAttribute("data-metacore-selected-index", "-1"); + target->SetAttribute("data-metacore-selected-index", uiEvent.Value); + } uiEvent.InputSource = Owner_.CurrentInputSource_; uiEvent.Consumed = !event.IsPropagating(); - const std::string writePath = target->GetAttribute("data-metacore-bind-value", ""); - const std::string writeMode = target->GetAttribute("data-metacore-bind-mode-value", ""); - if (uiEvent.EventType == MetaCoreRuntimeUiEventType::ValueChanged && + const char* bindingTarget = uiEvent.EventType == MetaCoreRuntimeUiEventType::SelectionChanged + ? "selected-index" : "value"; + const std::string writePath = target->GetAttribute( + std::string("data-metacore-bind-") + bindingTarget, ""); + const std::string writeMode = target->GetAttribute( + std::string("data-metacore-bind-mode-") + bindingTarget, ""); + if ((uiEvent.EventType == MetaCoreRuntimeUiEventType::ValueChanged || + uiEvent.EventType == MetaCoreRuntimeUiEventType::SelectionChanged) && writeMode == "two-way" && !writePath.empty()) { uiEvent.Action = writePath; - if (!writePath.starts_with("script.") && !writePath.starts_with("scene.")) { - uiEvent.EventType = MetaCoreRuntimeUiEventType::WriteRequest; - } const auto previous = Owner_.Values_.find(writePath); - if (previous != Owner_.Values_.end()) uiEvent.OldValue = FormatUiValue(previous->second, {}); - MetaCoreRuntimeUiValue value; - value.Type = MetaCoreRuntimeUiValueType::String; - value.StringValue = uiEvent.Value; - Owner_.Values_[writePath] = std::move(value); + if (previous != Owner_.Values_.end()) { + uiEvent.OldValue = FormatUiValue(previous->second, {}); + uiEvent.OldTypedValue = previous->second; + } + const MetaCoreUiInputValueType inputType = + uiEvent.EventType == MetaCoreRuntimeUiEventType::SelectionChanged + ? MetaCoreUiInputValueType::Integer + : static_cast( + target->GetAttribute("data-metacore-input-type", 0)); + if (const auto value = ParseUiValue(uiEvent.Value, inputType); value.has_value()) { + uiEvent.NewTypedValue = *value; + const auto [providerName, relativePath] = Impl::SplitProviderPath(writePath); + const auto provider = Owner_.Providers_.find(providerName); + const bool providerKnown = provider != Owner_.Providers_.end(); + const bool providerWritable = providerKnown && static_cast(provider->second.Writer); + const bool writeSucceeded = providerWritable && provider->second.Writer(relativePath, *value); + if ((providerKnown && !writeSucceeded) || + (!providerKnown && !writePath.starts_with("script."))) { + uiEvent.EventType = MetaCoreRuntimeUiEventType::WriteRequest; + } else { + Owner_.Values_[writePath] = *value; + } + } else { + Owner_.Diagnostics_.push_back({ + DocumentId_, target->GetId(), writePath, "UI value failed basic type validation", true + }); + } } if (uiEvent.Action.empty() && uiEvent.EventType != MetaCoreRuntimeUiEventType::Focus && @@ -635,6 +936,8 @@ public: std::vector> Listeners_; std::unordered_map Values_; std::unordered_map> Lists_; + std::unordered_map Providers_; + std::unordered_set ProviderPaths_; std::vector Events_; std::vector Errors_; std::vector Diagnostics_; @@ -644,13 +947,133 @@ public: std::string Theme_{}; bool Initialized_ = false; + void RefreshProviders() { + std::vector changed; + for (const std::string& path : ProviderPaths_) { + const auto [providerName, relativePath] = SplitProviderPath(path); + const auto provider = Providers_.find(providerName); + if (provider == Providers_.end() || !provider->second.Reader) continue; + const auto value = provider->second.Reader(relativePath); + if (!value.has_value()) { + if (std::none_of(Diagnostics_.begin(), Diagnostics_.end(), [&](const MetaCoreUiDiagnostic& diagnostic) { + return diagnostic.BindingPath == path && diagnostic.Message == "Binding provider source is unavailable"; + })) Diagnostics_.push_back({{}, {}, path, "Binding provider source is unavailable", true}); + continue; + } + const auto previous = Values_.find(path); + if (previous == Values_.end() || !UiValuesEqual(previous->second, *value)) { + Values_[path] = *value; + changed.push_back(path); + } + } + for (const std::string& path : changed) ApplyBindings(&path); + } + [[nodiscard]] bool HasInputEnabledDocument() const { return std::any_of(Documents_.begin(), Documents_.end(), [](const DocumentState& state) { return state.Entry.Visible && state.Entry.InputEnabled && state.Document != nullptr; }); } + bool NavigateFocus(int direction, Rml::Context* restrictedContext = nullptr) { + struct Candidate { + std::int32_t Layer = 0; + int Order = 0; + std::size_t Sequence = 0; + Rml::Element* Element = nullptr; + }; + std::vector candidates; + std::size_t sequence = 0; + for (const DocumentState& state : Documents_) { + if (restrictedContext != nullptr && state.Context != restrictedContext) continue; + if (!state.Entry.Visible || !state.Entry.InputEnabled || state.Document == nullptr) continue; + Rml::ElementList elements; + state.Document->QuerySelectorAll(elements, "[tabindex]"); + for (Rml::Element* element : elements) { + if (!element->IsVisible(true) || element->HasAttribute("disabled")) continue; + candidates.push_back({ + state.Entry.Layer, + element->GetAttribute("tabindex", 100000), + sequence++, + element + }); + } + } + if (candidates.empty()) { + for (const DocumentState& state : Documents_) + if (state.Context != nullptr && state.Context->GetFocusElement() != nullptr) + state.Context->GetFocusElement()->Blur(); + return false; + } + std::stable_sort(candidates.begin(), candidates.end(), [](const Candidate& lhs, const Candidate& rhs) { + if (lhs.Layer != rhs.Layer) return lhs.Layer > rhs.Layer; + if (lhs.Order != rhs.Order) return lhs.Order < rhs.Order; + return lhs.Sequence < rhs.Sequence; + }); + Rml::Element* focused = nullptr; + for (const DocumentState& state : Documents_) + if (state.Context != nullptr && state.Context->GetFocusElement() != nullptr) + focused = state.Context->GetFocusElement(); + auto current = std::find_if(candidates.begin(), candidates.end(), [&](const Candidate& value) { + return value.Element == focused; + }); + std::ptrdiff_t index = current == candidates.end() ? (direction > 0 ? -1 : 0) : + std::distance(candidates.begin(), current); + index = (index + direction + static_cast(candidates.size())) % + static_cast(candidates.size()); + candidates[static_cast(index)].Element->Focus(); + return true; + } + + void ApplyLocalization(DocumentState& state) { + if (state.Document == nullptr || state.Localization.empty()) return; + const auto findText = [&](std::string_view key) -> std::optional { + for (const std::string* locale : {&Locale_, &state.FallbackLocale, &state.DefaultLocale}) { + const auto localeValues = state.Localization.find(*locale); + if (localeValues == state.Localization.end()) continue; + const auto value = localeValues->second.find(std::string(key)); + if (value != localeValues->second.end()) return value->second; + } + return std::nullopt; + }; + Rml::ElementList elements; + state.Document->QuerySelectorAll(elements, "[data-metacore-loc]"); + for (Rml::Element* element : elements) { + const std::string key = element->GetAttribute("data-metacore-loc", ""); + if (const auto value = findText(key); value.has_value()) element->SetInnerRML(EscapeRmlText(*value)); + else Diagnostics_.push_back({state.Entry.DocumentId, element->GetId(), key, + "Localization key cannot be resolved for locale " + Locale_, true}); + } + } + + void ApplyCanvasLayouts(int framebufferWidth, int framebufferHeight) { + if (Window_ == nullptr) return; + const auto [windowWidth, windowHeight] = Window_->GetWindowSize(); + const float dpiScaleX = static_cast(framebufferWidth) / static_cast(std::max(1, windowWidth)); + const float dpiScaleY = static_cast(framebufferHeight) / static_cast(std::max(1, windowHeight)); + for (DocumentState& state : Documents_) { + if (state.Document == nullptr) continue; + const float width = state.Entry.RenderMode == 1U && state.Entry.TargetWidth > 0 + ? static_cast(state.Entry.TargetWidth) : static_cast(framebufferWidth); + const float height = state.Entry.RenderMode == 1U && state.Entry.TargetHeight > 0 + ? static_cast(state.Entry.TargetHeight) : static_cast(framebufferHeight); + const MetaCoreUiCanvasLayout layout = MetaCoreComputeUiCanvasLayout(state.CanvasSettings, { + width, height, 96.0F * std::max(dpiScaleX, dpiScaleY), 0.0F, 0.0F, 0.0F, 0.0F + }); + Rml::Element* body = state.Document->QuerySelector("body"); + if (body == nullptr) continue; + body->SetProperty("position", "absolute"); + body->SetProperty("left", std::to_string(layout.ViewportLeft) + "px"); + body->SetProperty("top", std::to_string(layout.ViewportTop) + "px"); + body->SetProperty("width", std::to_string(layout.LogicalWidth) + "px"); + body->SetProperty("height", std::to_string(layout.LogicalHeight) + "px"); + body->SetProperty("transform-origin", "0 0"); + body->SetProperty("transform", "scale(" + std::to_string(layout.Scale) + ")"); + } + } + void ApplyBindings(const std::string* onlyPath = nullptr) { + bool focusInvalidated = false; for (DocumentState& state : Documents_) { if (state.Document == nullptr) continue; for (const std::string target : {"text", "value", "visible", "enabled", "selected-index"}) { @@ -667,14 +1090,39 @@ public: else element->SetAttribute(target == "enabled" ? "disabled" : target, fallback); continue; } + const std::string converter = element->GetAttribute( + "data-metacore-converter-" + target, ""); + const auto converted = ConvertUiValue(value->second, converter); + const bool targetTypeValid = converted.has_value() && + ((target != "visible" && target != "enabled") || + converted->Type == MetaCoreRuntimeUiValueType::Bool) && + (target != "selected-index" || + converted->Type == MetaCoreRuntimeUiValueType::Int64); + if (!targetTypeValid) { + const std::string message = "Binding conversion or target type is invalid"; + if (std::none_of(Diagnostics_.begin(), Diagnostics_.end(), [&](const MetaCoreUiDiagnostic& diagnostic) { + return diagnostic.Document == state.Entry.DocumentId && + diagnostic.NodeId == element->GetId() && diagnostic.BindingPath == path && + diagnostic.Message == message; + })) Diagnostics_.push_back({state.Entry.DocumentId, element->GetId(), path, message, true}); + continue; + } const std::string format = element->GetAttribute("data-metacore-format-" + target, ""); - const std::string text = FormatUiValue(value->second, format); + const std::string text = FormatUiValue(*converted, format); if (target == "text") element->SetInnerRML(EscapeRmlText(text)); else if (target == "value") element->SetAttribute("value", text); else if (target == "visible") { - if (value->second.BoolValue) element->RemoveAttribute("hidden"); else element->SetAttribute("hidden", "true"); + if (converted->BoolValue) element->RemoveAttribute("hidden"); else element->SetAttribute("hidden", "true"); } else if (target == "enabled") { - if (value->second.BoolValue) element->RemoveAttribute("disabled"); else element->SetAttribute("disabled", "true"); + if (converted->BoolValue) { + element->RemoveAttribute("disabled"); + } else { + element->SetAttribute("disabled", "true"); + if (state.Context != nullptr && state.Context->GetFocusElement() == element) { + element->Blur(); + focusInvalidated = true; + } + } } else element->SetAttribute("data-metacore-selected-index", text); } } @@ -689,6 +1137,7 @@ public: element->GetAttribute("data-metacore-format", "")))); } } + if (focusInvalidated) (void)NavigateFocus(1); } bool LoadEntry(const MetaCoreRuntimeUiDocumentEntry& entry) { @@ -701,16 +1150,98 @@ public: Errors_.push_back("Invalid UI manifest entry: " + entry.DocumentId); return false; } - Rml::ElementDocument* document = Context_->LoadDocument(absolutePath.string()); + DocumentState state; + state.Entry = entry; + state.Context = Context_; + if (entry.RenderMode == 1U) { + state.ContextName = "MetaCoreWorldUi-" + std::to_string(entry.InstanceId); + state.Context = Rml::CreateContext(state.ContextName, { + std::max(1, entry.TargetWidth), std::max(1, entry.TargetHeight) + }); + if (state.Context == nullptr) { + Errors_.push_back("Failed to create World Space RmlUi context: " + entry.DocumentId); + return false; + } + } + const std::filesystem::path documentDirectory = absolutePath.parent_path(); + const std::filesystem::path localizationPath = documentDirectory / "localization.json"; + if (std::filesystem::is_regular_file(localizationPath)) { + try { + std::ifstream input(localizationPath); + nlohmann::json root; + input >> root; + state.DefaultLocale = root.value("default", "en-US"); + state.FallbackLocale = root.value("fallback", state.DefaultLocale); + if (root.contains("locales") && root["locales"].is_object()) { + for (auto locale = root["locales"].begin(); locale != root["locales"].end(); ++locale) { + if (!locale.value().is_object()) continue; + auto& values = state.Localization[locale.key()]; + for (auto value = locale.value().begin(); value != locale.value().end(); ++value) + if (value.value().is_string()) values[value.key()] = value.value().get(); + } + } + } catch (...) { + Diagnostics_.push_back({entry.DocumentId, {}, {}, "Compiled localization data is invalid", true}); + return false; + } + } + const std::filesystem::path fontDirectory = documentDirectory / "Assets"; + std::error_code fontError; + if (std::filesystem::is_directory(fontDirectory, fontError)) { + std::vector fonts; + for (std::filesystem::directory_iterator iterator(fontDirectory, fontError), end; + !fontError && iterator != end; iterator.increment(fontError)) { + if (iterator->is_regular_file() && iterator->path().extension() == ".font") fonts.push_back(iterator->path()); + } + std::sort(fonts.begin(), fonts.end()); + bool fallback = false; + for (const auto& font : fonts) { + if (!Rml::LoadFontFace(font.string(), fallback)) { + Diagnostics_.push_back({entry.DocumentId, {}, {}, "Failed to load font: " + font.filename().string(), true}); + } + fallback = true; + } + } + Rml::ElementDocument* document = state.Context->LoadDocument(absolutePath.string()); if (document == nullptr) { Errors_.push_back("Failed to load RML: " + relativePath.generic_string()); + if (!state.ContextName.empty()) (void)Rml::RemoveContext(state.ContextName); return false; } + state.Document = document; + for (const char* target : {"text", "value", "visible", "enabled", "items", "selected-index"}) { + Rml::ElementList boundElements; + document->QuerySelectorAll(boundElements, std::string("[data-metacore-bind-") + target + "]"); + for (Rml::Element* element : boundElements) { + const std::string path = element->GetAttribute( + std::string("data-metacore-bind-") + target, ""); + if (!path.empty()) ProviderPaths_.insert(path); + } + } + if (Rml::Element* body = document->QuerySelector("body"); body != nullptr) { + const std::string reference = body->GetAttribute("data-metacore-reference", "1920x1080"); + const std::size_t separator = reference.find('x'); + if (separator != std::string::npos) { + state.CanvasSettings.ReferenceWidth = std::max(1, std::atoi(reference.substr(0, separator).c_str())); + state.CanvasSettings.ReferenceHeight = std::max(1, std::atoi(reference.substr(separator + 1).c_str())); + } + state.CanvasSettings.ScaleMode = static_cast( + body->GetAttribute("data-metacore-scale-mode", 1)); + state.CanvasSettings.MatchWidthOrHeight = body->GetAttribute("data-metacore-match", 0.5F); + state.CanvasSettings.ReferenceDpi = body->GetAttribute("data-metacore-reference-dpi", 96.0F); + state.CanvasSettings.MinScale = body->GetAttribute("data-metacore-min-scale", 0.5F); + state.CanvasSettings.MaxScale = body->GetAttribute("data-metacore-max-scale", 4.0F); + state.CanvasSettings.MinAspectRatio = body->GetAttribute("data-metacore-min-aspect", 0.0F); + state.CanvasSettings.MaxAspectRatio = body->GetAttribute("data-metacore-max-aspect", 0.0F); + state.CanvasSettings.UseSafeArea = + body->GetAttribute("data-metacore-safe-area", "true") == "true"; + } if (entry.Visible) document->Show(); else document->Hide(); Listeners_.push_back(std::make_unique(*this, entry.DocumentId)); for (const char* type : {"mouseover", "mouseout", "click", "submit", "change", "scroll", "focus", "blur"}) document->AddEventListener(type, Listeners_.back().get()); - Documents_.push_back({entry, document}); + Documents_.push_back(std::move(state)); + ApplyLocalization(Documents_.back()); return true; } }; @@ -761,12 +1292,32 @@ void MetaCoreRuntimeUiSystem::AttachToViewportRenderer(MetaCoreEditorViewportRen viewportRenderer.SetRuntimeOverlayRenderCallback([this](filament::Engine& engine, filament::Renderer& renderer, uint32_t width, uint32_t height) { if (Impl_ && Impl_->Initialized_) Impl_->RenderInterface_.Render(engine, renderer, width, height); }); + viewportRenderer.SetRuntimeWorldUiRenderCallback([this, &viewportRenderer]( + filament::Engine& engine, filament::Renderer& renderer, filament::Scene& scene) { + if (!Impl_ || !Impl_->Initialized_) return; + std::vector surfaces; + for (const auto& state : Impl_->Documents_) { + if (state.Entry.RenderMode != 1U || !state.Entry.Visible || state.Document == nullptr) continue; + surfaces.push_back({ + state.Entry.InstanceId, + state.Entry.SceneObjectId, + std::max(1, state.Entry.TargetWidth), + std::max(1, state.Entry.TargetHeight), + state.Entry.WorldSize + }); + } + Impl_->RenderInterface_.RenderWorldSurfaces(engine, renderer, scene, surfaces, + [&viewportRenderer](std::uint64_t objectId, glm::mat4& matrix) { + return viewportRenderer.TryGetObjectWorldMatrix(objectId, matrix); + }); + }); } void MetaCoreRuntimeUiSystem::Shutdown() { if (!Impl_) return; if (Impl_->ViewportRenderer_ != nullptr) { Impl_->ViewportRenderer_->SetRuntimeOverlayRenderCallback({}); + Impl_->ViewportRenderer_->SetRuntimeWorldUiRenderCallback({}); Impl_->ViewportRenderer_ = nullptr; } if (Impl_->Initialized_) Rml::Shutdown(); @@ -778,16 +1329,32 @@ void MetaCoreRuntimeUiSystem::Shutdown() { Impl_->SystemInterface_.reset(); Impl_->Values_.clear(); Impl_->Lists_.clear(); + Impl_->Providers_.clear(); + Impl_->ProviderPaths_.clear(); Impl_->Diagnostics_.clear(); Impl_->Initialized_ = false; } void MetaCoreRuntimeUiSystem::Update(float) { if (!Impl_->Initialized_ || Impl_->Context_ == nullptr || Impl_->Window_ == nullptr) return; + Impl_->RefreshProviders(); const auto [width, height] = Impl_->Window_->GetFramebufferSize(); Impl_->Context_->SetDimensions({width, height}); + Impl_->ApplyCanvasLayouts(width, height); Impl_->RenderInterface_.BeginFrame(); Impl_->Context_->Update(); + for (auto& worldState : Impl_->Documents_) { + if (worldState.Entry.RenderMode != 1U || !worldState.Entry.Visible || + worldState.Document == nullptr || worldState.Context == nullptr) continue; + worldState.Context->SetDimensions({ + std::max(1, worldState.Entry.TargetWidth), + std::max(1, worldState.Entry.TargetHeight) + }); + worldState.Context->Update(); + Impl_->RenderInterface_.BeginWorld(worldState.Entry.InstanceId); + worldState.Context->Render(); + Impl_->RenderInterface_.EndWorld(); + } Impl_->Context_->Render(); } @@ -795,30 +1362,99 @@ MetaCoreInputConsumption MetaCoreRuntimeUiSystem::ProcessInput() { MetaCoreInputConsumption consumed; if (!Impl_->Initialized_ || Impl_->Context_ == nullptr || Impl_->Window_ == nullptr || !Impl_->HasInputEnabledDocument()) return consumed; MetaCoreInput& input = Impl_->Window_->GetInput(); + Rml::Context* keyboardContext = Impl_->Context_; + for (const auto& state : Impl_->Documents_) + if (state.Entry.RenderMode == 1U && state.Context != nullptr && + state.Context->GetFocusElement() != nullptr) keyboardContext = state.Context; const glm::vec2 cursor = input.GetCursorPosition(); Impl_->CurrentInputSource_ = MetaCoreRuntimeUiInputSource::Mouse; - consumed.Mouse = !Impl_->Context_->ProcessMouseMove(static_cast(cursor.x), static_cast(cursor.y), 0); + const bool hasScreenUi = std::any_of(Impl_->Documents_.begin(), Impl_->Documents_.end(), [](const auto& state) { + return state.Entry.RenderMode == 0U && state.Entry.Visible && state.Entry.InputEnabled && state.Document != nullptr; + }); + if (hasScreenUi) consumed.Mouse = !Impl_->Context_->ProcessMouseMove(static_cast(cursor.x), static_cast(cursor.y), 0); for (int button = 0; button < 3; ++button) { const auto mouseButton = static_cast(button); - if (input.WasMouseButtonPressed(mouseButton)) consumed.Mouse = !Impl_->Context_->ProcessMouseButtonDown(button, 0) || consumed.Mouse; - if (input.WasMouseButtonReleased(mouseButton)) consumed.Mouse = !Impl_->Context_->ProcessMouseButtonUp(button, 0) || consumed.Mouse; + if (hasScreenUi && input.WasMouseButtonPressed(mouseButton)) consumed.Mouse = !Impl_->Context_->ProcessMouseButtonDown(button, 0) || consumed.Mouse; + if (hasScreenUi && input.WasMouseButtonReleased(mouseButton)) consumed.Mouse = !Impl_->Context_->ProcessMouseButtonUp(button, 0) || consumed.Mouse; } - if (std::abs(input.GetMouseWheelDelta()) > 0.001F) consumed.Mouse = !Impl_->Context_->ProcessMouseWheel(input.GetMouseWheelDelta(), 0) || consumed.Mouse; + if (hasScreenUi && std::abs(input.GetMouseWheelDelta()) > 0.001F) consumed.Mouse = !Impl_->Context_->ProcessMouseWheel(input.GetMouseWheelDelta(), 0) || consumed.Mouse; for (const MetaCoreKeyEvent& event : input.GetKeyEvents()) { Impl_->CurrentInputSource_ = MetaCoreRuntimeUiInputSource::Keyboard; + if (event.Pressed && event.Key == GLFW_KEY_TAB) { + consumed.Keyboard = Impl_->NavigateFocus((event.Modifiers & GLFW_MOD_SHIFT) != 0 ? -1 : 1, keyboardContext) || consumed.Keyboard; + continue; + } + if (event.Pressed && event.Key == GLFW_KEY_ESCAPE && keyboardContext->GetFocusElement() != nullptr) { + keyboardContext->GetFocusElement()->Blur(); + consumed.Keyboard = true; + continue; + } + if (event.Pressed && (event.Key == GLFW_KEY_LEFT || event.Key == GLFW_KEY_UP || + event.Key == GLFW_KEY_RIGHT || event.Key == GLFW_KEY_DOWN)) { + Rml::Element* focus = keyboardContext->GetFocusElement(); + const std::string kind = focus != nullptr + ? focus->GetAttribute("data-metacore-kind", "") : ""; + if (kind != "input") { + const int direction = event.Key == GLFW_KEY_LEFT || event.Key == GLFW_KEY_UP ? -1 : 1; + consumed.Keyboard = Impl_->NavigateFocus(direction, keyboardContext) || consumed.Keyboard; + continue; + } + } const Rml::Input::KeyIdentifier key = MetaCoreMapGlfwKeyToRml(event.Key); const int modifiers = MetaCoreMapGlfwModifiersToRml(event.Modifiers); - const bool propagated = event.Pressed ? Impl_->Context_->ProcessKeyDown(key, modifiers) : Impl_->Context_->ProcessKeyUp(key, modifiers); + const bool propagated = event.Pressed ? keyboardContext->ProcessKeyDown(key, modifiers) : keyboardContext->ProcessKeyUp(key, modifiers); consumed.Keyboard = !propagated || consumed.Keyboard; } for (const char32_t codePoint : input.GetTextInput()) { Impl_->CurrentInputSource_ = MetaCoreRuntimeUiInputSource::Keyboard; - consumed.Keyboard = !Impl_->Context_->ProcessTextInput(static_cast(codePoint)) || consumed.Keyboard; + consumed.Keyboard = !keyboardContext->ProcessTextInput(static_cast(codePoint)) || consumed.Keyboard; } Impl_->CurrentInputSource_ = MetaCoreRuntimeUiInputSource::Programmatic; return consumed; } +bool MetaCoreRuntimeUiSystem::ProcessWorldPointer( + std::uint64_t sceneObjectId, + glm::vec2 surfaceUv, + MetaCoreRuntimeUiWorldPointerType type, + std::uint32_t button, + float scrollDelta +) { + if (!Impl_->Initialized_ || Impl_->Context_ == nullptr || Impl_->Window_ == nullptr || + sceneObjectId == 0 || surfaceUv.x < 0.0F || surfaceUv.y < 0.0F || + surfaceUv.x > 1.0F || surfaceUv.y > 1.0F) return false; + const auto state = std::max_element(Impl_->Documents_.begin(), Impl_->Documents_.end(), + [&](const auto& lhs, const auto& rhs) { + const bool lhsMatch = lhs.Entry.RenderMode == 1U && lhs.Entry.SceneObjectId == sceneObjectId && + lhs.Entry.Visible && lhs.Entry.InputEnabled && lhs.Document != nullptr; + const bool rhsMatch = rhs.Entry.RenderMode == 1U && rhs.Entry.SceneObjectId == sceneObjectId && + rhs.Entry.Visible && rhs.Entry.InputEnabled && rhs.Document != nullptr; + if (lhsMatch != rhsMatch) return !lhsMatch; + return lhs.Entry.Layer < rhs.Entry.Layer; + }); + if (state == Impl_->Documents_.end() || state->Entry.RenderMode != 1U || + state->Entry.SceneObjectId != sceneObjectId || !state->Entry.Visible || + !state->Entry.InputEnabled || state->Document == nullptr) return false; + const int width = std::max(1, state->Entry.TargetWidth); + const int height = std::max(1, state->Entry.TargetHeight); + const int x = std::clamp(static_cast(surfaceUv.x * static_cast(width)), 0, width - 1); + const int y = std::clamp(static_cast(surfaceUv.y * static_cast(height)), 0, height - 1); + Impl_->CurrentInputSource_ = MetaCoreRuntimeUiInputSource::WorldPointer; + if (state->Context == nullptr) return false; + state->Context->SetDimensions({width, height}); + bool propagated = state->Context->ProcessMouseMove(x, y, 0); + if (type == MetaCoreRuntimeUiWorldPointerType::Down) + propagated = state->Context->ProcessMouseButtonDown(static_cast(button), 0) && propagated; + else if (type == MetaCoreRuntimeUiWorldPointerType::Up) + propagated = state->Context->ProcessMouseButtonUp(static_cast(button), 0) && propagated; + else if (type == MetaCoreRuntimeUiWorldPointerType::Scroll) + propagated = state->Context->ProcessMouseWheel(scrollDelta, 0) && propagated; + else if (type == MetaCoreRuntimeUiWorldPointerType::Cancel) + propagated = state->Context->ProcessMouseLeave() && propagated; + Impl_->CurrentInputSource_ = MetaCoreRuntimeUiInputSource::Programmatic; + return !propagated; +} + void MetaCoreRuntimeUiSystem::ApplyDataUpdates(const std::vector& updates) { if (!Impl_->Initialized_) return; for (const MetaCoreRuntimeDataUpdate& update : updates) { @@ -850,25 +1486,56 @@ bool MetaCoreRuntimeUiSystem::SetListRows(const std::string& sourcePath, std::ve state.Document->QuerySelectorAll(elements, "[data-metacore-bind-items]"); for (Rml::Element* element : elements) { if (element->GetAttribute("data-metacore-bind-items", "") != sourcePath) continue; - std::ostringstream markup; + auto* select = dynamic_cast(element); + if (select == nullptr) continue; + select->RemoveAll(); std::size_t index = 0; for (const auto& row : Impl_->Lists_[sourcePath]) { - markup << "
"; + const auto value = row.find("value"); + const auto label = row.contains("label") ? row.find("label") : + (row.contains("text") ? row.find("text") : row.end()); std::vector keys; keys.reserve(row.size()); for (const auto& [key, _] : row) keys.push_back(key); std::sort(keys.begin(), keys.end()); - for (const std::string& key : keys) - markup << "" - << EscapeRmlText(FormatUiValue(row.at(key), {})) << ""; - markup << "
"; + std::ostringstream markup; + if (label != row.end()) { + markup << EscapeRmlText(FormatUiValue(label->second, {})); + } else { + bool first = true; + for (const std::string& key : keys) { + if (!first) markup << " | "; + first = false; + markup << EscapeRmlText(FormatUiValue(row.at(key), {})); + } + } + (void)select->Add(markup.str(), value == row.end() ? std::to_string(index) : + FormatUiValue(value->second, {})); + ++index; } - element->SetInnerRML(markup.str()); } } return true; } +bool MetaCoreRuntimeUiSystem::RegisterProvider( + std::string providerName, + ProviderReadCallback reader, + ProviderWriteCallback writer +) { + if (providerName.empty() || providerName.find('.') != std::string::npos || !reader) return false; + Impl_->Providers_.insert_or_assign(std::move(providerName), Impl::Provider{std::move(reader), std::move(writer)}); + return true; +} + +bool MetaCoreRuntimeUiSystem::UnregisterProvider(std::string_view providerName) { + return Impl_->Providers_.erase(std::string(providerName)) > 0; +} + +void MetaCoreRuntimeUiSystem::RefreshProviders() { + if (Impl_->Initialized_) Impl_->RefreshProviders(); +} + bool MetaCoreRuntimeUiSystem::RequestFocus(std::uint64_t instanceId, const std::string& elementId) { for (auto& state : Impl_->Documents_) { if (state.Entry.InstanceId != instanceId || state.Document == nullptr) continue; @@ -893,16 +1560,28 @@ bool MetaCoreRuntimeUiSystem::DestroyInstance(std::uint64_t instanceId) { return value.Entry.InstanceId == instanceId; }); if (found == Impl_->Documents_.end()) return false; + const bool ownedFocus = found->Context != nullptr && found->Context->GetFocusElement() != nullptr && + found->Context->GetFocusElement()->GetOwnerDocument() == found->Document; + if (ownedFocus) found->Context->GetFocusElement()->Blur(); if (found->Document != nullptr) found->Document->Close(); + if (!found->ContextName.empty()) (void)Rml::RemoveContext(found->ContextName); Impl_->Documents_.erase(found); + if (ownedFocus) (void)Impl_->NavigateFocus(1); return true; } bool MetaCoreRuntimeUiSystem::SetDocumentVisible(const std::string& documentId, bool visible) { for (Impl::DocumentState& state : Impl_->Documents_) { if (state.Entry.DocumentId != documentId || state.Document == nullptr) continue; - if (visible) state.Document->Show(); else state.Document->Hide(); + const bool ownedFocus = state.Context != nullptr && state.Context->GetFocusElement() != nullptr && + state.Context->GetFocusElement()->GetOwnerDocument() == state.Document; + if (visible) state.Document->Show(); + else { + if (ownedFocus) state.Context->GetFocusElement()->Blur(); + state.Document->Hide(); + } state.Entry.Visible = visible; + if (!visible && ownedFocus) (void)Impl_->NavigateFocus(1); return true; } return false; @@ -911,7 +1590,11 @@ bool MetaCoreRuntimeUiSystem::SetDocumentVisible(const std::string& documentId, void MetaCoreRuntimeUiSystem::SetLocale(std::string locale) { if (locale.empty()) return; Impl_->Locale_ = std::move(locale); - for (auto& state : Impl_->Documents_) if (state.Document != nullptr) state.Document->SetAttribute("data-metacore-locale", Impl_->Locale_); + for (auto& state : Impl_->Documents_) { + if (state.Document == nullptr) continue; + state.Document->SetAttribute("data-metacore-locale", Impl_->Locale_); + Impl_->ApplyLocalization(state); + } } void MetaCoreRuntimeUiSystem::SetTheme(std::string theme) { diff --git a/Source/MetaCoreRuntimeUi/Private/MetaCoreUiCompiler.cpp b/Source/MetaCoreRuntimeUi/Private/MetaCoreUiCompiler.cpp index 782df49..57cba1c 100644 --- a/Source/MetaCoreRuntimeUi/Private/MetaCoreUiCompiler.cpp +++ b/Source/MetaCoreRuntimeUi/Private/MetaCoreUiCompiler.cpp @@ -1,12 +1,14 @@ #include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h" #include +#include #include #include #include #include #include #include +#include namespace MetaCore { namespace { @@ -27,6 +29,31 @@ std::string Escape(std::string_view value) { return result; } +std::string EscapeJson(std::string_view value) { + std::string result; + result.reserve(value.size()); + for (const unsigned char c : value) { + switch (c) { + case '\\': result += "\\\\"; break; + case '"': result += "\\\""; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (c < 0x20U) { + constexpr char digits[] = "0123456789abcdef"; + result += "\\u00"; + result += digits[c >> 4U]; + result += digits[c & 0x0fU]; + } else { + result += static_cast(c); + } + break; + } + } + return result; +} + const char* NodeClass(MetaCoreUiNodeType type) { switch (type) { case MetaCoreUiNodeType::Panel: return "panel"; @@ -65,8 +92,265 @@ bool IsBindingPathValid(std::string_view path) { return path.starts_with("runtime.") || path.starts_with("script.") || path.starts_with("scene."); } +const char* EasingName(MetaCoreUiEasing easing) { + switch (easing) { + case MetaCoreUiEasing::Linear: return "linear"; + case MetaCoreUiEasing::EaseIn: return "ease-in"; + case MetaCoreUiEasing::EaseOut: return "ease-out"; + case MetaCoreUiEasing::EaseInOut: return "ease-in-out"; + } + return "linear"; +} + +const char* ThemeStateSelector(MetaCoreUiVisualState state) { + switch (state) { + case MetaCoreUiVisualState::Normal: return ""; + case MetaCoreUiVisualState::Hover: return ":hover"; + case MetaCoreUiVisualState::Pressed: return ":active"; + case MetaCoreUiVisualState::Focused: return ":focus"; + case MetaCoreUiVisualState::Disabled: return ":disabled"; + } + return ""; +} + +std::string AnimationValue(MetaCoreUiAnimationProperty property, const glm::vec3& value) { + std::ostringstream out; + switch (property) { + case MetaCoreUiAnimationProperty::Opacity: + out << std::clamp(value.x, 0.0F, 1.0F); + break; + case MetaCoreUiAnimationProperty::Translation: + out << "translate(" << value.x << "px," << value.y << "px)"; + break; + case MetaCoreUiAnimationProperty::Scale: + out << "scale(" << value.x << ',' << value.y << ')'; + break; + case MetaCoreUiAnimationProperty::Color: + return Color(value); + } + return out.str(); +} + +const char* AnimationPropertyName(MetaCoreUiAnimationProperty property) { + switch (property) { + case MetaCoreUiAnimationProperty::Opacity: return "opacity"; + case MetaCoreUiAnimationProperty::Translation: + case MetaCoreUiAnimationProperty::Scale: return "transform"; + case MetaCoreUiAnimationProperty::Color: return "color"; + } + return "opacity"; +} + +std::string Trim(std::string_view value) { + std::size_t first = 0; + while (first < value.size() && std::isspace(static_cast(value[first]))) ++first; + std::size_t last = value.size(); + while (last > first && std::isspace(static_cast(value[last - 1]))) --last; + return std::string(value.substr(first, last - first)); +} + +std::string Lower(std::string_view value) { + std::string result(value); + std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return result; +} + +std::string DecodeLegacyText(std::string_view value) { + std::string result(value); + const std::pair entities[] = { + {"<", "<"}, {">", ">"}, {""", "\""}, {"'", "'"}, {"&", "&"} + }; + for (const auto& [encoded, decoded] : entities) { + std::size_t position = 0; + while ((position = result.find(encoded, position)) != std::string::npos) { + result.replace(position, encoded.size(), decoded); + position += decoded.size(); + } + } + return result; +} + +std::string LegacyAttribute(std::string_view tag, std::string_view requestedName) { + const std::string name = Lower(requestedName); + std::size_t position = 0; + while (position < tag.size()) { + while (position < tag.size() && + (std::isspace(static_cast(tag[position])) || tag[position] == '<')) ++position; + const std::size_t keyStart = position; + while (position < tag.size() && + (std::isalnum(static_cast(tag[position])) || tag[position] == '-' || + tag[position] == '_' || tag[position] == ':')) ++position; + if (position == keyStart) { + ++position; + continue; + } + const std::string key = Lower(tag.substr(keyStart, position - keyStart)); + while (position < tag.size() && std::isspace(static_cast(tag[position]))) ++position; + if (position >= tag.size() || tag[position] != '=') continue; + ++position; + while (position < tag.size() && std::isspace(static_cast(tag[position]))) ++position; + if (position >= tag.size()) break; + const char quote = tag[position]; + if (quote != '"' && quote != '\'') continue; + const std::size_t valueStart = ++position; + const std::size_t valueEnd = tag.find(quote, valueStart); + if (valueEnd == std::string_view::npos) break; + if (key == name) return DecodeLegacyText(tag.substr(valueStart, valueEnd - valueStart)); + position = valueEnd + 1; + } + return {}; +} + +std::optional LegacyNodeType(std::string_view tag, std::string_view fullTag) { + const std::string explicitType = Lower(LegacyAttribute(fullTag, "data-metacore-type")); + const std::string name = explicitType.empty() ? Lower(tag) : explicitType; + if (name == "div" || name == "section" || name == "panel") return MetaCoreUiNodeType::Panel; + if (name == "p" || name == "span" || name == "label" || name == "text") return MetaCoreUiNodeType::Text; + if (name == "img" || name == "image") return MetaCoreUiNodeType::Image; + if (name == "button") return MetaCoreUiNodeType::Button; + if (name == "input" || name == "textarea") return MetaCoreUiNodeType::Input; + if (name == "select" || name == "ul" || name == "ol" || name == "list") return MetaCoreUiNodeType::List; + if (name == "scroll") return MetaCoreUiNodeType::Scroll; + return std::nullopt; +} + } // namespace +MetaCoreLegacyUiImportResult MetaCoreImportLegacyUiDocument( + std::string_view rml, + std::string_view rcss, + std::string_view documentName +) { + MetaCoreLegacyUiImportResult result; + result.Document.Name = Trim(documentName).empty() ? "ImportedUi" : Trim(documentName); + result.Document.SchemaVersion = METACORE_UI_SCHEMA_VERSION; + result.Document.GeneratorVersion = METACORE_UI_GENERATOR_VERSION; + + MetaCoreUiNodeDocument root; + root.Id = "LegacyRoot"; + root.Name = "LegacyRoot"; + root.Type = MetaCoreUiNodeType::Panel; + root.RectTransform.AnchorMax = {1.0F, 1.0F, 0.0F}; + root.RectTransform.Size = {0.0F, 0.0F, 0.0F}; + result.Document.Nodes.push_back(root); + result.Document.RootNodeIds.push_back(root.Id); + + struct OpenNode { + std::string Tag{}; + std::string NodeId{}; + }; + std::vector stack{{"__root", root.Id}}; + std::unordered_set ids{root.Id}; + std::size_t generatedId = 0; + auto diagnostic = [&](std::string node, std::string message, bool error = false) { + result.Diagnostics.push_back({result.Document.Name, std::move(node), {}, std::move(message), error}); + }; + auto appendText = [&](std::string_view rawText) { + const std::string text = Trim(DecodeLegacyText(rawText)); + if (text.empty() || stack.size() <= 1) return; + const std::string& nodeId = stack.back().NodeId; + const auto node = std::find_if(result.Document.Nodes.begin(), result.Document.Nodes.end(), + [&](const MetaCoreUiNodeDocument& candidate) { return candidate.Id == nodeId; }); + if (node != result.Document.Nodes.end() && + (node->Type == MetaCoreUiNodeType::Text || node->Type == MetaCoreUiNodeType::Button || + node->Type == MetaCoreUiNodeType::Input)) { + if (!node->Text.empty()) node->Text += ' '; + node->Text += text; + } + }; + + std::size_t position = 0; + while (position < rml.size()) { + const std::size_t tagStart = rml.find('<', position); + if (tagStart == std::string_view::npos) { + appendText(rml.substr(position)); + break; + } + appendText(rml.substr(position, tagStart - position)); + if (rml.substr(tagStart).starts_with("", tagStart + 4); + if (commentEnd == std::string_view::npos) { + diagnostic({}, "Legacy RML contains an unterminated comment", true); + break; + } + position = commentEnd + 3; + continue; + } + const std::size_t tagEnd = rml.find('>', tagStart + 1); + if (tagEnd == std::string_view::npos) { + diagnostic({}, "Legacy RML contains an unterminated tag", true); + break; + } + const std::string_view fullTag = rml.substr(tagStart, tagEnd - tagStart + 1); + std::string_view body = fullTag.substr(1, fullTag.size() - 2); + const bool closing = !body.empty() && body.front() == '/'; + if (closing) body.remove_prefix(1); + body = std::string_view(body.data(), body.size()); + std::size_t nameLength = 0; + while (nameLength < body.size() && + (std::isalnum(static_cast(body[nameLength])) || body[nameLength] == '-')) ++nameLength; + const std::string tagName = Lower(body.substr(0, nameLength)); + const bool selfClosing = (!body.empty() && body.back() == '/') || + tagName == "input" || tagName == "img" || tagName == "br" || tagName == "link" || tagName == "meta"; + position = tagEnd + 1; + if (tagName.empty() || tagName == "!doctype" || tagName == "rml" || tagName == "html" || + tagName == "head" || tagName == "body" || tagName == "title" || tagName == "style" || + tagName == "link" || tagName == "meta") { + continue; + } + if (closing) { + const auto open = std::find_if(stack.rbegin(), stack.rend(), + [&](const OpenNode& candidate) { return candidate.Tag == tagName; }); + if (open == stack.rend()) { + diagnostic({}, "Ignored unmatched closing tag "); + } else { + stack.erase(open.base() - 1, stack.end()); + } + continue; + } + const std::optional type = LegacyNodeType(tagName, fullTag); + if (!type.has_value()) { + diagnostic({}, "Unsupported legacy tag <" + tagName + "> was skipped"); + continue; + } + + std::string id = LegacyAttribute(fullTag, "id"); + if (id.empty() || ids.contains(id)) { + if (!id.empty()) diagnostic(id, "Duplicate legacy id was replaced with a deterministic generated id"); + do id = "LegacyNode" + std::to_string(++generatedId); while (ids.contains(id)); + } + ids.insert(id); + MetaCoreUiNodeDocument node; + node.Id = id; + node.Name = LegacyAttribute(fullTag, "name"); + if (node.Name.empty()) node.Name = id; + node.ParentId = stack.back().NodeId; + node.Type = *type; + node.Placeholder = LegacyAttribute(fullTag, "placeholder"); + node.Interactable = *type == MetaCoreUiNodeType::Button || *type == MetaCoreUiNodeType::Input || + *type == MetaCoreUiNodeType::List || *type == MetaCoreUiNodeType::Scroll; + if (*type == MetaCoreUiNodeType::Image) { + const std::string source = LegacyAttribute(fullTag, "src"); + if (!source.empty()) diagnostic(id, "Image src '" + source + "' requires manual Asset GUID assignment"); + } + const auto parent = std::find_if(result.Document.Nodes.begin(), result.Document.Nodes.end(), + [&](const MetaCoreUiNodeDocument& candidate) { return candidate.Id == node.ParentId; }); + if (parent != result.Document.Nodes.end()) parent->Children.push_back(id); + result.Document.Nodes.push_back(std::move(node)); + if (!selfClosing) stack.push_back({tagName, id}); + } + if (!rcss.empty()) { + diagnostic({}, "Legacy RCSS was diagnosed but not copied; migrate declarations to UiTheme tokens and node styles"); + } + const std::vector validation = MetaCoreValidateUiDocument(result.Document); + result.Diagnostics.insert(result.Diagnostics.end(), validation.begin(), validation.end()); + result.Succeeded = std::none_of(result.Diagnostics.begin(), result.Diagnostics.end(), + [](const MetaCoreUiDiagnostic& item) { return item.IsError; }); + return result; +} + MetaCoreUiDocument MetaCoreMigrateUiDocument(MetaCoreUiDocument document) { if (document.SchemaVersion >= METACORE_UI_SCHEMA_VERSION) { return document; @@ -100,6 +384,10 @@ std::vector MetaCoreValidateUiDocument(const MetaCoreUiDoc issue({}, {}, "MatchWidthOrHeight must be in [0, 1]"); if (document.ReferenceDpi <= 0.0F || document.MinScale <= 0.0F || document.MaxScale < document.MinScale) issue({}, {}, "Canvas DPI or scale limits are invalid"); + if (document.MinAspectRatio < 0.0F || document.MaxAspectRatio < 0.0F || + (document.MinAspectRatio > 0.0F && document.MaxAspectRatio > 0.0F && + document.MaxAspectRatio < document.MinAspectRatio)) + issue({}, {}, "Canvas aspect ratio limits are invalid"); std::unordered_map nodes; for (const MetaCoreUiNodeDocument& node : document.Nodes) { @@ -110,8 +398,28 @@ std::vector MetaCoreValidateUiDocument(const MetaCoreUiDoc node.RectTransform.MaxSize.y < node.RectTransform.MinSize.y) issue(node.Id, {}, "Node minimum/maximum size is invalid"); if (node.Type == MetaCoreUiNodeType::Input && node.MaxLength < 0) issue(node.Id, {}, "Input MaxLength cannot be negative"); + if (node.Type != MetaCoreUiNodeType::List && (!node.StaticItems.empty() || !node.ItemTemplateNodeId.empty())) + issue(node.Id, {}, "Static items and row templates are only valid on List nodes"); + std::unordered_set listItemIds; + for (const MetaCoreUiListItemDocument& item : node.StaticItems) { + if (item.Id.empty()) issue(node.Id, {}, "Static list item requires a stable ID"); + else if (!listItemIds.insert(item.Id).second) issue(node.Id, {}, "Duplicate static list item ID"); + } + std::unordered_set animationIds; + for (const MetaCoreUiAnimationDocument& animation : node.Animations) { + if (animation.Id.empty()) issue(node.Id, {}, "Animation requires a stable ID"); + else if (!animationIds.insert(animation.Id).second) issue(node.Id, {}, "Duplicate animation ID"); + if (!std::isfinite(animation.DurationSeconds) || animation.DurationSeconds < 0.0F || + !std::isfinite(animation.DelaySeconds) || animation.DelaySeconds < 0.0F) + issue(node.Id, {}, "Animation duration and delay must be finite and non-negative"); + } for (const MetaCoreUiBindingDocument& binding : node.Bindings) { if (!IsBindingPathValid(binding.SourcePath)) issue(node.Id, binding.SourcePath, "Binding path must use runtime., script. or scene."); + static const std::unordered_set converters{ + "", "not", "to_string", "uppercase", "lowercase", "to_bool", "to_int", "to_double" + }; + if (!converters.contains(binding.Converter)) + issue(node.Id, binding.SourcePath, "Binding converter is not supported: " + binding.Converter); if (binding.Mode == MetaCoreUiBindingMode::TwoWay && binding.Target != MetaCoreUiBindingTarget::Value && binding.Target != MetaCoreUiBindingTarget::SelectedIndex) @@ -122,6 +430,9 @@ std::vector MetaCoreValidateUiDocument(const MetaCoreUiDoc if (!nodes.contains(root)) issue(root, {}, "Root node does not exist"); else if (!nodes.at(root)->ParentId.empty()) issue(root, {}, "Root node has a parent"); } + for (const auto& node : document.Nodes) + if (!node.ItemTemplateNodeId.empty() && !nodes.contains(node.ItemTemplateNodeId)) + issue(node.Id, {}, "List row template node does not exist"); for (const MetaCoreUiNodeDocument& node : document.Nodes) { if (!node.ParentId.empty() && !nodes.contains(node.ParentId)) issue(node.Id, {}, "Parent node does not exist"); std::unordered_set children; @@ -152,9 +463,48 @@ std::vector MetaCoreValidateUiDocument(const MetaCoreUiDoc } MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& source) { + return MetaCoreCompileUiDocument(source, {}); +} + +MetaCoreCompiledUiDocument MetaCoreCompileUiDocument( + const MetaCoreUiDocument& source, + const MetaCoreUiCompileResources& resources +) { MetaCoreCompiledUiDocument result; const MetaCoreUiDocument document = MetaCoreMigrateUiDocument(source); result.Diagnostics = MetaCoreValidateUiDocument(document); + const auto resourceIssue = [&](std::string node, std::string message) { + result.Diagnostics.push_back({document.Name, std::move(node), {}, std::move(message), true}); + }; + if (resources.Theme.has_value()) { + std::unordered_set stateKeys; + for (const auto& style : resources.Theme->Styles) { + const std::string key = style.Token + ':' + std::to_string(static_cast(style.State)); + if (style.Token.empty()) resourceIssue({}, "Theme style requires a token"); + else if (!stateKeys.insert(key).second) resourceIssue({}, "Duplicate theme token state: " + key); + } + } + if (resources.FontFamily.has_value()) { + for (const auto& face : resources.FontFamily->Faces) + if (!face.FontAssetGuid.IsValid()) resourceIssue({}, "Font face requires an asset GUID"); + } + if (resources.Localization.has_value()) { + std::unordered_set localeIds; + for (const auto& locale : resources.Localization->Locales) { + if (locale.Locale.empty() || !localeIds.insert(locale.Locale).second) + resourceIssue({}, "Localization locale is empty or duplicated"); + std::unordered_set keys; + for (const auto& entry : locale.Entries) + if (entry.Key.empty() || !keys.insert(entry.Key).second) + resourceIssue({}, "Localization key is empty or duplicated in " + locale.Locale); + } + for (const auto& node : document.Nodes) { + if (!node.LocalizationKey.empty() && + !MetaCoreResolveUiLocalizedText(*resources.Localization, node.LocalizationKey, + document.DefaultLocale, document.FallbackLocale).has_value()) + resourceIssue(node.Id, "Localization key cannot be resolved: " + node.LocalizationKey); + } + } if (std::any_of(result.Diagnostics.begin(), result.Diagnostics.end(), [](const auto& value) { return value.IsError; })) return result; std::unordered_map nodes; @@ -164,6 +514,9 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s } for (MetaCoreAssetGuid guid : {document.ThemeAssetGuid, document.FontFamilyAssetGuid, document.LocalizationAssetGuid}) if (guid.IsValid()) result.Dependencies.push_back(guid); + if (resources.FontFamily.has_value()) + for (const auto& face : resources.FontFamily->Faces) + if (face.FontAssetGuid.IsValid()) result.Dependencies.push_back(face.FontAssetGuid); std::sort(result.Dependencies.begin(), result.Dependencies.end(), [](const auto& a, const auto& b) { return a.ToString() < b.ToString(); }); result.Dependencies.erase(std::unique(result.Dependencies.begin(), result.Dependencies.end()), result.Dependencies.end()); @@ -174,20 +527,44 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s << " data-metacore-reference=\"" << document.ReferenceWidth << 'x' << document.ReferenceHeight << "\"" << " data-metacore-match=\"" << document.MatchWidthOrHeight << "\"" << " data-metacore-reference-dpi=\"" << document.ReferenceDpi << "\"" - << " data-metacore-safe-area=\"" << (document.UseSafeArea ? "true" : "false") << "\">\n"; + << " data-metacore-min-scale=\"" << document.MinScale << "\"" + << " data-metacore-max-scale=\"" << document.MaxScale << "\"" + << " data-metacore-min-aspect=\"" << document.MinAspectRatio << "\"" + << " data-metacore-max-aspect=\"" << document.MaxAspectRatio << "\"" + << " data-metacore-safe-area=\"" << (document.UseSafeArea ? "true" : "false") << "\""; + if (resources.Theme.has_value()) rml << " data-metacore-theme=\"" << Escape(resources.Theme->Name) << "\""; + rml << ">\n"; + if (resources.FontFamily.has_value()) { + std::string requiredCharacters; + for (const auto& face : resources.FontFamily->Faces) requiredCharacters += face.RequiredCharacters; + if (!requiredCharacters.empty()) + rml << " " << Escape(requiredCharacters) << "\n"; + } + std::int32_t documentOrder = 0; std::function emit = [&](const std::string& id, int depth) { const MetaCoreUiNodeDocument& node = *nodes.at(id); + const std::int32_t currentDocumentOrder = documentOrder++; const std::string indent(static_cast(depth), ' '); const char* tag = "div"; if (node.Type == MetaCoreUiNodeType::Button) tag = "button"; else if (node.Type == MetaCoreUiNodeType::Input) tag = "input"; - else if (node.Type == MetaCoreUiNodeType::Image) tag = "img"; + else if (node.Type == MetaCoreUiNodeType::List) tag = "select"; + else if (node.Type == MetaCoreUiNodeType::Image) { + const bool isAtlased = std::any_of(resources.AtlasEntries.begin(), resources.AtlasEntries.end(), + [&](const MetaCoreUiCompileResources::AtlasEntry& entry) { + return entry.AssetGuid == node.Style.ImageAssetGuid; + }); + tag = isAtlased ? "div" : "img"; + } rml << indent << '<' << tag << " id=\"" << Escape(node.Id) << "\" class=\"mcui mcui-" << NodeClass(node.Type); if (!node.Style.ThemeToken.empty()) rml << " theme-" << Escape(node.Style.ThemeToken); rml << "\" data-metacore-kind=\"" << NodeClass(node.Type) << "\""; if (!node.Action.empty()) rml << " data-metacore-action=\"" << Escape(node.Action) << "\""; if (node.NavigationOrder >= 0) rml << " tabindex=\"" << node.NavigationOrder << "\""; + else if (node.Type == MetaCoreUiNodeType::Button || node.Type == MetaCoreUiNodeType::Input || + node.Type == MetaCoreUiNodeType::List) + rml << " tabindex=\"" << (100000 + currentDocumentOrder) << "\""; if (!node.LocalizationKey.empty()) rml << " data-metacore-loc=\"" << Escape(node.LocalizationKey) << "\""; if (!node.ShowAnimation.empty()) rml << " data-metacore-show-animation=\"" << Escape(node.ShowAnimation) << "\""; if (!node.HideAnimation.empty()) rml << " data-metacore-hide-animation=\"" << Escape(node.HideAnimation) << "\""; @@ -196,6 +573,7 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s << " data-metacore-bind-mode-" << BindingTargetName(binding.Target) << "=\"" << (binding.Mode == MetaCoreUiBindingMode::TwoWay ? "two-way" : "one-way") << "\""; if (!binding.Format.empty()) rml << " data-metacore-format-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Format) << "\""; + if (!binding.Converter.empty()) rml << " data-metacore-converter-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Converter) << "\""; if (!binding.Fallback.empty()) rml << " data-metacore-fallback-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Fallback) << "\""; } if (node.Bindings.empty() && !node.DataBinding.empty()) { @@ -207,14 +585,47 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s rml << " value=\"" << Escape(node.Text) << "\" placeholder=\"" << Escape(node.Placeholder) << "\""; if (node.ReadOnly) rml << " readonly=\"true\""; if (node.MaxLength > 0) rml << " maxlength=\"" << node.MaxLength << "\""; - rml << " type=\"" << (node.InputValueType == MetaCoreUiInputValueType::Text ? "text" : "number") << "\"/>\n"; + rml << " data-metacore-input-type=\"" << static_cast(node.InputValueType) << "\"" + << " type=\"" << (node.InputValueType == MetaCoreUiInputValueType::Text ? "text" : "number") << "\""; + if (node.InputValueType == MetaCoreUiInputValueType::Integer) rml << " step=\"1\""; + else if (node.InputValueType == MetaCoreUiInputValueType::Number) rml << " step=\"any\""; + rml << "/>\n"; return; } - if (node.Type == MetaCoreUiNodeType::Image && node.Style.ImageAssetGuid.IsValid()) + if (node.Type == MetaCoreUiNodeType::Image && node.Style.ImageAssetGuid.IsValid() && + std::none_of(resources.AtlasEntries.begin(), resources.AtlasEntries.end(), + [&](const MetaCoreUiCompileResources::AtlasEntry& entry) { + return entry.AssetGuid == node.Style.ImageAssetGuid; + })) rml << " src=\"Assets/" << node.Style.ImageAssetGuid.ToString() << ".png\""; if (node.Type == MetaCoreUiNodeType::List) rml << " role=\"listbox\" data-metacore-selected-index=\"" << node.SelectedIndex << "\""; rml << '>'; - if (node.Type == MetaCoreUiNodeType::Text || node.Type == MetaCoreUiNodeType::Button) rml << Escape(node.Text); + if (node.Type == MetaCoreUiNodeType::Text || node.Type == MetaCoreUiNodeType::Button) { + std::string text = node.Text; + if (resources.Localization.has_value() && !node.LocalizationKey.empty()) { + text = MetaCoreResolveUiLocalizedText( + *resources.Localization, + node.LocalizationKey, + document.DefaultLocale, + document.FallbackLocale + ).value_or(node.Text); + } + rml << Escape(text); + } + if (node.Type == MetaCoreUiNodeType::List) { + for (std::size_t index = 0; index < node.StaticItems.size(); ++index) { + const auto& item = node.StaticItems[index]; + std::string text = item.Text; + if (resources.Localization.has_value() && !item.LocalizationKey.empty()) + text = MetaCoreResolveUiLocalizedText(*resources.Localization, item.LocalizationKey, + document.DefaultLocale, document.FallbackLocale).value_or(item.Text); + rml << ""; + } + } if (!node.Children.empty()) rml << '\n'; for (const std::string& child : node.Children) emit(child, depth + 2); if (!node.Children.empty()) rml << indent; @@ -224,10 +635,28 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s rml << "\n"; std::ostringstream css; + if (!resources.AtlasFilename.empty() && !resources.AtlasEntries.empty()) { + css << "@spritesheet metacore-ui-atlas { src:url(\"Assets/" << Escape(resources.AtlasFilename) << "\");"; + for (const auto& entry : resources.AtlasEntries) { + css << " sprite:" << entry.AssetGuid.ToString() << ' ' << entry.X << "px " << entry.Y << "px " + << entry.Width << "px " << entry.Height << "px;"; + } + css << " }\n"; + } + if (resources.FontFamily.has_value()) { + const auto& family = *resources.FontFamily; + for (const auto& face : family.Faces) { + if (!face.FontAssetGuid.IsValid()) continue; + css << "@font-face { font-family:\"" << Escape(family.FamilyName) << "\"; src:url(\"Assets/" + << face.FontAssetGuid.ToString() << ".font\"); font-weight:" << face.Weight + << "; font-style:" << (face.Italic ? "italic" : "normal") << "; }\n"; + } + } css << "body { margin:0; width:100%; height:100%; background-color:" << Color(document.BackgroundColor, document.BackgroundAlpha) << "; }\n" << ".mcui { position:absolute; box-sizing:border-box; }\n" << ".mcui-button:focus, .mcui-input:focus, .mcui-list:focus { outline:2px #4da3ff; }\n" << ".mcui-button:disabled, .mcui-input:disabled { opacity:0.45; }\n" + << "#mcui-font-prewarm { position:absolute; left:-10000px; top:-10000px; opacity:0; }\n" << ".mcui-scroll { overflow:auto; }\n.mcui-list { overflow:auto; }\n"; for (const auto& node : document.Nodes) { const auto& rt = node.RectTransform; @@ -244,7 +673,74 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s << node.Style.FontSize << "px; padding:" << node.Style.Padding.y << "px " << node.Style.Padding.x << "px; border-radius:" << node.Style.BorderRadius << "px; overflow:" << (rt.ClipChildren ? "hidden" : (node.Type == MetaCoreUiNodeType::Scroll || node.Type == MetaCoreUiNodeType::List ? "auto" : "visible")) - << "; }\n"; + << ';'; + if (const auto atlas = std::find_if(resources.AtlasEntries.begin(), resources.AtlasEntries.end(), + [&](const MetaCoreUiCompileResources::AtlasEntry& entry) { + return node.Type == MetaCoreUiNodeType::Image && entry.AssetGuid == node.Style.ImageAssetGuid; + }); atlas != resources.AtlasEntries.end()) + css << " decorator:image(" << atlas->AssetGuid.ToString() << ");"; + css << " }\n"; + for (const MetaCoreUiAnimationDocument& animation : node.Animations) { + const std::string animationName = "mcui-" + node.Id + '-' + animation.Id; + css << "@keyframes " << animationName << " { from { " << AnimationPropertyName(animation.Property) + << ':' << AnimationValue(animation.Property, animation.From) << "; } to { " + << AnimationPropertyName(animation.Property) << ':' << AnimationValue(animation.Property, animation.To) + << "; } }\n"; + std::string selector = "#" + node.Id; + switch (animation.Trigger) { + case MetaCoreUiAnimationTrigger::Show: selector += ":not([hidden])"; break; + case MetaCoreUiAnimationTrigger::Hide: selector += "[hidden]"; break; + case MetaCoreUiAnimationTrigger::Hover: selector += ":hover"; break; + case MetaCoreUiAnimationTrigger::Pressed: selector += ":active"; break; + case MetaCoreUiAnimationTrigger::Focus: selector += ":focus"; break; + case MetaCoreUiAnimationTrigger::Custom: selector += ".animate-" + animation.Id; break; + } + css << selector << " { animation:" << animationName << ' ' << animation.DurationSeconds << "s " + << EasingName(animation.Easing) << ' ' << animation.DelaySeconds << "s both; }\n"; + } + } + if (resources.Theme.has_value()) { + const auto& theme = *resources.Theme; + for (const auto& style : theme.Styles) { + if (style.Token.empty()) continue; + css << "body[data-metacore-theme=\"" << Escape(theme.Name) << "\"] .theme-" << style.Token + << ThemeStateSelector(style.State) << " { background-color:" << Color(style.BackgroundColor, style.Opacity) + << "; color:" << Color(style.TextColor) << "; border-radius:" << style.BorderRadius + << "px; font-size:" << style.FontSize << "px; }\n"; + } + } + if (resources.FontFamily.has_value()) { + css << "body { font-family:\"" << Escape(resources.FontFamily->FamilyName) << "\""; + for (const std::string& fallback : resources.FontFamily->FallbackFamilies) + css << ",\"" << Escape(fallback) << "\""; + css << "; }\n"; + } + if (resources.Localization.has_value()) { + std::vector locales; + for (const auto& locale : resources.Localization->Locales) locales.push_back(&locale); + std::sort(locales.begin(), locales.end(), [](const auto* lhs, const auto* rhs) { return lhs->Locale < rhs->Locale; }); + std::ostringstream localization; + localization << "{\"default\":\"" << EscapeJson(resources.Localization->DefaultLocale) + << "\",\"fallback\":\"" << EscapeJson(resources.Localization->FallbackLocale) + << "\",\"locales\":{"; + bool firstLocale = true; + for (const auto* locale : locales) { + if (!firstLocale) localization << ','; + firstLocale = false; + localization << '"' << EscapeJson(locale->Locale) << "\":{"; + std::vector entries; + for (const auto& entry : locale->Entries) entries.push_back(&entry); + std::sort(entries.begin(), entries.end(), [](const auto* lhs, const auto* rhs) { return lhs->Key < rhs->Key; }); + bool firstEntry = true; + for (const auto* entry : entries) { + if (!firstEntry) localization << ','; + firstEntry = false; + localization << '"' << EscapeJson(entry->Key) << "\":\"" << EscapeJson(entry->Value) << '"'; + } + localization << '}'; + } + localization << "}}"; + result.LocalizationJson = localization.str(); } result.Rml = rml.str(); result.Rcss = css.str(); @@ -252,4 +748,106 @@ MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& s return result; } +MetaCoreUiCanvasLayout MetaCoreComputeUiCanvasLayout( + const MetaCoreUiDocument& document, + const MetaCoreUiCanvasEnvironment& environment +) { + MetaCoreUiCanvasLayout result; + const float fullWidth = std::max(1.0F, environment.Width); + const float fullHeight = std::max(1.0F, environment.Height); + result.ViewportLeft = document.UseSafeArea ? std::max(0.0F, environment.SafeInsetLeft) : 0.0F; + result.ViewportTop = document.UseSafeArea ? std::max(0.0F, environment.SafeInsetTop) : 0.0F; + result.ViewportWidth = std::max(1.0F, fullWidth - result.ViewportLeft - + (document.UseSafeArea ? std::max(0.0F, environment.SafeInsetRight) : 0.0F)); + result.ViewportHeight = std::max(1.0F, fullHeight - result.ViewportTop - + (document.UseSafeArea ? std::max(0.0F, environment.SafeInsetBottom) : 0.0F)); + + float aspect = result.ViewportWidth / result.ViewportHeight; + if (document.MinAspectRatio > 0.0F && aspect < document.MinAspectRatio) { + const float constrainedHeight = result.ViewportWidth / document.MinAspectRatio; + result.ViewportTop += (result.ViewportHeight - constrainedHeight) * 0.5F; + result.ViewportHeight = constrainedHeight; + } else if (document.MaxAspectRatio > 0.0F && aspect > document.MaxAspectRatio) { + const float constrainedWidth = result.ViewportHeight * document.MaxAspectRatio; + result.ViewportLeft += (result.ViewportWidth - constrainedWidth) * 0.5F; + result.ViewportWidth = constrainedWidth; + } + + switch (document.ScaleMode) { + case MetaCoreUiCanvasScaleMode::ConstantPixelSize: + result.Scale = 1.0F; + break; + case MetaCoreUiCanvasScaleMode::ScaleWithScreenSize: { + const float widthScale = result.ViewportWidth / static_cast(std::max(1, document.ReferenceWidth)); + const float heightScale = result.ViewportHeight / static_cast(std::max(1, document.ReferenceHeight)); + const float match = std::clamp(document.MatchWidthOrHeight, 0.0F, 1.0F); + result.Scale = std::exp2(std::log2(std::max(widthScale, 0.0001F)) * (1.0F - match) + + std::log2(std::max(heightScale, 0.0001F)) * match); + break; + } + case MetaCoreUiCanvasScaleMode::ConstantPhysicalSize: + result.Scale = std::max(1.0F, environment.Dpi) / std::max(1.0F, document.ReferenceDpi); + break; + } + result.Scale = std::clamp(result.Scale, std::max(0.0001F, document.MinScale), std::max(document.MinScale, document.MaxScale)); + result.LogicalWidth = result.ViewportWidth / result.Scale; + result.LogicalHeight = result.ViewportHeight / result.Scale; + return result; +} + +std::optional MetaCoreResolveUiLocalizedText( + const MetaCoreUiLocalizationTableDocument& table, + std::string_view key, + std::string_view locale, + std::string_view explicitFallbackLocale +) { + const auto lookup = [&](std::string_view requested) -> std::optional { + if (requested.empty()) return std::nullopt; + const auto foundLocale = std::find_if(table.Locales.begin(), table.Locales.end(), [&](const auto& value) { + return value.Locale == requested; + }); + if (foundLocale == table.Locales.end()) return std::nullopt; + const auto foundEntry = std::find_if(foundLocale->Entries.begin(), foundLocale->Entries.end(), [&](const auto& value) { + return value.Key == key; + }); + return foundEntry == foundLocale->Entries.end() ? std::nullopt : std::optional(foundEntry->Value); + }; + std::vector chain{locale, explicitFallbackLocale, table.FallbackLocale, table.DefaultLocale}; + for (const std::string_view candidate : chain) + if (const auto value = lookup(candidate); value.has_value()) return value; + return std::nullopt; +} + +MetaCoreUiWorldSurfaceLayout MetaCoreComputeUiWorldSurfaceLayout( + glm::vec3 worldSize, + float pixelsPerUnit, + std::int32_t maximumTextureSize +) { + MetaCoreUiWorldSurfaceLayout result; + result.PixelsPerUnit = std::max(1.0F, pixelsPerUnit); + const auto maximum = std::max(1, maximumTextureSize); + result.TargetWidth = static_cast(std::clamp( + std::round(std::max(0.001F, worldSize.x) * result.PixelsPerUnit), 1.0F, static_cast(maximum))); + result.TargetHeight = static_cast(std::clamp( + std::round(std::max(0.001F, worldSize.y) * result.PixelsPerUnit), 1.0F, static_cast(maximum))); + return result; +} + +std::optional MetaCoreMapWorldUiHitToUv( + const glm::mat4& worldMatrix, + glm::vec3 worldSize, + glm::vec3 worldHitPosition +) { + if (worldSize.x <= 0.0F || worldSize.y <= 0.0F) return std::nullopt; + const glm::vec4 local = glm::inverse(worldMatrix) * glm::vec4(worldHitPosition, 1.0F); + const glm::vec2 uv{ + local.x / worldSize.x + 0.5F, + 0.5F - local.y / worldSize.y + }; + constexpr float tolerance = 0.0001F; + if (uv.x < -tolerance || uv.y < -tolerance || uv.x > 1.0F + tolerance || uv.y > 1.0F + tolerance) + return std::nullopt; + return glm::clamp(uv, glm::vec2(0.0F), glm::vec2(1.0F)); +} + } // namespace MetaCore diff --git a/Source/MetaCoreRuntimeUi/Public/MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h b/Source/MetaCoreRuntimeUi/Public/MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h index 3ca90d1..b50a287 100644 --- a/Source/MetaCoreRuntimeUi/Public/MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h +++ b/Source/MetaCoreRuntimeUi/Public/MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include namespace MetaCore { @@ -41,6 +43,14 @@ enum class MetaCoreRuntimeUiInputSource : std::uint32_t { WorldPointer }; +enum class MetaCoreRuntimeUiWorldPointerType : std::uint32_t { + Move = 0, + Down, + Up, + Scroll, + Cancel +}; + enum class MetaCoreRuntimeUiValueType : std::uint32_t { Bool = 0, Int64, @@ -74,6 +84,8 @@ struct MetaCoreRuntimeUiEvent { std::string Type{}; std::string Value{}; std::string OldValue{}; + MetaCoreRuntimeUiValue NewTypedValue{}; + MetaCoreRuntimeUiValue OldTypedValue{}; MetaCoreRuntimeUiEventType EventType = MetaCoreRuntimeUiEventType::Click; MetaCoreRuntimeUiInputSource InputSource = MetaCoreRuntimeUiInputSource::Programmatic; bool Consumed = false; @@ -90,18 +102,99 @@ struct MetaCoreUiDiagnostic { struct MetaCoreCompiledUiDocument { std::string Rml{}; std::string Rcss{}; + std::string LocalizationJson{}; std::vector Dependencies{}; std::vector Diagnostics{}; bool Succeeded = false; }; +struct MetaCoreUiCompileResources { + struct AtlasEntry { + MetaCoreAssetGuid AssetGuid{}; + std::int32_t X = 0; + std::int32_t Y = 0; + std::int32_t Width = 0; + std::int32_t Height = 0; + }; + std::optional Theme{}; + std::optional FontFamily{}; + std::optional Localization{}; + std::string AtlasFilename{}; + std::int32_t AtlasWidth = 0; + std::int32_t AtlasHeight = 0; + std::vector AtlasEntries{}; +}; + +struct MetaCoreLegacyUiImportResult { + MetaCoreUiDocument Document{}; + std::vector Diagnostics{}; + bool Succeeded = false; +}; + +struct MetaCoreUiCanvasEnvironment { + float Width = 1920.0F; + float Height = 1080.0F; + float Dpi = 96.0F; + float SafeInsetLeft = 0.0F; + float SafeInsetTop = 0.0F; + float SafeInsetRight = 0.0F; + float SafeInsetBottom = 0.0F; +}; + +struct MetaCoreUiCanvasLayout { + float Scale = 1.0F; + float LogicalWidth = 1920.0F; + float LogicalHeight = 1080.0F; + float ViewportLeft = 0.0F; + float ViewportTop = 0.0F; + float ViewportWidth = 1920.0F; + float ViewportHeight = 1080.0F; +}; + +struct MetaCoreUiWorldSurfaceLayout { + std::int32_t TargetWidth = 1; + std::int32_t TargetHeight = 1; + float PixelsPerUnit = 1.0F; +}; + [[nodiscard]] std::vector MetaCoreValidateUiDocument(const MetaCoreUiDocument& document); [[nodiscard]] MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& document); +[[nodiscard]] MetaCoreCompiledUiDocument MetaCoreCompileUiDocument( + const MetaCoreUiDocument& document, + const MetaCoreUiCompileResources& resources +); [[nodiscard]] MetaCoreUiDocument MetaCoreMigrateUiDocument(MetaCoreUiDocument document); +[[nodiscard]] MetaCoreLegacyUiImportResult MetaCoreImportLegacyUiDocument( + std::string_view rml, + std::string_view rcss, + std::string_view documentName = "ImportedUi" +); +[[nodiscard]] MetaCoreUiCanvasLayout MetaCoreComputeUiCanvasLayout( + const MetaCoreUiDocument& document, + const MetaCoreUiCanvasEnvironment& environment +); +[[nodiscard]] std::optional MetaCoreResolveUiLocalizedText( + const MetaCoreUiLocalizationTableDocument& table, + std::string_view key, + std::string_view locale, + std::string_view explicitFallbackLocale = {} +); +[[nodiscard]] MetaCoreUiWorldSurfaceLayout MetaCoreComputeUiWorldSurfaceLayout( + glm::vec3 worldSize, + float pixelsPerUnit, + std::int32_t maximumTextureSize = 8192 +); +[[nodiscard]] std::optional MetaCoreMapWorldUiHitToUv( + const glm::mat4& worldMatrix, + glm::vec3 worldSize, + glm::vec3 worldHitPosition +); class MetaCoreRuntimeUiSystem { public: using EventCallback = std::function; + using ProviderReadCallback = std::function(std::string_view)>; + using ProviderWriteCallback = std::function; MetaCoreRuntimeUiSystem(); ~MetaCoreRuntimeUiSystem(); @@ -118,10 +211,24 @@ public: void Update(float deltaSeconds); [[nodiscard]] MetaCoreInputConsumption ProcessInput(); + [[nodiscard]] bool ProcessWorldPointer( + std::uint64_t sceneObjectId, + glm::vec2 surfaceUv, + MetaCoreRuntimeUiWorldPointerType type, + std::uint32_t button = 0, + float scrollDelta = 0.0F + ); void ApplyDataUpdates(const std::vector& updates); [[nodiscard]] bool SetValue(const std::string& sourcePath, const MetaCoreRuntimeUiValue& value); [[nodiscard]] std::optional GetValue(const std::string& sourcePath) const; [[nodiscard]] bool SetListRows(const std::string& sourcePath, std::vector rows); + [[nodiscard]] bool RegisterProvider( + std::string providerName, + ProviderReadCallback reader, + ProviderWriteCallback writer = {} + ); + [[nodiscard]] bool UnregisterProvider(std::string_view providerName); + void RefreshProviders(); [[nodiscard]] bool RequestFocus(std::uint64_t instanceId, const std::string& elementId); [[nodiscard]] bool CreateInstance(const MetaCoreRuntimeUiDocumentEntry& entry); [[nodiscard]] bool DestroyInstance(std::uint64_t instanceId); diff --git a/Source/MetaCoreScene/Private/MetaCoreSceneSerializer.cpp b/Source/MetaCoreScene/Private/MetaCoreSceneSerializer.cpp index e7b43c3..9d27f59 100644 --- a/Source/MetaCoreScene/Private/MetaCoreSceneSerializer.cpp +++ b/Source/MetaCoreScene/Private/MetaCoreSceneSerializer.cpp @@ -935,6 +935,46 @@ static MetaCoreUiBindingDocument JsonToUiBinding(const json& j) { return binding; } +static json UiAnimationToJson(const MetaCoreUiAnimationDocument& animation) { + return json{ + {"Id", animation.Id}, + {"Trigger", static_cast(animation.Trigger)}, + {"Property", static_cast(animation.Property)}, + {"From", Vec3ToJson(animation.From)}, + {"To", Vec3ToJson(animation.To)}, + {"DurationSeconds", animation.DurationSeconds}, + {"DelaySeconds", animation.DelaySeconds}, + {"Easing", static_cast(animation.Easing)} + }; +} + +static MetaCoreUiAnimationDocument JsonToUiAnimation(const json& j) { + MetaCoreUiAnimationDocument animation; + if (j.contains("Id")) animation.Id = j["Id"].get(); + if (j.contains("Trigger")) animation.Trigger = static_cast(j["Trigger"].get()); + if (j.contains("Property")) animation.Property = static_cast(j["Property"].get()); + if (j.contains("From")) animation.From = JsonToVec3(j["From"]); + if (j.contains("To")) animation.To = JsonToVec3(j["To"]); + if (j.contains("DurationSeconds")) animation.DurationSeconds = j["DurationSeconds"].get(); + if (j.contains("DelaySeconds")) animation.DelaySeconds = j["DelaySeconds"].get(); + if (j.contains("Easing")) animation.Easing = static_cast(j["Easing"].get()); + return animation; +} + +static json UiListItemToJson(const MetaCoreUiListItemDocument& item) { + return json{{"Id", item.Id}, {"Text", item.Text}, {"Value", item.Value}, + {"LocalizationKey", item.LocalizationKey}}; +} + +static MetaCoreUiListItemDocument JsonToUiListItem(const json& j) { + MetaCoreUiListItemDocument item; + if (j.contains("Id")) item.Id = j["Id"].get(); + if (j.contains("Text")) item.Text = j["Text"].get(); + if (j.contains("Value")) item.Value = j["Value"].get(); + if (j.contains("LocalizationKey")) item.LocalizationKey = j["LocalizationKey"].get(); + return item; +} + static json UiNodeToJson(const MetaCoreUiNodeDocument& node) { json j = json::object(); j["Id"] = node.Id; @@ -958,10 +998,14 @@ static json UiNodeToJson(const MetaCoreUiNodeDocument& node) { j["ReadOnly"] = node.ReadOnly; j["MaxLength"] = node.MaxLength; j["SelectedIndex"] = node.SelectedIndex; + j["StaticItems"] = json::array(); + for (const auto& item : node.StaticItems) j["StaticItems"].push_back(UiListItemToJson(item)); j["ItemTemplateNodeId"] = node.ItemTemplateNodeId; j["LocalizationKey"] = node.LocalizationKey; j["ShowAnimation"] = node.ShowAnimation; j["HideAnimation"] = node.HideAnimation; + j["Animations"] = json::array(); + for (const auto& animation : node.Animations) j["Animations"].push_back(UiAnimationToJson(animation)); return j; } @@ -988,10 +1032,14 @@ static MetaCoreUiNodeDocument JsonToUiNode(const json& j) { if (j.contains("ReadOnly")) node.ReadOnly = j["ReadOnly"].get(); if (j.contains("MaxLength")) node.MaxLength = j["MaxLength"].get(); if (j.contains("SelectedIndex")) node.SelectedIndex = j["SelectedIndex"].get(); + if (j.contains("StaticItems") && j["StaticItems"].is_array()) + for (const auto& item : j["StaticItems"]) node.StaticItems.push_back(JsonToUiListItem(item)); if (j.contains("ItemTemplateNodeId")) node.ItemTemplateNodeId = j["ItemTemplateNodeId"].get(); if (j.contains("LocalizationKey")) node.LocalizationKey = j["LocalizationKey"].get(); if (j.contains("ShowAnimation")) node.ShowAnimation = j["ShowAnimation"].get(); if (j.contains("HideAnimation")) node.HideAnimation = j["HideAnimation"].get(); + if (j.contains("Animations") && j["Animations"].is_array()) + for (const auto& animation : j["Animations"]) node.Animations.push_back(JsonToUiAnimation(animation)); return node; } @@ -1014,6 +1062,8 @@ bool MetaCoreSceneSerializer::SaveUiToJson( uiJson["MinScale"] = uiDocument.MinScale; uiJson["MaxScale"] = uiDocument.MaxScale; uiJson["UseSafeArea"] = uiDocument.UseSafeArea; + uiJson["MinAspectRatio"] = uiDocument.MinAspectRatio; + uiJson["MaxAspectRatio"] = uiDocument.MaxAspectRatio; uiJson["ThemeAssetGuid"] = GuidToString(uiDocument.ThemeAssetGuid); uiJson["FontFamilyAssetGuid"] = GuidToString(uiDocument.FontFamilyAssetGuid); uiJson["LocalizationAssetGuid"] = GuidToString(uiDocument.LocalizationAssetGuid); @@ -1065,6 +1115,8 @@ std::optional MetaCoreSceneSerializer::LoadUiFromJson( if (uiJson.contains("MinScale")) doc.MinScale = uiJson["MinScale"].get(); if (uiJson.contains("MaxScale")) doc.MaxScale = uiJson["MaxScale"].get(); if (uiJson.contains("UseSafeArea")) doc.UseSafeArea = uiJson["UseSafeArea"].get(); + if (uiJson.contains("MinAspectRatio")) doc.MinAspectRatio = uiJson["MinAspectRatio"].get(); + if (uiJson.contains("MaxAspectRatio")) doc.MaxAspectRatio = uiJson["MaxAspectRatio"].get(); if (uiJson.contains("ThemeAssetGuid")) doc.ThemeAssetGuid = StringToGuid(uiJson["ThemeAssetGuid"].get()); if (uiJson.contains("FontFamilyAssetGuid")) doc.FontFamilyAssetGuid = StringToGuid(uiJson["FontFamilyAssetGuid"].get()); if (uiJson.contains("LocalizationAssetGuid")) doc.LocalizationAssetGuid = StringToGuid(uiJson["LocalizationAssetGuid"].get()); @@ -1085,4 +1137,171 @@ std::optional MetaCoreSceneSerializer::LoadUiFromJson( } } +bool MetaCoreSceneSerializer::SaveUiThemeToJson( + const std::filesystem::path& absolutePath, + const MetaCoreUiThemeDocument& document +) { + try { + json root{{"SchemaVersion", document.SchemaVersion}, {"Name", document.Name}}; + root["Styles"] = json::array(); + for (const auto& style : document.Styles) { + root["Styles"].push_back({ + {"Token", style.Token}, + {"State", static_cast(style.State)}, + {"BackgroundColor", Vec3ToJson(style.BackgroundColor)}, + {"TextColor", Vec3ToJson(style.TextColor)}, + {"Opacity", style.Opacity}, + {"BorderRadius", style.BorderRadius}, + {"FontSize", style.FontSize} + }); + } + std::ofstream output(absolutePath); + return output.is_open() && static_cast(output << root.dump(4)); + } catch (...) { + return false; + } +} + +std::optional MetaCoreSceneSerializer::LoadUiThemeFromJson( + const std::filesystem::path& absolutePath +) { + try { + std::ifstream input(absolutePath); + if (!input.is_open()) return std::nullopt; + json root; + input >> root; + MetaCoreUiThemeDocument result; + if (root.contains("SchemaVersion")) result.SchemaVersion = root["SchemaVersion"].get(); + if (root.contains("Name")) result.Name = root["Name"].get(); + if (root.contains("Styles") && root["Styles"].is_array()) { + for (const auto& value : root["Styles"]) { + MetaCoreUiThemeStyleDocument style; + if (value.contains("Token")) style.Token = value["Token"].get(); + if (value.contains("State")) style.State = static_cast(value["State"].get()); + if (value.contains("BackgroundColor")) style.BackgroundColor = JsonToVec3(value["BackgroundColor"]); + if (value.contains("TextColor")) style.TextColor = JsonToVec3(value["TextColor"]); + if (value.contains("Opacity")) style.Opacity = value["Opacity"].get(); + if (value.contains("BorderRadius")) style.BorderRadius = value["BorderRadius"].get(); + if (value.contains("FontSize")) style.FontSize = value["FontSize"].get(); + result.Styles.push_back(std::move(style)); + } + } + return result; + } catch (...) { + return std::nullopt; + } +} + +bool MetaCoreSceneSerializer::SaveUiFontFamilyToJson( + const std::filesystem::path& absolutePath, + const MetaCoreUiFontFamilyDocument& document +) { + try { + json root{ + {"SchemaVersion", document.SchemaVersion}, + {"FamilyName", document.FamilyName}, + {"FallbackFamilies", document.FallbackFamilies} + }; + root["Faces"] = json::array(); + for (const auto& face : document.Faces) { + root["Faces"].push_back({ + {"FontAssetGuid", GuidToString(face.FontAssetGuid)}, + {"Weight", face.Weight}, + {"Italic", face.Italic}, + {"RequiredCharacters", face.RequiredCharacters} + }); + } + std::ofstream output(absolutePath); + return output.is_open() && static_cast(output << root.dump(4)); + } catch (...) { + return false; + } +} + +std::optional MetaCoreSceneSerializer::LoadUiFontFamilyFromJson( + const std::filesystem::path& absolutePath +) { + try { + std::ifstream input(absolutePath); + if (!input.is_open()) return std::nullopt; + json root; + input >> root; + MetaCoreUiFontFamilyDocument result; + if (root.contains("SchemaVersion")) result.SchemaVersion = root["SchemaVersion"].get(); + if (root.contains("FamilyName")) result.FamilyName = root["FamilyName"].get(); + if (root.contains("FallbackFamilies")) result.FallbackFamilies = root["FallbackFamilies"].get>(); + if (root.contains("Faces") && root["Faces"].is_array()) { + for (const auto& value : root["Faces"]) { + MetaCoreUiFontFaceDocument face; + if (value.contains("FontAssetGuid")) face.FontAssetGuid = StringToGuid(value["FontAssetGuid"].get()); + if (value.contains("Weight")) face.Weight = value["Weight"].get(); + if (value.contains("Italic")) face.Italic = value["Italic"].get(); + if (value.contains("RequiredCharacters")) face.RequiredCharacters = value["RequiredCharacters"].get(); + result.Faces.push_back(std::move(face)); + } + } + return result; + } catch (...) { + return std::nullopt; + } +} + +bool MetaCoreSceneSerializer::SaveUiLocalizationTableToJson( + const std::filesystem::path& absolutePath, + const MetaCoreUiLocalizationTableDocument& document +) { + try { + json root{ + {"SchemaVersion", document.SchemaVersion}, + {"DefaultLocale", document.DefaultLocale}, + {"FallbackLocale", document.FallbackLocale} + }; + root["Locales"] = json::array(); + for (const auto& locale : document.Locales) { + json localeJson{{"Locale", locale.Locale}}; + localeJson["Entries"] = json::array(); + for (const auto& entry : locale.Entries) + localeJson["Entries"].push_back({{"Key", entry.Key}, {"Value", entry.Value}}); + root["Locales"].push_back(std::move(localeJson)); + } + std::ofstream output(absolutePath); + return output.is_open() && static_cast(output << root.dump(4)); + } catch (...) { + return false; + } +} + +std::optional MetaCoreSceneSerializer::LoadUiLocalizationTableFromJson( + const std::filesystem::path& absolutePath +) { + try { + std::ifstream input(absolutePath); + if (!input.is_open()) return std::nullopt; + json root; + input >> root; + MetaCoreUiLocalizationTableDocument result; + if (root.contains("SchemaVersion")) result.SchemaVersion = root["SchemaVersion"].get(); + if (root.contains("DefaultLocale")) result.DefaultLocale = root["DefaultLocale"].get(); + if (root.contains("FallbackLocale")) result.FallbackLocale = root["FallbackLocale"].get(); + if (root.contains("Locales") && root["Locales"].is_array()) { + for (const auto& value : root["Locales"]) { + MetaCoreUiLocalizationLocaleDocument locale; + if (value.contains("Locale")) locale.Locale = value["Locale"].get(); + if (value.contains("Entries") && value["Entries"].is_array()) { + for (const auto& entryValue : value["Entries"]) { + MetaCoreUiLocalizationEntryDocument entry; + if (entryValue.contains("Key")) entry.Key = entryValue["Key"].get(); + if (entryValue.contains("Value")) entry.Value = entryValue["Value"].get(); + locale.Entries.push_back(std::move(entry)); + } + } + result.Locales.push_back(std::move(locale)); + } + } + return result; + } catch (...) { + return std::nullopt; + } +} + } // namespace MetaCore diff --git a/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneDocument.h b/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneDocument.h index f5b5144..24cb0dc 100644 --- a/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneDocument.h +++ b/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneDocument.h @@ -134,6 +134,41 @@ enum class MetaCoreUiInputValueType { Number }; +MC_ENUM() +enum class MetaCoreUiVisualState { + Normal = 0, + Hover, + Pressed, + Focused, + Disabled +}; + +MC_ENUM() +enum class MetaCoreUiAnimationTrigger { + Show = 0, + Hide, + Hover, + Pressed, + Focus, + Custom +}; + +MC_ENUM() +enum class MetaCoreUiAnimationProperty { + Opacity = 0, + Translation, + Scale, + Color +}; + +MC_ENUM() +enum class MetaCoreUiEasing { + Linear = 0, + EaseIn, + EaseOut, + EaseInOut +}; + MC_ENUM() enum class MetaCoreUiHorizontalAlignment { Left = 0, @@ -243,6 +278,52 @@ struct MetaCoreUiBindingDocument { std::string Fallback{}; }; +MC_STRUCT() +struct MetaCoreUiAnimationDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::string Id{}; + + MC_PROPERTY() + MetaCoreUiAnimationTrigger Trigger = MetaCoreUiAnimationTrigger::Show; + + MC_PROPERTY() + MetaCoreUiAnimationProperty Property = MetaCoreUiAnimationProperty::Opacity; + + MC_PROPERTY() + glm::vec3 From{0.0F}; + + MC_PROPERTY() + glm::vec3 To{1.0F}; + + MC_PROPERTY() + float DurationSeconds = 0.2F; + + MC_PROPERTY() + float DelaySeconds = 0.0F; + + MC_PROPERTY() + MetaCoreUiEasing Easing = MetaCoreUiEasing::EaseOut; +}; + +MC_STRUCT() +struct MetaCoreUiListItemDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::string Id{}; + + MC_PROPERTY() + std::string Text{}; + + MC_PROPERTY() + std::string Value{}; + + MC_PROPERTY() + std::string LocalizationKey{}; +}; + MC_STRUCT() struct MetaCoreUiNodeDocument { MC_GENERATED_BODY() @@ -307,6 +388,9 @@ struct MetaCoreUiNodeDocument { MC_PROPERTY() std::int32_t SelectedIndex = -1; + MC_PROPERTY() + std::vector StaticItems{}; + MC_PROPERTY() std::string ItemTemplateNodeId{}; @@ -318,6 +402,122 @@ struct MetaCoreUiNodeDocument { MC_PROPERTY() std::string HideAnimation{}; + + MC_PROPERTY() + std::vector Animations{}; +}; + +MC_STRUCT() +struct MetaCoreUiThemeStyleDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::string Token{}; + + MC_PROPERTY() + MetaCoreUiVisualState State = MetaCoreUiVisualState::Normal; + + MC_PROPERTY() + glm::vec3 BackgroundColor{0.15F}; + + MC_PROPERTY() + glm::vec3 TextColor{1.0F}; + + MC_PROPERTY() + float Opacity = 1.0F; + + MC_PROPERTY() + float BorderRadius = 0.0F; + + MC_PROPERTY() + float FontSize = 16.0F; +}; + +MC_STRUCT() +struct MetaCoreUiThemeDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::uint32_t SchemaVersion = 1; + + MC_PROPERTY() + std::string Name{"Default"}; + + MC_PROPERTY() + std::vector Styles{}; +}; + +MC_STRUCT() +struct MetaCoreUiFontFaceDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + MetaCoreAssetGuid FontAssetGuid{}; + + MC_PROPERTY() + std::int32_t Weight = 400; + + MC_PROPERTY() + bool Italic = false; + + MC_PROPERTY() + std::string RequiredCharacters{}; +}; + +MC_STRUCT() +struct MetaCoreUiFontFamilyDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::uint32_t SchemaVersion = 1; + + MC_PROPERTY() + std::string FamilyName{"MetaCore UI"}; + + MC_PROPERTY() + std::vector FallbackFamilies{}; + + MC_PROPERTY() + std::vector Faces{}; +}; + +MC_STRUCT() +struct MetaCoreUiLocalizationEntryDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::string Key{}; + + MC_PROPERTY() + std::string Value{}; +}; + +MC_STRUCT() +struct MetaCoreUiLocalizationLocaleDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::string Locale{}; + + MC_PROPERTY() + std::vector Entries{}; +}; + +MC_STRUCT() +struct MetaCoreUiLocalizationTableDocument { + MC_GENERATED_BODY() + + MC_PROPERTY() + std::uint32_t SchemaVersion = 1; + + MC_PROPERTY() + std::string DefaultLocale{"en-US"}; + + MC_PROPERTY() + std::string FallbackLocale{"en-US"}; + + MC_PROPERTY() + std::vector Locales{}; }; MC_STRUCT() @@ -357,6 +557,12 @@ struct MetaCoreUiDocument { MC_PROPERTY() bool UseSafeArea = true; + MC_PROPERTY() + float MinAspectRatio = 0.0F; + + MC_PROPERTY() + float MaxAspectRatio = 0.0F; + MC_PROPERTY() MetaCoreAssetGuid ThemeAssetGuid{}; diff --git a/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneSerializer.h b/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneSerializer.h index d632191..5606478 100644 --- a/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneSerializer.h +++ b/Source/MetaCoreScene/Public/MetaCoreScene/MetaCoreSceneSerializer.h @@ -57,6 +57,30 @@ public: const std::filesystem::path& absolutePath, const MetaCoreTypeRegistry& registry ); + + static bool SaveUiThemeToJson( + const std::filesystem::path& absolutePath, + const MetaCoreUiThemeDocument& document + ); + static std::optional LoadUiThemeFromJson( + const std::filesystem::path& absolutePath + ); + + static bool SaveUiFontFamilyToJson( + const std::filesystem::path& absolutePath, + const MetaCoreUiFontFamilyDocument& document + ); + static std::optional LoadUiFontFamilyFromJson( + const std::filesystem::path& absolutePath + ); + + static bool SaveUiLocalizationTableToJson( + const std::filesystem::path& absolutePath, + const MetaCoreUiLocalizationTableDocument& document + ); + static std::optional LoadUiLocalizationTableFromJson( + const std::filesystem::path& absolutePath + ); }; } // namespace MetaCore diff --git a/Source/MetaCoreScripting/Private/MetaCoreScripting.cpp b/Source/MetaCoreScripting/Private/MetaCoreScripting.cpp index ba0e33b..6055c6f 100644 --- a/Source/MetaCoreScripting/Private/MetaCoreScripting.cpp +++ b/Source/MetaCoreScripting/Private/MetaCoreScripting.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -591,6 +592,78 @@ MetaCoreRuntimeInput* MetaCoreScriptRuntime::GetRuntimeInput() const { return Im MetaCorePhysicsWorld* MetaCoreScriptRuntime::GetPhysicsWorld() const { return Impl_->Physics; } MetaCoreAnimationRuntime* MetaCoreScriptRuntime::GetAnimationRuntime() const { return Impl_->Animation; } MetaCoreRuntimeUiSystem* MetaCoreScriptRuntime::GetRuntimeUi() const { return Impl_->RuntimeUi; } +std::optional MetaCoreScriptRuntime::ReadSceneUiBinding(std::string_view relativePath) const { + const auto objectSeparator = relativePath.find('.'); + const auto componentSeparator = objectSeparator == std::string_view::npos + ? std::string_view::npos : relativePath.find('.', objectSeparator + 1); + if (objectSeparator == std::string_view::npos || componentSeparator == std::string_view::npos) return std::nullopt; + std::uint64_t objectId = 0; + const auto idText = relativePath.substr(0, objectSeparator); + const auto [end, error] = std::from_chars(idText.data(), idText.data() + idText.size(), objectId); + if (error != std::errc{} || end != idText.data() + idText.size() || objectId == 0) return std::nullopt; + MetaCoreScriptValueV1 source{}; + if (!GetProperty( + {WorldGeneration_, objectId}, + relativePath.substr(objectSeparator + 1, componentSeparator - objectSeparator - 1), + relativePath.substr(componentSeparator + 1), source)) return std::nullopt; + MetaCoreRuntimeUiValue value; + switch (source.Type) { + case MetaCoreScriptAbiValue_Bool: value.Type = MetaCoreRuntimeUiValueType::Bool; value.BoolValue = source.IntValue != 0; break; + case MetaCoreScriptAbiValue_Int: value.Type = MetaCoreRuntimeUiValueType::Int64; value.Int64Value = source.IntValue; break; + case MetaCoreScriptAbiValue_Float: value.Type = MetaCoreRuntimeUiValueType::Double; value.DoubleValue = source.NumberValue; break; + case MetaCoreScriptAbiValue_String: value.Type = MetaCoreRuntimeUiValueType::String; value.StringValue = source.Text; break; + case MetaCoreScriptAbiValue_Vec3: + value.Type = MetaCoreRuntimeUiValueType::Vec3; + value.Vec3Value = {static_cast(source.Vec3[0]), static_cast(source.Vec3[1]), static_cast(source.Vec3[2])}; + break; + case MetaCoreScriptAbiValue_AssetRef: + value.Type = MetaCoreRuntimeUiValueType::AssetRef; + value.AssetValue = MetaCoreAssetGuid::Parse(source.Text).value_or(MetaCoreAssetGuid{}); + break; + case MetaCoreScriptAbiValue_GameObjectRef: + value.Type = MetaCoreRuntimeUiValueType::GameObjectRef; + value.ObjectValue = source.ObjectValue.ObjectId; + break; + default: return std::nullopt; + } + return value; +} +bool MetaCoreScriptRuntime::WriteSceneUiBinding(std::string_view relativePath, const MetaCoreRuntimeUiValue& value) { + const auto objectSeparator = relativePath.find('.'); + const auto componentSeparator = objectSeparator == std::string_view::npos + ? std::string_view::npos : relativePath.find('.', objectSeparator + 1); + if (objectSeparator == std::string_view::npos || componentSeparator == std::string_view::npos) return false; + std::uint64_t objectId = 0; + const auto idText = relativePath.substr(0, objectSeparator); + const auto [end, error] = std::from_chars(idText.data(), idText.data() + idText.size(), objectId); + if (error != std::errc{} || end != idText.data() + idText.size() || objectId == 0) return false; + MetaCoreScriptValueV1 target{}; + switch (value.Type) { + case MetaCoreRuntimeUiValueType::Bool: target.Type = MetaCoreScriptAbiValue_Bool; target.IntValue = value.BoolValue ? 1 : 0; break; + case MetaCoreRuntimeUiValueType::Int64: target.Type = MetaCoreScriptAbiValue_Int; target.IntValue = value.Int64Value; break; + case MetaCoreRuntimeUiValueType::Double: target.Type = MetaCoreScriptAbiValue_Float; target.NumberValue = value.DoubleValue; break; + case MetaCoreRuntimeUiValueType::String: + target.Type = MetaCoreScriptAbiValue_String; + std::snprintf(target.Text, sizeof(target.Text), "%s", value.StringValue.c_str()); + break; + case MetaCoreRuntimeUiValueType::Vec3: + target.Type = MetaCoreScriptAbiValue_Vec3; + target.Vec3[0] = value.Vec3Value.x; target.Vec3[1] = value.Vec3Value.y; target.Vec3[2] = value.Vec3Value.z; + break; + case MetaCoreRuntimeUiValueType::AssetRef: + target.Type = MetaCoreScriptAbiValue_AssetRef; + std::snprintf(target.Text, sizeof(target.Text), "%s", value.AssetValue.ToString().c_str()); + break; + case MetaCoreRuntimeUiValueType::GameObjectRef: + target.Type = MetaCoreScriptAbiValue_GameObjectRef; + target.ObjectValue = {WorldGeneration_, value.ObjectValue}; + break; + } + return SetProperty( + {WorldGeneration_, objectId}, + relativePath.substr(objectSeparator + 1, componentSeparator - objectSeparator - 1), + relativePath.substr(componentSeparator + 1), target); +} std::uint64_t MetaCoreScriptRuntime::ScheduleTimer(MetaCoreId object, std::uint64_t instance, double delay, double interval, bool unscaled, std::function callback) { const auto id = Impl_->NextTimer++; Impl_->Timers.push_back({id, object, instance, std::max(0.0, delay), std::max(0.0, interval), unscaled, std::move(callback)}); return id; } void MetaCoreScriptRuntime::CancelTimer(std::uint64_t id) { std::erase_if(Impl_->Timers, [id](const auto& t) { return t.Id == id; }); } bool MetaCoreScriptRuntime::IsHandleValid(MetaCoreScriptObjectHandleV1 handle) const { return handle.WorldGeneration == WorldGeneration_ && handle.ObjectId != 0 && static_cast(Impl_->Scene.FindGameObject(handle.ObjectId)); } @@ -601,6 +674,9 @@ bool MetaCoreScriptRuntime::HasComponent(MetaCoreScriptObjectHandleV1 handle, st if (component == "Script") return object.HasComponent(); if (component == "Camera") return object.HasComponent(); if (component == "Light") return object.HasComponent(); + if (component == "Active") return object.HasComponent(); + if (component == "Rotator") return object.HasComponent(); + if (component == "UiRenderer") return object.HasComponent(); if (component == "MeshRenderer") return object.HasComponent(); if (component == "Animator") return object.HasComponent(); return false; @@ -608,16 +684,100 @@ bool MetaCoreScriptRuntime::HasComponent(MetaCoreScriptObjectHandleV1 handle, st bool MetaCoreScriptRuntime::GetProperty(MetaCoreScriptObjectHandleV1 handle, std::string_view component, std::string_view property, MetaCoreScriptValueV1& value) const { if (!IsHandleValid(handle)) return false; auto object = Impl_->Scene.FindGameObject(handle.ObjectId); value = {}; if (component == "Name" && property == "Value") { value.Type = MetaCoreScriptAbiValue_String; std::snprintf(value.Text, sizeof(value.Text), "%s", object.GetName().c_str()); return true; } - if (component != "Transform") return false; const auto& transform = object.GetComponent(); const glm::vec3* source = nullptr; - if (property == "Position") source = &transform.Position; else if (property == "Rotation") source = &transform.RotationEulerDegrees; else if (property == "Scale") source = &transform.Scale; else return false; - value.Type = MetaCoreScriptAbiValue_Vec3; value.Vec3[0] = source->x; value.Vec3[1] = source->y; value.Vec3[2] = source->z; return true; + const auto boolean = [&](bool source) { value.Type = MetaCoreScriptAbiValue_Bool; value.IntValue = source ? 1 : 0; return true; }; + const auto number = [&](double source) { value.Type = MetaCoreScriptAbiValue_Float; value.NumberValue = source; return true; }; + const auto vector = [&](glm::vec3 source) { value.Type = MetaCoreScriptAbiValue_Vec3; value.Vec3[0]=source.x; value.Vec3[1]=source.y; value.Vec3[2]=source.z; return true; }; + if (component == "Transform") { + const auto& transform = object.GetComponent(); + if (property == "Position") return vector(transform.Position); + if (property == "Rotation") return vector(transform.RotationEulerDegrees); + if (property == "Scale") return vector(transform.Scale); + return false; + } + if (component == "Active" && object.HasComponent() && property == "Active") + return boolean(object.GetComponent().Active); + if (component == "Rotator" && object.HasComponent()) { + const auto& rotator = object.GetComponent(); + if (property == "Enabled") return boolean(rotator.Enabled); + if (property == "DegreesPerSecond") return number(rotator.DegreesPerSecond); + return false; + } + if (component == "Camera" && object.HasComponent()) { + const auto& camera = object.GetComponent(); + if (property == "Enabled") return boolean(camera.Enabled); + if (property == "IsPrimary") return boolean(camera.IsPrimary); + if (property == "FieldOfViewDegrees") return number(camera.FieldOfViewDegrees); + if (property == "NearClip") return number(camera.NearClip); + if (property == "FarClip") return number(camera.FarClip); + if (property == "OrthographicSize") return number(camera.OrthographicSize); + if (property == "Depth") return number(camera.Depth); + if (property == "BackgroundColor") return vector(camera.BackgroundColor); + return false; + } + if (component == "Light" && object.HasComponent()) { + const auto& light = object.GetComponent(); + if (property == "Enabled") return boolean(light.Enabled); + if (property == "CastShadows") return boolean(light.CastShadows); + if (property == "Intensity") return number(light.Intensity); + if (property == "Range") return number(light.Range); + if (property == "ColorTemperatureKelvin") return number(light.ColorTemperatureKelvin); + if (property == "Color") return vector(light.Color); + return false; + } + if (component == "UiRenderer" && object.HasComponent()) { + const auto& ui = object.GetComponent(); + if (property == "Visible") return boolean(ui.Visible); + if (property == "InputEnabled") return boolean(ui.InputEnabled); + if (property == "PixelsPerUnit") return number(ui.PixelsPerUnit); + if (property == "WorldSize") return vector(ui.WorldSize); + } + return false; } bool MetaCoreScriptRuntime::SetProperty(MetaCoreScriptObjectHandleV1 handle, std::string_view component, std::string_view property, const MetaCoreScriptValueV1& value) { if (!IsHandleValid(handle)) return false; auto object = Impl_->Scene.FindGameObject(handle.ObjectId); if (component == "Name" && property == "Value" && value.Type == MetaCoreScriptAbiValue_String) { object.GetName() = value.Text; Impl_->Scene.IncrementRevision(); return true; } - if (component != "Transform" || value.Type != MetaCoreScriptAbiValue_Vec3) return false; auto& transform = object.GetComponent(); glm::vec3* target = nullptr; - if (property == "Position") target = &transform.Position; else if (property == "Rotation") target = &transform.RotationEulerDegrees; else if (property == "Scale") target = &transform.Scale; else return false; - *target = {static_cast(value.Vec3[0]), static_cast(value.Vec3[1]), static_cast(value.Vec3[2])}; Impl_->Scene.IncrementRevision(); return true; + const auto changed = [&] { Impl_->Scene.IncrementRevision(); return true; }; + if (component == "Transform" && value.Type == MetaCoreScriptAbiValue_Vec3) { + auto& transform = object.GetComponent(); glm::vec3* target = nullptr; + if (property == "Position") target = &transform.Position; else if (property == "Rotation") target = &transform.RotationEulerDegrees; else if (property == "Scale") target = &transform.Scale; else return false; + *target = {static_cast(value.Vec3[0]), static_cast(value.Vec3[1]), static_cast(value.Vec3[2])}; return changed(); + } + if (component == "Active" && object.HasComponent() && + property == "Active" && value.Type == MetaCoreScriptAbiValue_Bool) { + object.GetComponent().Active = value.IntValue != 0; return changed(); + } + if (component == "Rotator" && object.HasComponent()) { + auto& rotator = object.GetComponent(); + if (property == "Enabled" && value.Type == MetaCoreScriptAbiValue_Bool) { rotator.Enabled=value.IntValue!=0; return changed(); } + if (property == "DegreesPerSecond" && value.Type == MetaCoreScriptAbiValue_Float) { rotator.DegreesPerSecond=static_cast(value.NumberValue); return changed(); } + } + if (component == "Camera" && object.HasComponent()) { + auto& camera = object.GetComponent(); + if (value.Type == MetaCoreScriptAbiValue_Bool && property == "Enabled") { camera.Enabled=value.IntValue!=0; return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Bool && property == "IsPrimary") { camera.IsPrimary=value.IntValue!=0; return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "FieldOfViewDegrees") { camera.FieldOfViewDegrees=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "NearClip") { camera.NearClip=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "FarClip") { camera.FarClip=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "OrthographicSize") { camera.OrthographicSize=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "Depth") { camera.Depth=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Vec3 && property == "BackgroundColor") { camera.BackgroundColor={static_cast(value.Vec3[0]),static_cast(value.Vec3[1]),static_cast(value.Vec3[2])}; return changed(); } + } + if (component == "Light" && object.HasComponent()) { + auto& light = object.GetComponent(); + if (value.Type == MetaCoreScriptAbiValue_Bool && property == "Enabled") { light.Enabled=value.IntValue!=0; return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Bool && property == "CastShadows") { light.CastShadows=value.IntValue!=0; return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "Intensity") { light.Intensity=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "Range") { light.Range=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Vec3 && property == "Color") { light.Color={static_cast(value.Vec3[0]),static_cast(value.Vec3[1]),static_cast(value.Vec3[2])}; return changed(); } + } + if (component == "UiRenderer" && object.HasComponent()) { + auto& ui = object.GetComponent(); + if (value.Type == MetaCoreScriptAbiValue_Bool && property == "Visible") { ui.Visible=value.IntValue!=0; return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Bool && property == "InputEnabled") { ui.InputEnabled=value.IntValue!=0; return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Float && property == "PixelsPerUnit" && value.NumberValue > 0.0) { ui.PixelsPerUnit=static_cast(value.NumberValue); return changed(); } + if (value.Type == MetaCoreScriptAbiValue_Vec3 && property == "WorldSize" && value.Vec3[0] > 0.0 && value.Vec3[1] > 0.0) { ui.WorldSize={static_cast(value.Vec3[0]),static_cast(value.Vec3[1]),static_cast(value.Vec3[2])}; return changed(); } + } + return false; } bool MetaCoreScriptRuntime::GetField(MetaCoreScriptObjectHandleV1 handle, std::uint64_t instance, std::string_view field, MetaCoreScriptValueV1& value) const { if (!IsHandleValid(handle)) return false; diff --git a/Source/MetaCoreScripting/Public/MetaCoreScripting/MetaCoreScripting.h b/Source/MetaCoreScripting/Public/MetaCoreScripting/MetaCoreScripting.h index 94312d9..6cbebcb 100644 --- a/Source/MetaCoreScripting/Public/MetaCoreScripting/MetaCoreScripting.h +++ b/Source/MetaCoreScripting/Public/MetaCoreScripting/MetaCoreScripting.h @@ -2,6 +2,7 @@ #include "MetaCoreScripting/MetaCoreScriptAbi.h" #include "MetaCoreScene/MetaCoreScene.h" +#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h" #include #include @@ -13,7 +14,6 @@ namespace MetaCore { class MetaCoreAnimationRuntime; -class MetaCoreRuntimeUiSystem; enum class MetaCoreScriptFieldType { Bool = 0, @@ -143,6 +143,8 @@ public: [[nodiscard]] MetaCorePhysicsWorld* GetPhysicsWorld() const; [[nodiscard]] MetaCoreAnimationRuntime* GetAnimationRuntime() const; [[nodiscard]] MetaCoreRuntimeUiSystem* GetRuntimeUi() const; + [[nodiscard]] std::optional ReadSceneUiBinding(std::string_view relativePath) const; + [[nodiscard]] bool WriteSceneUiBinding(std::string_view relativePath, const MetaCoreRuntimeUiValue& value); std::uint64_t ScheduleTimer(MetaCoreId objectId, std::uint64_t instanceId, double delay, double interval, bool unscaled, std::function callback); void CancelTimer(std::uint64_t timerId); diff --git a/docs/designs/metacore-mature-engine-gap-and-work-plan.md b/docs/designs/metacore-mature-engine-gap-and-work-plan.md index 9795192..2db764f 100644 --- a/docs/designs/metacore-mature-engine-gap-and-work-plan.md +++ b/docs/designs/metacore-mature-engine-gap-and-work-plan.md @@ -334,27 +334,28 @@ R8.1–R8.5 已落地的工程边界: ### 9. UI 系统 -当前状态:**核心资产、编译、运行时、Cook 和脚本接口已迁移到 v2,但图形签收门禁尚未满足,问题 9 保持打开。** +当前状态:**计划内工程实现已完成;当前执行环境没有图形会话,Linux x86_64 图形参考机签收尚未执行,因此问题 9 保持打开。** 2026-07-27 已实施: - `MetaCoreUiDocument` 成为正式创作源,具有 Schema/Generator Version、稳定节点 ID、Canvas 参数及 Theme/Font/Localization GUID;控件扩展为 Panel、Text、Image、Button、Input、List、Scroll,旧四控件文档具有确定性默认迁移。 -- 新增独立校验与确定性编译器,拒绝重复 ID、损坏层级、循环/不可达节点、非法绑定命名空间、错误双向目标和不兼容版本,并生成版本化 RML/RCSS 与 GUID 依赖集合。Editor 导出、Cook 和 Runtime 使用同一编译入口;旧直接 RML/清单编辑面板已隐藏。 -- RectTransform 已覆盖 AnchorMin/AnchorMax、Pivot、Offset/Size、最小/最大尺寸和裁剪;Canvas 数据已覆盖三种缩放模式、参考分辨率、宽高匹配、参考 DPI、缩放上下限和安全区声明。 -- Runtime UI 清单升级为 v2 UI Asset GUID 实例清单,包含 Instance/Scene Object、RenderMode、Layer 和目标分辨率;旧 `DocumentId + RmlPath` 继续读取。Cook 从启动场景 UI 组件生成 v2 清单与 `Ui/Compiled/` 产物,并收集、验证和复制 UI 依赖。 -- 运行时提供类型化 Bool/Int64/Double/String/Vec3/AssetRef/GameObjectRef 值、列表行、增量路径刷新、fallback、实例创建销毁、焦点、Locale、Theme 和诊断接口;输入优先于 3D 交互,事件涵盖 Pointer Enter/Leave、Click、Submit、Value/Selection Changed、Scroll、Focus/Blur 和不可写 Provider 的 WriteRequest。 -- 脚本 ABI Minor 升至 5,新增 `METACORE_SCRIPT_CAP_UI`,提供文档显示、节点值、列表、焦点、Locale 和 Theme 接口;UI 事件同时发布到全局 `MetaCore.UI` 和清单指定的场景对象。 -- UI Designer 已补齐新控件、Canvas、Anchor/Pivot、导航、输入/列表/滚动和类型化绑定 Inspector,并以正式编译产物作为导出/预览来源。 -- 新增独立 `MetaCoreRuntimeUiTests`,覆盖七类节点、文档校验/迁移、确定性编译、Anchor、双向绑定及 JSON/二进制往返;Smoke 覆盖启动场景 GUID 依赖扫描、v2 清单和编译产物。Linux Development 全量构建成功,CTest **12/12** 通过。 +- 新增独立校验与确定性编译器,拒绝重复 ID、损坏层级、循环/不可达节点、非法绑定命名空间、错误双向目标和不兼容版本,并生成版本化 RML/RCSS 与 GUID 依赖集合。Editor 导出、Cook 和 Runtime 使用同一编译入口;旧直接 RML/清单编辑面板已隐藏。一次性旧 RML 导入器会确定性重建受支持控件层级和稳定 ID,并对不支持标签、重复 ID、图片路径及需迁移到 Theme Token 的 RCSS 输出诊断。 +- RectTransform 已覆盖 AnchorMin/AnchorMax、Pivot、Offset/Size、最小/最大尺寸和裁剪;Canvas 的三种缩放模式、参考分辨率、宽高匹配、DPI 回退/上下限、安全区与宽高比约束均由 Editor/Player 共用的逐实例布局函数求值。 +- Runtime UI 清单升级为 v2 UI Asset GUID 实例清单,包含 Instance/Scene Object、RenderMode、Layer、`PixelsPerUnit`、World Size 和目标纹理分辨率;旧 `DocumentId + RmlPath` 继续只读兼容。Cook 从启动场景 UI 组件生成 v2 清单与 `Ui/Compiled/` 产物,并收集、验证和复制 UI 依赖。 +- `UiTheme`、`UiFontFamily`、`UiLocalizationTable` 已具有版本化 JSON 格式和确定性编译路径;主题 Token 与控件状态样式、本地化显式 fallback、字体 fallback/所需字符预热、确定性图片图集和 Show/Hide/Hover/Pressed/Focus/Custom 基础动效均进入正式 RML/RCSS 产物。 +- 运行时提供类型化 Bool/Int64/Double/String/Vec3/AssetRef/GameObjectRef 值、列表行复用、增量路径刷新、严格转换、fallback、实例创建销毁、焦点、Locale、Theme 和诊断接口。`runtime.*`、`script.*`、`scene.*` Provider 已统一注册;场景 Provider 使用稳定对象 ID,覆盖 Name、Transform、Camera、Light、Active、Rotator 和 UiRenderer 的严格类型读写。 +- 输入顺序固定为高层 Screen Space、World Space 命中 UI、3D 场景;仅在命中、焦点、滚动或捕获时消费。事件涵盖 Pointer Enter/Leave、Click、Submit、Value/Selection Changed、Scroll、Focus/Blur 和不可写 Provider 的 WriteRequest;显式 NavigationOrder、文档顺序、方向键与焦点失效转移使用确定性实现。 +- World Space 每个实例使用独立离屏 RmlUi Context 和持久 Filament Render Target,再以场景平面参与深度遮挡;尺寸由 `PixelsPerUnit` 和 World Size 共同确定,物理射线命中点转换为稳定 UV 后路由 Move/Down/Up/Scroll/Cancel,且被 UI 消费后不再触发 3D 交互。 +- 脚本 ABI Minor 升至 5,新增 `METACORE_SCRIPT_CAP_UI`,提供文档显示、节点值、列表、焦点、Locale 和 Theme 接口;UI 事件同时发布到全局 `MetaCore.UI` 和清单指定的场景对象,旧插件未声明能力时继续加载。 +- UI Designer 已补齐新控件、Canvas、Anchor/Pivot、导航、输入/列表/滚动、动效、主题/字体/本地化和类型化绑定 Inspector,并支持拖放重挂、复制删除、排序、资产级 Undo/Redo 和未保存切换保护。正式一致性判断由 Game View 与 Player 的共用编译/运行/渲染链路承担,不再使用旧 ImGui 近似绘制。 +- 新增独立 `MetaCoreRuntimeUiTests`,覆盖七类节点、文档校验/迁移、确定性资源编译、Anchor/DPI/安全区/宽高比、World Size/UV、类型转换、静态与动态列表、本地化 fallback、主题/字体/图集/动效及 JSON/二进制往返;Runtime Input 和 Scripting Tests 覆盖 World Space 输入消费与 `scene.*` 严格读写。 +- 2,000 节点、500 动态绑定和 100 行列表编译基准已加入回归测试,本轮独立复测 CPU p95 为 **11.93–29.61 ms**,低于当前 2,000 ms 防退化上限;Linux Development 的 Editor、Player 和相关测试目标构建成功,CTest **12/12** 通过。 尚未满足、因此不能正式关闭问题 9: -- World Space 目前仍复用 Screen Space overlay 路径;独立离屏 RmlUi Context、Filament Render Target、`PixelsPerUnit` 尺寸映射、遮挡和射线 UV 输入尚未完成。 -- Canvas 参数已进入格式和编译元数据,但运行时 DPI/物理尺寸、安全区、宽高比约束的逐实例求值尚未完成;方向键空间导航和焦点失效转移也缺少完整自动化覆盖。 -- `UiTheme`、`UiFontFamily`、`UiLocalizationTable` 仍只有文档 GUID 引用和运行时切换接口,Token 状态样式解析、字体 fallback/字符集图集、本地化表 fallback、图片图集及基础动效编译尚未形成完整资产管线。 -- `runtime.*` 已连接 RuntimeData,`script.*` 可由脚本发布;`scene.*` 的稳定对象句柄反射 Provider、严格类型转换/格式诊断及完整双向写请求测试仍需补齐。 -- 旧 RML 目前是运行时兼容与 Cook 复制,不是一次性结构化导入器;Designer 的拖放重挂、资产级 Undo/Redo、未保存切换保护及完整分辨率/DPI/Locale/Theme/输入模拟尚未全部验收。 -- 2,000 节点/500 绑定/100 行列表基准、Screen/World Space GPU 黄金图、4:3/16:9/21:9 与 DPI/中英文矩阵、SSIM ≥ 0.995、16.67 ms 帧预算和 Linux x86_64 图形参考机 Editor/Player 像素及交互签收尚未执行。 +- 当前环境在 `glfwInit` 处没有可用图形会话,无法生成可信的 Screen/World Space GPU 黄金图;仍需在固定 Linux x86_64 图形参考机执行 4:3、16:9、21:9,DPI 1.0/1.5/2.0 及中英文 Locale 矩阵,并证明 Editor Play Mode 与 Player 的 SSIM ≥ 0.995。 +- CPU 编译压力测试已经自动化;运行时 2,000 节点、500 动态绑定、100 行滚动列表的 CPU/GPU p95、显存和常驻内存基线仍需在同一图形参考机签收,并建立不超过签收基线 120% 且整体帧时间不超过 16.67 ms 的持续门禁。 +- 鼠标、滚轮、纯键盘、文本输入、重叠文档、World Space 遮挡/射线命中和“UI 消费后不触发 3D”已有代码路径与非 GPU 自动化覆盖;最终 Development 发布包的不同窗口尺寸、DPI 和真实显示/输入设备交互仍须在参考机人工签收。 ### 10. Scene 与 Prefab diff --git a/tests/MetaCoreRuntimeInputTests.cpp b/tests/MetaCoreRuntimeInputTests.cpp index 7b87c2d..8b6f858 100644 --- a/tests/MetaCoreRuntimeInputTests.cpp +++ b/tests/MetaCoreRuntimeInputTests.cpp @@ -107,6 +107,28 @@ int main() { pointerInput.BeginFrame(); interaction.Update(scene, pickBackend, pointerInput, viewport, true); Expect(pickCallbacks.empty(), "UI-consumed mouse should not submit picking"); + interaction.Cancel(); + pointerEvents.clear(); + pickCallbacks.clear(); + auto worldUiObject = scene.CreateGameObject("World UI"); + MetaCore::MetaCoreUiRendererComponent worldUi; + worldUi.RenderMode = MetaCore::MetaCoreUiRenderMode::WorldSpace; + worldUi.WorldSize = {2.0F, 1.0F, 0.0F}; + worldUiObject.AddComponent(worldUi); + pointerInput.BeginFrame(); + pointerInput.SetCursorPosition({50.0F, 50.0F}); + interaction.Update(scene, pickBackend, pointerInput, viewport, false); + auto worldHover = std::move(pickCallbacks.back()); + pickCallbacks.clear(); + worldHover({worldUiObject.GetId(), {0.5F, 0.25F, 0.0F}}); + pointerInput.BeginFrame(); + interaction.Update(scene, pickBackend, pointerInput, viewport, false); + Expect(!pointerEvents.empty() && pointerEvents.back().WorldSpaceUi, + "World Space UI should participate in the pointer priority pipeline"); + Expect(std::abs(pointerEvents.back().SurfaceUv.x - 0.75F) < 0.001F && + std::abs(pointerEvents.back().SurfaceUv.y - 0.25F) < 0.001F, + "World Space UI ray hit should map to deterministic surface UV"); + std::filesystem::remove(mapPath); std::filesystem::remove(overridePath); std::cout << "MetaCoreRuntimeInputTests passed\n"; return 0; diff --git a/tests/MetaCoreRuntimeUiTests.cpp b/tests/MetaCoreRuntimeUiTests.cpp index a55680f..7410c6e 100644 --- a/tests/MetaCoreRuntimeUiTests.cpp +++ b/tests/MetaCoreRuntimeUiTests.cpp @@ -3,9 +3,13 @@ #include "MetaCoreScene/MetaCoreSceneSerializer.h" #include +#include +#include +#include #include #include #include +#include namespace { @@ -58,6 +62,22 @@ MetaCore::MetaCoreUiDocument BuildDocument() { if (types[index] == MetaCoreUiNodeType::List) { node.Bindings.push_back({"script.rows", MetaCoreUiBindingTarget::Items, MetaCoreUiBindingMode::OneWay, {}, {}, {}}); + node.StaticItems = {{"first", "First", "1", {}}, {"second", "Second", "2", {}}}; + node.SelectedIndex = 1; + } + if (types[index] == MetaCoreUiNodeType::Button) { + node.Style.ThemeToken = "primary"; + node.LocalizationKey = "button.ok"; + node.Animations.push_back({ + "hover-opacity", + MetaCoreUiAnimationTrigger::Hover, + MetaCoreUiAnimationProperty::Opacity, + {0.7F, 0.0F, 0.0F}, + {1.0F, 0.0F, 0.0F}, + 0.15F, + 0.0F, + MetaCoreUiEasing::EaseOut + }); } root.Children.push_back(node.Id); document.Nodes.push_back(std::move(node)); @@ -71,6 +91,33 @@ MetaCore::MetaCoreUiDocument BuildDocument() { int main() { using namespace MetaCore; + const MetaCoreLegacyUiImportResult legacyImport = MetaCoreImportLegacyUiDocument( + R"( + )", + "#menu { width: 400px; }", + "LegacyMenu" + ); + Expect(legacyImport.Succeeded, "legacy RML structural import failed"); + Expect(legacyImport.Document.Nodes.size() == 5, "legacy importer produced unexpected node count"); + const auto importedButton = std::find_if( + legacyImport.Document.Nodes.begin(), + legacyImport.Document.Nodes.end(), + [](const MetaCoreUiNodeDocument& node) { return node.Id == "play"; } + ); + Expect(importedButton != legacyImport.Document.Nodes.end() && + importedButton->Type == MetaCoreUiNodeType::Button && + importedButton->Text == "Play & Go", "legacy button semantics were not imported"); + Expect(std::any_of( + legacyImport.Diagnostics.begin(), + legacyImport.Diagnostics.end(), + [](const MetaCoreUiDiagnostic& item) { + return !item.IsError && item.Message.find("UiTheme") != std::string::npos; + } + ), "legacy RCSS migration diagnostic was not emitted"); + const MetaCoreLegacyUiImportResult brokenLegacy = MetaCoreImportLegacyUiDocument("OK") != std::string::npos, "default locale text was not compiled"); + Expect(localized.LocalizationJson.find("确定") != std::string::npos, "localization sidecar was not compiled"); + Expect(localized.Rml.find("mcui-font-prewarm") != std::string::npos && + localized.Rml.find("中文ABC") != std::string::npos, "required font characters were not prewarmed for glyph atlas Cook"); + Expect(MetaCoreResolveUiLocalizedText(localization, "button.ok", "zh-CN").value_or("") == "确定", + "locale lookup failed"); + MetaCoreUiDocument atlasDocument = document; + const MetaCoreAssetGuid atlasImage = MetaCoreAssetGuid::Generate(); + atlasDocument.Nodes[2].Style.ImageAssetGuid = atlasImage; + resources.AtlasFilename = "atlas.tga"; + resources.AtlasWidth = 64; + resources.AtlasHeight = 64; + resources.AtlasEntries.push_back({atlasImage, 1, 1, 32, 32}); + const MetaCoreCompiledUiDocument atlased = MetaCoreCompileUiDocument(atlasDocument, resources); + Expect(atlased.Succeeded && atlased.Rcss.find("@spritesheet metacore-ui-atlas") != std::string::npos, + "image atlas spritesheet was not compiled"); + Expect(atlased.Rcss.find("decorator:image(" + atlasImage.ToString()) != std::string::npos, + "atlased image did not reference its deterministic sprite"); + Expect(atlased.Rml.find("Assets/" + atlasImage.ToString() + ".png") == std::string::npos, + "atlased image should not load a loose texture"); + + MetaCoreUiCanvasEnvironment environment{2560.0F, 1440.0F, 144.0F, 100.0F, 20.0F, 100.0F, 20.0F}; + MetaCoreUiCanvasLayout canvas = MetaCoreComputeUiCanvasLayout(document, environment); + Expect(canvas.Scale > 1.0F && canvas.ViewportLeft == 100.0F, "safe-area screen scaling failed"); + MetaCoreUiDocument physical = document; + physical.ScaleMode = MetaCoreUiCanvasScaleMode::ConstantPhysicalSize; + canvas = MetaCoreComputeUiCanvasLayout(physical, environment); + Expect(std::abs(canvas.Scale - 1.5F) < 0.001F, "physical DPI scaling failed"); + const auto worldSurface = MetaCoreComputeUiWorldSurfaceLayout({1.92F, 1.08F, 0.0F}, 1000.0F); + Expect(worldSurface.TargetWidth == 1920 && worldSurface.TargetHeight == 1080, + "World Space PixelsPerUnit sizing failed"); + const auto centerUv = MetaCoreMapWorldUiHitToUv(glm::mat4(1.0F), {2.0F, 1.0F, 0.0F}, {0.0F, 0.0F, 0.0F}); + Expect(centerUv.has_value() && std::abs(centerUv->x - 0.5F) < 0.001F && + std::abs(centerUv->y - 0.5F) < 0.001F, "World Space ray hit UV mapping failed"); + Expect(!MetaCoreMapWorldUiHitToUv(glm::mat4(1.0F), {2.0F, 1.0F, 0.0F}, {2.0F, 0.0F, 0.0F}).has_value(), + "World Space hit outside the UI plane should be rejected"); + + MetaCoreUiDocument benchmark; + benchmark.Name = "UiSteadyStateBenchmark"; + benchmark.ReferenceWidth = 1920; + benchmark.ReferenceHeight = 1080; + MetaCoreUiNodeDocument benchmarkRoot; + benchmarkRoot.Id = "root"; + benchmarkRoot.Type = MetaCoreUiNodeType::Panel; + benchmark.RootNodeIds.push_back(benchmarkRoot.Id); + benchmark.Nodes.push_back(benchmarkRoot); + for (int index = 1; index < 2000; ++index) { + MetaCoreUiNodeDocument node; + node.Id = "node-" + std::to_string(index); + node.ParentId = "root"; + node.Type = index == 1 ? MetaCoreUiNodeType::List : MetaCoreUiNodeType::Text; + node.Text = "Row " + std::to_string(index); + if (index <= 500) node.Bindings.push_back({"runtime.perf." + std::to_string(index), + MetaCoreUiBindingTarget::Text, MetaCoreUiBindingMode::OneWay, {}, {}, "0"}); + benchmark.Nodes.front().Children.push_back(node.Id); + benchmark.Nodes.push_back(std::move(node)); + } + for (int row = 0; row < 100; ++row) + benchmark.Nodes[1].StaticItems.push_back({"row-" + std::to_string(row), + "Row " + std::to_string(row), std::to_string(row), {}}); + std::vector compileSamples; + for (int sample = 0; sample < 5; ++sample) { + const auto start = std::chrono::steady_clock::now(); + const auto result = MetaCoreCompileUiDocument(benchmark); + const auto finish = std::chrono::steady_clock::now(); + Expect(result.Succeeded, "2,000-node benchmark document failed to compile"); + compileSamples.push_back(std::chrono::duration(finish - start).count()); + } + std::sort(compileSamples.begin(), compileSamples.end()); + const double compileP95 = compileSamples.back(); + Expect(compileP95 < 2000.0, "2,000-node deterministic compiler p95 exceeded the regression guard"); + std::cout << "MetaCoreRuntimeUi benchmark compile_p95_ms=" << compileP95 + << " nodes=2000 bindings=500 list_rows=100\n"; MetaCoreUiDocument invalid = document; invalid.Nodes.back().Id = invalid.Nodes.front().Id; @@ -91,6 +242,10 @@ int main() { invalid.Nodes[1].Bindings.push_back({"unknown.path", MetaCoreUiBindingTarget::Text, MetaCoreUiBindingMode::OneWay, {}, {}, {}}); Expect(!MetaCoreValidateUiDocument(invalid).empty(), "invalid binding namespace was accepted"); + invalid = document; + invalid.Nodes[1].Bindings.push_back({"runtime.value", MetaCoreUiBindingTarget::Text, + MetaCoreUiBindingMode::OneWay, {}, "arbitrary_expression()", {}}); + Expect(!MetaCoreValidateUiDocument(invalid).empty(), "arbitrary binding converter was accepted"); MetaCoreUiDocument legacy = document; legacy.SchemaVersion = 1; @@ -110,8 +265,27 @@ int main() { const auto loaded = MetaCoreSceneSerializer::LoadUiFromJson(jsonPath, registry); Expect(loaded.has_value() && loaded->SchemaVersion == 2, "v2 JSON schema did not round-trip"); Expect(loaded->Nodes[4].Placeholder == "Value", "Input settings did not round-trip"); + Expect(loaded->Nodes[5].StaticItems.size() == 2, "List static items did not round-trip"); std::filesystem::remove(jsonPath); + const std::filesystem::path themePath = std::filesystem::temp_directory_path() / "MetaCoreUiTheme.mcuitheme.json"; + const std::filesystem::path fontPath = std::filesystem::temp_directory_path() / "MetaCoreUiFont.mcuifont.json"; + const std::filesystem::path localizationPath = std::filesystem::temp_directory_path() / "MetaCoreUiLoc.mculoc.json"; + MetaCoreUiFontFamilyDocument font; + font.FamilyName = "UI"; + font.FallbackFamilies = {"sans-serif"}; + font.Faces.push_back({MetaCoreAssetGuid::Generate(), 400, false, "中文ABC"}); + Expect(MetaCoreSceneSerializer::SaveUiThemeToJson(themePath, theme), "theme JSON write failed"); + Expect(MetaCoreSceneSerializer::SaveUiFontFamilyToJson(fontPath, font), "font family JSON write failed"); + Expect(MetaCoreSceneSerializer::SaveUiLocalizationTableToJson(localizationPath, localization), "localization JSON write failed"); + Expect(MetaCoreSceneSerializer::LoadUiThemeFromJson(themePath)->Styles.size() == 1, "theme JSON round-trip failed"); + Expect(MetaCoreSceneSerializer::LoadUiFontFamilyFromJson(fontPath)->Faces.size() == 1, "font JSON round-trip failed"); + Expect(MetaCoreSceneSerializer::LoadUiLocalizationTableFromJson(localizationPath)->Locales.size() == 2, + "localization JSON round-trip failed"); + std::filesystem::remove(themePath); + std::filesystem::remove(fontPath); + std::filesystem::remove(localizationPath); + std::cout << "MetaCoreRuntimeUiTests passed\n"; return 0; } diff --git a/tests/MetaCoreScriptingTests.cpp b/tests/MetaCoreScriptingTests.cpp index eff3c97..8f451a3 100644 --- a/tests/MetaCoreScriptingTests.cpp +++ b/tests/MetaCoreScriptingTests.cpp @@ -88,6 +88,24 @@ void TestRuntimeLifecycleFaultsHandlesEventsAndTimers() { Expect(counters->Start == 1 && counters->Fixed == 1 && counters->Update == 1 && counters->Late == 1, "runtime should dispatch lifecycle in all phases"); Expect(runtime.GetFaultedInstanceCount() == 1 && !logs.empty(), "one throwing behaviour should be isolated and diagnosed"); + auto scenePosition = runtime.ReadSceneUiBinding(std::to_string(objectId) + ".Transform.Position"); + Expect(scenePosition.has_value() && scenePosition->Type == MetaCore::MetaCoreRuntimeUiValueType::Vec3, + "scene UI provider should read reflected transform values through a stable object handle"); + MetaCore::MetaCoreRuntimeUiValue changedPosition; + changedPosition.Type = MetaCore::MetaCoreRuntimeUiValueType::Vec3; + changedPosition.Vec3Value = {4.0F, 5.0F, 6.0F}; + Expect(runtime.WriteSceneUiBinding(std::to_string(objectId) + ".Transform.Position", changedPosition), + "scene UI provider should write compatible reflected transform values"); + Expect(object.GetComponent().Position == changedPosition.Vec3Value, + "scene UI provider write should update the scene component"); + Expect(!runtime.ReadSceneUiBinding("invalid.Transform.Position").has_value(), + "scene UI provider should reject malformed handles"); + MetaCore::MetaCoreRuntimeUiValue incompatible; + incompatible.Type = MetaCore::MetaCoreRuntimeUiValueType::String; + incompatible.StringValue = "invalid"; + Expect(!runtime.WriteSceneUiBinding(std::to_string(objectId) + ".Transform.Position", incompatible), + "scene UI provider should reject incompatible typed writes"); + std::vector eventOrder; runtime.Subscribe(objectId, 11, "first", [&](std::string_view, const auto&) { eventOrder.push_back("first"); runtime.Publish("second"); }); runtime.Subscribe(objectId, 11, "second", [&](std::string_view, const auto&) { eventOrder.push_back("second"); });