This commit is contained in:
Rowland 2026-07-27 15:01:03 +08:00
parent 92431296fa
commit ddeba5bbbc
24 changed files with 2769 additions and 201 deletions

View File

@ -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<PointerType> 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<std::int64_t>(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;

View File

@ -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<const std::byte>(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<const std::byte>(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<const std::byte>(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<bool>(output.write(text.data(), static_cast<std::streamsize>(text.size())));
};
const auto loadTheme = [&](const MetaCoreAssetGuid& guid) -> std::optional<MetaCoreUiThemeDocument> {
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<MetaCoreUiThemeDocument>(*package, registry, "MetaCoreUiThemeDocument")
: std::nullopt;
};
const auto loadFontFamily = [&](const MetaCoreAssetGuid& guid) -> std::optional<MetaCoreUiFontFamilyDocument> {
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<MetaCoreUiFontFamilyDocument>(*package, registry, "MetaCoreUiFontFamilyDocument")
: std::nullopt;
};
const auto loadLocalization = [&](const MetaCoreAssetGuid& guid) -> std::optional<MetaCoreUiLocalizationTableDocument> {
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<MetaCoreUiLocalizationTableDocument>(*package, registry, "MetaCoreUiLocalizationTableDocument")
: std::nullopt;
};
const auto buildAtlas = [&](const MetaCoreUiDocument& document, MetaCoreUiCompileResources& resources,
std::vector<std::uint8_t>& pixels) {
struct Image {
MetaCoreAssetGuid Guid{};
int Width = 0;
int Height = 0;
std::vector<std::uint8_t> Pixels{};
};
std::vector<MetaCoreAssetGuid> 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<Image> 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<std::size_t>(width) * static_cast<std::size_t>(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<std::size_t>(resources.AtlasWidth) *
static_cast<std::size_t>(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<std::size_t>(row) * static_cast<std::size_t>(image.Width) * 4U;
const auto targetOffset = (static_cast<std::size_t>(placement.Y + row) *
static_cast<std::size_t>(resources.AtlasWidth) + static_cast<std::size_t>(placement.X)) * 4U;
std::copy_n(image.Pixels.data() + sourceOffset, static_cast<std::size_t>(image.Width) * 4U,
pixels.data() + targetOffset);
}
}
return true;
};
const auto writeAtlas = [](const std::filesystem::path& path, int width, int height,
const std::vector<std::uint8_t>& pixels) {
if (width <= 0 || height <= 0 || width > 65535 || height > 65535 ||
pixels.size() != static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4U) return false;
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) return false;
std::array<std::uint8_t, 18> header{};
header[2] = 2;
header[12] = static_cast<std::uint8_t>(width & 0xff);
header[13] = static_cast<std::uint8_t>((width >> 8) & 0xff);
header[14] = static_cast<std::uint8_t>(height & 0xff);
header[15] = static_cast<std::uint8_t>((height >> 8) & 0xff);
header[16] = 32;
header[17] = 0x28;
output.write(reinterpret_cast<const char*>(header.data()), static_cast<std::streamsize>(header.size()));
std::vector<std::uint8_t> 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<const char*>(bgra.data()), static_cast<std::streamsize>(bgra.size()));
return output.good();
};
std::optional<MetaCoreSceneDocument> startupScene;
if (!project.StartupScenePath.empty()) {
const std::filesystem::path scenePath = project.RootPath / project.StartupScenePath;
@ -8944,22 +9121,44 @@ bool MetaCoreBuiltinCookService::CookRuntimeUiAssets() {
document = MetaCoreReadTypedPackagePayload<MetaCoreUiDocument>(*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<std::uint8_t> 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<std::uint32_t>(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<PointerType> 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<std::int64_t>(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({});

View File

@ -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<int>(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<int>(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<std::ptrdiff_t>(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<std::ptrdiff_t>(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<int>(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<int>(animation.Trigger);
if (ImGui::Combo("触发", &trigger, triggerLabels, IM_ARRAYSIZE(triggerLabels))) {
animation.Trigger = static_cast<MetaCoreUiAnimationTrigger>(std::clamp(trigger, 0, 5));
Dirty_ = true;
}
const char* propertyLabels[] = {"Opacity", "Translation", "Scale", "Color"};
int property = static_cast<int>(animation.Property);
if (ImGui::Combo("属性", &property, propertyLabels, IM_ARRAYSIZE(propertyLabels))) {
animation.Property = static_cast<MetaCoreUiAnimationProperty>(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<std::ptrdiff_t>(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<MetaCoreAssetGuid, MetaCoreAssetGuidHasher> 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<MetaCoreColliderComponent>(collider);
}
editorContext.SelectOnly(createdObjectId);
@ -5921,6 +5950,9 @@ private:
float CanvasZoom_ = 1.0F;
bool SnapToGrid_ = true;
float GridSize_ = 8.0F;
std::vector<MetaCoreUiDocument> UndoHistory_{};
std::vector<MetaCoreUiDocument> RedoHistory_{};
bool HistoryApplying_ = false;
};
struct MetaCoreGeneratedResourceListEntry {

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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<void(
filament::Engine& engine,
filament::Renderer& renderer,
filament::Scene& scene
)>;
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;

View File

@ -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()

View File

@ -6,8 +6,10 @@
#include "MetaCoreRender/MetaCoreEditorViewportRenderer.h"
#include "MetaCoreRender/MetaCoreRenderTypes.h"
#include "MetaCoreScene/MetaCoreScene.h"
#include "MetaCoreScene/MetaCoreTransformUtils.h"
#include <algorithm>
#include <glm/gtc/matrix_inverse.hpp>
#include <cmath>
#include <deque>
#include <limits>
@ -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<MetaCoreUiRendererComponent>()) {
const auto& ui = object.GetComponent<MetaCoreUiRendererComponent>();
if (ui.RenderMode == MetaCoreUiRenderMode::WorldSpace && ui.Visible && ui.InputEnabled)
return (mask & (MetaCoreInteraction_Hover | MetaCoreInteraction_Click)) != 0;
}
return object && object.HasComponent<MetaCoreInteractableComponent>() && object.GetComponent<MetaCoreInteractableComponent>().Enabled &&
object.HasComponent<MetaCoreMeshRendererComponent>() && object.GetComponent<MetaCoreMeshRendererComponent>().Visible &&
(object.GetComponent<MetaCoreInteractableComponent>().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<glm::vec2, bool> {
auto object = scene.FindGameObject(result.Object);
if (!object || !object.HasComponent<MetaCoreUiRendererComponent>()) return {{-1.0F, -1.0F}, false};
const auto& ui = object.GetComponent<MetaCoreUiRendererComponent>();
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<MetaCoreTransformComponent>());
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)) {

View File

@ -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;

View File

@ -4,7 +4,7 @@ material {
blending : transparent,
transparency : default,
depthWrite : false,
depthCulling : false,
depthCulling : true,
doubleSided : true,
requires : [ uv0, color ],
parameters : [

View File

@ -6,8 +6,11 @@
#include "stb/stb_image.h"
#include <RmlUi/Core.h>
#include <RmlUi/Core/Elements/ElementFormControlSelect.h>
#include <nlohmann/json.hpp>
#include <GLFW/glfw3.h>
#include <glm/gtc/type_ptr.hpp>
#include <filament/Camera.h>
#include <filament/Engine.h>
@ -15,6 +18,7 @@
#include <filament/Material.h>
#include <filament/MaterialInstance.h>
#include <filament/RenderableManager.h>
#include <filament/RenderTarget.h>
#include <filament/Renderer.h>
#include <filament/Scene.h>
#include <filament/Texture.h>
@ -28,6 +32,7 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstdlib>
@ -38,6 +43,7 @@
#include <limits>
#include <sstream>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@ -85,6 +91,101 @@ namespace {
return stream.str();
}
[[nodiscard]] std::optional<MetaCoreRuntimeUiValue> 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<std::int64_t>(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<MetaCoreRuntimeUiValue> 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<char>(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<double>(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<std::int64_t>(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<MetaCoreRuntimeUiValueType>(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<double>(width),
static_cast<double>(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<WorldSurface>& surfaces,
const std::function<bool(std::uint64_t, glm::mat4&)>& worldMatrixProvider
) {
EnsureFilamentResources(engine);
std::unordered_set<std::uint64_t> 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<std::uint32_t>(surface.Width),
static_cast<std::uint32_t>(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<const filament::math::mat4f*>(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<std::uint32_t>(surface.Width))
.height(static_cast<std::uint32_t>(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<GpuVertex, 4> 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<std::uint32_t, 6> 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<GpuVertex, 4>(vertices);
auto* indexCopy = new std::array<std::uint32_t, 6>(indices);
target.VertexBuffer->setBufferAt(engine, 0, filament::VertexBuffer::BufferDescriptor(
vertexCopy, sizeof(*vertexCopy), [](void* buffer, std::size_t, void*) {
delete static_cast<std::array<GpuVertex, 4>*>(buffer);
}, nullptr));
target.IndexBuffer->setBuffer(engine, filament::IndexBuffer::BufferDescriptor(
indexCopy, sizeof(*indexCopy), [](void* buffer, std::size_t, void*) {
delete static_cast<std::array<std::uint32_t, 6>*>(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<GpuVertex> translatedVertices = geometry.Vertices;
@ -515,6 +764,9 @@ private:
std::unordered_map<Rml::CompiledGeometryHandle, Geometry> Geometries_;
std::unordered_map<Rml::TextureHandle, Texture> Textures_;
std::vector<DrawCommand> DrawCommands_;
std::unordered_map<std::uint64_t, std::vector<DrawCommand>> WorldDrawCommands_;
std::uint64_t ActiveWorldInstance_ = 0;
std::unordered_map<std::uint64_t, WorldTarget> 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<std::string, std::unordered_map<std::string, std::string>> Localization{};
MetaCoreUiDocument CanvasSettings{};
};
static std::pair<std::string, std::string_view> 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<Rml::String>("value", "");
uiEvent.EventType = EventTypeFromRml(uiEvent.Type);
const std::string kind = target->GetAttribute<Rml::String>("data-metacore-kind", "");
if (uiEvent.EventType == MetaCoreRuntimeUiEventType::ValueChanged && kind == "list") {
uiEvent.EventType = MetaCoreRuntimeUiEventType::SelectionChanged;
if (const auto* select = dynamic_cast<Rml::ElementFormControlSelect*>(target))
uiEvent.Value = std::to_string(select->GetSelection());
else uiEvent.Value = target->GetAttribute<Rml::String>("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<Rml::String>("data-metacore-bind-value", "");
const std::string writeMode = target->GetAttribute<Rml::String>("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<Rml::String>(
std::string("data-metacore-bind-") + bindingTarget, "");
const std::string writeMode = target->GetAttribute<Rml::String>(
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<MetaCoreUiInputValueType>(
target->GetAttribute<int>("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<bool>(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<std::unique_ptr<EventListener>> Listeners_;
std::unordered_map<std::string, MetaCoreRuntimeUiValue> Values_;
std::unordered_map<std::string, std::vector<MetaCoreRuntimeUiRow>> Lists_;
std::unordered_map<std::string, Provider> Providers_;
std::unordered_set<std::string> ProviderPaths_;
std::vector<MetaCoreRuntimeUiEvent> Events_;
std::vector<std::string> Errors_;
std::vector<MetaCoreUiDiagnostic> Diagnostics_;
@ -644,13 +947,133 @@ public:
std::string Theme_{};
bool Initialized_ = false;
void RefreshProviders() {
std::vector<std::string> 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<Candidate> 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<int>("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<std::ptrdiff_t>(candidates.size())) %
static_cast<std::ptrdiff_t>(candidates.size());
candidates[static_cast<std::size_t>(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<std::string> {
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<Rml::String>("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<float>(framebufferWidth) / static_cast<float>(std::max(1, windowWidth));
const float dpiScaleY = static_cast<float>(framebufferHeight) / static_cast<float>(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<float>(state.Entry.TargetWidth) : static_cast<float>(framebufferWidth);
const float height = state.Entry.RenderMode == 1U && state.Entry.TargetHeight > 0
? static_cast<float>(state.Entry.TargetHeight) : static_cast<float>(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<Rml::String>(
"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<Rml::String>("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<Rml::String>("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<std::string>();
}
}
} 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<std::filesystem::path> 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<Rml::String>(
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<Rml::String>("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<MetaCoreUiCanvasScaleMode>(
body->GetAttribute<int>("data-metacore-scale-mode", 1));
state.CanvasSettings.MatchWidthOrHeight = body->GetAttribute<float>("data-metacore-match", 0.5F);
state.CanvasSettings.ReferenceDpi = body->GetAttribute<float>("data-metacore-reference-dpi", 96.0F);
state.CanvasSettings.MinScale = body->GetAttribute<float>("data-metacore-min-scale", 0.5F);
state.CanvasSettings.MaxScale = body->GetAttribute<float>("data-metacore-max-scale", 4.0F);
state.CanvasSettings.MinAspectRatio = body->GetAttribute<float>("data-metacore-min-aspect", 0.0F);
state.CanvasSettings.MaxAspectRatio = body->GetAttribute<float>("data-metacore-max-aspect", 0.0F);
state.CanvasSettings.UseSafeArea =
body->GetAttribute<Rml::String>("data-metacore-safe-area", "true") == "true";
}
if (entry.Visible) document->Show(); else document->Hide();
Listeners_.push_back(std::make_unique<EventListener>(*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<MetaCoreRmlFilamentRenderInterface::WorldSurface> 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<int>(cursor.x), static_cast<int>(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<int>(cursor.x), static_cast<int>(cursor.y), 0);
for (int button = 0; button < 3; ++button) {
const auto mouseButton = static_cast<MetaCoreMouseButton>(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<Rml::String>("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<Rml::Character>(codePoint)) || consumed.Keyboard;
consumed.Keyboard = !keyboardContext->ProcessTextInput(static_cast<Rml::Character>(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<int>(surfaceUv.x * static_cast<float>(width)), 0, width - 1);
const int y = std::clamp(static_cast<int>(surfaceUv.y * static_cast<float>(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<int>(button), 0) && propagated;
else if (type == MetaCoreRuntimeUiWorldPointerType::Up)
propagated = state->Context->ProcessMouseButtonUp(static_cast<int>(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<MetaCoreRuntimeDataUpdate>& 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<Rml::String>("data-metacore-bind-items", "") != sourcePath) continue;
std::ostringstream markup;
auto* select = dynamic_cast<Rml::ElementFormControlSelect*>(element);
if (select == nullptr) continue;
select->RemoveAll();
std::size_t index = 0;
for (const auto& row : Impl_->Lists_[sourcePath]) {
markup << "<div class=\"mcui-list-row\" data-index=\"" << index++ << "\">";
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<std::string> 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 << "<span data-field=\"" << EscapeRmlText(key) << "\">"
<< EscapeRmlText(FormatUiValue(row.at(key), {})) << "</span>";
markup << "</div>";
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) {

View File

@ -1,12 +1,14 @@
#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <functional>
#include <iomanip>
#include <sstream>
#include <unordered_map>
#include <unordered_set>
#include <glm/gtc/matrix_inverse.hpp>
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<char>(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<unsigned char>(value[first]))) ++first;
std::size_t last = value.size();
while (last > first && std::isspace(static_cast<unsigned char>(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<char>(std::tolower(c));
});
return result;
}
std::string DecodeLegacyText(std::string_view value) {
std::string result(value);
const std::pair<std::string_view, std::string_view> entities[] = {
{"&lt;", "<"}, {"&gt;", ">"}, {"&quot;", "\""}, {"&#39;", "'"}, {"&amp;", "&"}
};
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<unsigned char>(tag[position])) || tag[position] == '<')) ++position;
const std::size_t keyStart = position;
while (position < tag.size() &&
(std::isalnum(static_cast<unsigned char>(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<unsigned char>(tag[position]))) ++position;
if (position >= tag.size() || tag[position] != '=') continue;
++position;
while (position < tag.size() && std::isspace(static_cast<unsigned char>(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<MetaCoreUiNodeType> 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<OpenNode> stack{{"__root", root.Id}};
std::unordered_set<std::string> 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("<!--")) {
const std::size_t commentEnd = rml.find("-->", 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<unsigned char>(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 </" + tagName + ">");
} else {
stack.erase(open.base() - 1, stack.end());
}
continue;
}
const std::optional<MetaCoreUiNodeType> 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<MetaCoreUiDiagnostic> 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<MetaCoreUiDiagnostic> 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<std::string, const MetaCoreUiNodeDocument*> nodes;
for (const MetaCoreUiNodeDocument& node : document.Nodes) {
@ -110,8 +398,28 @@ std::vector<MetaCoreUiDiagnostic> 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<std::string> 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<std::string> 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<std::string> 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<MetaCoreUiDiagnostic> 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<std::string> children;
@ -152,9 +463,48 @@ std::vector<MetaCoreUiDiagnostic> 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<std::string> stateKeys;
for (const auto& style : resources.Theme->Styles) {
const std::string key = style.Token + ':' + std::to_string(static_cast<int>(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<std::string> 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<std::string> 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<std::string, const MetaCoreUiNodeDocument*> 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 << " <span id=\"mcui-font-prewarm\" aria-hidden=\"true\">" << Escape(requiredCharacters) << "</span>\n";
}
std::int32_t documentOrder = 0;
std::function<void(const std::string&, int)> 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<std::size_t>(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<int>(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 << "<option id=\"" << Escape(node.Id + "-item-" + item.Id) << "\" value=\""
<< Escape(item.Value) << "\" data-metacore-index=\"" << index << "\"";
if (static_cast<std::int32_t>(index) == node.SelectedIndex) rml << " selected=\"true\"";
if (!item.LocalizationKey.empty()) rml << " data-metacore-loc=\"" << Escape(item.LocalizationKey) << "\"";
rml << '>' << Escape(text) << "</option>";
}
}
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 << "</body></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<const MetaCoreUiLocalizationLocaleDocument*> 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<const MetaCoreUiLocalizationEntryDocument*> 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<float>(std::max(1, document.ReferenceWidth));
const float heightScale = result.ViewportHeight / static_cast<float>(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<std::string> 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<std::string> {
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<std::string>(foundEntry->Value);
};
std::vector<std::string_view> 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::int32_t>(std::clamp(
std::round(std::max(0.001F, worldSize.x) * result.PixelsPerUnit), 1.0F, static_cast<float>(maximum)));
result.TargetHeight = static_cast<std::int32_t>(std::clamp(
std::round(std::max(0.001F, worldSize.y) * result.PixelsPerUnit), 1.0F, static_cast<float>(maximum)));
return result;
}
std::optional<glm::vec2> 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

View File

@ -10,8 +10,10 @@
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include <glm/mat4x4.hpp>
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<MetaCoreAssetGuid> Dependencies{};
std::vector<MetaCoreUiDiagnostic> 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<MetaCoreUiThemeDocument> Theme{};
std::optional<MetaCoreUiFontFamilyDocument> FontFamily{};
std::optional<MetaCoreUiLocalizationTableDocument> Localization{};
std::string AtlasFilename{};
std::int32_t AtlasWidth = 0;
std::int32_t AtlasHeight = 0;
std::vector<AtlasEntry> AtlasEntries{};
};
struct MetaCoreLegacyUiImportResult {
MetaCoreUiDocument Document{};
std::vector<MetaCoreUiDiagnostic> 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<MetaCoreUiDiagnostic> 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<std::string> 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<glm::vec2> MetaCoreMapWorldUiHitToUv(
const glm::mat4& worldMatrix,
glm::vec3 worldSize,
glm::vec3 worldHitPosition
);
class MetaCoreRuntimeUiSystem {
public:
using EventCallback = std::function<void(const MetaCoreRuntimeUiEvent&)>;
using ProviderReadCallback = std::function<std::optional<MetaCoreRuntimeUiValue>(std::string_view)>;
using ProviderWriteCallback = std::function<bool(std::string_view, const MetaCoreRuntimeUiValue&)>;
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<MetaCoreRuntimeDataUpdate>& updates);
[[nodiscard]] bool SetValue(const std::string& sourcePath, const MetaCoreRuntimeUiValue& value);
[[nodiscard]] std::optional<MetaCoreRuntimeUiValue> GetValue(const std::string& sourcePath) const;
[[nodiscard]] bool SetListRows(const std::string& sourcePath, std::vector<MetaCoreRuntimeUiRow> 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);

View File

@ -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<int>(animation.Trigger)},
{"Property", static_cast<int>(animation.Property)},
{"From", Vec3ToJson(animation.From)},
{"To", Vec3ToJson(animation.To)},
{"DurationSeconds", animation.DurationSeconds},
{"DelaySeconds", animation.DelaySeconds},
{"Easing", static_cast<int>(animation.Easing)}
};
}
static MetaCoreUiAnimationDocument JsonToUiAnimation(const json& j) {
MetaCoreUiAnimationDocument animation;
if (j.contains("Id")) animation.Id = j["Id"].get<std::string>();
if (j.contains("Trigger")) animation.Trigger = static_cast<MetaCoreUiAnimationTrigger>(j["Trigger"].get<int>());
if (j.contains("Property")) animation.Property = static_cast<MetaCoreUiAnimationProperty>(j["Property"].get<int>());
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<float>();
if (j.contains("DelaySeconds")) animation.DelaySeconds = j["DelaySeconds"].get<float>();
if (j.contains("Easing")) animation.Easing = static_cast<MetaCoreUiEasing>(j["Easing"].get<int>());
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<std::string>();
if (j.contains("Text")) item.Text = j["Text"].get<std::string>();
if (j.contains("Value")) item.Value = j["Value"].get<std::string>();
if (j.contains("LocalizationKey")) item.LocalizationKey = j["LocalizationKey"].get<std::string>();
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<bool>();
if (j.contains("MaxLength")) node.MaxLength = j["MaxLength"].get<std::int32_t>();
if (j.contains("SelectedIndex")) node.SelectedIndex = j["SelectedIndex"].get<std::int32_t>();
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<std::string>();
if (j.contains("LocalizationKey")) node.LocalizationKey = j["LocalizationKey"].get<std::string>();
if (j.contains("ShowAnimation")) node.ShowAnimation = j["ShowAnimation"].get<std::string>();
if (j.contains("HideAnimation")) node.HideAnimation = j["HideAnimation"].get<std::string>();
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<MetaCoreUiDocument> MetaCoreSceneSerializer::LoadUiFromJson(
if (uiJson.contains("MinScale")) doc.MinScale = uiJson["MinScale"].get<float>();
if (uiJson.contains("MaxScale")) doc.MaxScale = uiJson["MaxScale"].get<float>();
if (uiJson.contains("UseSafeArea")) doc.UseSafeArea = uiJson["UseSafeArea"].get<bool>();
if (uiJson.contains("MinAspectRatio")) doc.MinAspectRatio = uiJson["MinAspectRatio"].get<float>();
if (uiJson.contains("MaxAspectRatio")) doc.MaxAspectRatio = uiJson["MaxAspectRatio"].get<float>();
if (uiJson.contains("ThemeAssetGuid")) doc.ThemeAssetGuid = StringToGuid(uiJson["ThemeAssetGuid"].get<std::string>());
if (uiJson.contains("FontFamilyAssetGuid")) doc.FontFamilyAssetGuid = StringToGuid(uiJson["FontFamilyAssetGuid"].get<std::string>());
if (uiJson.contains("LocalizationAssetGuid")) doc.LocalizationAssetGuid = StringToGuid(uiJson["LocalizationAssetGuid"].get<std::string>());
@ -1085,4 +1137,171 @@ std::optional<MetaCoreUiDocument> 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<int>(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<bool>(output << root.dump(4));
} catch (...) {
return false;
}
}
std::optional<MetaCoreUiThemeDocument> 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<std::uint32_t>();
if (root.contains("Name")) result.Name = root["Name"].get<std::string>();
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<std::string>();
if (value.contains("State")) style.State = static_cast<MetaCoreUiVisualState>(value["State"].get<int>());
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<float>();
if (value.contains("BorderRadius")) style.BorderRadius = value["BorderRadius"].get<float>();
if (value.contains("FontSize")) style.FontSize = value["FontSize"].get<float>();
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<bool>(output << root.dump(4));
} catch (...) {
return false;
}
}
std::optional<MetaCoreUiFontFamilyDocument> 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<std::uint32_t>();
if (root.contains("FamilyName")) result.FamilyName = root["FamilyName"].get<std::string>();
if (root.contains("FallbackFamilies")) result.FallbackFamilies = root["FallbackFamilies"].get<std::vector<std::string>>();
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<std::string>());
if (value.contains("Weight")) face.Weight = value["Weight"].get<std::int32_t>();
if (value.contains("Italic")) face.Italic = value["Italic"].get<bool>();
if (value.contains("RequiredCharacters")) face.RequiredCharacters = value["RequiredCharacters"].get<std::string>();
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<bool>(output << root.dump(4));
} catch (...) {
return false;
}
}
std::optional<MetaCoreUiLocalizationTableDocument> 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<std::uint32_t>();
if (root.contains("DefaultLocale")) result.DefaultLocale = root["DefaultLocale"].get<std::string>();
if (root.contains("FallbackLocale")) result.FallbackLocale = root["FallbackLocale"].get<std::string>();
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<std::string>();
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<std::string>();
if (entryValue.contains("Value")) entry.Value = entryValue["Value"].get<std::string>();
locale.Entries.push_back(std::move(entry));
}
}
result.Locales.push_back(std::move(locale));
}
}
return result;
} catch (...) {
return std::nullopt;
}
}
} // namespace MetaCore

View File

@ -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<MetaCoreUiListItemDocument> StaticItems{};
MC_PROPERTY()
std::string ItemTemplateNodeId{};
@ -318,6 +402,122 @@ struct MetaCoreUiNodeDocument {
MC_PROPERTY()
std::string HideAnimation{};
MC_PROPERTY()
std::vector<MetaCoreUiAnimationDocument> 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<MetaCoreUiThemeStyleDocument> 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<std::string> FallbackFamilies{};
MC_PROPERTY()
std::vector<MetaCoreUiFontFaceDocument> 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<MetaCoreUiLocalizationEntryDocument> 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<MetaCoreUiLocalizationLocaleDocument> 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{};

View File

@ -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<MetaCoreUiThemeDocument> LoadUiThemeFromJson(
const std::filesystem::path& absolutePath
);
static bool SaveUiFontFamilyToJson(
const std::filesystem::path& absolutePath,
const MetaCoreUiFontFamilyDocument& document
);
static std::optional<MetaCoreUiFontFamilyDocument> LoadUiFontFamilyFromJson(
const std::filesystem::path& absolutePath
);
static bool SaveUiLocalizationTableToJson(
const std::filesystem::path& absolutePath,
const MetaCoreUiLocalizationTableDocument& document
);
static std::optional<MetaCoreUiLocalizationTableDocument> LoadUiLocalizationTableFromJson(
const std::filesystem::path& absolutePath
);
};
} // namespace MetaCore

View File

@ -10,6 +10,7 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <charconv>
#include <cctype>
#include <cmath>
#include <cstdio>
@ -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<MetaCoreRuntimeUiValue> 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<float>(source.Vec3[0]), static_cast<float>(source.Vec3[1]), static_cast<float>(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<void()> 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<bool>(Impl_->Scene.FindGameObject(handle.ObjectId)); }
@ -601,6 +674,9 @@ bool MetaCoreScriptRuntime::HasComponent(MetaCoreScriptObjectHandleV1 handle, st
if (component == "Script") return object.HasComponent<MetaCoreScriptComponent>();
if (component == "Camera") return object.HasComponent<MetaCoreCameraComponent>();
if (component == "Light") return object.HasComponent<MetaCoreLightComponent>();
if (component == "Active") return object.HasComponent<MetaCoreActiveComponent>();
if (component == "Rotator") return object.HasComponent<MetaCoreRotatorComponent>();
if (component == "UiRenderer") return object.HasComponent<MetaCoreUiRendererComponent>();
if (component == "MeshRenderer") return object.HasComponent<MetaCoreMeshRendererComponent>();
if (component == "Animator") return object.HasComponent<MetaCoreAnimatorComponent>();
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<MetaCoreTransformComponent>(); 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<MetaCoreTransformComponent>();
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<MetaCoreActiveComponent>() && property == "Active")
return boolean(object.GetComponent<MetaCoreActiveComponent>().Active);
if (component == "Rotator" && object.HasComponent<MetaCoreRotatorComponent>()) {
const auto& rotator = object.GetComponent<MetaCoreRotatorComponent>();
if (property == "Enabled") return boolean(rotator.Enabled);
if (property == "DegreesPerSecond") return number(rotator.DegreesPerSecond);
return false;
}
if (component == "Camera" && object.HasComponent<MetaCoreCameraComponent>()) {
const auto& camera = object.GetComponent<MetaCoreCameraComponent>();
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<MetaCoreLightComponent>()) {
const auto& light = object.GetComponent<MetaCoreLightComponent>();
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<MetaCoreUiRendererComponent>()) {
const auto& ui = object.GetComponent<MetaCoreUiRendererComponent>();
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<MetaCoreTransformComponent>(); 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<float>(value.Vec3[0]), static_cast<float>(value.Vec3[1]), static_cast<float>(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<MetaCoreTransformComponent>(); 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<float>(value.Vec3[0]), static_cast<float>(value.Vec3[1]), static_cast<float>(value.Vec3[2])}; return changed();
}
if (component == "Active" && object.HasComponent<MetaCoreActiveComponent>() &&
property == "Active" && value.Type == MetaCoreScriptAbiValue_Bool) {
object.GetComponent<MetaCoreActiveComponent>().Active = value.IntValue != 0; return changed();
}
if (component == "Rotator" && object.HasComponent<MetaCoreRotatorComponent>()) {
auto& rotator = object.GetComponent<MetaCoreRotatorComponent>();
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<float>(value.NumberValue); return changed(); }
}
if (component == "Camera" && object.HasComponent<MetaCoreCameraComponent>()) {
auto& camera = object.GetComponent<MetaCoreCameraComponent>();
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<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Float && property == "NearClip") { camera.NearClip=static_cast<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Float && property == "FarClip") { camera.FarClip=static_cast<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Float && property == "OrthographicSize") { camera.OrthographicSize=static_cast<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Float && property == "Depth") { camera.Depth=static_cast<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Vec3 && property == "BackgroundColor") { camera.BackgroundColor={static_cast<float>(value.Vec3[0]),static_cast<float>(value.Vec3[1]),static_cast<float>(value.Vec3[2])}; return changed(); }
}
if (component == "Light" && object.HasComponent<MetaCoreLightComponent>()) {
auto& light = object.GetComponent<MetaCoreLightComponent>();
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<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Float && property == "Range") { light.Range=static_cast<float>(value.NumberValue); return changed(); }
if (value.Type == MetaCoreScriptAbiValue_Vec3 && property == "Color") { light.Color={static_cast<float>(value.Vec3[0]),static_cast<float>(value.Vec3[1]),static_cast<float>(value.Vec3[2])}; return changed(); }
}
if (component == "UiRenderer" && object.HasComponent<MetaCoreUiRendererComponent>()) {
auto& ui = object.GetComponent<MetaCoreUiRendererComponent>();
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<float>(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<float>(value.Vec3[0]),static_cast<float>(value.Vec3[1]),static_cast<float>(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;

View File

@ -2,6 +2,7 @@
#include "MetaCoreScripting/MetaCoreScriptAbi.h"
#include "MetaCoreScene/MetaCoreScene.h"
#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h"
#include <filesystem>
#include <functional>
@ -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<MetaCoreRuntimeUiValue> 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<void()> callback);
void CancelTimer(std::uint64_t timerId);

View File

@ -334,27 +334,28 @@ R8.1R8.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/<guid>` 产物,并收集、验证和复制 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/<guid>` 产物,并收集、验证和复制 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.9329.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:9DPI 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

View File

@ -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<MetaCore::MetaCoreUiRendererComponent>(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;

View File

@ -3,9 +3,13 @@
#include "MetaCoreScene/MetaCoreSceneSerializer.h"
#include <cstdlib>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <iostream>
#include <string>
#include <vector>
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"(<rml><head><link type="text/rcss" href="legacy.rcss"/></head><body>
<div id="menu"><button id="play">Play &amp; Go</button><input id="name" placeholder="Name"/>
<img id="logo" src="logo.png"/><custom id="ignored"/></div></body></rml>)",
"#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("<rml><body><div", {});
Expect(!brokenLegacy.Succeeded, "malformed legacy RML import was accepted");
const MetaCoreUiDocument document = BuildDocument();
Expect(MetaCoreValidateUiDocument(document).empty(), "valid document was rejected");
const MetaCoreCompiledUiDocument first = MetaCoreCompileUiDocument(document);
@ -79,10 +126,114 @@ int main() {
Expect(first.Rml == second.Rml && first.Rcss == second.Rcss, "compiler output is not deterministic");
Expect(first.Rml.find("<input") != std::string::npos, "Input control was not compiled");
Expect(first.Rml.find("role=\"listbox\"") != std::string::npos, "List control was not compiled");
Expect(first.Rml.find("<option id=\"Node4-item-first\"") != std::string::npos,
"static List items were not compiled");
Expect(first.Rml.find("mcui-scroll") != std::string::npos, "Scroll control was not compiled");
Expect(first.Rml.find("Safe &lt;text&gt;") != std::string::npos, "text was not escaped");
Expect(first.Rcss.find("left:calc(25") != std::string::npos, "anchors were not compiled");
Expect(first.Rml.find("data-metacore-bind-mode-value=\"two-way\"") != std::string::npos, "two-way binding was not compiled");
Expect(first.Rcss.find("@keyframes mcui-Node2-hover-opacity") != std::string::npos, "basic animation was not compiled");
MetaCoreUiCompileResources resources;
MetaCoreUiThemeDocument theme;
theme.Name = "Dark";
MetaCoreUiThemeStyleDocument primaryStyle;
primaryStyle.Token = "primary";
primaryStyle.BackgroundColor = {0.1F, 0.2F, 0.3F};
primaryStyle.BorderRadius = 5.0F;
primaryStyle.FontSize = 18.0F;
theme.Styles.push_back(primaryStyle);
resources.Theme = theme;
MetaCoreUiLocalizationTableDocument localization;
localization.DefaultLocale = "en-US";
localization.FallbackLocale = "en-US";
localization.Locales = {
{"en-US", {{"button.ok", "OK"}}},
{"zh-CN", {{"button.ok", "确定"}}}
};
resources.Localization = localization;
MetaCoreUiFontFamilyDocument compileFont;
compileFont.FamilyName = "UI";
compileFont.FallbackFamilies = {"sans-serif"};
compileFont.Faces.push_back({MetaCoreAssetGuid::Generate(), 400, false, "中文ABC"});
resources.FontFamily = compileFont;
const MetaCoreCompiledUiDocument localized = MetaCoreCompileUiDocument(document, resources);
Expect(localized.Succeeded, "resource-aware compilation failed");
Expect(localized.Rml.find("data-metacore-theme=\"Dark\"") != std::string::npos, "theme name was not compiled");
Expect(localized.Rcss.find("theme-primary") != std::string::npos, "theme token state was not compiled");
Expect(localized.Rml.find(">OK</button>") != 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<double> 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<double, std::milli>(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;
}

View File

@ -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<MetaCore::MetaCoreTransformComponent>().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<std::string> 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"); });