Code up the card part
This commit is contained in:
3
Assets/Scripts/UI/DragAndDrop/Core.meta
Normal file
3
Assets/Scripts/UI/DragAndDrop/Core.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de2fa1660c564a13ab22715e94b45e4c
|
||||
timeCreated: 1762420597
|
||||
476
Assets/Scripts/UI/DragAndDrop/Core/DraggableObject.cs
Normal file
476
Assets/Scripts/UI/DragAndDrop/Core/DraggableObject.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Pixelplacement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace UI.DragAndDrop.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract base class for draggable UI objects.
|
||||
/// Handles drag logic, slot snapping, and events.
|
||||
/// Spawns and manages a separate DraggableVisual for rendering.
|
||||
/// Touch-compatible via Unity's pointer event system.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Image))]
|
||||
public abstract class DraggableObject : MonoBehaviour,
|
||||
IBeginDragHandler, IDragHandler, IEndDragHandler,
|
||||
IPointerEnterHandler, IPointerExitHandler,
|
||||
IPointerUpHandler, IPointerDownHandler
|
||||
{
|
||||
[Header("Draggable Settings")]
|
||||
[SerializeField] protected float moveSpeed = 50f;
|
||||
[SerializeField] protected bool smoothMovement = true;
|
||||
[SerializeField] protected float snapDuration = 0.3f;
|
||||
|
||||
[Header("Visual")]
|
||||
[SerializeField] protected GameObject visualPrefab;
|
||||
[SerializeField] protected bool instantiateVisual = true;
|
||||
[SerializeField] protected Transform visualParent;
|
||||
|
||||
[Header("Selection")]
|
||||
[SerializeField] protected bool isSelectable = true;
|
||||
[SerializeField] protected float selectionOffset = 50f;
|
||||
|
||||
// State
|
||||
protected bool _isDragging;
|
||||
protected bool _isHovering;
|
||||
protected bool _isSelected;
|
||||
protected bool _wasDragged;
|
||||
|
||||
// References
|
||||
protected Canvas _canvas;
|
||||
protected Image _imageComponent;
|
||||
protected GraphicRaycaster _raycaster;
|
||||
protected DraggableSlot _currentSlot;
|
||||
protected DraggableVisual _visualInstance;
|
||||
|
||||
// Drag tracking
|
||||
protected Vector3 _dragOffset;
|
||||
protected Vector3 _lastPointerPosition;
|
||||
protected float _pointerDownTime;
|
||||
protected float _pointerUpTime;
|
||||
|
||||
// Events
|
||||
public event Action<DraggableObject> OnDragStarted;
|
||||
public event Action<DraggableObject> OnDragEnded;
|
||||
public event Action<DraggableObject> OnPointerEntered;
|
||||
public event Action<DraggableObject> OnPointerExited;
|
||||
public event Action<DraggableObject> OnPointerDowned;
|
||||
public event Action<DraggableObject, bool> OnPointerUpped; // bool = long press
|
||||
public event Action<DraggableObject, bool> OnSelected; // bool = selected state
|
||||
public event Action<DraggableObject, DraggableSlot> OnSlotChanged;
|
||||
|
||||
// Properties
|
||||
public bool IsDragging => _isDragging;
|
||||
public bool IsHovering => _isHovering;
|
||||
public bool IsSelected => _isSelected;
|
||||
public bool WasDragged => _wasDragged;
|
||||
public DraggableSlot CurrentSlot => _currentSlot;
|
||||
public DraggableVisual Visual => _visualInstance;
|
||||
public Vector3 WorldPosition => transform.position;
|
||||
public RectTransform RectTransform => transform as RectTransform;
|
||||
|
||||
protected virtual void Start()
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
protected virtual void Initialize()
|
||||
{
|
||||
_canvas = GetComponentInParent<Canvas>();
|
||||
_imageComponent = GetComponent<Image>();
|
||||
_raycaster = _canvas?.GetComponent<GraphicRaycaster>();
|
||||
|
||||
if (instantiateVisual && visualPrefab != null)
|
||||
{
|
||||
SpawnVisual();
|
||||
}
|
||||
|
||||
// If we're already in a slot, register with it
|
||||
DraggableSlot parentSlot = GetComponentInParent<DraggableSlot>();
|
||||
if (parentSlot != null)
|
||||
{
|
||||
AssignToSlot(parentSlot, false);
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void SpawnVisual()
|
||||
{
|
||||
Transform parent = visualParent != null ? visualParent : _canvas.transform;
|
||||
GameObject visualObj = Instantiate(visualPrefab, parent);
|
||||
_visualInstance = visualObj.GetComponent<DraggableVisual>();
|
||||
|
||||
if (_visualInstance != null)
|
||||
{
|
||||
_visualInstance.Initialize(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
if (_isDragging && smoothMovement)
|
||||
{
|
||||
SmoothMoveTowardPointer();
|
||||
}
|
||||
|
||||
ClampToScreen();
|
||||
}
|
||||
|
||||
protected virtual void SmoothMoveTowardPointer()
|
||||
{
|
||||
Vector3 targetPosition = _lastPointerPosition - _dragOffset;
|
||||
Vector3 direction = (targetPosition - transform.position).normalized;
|
||||
float distance = Vector3.Distance(transform.position, targetPosition);
|
||||
float speed = Mathf.Min(moveSpeed, distance / Time.deltaTime);
|
||||
|
||||
transform.Translate(direction * speed * Time.deltaTime, Space.World);
|
||||
}
|
||||
|
||||
protected virtual void ClampToScreen()
|
||||
{
|
||||
if (Camera.main == null || RectTransform == null)
|
||||
return;
|
||||
|
||||
Vector3[] corners = new Vector3[4];
|
||||
RectTransform.GetWorldCorners(corners);
|
||||
|
||||
// Simple clamping - can be improved
|
||||
Vector3 clampedPosition = transform.position;
|
||||
Vector2 screenBounds = Camera.main.ScreenToWorldPoint(new Vector3(Screen.width, Screen.height, 0));
|
||||
|
||||
clampedPosition.x = Mathf.Clamp(clampedPosition.x, -screenBounds.x, screenBounds.x);
|
||||
clampedPosition.y = Mathf.Clamp(clampedPosition.y, -screenBounds.y, screenBounds.y);
|
||||
|
||||
transform.position = clampedPosition;
|
||||
}
|
||||
|
||||
#region Unity Pointer Event Handlers
|
||||
|
||||
public virtual void OnBeginDrag(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left)
|
||||
return;
|
||||
|
||||
_isDragging = true;
|
||||
_wasDragged = true;
|
||||
|
||||
// Calculate offset
|
||||
Vector3 worldPointer = GetWorldPosition(eventData);
|
||||
_dragOffset = worldPointer - transform.position;
|
||||
_lastPointerPosition = worldPointer;
|
||||
|
||||
// Disable raycasting to allow detecting slots underneath
|
||||
if (_raycaster != null)
|
||||
_raycaster.enabled = false;
|
||||
if (_imageComponent != null)
|
||||
_imageComponent.raycastTarget = false;
|
||||
|
||||
// Notify current slot we're leaving
|
||||
if (_currentSlot != null)
|
||||
{
|
||||
_currentSlot.Vacate();
|
||||
}
|
||||
|
||||
OnDragStarted?.Invoke(this);
|
||||
OnDragStartedHook();
|
||||
}
|
||||
|
||||
public virtual void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
if (!_isDragging)
|
||||
return;
|
||||
|
||||
_lastPointerPosition = GetWorldPosition(eventData);
|
||||
|
||||
if (!smoothMovement)
|
||||
{
|
||||
transform.position = _lastPointerPosition - _dragOffset;
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
if (!_isDragging)
|
||||
return;
|
||||
|
||||
_isDragging = false;
|
||||
|
||||
// Re-enable raycasting
|
||||
if (_raycaster != null)
|
||||
_raycaster.enabled = true;
|
||||
if (_imageComponent != null)
|
||||
_imageComponent.raycastTarget = true;
|
||||
|
||||
// Find closest slot and snap
|
||||
FindAndSnapToSlot();
|
||||
|
||||
OnDragEnded?.Invoke(this);
|
||||
OnDragEndedHook();
|
||||
|
||||
// Reset wasDragged after a frame
|
||||
StartCoroutine(ResetWasDraggedFlag());
|
||||
}
|
||||
|
||||
public virtual void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
_isHovering = true;
|
||||
OnPointerEntered?.Invoke(this);
|
||||
OnPointerEnterHook();
|
||||
}
|
||||
|
||||
public virtual void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
_isHovering = false;
|
||||
OnPointerExited?.Invoke(this);
|
||||
OnPointerExitHook();
|
||||
}
|
||||
|
||||
public virtual void OnPointerDown(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left)
|
||||
return;
|
||||
|
||||
_pointerDownTime = Time.time;
|
||||
OnPointerDowned?.Invoke(this);
|
||||
OnPointerDownHook();
|
||||
}
|
||||
|
||||
public virtual void OnPointerUp(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left)
|
||||
return;
|
||||
|
||||
_pointerUpTime = Time.time;
|
||||
bool isLongPress = (_pointerUpTime - _pointerDownTime) > 0.2f;
|
||||
|
||||
OnPointerUpped?.Invoke(this, isLongPress);
|
||||
OnPointerUpHook(isLongPress);
|
||||
|
||||
// Handle selection (only if not long press and not dragged)
|
||||
if (!isLongPress && !_wasDragged && isSelectable)
|
||||
{
|
||||
ToggleSelection();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Slot Management
|
||||
|
||||
protected virtual void FindAndSnapToSlot()
|
||||
{
|
||||
SlotContainer[] containers = FindObjectsOfType<SlotContainer>();
|
||||
DraggableSlot closestSlot = null;
|
||||
float closestDistance = float.MaxValue;
|
||||
|
||||
foreach (var container in containers)
|
||||
{
|
||||
DraggableSlot slot = container.FindClosestSlot(transform.position, this);
|
||||
if (slot != null)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, slot.WorldPosition);
|
||||
if (distance < closestDistance)
|
||||
{
|
||||
closestDistance = distance;
|
||||
closestSlot = slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closestSlot != null)
|
||||
{
|
||||
// Check if slot is occupied
|
||||
if (closestSlot.IsOccupied && closestSlot.Occupant != this)
|
||||
{
|
||||
// Swap with occupant
|
||||
SwapWithSlot(closestSlot);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Move to empty slot
|
||||
AssignToSlot(closestSlot, true);
|
||||
}
|
||||
}
|
||||
else if (_currentSlot != null)
|
||||
{
|
||||
// Return to current slot if no valid slot found
|
||||
SnapToCurrentSlot();
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void SwapWithSlot(DraggableSlot targetSlot)
|
||||
{
|
||||
DraggableSlot mySlot = _currentSlot;
|
||||
DraggableObject otherObject = targetSlot.Occupant;
|
||||
|
||||
if (otherObject != null)
|
||||
{
|
||||
// Both objects swap slots
|
||||
targetSlot.Vacate();
|
||||
if (mySlot != null)
|
||||
mySlot.Vacate();
|
||||
|
||||
AssignToSlot(targetSlot, true);
|
||||
if (mySlot != null)
|
||||
otherObject.AssignToSlot(mySlot, true);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void AssignToSlot(DraggableSlot slot, bool animate)
|
||||
{
|
||||
if (slot == null)
|
||||
return;
|
||||
|
||||
DraggableSlot previousSlot = _currentSlot;
|
||||
_currentSlot = slot;
|
||||
|
||||
if (slot.Occupy(this))
|
||||
{
|
||||
if (animate)
|
||||
{
|
||||
SnapToSlot(slot);
|
||||
}
|
||||
else
|
||||
{
|
||||
transform.SetParent(slot.transform);
|
||||
transform.localPosition = _isSelected ? new Vector3(0, selectionOffset, 0) : Vector3.zero;
|
||||
}
|
||||
|
||||
OnSlotChanged?.Invoke(this, slot);
|
||||
OnSlotChangedHook(previousSlot, slot);
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void SnapToSlot(DraggableSlot slot)
|
||||
{
|
||||
transform.SetParent(slot.transform);
|
||||
|
||||
Vector3 targetLocalPos = _isSelected ? new Vector3(0, selectionOffset, 0) : Vector3.zero;
|
||||
|
||||
if (RectTransform != null)
|
||||
{
|
||||
Tween.LocalPosition(RectTransform, targetLocalPos, snapDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
else
|
||||
{
|
||||
transform.localPosition = targetLocalPos;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void SnapToCurrentSlot()
|
||||
{
|
||||
if (_currentSlot != null)
|
||||
{
|
||||
SnapToSlot(_currentSlot);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Selection
|
||||
|
||||
public virtual void ToggleSelection()
|
||||
{
|
||||
SetSelected(!_isSelected);
|
||||
}
|
||||
|
||||
public virtual void SetSelected(bool selected)
|
||||
{
|
||||
if (!isSelectable)
|
||||
return;
|
||||
|
||||
_isSelected = selected;
|
||||
|
||||
// Update position based on selection
|
||||
Vector3 targetLocalPos = _isSelected ? new Vector3(0, selectionOffset, 0) : Vector3.zero;
|
||||
|
||||
if (RectTransform != null && _currentSlot != null)
|
||||
{
|
||||
Tween.LocalPosition(RectTransform, targetLocalPos, 0.15f, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnSelected?.Invoke(this, _isSelected);
|
||||
OnSelectionChangedHook(_isSelected);
|
||||
}
|
||||
|
||||
public virtual void Deselect()
|
||||
{
|
||||
SetSelected(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
protected Vector3 GetWorldPosition(PointerEventData eventData)
|
||||
{
|
||||
if (Camera.main == null)
|
||||
return Vector3.zero;
|
||||
|
||||
// For screen space overlay canvas
|
||||
if (_canvas != null && _canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||
{
|
||||
return eventData.position;
|
||||
}
|
||||
|
||||
// For world space or camera space
|
||||
return Camera.main.ScreenToWorldPoint(new Vector3(eventData.position.x, eventData.position.y, _canvas.planeDistance));
|
||||
}
|
||||
|
||||
protected IEnumerator ResetWasDraggedFlag()
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
_wasDragged = false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Abstract/Virtual Hooks for Subclasses
|
||||
|
||||
protected virtual void OnDragStartedHook() { }
|
||||
protected virtual void OnDragEndedHook() { }
|
||||
protected virtual void OnPointerEnterHook() { }
|
||||
protected virtual void OnPointerExitHook() { }
|
||||
protected virtual void OnPointerDownHook() { }
|
||||
protected virtual void OnPointerUpHook(bool longPress) { }
|
||||
protected virtual void OnSelectionChangedHook(bool selected) { }
|
||||
protected virtual void OnSlotChangedHook(DraggableSlot previousSlot, DraggableSlot newSlot) { }
|
||||
|
||||
#endregion
|
||||
|
||||
protected virtual void OnDestroy()
|
||||
{
|
||||
if (_visualInstance != null)
|
||||
{
|
||||
Destroy(_visualInstance.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
public int GetSiblingCount()
|
||||
{
|
||||
return _currentSlot != null && _currentSlot.transform.parent != null
|
||||
? _currentSlot.transform.parent.childCount - 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
public int GetSlotIndex()
|
||||
{
|
||||
return _currentSlot != null ? _currentSlot.SlotIndex : 0;
|
||||
}
|
||||
|
||||
public float GetNormalizedSlotPosition()
|
||||
{
|
||||
if (_currentSlot == null || _currentSlot.transform.parent == null)
|
||||
return 0f;
|
||||
|
||||
int siblingCount = _currentSlot.transform.parent.childCount - 1;
|
||||
if (siblingCount <= 0)
|
||||
return 0f;
|
||||
|
||||
return (float)_currentSlot.SlotIndex / siblingCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 062198d83a0940538c140da0999f4de9
|
||||
timeCreated: 1762420597
|
||||
139
Assets/Scripts/UI/DragAndDrop/Core/DraggableSlot.cs
Normal file
139
Assets/Scripts/UI/DragAndDrop/Core/DraggableSlot.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using Pixelplacement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI.DragAndDrop.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a position where draggable objects can snap to.
|
||||
/// Can be occupied by one DraggableObject at a time.
|
||||
/// </summary>
|
||||
public class DraggableSlot : MonoBehaviour
|
||||
{
|
||||
[Header("Slot Settings")]
|
||||
[SerializeField] private int slotIndex;
|
||||
[SerializeField] private bool isLocked;
|
||||
|
||||
[Header("Type Filtering")]
|
||||
[SerializeField] private bool filterByType;
|
||||
[SerializeField] private string[] allowedTypeNames;
|
||||
|
||||
[Header("Scale Control")]
|
||||
[SerializeField] private bool applyScaleToOccupant = false;
|
||||
[SerializeField] private Vector3 occupantScale = Vector3.one;
|
||||
[SerializeField] private float scaleTransitionDuration = 0.3f;
|
||||
|
||||
// Current occupant
|
||||
private DraggableObject _occupant;
|
||||
|
||||
// Events
|
||||
public event Action<DraggableObject> OnOccupied;
|
||||
public event Action<DraggableObject> OnVacated;
|
||||
|
||||
public int SlotIndex => slotIndex;
|
||||
public bool IsOccupied => _occupant != null;
|
||||
public bool IsLocked => isLocked;
|
||||
public DraggableObject Occupant => _occupant;
|
||||
public Vector3 WorldPosition => transform.position;
|
||||
public RectTransform RectTransform => transform as RectTransform;
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to occupy this slot with a draggable object
|
||||
/// </summary>
|
||||
public bool Occupy(DraggableObject draggable)
|
||||
{
|
||||
if (isLocked)
|
||||
return false;
|
||||
|
||||
if (!CanAccept(draggable))
|
||||
return false;
|
||||
|
||||
if (_occupant != null && _occupant != draggable)
|
||||
return false;
|
||||
|
||||
_occupant = draggable;
|
||||
draggable.transform.SetParent(transform);
|
||||
|
||||
// Apply scale if configured
|
||||
if (applyScaleToOccupant)
|
||||
{
|
||||
Tween.LocalScale(draggable.transform, occupantScale, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnOccupied?.Invoke(draggable);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vacate this slot, removing the current occupant
|
||||
/// </summary>
|
||||
public void Vacate()
|
||||
{
|
||||
if (_occupant != null)
|
||||
{
|
||||
DraggableObject previousOccupant = _occupant;
|
||||
_occupant = null;
|
||||
OnVacated?.Invoke(previousOccupant);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if this slot can accept a specific draggable type
|
||||
/// </summary>
|
||||
public bool CanAccept(DraggableObject draggable)
|
||||
{
|
||||
if (!filterByType || allowedTypeNames == null || allowedTypeNames.Length == 0)
|
||||
return true;
|
||||
|
||||
string draggableTypeName = draggable.GetType().Name;
|
||||
|
||||
foreach (string allowedType in allowedTypeNames)
|
||||
{
|
||||
if (draggableTypeName == allowedType)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swap occupants with another slot
|
||||
/// </summary>
|
||||
public void SwapWith(DraggableSlot otherSlot)
|
||||
{
|
||||
if (otherSlot == null || otherSlot == this)
|
||||
return;
|
||||
|
||||
DraggableObject thisOccupant = _occupant;
|
||||
DraggableObject otherOccupant = otherSlot._occupant;
|
||||
|
||||
// Vacate both slots
|
||||
Vacate();
|
||||
otherSlot.Vacate();
|
||||
|
||||
// Occupy with swapped objects
|
||||
if (otherOccupant != null)
|
||||
Occupy(otherOccupant);
|
||||
|
||||
if (thisOccupant != null)
|
||||
otherSlot.Occupy(thisOccupant);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lock/unlock this slot
|
||||
/// </summary>
|
||||
public void SetLocked(bool locked)
|
||||
{
|
||||
isLocked = locked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the slot index
|
||||
/// </summary>
|
||||
public void SetSlotIndex(int index)
|
||||
{
|
||||
slotIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Assets/Scripts/UI/DragAndDrop/Core/DraggableSlot.cs.meta
Normal file
3
Assets/Scripts/UI/DragAndDrop/Core/DraggableSlot.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee43b700f9dd44dba39deb8c5bcd688c
|
||||
timeCreated: 1762420738
|
||||
367
Assets/Scripts/UI/DragAndDrop/Core/DraggableVisual.cs
Normal file
367
Assets/Scripts/UI/DragAndDrop/Core/DraggableVisual.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
using Pixelplacement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI.DragAndDrop.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract base class for visual representation of draggable objects.
|
||||
/// Follows the parent DraggableObject with lerping and visual effects.
|
||||
/// Inspired by Balatro's CardVisual system.
|
||||
/// </summary>
|
||||
public abstract class DraggableVisual : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] protected Canvas canvas;
|
||||
[SerializeField] protected CanvasGroup canvasGroup;
|
||||
[SerializeField] protected Transform tiltParent;
|
||||
[SerializeField] protected Transform shakeParent;
|
||||
|
||||
[Header("Follow Parameters")]
|
||||
[SerializeField] protected float followSpeed = 30f;
|
||||
[SerializeField] protected bool useFollowDelay = true;
|
||||
|
||||
[Header("Rotation/Tilt Parameters")]
|
||||
[SerializeField] protected float rotationAmount = 20f;
|
||||
[SerializeField] protected float rotationSpeed = 20f;
|
||||
[SerializeField] protected float autoTiltAmount = 30f;
|
||||
[SerializeField] protected float manualTiltAmount = 20f;
|
||||
[SerializeField] protected float tiltSpeed = 20f;
|
||||
|
||||
[Header("Scale Parameters")]
|
||||
[SerializeField] protected bool useScaleAnimations = true;
|
||||
[SerializeField] protected float scaleOnHover = 1.15f;
|
||||
[SerializeField] protected float scaleOnDrag = 1.25f;
|
||||
[SerializeField] protected float scaleTransitionDuration = 0.15f;
|
||||
|
||||
[Header("Idle Animation")]
|
||||
[SerializeField] protected bool useIdleAnimation = true;
|
||||
[SerializeField] protected float idleAnimationSpeed = 1f;
|
||||
|
||||
// State
|
||||
protected DraggableObject _parentDraggable;
|
||||
protected bool _isInitialized;
|
||||
protected Vector3 _movementDelta;
|
||||
protected Vector3 _rotationDelta;
|
||||
protected int _savedSlotIndex;
|
||||
protected Vector3 _lastPosition;
|
||||
|
||||
// Properties
|
||||
public DraggableObject ParentDraggable => _parentDraggable;
|
||||
public bool IsInitialized => _isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the visual with its parent draggable object
|
||||
/// </summary>
|
||||
public virtual void Initialize(DraggableObject parent)
|
||||
{
|
||||
_parentDraggable = parent;
|
||||
|
||||
// Get or add required components
|
||||
if (canvas == null)
|
||||
canvas = GetComponent<Canvas>();
|
||||
if (canvas == null)
|
||||
canvas = gameObject.AddComponent<Canvas>();
|
||||
|
||||
if (canvasGroup == null)
|
||||
canvasGroup = GetComponent<CanvasGroup>();
|
||||
if (canvasGroup == null)
|
||||
canvasGroup = gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
// Subscribe to parent events
|
||||
SubscribeToParentEvents();
|
||||
|
||||
// Initial position
|
||||
transform.position = parent.transform.position;
|
||||
_lastPosition = transform.position;
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
OnInitialized();
|
||||
}
|
||||
|
||||
protected virtual void SubscribeToParentEvents()
|
||||
{
|
||||
if (_parentDraggable == null)
|
||||
return;
|
||||
|
||||
_parentDraggable.OnDragStarted += HandleDragStarted;
|
||||
_parentDraggable.OnDragEnded += HandleDragEnded;
|
||||
_parentDraggable.OnPointerEntered += HandlePointerEnter;
|
||||
_parentDraggable.OnPointerExited += HandlePointerExit;
|
||||
_parentDraggable.OnPointerDowned += HandlePointerDown;
|
||||
_parentDraggable.OnPointerUpped += HandlePointerUp;
|
||||
_parentDraggable.OnSelected += HandleSelection;
|
||||
}
|
||||
|
||||
protected virtual void UnsubscribeFromParentEvents()
|
||||
{
|
||||
if (_parentDraggable == null)
|
||||
return;
|
||||
|
||||
_parentDraggable.OnDragStarted -= HandleDragStarted;
|
||||
_parentDraggable.OnDragEnded -= HandleDragEnded;
|
||||
_parentDraggable.OnPointerEntered -= HandlePointerEnter;
|
||||
_parentDraggable.OnPointerExited -= HandlePointerExit;
|
||||
_parentDraggable.OnPointerDowned -= HandlePointerDown;
|
||||
_parentDraggable.OnPointerUpped -= HandlePointerUp;
|
||||
_parentDraggable.OnSelected -= HandleSelection;
|
||||
}
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
if (!_isInitialized || _parentDraggable == null)
|
||||
return;
|
||||
|
||||
UpdateFollowPosition();
|
||||
UpdateRotation();
|
||||
UpdateTilt();
|
||||
UpdateVisualContent();
|
||||
}
|
||||
|
||||
#region Position & Movement
|
||||
|
||||
protected virtual void UpdateFollowPosition()
|
||||
{
|
||||
if (_parentDraggable == null)
|
||||
return;
|
||||
|
||||
Vector3 targetPosition = GetTargetPosition();
|
||||
|
||||
if (useFollowDelay)
|
||||
{
|
||||
transform.position = Vector3.Lerp(transform.position, targetPosition, followSpeed * Time.deltaTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
transform.position = targetPosition;
|
||||
}
|
||||
|
||||
// Calculate movement delta for tilt
|
||||
Vector3 movement = transform.position - _lastPosition;
|
||||
_movementDelta = Vector3.Lerp(_movementDelta, movement, 25f * Time.deltaTime);
|
||||
_lastPosition = transform.position;
|
||||
}
|
||||
|
||||
protected virtual Vector3 GetTargetPosition()
|
||||
{
|
||||
return _parentDraggable.transform.position;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rotation & Tilt
|
||||
|
||||
protected virtual void UpdateRotation()
|
||||
{
|
||||
if (_parentDraggable == null)
|
||||
return;
|
||||
|
||||
// Rotation based on movement direction (like Balatro)
|
||||
Vector3 movementRotation = _parentDraggable.IsDragging
|
||||
? _movementDelta * rotationAmount
|
||||
: (_lastPosition - _parentDraggable.transform.position) * rotationAmount;
|
||||
|
||||
_rotationDelta = Vector3.Lerp(_rotationDelta, movementRotation, rotationSpeed * Time.deltaTime);
|
||||
|
||||
float clampedZ = Mathf.Clamp(_rotationDelta.x, -60f, 60f);
|
||||
transform.eulerAngles = new Vector3(transform.eulerAngles.x, transform.eulerAngles.y, clampedZ);
|
||||
}
|
||||
|
||||
protected virtual void UpdateTilt()
|
||||
{
|
||||
if (tiltParent == null)
|
||||
return;
|
||||
|
||||
// Save slot index when not dragging for idle animation
|
||||
_savedSlotIndex = _parentDraggable.IsDragging
|
||||
? _savedSlotIndex
|
||||
: _parentDraggable.GetSlotIndex();
|
||||
|
||||
// Idle animation (sine/cosine wobble)
|
||||
float idleMultiplier = _parentDraggable.IsHovering ? 0.2f : 1f;
|
||||
float time = Time.time * idleAnimationSpeed + _savedSlotIndex;
|
||||
float sineWobble = Mathf.Sin(time) * idleMultiplier;
|
||||
float cosineWobble = Mathf.Cos(time) * idleMultiplier;
|
||||
|
||||
// Manual tilt based on pointer position (when hovering)
|
||||
float manualTiltX = 0f;
|
||||
float manualTiltY = 0f;
|
||||
|
||||
if (_parentDraggable.IsHovering && Camera.main != null)
|
||||
{
|
||||
Vector3 mouseWorldPos = Camera.main.ScreenToWorldPoint(UnityEngine.Input.mousePosition);
|
||||
Vector3 offset = transform.position - mouseWorldPos;
|
||||
manualTiltX = (offset.y * -1f) * manualTiltAmount;
|
||||
manualTiltY = offset.x * manualTiltAmount;
|
||||
}
|
||||
|
||||
// Combine auto and manual tilt
|
||||
float targetTiltX = manualTiltX + (useIdleAnimation ? sineWobble * autoTiltAmount : 0f);
|
||||
float targetTiltY = manualTiltY + (useIdleAnimation ? cosineWobble * autoTiltAmount : 0f);
|
||||
float targetTiltZ = _parentDraggable.IsDragging ? tiltParent.eulerAngles.z : 0f;
|
||||
|
||||
// Lerp to target tilt
|
||||
float lerpX = Mathf.LerpAngle(tiltParent.eulerAngles.x, targetTiltX, tiltSpeed * Time.deltaTime);
|
||||
float lerpY = Mathf.LerpAngle(tiltParent.eulerAngles.y, targetTiltY, tiltSpeed * Time.deltaTime);
|
||||
float lerpZ = Mathf.LerpAngle(tiltParent.eulerAngles.z, targetTiltZ, (tiltSpeed / 2f) * Time.deltaTime);
|
||||
|
||||
tiltParent.eulerAngles = new Vector3(lerpX, lerpY, lerpZ);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
protected virtual void HandleDragStarted(DraggableObject draggable)
|
||||
{
|
||||
if (useScaleAnimations)
|
||||
{
|
||||
Tween.LocalScale(transform, Vector3.one * scaleOnDrag, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
if (canvas != null)
|
||||
{
|
||||
canvas.overrideSorting = true;
|
||||
}
|
||||
|
||||
OnDragStartedVisual();
|
||||
}
|
||||
|
||||
protected virtual void HandleDragEnded(DraggableObject draggable)
|
||||
{
|
||||
if (canvas != null)
|
||||
{
|
||||
canvas.overrideSorting = false;
|
||||
}
|
||||
|
||||
Tween.LocalScale(transform, Vector3.one, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
|
||||
OnDragEndedVisual();
|
||||
}
|
||||
|
||||
protected virtual void HandlePointerEnter(DraggableObject draggable)
|
||||
{
|
||||
if (useScaleAnimations)
|
||||
{
|
||||
Tween.LocalScale(transform, Vector3.one * scaleOnHover, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
// Punch rotation effect
|
||||
if (shakeParent != null)
|
||||
{
|
||||
Tween.Rotation(shakeParent, shakeParent.eulerAngles + Vector3.forward * 5f, 0.15f, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnPointerEnterVisual();
|
||||
}
|
||||
|
||||
protected virtual void HandlePointerExit(DraggableObject draggable)
|
||||
{
|
||||
if (!draggable.WasDragged && useScaleAnimations)
|
||||
{
|
||||
Tween.LocalScale(transform, Vector3.one, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnPointerExitVisual();
|
||||
}
|
||||
|
||||
protected virtual void HandlePointerDown(DraggableObject draggable)
|
||||
{
|
||||
if (useScaleAnimations)
|
||||
{
|
||||
Tween.LocalScale(transform, Vector3.one * scaleOnDrag, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnPointerDownVisual();
|
||||
}
|
||||
|
||||
protected virtual void HandlePointerUp(DraggableObject draggable, bool longPress)
|
||||
{
|
||||
float targetScale = longPress ? scaleOnHover : scaleOnDrag;
|
||||
|
||||
if (useScaleAnimations)
|
||||
{
|
||||
Tween.LocalScale(transform, Vector3.one * targetScale, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnPointerUpVisual(longPress);
|
||||
}
|
||||
|
||||
protected virtual void HandleSelection(DraggableObject draggable, bool selected)
|
||||
{
|
||||
// Punch effect on selection
|
||||
if (shakeParent != null && selected)
|
||||
{
|
||||
Vector3 punchAmount = shakeParent.up * 20f;
|
||||
Tween.Position(shakeParent, shakeParent.position + punchAmount, 0.15f, 0f, Tween.EaseOutBack,
|
||||
completeCallback: () => Tween.Position(shakeParent, shakeParent.position - punchAmount, 0.15f, 0f, Tween.EaseInBack));
|
||||
}
|
||||
|
||||
if (useScaleAnimations)
|
||||
{
|
||||
float targetScale = selected ? scaleOnDrag : scaleOnHover;
|
||||
Tween.LocalScale(transform, Vector3.one * targetScale, scaleTransitionDuration, 0f, Tween.EaseOutBack);
|
||||
}
|
||||
|
||||
OnSelectionVisual(selected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Abstract/Virtual Hooks for Subclasses
|
||||
|
||||
/// <summary>
|
||||
/// Called after initialization is complete
|
||||
/// </summary>
|
||||
protected virtual void OnInitialized() { }
|
||||
|
||||
/// <summary>
|
||||
/// Update the actual visual content (sprites, UI elements, etc.)
|
||||
/// Called every frame
|
||||
/// </summary>
|
||||
protected abstract void UpdateVisualContent();
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when drag starts
|
||||
/// </summary>
|
||||
protected virtual void OnDragStartedVisual() { }
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when drag ends
|
||||
/// </summary>
|
||||
protected virtual void OnDragEndedVisual() { }
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when pointer enters
|
||||
/// </summary>
|
||||
protected virtual void OnPointerEnterVisual() { }
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when pointer exits
|
||||
/// </summary>
|
||||
protected virtual void OnPointerExitVisual() { }
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when pointer down
|
||||
/// </summary>
|
||||
protected virtual void OnPointerDownVisual() { }
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when pointer up
|
||||
/// </summary>
|
||||
protected virtual void OnPointerUpVisual(bool longPress) { }
|
||||
|
||||
/// <summary>
|
||||
/// Visual-specific behavior when selection changes
|
||||
/// </summary>
|
||||
protected virtual void OnSelectionVisual(bool selected) { }
|
||||
|
||||
#endregion
|
||||
|
||||
protected virtual void OnDestroy()
|
||||
{
|
||||
UnsubscribeFromParentEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 203c649ed73845c6999682bcf8383ee8
|
||||
timeCreated: 1762420644
|
||||
258
Assets/Scripts/UI/DragAndDrop/Core/SlotContainer.cs
Normal file
258
Assets/Scripts/UI/DragAndDrop/Core/SlotContainer.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI.DragAndDrop.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a collection of DraggableSlots.
|
||||
/// Handles layout, slot finding, and reordering.
|
||||
/// </summary>
|
||||
public class SlotContainer : MonoBehaviour
|
||||
{
|
||||
[Header("Container Settings")]
|
||||
[SerializeField] private LayoutType layoutType = LayoutType.Horizontal;
|
||||
[SerializeField] private float spacing = 100f;
|
||||
[SerializeField] private bool centerSlots = true;
|
||||
[SerializeField] private bool autoRegisterChildren = true;
|
||||
|
||||
[Header("Curve Layout (Horizontal Only)")]
|
||||
[SerializeField] private bool useCurveLayout;
|
||||
[SerializeField] private AnimationCurve positionCurve = AnimationCurve.Linear(0, 0, 1, 0);
|
||||
[SerializeField] private float curveHeight = 50f;
|
||||
|
||||
private List<DraggableSlot> _slots = new List<DraggableSlot>();
|
||||
|
||||
public enum LayoutType
|
||||
{
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Grid,
|
||||
Custom
|
||||
}
|
||||
|
||||
// Events
|
||||
public event Action<DraggableSlot> OnSlotAdded;
|
||||
public event Action<DraggableSlot> OnSlotRemoved;
|
||||
public event Action OnLayoutChanged;
|
||||
|
||||
public List<DraggableSlot> Slots => _slots;
|
||||
public int SlotCount => _slots.Count;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (autoRegisterChildren)
|
||||
{
|
||||
RegisterChildSlots();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Automatically register all child DraggableSlot components
|
||||
/// </summary>
|
||||
private void RegisterChildSlots()
|
||||
{
|
||||
DraggableSlot[] childSlots = GetComponentsInChildren<DraggableSlot>();
|
||||
foreach (var slot in childSlots)
|
||||
{
|
||||
RegisterSlot(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a slot with this container
|
||||
/// </summary>
|
||||
public void RegisterSlot(DraggableSlot slot)
|
||||
{
|
||||
if (slot == null || _slots.Contains(slot))
|
||||
return;
|
||||
|
||||
_slots.Add(slot);
|
||||
slot.SetSlotIndex(_slots.Count - 1);
|
||||
|
||||
OnSlotAdded?.Invoke(slot);
|
||||
UpdateLayout();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a slot from this container
|
||||
/// </summary>
|
||||
public void UnregisterSlot(DraggableSlot slot)
|
||||
{
|
||||
if (slot == null || !_slots.Contains(slot))
|
||||
return;
|
||||
|
||||
_slots.Remove(slot);
|
||||
OnSlotRemoved?.Invoke(slot);
|
||||
|
||||
// Re-index remaining slots
|
||||
for (int i = 0; i < _slots.Count; i++)
|
||||
{
|
||||
_slots[i].SetSlotIndex(i);
|
||||
}
|
||||
|
||||
UpdateLayout();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the closest slot to a world position
|
||||
/// </summary>
|
||||
public DraggableSlot FindClosestSlot(Vector3 worldPosition, DraggableObject draggable = null)
|
||||
{
|
||||
if (_slots.Count == 0)
|
||||
return null;
|
||||
|
||||
DraggableSlot closest = null;
|
||||
float closestDistance = float.MaxValue;
|
||||
|
||||
foreach (var slot in _slots)
|
||||
{
|
||||
// Skip locked slots or slots that can't accept this type
|
||||
if (slot.IsLocked)
|
||||
continue;
|
||||
|
||||
if (draggable != null && !slot.CanAccept(draggable))
|
||||
continue;
|
||||
|
||||
float distance = Vector3.Distance(worldPosition, slot.WorldPosition);
|
||||
if (distance < closestDistance)
|
||||
{
|
||||
closestDistance = distance;
|
||||
closest = slot;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all available (empty) slots
|
||||
/// </summary>
|
||||
public List<DraggableSlot> GetAvailableSlots()
|
||||
{
|
||||
return _slots.Where(s => !s.IsOccupied && !s.IsLocked).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all occupied slots
|
||||
/// </summary>
|
||||
public List<DraggableSlot> GetOccupiedSlots()
|
||||
{
|
||||
return _slots.Where(s => s.IsOccupied).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get slot at specific index
|
||||
/// </summary>
|
||||
public DraggableSlot GetSlotAtIndex(int index)
|
||||
{
|
||||
if (index < 0 || index >= _slots.Count)
|
||||
return null;
|
||||
|
||||
return _slots[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the layout of all slots based on layout type
|
||||
/// </summary>
|
||||
public void UpdateLayout()
|
||||
{
|
||||
if (layoutType == LayoutType.Custom)
|
||||
{
|
||||
OnLayoutChanged?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
int count = _slots.Count;
|
||||
if (count == 0)
|
||||
return;
|
||||
|
||||
switch (layoutType)
|
||||
{
|
||||
case LayoutType.Horizontal:
|
||||
LayoutHorizontal();
|
||||
break;
|
||||
case LayoutType.Vertical:
|
||||
LayoutVertical();
|
||||
break;
|
||||
case LayoutType.Grid:
|
||||
LayoutGrid();
|
||||
break;
|
||||
}
|
||||
|
||||
OnLayoutChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void LayoutHorizontal()
|
||||
{
|
||||
float totalWidth = (_slots.Count - 1) * spacing;
|
||||
float startX = centerSlots ? -totalWidth / 2f : 0f;
|
||||
|
||||
for (int i = 0; i < _slots.Count; i++)
|
||||
{
|
||||
if (_slots[i].RectTransform != null)
|
||||
{
|
||||
float xPos = startX + (i * spacing);
|
||||
float yPos = 0;
|
||||
|
||||
// Apply curve if enabled
|
||||
if (useCurveLayout && _slots.Count > 1)
|
||||
{
|
||||
float normalizedPos = i / (float)(_slots.Count - 1);
|
||||
yPos = positionCurve.Evaluate(normalizedPos) * curveHeight;
|
||||
}
|
||||
|
||||
_slots[i].RectTransform.anchoredPosition = new Vector2(xPos, yPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LayoutVertical()
|
||||
{
|
||||
float totalHeight = (_slots.Count - 1) * spacing;
|
||||
float startY = centerSlots ? totalHeight / 2f : 0f;
|
||||
|
||||
for (int i = 0; i < _slots.Count; i++)
|
||||
{
|
||||
if (_slots[i].RectTransform != null)
|
||||
{
|
||||
float yPos = startY - (i * spacing);
|
||||
_slots[i].RectTransform.anchoredPosition = new Vector2(0, yPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LayoutGrid()
|
||||
{
|
||||
// Simple grid layout - can be expanded
|
||||
int columns = Mathf.CeilToInt(Mathf.Sqrt(_slots.Count));
|
||||
|
||||
for (int i = 0; i < _slots.Count; i++)
|
||||
{
|
||||
if (_slots[i].RectTransform != null)
|
||||
{
|
||||
int row = i / columns;
|
||||
int col = i % columns;
|
||||
|
||||
float xPos = col * spacing;
|
||||
float yPos = -row * spacing;
|
||||
|
||||
_slots[i].RectTransform.anchoredPosition = new Vector2(xPos, yPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all slots
|
||||
/// </summary>
|
||||
public void ClearAllSlots()
|
||||
{
|
||||
foreach (var slot in _slots)
|
||||
{
|
||||
slot.Vacate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Assets/Scripts/UI/DragAndDrop/Core/SlotContainer.cs.meta
Normal file
3
Assets/Scripts/UI/DragAndDrop/Core/SlotContainer.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f1347da8005d4687a85dbc4db9a1bbbb
|
||||
timeCreated: 1762420766
|
||||
Reference in New Issue
Block a user