diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs new file mode 100644 index 0000000..c8c53e9 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + public static class EveInventoryInteraction + { + public const string GridKind = "inventory.grid"; + public const string ItemKind = "inventory.item"; + public const string DragSessionKind = "inventory.drag_session"; + + public static bool TryCreateDropRequest( + EveSurfaceDocument document, + EveSurfaceComponent source, + EveSurfaceComponent target, + int destinationX, + int destinationY, + string clientId, + out EveSurfaceCommandRequest? request) + { + request = null; + if (document == null || source == null || target == null || + !string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) || + !string.Equals(target.Kind, GridKind, StringComparison.Ordinal)) + { + return false; + } + + var sourceKind = source.GetProp("sourceKind", source.GetProp("source")); + var command = target.GetProp($"dropCommand.{sourceKind}", target.GetProp("dropCommand")); + if (string.IsNullOrWhiteSpace(sourceKind) || string.IsNullOrWhiteSpace(command)) + return false; + + var sourceIndex = Int(source.GetProp("sourceIndex"), -1); + var targetIndex = Int(target.GetProp("targetIndex"), -1); + var targetKind = target.GetProp("targetKind"); + var payload = new Dictionary(StringComparer.Ordinal) + { + ["sourceKind"] = sourceKind, + ["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")), + ["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture), + ["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1) + .ToString(CultureInfo.InvariantCulture), + ["itemKey"] = source.GetProp("itemKey"), + ["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture), + ["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture), + ["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture), + ["destinationKind"] = targetKind, + ["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")), + ["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture), + ["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1) + .ToString(CultureInfo.InvariantCulture), + ["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture), + ["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture), + ["hasDestinationPosition"] = "true" + }; + var template = document.Commands.FirstOrDefault(candidate => + string.Equals(candidate.Command, command, StringComparison.Ordinal)); + var operation = template == null + ? CultMesh.OperationInvocation(command) + : CultMesh.OperationInvocation(template.Operation); + request = new EveSurfaceCommandRequest( + document.ProviderId, + document.Surface.Id, + operation, + CultMesh.OperationPayload(payload), + DateTimeOffset.UtcNow, + string.IsNullOrWhiteSpace(clientId) ? "unity-uitoolkit" : clientId); + return true; + } + + private static int Int(string value, int fallback) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : fallback; + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta new file mode 100644 index 0000000..57cb330 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 17fd4173e6e44b4b9c503d343021ffcb diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index 6586d67..cf7690a 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -13,6 +13,10 @@ namespace GameCult.Eve.UnityUIToolkit public sealed class EveUiToolkitSurfaceLowerer { private readonly EveUiToolkitSurfaceOptions _options; + private EveSurfaceComponent? _inventoryDragSource; + private Vector2 _inventoryDragStart; + private bool _inventoryPointerMoved; + private bool _suppressInventoryClick; public EveUiToolkitSurfaceLowerer(EveUiToolkitSurfaceOptions? options = null) { @@ -43,7 +47,11 @@ namespace GameCult.Eve.UnityUIToolkit ApplyGeneratedLayout(element, component); foreach (var child in component.Children) - element.Add(LowerComponent(child, document, commandSink)); + { + var loweredChild = LowerComponent(child, document, commandSink); + PositionInventoryChild(component, child, loweredChild); + element.Add(loweredChild); + } foreach (var slot in component.EmbeddedDocuments) { @@ -97,6 +105,12 @@ namespace GameCult.Eve.UnityUIToolkit element.style.alignItems = Align.Stretch; return element; } + case EveInventoryInteraction.GridKind: + return InventoryGrid(component, document, commandSink); + case EveInventoryInteraction.ItemKind: + return InventoryItem(component, document, commandSink); + case EveInventoryInteraction.DragSessionKind: + return InventoryDragSession(component); case "partition": { var element = new VisualElement(); @@ -210,6 +224,167 @@ namespace GameCult.Eve.UnityUIToolkit } } + private VisualElement InventoryGrid( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var grid = new VisualElement(); + grid.AddToClassList("eve-inventory-grid"); + grid.style.position = Position.Relative; + var columns = Math.Max(1, ParseInt(component.GetProp("columns"), 6)); + var rows = Math.Max(1, ParseInt(component.GetProp("rows"), 3)); + var cellSize = Math.Max(1f, ParseNumber(component.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(component.GetProp("cellGap"), 4f)); + grid.style.width = columns * cellSize + Math.Max(0, columns - 1) * gap; + grid.style.height = rows * cellSize + Math.Max(0, rows - 1) * gap; + for (var index = 0; index < Math.Min(columns * rows, 256); index++) + { + var cell = new VisualElement(); + cell.AddToClassList("eve-inventory-cell"); + cell.style.position = Position.Absolute; + cell.style.left = (index % columns) * (cellSize + gap); + cell.style.top = (index / columns) * (cellSize + gap); + cell.style.width = cellSize; + cell.style.height = cellSize; + grid.Add(cell); + } + grid.RegisterCallback(evt => + { + if (_inventoryDragSource == null || _suppressInventoryClick) + return; + if (TryEmitInventoryDrop(document, _inventoryDragSource, component, grid, evt.position, commandSink)) + _inventoryDragSource = null; + }); + return grid; + } + + private VisualElement InventoryItem( + EveSurfaceComponent component, + EveSurfaceDocument document, + Action? commandSink) + { + var item = new Button { text = component.GetProp("label", component.GetProp("itemKey")) }; + item.AddToClassList("eve-inventory-item"); + item.RegisterCallback(evt => + { + if (!ParseBool(component.GetProp("draggable", "true"))) + return; + _inventoryDragSource = component; + _inventoryDragStart = evt.position; + _inventoryPointerMoved = false; + item.CapturePointer(evt.pointerId); + evt.StopPropagation(); + }); + item.RegisterCallback(evt => + { + if (_inventoryDragSource != component) + return; + var pointerPosition = new Vector2(evt.position.x, evt.position.y); + if ((pointerPosition - _inventoryDragStart).sqrMagnitude > 16f) + _inventoryPointerMoved = true; + }); + item.RegisterCallback(evt => + { + if (_inventoryDragSource != component) + return; + item.ReleasePointer(evt.pointerId); + if (_inventoryPointerMoved) + { + var picked = item.panel?.Pick(evt.position); + var targetElement = InventoryGridAncestor(picked); + if (targetElement?.userData is EveSurfaceComponent target) + TryEmitInventoryDrop(document, component, target, targetElement, evt.position, commandSink); + _inventoryDragSource = null; + _suppressInventoryClick = true; + } + evt.StopPropagation(); + }); + item.RegisterCallback(evt => + { + if (_suppressInventoryClick) + { + _suppressInventoryClick = false; + evt.StopPropagation(); + return; + } + _inventoryDragSource = component; + evt.StopPropagation(); + }); + return item; + } + + private static VisualElement InventoryDragSession(EveSurfaceComponent component) + { + var panel = new VisualElement(); + panel.AddToClassList("eve-inventory-drag-session"); + var active = ParseBool(component.GetProp("active")); + panel.Add(BodyLabel(active + ? component.GetProp("itemKey", "Dragging") + : "No active drag")); + return panel; + } + + private static void PositionInventoryChild( + EveSurfaceComponent parent, + EveSurfaceComponent child, + VisualElement element) + { + if (!string.Equals(parent.Kind, EveInventoryInteraction.GridKind, StringComparison.Ordinal) || + !string.Equals(child.Kind, EveInventoryInteraction.ItemKind, StringComparison.Ordinal)) + return; + var cellSize = Math.Max(1f, ParseNumber(parent.GetProp("cellSize"), 72f)); + var gap = Math.Max(0f, ParseNumber(parent.GetProp("cellGap"), 4f)); + element.style.position = Position.Absolute; + element.style.left = Math.Max(0, ParseInt(child.GetProp("x"), 0)) * (cellSize + gap); + element.style.top = Math.Max(0, ParseInt(child.GetProp("y"), 0)) * (cellSize + gap); + element.style.width = cellSize; + element.style.height = cellSize; + } + + private static VisualElement? InventoryGridAncestor(VisualElement? element) + { + while (element != null) + { + if (element.userData is EveSurfaceComponent component && + string.Equals(component.Kind, EveInventoryInteraction.GridKind, StringComparison.Ordinal)) + return element; + element = element.parent; + } + return null; + } + + private static bool TryEmitInventoryDrop( + EveSurfaceDocument document, + EveSurfaceComponent source, + EveSurfaceComponent target, + VisualElement targetElement, + Vector2 panelPosition, + Action? commandSink) + { + if (commandSink == null) + return false; + var local = targetElement.WorldToLocal(panelPosition); + var pitch = Math.Max(1f, ParseNumber(target.GetProp("cellSize"), 72f) + + Math.Max(0f, ParseNumber(target.GetProp("cellGap"), 4f))); + var x = Math.Max(0, (int)Math.Floor(local.x / pitch)); + var y = Math.Max(0, (int)Math.Floor(local.y / pitch)); + if (!EveInventoryInteraction.TryCreateDropRequest( + document, source, target, x, y, "unity-uitoolkit", out var request) || request == null) + return false; + commandSink(request); + return true; + } + + private static int ParseInt(string value, int fallback) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + + private static bool ParseBool(string value) => + bool.TryParse(value, out var parsed) && parsed; + + private static float ParseNumber(string value, float fallback) => + float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + private static void EmitCommand( EveSurfaceDocument document, EveSurfaceComponent component, diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index 0b9dc54..7752398 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -142,6 +142,72 @@ namespace GameCult.Eve.UnityUIToolkit.Tests Assert.That(root.style.bottom.value.value, Is.EqualTo(32f)); } + [Test] + public void InventoryDropBuildsAdvertisedTypedOperationPayload() + { + var source = Component("ore", EveInventoryInteraction.ItemKind, new Dictionary + { + ["sourceKind"] = "cargo", + ["sourceEntityKey"] = "zone.0.entity.1", + ["sourceIndex"] = "2", + ["itemKey"] = "ore", + ["quantity"] = "4", + ["x"] = "3", + ["y"] = "5" + }); + var target = Component("equipment", EveInventoryInteraction.GridKind, new Dictionary + { + ["targetKind"] = "equipment", + ["targetEntityKey"] = "zone.0.entity.1", + ["targetIndex"] = "-1", + ["dropCommand.cargo"] = "aetheria.daemon.commands.EquipItem" + }); + + Assert.That(EveInventoryInteraction.TryCreateDropRequest( + Document(target), source, target, 7, 9, "unity-test", out var request), Is.True); + Assert.That(request, Is.Not.Null); + Assert.That(request!.Command, Is.EqualTo("aetheria.daemon.commands.EquipItem")); + Assert.That(request.Payload.GetString("originEntityKey"), Is.EqualTo("zone.0.entity.1")); + Assert.That(request.Payload.GetString("originCargoIndex"), Is.EqualTo("2")); + Assert.That(request.Payload.GetString("destinationX"), Is.EqualTo("7")); + Assert.That(request.Payload.GetString("destinationY"), Is.EqualTo("9")); + Assert.That(request.Payload.GetString("hasDestinationPosition"), Is.EqualTo("true")); + } + + [Test] + public void InventoryGridAndItemsLowerToNativeSpatialElements() + { + var item = Component("ore", EveInventoryInteraction.ItemKind, new Dictionary + { + ["label"] = "Iron Ore", + ["sourceKind"] = "cargo", + ["x"] = "1", + ["y"] = "2" + }); + var grid = new EveSurfaceComponent( + "cargo", + EveInventoryInteraction.GridKind, + new Dictionary + { + ["columns"] = "4", + ["rows"] = "3", + ["cellSize"] = "32", + ["cellGap"] = "2" + }, + new[] { item }); + + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(grid)); + var loweredItem = root.Query