using UnityEngine; public class AxisRotateStrategy : IGhostMovementStrategy { public bool OverridesSurfaceTracking => true; public enum LocalAxis { Up, Forward, Right } private readonly LocalAxis _localAxis; private readonly Vector3 _lockedPosition; private bool _isRotating; private float _accumulatedDegrees; private Vector2 _lastMousePos; private Quaternion _unsnappedRot; private Vector3 _unsnappedPos; private bool _wasSnapped; public float Sensitivity = 0.4f; public float SnapAngle = 15f; public bool EnableSnapping = false; /// Local axis of the ghost to rotate around. /// Ghost world position frozen for the entire interaction. public AxisRotateStrategy(LocalAxis localAxis, Vector3 lockedPosition) { _localAxis = localAxis; _lockedPosition = lockedPosition; } public void BeginRotate() { _lastMousePos = Input.mousePosition; _accumulatedDegrees = 0f; _isRotating = true; _wasSnapped = false; } public void EndRotate() => _isRotating = false; public void UpdateMovement(ref GhostMovementContext ctx) { // 1. Revert previous frame's snap so we rotate from the true axial position if (_wasSnapped) { ctx.GhostTransform.position = _unsnappedPos; ctx.GhostTransform.rotation = _unsnappedRot; _wasSnapped = false; } // Read the axis from the ghost's current orientation every frame // so it stays local regardless of how the ghost has been rotated Vector3 worldAxis = GetWorldAxis(ctx.GhostTransform); if (_isRotating) { Vector2 mouseDelta = (Vector2)Input.mousePosition - _lastMousePos; _lastMousePos = Input.mousePosition; if (mouseDelta.sqrMagnitude > 0.001f) { // Project the world axis into screen space to get its 2D direction Vector3 axisScreenStart = Camera.main.WorldToScreenPoint(_lockedPosition); Vector3 axisScreenEnd = Camera.main.WorldToScreenPoint(_lockedPosition + worldAxis); Vector2 axisScreen = (axisScreenEnd - axisScreenStart).normalized; // The rotation direction is the perpendicular to the screen-space axis // Dot the mouse delta against that perpendicular to get signed rotation Vector2 perpendicular = new Vector2(-axisScreen.y, axisScreen.x); float signedDelta = Vector2.Dot(mouseDelta, perpendicular); if (ctx.SnapRotation || EnableSnapping) { _accumulatedDegrees += signedDelta * Sensitivity; float sign = Mathf.Sign(_accumulatedDegrees); while (Mathf.Abs(_accumulatedDegrees) >= SnapAngle) { ctx.GhostTransform.RotateAround(_lockedPosition, worldAxis, SnapAngle * sign); _accumulatedDegrees -= SnapAngle * sign; } } else { float pendingDegrees = signedDelta * Sensitivity; if (pendingDegrees != 0f) { ctx.GhostTransform.RotateAround(_lockedPosition, worldAxis, pendingDegrees); } } } } // Save the pure axial rotation state _unsnappedPos = ctx.GhostTransform.position; _unsnappedRot = ctx.GhostTransform.rotation; // Attempt to snap to nearby sockets visually if (TrySocketSnap(ref ctx)) { _wasSnapped = true; } ctx.RotationAxis = worldAxis; } private bool TrySocketSnap(ref GhostMovementContext ctx) { SocketPoint[] mySockets = ctx.GhostTransform.GetComponentsInChildren(); if (mySockets.Length == 0) return false; SocketPoint bestMySocket = null; SocketPoint bestTargetSocket = null; float bestDist = 0.5f; foreach (var mySocket in mySockets) { Collider[] hits = Physics.OverlapSphere(mySocket.transform.position, bestDist, Physics.AllLayers, QueryTriggerInteraction.Collide); foreach (var hit in hits) { if (hit.transform.root == ctx.GhostTransform.root) continue; SocketPoint[] targetSockets = hit.transform.root.GetComponentsInChildren(); foreach (var target in targetSockets) { if (target.IsOccupied()) continue; if (!target.CanAccept(mySocket)) continue; float dist = Vector3.Distance(mySocket.transform.position, target.transform.position); if (dist < bestDist) { bestDist = dist; bestMySocket = mySocket; bestTargetSocket = target; } } } } if (bestMySocket != null && bestTargetSocket != null) { // Align the rotations exactly like FreeMoveStrategy Vector3 myNormal = bestMySocket.GetNormal().normalized; Vector3 myTargetNormal = -bestTargetSocket.GetNormal().normalized; Quaternion primaryRot = Quaternion.FromToRotation(myNormal, myTargetNormal); ctx.GhostTransform.rotation = primaryRot * ctx.GhostTransform.rotation; Vector3 mySecondary = Mathf.Abs(Vector3.Dot(bestMySocket.transform.up, myTargetNormal)) < 0.9f ? bestMySocket.transform.up : bestMySocket.transform.forward; Vector3 targetSecondary = Mathf.Abs(Vector3.Dot(bestTargetSocket.transform.up, -myTargetNormal)) < 0.9f ? bestTargetSocket.transform.up : bestTargetSocket.transform.forward; Vector3 projectedMySecondary = Vector3.ProjectOnPlane(mySecondary, myTargetNormal).normalized; Vector3 projectedTargetSecondary = Vector3.ProjectOnPlane(targetSecondary, myTargetNormal).normalized; if (projectedMySecondary != Vector3.zero && projectedTargetSecondary != Vector3.zero) { float angle = Vector3.SignedAngle(projectedMySecondary, projectedTargetSecondary, myTargetNormal); float correction = angle - Mathf.Round(angle / 45f) * 45f; ctx.GhostTransform.rotation = Quaternion.AngleAxis(correction, myTargetNormal) * ctx.GhostTransform.rotation; } // Translate to connect the sockets perfectly Vector3 shift = bestTargetSocket.transform.position - bestMySocket.transform.position; ctx.GhostTransform.position += shift; return true; } return false; } // Re-evaluated every frame from the ghost's current transform private Vector3 GetWorldAxis(Transform t) => _localAxis switch { LocalAxis.Up => t.up, LocalAxis.Forward => t.forward, LocalAxis.Right => t.right, _ => t.up, }; }