다행히 기초적인 MVP는 하루에 만들 수 있었다. 근데 뭔가뭔가 조악해서 이런저런 개선은 알아봐야함. 변환식을 적용하면 스테이지에서의 동작은 항상 변환식을 거치도록 해야하는데, 이 부분을 감안하지 않고 테스트를 적용했다가 잠시 헤매게 되었다.
2025.09.25 - [구현하기/Unity] - 퍼즐위치사가 3 클론 기록 #1 : 서론
퍼즐위치사가 3 클론 기록 #1 : 서론
기회가 있어서 퍼즐위치사가를 클론하게 되었는데, 내용 구조 상 오리지널 게임 퍼즐보불이랑 유사한 점이 많았다. 이제 단순히 만드는 것도 만드는건데, 만들기 전에 코드를 사전에 어느정도
hyeonistic.tistory.com
기존 글에서 이어진다.
MVP 정의하기
- 발사한 후 기존 버블이 멈출 때 까지 발사를 진행 할 수 없다.
- 발사하면 "벽" 에는 반사된다. "천장" 또는 "버블" 에는 부착된다.
- 3개 이상 붙으면 제거 된다.
- [Extra] 인접칸 없는 경우 제거한다.
발사 로직 만들기
대략의 자연어 형태의 그림을 맞추어놨기 때문에, 그걸 수도코드로 만들고 제미나이한테 던지긴 했다. 체감상 GPT 상위면서 Claude 만큼은 아닌 느낌. 물론 어줍잖게 이거 안된다고, 저거 안된다고 말하는 것에 비해 수도코드를 만들어 던지면 환각 현상을 정말 많이 줄일 수 있었다.
발사 로직은 두 가지로 구성했다. 마우스를 꾹 누르고 있으면 조준, 그 상태에서 놓아야 발사하게끔 했는데, 조준 만드는게 생각보다 애먹었고 원본에 가깝게 구현하지도 못했다. 뭐 그 경로로 나가기라도 하면 다행인데..
발사 부분을 생각을 했는데, Raycast와 Circlecast를 혼합한 형태를 사용해야 했다. 정확한 위치에 표시하면서, 진짜 그 방향으로 버블을 발사하면 그대로 갈 것이다 라는 표시를 생각했는데, 영 시원찮았다. 지금봐도 더 딥한 접근을 어떻게 할 수 있을지 잘 모르겠음.
private void Aim()
{
Vector2 startPosition = currentBubbleSlot.position;
Vector2 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector2 direction = (mouseWorldPos - startPosition).normalized;
// 현재 조준 각도가 유효한지 계산, 조건문에 걸리면 유효하지 않음
float angle = Vector2.SignedAngle(Vector2.up, direction);
if (Mathf.Abs(angle) > maxAimAngle)
{
aimingLine.enabled = false;
if (isSilhouetteValid) silhouetteInstance.SetActive(false);
return;
}
List<Vector3> linePoints = new List<Vector3>();
linePoints.Add(startPosition);
Vector2 lastPos = startPosition;
Vector2 lastDir = direction;
// BEAM 발사!!
for (int i = 0; i < maxReflections + 1; i++)
{
// RaycastHit2D hit = Physics2D.Raycast(lastPos, lastDir, 100f);
RaycastHit2D hit = Physics2D.Raycast(lastPos, lastDir, 100f);
if (hit.collider != null)
{
if (hit.collider.CompareTag("Bubble") || hit.collider.CompareTag("Ceiling"))
{
linePoints.Add(hit.centroid);
lastPos = hit.point; // 최종 위치 업데이트 후 루프 종료
lastDir = Vector2.zero; // 방향 없음
break;
}
if (hit.collider.CompareTag("Wall"))
{
linePoints.Add(hit.point);
lastPos = hit.point + hit.normal * 0.01f;
lastDir = Vector2.Reflect(lastDir, hit.normal);
}
}
else
{
lastPos = lastPos + lastDir * 100f; // 최종 위치 업데이트
linePoints.Add(lastPos);
break;
}
}
aimingLine.positionCount = linePoints.Count;
aimingLine.SetPositions(linePoints.ToArray());
// Silhouette 최종 목적지 계산, CircleCast 사용
if (isSilhouetteValid)
{
bool isWallCollided = false;
float aimingRadius = trueBubbleRadius;
RaycastHit2D finalHit = Physics2D.CircleCast(lastPos, aimingRadius, lastDir, 100f);
//RaycastHit2D finalHit = Physics2D.CircleCast(startPosition, aimingRadius, direction, 100f);
// 만약 첫번째 CircleCast가 벽에 부딪혔고, 반사가 일어난다면
if (finalHit.collider != null && finalHit.collider.CompareTag("Wall") && maxReflections > 0)
{
isWallCollided = true;
// Raycast 루프의 마지막 위치와 방향을 사용해 최종 목적지를 다시 검색
Vector2 reflectPos = linePoints.Count >= 1 ? linePoints[linePoints.Count - 1] : startPosition;
Vector2 reflectDir = lastDir; // Raycast에서 알아냈던 최종 반사각
finalHit = Physics2D.CircleCast(reflectPos, aimingRadius, reflectDir, 1f);
}
if (finalHit.collider != null)
{
// #1 이걸로 하면 단일 경로로는 잘 작동하지만, 반사로 인한 경로부터 망가짐
Vector2 finalRawPoint = finalHit.centroid;
// #2 이걸로 하면 일관되게 망가짐. 0.5f 적용하면 좀 작동 되는 것 같아서 일단 임시로 작성
if(isWallCollided)
finalRawPoint = new Vector2(finalHit.point.x - 0.5f, finalHit.point.y - 0.5f);
Vector2Int finalHexCoords = HexGridConverter.WorldToHex(finalRawPoint);
Vector2 finalSnappedPoint = HexGridConverter.HexToWorld(finalHexCoords);
silhouetteInstance.transform.position = finalSnappedPoint;
silhouetteInstance.SetActive(true);
}
else
{
silhouetteInstance.SetActive(false);
}
}
}
벽에 박냐, 벽이 아닌 다른 곳에 박냐에 따라서도 선 경로에 대해 구분을 짓는 부분은 정직하게 작성했는데, 문제는 "Raycast로 부딪힌 영역 주변에 해당하는 Hex 좌표를 얻어오기" 가 진짜 시도한 모든 가설들이 안되가지고 일단은 구현을 뒷순위로 미루었다. 그러다가 결국 달성은 못했다. ㅎㅎ;
간단하게 요약하면 반사 허용 횟수만큼 Raycast를 발사하고 그 끝나는 지점에 실루엣 프리팹을 띄워서 지금 쏘시면 저기 맞아요 를 말하고자 했었다. 하지만 Collider 가지고 이것저것 해본다고 장난을 너무 쳐놨는지 실루엣까지 가지 못하는 현상도 다수 일어났다. 그냥 던짐
Aim 상태에서 발사를 하면 이 스크립트를 시작하게 된다 :
private FireResultDTO Fire(Vector2? direction = null)
{
// 발사 방향 계산 (Aim 함수와 동일한 로직)
Vector2 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
Vector2 singleFireDirection = direction ?? (mouseWorldPos - startPosition).normalized;
// 현재 조준 각도를 계산
float angle = Vector2.SignedAngle(Vector2.up, singleFireDirection);
// 만약 각도의 절대값이 설정된 최대 각도를 벗어난다면, 조준을 비활성화 한다.
if (Mathf.Abs(angle) > maxAimAngle)
{
aimingLine.enabled = false;
return null;
}
// 위 유효성 검사를 통과하면 실제 발사가 이루어진다.
BubbleController bubbleCtrl =
ObjectPoolManager.Instance.GetObject(bubblePrefab, startPosition, Quaternion.identity);
bubbleCtrl.Setup(false, 0, stageManager);
bubbleCtrl.Type = curBubbleType;
if (curBubbleType == BubbleType.Special)
GameManager.Instance.SpecialFired();
RefreshType(RefreshMode.Normal);
if (bubbleCtrl != null)
{
bubbleCtrl.Launch(singleFireDirection, launchSpeed);
print(singleFireDirection.ToString() + " 경로로 발사 완료");
isAbleFire = false;
}
return new FireResultDTO(bubbleCtrl, singleFireDirection);
}
이게 왜 DTO를 반환하나요? 나는 한 번에 두 개 이상의 버블을 발사하는 시스템을 만들고 싶었다. 그래서 이전 값을 참고하는데 쓰려고 했던 건데, 이 부분은 이후에 특수기능 구현 편에서 설명하겠다. 이전 값을 참고 할 수 있도록 기존 내용을 담아갈 구조를 마련했다고 생각해주면 좋겠다.
벽과 벽이 아닌 곳 구분하기
이 부분은 발사된 버블 하나하나가 들고 있는 컨트롤러가 구분하고 작동하게 만들었다. 부착이 뭐 별거냐만은 그래도 부착내지 반사에 문제가 생기면 얘 책임으로 물을 수 있다. 처음부터 단일 책임 원칙을 염두하고 구현해서 이렇게 찾아가서 고치는건 내가 여태껏 해온 프로젝트 들 중 제일 빠른 추론-수정시도-오 안되네-아 이제 됨 굿 이 사이클을 정말 빨리 돌릴 수 있었던 것 같다.
void OnCollisionEnter2D(Collision2D collision)
{
if (!isMoving) return;
if (collision.gameObject.CompareTag("Bubble") || collision.gameObject.CompareTag("Ceiling"))
{
isMoving = false;
rb.linearVelocity = Vector2.zero;
rb.bodyType = RigidbodyType2D.Kinematic;
Vector2Int bestSpot;
Vector2 hitBubbleCenter;
if (collision.gameObject.CompareTag("Ceiling"))
hitBubbleCenter = collision.contacts[0].point;
else
hitBubbleCenter = collision.transform.position;
Vector2 myCenter = transform.position;
Vector2 directionFromHitBubble = (myCenter - hitBubbleCenter).normalized;
Vector2 idealCollisionPoint = hitBubbleCenter +
(directionFromHitBubble * HexGridConverter.hexCellRadius);
Vector2Int hitBubbleCoords = HexGridConverter.WorldToHex(hitBubbleCenter);
List<Vector2Int> neighbors = HexGridConverter.GetNeighbors(hitBubbleCoords);
List<Vector2Int> emptyNeighbors = new List<Vector2Int>();
foreach (var neighbor in neighbors)
{
if (!stageManager.IsSpotTaken(neighbor))
emptyNeighbors.Add(neighbor);
}
bestSpot = emptyNeighbors[0];
float minDistance = Vector2.Distance(idealCollisionPoint,
HexGridConverter.HexToWorld(bestSpot));
foreach (var spot in emptyNeighbors)
{
float distance = Vector2.Distance(idealCollisionPoint,
HexGridConverter.HexToWorld(spot));
if (distance < minDistance)
{
minDistance = distance;
bestSpot = spot;
}
}
transform.position = HexGridConverter.HexToWorld(bestSpot);
OnLanded?.Invoke(this, bestSpot);
}
}
현사실 벽은 그냥 적절한 네모 콜라이더이기 때문에 적당히 튕기고 갈 곳 갈 것이라고 기대하기 떄문에 CollisionEnter에 쓰지 않았다. 그래서 핀트는 벽이 아닌 부분에서의 처리를 이야기 할 것이다.
우선, Ceiling에 박으면, 얘도 어찌됐든 벽 영역이라서 단일 오브젝트이다. 그래서 그 벽의 위치를 기반으로 얘가 자리 할 곳을 만드려고 하면 벽 정가운데 언저리에 버블이 쳐박히는 현상이 나겠지. 다행히도 이건 염두하고 있었어서 다른 문제를 더 오래 겪을 시간을 만들 수 있었다.
이 내용 이후에 일어나는 내용들은 얘가 전 글에서 설명했던 육각 좌표계를 이용해서 자기 자리 찾아다가 나 여기 앉을래요 하는 내용이다. 그리고 OnLanded?.Invoke(this, bestSpot); 에서 GameManager로 넘어가서 처리가 이루어진다.
public void OnBubbleLanded(BubbleController bubble, Vector2Int hexCoords)
{
stageManager.TryPlaceBubble(bubble, hexCoords);
stageAnalyzer.CheckForMatches(hexCoords);
// 잠재위험 : 승리 패배 동시 출력 여지
uiManager.UpdateTurnLeft(--remainingTurns);
if (remainingTurns <= 0)
HandlePlayerDefeated();
// 특정 위치까지 내려와버리면 즉시 게임 종료 처리, 대신 앞에서 지우기 다 끝내고 해야함
foreach (Vector2Int coords in gameOverZoneCoords)
{
if (stageManager.IsSpotTaken(coords))
{
HandlePlayerDefeated();
return;
}
}
}
여기서 3개 이상 붙으면 제거하는 로직이 CheckForMatches 가 맡고있다. GameManager는 그 이름 상 모든 우선순위 강조 코딩에 예외적 허용이라고 생각했기 때문에, 실제 내 코드에는 GameManager 하에 모든 Manager 계열이 등록되어있게끔 세팅되어있다. 뭐.. GameManager가 없으면 게임을 어떻게 작동시킬거냐는 반례도 생각나는데 그럼 작동이 안되야하는거 아닐까?
3개 이상 붙으면 제거
public void CheckForMatches(Vector2Int startCoords)
{
bool isStageChanged = false;
BubbleController startBubble = stageManager.GetBubbleAt(startCoords);
BubbleType targetType = startBubble.Type;
List<Vector2Int> matchedCoords = new List<Vector2Int>(); // 최종적으로 찾은 버블 목록
List<Vector2Int> notMatchedCoords = new List<Vector2Int>(); // 매치는 아니지만, 삭제 대상인 목록
Queue<Vector2Int> coordsToVisit = new Queue<Vector2Int>(); // 앞으로 방문해야 할 버블 목록
Queue<Vector2Int> coordToBombBoom = new Queue<Vector2Int>();
HashSet<Vector2Int> visitedCoords = new HashSet<Vector2Int>();// 이미 방문한 곳을 기록 (무한루프 방지)
coordsToVisit.Enqueue(startCoords);
visitedCoords.Add(startCoords);
bool isFirstSearch = true;
while (coordsToVisit.Count > 0)
{
Vector2Int currentCoords = coordsToVisit.Dequeue();
matchedCoords.Add(currentCoords);
// 현재 위치의 주변 6방향 이웃을 모두 조사
foreach (var neighborCoords in HexGridConverter.GetNeighbors(currentCoords))
{
// 조건: 방문한적 없고 그리드에 존재하며 색상이 같던가 폭탄이던가
if (!visitedCoords.Contains(neighborCoords))
{
BubbleController neighborBubble = stageManager.GetBubbleAt(neighborCoords);
if (neighborBubble != null && (neighborBubble.Type == targetType || neighborBubble.Type == BubbleType.Bomb))
{
if (neighborBubble.Type == BubbleType.Bomb)
{
if (!isFirstSearch)
continue;
coordToBombBoom.Enqueue(neighborCoords);
}
else
{
coordsToVisit.Enqueue(neighborCoords);
}
// 모든 조건을 만족하면, 다음 방문 대상으로 추가
visitedCoords.Add(neighborCoords);
} } }
isFirstSearch = false;
}
if (notMatchedCoords.Count > 0 || matchedCoords.Count >= 3)
{
scoreManager.AddStreak();
isStageChanged = true;
}
else
{
scoreManager.InitializeStreak();
}
// 최종 결과 확인 및 제거
if (matchedCoords.Count >= 3)
{
scoreManager.AddScoreByStreak((matchedCoords.Count));
foreach (var coords in matchedCoords)
{
if(stageManager.IsSpotTaken(coords))
{
BubbleController bubbleCtrl = stageManager.GetBubbleAt(coords);
if (bubbleCtrl.IsHaveLight)
{
Vector3 attackStartPos = stageManager.GetBubbleAt(coords).transform.position;
StartCoroutine(uiManager.AttackSequence(attackStartPos));
}
stageManager.RemoveBubbleAt(coords, true);
GameManager.Instance.ChargePower(false);
}
}
}
if (coru != null) return;
coru = StartCoroutine(ProcessPostMatchLogic(isStageChanged));
}
음.. 이건 수도코드를 생각하기가 쉬웠다. 내가 아니어도 쉬웠을 것이다. BFS랑 거의 동일하다. 이 부분을 DFS로 쓸 수는 있을 것이다. 특별히 이점이 있나?
어쩄든, 색이 일치하는 세 개가 있으면, 그 부분을 제거한다. 만약 그 버블이 뭔가 들고있다면, 그 부분을 활성화하는 형태로 정직하게.. 되어있다. 뭐 어디서 더 잘해야할지 몰랐는데, 이후 특수 버블 확장에서 이 코드는 개판이 되었다.
마지막의 ProcessPostMatchLogic(bool) 은 이제 스테이지에서 해야 할 일이 있는 경우 작동되는 코루틴인데, 이 내용은 이후에 스테이지를 자세하게 구성할때 다시 한번 다루도록 하겠다.
인접칸이 없는경우 제거하기
private void HandleFloatingBubbles()
{
HashSet<Vector2Int> supportedBubbles = new HashSet<Vector2Int>();
Queue<Vector2Int> coordsToVisit = new Queue<Vector2Int>();
// coordToVisit에 SpawnManager를 다 가져와서 넣어두면 되겠다.
foreach (var spawnPoint in currentSpawnPoints)
{
coordsToVisit.Enqueue(spawnPoint);
supportedBubbles.Add(spawnPoint);
}
while (coordsToVisit.Count > 0)
{
Vector2Int currentCoords = coordsToVisit.Dequeue();
foreach (var neighborCoords in HexGridConverter.GetNeighbors(currentCoords))
{
if (!supportedBubbles.Contains(neighborCoords) && stageManager.IsSpotTaken(neighborCoords))
{
coordsToVisit.Enqueue(neighborCoords);
supportedBubbles.Add(neighborCoords);
}
}
}
// '안전 그룹'에 속하지 않은 모든 버블을 찾아 제거(떨어뜨리기)
List<Vector2Int> floatingBubbles = new List<Vector2Int>();
int dropBombCount = 0;
foreach (var bubblePair in stageManager.GetAllBubbleCoords())
{
if (!supportedBubbles.Contains(bubblePair))
{
if (stageManager.GetBubbleAt(bubblePair).Type == BubbleType.Bomb)
dropBombCount++;
floatingBubbles.Add(bubblePair);
}
}
if (floatingBubbles.Count > 0)
{
foreach (var coords in floatingBubbles)
{
stageManager.GetBubbleAt(coords).ApplyGravity();
stageManager.RemoveBubbleAt(coords, false);
// 점수 give
}
}
}
spawnPoint를 기준으로도 BFS를 실시한다. 이렇게 해서 닿지 못한 애들을 집합 형태로 걸러내서, 이 목록들에 있는 내용들을 버블이 다 들어있는 자료구조에서 빼는 식으로 만들었다. 이 부분은 ProcessPostMatchLogic(bool) 이라고 방금 나중에 설명하겠다고 했던 로직의 일부 내용이니, 이게 정확히 언제 실행되냐? 는 지금은 그냥 곧바로 이루어진다라고 생각해주길 바란다. 나중에는 조금 가운데에 추가적으로 해야 할 일이 늘어나기 때문이다.
이런식으로 만들었을 때, 실행이 이정도까지는 이루었다. 다음 내용은.. 좀 발전시킨 형태를 논해보겠다.
'구현하기 > Unity' 카테고리의 다른 글
퍼즐위치사가 3 클론 #3 : 경로 개념과 레벨 디자인 (1) | 2025.10.03 |
---|---|
퍼즐위치사가 3 클론 기록 #1 : 서론 (1) | 2025.09.25 |