Flutter 앱 개발기 (5/5) — 육각 지뢰찾기: 큐브 좌표계와 BLoC 상태 관리

육각 그리드 좌표계 설계, 이벤트 기반 상태 관리, 커스텀 렌더링 판정까지 — 사각형과 무엇이 다른지 정리한다.


이 글이 전달하는 것

  • 육각형 그리드에서 오프셋 좌표 대신 큐브 좌표(Cube Coordinates)를 쓰는 이유와 변환 규칙
  • 게임처럼 단일 입력이 연쇄 상태 변화를 유발할 때 Riverpod보다 BLoC이 맞는 근거
  • Flood Fill, 터치 판정(Cube Round), 테스트 경계 — 육각 그리드에서 바뀌는 구현 포인트
  • 상태 폭발을 막는 BLoC 분리 기준

1. 좌표계 — 오프셋 vs 큐브

사각 그리드는 (row, col) 2차원 배열로 충분하다. 육각 그리드는 그렇지 않다. 오프셋 좌표로 시작하면 홀수/짝수 행의 이웃 인덱스가 달라지고, 방향 계산이 조건 분기로 흩어진다.

큐브 좌표는 3축 (q, r, s)로 셀을 표현하되 q + r + s = 0 제약을 유지한다. 이 표현의 핵심 이득은 다음과 같다.

  • 이웃 계산의 균일성: 6방향 오프셋 배열 하나로 모든 이웃을 얻는다. 행 패리티 분기가 사라진다.
  • 거리 계산 단순화: 두 셀의 거리는 (|dq| + |dr| + |ds|) / 2.
  • 회전·반사의 수학적 표현: 축 치환만으로 구현 가능.

오프셋 좌표는 렌더링 시 화면 좌표와 가까워서 편하지만, 로직은 큐브, 표시는 오프셋으로 분리하는 편이 깨끗하다. 두 체계 간 변환 함수(offsetToCube / cubeToOffset)만 유지하면 나머지 로직이 일관된다.

적용 원칙

육각 그리드를 다룬다면 초기부터 큐브 좌표로 시작하는 것이 바람직하다. 오프셋 좌표 기반 이웃 로직은 버그가 반복되고, 후반에 전면 교체하는 비용이 크다.


2. 상태 관리 — BLoC이 맞는 경우

Flutter 상태 관리 선택은 프로젝트 특성에 따른다. 같은 앱 생태계에서도 적합한 도구가 다르다.

프로젝트 유형 특성 적합한 패턴
뷰어·리더 계열 입력이 단순하고 상태 그래프가 얕음 Riverpod (반응형)
게임·워크플로우 계열 단일 입력이 연쇄 전이를 유발 BLoC (이벤트 기반)

게임 상태의 특성

지뢰찾기의 "셀 탭" 한 번은 다음 흐름을 트리거한다.

입력: CellTapped(q, r, s)
  → 지뢰 여부 판정
  → 빈 셀이면 Flood Fill
  → 승리 조건 확인
  → UI 상태 업데이트

이 흐름은 하나의 트랜잭션으로 다뤄져야 한다. BLoC의 Event → State 매핑은 이 구조와 직접 대응한다. 각 Event가 어떤 State 전이를 유발했는지가 로그에 그대로 드러나서, 회귀 추적과 재현이 쉽다.

Riverpod이 불리한 지점

Riverpod은 Provider 간 의존 그래프로 상태를 조합하는 모델이다. "한 입력 → 여러 상태 연쇄"에서는 Provider 업데이트 순서가 암묵적이 되기 쉽고, 디버깅 시 원인 이벤트를 역추적하기 어렵다.

판단 규칙

  • 입력 → 상태 변화가 1:다이고 순서가 중요하다 → BLoC
  • 입력 → 상태 변화가 대부분 1:1이고 선언적 조합이 중심 → Riverpod

3. Flood Fill — BFS 기반 구현

지뢰 없는 빈 셀을 탭하면 이웃 중 지뢰가 0인 셀들이 연쇄적으로 열린다. 원리는 사각과 동일하지만, 이웃 집합을 큐브 좌표 기준으로 돌린다.

directions = [
  (+1, -1,  0), (+1,  0, -1), ( 0, +1, -1),
  (-1, +1,  0), (-1,  0, +1), ( 0, -1, +1),
]

재귀 대신 BFS

큰 보드에서는 한 번의 Flood Fill이 수백 셀을 연다. 재귀 DFS는 스택 오버플로 위험이 있다. 큐 기반 BFS로 구현하면 안전하고, 방문 마킹(visited Set)으로 중복 확장을 막는다.

성능 관측 포인트

  • Flood Fill 1회의 비용은 O(열리는 셀 수)
  • UI 업데이트는 Flood Fill 종료 후 일괄 방출한다. 셀마다 state emit을 호출하면 리빌드가 폭발한다.

4. 커스텀 렌더링과 터치 판정

CustomPainter로 정육각형

  • 짝수/홀수 행의 x 오프셋이 다르다
  • 행 간 y 간격은 타일 높이의 3/4 (height * 0.75)
  • 타일은 30도 회전된 정육각형(pointy-top) 또는 평평한 상단(flat-top) 중 하나로 통일한다

터치 판정 — Cube Round

육각형은 모서리가 비스듬해서 경계 박스 비교로는 오판이 잦다. 타일 경계가 인접 셀과 교차하는 영역에서 틀린 셀이 잡힌다.

해법은 화면 좌표 → 연속 큐브 좌표 → 정수 큐브 좌표 반올림(Cube Round) 순서로 내려오는 것이다.

1. 터치 좌표 (px, py) 를 픽셀 단위로 큐브 좌표 (qf, rf, sf) 로 변환
2. 각 축을 반올림 → (qi, ri, si)
3. q + r + s = 0 제약이 깨지면, 가장 반올림 오차가 큰 축을 나머지 두 축으로 재계산

이 방식은 "가장 가까운 타일의 중심"을 수학적으로 보장한다. 포인트-인-폴리곤 검사를 타일마다 돌리는 것보다 저렴하고 정확하다.


5. 테스트 경계 — BLoC이 주는 분리선

BLoC 구조의 이점은 UI 독립 테스트가 가능하다는 점이다. 게임 로직과 렌더링이 분리되므로 다음을 순수 Dart 테스트로 검증할 수 있다.

검증 대상 입력 기대 출력
게임 오버 지뢰 셀 CellTapped GameState.over
승리 비지뢰 전부 열림 GameState.victory
Flood Fill 지뢰 0 셀 CellTapped 연쇄 열림 집합
좌표 변환 offset → cube → offset 원래 좌표 일치 (라운드트립)

좌표 변환 라운드트립이 깨지면 모든 기능이 연쇄 실패한다. 가장 먼저 고정해야 할 테스트 대상이다.


6. BLoC 분리 기준 — 상태 폭발 방지

게임 상태를 단일 BLoC에 몰아넣으면 이벤트 타입과 분기가 빠르게 늘어난다. 책임 기준으로 분리한다.

BLoC 책임
GameBloc 보드 상태, 셀 열기/깃발, 승패 판정
TimerBloc 경과 시간, 일시정지
LeaderboardBloc 기록 저장/조회 (Hive 연동)

분리 기준은 상태 수명업데이트 주기다. Timer는 초 단위로 emit하고, Game은 입력 단위로 emit한다. 둘을 합치면 Timer의 고빈도 업데이트가 Game 리스너까지 흔들어서 불필요한 리빌드가 발생한다.


한계와 열린 질문

  • 큐브 좌표 도입 비용: 팀원이 익숙하지 않으면 학습 곡선이 있다. 문서화가 필수.
  • 육각 타일 UI의 접근성: 스크린 리더, 고대비 모드에서의 타일 식별은 추가 설계가 필요하다.
  • 난이도 생성 알고리즘: 균일한 지뢰 배치가 반드시 "좋은 게임"을 만들지 않는다. 첫 클릭 안전 보장, 보장 가능한 논리 경로 등은 별도 주제.
  • 플랫폼별 제스처: 모바일의 롱프레스(깃발)와 데스크톱의 우클릭은 BLoC 이벤트로 동일하게 흡수할 수 있으나, UX 가이드는 플랫폼 별로 분리하는 편이 안전하다.

적용 가능 범위

  • 육각 기반 보드게임(턴제 전략, 퍼즐) 일반에 동일 좌표계 적용 가능
  • "단일 입력 → 연쇄 상태 전이" 구조가 있는 워크플로우성 앱(체크아웃 흐름, 단계형 폼, 턴 기반 UI)도 BLoC이 유리
  • Cube Round는 육각뿐 아니라 비정형 타일 기반 좌표 보정에 일반화 가능

시리즈 전체 안내: 시리즈 목차

댓글

이 블로그의 인기 게시물

"LLM 핵심 학습 (1/6) — 기본: 토큰화·임베딩·어텐션·위치 인코딩"

"LLM 핵심 학습 (2/6) — 파인튜닝: LoRA·QLoRA·증류·Adapter"

"ML 기초 학습 (1/9) — 머신러닝과 sklearn: 학습의 좌표계"