Now that I have defined visually a card, the next thing to do is to move it around the scene.
To do this we first have to detect the cards. We will do this using the ‘Raycast’ functions, which fire rays that when colliding with an object give us information about it.
Since it is a performance consuming operation, we will use some tricks to optimize it. The first one is that we will create a layer to discard objects that are not involved in the operation. We will named it ‘DragAndDrop’.
We will also limit the ray length to the maximum distance our camera sees, since as a general rule we are not interested in objects we are not going to see. This distance is ‘Camera.main.farClipPlane’.
Since the ‘Raycast’ operation is going to be used inside the ‘Update’ loop (it is going to be executed every frame), it is always a good idea to avoid allocating memory if we can avoid it. So we will create a Ray and use the function Physics.RaycastNonAlloc.
The code to detect the cards would look something like this:
// Layer of the objects to be detected.
[SerializeField]
private LayerMask raycastMask;
// How many impacts of the beam we want to obtain.
private const int HitsCount = 5;
// Information on the impacts of shooting a ray.
private readonly RaycastHit[] raycastHits = new RaycastHit[HitsCount];
// Ray created from the camera to the projection of the mouse
// coordinates on the scene.
private Ray mouseRay;
/// <summary>
/// Returns the Transfrom of the object closest to the origin
/// of the ray.
/// </summary>
/// <returns>Transform or null if there is no impact.</returns>
private Transform MouseRaycast()
{
Transform hit = null;
// Fire the ray!
if (Physics.RaycastNonAlloc(mouseRay,
raycastHits,
Camera.main.farClipPlane,
raycastMask) > 0)
{
// We order the impacts according to distance.
System.Array.Sort(raycastHits,
(x, y) => x.distance.CompareTo(y.distance));
// We are only interested in the first one.
hit = raycastHits[0].transform;
}
return hit;
}
Before calling this function, we must update ‘mouseRay’ with the mouse coordinates. We will do it with this code:
mouseRay = Camera.main.ScreenPointToRay(Input.mousePosition);
In order to get all this to work on a card, we must first add a ‘Collider’. Without this component, our ray would pass through the card without detecting it. A ‘Box Collider’ is a good choice for the type of geometry of a card, but if you need more precision you can use a ‘Mesh Collider’.
The second thing to do is to assign the ‘layer’ we have selected to the chart. We are now ready to detect a card.
Now we can start moving our cards. First we are going to define two interfaces, the first one is IDrag, for objects that you can drag.
/// <summary>
/// Draggable object.
/// </summary>
public interface IDrag
{
/// <summary> Can it be draggable? </summary>
public bool IsDraggable { get; }
/// <summary> A Drag operation is currently underway. </summary>
public bool Dragging { get; set; }
/// <summary> Mouse enters the object. </summary>
/// <param name="position">Mouse position.</param>
public void OnPointerEnter(Vector3 position);
/// <summary> Mouse exits object. </summary>
/// <param name="position">Mouse position.</param>
public void OnPointerExit(Vector3 position);
/// <summary> Drag begins. </summary>
/// <param name="position">Mouse position.</param>
public void OnBeginDrag(Vector3 position);
/// <summary>A drag is being made. </summary>
/// <param name="deltaPosition"> Mouse offset position. </param>
/// <param name="droppable">
/// Object on which a drop may be made, or null. </param>
public void OnDrag(Vector3 deltaPosition, IDrop droppable);
/// <summary> The drag operation is completed. </summary>
/// <param name="position">Mouse position.</param>
/// <param name="droppable">
/// Object on which a drop may be made, or null. </param>
public void OnEndDrag(Vector3 position, IDrop droppable);
}
And the second is IDrop, for objects that can accept IDrag objects.
/// <summary>
/// Accept draggable objects.
/// </summary>
public interface IDrop
{
/// <summary> Is it droppable? </summary>
public bool IsDroppable { get; }
/// <summary> Accept an IDrag? </summary>
/// <param name="drag">Object IDrag.</param>
/// <returns>Accept or not the object.</returns>
public bool AcceptDrop(IDrag drag);
/// <summary> Performs the drop operation of an IDrag object. </summary>
/// <param name="drag">Object IDrag.</param>
public void OnDrop(IDrag drag);
}
As you can see, it is IDrop that will authorize an IDrag, with its AcceptDrop method, whether or not to accept a drop operation to be executed on it.
With these two interfaces ready we can start with the one in charge of handling the drag and drop operations of our cards. We can call it ‘DragAndDropManager’ and will be a MonoBehaviour. An operation of drag & drop operation can be divided into:
All this will have to be done in each frame, so it will be done inside the function ‘Update’ of ‘DragAndDropManager’. We will use these variables:
// Height at which we want the card to be in a drag operation.
[SerializeField, Range(0.0f, 10.0f)]
private float height = 1.0f;
// Object to which we are doing a drag operation
// or null if no drag operation currently exists.
private IDrag currentDrag;
// IDrag objects that the mouse passes over.
private IDrag possibleDrag;
// To know the position of the drag object.
private Transform currentDragTransform;
// To calculate the mouse offset (in world-space).
private Vector3 oldMouseWorldPosition;
Let’s see how to detect an IDrag.
/// <summary>Detects an IDrag object under the mouse.</summary>
/// <returns>IDrag or null.</returns>
public IDrag DetectDraggable()
{
IDrag draggable = null;
mouseRay = Camera.main.ScreenPointToRay(Input.mousePosition);
Transform hit = MouseRaycast();
if (hit != null)
{
draggable = hit.GetComponent<IDrag>();
if (draggable is { IsDraggable: true })
currentDragTransform = hit;
else
draggable = null;
}
return draggable;
}
Now there are two possibilities, that the left mouse button is pressed or not pressed. If it is pressed and there is an IDrag object under the mouse pointer:
IDrag draggable = DetectDraggable();
// Left mouse button pressed?
if (Input.GetMouseButtonDown(0) == true)
{
// Is there an IDrag object under the mouse pointer?
if (draggable != null)
{
// We already have an object to start the drag operation!
currentDrag = draggable;
currentDragTransform = hit;
oldMouseWorldPosition = MousePositionToWorldPoint();
// Hide the mouse icon.
Cursor.visible = false;
// And we lock the movements to the window frame,
// so we can't move objects out of the camera's view.
Cursor.lockState = CursorLockMode.Confined;
// The drag operation begins.
currentDrag.Dragging = true;
currentDrag.OnBeginDrag(new Vector3(raycastHits[0].point.x,
raycastHits[0].point.y + height,
raycastHits[0].point.z));
}
}
And if the left mouse button is not pressed:
// Left mouse button not pressed?
if (Input.GetMouseButtonDown(0) == false)
{
// We pass over a new IDrag?
if (draggable != null && possibleDrag == null)
{
// We execute its OnPointerEnter.
possibleDrag = draggable;
possibleDrag.OnPointerEnter(raycastHits[0].point);
}
// We are leaving an IDrag?
if (draggable == null && possibleDrag != null)
{
// We execute its OnPointerExit.
possibleDrag.OnPointerExit(raycastHits[0].point);
possibleDrag = null;
}
}
We already have the first part, now let’s go for the second part: to handle a drag operation. The first thing we will do is a new method that will return the IDrop that is under a card. It’s not as simple as ‘DetectDraggable()’, since a card has a surface and can be on several objects at once. We will need to cast four rays, one for each corner. And to choose one, we will order the
hits by proximity to the center of the card, this way we will get the IDrop object that is on the card and is the closest to it.
We will store the rays hits in an array:
// Information on impacts from the corners of a card.
private readonly RaycastHit[] cardHits = new RaycastHit[4];
And this is the method to search for the nearest IDrop:
/// <summary>Detects an IDrop object under the mouse.</summary>
/// <returns>IDrop or null.</returns>
private IDrop DetectDroppable()
{
IDrop droppable = null;
// The four corners of the card.
Vector3 position = currentDragTransform.position;
Vector2 halfSize = cardSize * 0.5f;
Vector3[] cardConner =
{
new(position.x + halfSize.x, position.y, position.z - halfSize.y),
new(position.x + halfSize.x, position.y, position.z + halfSize.y),
new(position.x - halfSize.x, position.y, position.z - halfSize.y),
new(position.x - halfSize.x, position.y, position.z + halfSize.y)
};
int cardHitIndex = 0;
cardHits.Clear();
// We launch the four rays.
for (int i = 0; i < cardConner.Length; ++i)
{
Ray ray = new(cardConner[i], Vector3.down);
if (Physics.RaycastNonAlloc(ray,
raycastHits,
Camera.main.farClipPlane,
raycastMask) > 0)
{
// We order the impacts by distance from the origin of the ray.
System.Array.Sort(raycastHits, (x, y) =>
x.distance.CompareTo(y.distance));
// We are only interested in the closest one.
cardHits[cardHitIndex++] = raycastHits[0];
}
}
if (cardHitIndex > 0)
{
// We are looking for the nearest possible IDrop.
System.Array.Sort(cardHits, (x, y) =>
x.distance.CompareTo(y.distance));
droppable = cardHits[0].transform.GetComponent<IDrop>();
}
return droppable;
}
Let’s see it in action:
We can now proceed to the part where we handle a drag operation:
if (currentDrag != null)
{
IDrop droppable = DetectDroppable();
// Is the left mouse button held down?
if (Input.GetMouseButton(0) == true)
{
// Calculate the offset of the mouse with respect
// to its previous position.
Vector3 mouseWorldPosition = MousePositionToWorldPoint();
Vector3 offset = mouseWorldPosition - oldMouseWorldPosition;
offset *= dragSpeed;
// OnDrag is executed.
currentDrag.OnDrag(offset, droppable);
oldMouseWorldPosition = mouseWorldPosition;
}
else if (Input.GetMouseButtonUp(0) == true)
{
// The left mouse button is released and
// the drag operation is finished.
currentDrag.Dragging = false;
currentDrag.OnEndDrag(raycastHits[0].point, droppable);
currentDrag = null;
currentDragTransform = null;
// We return the mouse icon to its normal state.
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
}
}
We already have the complete manager, but if you look at the code it doesn’t actually move anything. That’s because it’s the IDrag objects that are responsible for doing it. Let’s see how they would be an object IDrag, which in our case is a card.
/// <summary> Card Drag. </summary>
[RequireComponent(typeof(Collider))]
public sealed class CardDrag : MonoBehaviour, IDrag
{
public bool IsDraggable { get; private set; } = true;
public bool Dragging { get; set; }
// Position when the Drag starts.
private Vector3 dragOriginPosition;
// Unused for the moment.
public void OnPointerEnter(Vector3 position) { }
public void OnPointerExit(Vector3 position) { }
/// <summary> Drag begins. </summary>
/// <param name="position">Mouse position.</param>
public void OnBeginDrag(Vector3 position)
{
// We store the current position, so that in case
// the drag operation is not completed, the card
// will return to its original position.
dragOriginPosition = transform.position;
// We raise the card to the height indicated by 'position'.
transform.position = new Vector3(transform.position.x,
position.y,
transform.position.z);
}
/// <summary>A drag is being made. </summary>
/// <param name="deltaPosition"> Mouse offset position. </param>
/// <param name="droppable">Object on which a drop may be made, or null.</param>
public void OnDrag(Vector3 deltaPosition, IDrop droppable)
{
// We ignore the displacement of the height.
deltaPosition.y = 0.0f;
// We move the card.
transform.position += deltaPosition;
}
/// <summary> The drag operation is completed. </summary>
/// <param name="position">Mouse position.</param>
/// <param name="droppable">Object on which a drop may be made, or null.</param>
public void OnEndDrag(Vector3 position, IDrop droppable)
{
// The IDrop object is active and accepts IDrag.
if (droppable is { IsDroppable: true } &&
droppable.AcceptDrop(this) == true)
transform.position = new Vector3(transform.position.x,
position.y,
transform.position.z);
else
// There was no drop, we return to the original position.
transform.position = dragOriginPosition;
}
}
We also need an IDrop object that will accept our traveling card. It is as simple as this:
/// <summary>
/// Accept draggable objects.
/// </summary>
public class DroppableFloor : MonoBehaviour, IDrop
{
public bool IsDroppable => true;
// We accept all IDrags.
public bool AcceptDrop(IDrag drag) => true;
public void OnDrop(IDrag drag) { }
}
Let’s take a look at a successful drag operation.
And now one that does not, since one of the corners collides with an IDrag (another card) and this one is closer to the IDrop (the ground).
We already have the basics. Now let’s improve it a bit. The first thing we can do is change the way we pick up and drop the cards. Now they do it instantly,
they teleport to the position we tell them to. Instead we can use the velocity formula (velocity = space / time) to move with constant velocity the cards.
We would have something like this:
Better, but still doesn’t give a good feeling. Objects in real life do not reach a speed at instance, they have inertia. To make it similar in a simple way we can use Easing functions, functions that interpolate values (values, vectors, colors, etc) using different curves.
There are many libraries with these functions. You may be interested in using my ‘Tiny Tween’, a simple to use library, very complete and in one file.
Let’s change the way we pick up cards, using ‘Tiny Tween’ to raise it to the height we want in a natural way.
public void OnBeginDrag(Vector3 position)
{
dragOriginPosition = transform.position;
// While the card is being lifted, we do not want it to be draggable.
IsDraggable = false;
// We create a Tween to change the height of the card.
TweenFloat.Create()
.Origin(dragOriginPosition.y) // Origin.
.Destination(position.y) // Destination.
.Duration(riseDuration) // Duration.
.EasingIn(riseEaseIn) // Initial Easing function.
.EasingOut(riseEaseOut) // Final Easing function.
// This is where the position is modified.
.OnUpdate(tween => transform.position =
new Vector3(transform.position.x,
tween.Value, // Only the height.
transform.position.z))
.OnEnd(_ => IsDraggable = true) // When finished,
// it becomes draggable again.
.Start();
}
And this would be the result:
Much better! And only adding one file! ;)
Let’s apply the same change to how the card moves. Right now it’s very static, it looks like it’s moving in a vacuum. We can exaggerate the friction of a flexible object moving in the air and rotate the cards depending on their direction and speed. You may have seen this effect in games such as ‘Hearthstone’.
We are going to modify the angles of the card according to its velocity vector. Specifically we will change the pitch and roll.
Each axis will have a force that will be applied to modify that axis. We will also limit the range of the angles. And finally a time that will be the time it takes for the axes to return to their original value.
[Header("Pitch")]
[SerializeField, Label("Force")]
private float pitchForce = 10.0f;
[SerializeField, Label("Minimum Angle")]
private float pitchMinAngle = -25.0f;
[SerializeField, Label("Maximum Angle")]
private float pitchMaxAngle = 25.0f;
[Space]
[Header("Roll")]
[SerializeField, Label("Force")]
private float rollForce = 10.0f;
[SerializeField, Label("Minimum Angle")]
private float rollMinAngle = -25.0f;
[SerializeField, Label("Maximum Angle")]
private float rollMaxAngle = 25.0f;
[Space]
[SerializeField]
private float restTime = 1.0f;
// Pitch angle and velocity.
private float pitchAngle, pitchVelocity;
// Roll angle and velocity.
private float rollAngle, rollVelocity;
// To calculate the velocity vector.
private Vector3 oldPosition;
// The original rotation
private Vector3 originalAngles;
In each frame we must calculate:
The offset vector is very simple, just subtract the current position from the position of the previous frame.
Vector3 currentPosition = transform.position;
Vector3 offset = currentPosition - oldPosition;
...
oldPosition = currentPosition;
To rule out small vibrations, we will only calculate the angles when the modulus of offset is greater than Mathf.Epsilon (which is a value very close to zero). And since we don’t care about the actual value of the modulus, we will use Vector3.sqrMagnitude to avoid calculating a square root.
if (offset.sqrMagnitude > Mathf.Epsilon)
{
pitchAngle = Mathf.Clamp(pitchAngle + offset.z * pitchForce,
pitchMinAngle,
pitchMaxAngle);
rollAngle = Mathf.Clamp(rollAngle + offset.x * rollForce,
rollMinAngle,
rollMaxAngle);
}
We already have the value of each angle, now we want that little by little those values tend to be at rest (restTime). To do it we will use the function Mathf.SmoothDamp that also smooths the changes.
pitchAngle = Mathf.SmoothDamp(pitchAngle,
0.0f,
ref pitchVelocity,
restTime * Time.deltaTime * 10.0f);
rollAngle = Mathf.SmoothDamp(rollAngle,
0.0f,
ref rollVelocity,
restTime * Time.deltaTime * 10.0f);
And now we only have to apply the angles to the rotation of the card.
transform.rotation = Quaternion.Euler(originalAngles.x + pitchAngle,
originalAngles.y,
originalAngles.z - rollAngle);
Let’s see the result.
Nice! That’s all for now. In next posts we will see some useful objects for a card game such as: decks, slot, etc. In the meantime you can add some improvements like:
Until next time…
stay gamedev, stay awesome!
Just write an email to fronkongames@gmail.com