기본적인 맵과 Enemy AI 까지 만들었습니다.
이제 카드 UI를 제작해보겠습니다.
저는 카드를 꺼내서 맵에 몹 생성/함정/버프가 가능하도록 만들 예정입니다.
카드게임 이라 하면 가장 유명한 게임 두 개가 있습니다.
하스스톤 과 슬레이 더 스파이어 입니다.
저는 둘 중 더 즐겨하는 슬더스를 참고하여 작성해보겠습니다.
혹시 해당 게임을 잘 모르신다면 유튜브 참고하시거나 플레이 해보시는걸 추천드립니다.
크게 CardBase와 CardInHand 클래스로 나누겠습니다.
CardBase 는 추상클래스로 나중에 각 카드의 기능을 추상함수로 구현할 예정입니다.
CardInHand 는 각 카드를 자식으로 두고 List로 관리합니다.
CardBase 부터 보겠습니다.
public abstract class CardBase : MonoBehaviour
, IPointerEnterHandler
, IPointerExitHandler
, IBeginDragHandler
, IDragHandler
, IEndDragHandler
우선 여러 인터페이스들을 상속받습니다.
IPointerHandler 는 Hover를 구현하기 위해 있고,
IDragHandler 는 카드를 드래그해서 실제 맵에 효과를 구현하기 위해 상속했습니다.
private void Update()
{
var targetV = UtilFunctions.CardLerp(rect.anchoredPosition, targetPos, 6f);
this.rect.anchoredPosition = new Vector3(targetV.x, targetV.y);
this.rect.localPosition = new Vector3(rect.localPosition.x, rect.localPosition.y, 0);
this.rect.localScale= UtilFunctions.CardLerp(rect.localScale, targetScale, cardMoveSpeed);
var rotateZ = rect.localRotation.eulerAngles.z;
if (rotateZ >= 180) rotateZ = rotateZ - 360;
this.rect.localRotation = Quaternion.Euler(0, 0, UtilFunctions.CardRotateLerp(rotateZ, targetAngle, 6f));
}
핵심적인 Update문 입니다.
poistion, angle, scale 각각 target변수를 따로 두고 lerp로 따라가게 했습니다.
rotateZ는 그냥 쓰면 0~360 사이의 값이 나옵니다. lerp를 사용하기 위해 180 이상은 음수로 변환해줍니다.
또한 anchoredPosition을 사용해서 화면의 비율도 고려했습니다.
다음은 일부 인터페이스 멤버를 살펴보겠습니다.
public void OnBeginDrag(PointerEventData eventData)
{
isDragged = true;
transform.parent.GetComponent<CardInHand>().UpdateCardLayout();
}
OnBeginDrag 함수입니다.
isDragged 변수를 true로 두고 CardInHand 의 UpdateCardLayout 를 호출합니다.
간단하게 말하면 카드의 개수에 따라 위치를 조정하는 함수입니다.
밑에서 자세하게 살펴보겠습니다.
public void OnDrag(PointerEventData eventData)
{
var mousePos = transform.parent.InverseTransformPoint(Camera.main.ScreenToWorldPoint(Input.mousePosition));
SetTargetPosX(mousePos.x);
SetTargetPosY(mousePos.y);
var tmpColor = GetComponent<Image>().color;
if (mousePos.y > CARD_HEIGHT)
{
isInField = true;
tmpColor.a = UtilFunctions.ColorAlphaLerp(tmpColor.a, 0f, 10f);
GetComponent<Image>().color = tmpColor;
}
else
{
isInField = false;
tmpColor.a = UtilFunctions.ColorAlphaLerp(tmpColor.a, 1f, 6f);
GetComponent<Image>().color = tmpColor;
}
}
OnDrag 함수입니다. 드래그 하는동안 계속 호출됩니다.
마우스 위치를 CardInHand 기준으로 변환해서 target값을 바꿔줍니다.
또한, 자연스러운 효과를 위해 일정 높이 이상이면 점점 투명해지게 만들었습니다.
이 때 isInField 를 바꿔주어서 카드를 뗄 떼, 손으로 들어올지 맵에 기능을 구현할지 결정합니다.
public void OnEndDrag(PointerEventData eventData)
{
CameraController.CanMove = true;
isDragged = false;
if (isInField)
{
ActivateEffect(Camera.main.ScreenToWorldPoint(Input.mousePosition));
transform.parent.GetComponent<CardInHand>().RemoveCardInHand(transform.GetSiblingIndex());
}
else
{
SetColor(Color.white);
transform.parent.GetComponent<CardInHand>().UpdateCardLayout();
}
}
드래그를 끝낼 때 호출되는 OnEndDrag 함수입니다.
isInField 가 true 면 삭제시키고, ActiveEffect 추상 함수를 호출합니다.
false 면 색을 원래대로 되돌리고 카드배치를 업데이트합니다.
그 외 OnPointerEnter/Exit 는 카드의 targetPosition, angle, scale 을 조금씩만 조정해주면 돼서 생략하겠습니다.
CardInHand 클래스에는 크게 카드 추가, 삭제, 리스트 업데이트, 카드 배치 업데이트 등이 있습니다.
카드 추가 및 삭제 함수는 List에 Add 한 후에 카드 배치 업데이트 하는게 전부라서 생략하겠습니다.
public void UpdateCardLayout()
{
UpdateCardList();
// posx 로직 구현
float posXUnit = 0.43f;
float sum = 0;
if (cardsInHand.Count % 2 == 0) sum += posXUnit;
for (int i = cardsInHand.Count/2; i < cardsInHand.Count; i++)
{
cardsInHand[i].SetTargetPosX(CardBase.CARD_WIDTH * sum);
cardsInHand[cardsInHand.Count - i - 1].SetTargetPosX(-1 * CardBase.CARD_WIDTH * sum);
sum += posXUnit * 2;
posXUnit -= 0.05f;
}
// angle과 posy 로직 구현
for (int i = 0; i < cardsInHand.Count/2; i++)
{
cardsInHand[i].SetTargetAngle(ANGLE_PER_CARD * (cardsInHand.Count/2 - i));
cardsInHand[cardsInHand.Count -i -1].SetTargetAngle(-1 * ANGLE_PER_CARD * (cardsInHand.Count / 2 - i));
float offsetSum = 0;
if (i == 0) {
cardsInHand[i].SetTargetPosY(0);
cardsInHand[cardsInHand.Count - i - 1].SetTargetPosY(0);
}
else
{
for (int t = 0; t < i; t++) offsetSum += CARD_POS_Y_OFFSET * Mathf.Pow(0.86f, t);
cardsInHand[i].SetTargetPosY(offsetSum);
cardsInHand[cardsInHand.Count - i - 1].SetTargetPosY(offsetSum);
}
}
if (cardsInHand.Count % 2 != 0)
{
cardsInHand[cardsInHand.Count / 2].SetTargetAngle(0);
float offsetSum = 0;
for (int t = 0; t < cardsInHand.Count / 2; t++) offsetSum += CARD_POS_Y_OFFSET * Mathf.Pow(0.86f, t);
cardsInHand[cardsInHand.Count /2].SetTargetPosY(offsetSum);
}
}
UpdateCardList 함수는 현재 자식으로 가지는 카드들을 List에 최신화하는 함수입니다.
카드의 개수에 따라 각 카드의 targetPosition, targetAngle을 바꿔줍니다.
각 상수값들은 최대한 비슷하게 만들기 위해 조정한 값입니다.
여기까지 하고 실행화면을 한번 보겠습니다.

Hover 때 targetPosY에 CardWidth의 절반을 더한게 약간 어색합니다.
더하지 말고 그냥 PosY를 CardWidth의 절반으로 설정해주겠습니다.
뭔가 밋밋해 보입니다.
카드위에 마우스가 올라갔을 때 그 카드 말고 다른 카드들도 움직이면 더 자연스러울 것 같습니다.
따라서 함수 하나를 더 추가하겠습니다
private void UpdateWhenHovered()
{
var hoverIdx = HasAnyHovoeredCard();
if (hoverIdx == -1) return;
// hover시 다른 카드들이 밀려나도록 만듦
for (int i=0; i< cardsInHand.Count; i++)
{
if (i == hoverIdx) continue;
cardsInHand[i].SetTargetPosX(cardsInHand[i].GetTargetPosX() + Mathf.Pow(0.3f, Mathf.Abs(i-hoverIdx)) * CardBase.CARD_WIDTH * Mathf.Sign(i-hoverIdx));
}
}
각 CardBase에서 OnPointerEnter 시에 호출하는 함수입니다.
카드들이 살짝식 옆으로 밀려나서 자연스럽게 만들어줍니다.
수정 후의 실행화면 보겠습니다.

주변 카드까지 움직여주니 좀더 자연스러워 졌습니다.
이번에는 복잡한 로직이 아니라서 금방 끝낼 수 있었습니다.
다음에는 카드 기능을 약간 구현하고 맵에 설치하는 기능을 만들어보겠습니다.
저는 여러 클래스를 왔다갔다 하면서 동시에 쌓아올려서 코드가 이해가 가지만, 블로그에 정리하면서 한 클래스씩 살펴보고 몇몇 부분은 생략하다보니 처음 보시는 분들은 이해가 잘 가지 않을 수도 있을 것 같습니다.
코드 전문은 제 Github에서 보실 수 있고 참고자료를 항상 하단에 적어두고 있으니, 이 글의 설명만으로 부족하시다면 참고하시기 바랍니다.