From d6f4a8658af71e1babb85e321e194ef6f6da5df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Kub=C5=9B?= Date: Sat, 13 Jun 2026 19:06:09 +0200 Subject: [PATCH 01/13] feat(ship-factory): implement module rotation functionality --- Assets/DesignSystem/Docs/COMPONENTS.md | 12 ++ .../Core/Constants/GameplayConstants.cs | 1 + .../LegalPositionCalculator/Calculator.cs | 27 ++-- Assets/Scripts/ShipFactory/ModuleOverlay.cs | 10 +- .../ShipFactory/ModuleRotationUtility.cs | 61 ++++++++ .../ShipFactory/ModuleRotationUtility.cs.meta | 2 + Assets/Scripts/ShipFactory/ShipFactory.uss | 8 ++ Assets/Scripts/ShipFactory/ShipFactory.uxml | 14 ++ .../ShipFactoryCanvasController.cs | 103 ++++++++++++-- .../ShipFactory/ShipFactoryController.cs | 11 ++ Assets/Scripts/ShipFactory/Snapper.cs | 20 ++- .../CalculatorTests.cs | 131 +++++++++++++++++- .../Tests/ModuleRotationUtilityTests.cs | 100 +++++++++++++ .../Tests/ModuleRotationUtilityTests.cs.meta | 2 + .../Tests/ShipFactory.Tests.asmdef | 2 +- .../Scripts/ShipFactory/Tests/SnapperTests.cs | 64 +++++++++ .../ShipFactory/Tests/SnapperTests.cs.meta | 2 + .../ShipFactory/UI/Runtime/OverlayManager.cs | 19 +-- .../UI/ToolkitComponents/ModuleInfo.cs | 36 ++++- README.md | 2 + 20 files changed, 579 insertions(+), 48 deletions(-) create mode 100644 Assets/Scripts/ShipFactory/ModuleRotationUtility.cs create mode 100644 Assets/Scripts/ShipFactory/ModuleRotationUtility.cs.meta create mode 100644 Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs create mode 100644 Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs.meta create mode 100644 Assets/Scripts/ShipFactory/Tests/SnapperTests.cs create mode 100644 Assets/Scripts/ShipFactory/Tests/SnapperTests.cs.meta diff --git a/Assets/DesignSystem/Docs/COMPONENTS.md b/Assets/DesignSystem/Docs/COMPONENTS.md index a2c043cc..f7aa6d68 100644 --- a/Assets/DesignSystem/Docs/COMPONENTS.md +++ b/Assets/DesignSystem/Docs/COMPONENTS.md @@ -170,6 +170,18 @@ Set placeholders via `field.textEdition.placeholder = "..."` in C#. Unity 6's AP | `.ds-stepper` | Quantity selector container. | | `.ds-stepper__btn` | − / + button. | | `.ds-stepper__value` | Value display. | +| `.ds-row` | Horizontal flex row with centred children; adds bottom margin for stacked showcase rows. | +| `.ds-row__gap` | Margin-based horizontal spacing between direct children (`--space-2`). Use instead of CSS `gap`, which UI Toolkit does not support. Pair with `.ds-row` or any `flex-direction: row` container. | +| `.ds-col-gap` | Margin-based vertical spacing between direct children (`--space-2`). Use on column-flex containers. | + +DOM (row gap): + +```xml + + + + +``` ## Icons diff --git a/Assets/Scripts/Core/Constants/GameplayConstants.cs b/Assets/Scripts/Core/Constants/GameplayConstants.cs index 3bd751d7..78513b98 100644 --- a/Assets/Scripts/Core/Constants/GameplayConstants.cs +++ b/Assets/Scripts/Core/Constants/GameplayConstants.cs @@ -12,5 +12,6 @@ public static class GameplayConstants public const float ChanceOfSpawningExplosionOnDetachingConnectionPoint = 0.3f; public const float EngineThrustEfficiencyMultiplier = 1000f; + public const float CannonProjectileSpeedMultiplier = 1000f; } } \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/LegalPositionCalculator/Calculator.cs b/Assets/Scripts/ShipFactory/LegalPositionCalculator/Calculator.cs index faf5ce0d..5081be6d 100644 --- a/Assets/Scripts/ShipFactory/LegalPositionCalculator/Calculator.cs +++ b/Assets/Scripts/ShipFactory/LegalPositionCalculator/Calculator.cs @@ -13,6 +13,8 @@ public enum PositionLegality public static class Calculator { + private const float EdgeEpsilon = 0.001f; + public static PositionLegality CalculateLegalityPosition(ShipModuleSOInstanceBundle bundleToCheck, IEnumerable placedElements) { @@ -45,36 +47,35 @@ public static PositionLegality CalculateLegalityPosition(ShipModuleSOInstanceBun private static (Vector2, Vector2) GetBottomLeftAndTopRightPositions( ShipModuleSOInstanceBundle bundleToCheck) { - var dimensions = bundleToCheck.ModuleSO.Dimensions; - var position = (Vector2)bundleToCheck.Instance.transform.position; - - var bottomLeft = position - (Vector2)dimensions / 2; - var topRight = position + (Vector2)dimensions / 2; - - return (bottomLeft, topRight); + return ModuleRotationUtility.GetAxisAlignedBounds(bundleToCheck); } private static bool Overlap(Vector2 aMin, Vector2 aMax, Vector2 bMin, Vector2 bMax) { - return aMin.x < bMax.x && - aMax.x > bMin.x && - aMin.y < bMax.y && - aMax.y > bMin.y; + return aMin.x < bMax.x - EdgeEpsilon && + aMax.x > bMin.x + EdgeEpsilon && + aMin.y < bMax.y - EdgeEpsilon && + aMax.y > bMin.y + EdgeEpsilon; } private static bool TouchSides(Vector2 aMin, Vector2 aMax, Vector2 bMin, Vector2 bMax) { var touchVertical = (Mathf.Approximately(aMax.x, bMin.x) || Mathf.Approximately(aMin.x, bMax.x)) && - aMax.y > bMin.y && aMin.y < bMax.y; + GetSharedSpan(aMin.y, aMax.y, bMin.y, bMax.y) >= EdgeEpsilon; var touchHorizontal = (Mathf.Approximately(aMax.y, bMin.y) || Mathf.Approximately(aMin.y, bMax.y)) && - aMax.x > bMin.x && aMin.x < bMax.x; + GetSharedSpan(aMin.x, aMax.x, bMin.x, bMax.x) >= EdgeEpsilon; return touchVertical || touchHorizontal; } + private static float GetSharedSpan(float aMin, float aMax, float bMin, float bMax) + { + return Mathf.Max(0f, Mathf.Min(aMax, bMax) - Mathf.Max(aMin, bMin)); + } + private static bool KeepsSingleConnectedShip(List bundles) { if (bundles.Count <= 1) diff --git a/Assets/Scripts/ShipFactory/ModuleOverlay.cs b/Assets/Scripts/ShipFactory/ModuleOverlay.cs index 79ce27d9..442a36f1 100644 --- a/Assets/Scripts/ShipFactory/ModuleOverlay.cs +++ b/Assets/Scripts/ShipFactory/ModuleOverlay.cs @@ -17,7 +17,7 @@ public static ModuleOverlay Create(ShipModuleSOInstanceBundle bundle, Transform { var go = new GameObject($"Overlay_{bundle.ModuleSO.Name}"); go.transform.SetParent(parent, false); - go.transform.position = bundle.Instance.transform.position; + SyncTransformFromBundle(go.transform, bundle); var overlay = go.AddComponent(); overlay._renderer = go.AddComponent(); @@ -36,6 +36,12 @@ public void SetColor(Color color) _renderer.color = color; } + public static void SyncTransformFromBundle(Transform overlayTransform, ShipModuleSOInstanceBundle bundle) + { + overlayTransform.position = bundle.Instance.transform.position; + overlayTransform.rotation = bundle.Instance.transform.rotation; + } + private static Sprite GetOrCreateSprite() { if (_sharedSprite != null) return _sharedSprite; @@ -47,4 +53,4 @@ private static Sprite GetOrCreateSprite() return _sharedSprite; } } -} +} \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/ModuleRotationUtility.cs b/Assets/Scripts/ShipFactory/ModuleRotationUtility.cs new file mode 100644 index 00000000..61074ac3 --- /dev/null +++ b/Assets/Scripts/ShipFactory/ModuleRotationUtility.cs @@ -0,0 +1,61 @@ +using UnityEngine; + +namespace ShipFactory +{ + public static class ModuleRotationUtility + { + private const float EdgeEpsilon = 0.001f; + + public static int CalculateQuarterTurns(Transform transform) + { + var z = transform.localEulerAngles.z; + return CalculateQuarterTurns(z); + } + + private static int CalculateQuarterTurns(float zDegrees) + { + return (Mathf.RoundToInt(zDegrees / 90f) % 4 + 4) % 4; + } + + public static Vector2Int GetWorldFootprintDimensions(Quaternion worldRotation, Vector2Int baseDimensions) + { + var z = worldRotation.eulerAngles.z; + var quarterTurns = CalculateQuarterTurns(z); + return quarterTurns is 1 or 3 ? new Vector2Int(baseDimensions.y, baseDimensions.x) : baseDimensions; + } + + public static (Vector2 min, Vector2 max) GetAxisAlignedBounds(ShipModuleSOInstanceBundle bundle) + { + return GetFootprintBoundsInParentSpace( + bundle.Instance.transform.position, + bundle.ModuleSO.Dimensions, + bundle.Instance.transform.rotation); + } + + public static (Vector2 min, Vector2 max) GetFootprintBoundsInParentSpace(Vector2 centerInParentSpace, + Vector2Int dimensions, Quaternion rotationInParentSpace) + { + var z = rotationInParentSpace.eulerAngles.z; + var quarterTurns = CalculateQuarterTurns(z); + var footprint = quarterTurns is 1 or 3 ? new Vector2Int(dimensions.y, dimensions.x) : dimensions; + var half = (Vector2)footprint * 0.5f; + return (centerInParentSpace - half, centerInParentSpace + half); + } + + public static bool ContainsWorldPoint(ShipModuleSOInstanceBundle bundle, Vector2 worldPoint) + { + var local = bundle.Instance.transform.InverseTransformPoint(worldPoint); + var half = (Vector2)bundle.ModuleSO.Dimensions * 0.5f; + return Mathf.Abs(local.x) <= half.x + EdgeEpsilon && Mathf.Abs(local.y) <= half.y + EdgeEpsilon; + } + + public static void ApplyQuarterTurn(ShipModuleSOInstanceBundle bundle, int deltaSteps) + { + if (deltaSteps == 0) return; + + var currentQuarterTurns = CalculateQuarterTurns(bundle.Instance.transform); + var newQuarterTurns = (currentQuarterTurns + deltaSteps + 4) % 4; + bundle.Instance.transform.localRotation = Quaternion.Euler(0f, 0f, newQuarterTurns * 90f); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/ModuleRotationUtility.cs.meta b/Assets/Scripts/ShipFactory/ModuleRotationUtility.cs.meta new file mode 100644 index 00000000..3a60a445 --- /dev/null +++ b/Assets/Scripts/ShipFactory/ModuleRotationUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ca9f46e720963abb8b7a7ff7359fd60 \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/ShipFactory.uss b/Assets/Scripts/ShipFactory/ShipFactory.uss index 6b9dccc1..c03707ac 100644 --- a/Assets/Scripts/ShipFactory/ShipFactory.uss +++ b/Assets/Scripts/ShipFactory/ShipFactory.uss @@ -149,6 +149,14 @@ padding-top: 4px; } +.module-rotation-icon--counter { + scale: -1 1; +} + +.module-rotation-buttons--hidden { + display: none; +} + .remove-module-button { margin-top: 24px; } diff --git a/Assets/Scripts/ShipFactory/ShipFactory.uxml b/Assets/Scripts/ShipFactory/ShipFactory.uxml index 32a07dbf..3832a8ec 100644 --- a/Assets/Scripts/ShipFactory/ShipFactory.uxml +++ b/Assets/Scripts/ShipFactory/ShipFactory.uxml @@ -40,6 +40,20 @@ class="module-info-field ds-body-2"/> + + + + + + + + + + diff --git a/Assets/Scripts/ShipFactory/ShipFactoryCanvasController.cs b/Assets/Scripts/ShipFactory/ShipFactoryCanvasController.cs index 92006059..3911f865 100644 --- a/Assets/Scripts/ShipFactory/ShipFactoryCanvasController.cs +++ b/Assets/Scripts/ShipFactory/ShipFactoryCanvasController.cs @@ -27,6 +27,7 @@ public class ShipFactoryCanvasController : IDisposable private ShipModuleSOInstanceBundle _draggedModuleBundle; private bool _draggedModuleWasNew; + private Quaternion _dragStartLocalRotation; private Vector2 _dragStartWorldPos; private Vector2 _dragWorldOffset; @@ -52,6 +53,8 @@ public ShipFactoryCanvasController(VisualElement root, IGameInput gameInput) _resourcesPanel = new ResourcesPanel(root); _infoPanel = new ModuleInfoPanel(root); _infoPanel.OnRemoveModuleClicked += RemoveSelectedModule; + _infoPanel.OnRotateClockwiseClicked += () => RotateActiveModule(90); + _infoPanel.OnRotateCounterClockwiseClicked += () => RotateActiveModule(-90); // 2. Initialize Managers _overlayManager = new OverlayManager(); @@ -240,7 +243,10 @@ public void BeginModuleDrop(ShipModuleSO shipModuleSO) _hoveredPaletteModule = null; - var worldPos = Snapper.SnapToGrid(_gameInput.WorldPointerPosition); + var localSnapped = Snapper.SnapModuleLocalCenter( + _ship.transform.InverseTransformPoint(_gameInput.WorldPointerPosition), + shipModuleSO.Dimensions); + var worldPos = (Vector2)_ship.transform.TransformPoint(localSnapped); var bundle = InstantiateModule(shipModuleSO, worldPos); if (bundle == null) return; @@ -253,6 +259,7 @@ private void BeginModuleDrag(ShipModuleSOInstanceBundle bundle, bool isNewBundle _draggedModuleBundle = bundle; _draggedModuleWasNew = isNewBundle; _dragStartWorldPos = bundle.Instance.transform.position; + _dragStartLocalRotation = bundle.Instance.transform.localRotation; _hoveredPaletteModule = null; _dragWorldOffset = !isNewBundle @@ -266,18 +273,14 @@ private void BeginModuleDrag(ShipModuleSOInstanceBundle bundle, bool isNewBundle private void MoveGhostToPointer() { - var snapped = Snapper.SnapToGrid(_gameInput.WorldPointerPosition + _dragWorldOffset); - _overlayManager.SetPosition(_draggedModuleBundle, snapped); - - var legality = Calculator.CalculateLegalityPosition(_draggedModuleBundle, _overlayManager.AllBundles); - var color = legality switch - { - PositionLegality.InsideOther => ModuleOverlay.InsideOtherColor, - PositionLegality.OutsideShip or PositionLegality.DisconnectsShip => ModuleOverlay.OutsideShipColor, - _ => ModuleOverlay.SelectedColor - }; - - _overlayManager.SetColor(_draggedModuleBundle, color); + var draggedTransform = _draggedModuleBundle.Instance.transform; + var localSnapped = Snapper.SnapModuleLocalCenter( + _ship.transform.InverseTransformPoint(_gameInput.WorldPointerPosition + _dragWorldOffset), + _draggedModuleBundle.ModuleSO.Dimensions, + draggedTransform.localRotation); + var worldPos = (Vector2)_ship.transform.TransformPoint(localSnapped); + _overlayManager.SetPosition(_draggedModuleBundle, worldPos); + RefreshDraggedModuleLegalityOverlay(); } private void HandleDragRelease() @@ -313,8 +316,11 @@ private void HandleDragRelease() return; } + RestoreDragStartRotation(activeBundle); + _animator.AnimateBundleMovement(activeBundle, currentWorldPos, _dragStartWorldPos, () => { + RestoreDragStartRotation(activeBundle); FinishActiveDrag(); SetInputLocked(false); }); @@ -410,5 +416,76 @@ public void RefreshShipResourcesPanel() { _resourcesPanel.Refresh(_ship); } + + public void RotateActiveModule(int degrees) + { + if (degrees is not (90 or -90) || IsInputLocked) return; + + var bundle = _draggedModuleBundle ?? _selectedModuleBundle; + if (bundle == null) return; + + var previousRotation = bundle.Instance.transform.localRotation; + var deltaSteps = degrees / 90; + ModuleRotationUtility.ApplyQuarterTurn(bundle, deltaSteps); + + if (IsDraggingModule) + ResnapDraggedModuleToGrid(); + + _overlayManager.SyncTransformFromBundle(bundle); + + if (IsDraggingModule) + { + RefreshDraggedModuleLegalityOverlay(); + return; + } + + var legality = Calculator.CalculateLegalityPosition(bundle, _overlayManager.AllBundles); + if (legality == PositionLegality.Correct) return; + + bundle.Instance.transform.localRotation = previousRotation; + _overlayManager.SyncTransformFromBundle(bundle); + + var message = legality switch + { + PositionLegality.InsideOther => "Cannot rotate: modules would overlap.", + PositionLegality.OutsideShip => "Cannot rotate: module would be outside the ship.", + PositionLegality.DisconnectsShip => "Cannot rotate: it would split the ship into islands.", + _ => "Cannot rotate module to this orientation." + }; + + if (legality == PositionLegality.DisconnectsShip) + ShowErrorMessage(message); + else + ShowWarningMessage(message); + } + + private void ResnapDraggedModuleToGrid() + { + var transform = _draggedModuleBundle.Instance.transform; + var localSnapped = Snapper.SnapModuleLocalCenter( + transform.localPosition, + _draggedModuleBundle.ModuleSO.Dimensions, + transform.localRotation); + transform.localPosition = new Vector3(localSnapped.x, localSnapped.y, transform.localPosition.z); + } + + private void RestoreDragStartRotation(ShipModuleSOInstanceBundle bundle) + { + bundle.Instance.transform.localRotation = _dragStartLocalRotation; + _overlayManager.SyncTransformFromBundle(bundle); + } + + private void RefreshDraggedModuleLegalityOverlay() + { + var legality = Calculator.CalculateLegalityPosition(_draggedModuleBundle, _overlayManager.AllBundles); + var color = legality switch + { + PositionLegality.InsideOther => ModuleOverlay.InsideOtherColor, + PositionLegality.OutsideShip or PositionLegality.DisconnectsShip => ModuleOverlay.OutsideShipColor, + _ => ModuleOverlay.SelectedColor + }; + + _overlayManager.SetColor(_draggedModuleBundle, color); + } } } \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/ShipFactoryController.cs b/Assets/Scripts/ShipFactory/ShipFactoryController.cs index cd0cff03..22eff3b7 100644 --- a/Assets/Scripts/ShipFactory/ShipFactoryController.cs +++ b/Assets/Scripts/ShipFactory/ShipFactoryController.cs @@ -54,6 +54,7 @@ private void Update() { _canvasController?.RefreshShipResourcesPanel(); HandlePauseInput(); + HandleRotationInput(); } private void OnEnable() @@ -217,6 +218,16 @@ private void InitializePauseUi(VisualElement root) _pauseUiInitialized = true; } + private void HandleRotationInput() + { + if (_isPaused || _canvasController == null) return; + if (_shipNameField.focusController?.focusedElement == _shipNameField) return; + if (!Input.GetKeyDown(KeyCode.R)) return; + + var counterClockwise = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift); + _canvasController.RotateActiveModule(counterClockwise ? -90 : 90); + } + private void HandlePauseInput() { if (!Input.GetKeyDown(KeyCode.Escape)) diff --git a/Assets/Scripts/ShipFactory/Snapper.cs b/Assets/Scripts/ShipFactory/Snapper.cs index 17f5df4d..77cad2c5 100644 --- a/Assets/Scripts/ShipFactory/Snapper.cs +++ b/Assets/Scripts/ShipFactory/Snapper.cs @@ -6,11 +6,25 @@ public static class Snapper { public const int SnapUnits = 8; - public static Vector2 SnapToGrid(Vector2 worldPosition) + public static Vector2 SnapToGrid(Vector2 position) { return new Vector2( - Mathf.Round(worldPosition.x / SnapUnits) * SnapUnits, - Mathf.Round(worldPosition.y / SnapUnits) * SnapUnits); + Mathf.Round(position.x / SnapUnits) * SnapUnits, + Mathf.Round(position.y / SnapUnits) * SnapUnits); + } + + public static Vector2 SnapModuleLocalCenter(Vector2 localCenter, Vector2Int dimensions) + { + return SnapModuleLocalCenter(localCenter, dimensions, Quaternion.identity); + } + + public static Vector2 SnapModuleLocalCenter(Vector2 localCenter, Vector2Int dimensions, + Quaternion localRotation) + { + var (boundsMin, _) = ModuleRotationUtility.GetFootprintBoundsInParentSpace( + localCenter, dimensions, localRotation); + var snappedBoundsMin = SnapToGrid(boundsMin); + return localCenter + (snappedBoundsMin - boundsMin); } } } \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/Tests/LegalPositionCalculator/CalculatorTests.cs b/Assets/Scripts/ShipFactory/Tests/LegalPositionCalculator/CalculatorTests.cs index 37094fcf..eda6eed0 100644 --- a/Assets/Scripts/ShipFactory/Tests/LegalPositionCalculator/CalculatorTests.cs +++ b/Assets/Scripts/ShipFactory/Tests/LegalPositionCalculator/CalculatorTests.cs @@ -11,8 +11,6 @@ namespace ShipFactory.Tests.LegalPositionCalculator [TestFixture] public class CalculatorTests { - private readonly List _createdObjects = new(); - [TearDown] public void TearDown() { @@ -23,6 +21,8 @@ public void TearDown() _createdObjects.Clear(); } + private readonly List _createdObjects = new(); + [Test] public void CalculateLegalityPosition_OverlappingModule_ReturnsInsideOther() { @@ -69,11 +69,129 @@ public void CalculateLegalityPosition_ChainMiddleMovedAndTailDisconnected_Return Assert.That(legality, Is.EqualTo(PositionLegality.DisconnectsShip)); } + [Test] + public void CalculateLegalityPosition_RotatedNonSquareModule_ConnectsWhereUnrotatedWouldNot() + { + var command = CreateBundle("Command", ModuleType.Command, Vector2.zero, new Vector2Int(2, 2)); + var unrotatedAbove = + CreateBundle("Unrotated", ModuleType.Engine, new Vector2(0f, 3f), new Vector2Int(4, 2)); + var rotatedAbove = + CreateBundle("Rotated", ModuleType.Engine, new Vector2(0f, 3f), new Vector2Int(4, 2), 90f); + + var unrotatedLegality = + Calculator.CalculateLegalityPosition(unrotatedAbove, new[] { command, unrotatedAbove }); + var rotatedLegality = Calculator.CalculateLegalityPosition(rotatedAbove, new[] { command, rotatedAbove }); + + Assert.That(unrotatedLegality, Is.EqualTo(PositionLegality.OutsideShip)); + Assert.That(rotatedLegality, Is.EqualTo(PositionLegality.Correct)); + } + + [Test] + public void CalculateLegalityPosition_OddMultipleOfEightModules_TouchWhenEdgeAligned() + { + var command = CreateBundle("Command", ModuleType.Command, new Vector2(12f, 12f), new Vector2Int(24, 24)); + var adjacent = CreateBundle("Adjacent", ModuleType.Engine, new Vector2(36f, 12f), new Vector2Int(24, 24)); + + var legality = Calculator.CalculateLegalityPosition(adjacent, new[] { command, adjacent }); + + Assert.That(legality, Is.EqualTo(PositionLegality.Correct)); + } + + [Test] + public void CalculateLegalityPosition_RotatedModule_OverlapStillDetected() + { + var command = CreateBundle("Command", ModuleType.Command, Vector2.zero, new Vector2Int(2, 2)); + var rotatedOverlap = CreateBundle("RotatedOverlap", ModuleType.Resources, new Vector2(1f, 1f), + new Vector2Int(4, 2), 90f); + + var legality = Calculator.CalculateLegalityPosition(rotatedOverlap, new[] { command, rotatedOverlap }); + + Assert.That(legality, Is.EqualTo(PositionLegality.InsideOther)); + } + + [Test] + public void CalculateLegalityPosition_CornerTouchOnly_ReturnsOutsideShip() + { + var command = CreateBundle("Command", ModuleType.Command, new Vector2(12f, 12f), new Vector2Int(24, 24)); + var cornerTouch = CreateBundle("Corner", ModuleType.Engine, new Vector2(36f, 36f), new Vector2Int(24, 24)); + + var legality = Calculator.CalculateLegalityPosition(cornerTouch, new[] { command, cornerTouch }); + + Assert.That(legality, Is.EqualTo(PositionLegality.OutsideShip)); + } + + [TestCase(0f)] + [TestCase(90f)] + [TestCase(180f)] + [TestCase(270f)] + public void CalculateLegalityPosition_RotatedModule_CornerTouchOnly_ReturnsOutsideShip(float rotationZ) + { + var command = CreateBundle("Command", ModuleType.Command, new Vector2(12f, 12f), new Vector2Int(24, 24)); + var cornerTouch = CreateBundle("Corner", ModuleType.Engine, new Vector2(32f, 36f), new Vector2Int(24, 16), + rotationZ); + + var legality = Calculator.CalculateLegalityPosition(cornerTouch, new[] { command, cornerTouch }); + + Assert.That(legality, Is.EqualTo(PositionLegality.OutsideShip)); + } + + [TestCase(0f)] + [TestCase(90f)] + [TestCase(180f)] + [TestCase(270f)] + public void CalculateLegalityPosition_AdjacentSquareModule_RemainsCorrectAtAnyQuarterTurn(float rotationZ) + { + var command = CreateBundle("Command", ModuleType.Command, new Vector2(12f, 12f), new Vector2Int(24, 24)); + var adjacent = CreateBundle("Adjacent", ModuleType.Engine, new Vector2(36f, 12f), new Vector2Int(24, 24), + rotationZ); + + var legality = Calculator.CalculateLegalityPosition(adjacent, new[] { command, adjacent }); + + Assert.That(legality, Is.EqualTo(PositionLegality.Correct)); + } + + [Test] + public void CalculateLegalityPosition_Rotated24x16_AdjacentToSquareModule_IsCorrect() + { + var command = CreateBundle("Command", ModuleType.Command, new Vector2(12f, 12f), new Vector2Int(24, 24)); + var adjacent = CreateBundle("Adjacent", ModuleType.Engine, new Vector2(32f, 12f), new Vector2Int(24, 16), + 90f); + + var legality = Calculator.CalculateLegalityPosition(adjacent, new[] { command, adjacent }); + + Assert.That(legality, Is.EqualTo(PositionLegality.Correct)); + } + + [Test] + public void CalculateLegalityPosition_RectangularModule_RemainsCorrectForEachQuarterTurnPlacement() + { + var command = CreateBundle("Command", ModuleType.Command, new Vector2(0f, 0f), new Vector2Int(24, 24)); + + AssertTouchingPlacementIsCorrect(command, new Vector2(32f, 12f), new Vector2Int(40, 24), 0f); + AssertTouchingPlacementIsCorrect(command, new Vector2(24f, 12f), new Vector2Int(40, 24), 90f); + AssertTouchingPlacementIsCorrect(command, new Vector2(32f, 12f), new Vector2Int(40, 24), 180f); + AssertTouchingPlacementIsCorrect(command, new Vector2(24f, 12f), new Vector2Int(40, 24), 270f); + } + + private void AssertTouchingPlacementIsCorrect(ShipModuleSOInstanceBundle command, Vector2 worldPosition, + Vector2Int dimensions, float rotationZ) + { + var adjacent = CreateBundle("Adjacent", ModuleType.Engine, worldPosition, dimensions, rotationZ); + var legality = Calculator.CalculateLegalityPosition(adjacent, new[] { command, adjacent }); + Assert.That(legality, Is.EqualTo(PositionLegality.Correct)); + } + private ShipModuleSOInstanceBundle CreateBundle(string name, ModuleType moduleType, Vector2 worldPosition, - Vector2Int dimensions) + Vector2Int dimensions, float rotationZ = 0f) { - var go = new GameObject(name); - go.transform.position = worldPosition; + var go = new GameObject(name) + { + transform = + { + position = worldPosition, + rotation = Quaternion.Euler(0f, 0f, rotationZ) + } + }; _createdObjects.Add(go); var moduleSO = ScriptableObject.CreateInstance(); @@ -87,5 +205,4 @@ private ShipModuleSOInstanceBundle CreateBundle(string name, ModuleType moduleTy return new ShipModuleSOInstanceBundle(go, moduleSO, module); } } -} - +} \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs b/Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs new file mode 100644 index 00000000..6d835dba --- /dev/null +++ b/Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using Core.Ship; +using NSubstitute; +using NUnit.Framework; +using UnityEngine; +using ZLinq; +using Object = UnityEngine.Object; + +namespace ShipFactory.Tests +{ + [TestFixture] + public class ModuleRotationUtilityTests + { + [TearDown] + public void TearDown() + { + foreach (var obj in _createdObjects.AsValueEnumerable().Where(obj => obj != null)) + Object.DestroyImmediate(obj); + + _createdObjects.Clear(); + } + + private readonly List _createdObjects = new(); + + [Test] + public void GetQuarterTurns_NegativeEulerAngle_NormalizesToPositiveQuarterTurn() + { + var transform = CreateTransform(-90f); + + Assert.That(ModuleRotationUtility.CalculateQuarterTurns(transform), Is.EqualTo(3)); + } + + [Test] + public void GetWorldFootprintDimensions_RotatedNonSquareModule_SwapsExtents() + { + var bundle = CreateBundle(new Vector2(0f, 0f), new Vector2Int(40, 24), 90f); + + var footprint = ModuleRotationUtility.GetWorldFootprintDimensions( + bundle.Instance.transform.rotation, + bundle.ModuleSO.Dimensions); + + Assert.That(footprint, Is.EqualTo(new Vector2Int(24, 40))); + } + + [Test] + public void GetAxisAlignedBounds_RotatedNonSquareModule_UsesExactIntegerExtents() + { + var bundle = CreateBundle(new Vector2(0f, 3f), new Vector2Int(4, 2), 90f); + + var (min, max) = ModuleRotationUtility.GetAxisAlignedBounds(bundle); + + Assert.IsTrue(min == new Vector2(-1f, 1f)); + Assert.IsTrue(max == new Vector2(1f, 5f)); + } + + [Test] + public void ContainsWorldPoint_RotatedModule_UsesOrientedBounds() + { + var bundle = CreateBundle(new Vector2(0f, 0f), new Vector2Int(40, 24), 90f); + + Assert.That(ModuleRotationUtility.ContainsWorldPoint(bundle, new Vector2(10f, 0f)), Is.True); + Assert.That(ModuleRotationUtility.ContainsWorldPoint(bundle, new Vector2(0f, 18f)), Is.True); + Assert.That(ModuleRotationUtility.ContainsWorldPoint(bundle, new Vector2(20f, 0f)), Is.False); + Assert.That(ModuleRotationUtility.ContainsWorldPoint(bundle, new Vector2(0f, 25f)), Is.False); + } + + [Test] + public void ApplyQuarterTurn_FromNegativeEulerAngle_StillAdvancesQuarterTurn() + { + var bundle = CreateBundle(Vector2.zero, new Vector2Int(16, 16), -90f); + + ModuleRotationUtility.ApplyQuarterTurn(bundle, 1); + + Assert.That(ModuleRotationUtility.CalculateQuarterTurns(bundle.Instance.transform), Is.EqualTo(0)); + } + + private Transform CreateTransform(float rotationZ) + { + var go = new GameObject("RotationTest"); + go.transform.rotation = Quaternion.Euler(0f, 0f, rotationZ); + _createdObjects.Add(go); + return go.transform; + } + + private ShipModuleSOInstanceBundle CreateBundle(Vector2 worldPosition, Vector2Int dimensions, float rotationZ) + { + var go = new GameObject("Module"); + go.transform.position = worldPosition; + go.transform.rotation = Quaternion.Euler(0f, 0f, rotationZ); + _createdObjects.Add(go); + + var moduleSO = ScriptableObject.CreateInstance(); + _createdObjects.Add(moduleSO); + moduleSO.ConfigureForTesting("Module", "desc", dimensions, go); + + var module = Substitute.For(); + return new ShipModuleSOInstanceBundle(go, moduleSO, module); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs.meta b/Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs.meta new file mode 100644 index 00000000..11b8c6c8 --- /dev/null +++ b/Assets/Scripts/ShipFactory/Tests/ModuleRotationUtilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc144203095fad84bb028a8df7c13996 \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/Tests/ShipFactory.Tests.asmdef b/Assets/Scripts/ShipFactory/Tests/ShipFactory.Tests.asmdef index 0cd94046..23a84bf8 100644 --- a/Assets/Scripts/ShipFactory/Tests/ShipFactory.Tests.asmdef +++ b/Assets/Scripts/ShipFactory/Tests/ShipFactory.Tests.asmdef @@ -13,6 +13,7 @@ "overrideReferences": true, "precompiledReferences": [ "nunit.framework.dll", + "ZLinq.dll", "NSubstitute.dll" ], "autoReferenced": false, @@ -22,4 +23,3 @@ "versionDefines": [], "noEngineReferences": false } - diff --git a/Assets/Scripts/ShipFactory/Tests/SnapperTests.cs b/Assets/Scripts/ShipFactory/Tests/SnapperTests.cs new file mode 100644 index 00000000..ee1248aa --- /dev/null +++ b/Assets/Scripts/ShipFactory/Tests/SnapperTests.cs @@ -0,0 +1,64 @@ +using NUnit.Framework; +using UnityEngine; + +namespace ShipFactory.Tests +{ + [TestFixture] + public class SnapperTests + { + [Test] + public void SnapModuleLocalCenter_EvenMultipleOfEight_AlignsEdgesToGrid() + { + var snapped = Snapper.SnapModuleLocalCenter(new Vector2(3f, -2f), new Vector2Int(16, 16)); + + Assert.That(snapped, Is.EqualTo(new Vector2(0f, 0f))); + Assert.That(snapped.x - 8f, Is.EqualTo(-8f).Within(0.001f)); + Assert.That(snapped.x + 8f, Is.EqualTo(8f).Within(0.001f)); + } + + [Test] + public void SnapModuleLocalCenter_OddMultipleOfEight_AlignsEdgesToGrid() + { + var snapped = Snapper.SnapModuleLocalCenter(new Vector2(13f, 11f), new Vector2Int(24, 24)); + + Assert.That(snapped, Is.EqualTo(new Vector2(12f, 12f))); + Assert.That(snapped.x - 12f, Is.EqualTo(0f).Within(0.001f)); + Assert.That(snapped.x + 12f, Is.EqualTo(24f).Within(0.001f)); + } + + [Test] + public void SnapModuleLocalCenter_NonSquareOddMultiple_AlignsEachAxisIndependently() + { + var snapped = Snapper.SnapModuleLocalCenter(new Vector2(21f, 17f), new Vector2Int(40, 24)); + + Assert.That(snapped, Is.EqualTo(new Vector2(20f, 20f))); + Assert.That(snapped.x - 20f, Is.EqualTo(0f).Within(0.001f)); + Assert.That(snapped.x + 20f, Is.EqualTo(40f).Within(0.001f)); + Assert.That(snapped.y - 12f, Is.EqualTo(8f).Within(0.001f)); + Assert.That(snapped.y + 12f, Is.EqualTo(32f).Within(0.001f)); + } + + [Test] + public void SnapModuleLocalCenter_Rotated24x16_AlignsFootprintBoundsToGrid() + { + var rotation = Quaternion.Euler(0f, 0f, 90f); + var snapped = Snapper.SnapModuleLocalCenter(new Vector2(12f, 8f), new Vector2Int(24, 16), rotation); + + Assert.That(snapped, Is.EqualTo(new Vector2(8f, 12f))); + + var (min, max) = ModuleRotationUtility.GetFootprintBoundsInParentSpace( + snapped, new Vector2Int(24, 16), rotation); + + Assert.That(max.x - min.x, Is.EqualTo(16f).Within(0.001f)); + Assert.That(max.y - min.y, Is.EqualTo(24f).Within(0.001f)); + AssertFootprintCornerOnGrid(min); + AssertFootprintCornerOnGrid(max); + } + + private static void AssertFootprintCornerOnGrid(Vector2 corner) + { + Assert.That(Mathf.Abs(corner.x % Snapper.SnapUnits), Is.EqualTo(0f).Within(0.001f)); + Assert.That(Mathf.Abs(corner.y % Snapper.SnapUnits), Is.EqualTo(0f).Within(0.001f)); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/Tests/SnapperTests.cs.meta b/Assets/Scripts/ShipFactory/Tests/SnapperTests.cs.meta new file mode 100644 index 00000000..63686663 --- /dev/null +++ b/Assets/Scripts/ShipFactory/Tests/SnapperTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: faaff8a92e475524c856c130b95bae10 \ No newline at end of file diff --git a/Assets/Scripts/ShipFactory/UI/Runtime/OverlayManager.cs b/Assets/Scripts/ShipFactory/UI/Runtime/OverlayManager.cs index 02089efa..0d79da11 100644 --- a/Assets/Scripts/ShipFactory/UI/Runtime/OverlayManager.cs +++ b/Assets/Scripts/ShipFactory/UI/Runtime/OverlayManager.cs @@ -66,7 +66,16 @@ public void RebuildFromShip(Ship ship) public void SetPosition(ShipModuleSOInstanceBundle bundle, Vector2 worldPos) { bundle.Instance.transform.position = worldPos; - if (_bundleToOverlay.TryGetValue(bundle, out var overlay)) overlay.transform.position = worldPos; + SyncTransformFromBundle(bundle); + } + + public void SyncTransformFromBundle(ShipModuleSOInstanceBundle bundle) + { + if (!_bundleToOverlay.TryGetValue(bundle, out var overlay)) return; + + ModuleOverlay.SyncTransformFromBundle(overlay.transform, bundle); + var dims = bundle.ModuleSO.Dimensions; + overlay.transform.localScale = new Vector3(dims.x, dims.y, 1f); } public void SetColor(ShipModuleSOInstanceBundle bundle, Color color) @@ -77,14 +86,8 @@ public void SetColor(ShipModuleSOInstanceBundle bundle, Color color) public ShipModuleSOInstanceBundle FindBundleAtWorldPosition(Vector2 worldPos) { foreach (var (bundle, _) in _bundleToOverlay) - { - var pos = (Vector2)bundle.Instance.transform.position; - var halfDims = (Vector2)bundle.ModuleSO.Dimensions / 2f; - - if (worldPos.x >= pos.x - halfDims.x && worldPos.x <= pos.x + halfDims.x && - worldPos.y >= pos.y - halfDims.y && worldPos.y <= pos.y + halfDims.y) + if (ModuleRotationUtility.ContainsWorldPoint(bundle, worldPos)) return bundle; - } return null; } diff --git a/Assets/Scripts/ShipFactory/UI/ToolkitComponents/ModuleInfo.cs b/Assets/Scripts/ShipFactory/UI/ToolkitComponents/ModuleInfo.cs index a5212e49..d7f0baa6 100644 --- a/Assets/Scripts/ShipFactory/UI/ToolkitComponents/ModuleInfo.cs +++ b/Assets/Scripts/ShipFactory/UI/ToolkitComponents/ModuleInfo.cs @@ -7,6 +7,7 @@ namespace ShipFactory.UI.ToolkitComponents public class ModuleInfoPanel { private const string RemoveButtonHiddenClassName = "remove-module-button--hidden"; + private const string RotationButtonsHiddenClassName = "module-rotation-buttons--hidden"; private readonly Label _moduleDescriptionLabel; private readonly Label _moduleNameLabel; @@ -18,6 +19,9 @@ public class ModuleInfoPanel private readonly Label _resourceEnergyCapacityLabel; private readonly Label _resourceEnergyDrawLabel; private readonly Label _resourceEnergyProductionLabel; + private readonly Button _rotateClockwiseButton; + private readonly Button _rotateCounterButton; + private readonly VisualElement _rotationButtonsContainer; public ModuleInfoPanel(VisualElement root) { @@ -31,18 +35,26 @@ public ModuleInfoPanel(VisualElement root) _resourceCrewNeededLabel = root.Q