들어가며
오늘은 파이프를 연결하는 퍼즐을 만들어 볼 것이다. 시작점과 도착점이 존재할 것이고, 유저는 다양한 파이프 종류를 보드 위에 배치하여 시작점과 도착점을 파이프들로 연결을 하여 퍼즐을 해결하는 방식이다. 어떤 식인지 감이 안 온다면 다음 유튜브 링크를 걸어놨으니 시청해 보는 것도 좋을 것이다.
https://www.youtube.com/watch?v=TXlIfjAoeWk
하지만, 나는 이 영상을 참고하진 않았고, 썸네일을 보자마자 이런 식으로 만들면 되겠다는 생각이 들었다. 그 후 바로 구상에 들어간 다음 계획을 세우기 시작하였다. 따라서 영상에 나오는 게임과는 로직이나 처리부분이 다를 수 있다.
구성요소
이 게임의 구성 요소로는 다음과 같이 존재할 것이다.
- 파이프를 배치할 수 있는 Block(블록)과 블록을 가지고 있는 Board(보드)
- 배치할 수 있는 Pipe(파이프)
- 유체가 흐르기 시작하는 시작점
- 유체가 도착해야하는 도착점
- 유체는 파이프가 잘 연결되었는지 확인하기 위한 용도(즉, 클리어 조건)
이때, 파이프는 회전시켜 배치가 가능하며, 종류는 총 4가지가 있다.
- 일자형(직선)
- ㄱ자형(커브)
- T자형
- 십자형(크로스)
또한, 게임 진행의 큰 흐름은 다음과 같은 것이다.
1. 보드에 시작점과 도착점이 생성되고, 유저에게 파이프가 지급된다.
2. 유저는 자유롭게 보드에 파이프를 배치하여 시작점과 도착점을 연결한다.
3. "시작 버튼"을 눌러 유체를 흘려보내 잘 연결되었는지 시험한다.
유체가 도착점에 도착하면 해당 스테이지를 클리어한다.
이후에 수정될 수 있지만, 현재로서는 이런 방향을 가지고 개발을 해나갈 것 같다.
개발 전 고려사항
게임 개발에 들어가기전 지금까지 생각해 놓은 계획들과 구성들로 봤을 때 우려사항이 있을 수도 있고, 고려사항이 존재할 것이다.
다음은 현재 떠오른 고려사항들이다.
- 랜덤성이 있는가?
- 유저가 파이프를 배치할 때, 제공 되어야할 기능들은 무엇이 있는가?
- 유체가 흐른다는 것의 표현은 어떻게 처리할 것인가?
- 파이프와 파이프의 연결은 어떻게 처리할 것인가?
랜덤성에 대한 고려사항은 퍼즐이 시작될 때, 시작점과 도착점의 생성과 파이프 지급에서 나타난다. 일단 만들 프로토타입의 게임에서는 랜덤으로 생성이 되게끔 하겠다.
유저가 파이프를 배치할 때, 제공되어야 할 기능들에는 선택, 회전, 제거 등이 있을 것이다.
유체의 표현은 생략하도록 하겠다. 이는 연출적인 부분이며, 유체는 단지 유저가 퍼즐을 클리어했는지, 아닌지만 판단하면 되기 때문에 연출적인 부분인 유체의 표현은 챌린지의 규칙대로 생략하도록 하겠다.
파이프와 파이프의 연결은 어떻게 처리를 할 것인가에 대해서는 DFS 알고리즘을 활용하도록 하겠다. 파이프는 방향성을 가지고 있는 도형(오브젝트)이기 때문에 이런 문제를 해결하기에는 적합하다고 생각하였다.
개발 계획
초기 개발 계획 및 순서는 이렇다.
1. 프로젝트 세팅 및 오브젝트 제작
2. 스테이지 시작 (시작점과 도착점 생성)
3. 파이프
4. 클리어 판정
5. 마무리
6. 스테이지 및 에디터
기존 게획은 이렇게 되어있으며, 개발이 끝날 때쯤에는 마무리 부분 대신에 파이프의 생성과 제거에 관한 개발을 진행하였다. 또한, 스테이지 및 에디터는 시간 관계상 이루지 못하였으며, 총 개발 시간은 약 13시간 정도 소요하였다.
원래는 토요일 하루에 끝낼 생각이었으나, 새벽 12시까지 진행하였을 때가 클리어 판정을 완성한 시기였다. 그래서 실패로 남기고 다음 날인 일요일에 이후 마무리를 하였으며, 스테이지와 에디터 부분은 일요일 안에 끝내지 못할 것이라 판단하여 진행하지 않았다.
개발현황
프로젝트 세팅 및 오브젝트 제작
가장 먼저, 블록을 관리하는 오브젝트인 Board를 만들고, 그 안에 Block들을 만들어주는 형태로 만들 생각이다.

Board 안에 Blocks가 있고, 모든 Block은 Block의 하위 오브젝트로 생성될 것이다. Blocks는 1000*1000의 크기로 Grid Layout Group 컴포넌트를 가지고 있어 자동으로 Block들을 정렬시켜 줄 것이다.

이제 Block이 생성될 장소를 마련했으니, Block을 프리팹으로 만들고, 10*10의 크기로 보드에 블록들을 생성시킬 것이다. 작성할 클래스들은 PuzzleController, PuzzleGenerater이다.
PuzzleController의 역할은 보드 판의 Block들을 관리하고, 파이프의 개수 관리, 게임의 시작과 끝 처리 등이 될 것이고, PuzzleGenerater는 퍼즐 블록을 생성하는 등의 기능이 있을 것이다.,
코드는 다음과 같다.
PuzzleController.cs
using UnityEngine;
namespace ConnectPipe
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private PuzzleGenerater generater;
private int rows = 10; //y, 행
private int cols = 10; //x, 열
private void Start()
{
GameStart();
}
public void GameStart()
{
generater.GenerateBlock(rows, cols);
}
}
}
PuzzleGenerater.cs
using UnityEngine;
namespace ConnectPipe
{
public class PuzzleGenerater : MonoBehaviour
{
[SerializeField]
GameObject block;
[SerializeField]
Transform blocksTrs;
public void GenerateBlock(int rows, int cols)
{
for (int y = 0; y < rows; y++)
{
for (int x = 0; x < cols; x++)
{
GameObject newBlock = Instantiate(block, blocksTrs);
newBlock.name = x + " , " + y;
}
}
}
}
}
여기까지 작성을 해주고, 각 스크립트를 씬 위의 오브젝트에 붙여주었다.


시작점과 도착점 생성
스테이지가 시작되면, 보드위의 10*10의 블록 중에서 시작점과 도착점이 생성되게 만들 것이다. 위치를 고정시킬 수도 있지만, 테스트를 위해서 랜덤으로 위치가 생성이 되게끔 만들어보겠다.
그리고, 시작점과 도착점으로 지정된 블록의 색을 일반 블록과 다르게 하여 쉽게 구별하기 쉽게 하겠다.
시작점 : 파란색, 유체가 흐르기 시작하는 블록
도착점 : 빨간색, 유체가 도착해야하는 블록
그러기 위해서는 먼저 블록의 상태를 정의해야한다.
- Empty : 비어있는 블록
- Fill : 파이프가 배치된 블록
- Obstacle : 장애물 블록 (파이프 설치 불가)
- Firm : 특정 파이프가 고정되어 배치된 블록
- Start : 시작점
- End : 도착점
이렇게 미리 블록의 상태들을 정의해 주었고, 이를 enum으로 선언해주었다. 그다음 Block에 붙일 스크립트를 작성하도록 하겠다.
Block의 코드는 다음과 같다.
Block.cs
using UnityEngine;
using UnityEngine.UI;
namespace ConnectPipe
{
public enum BlockState
{
Empty, //비어있는 블록(파이프 배치가능)
Fill, //파이프가 배치된 블록
Obstacle, //장애물 블록 (파이프 배치 불가)
Firm, //특정 파이프가 고정되어 있는 블록
Start, //시작점
End, //도착점
}
public class Block : MonoBehaviour
{
Image blockImage; //Block의 이미지 컴포넌트
[SerializeField]
BlockState state; //Block의 종류
[SerializeField]
Vector2Int position; //Block의 위치
public void InitBlock(BlockState arg, Vector2Int pos)
{
state = arg;
position = pos;
blockImage = GetComponent<Image>();
SetState(arg);
}
void SetColor(Color color)
{
blockImage.color = color;
}
public void SetState(BlockState argS)
{
state = argS;
switch (state)
{
case BlockState.Empty:
SetColor(Color.white);
break;
case BlockState.Fill:
//SetColor(Color.white);
break;
case BlockState.Obstacle:
SetColor(Color.black);
break;
case BlockState.Firm:
break;
case BlockState.Start:
SetColor(Color.blue);
break;
case BlockState.End:
SetColor(Color.red);
break;
}
}
public BlockState GetState()
{
return state;
}
}
}
위에서 정의한 대로 enum인 BlockState를 선언해주었다. 그리고 블록의 초기화를 진행할 수 있는 함수인 InitBlock을 만들고, 중간에 값이 바뀌는 프로퍼티들의 Getter와 Setter를 만들어주었다.
PuzzleGenerater.cs
using UnityEngine;
namespace ConnectPipe
{
public class PuzzleGenerater : MonoBehaviour
{
[SerializeField]
GameObject blockPrefab;
[SerializeField]
Transform blocksTrs;
public void GenerateBlock(int rows, int cols)
{
for (int y = 0; y < rows; y++)
{
for (int x = 0; x < cols; x++)
{
GameObject newBlock = Instantiate(blockPrefab, blocksTrs);
newBlock.name = x + " , " + y;
Block block = newBlock.GetComponent<Block>();
block.InitBlock(BlockState.Empty, new Vector2Int(x, y));
}
}
}
}
}
Block 클래스를 만들어 해당 프리팹에 붙였으니, 해당 블록을 만들면서 동시에 초기화를 진행해 주기로 하였다.
PuzzleGenerater의 GeneraterBlock 함수에 코드 두 줄을 추가하였다.
여기까지가 블록의 생성과 초기화를 하는 과정이었으며, 이제 다음은 시작점과 도착점을 생성하는 로직을 작성하겠다.
시작점과 도착점을 생성하는 것은 이미 만들어진 보드 위의 블록 중에서 하나씩 골라서 그 블록의 상태를 변경시키는 방식이며, 이는 PuzzleController가 해주도록 하겠다.
코드는 다음과 같다.
PuzzleController.cs
using UnityEngine;
namespace ConnectPipe
{
public class PuzzleController : MonoBehaviour
{
[SerializeField]
private PuzzleGenerater generater;
private int rows = 10; //y, 행
private int cols = 10; //x, 열
Block[,] blocks;
Block startBlock;
Block endBlock;
private void Start()
{
GameStart();
}
public void GameStart()
{
blocks = generater.GenerateBlock(rows, cols);
//우선 랜덤으로 시작점과 도착점 정하기
int startPosX = Random.Range(0, cols);
int startPosY = Random.Range(0, rows);
int endPosX = Random.Range(0, cols);
int endPosY = Random.Range(0, rows);
SelectStartEndBlock(new Vector2Int(startPosX,startPosY)
, new Vector2Int(endPosX, endPosY));
}
void SelectStartEndBlock(Vector2Int startPos, Vector2Int endPos)
{
blocks[startPos.x, startPos.y].SetState(BlockState.Start);
blocks[endPos.x, endPos.y].SetState(BlockState.End);
}
}
}
SelectStartEndBlock 함수를 만들어 GameStart 함수 내부에서 호출하도록 만들었다. 잘 작동하는지 확인하기 위하여 시작점과 도착점을 랜덤으로 정하도록 하였으며, 이후에 데이터를 받아 시작점과 도착점의 위치가 고정된 경우를 생각하여 매개변수를 받아 해당 좌표에 있는 블럭들의 상태를 변경하도록 만들었다.
테스트한 결과는 다음과 같다.

파이프 만들기
이제 유저가 배치해야하는 파이프를 만들어보는 차례이다. 시작하기 전에 생각해 놓은 파이프의 종류는 4개였지만, 지금은 테스트를 위해서 하나만 만들어 프리팹으로 두겠다.

크기가 잘 맞는지 테스트를 하고, 파이프들의 접합부가 잘 보이지 않아 Outline 컴포넌트를 이용해 외곽선을 표시했다.

파이프 프리팹을 만들었으니, 파이프에 붙일 Pipe 스크립트를 작성하도록 하겠다.
코드는 다음과 같다.
Pipe.cs
using UnityEngine;
namespace ConnectPipe
{
public enum PipeForm
{
Straight, //일자형
Cross, //십자형
Curve, //ㄱ자형
T_Shape, //T자형
}
public enum RotateDirection
{
Up = 0, //회전 0
Right, //회전 1
Down, //회전 2
Left, //회전 3
}
public class Pipe : MonoBehaviour
{
[SerializeField]
PipeForm pipeForm;
[SerializeField]
RotateDirection currRotation;
//파이프의 필드 초기화
public void InitPipe(PipeForm argForm)
{
pipeForm = argForm;
currRotation = RotateDirection.Up;
transform.rotation = Quaternion.identity; //회전 초기화
}
//파이프의 형태 setter
public void SetForm(PipeForm argForm)
{
pipeForm = argForm;
}
//파이프의 형태 getter
public PipeForm GetForm()
{
return pipeForm;
}
//현재 파이프의 회전 방향 getter
public RotateDirection GetDirection()
{
return currRotation;
}
}
}
파이프의 프로퍼티들만 선언한 상태이며, enum으로 선언한 PipeForm은 파이프의 형태이며, RotateDirection(이후에 Direction으로 이름을 변경)은 파이프의 현재 회전 상태를 나타낸다.
회전을 한 번도 하지 않았거나, 원래의 상태(z축으로 0도)이면 Up( == 0)이며, 만약 두 번 회전을 했다면 (z 축으로 180도)이면 Down(==2) 이런 형태이다.
당장은 사용하지 않지만, 미리 만들어 두었다.
파이프 선택 및 이동 처리
파이프의 이동 처리를 구현하도록 하겠다.
우선, 이 게임이 빌드된 환경을 핸드폰이라는 가정하에 다음과 같은 조작방법들을 구상하였다.
- 터치 시, 해당 파이프를 선택
- 이후 선택된 파이프에 추가적 조작이 가능 (예 : 회전, 제거)
- 드래그 시작 시, 파이프가 유저의 손가락(마우스 위치)을 따라 이동
- 또는 드래그 도중에 회전 버튼을 눌러 회전 조작
- 터치 종료(드래그 종료) 시, 파이프가 가장 가까운 블록에 배치
- 만약, 블록이 아닌 파이프 대기 UI에 더 가깝다면 원상복귀
이를 구현하기 위해 플레이어의 입력을 받아 추가적인 추가적인 처리를 하는 InputController를 따로 만들어 주었다.
코드는 다음과 같다.
using UnityEngine;
using UnityEngine.EventSystems;
namespace ConnectPipe
{
public class InputController : MonoBehaviour
{
[SerializeField]
GameObject curSelectPipe;
private void Update()
{
if (Input.GetKeyDown(KeyCode.R))
{
RotateSelectPipe();
}
if (Input.GetKeyDown(KeyCode.Delete))
{
RemoveSelectPipe();
}
if (Input.GetKeyDown(KeyCode.Escape))
{
EmptySelectPipe();
}
}
public void SetSelectPipe(GameObject selectPipe)
{
if (curSelectPipe == null && selectPipe.GetComponent<Pipe>() != null)
{
curSelectPipe = selectPipe;
}
//같은 파이프를 두번 클릭 했다면 취소 또는 Null이 매개변수로 들어왔으면 취소
else if (curSelectPipe == selectPipe || selectPipe == null)
{
curSelectPipe = null;
}
}
public void RotateSelectPipe()
{
if (curSelectPipe != null)
{
//파이프 회전 로직
}
}
//배치되어 있던 파이프를 제거
public void RemoveSelectPipe()
{
if (curSelectPipe != null)
{
//파이프 제거 로직
//제거 후 현재 선택한 파이프를 비우기
curSelectPipe = null;
}
}
//현재 선택된 파이프를 선택 해제
void EmptySelectPipe()
{
PointerEventData nullEventData = null;
curSelectPipe.GetComponent<Pipe>().OnPointerDown(nullEventData);
curSelectPipe = null;
}
}
}
이렇게 유저의 입력을 받으면, 여러 가지 처리를 하는 InputController의 틀을 만들어주었다.
우선은 현재 선택된 파이프를 curSelectPipe에 담으며, SetSelectPipe 함수를 통해 선택 파이프를 담을 수 있게 만들었다. 만약 이미 선택된 파이프가 매개변수로 들어오면, 해당 파이프를 선택 해제하려는 것으로 판단하여 curSelectPipe를 null로 만들어주었다.
이제 Pipe 클래스에 IPointer 인터페이스를 활용하여 실제 입력을 받으면, InputController의 함수들을 호출하여 추가적인 처리(선택)를 진행하도록 하겠다.
Pipe의 코드는 다음과 같다.
Pipe.cs
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace ConnectPipe
{
public enum PipeForm
{
Straight, //일자형
Cross, //십자형
Curve, //ㄱ자형
T_Shape, //T자형
}
public enum RotateDirection
{
Up = 0, //회전 0
Right, //회전 1
Down, //회전 2
Left, //회전 3
}
public class Pipe : MonoBehaviour , IPointerDownHandler ,IBeginDragHandler, IDragHandler, IEndDragHandler ,IPointerUpHandler
{
//중간 생략...
//조작 관련 메소드들
public void OnPointerDown(PointerEventData eventData)
{
isSelected = !isSelected;
SetOutLineColor(isSelected);
inputController.SetSelectPipe(gameObject);
}
public void OnBeginDrag(PointerEventData eventData)
{
//드래그 중이라면 약간 투명해지는 연출
for (int i = 0; i < transform.childCount; i++)
{
image[i].color = new Color(image[i].color.r, image[i].color.g, image[i].color.b, 0.4f);
outline[i].effectColor = new Color(outline[i].effectColor.r, outline[i].effectColor.g, outline[i].effectColor.b, 0.0f);
}
}
public void OnDrag(PointerEventData eventData)
{
rectTrs.anchoredPosition += eventData.delta / canvas.scaleFactor;
}
public void OnEndDrag(PointerEventData eventData)
{
for (int i = 0; i < transform.childCount; i++)
{
image[i].color = new Color(image[i].color.r, image[i].color.g, image[i].color.b, 1f);
outline[i].effectColor = new Color(outline[i].effectColor.r, outline[i].effectColor.g, outline[i].effectColor.b, 1f);
}
}
public void OnPointerUp(PointerEventData eventData)
{
//블록에 배치하는 로직
//또는 원상복귀하는 로직
}
void SetOutLineColor(bool isSelected)
{
switch (isSelected)
{
case true:
// outline.effectColor = Color.cyan;
foreach(var item in outline)
{
item.effectColor = Color.green;
}
break;
case false:
// outline.effectColor = Color.black;
foreach (var item in outline)
{
item.effectColor = Color.black;
}
break;
}
}
}
}
인터페이스들을 장착하여 파이프가 클릭과 드래그를 인식할 수 있게 하였다.
- 클릭 (OnPointerDown) : 클릭되면 선택 상태 전환(isSelected!= isSelected)이 되고, 자신을 매개변수로 InputController에 건네줘서 선택되었다고 등록
- 드래그 시작 (OnBeginDrag) : 드래그 시작 단계에서는 파이프가 약간 투명해지는 연출을 해주어서, 드래그 도중 파이프가 유저의 시야를 방해하지 않도록 처리했다.
- 드래그 중 (OnDrag) : 드래그 도중에는 파이프의 위치를 마우스 포인터의 위치를 따라오게끔 만들었다.
- 드래그 종료(OnDragEnd) : 파이프가 투명해지는 연출을 다시 되돌렸다.
- 클릭 종료 (OnPointerUp) : 이곳에는 클릭이 종료될 때 필요한 연출 및 처리를 해주기 위해서 만들었지만, 이후에는 사용하지 않아서 삭제하였다.
파이프의 드래그 드롭 처리
파이프가 드래그 시, 이동하는 처리는 되었으니 이제 드래그 드롭에 관련하여 처리를 하도록 하겠다. 드롭을 감지하는 오브젝트는 블록이 될 테니, 블록이 IDropHandler 인터페이스를 장착시켜 구현하도록 하겠다.
코드는 다음과 같다.
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace ConnectPipe
{
public enum BlockState
{
Empty, //비어있는 블록(파이프 배치가능)
Fill, //파이프가 배치된 블록
Obstacle, //장애물 블록 (파이프 배치 불가)
Firm, //특정 파이프가 고정되어 있는 블록
Start, //시작점
End, //도착점
}
public class Block : MonoBehaviour , IDropHandler
{
Image blockImage; //Block의 이미지 컴포넌트
[SerializeField]
BlockState state; //Block의 종류
[SerializeField]
Vector2Int position; //Block의 위치
[SerializeField]
Pipe pipe; //파이프
public void InitBlock(BlockState arg, Vector2Int pos)
{
state = arg;
position = pos;
blockImage = GetComponent<Image>();
SetState(arg);
}
void SetColor(Color color)
{
blockImage.color = color;
}
public void SetState(BlockState argS)
{
state = argS;
switch (state)
{
case BlockState.Empty:
SetColor(Color.white);
break;
case BlockState.Fill:
//SetColor(Color.white);
break;
case BlockState.Obstacle:
SetColor(Color.black);
break;
case BlockState.Firm:
break;
case BlockState.Start:
SetColor(Color.blue);
break;
case BlockState.End:
SetColor(Color.red);
break;
}
}
public BlockState GetState()
{
return state;
}
public void SetPipe(Pipe arg)
{
pipe= arg;
RectTransform blockRect = GetComponent<RectTransform>();
RectTransform pipeRect = pipe.GetComponent<RectTransform>();
pipeRect.SetParent(transform);
pipeRect.anchoredPosition = Vector2.zero;
pipeRect.localScale = Vector2.one;
SetState(BlockState.Fill);
}
public void OnDrop(PointerEventData eventData)
{
if (state != BlockState.Empty) return; //파이프는 비어있는 블록에만 배치 가능
Debug.Log($"OnDrop :: {gameObject.name}");
if (eventData.pointerDrag != null)
{
Pipe droppedPipe = eventData.pointerDrag.GetComponent<Pipe>();
if (droppedPipe == null) return;
//새로 배치할 경우 이전의 블록에서 pipe를 제거
Block oldBlock = droppedPipe.GetComponentInParent<Block>();
if(oldBlock != null && oldBlock != this)
{
oldBlock.RemovePipe();
}
SetPipe(droppedPipe);
}
}
//파이프를 비우는 함수
public void RemovePipe()
{
if (pipe != null)
{
pipe = null;
SetState(BlockState.Empty);
}
}
}
}
SetPipe 함수부터 가장 마지막에 있는 RemovePipe 함수까지가 이번에 추가된 부분이다.
파이프가 드롭이 되면 가장 먼저 블록의 상태가 Empty인지 확인한다. 파이프는 비어있는 블록에만 배치할 수 있기 때문이다.
그 이후에 드롭된 객체가 null이 아니고, Pipe인지 확인을 한다. 만약, 이전에 배치된 블록이 있다면 이전에 배치된 블록에서 Pipe를 제거하고 자신에게 배치시킨다. 이때 이전 블록의 RemovePipe를 호출시켜 처리한다.
배치는 파이프를 자신의 자식 오브젝트로 만들고, 위치를 (0, 0)으로 만든다. 그리고 파이프가 배치가 되었으니, 다른 파이프가 배치되지 못하도록 자기 자신(블록)의 상태를 Fill로 변경한다.
문제 해결 (디버깅)
여기까지 만들면서 중간중간 테스트를 해보았는데, 돌아가지 발견된 문제들이 있었다. 그 문제들은 다음과 같았다.
- 드래그 드롭 이벤트가 제대로 인식되지 않는 버그
- 블록에 파이프를 배치한 후 다시 드래그하였을 때, 현 부모인 블록의 렌더링 순서를 따라가기에 일부 블록의 뒤로 렌더링 되는 버그
- 드래그 드롭에 실패하였을 때의 처리를 추가
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace ConnectPipe
{
public enum PipeForm
{
Straight, //일자형
Cross, //십자형
Curve, //ㄱ자형
T_Shape, //T자형
}
public enum RotateDirection
{
Up = 0, //회전 0
Right, //회전 1
Down, //회전 2
Left, //회전 3
}
public class Pipe : MonoBehaviour , IPointerDownHandler ,IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField]
PipeForm pipeForm;
[SerializeField]
RotateDirection currRotation;
[SerializeField]
InputController inputController;
[SerializeField]
Canvas canvas;
[SerializeField]
bool isSelected;
Outline[] outline;
Image[] image;
RectTransform rectTrs;
private Transform originParent;
private Vector2 originAnchoredPos;
//테스트용 Start
private void Start()
{
InitPipe(PipeForm.Straight);
}
//파이프의 필드 초기화
public void InitPipe(PipeForm argForm)
{
pipeForm = argForm;
currRotation = RotateDirection.Up;
transform.rotation = Quaternion.identity; //회전 초기화
outline = new Outline[transform.childCount];
outline = GetComponentsInChildren<Outline>();
image = new Image[transform.childCount];
image = GetComponentsInChildren<Image>();
rectTrs = GetComponent<RectTransform>();
inputController = GameObject.Find("@Controller").GetComponent<InputController>();
canvas = GameObject.Find("Canvas").GetComponent<Canvas>();
}
//파이프의 형태 setter
public void SetForm(PipeForm argForm)
{
pipeForm = argForm;
}
//파이프의 형태 getter
public PipeForm GetForm()
{
return pipeForm;
}
//현재 파이프의 회전 방향 getter
public RotateDirection GetDirection()
{
return currRotation;
}
//조작 관련 메소드들
public void OnPointerDown(PointerEventData eventData)
{
//토글 형식의 선택 방식
isSelected = !isSelected;
SetOutLineColor(isSelected);
//null을 전해주면 selectedPipe가 null이 됨
inputController.SetSelectPipe(isSelected ? gameObject : null);
}
public void OnBeginDrag(PointerEventData eventData)
{
//이전 부모가 블록일 경우
if (transform.parent.GetComponent<Block>() != null)
{
originParent = transform.parent;
originAnchoredPos = rectTrs.anchoredPosition;
}
//드래그할 때 항상 앞에 보이도록 캔버스를 부모로 지정(자동으로 가장 마지막 SiblingIndex를 받게됨)
transform.SetParent(canvas.transform);
//드래그 중이라면 약간 투명해지는 연출
for (int i = 0; i < transform.childCount+1; i++)
{
//파이프 이미지에 의해 막혀서 드롭 이벤트가 호출이 안되는 문제가 발생
image[i].raycastTarget = false; //그래서 드래그 도중에는 raycastTarget을 꺼서 드롭이벤트가 잘 작동하도록 함
if(i != 0) //0번째 이미지 컴포넌트는 본체를 말함
//본체는 이벤트를 위해 추가한 것으로, 실제로는 색상이 변경되면 안되니 조건문을 추가
{
image[i].color = new Color(image[i].color.r, image[i].color.g, image[i].color.b, 0.4f);
}
//본체는 outline 컴포넌트가 없어서, 두 배열의 길이가 다름
if (i != transform.childCount) //그래서 마지막 i는 outline 배열 범위에 벗어나는 값이니 건너뛰기 위해서 조건문 추가
outline[i].effectColor = new Color(outline[i].effectColor.r, outline[i].effectColor.g, outline[i].effectColor.b, 0.0f);
}
}
public void OnDrag(PointerEventData eventData)
{
rectTrs.anchoredPosition += eventData.delta / canvas.scaleFactor;
}
public void OnEndDrag(PointerEventData eventData)
{
bool dropSuccsess = rectTrs.parent != canvas.transform; //부모가 여전히 캔버스이면 드롭 실패
if (!dropSuccsess)
{
transform.SetParent(originParent);
rectTrs.anchoredPosition = originAnchoredPos;
}
for (int i = 0; i < transform.childCount+1; i++)
{
image[i].raycastTarget = true; //반면, 드래그가 끝나면 다시 클릭이 되게 raycaseTarget을 켜줌
if(i != 0)
image[i].color = new Color(image[i].color.r, image[i].color.g, image[i].color.b, 1f);
if (i != transform.childCount)
outline[i].effectColor = new Color(outline[i].effectColor.r, outline[i].effectColor.g, outline[i].effectColor.b, 1f);
}
}
void SetOutLineColor(bool isSelected)
{
switch (isSelected)
{
case true:
// outline.effectColor = Color.cyan;
foreach(var item in outline)
{
item.effectColor = Color.green;
}
break;
case false:
// outline.effectColor = Color.black;
foreach (var item in outline)
{
item.effectColor = Color.black;
}
break;
}
}
}
}
드래그 드롭 이벤트가 제대로 인식되지 않는 버그
기존의 파이프 오브젝트의 최상위 오브젝트(본체)는 원래 빈 오브젝트였다. 그러나, 이런 경우에는 EventSystem이 제대로 작동하지 않아서 IPonter류 인터페이스의 함수들이 제대로 작동하지 않을 수 있다. 그럴 때는 본체 오브젝트에 이미지 컴포넌트를 추가하면 된다.
본인 같은 경우도 이미지 컴포넌트를 추가하고 color의 a값을 0으로 고정시켜서 투명하게 만든 다음, 색상이 변경되지 않게끔 만들었다.
그런데, 이렇게 되면 Outline 처리의 경우에는 배열의 범위를 벗어나는 문제가 생긴다.
본체에는 Image 컴포넌트가 있지만, Outline 컴포넌트는 존재하지 않는다. 그래서 image 컴포넌트를 담아놓은 image 배열은 Outline 컴포넌트를 담아놓은 outline보다 항상 하나가 더 많은 상태일 것이다. 그래서 기존 코드대로 실행을 하면, 배열의 범위를 벗어나는 문제가 발생한다. 그래서 outline 처리의 마지막에서는 건너뛰도록 처리하였다.
특정 상황에 블록 뒤에 렌더링 되는 문제
이 경우는 자식 UI오브젝트는 부모 UI오브젝트의 렌더링 순서를 따라가기 때문에 일어난 문제였다.
특정 상황은 파이프가 0,0 블록에 배치가 된 상황에서 드래그를 하였을 때, 부모가 0,0 블록이라서 hierarchy창에 0,0 블록보다 위에 있는 블록들을 지나갈 때, 그 블록들보다 렌더링 순서가 빨라서 생긴 문제였다.
그래서 해결을 위해 드래그 도중에는 캔버스를 부모로 설정하였다.
드래그 드롭에 실패하였을 때의 처리
드래그 도중에는 캔버스를 부모로 설정하는 처리까지 진행한 뒤에, 드래그 드롭에 실패하였을 때의 처리도 진행하였다. 유저가 드롭을 하면 Block의 OnDrop이 먼저 호출되고 그다음에 Pipe의 OnEndDrag가 호출된다. 그렇기 때문에, OnDrop에서 부모 설정(Block을 부모로) 이루어지지 않았다면, OnEndDrag에서 그것을 판단한다.
(아직도 부모가 Canvas이면 Block에 드롭을 실패했다고 판단하여 원래 위치로 돌아가게 만들었다.)
중간 테스트
내용이 너무 길어지는 것 같아서 여기까지 끊고 포스팅을 이어서 하도록 하겠다.
파이프의 이동과 드래그 드롭(배치)에 관련된 부분까지 작성을 하였고, 이후에는 파이프의 회전과 DFS를 이용하여 파이프의 연결상태(클리어 판정)를 내렸는지에 대한 포스팅을 이어하도록 하겠다.
아마 지금 길이로 봤을 때, 이 글을 포함해서 3개 올라갈 것 같다.
'Game Develop > 소규모 프로젝트' 카테고리의 다른 글
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [하] (0) | 2025.11.23 |
|---|---|
| "하루만에 퍼즐 게임 만들기" (4일차) : 파이프 연결 (Connect Pipe) [중] (0) | 2025.11.23 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (3일차) : 숫자 합치기 (2048) (1) | 2025.11.07 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (2일차) : 매치-3 (애니팡) (1) | 2025.11.03 |
| "하루만에 퍼즐 게임 만들기" 챌린지 (1일차) : 슬라이딩 퍼즐 (0) | 2025.10.19 |