diff --git a/.gitignore b/.gitignore index bda0abf..1d3666e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ .theos/ -packages/ +packages/* +!packages/.gitkeep +!packages/org.gamecult.eve.surface/ +!packages/org.gamecult.eve.surface/** +!packages/org.gamecult.eve.unity-uitoolkit/ +!packages/org.gamecult.eve.unity-uitoolkit/** obj/ artifacts/ tools/deps/ diff --git a/packages/.gitkeep b/packages/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs new file mode 100644 index 0000000..bdfb19f --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + public sealed class EveSurfaceDocument + { + public EveSurfaceDocument( + string type, + string schema, + string providerId, + string providerKind, + string title, + long version, + string updatedAtUtc, + EveSurfaceTree surface, + IReadOnlyList commands) + { + Type = type; + Schema = schema; + ProviderId = providerId; + ProviderKind = providerKind; + Title = title; + Version = version; + UpdatedAtUtc = updatedAtUtc; + Surface = surface; + Commands = commands; + } + + public string Type { get; } + + public string Schema { get; } + + public string ProviderId { get; } + + public string ProviderKind { get; } + + public string Title { get; } + + public long Version { get; } + + public string UpdatedAtUtc { get; } + + public EveSurfaceTree Surface { get; } + + public IReadOnlyList Commands { get; } + } + + public sealed class EveSurfaceTree + { + public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList styles) + { + Id = id; + Root = root; + Styles = styles; + } + + public string Id { get; } + + public EveSurfaceComponent Root { get; } + + public IReadOnlyList Styles { get; } + } + + public sealed class EveSurfaceComponent + { + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children) + { + Id = id; + Kind = kind; + Props = props; + Children = children; + } + + public string Id { get; } + + public string Kind { get; } + + public IReadOnlyDictionary Props { get; } + + public IReadOnlyList Children { get; } + + public string GetProp(string key, string fallback = "") + { + return Props.TryGetValue(key, out var value) ? value : fallback; + } + } + + public sealed class EveStyleToken + { + public EveStyleToken(string name, string value) + { + Name = name; + Value = value; + } + + public string Name { get; } + + public string Value { get; } + } + + public sealed class EveCommandTemplate + { + public EveCommandTemplate(string command, string label, string transport) + { + Command = command; + Label = label; + Transport = transport; + } + + public string Command { get; } + + public string Label { get; } + + public string Transport { get; } + } + + public sealed class EveSurfaceCommandRequest + { + public EveSurfaceCommandRequest( + string providerId, + string surfaceId, + string command, + IReadOnlyDictionary payload, + DateTimeOffset issuedAt, + string clientId) + { + ProviderId = providerId; + SurfaceId = surfaceId; + Command = command; + Payload = payload; + IssuedAt = issuedAt; + ClientId = clientId; + } + + public string ProviderId { get; } + + public string SurfaceId { get; } + + public string Command { get; } + + public IReadOnlyDictionary Payload { get; } + + public DateTimeOffset IssuedAt { get; } + + public string ClientId { get; } + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef new file mode 100644 index 0000000..d8f56be --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef @@ -0,0 +1,14 @@ +{ + "name": "GameCult.Eve.Surface", + "rootNamespace": "GameCult.Eve.Surface", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json new file mode 100644 index 0000000..bea317d --- /dev/null +++ b/packages/org.gamecult.eve.surface/package.json @@ -0,0 +1,15 @@ +{ + "name": "org.gamecult.eve.surface", + "displayName": "Eve Surface Contract", + "version": "0.1.0", + "unity": "2021.3", + "description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.", + "keywords": [ + "Eve", + "CultUI", + "CultMesh", + "Surface" + ], + "license": "MPL-2.0", + "category": "Scripting" +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/README.md b/packages/org.gamecult.eve.unity-uitoolkit/README.md new file mode 100644 index 0000000..2bce5f8 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/README.md @@ -0,0 +1,12 @@ +# Eve UI Toolkit Lowering + +This package lowers `gamecult.eve.surface.v1` retained surface documents into +Unity UI Toolkit `VisualElement` trees. + +It owns native projection only. Providers still own truth, accepted state, +style token values, and command effects through CultMesh/CultNet. Unknown +component kinds degrade to inert containers instead of gaining local semantics. + +This package lives in the Eve repository as the shared Unity lowering target. +Aetheria and other Unity consumers should import it from Eve instead of +carrying local copies. diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs new file mode 100644 index 0000000..f73fc28 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using GameCult.Eve.Surface; +using UnityEngine; +using UnityEngine.UIElements; + +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class EveUiToolkitSurfaceLowerer + { + private readonly EveUiToolkitSurfaceOptions _options; + + public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) + { + _options = options ?? EveUiToolkitSurfaceOptions.Default; + } + + public VisualElement Lower(EveSurfaceDocument document, Action? commandSink = null) + { + if (document == null) throw new ArgumentNullException(nameof(document)); + + var root = LowerComponent(document.Surface.Root, document, commandSink); + root.name = string.IsNullOrWhiteSpace(root.name) ? document.Surface.Id : root.name; + root.AddToClassList("eve-surface-root"); + ApplyStyleTokens(root, document.Surface.Styles); + return root; + } + + private VisualElement LowerComponent( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var element = CreateElement(component, document, commandSink); + element.name = SafeName(component.Id); + element.AddToClassList("eve-component"); + element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}"); + element.userData = component; + + foreach (var child in component.Children) + element.Add(LowerComponent(child, document, commandSink)); + + return element; + } + + private VisualElement CreateElement( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + switch (NormalizeKind(component.Kind)) + { + case "surface": + { + var element = new VisualElement(); + element.style.flexGrow = 1; + element.style.flexDirection = FlexDirection.Column; + return element; + } + case "grid": + { + var element = new VisualElement(); + element.style.flexDirection = FlexDirection.Row; + element.style.flexWrap = Wrap.Wrap; + element.style.alignItems = Align.Stretch; + return element; + } + case "card": + { + var card = new VisualElement(); + card.AddToClassList("eve-card"); + card.style.flexDirection = FlexDirection.Column; + var title = component.GetProp("title"); + if (!string.IsNullOrWhiteSpace(title)) + card.Add(TitleLabel(title)); + return card; + } + case "metric": + { + var metric = new VisualElement(); + metric.AddToClassList("eve-metric"); + metric.style.flexDirection = FlexDirection.Column; + metric.Add(MutedLabel(component.GetProp("label"))); + metric.Add(ValueLabel(component.GetProp("value"))); + return metric; + } + case "inspector.kv": + { + var inspector = new VisualElement(); + inspector.AddToClassList("eve-inspector"); + inspector.style.flexDirection = FlexDirection.Column; + return inspector; + } + case "row": + { + var row = new VisualElement(); + row.AddToClassList("eve-row"); + row.style.flexDirection = FlexDirection.Row; + row.style.flexWrap = Wrap.Wrap; + foreach (var prop in component.Props) + row.Add(FieldLabel(prop.Key, prop.Value)); + return row; + } + case "text": + { + return BodyLabel(component.GetProp("text", component.GetProp("value", component.GetProp("title")))); + } + case "control.button": + { + var label = component.GetProp("label", component.GetProp("title", "Invoke")); + var command = component.GetProp("command", component.GetProp("action", "invoke")); + return new Button(() => EmitCommand(document, component, command, commandSink)) { text = label }; + } + case "control.text": + { + var label = component.GetProp("label", component.GetProp("title", "Value")); + var command = component.GetProp("command", component.GetProp("action", "set")); + var value = component.GetProp("value"); + var field = new TextField(label); + field.SetValueWithoutNotify(value); + field.RegisterValueChangedCallback(evt => + { + if (string.Equals(evt.newValue, value, StringComparison.Ordinal)) + return; + + var payload = new Dictionary(component.Props, StringComparer.Ordinal) + { + ["value"] = evt.newValue ?? "" + }; + EmitCommand(document, component, command, payload, commandSink); + }); + return field; + } + default: + { + var element = new VisualElement(); + element.AddToClassList("eve-unknown"); + var title = component.GetProp("title", component.GetProp("label")); + if (!string.IsNullOrWhiteSpace(title)) + element.Add(BodyLabel(title)); + return element; + } + } + } + + private static void EmitCommand( + EveSurfaceDocument document, + EveSurfaceComponent component, + string command, + Action? commandSink) + { + EmitCommand( + document, + component, + command, + new Dictionary(component.Props, StringComparer.Ordinal), + commandSink); + } + + private static void EmitCommand( + EveSurfaceDocument document, + EveSurfaceComponent component, + string command, + IReadOnlyDictionary payload, + Action? commandSink) + { + if (commandSink == null || string.IsNullOrWhiteSpace(command)) + return; + + commandSink(new EveSurfaceCommandRequest( + document.ProviderId, + document.Surface.Id, + command, + payload, + DateTimeOffset.UtcNow, + "unity-uitoolkit")); + } + + private static Label TitleLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-title"); + label.style.unityFontStyleAndWeight = FontStyle.Bold; + return label; + } + + private static Label BodyLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-text"); + return label; + } + + private static Label MutedLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-muted"); + return label; + } + + private static Label ValueLabel(string text) + { + var label = new Label(text); + label.AddToClassList("eve-value"); + label.style.unityFontStyleAndWeight = FontStyle.Bold; + return label; + } + + private static VisualElement FieldLabel(string key, string value) + { + var field = new VisualElement(); + field.AddToClassList("eve-field"); + field.style.flexDirection = FlexDirection.Row; + field.Add(MutedLabel(key)); + field.Add(BodyLabel(value)); + return field; + } + + private static void ApplyStyleTokens(VisualElement root, IReadOnlyList tokens) + { + foreach (var token in tokens) + { + if (string.IsNullOrWhiteSpace(token.Name) || string.IsNullOrWhiteSpace(token.Value)) + continue; + + root.AddToClassList($"eve-style-token-{SafeClass(token.Name)}"); + } + } + + private static string NormalizeKind(string kind) + { + if (string.IsNullOrWhiteSpace(kind)) + return ""; + + if (kind == "panel.dialogue") + return "panel"; + if (kind.StartsWith("text.", StringComparison.Ordinal)) + return "text"; + if (kind.StartsWith("control.button.", StringComparison.Ordinal)) + return "control.button"; + if (kind.StartsWith("control.text.", StringComparison.Ordinal)) + return "control.text"; + + return kind; + } + + private static string SafeName(string value) + { + return string.IsNullOrWhiteSpace(value) ? "eve.component" : value; + } + + private static string SafeClass(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "unknown"; + + var characters = value.ToCharArray(); + for (var index = 0; index < characters.Length; index++) + { + if (!char.IsLetterOrDigit(characters[index])) + characters[index] = '-'; + } + + return new string(characters).Trim('-').ToLowerInvariant(); + } + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs new file mode 100644 index 0000000..53cac24 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceOptions.cs @@ -0,0 +1,9 @@ +#nullable enable + +namespace GameCult.Eve.UnityUIToolkit +{ + public sealed class EveUiToolkitSurfaceOptions + { + public static EveUiToolkitSurfaceOptions Default { get; } = new EveUiToolkitSurfaceOptions(); + } +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef new file mode 100644 index 0000000..9fc5a53 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/GameCult.Eve.UnityUIToolkit.asmdef @@ -0,0 +1,16 @@ +{ + "name": "GameCult.Eve.UnityUIToolkit", + "rootNamespace": "GameCult.Eve.UnityUIToolkit", + "references": [ + "GameCult.Eve.Surface" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/packages/org.gamecult.eve.unity-uitoolkit/package.json b/packages/org.gamecult.eve.unity-uitoolkit/package.json new file mode 100644 index 0000000..cf91087 --- /dev/null +++ b/packages/org.gamecult.eve.unity-uitoolkit/package.json @@ -0,0 +1,18 @@ +{ + "name": "org.gamecult.eve.unity-uitoolkit", + "displayName": "Eve UI Toolkit Lowering", + "version": "0.1.0", + "unity": "2021.3", + "description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.", + "dependencies": { + "org.gamecult.eve.surface": "0.1.0" + }, + "keywords": [ + "Eve", + "CultUI", + "UI Toolkit", + "Unity" + ], + "license": "MPL-2.0", + "category": "Scripting" +}