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는 육각뿐 아니라 비정형 타일 기반 좌표 보정에 일반화 가능
시리즈 전체 안내: 시리즈 목차
댓글
댓글 쓰기