Mahjong Joy 개발기 (4) — 파스텔 UI와 RotatedBox 마작 테이블, 타일 애니메이션
Mahjong Joy 시리즈
- 기획 분석과 작업 계획
- 핵심 로직 — 승리 판정 알고리즘
- 게임 엔진과 AI 만들기
- 파스텔 UI와 타일 애니메이션 ← 이번 글
- 점수 시스템과 메인화면
게임 엔진처럼 보이지만, 전부 기본 위젯이다
이번 편의 결론을 먼저 말하면: 턴제 보드게임 UI에 게임 엔진(Flame)은 필요 없다. 타일이 날아다니는 애니메이션까지 포함해서 전부 Flutter 기본 위젯으로 만들었다. Container, Stack, RotatedBox, TweenAnimationBuilder. 이게 전부다.
Step 1: 파스텔 테마와 이모지 타일
기획안의 컨셉은 "따뜻하고 귀여운 비주얼". 민트/핑크/크림 팔레트를 상수로 정의하고 시작했다.
class Palette {
static const mint = Color(0xFFA8E6CF);
static const pink = Color(0xFFFFB3C1);
static const cream = Color(0xFFFFF6E9);
static const tableGreen = Color(0xFFD3F0E4);
static const textBrown = Color(0xFF6B5B4D);
}
타일은 한자 대신 숫자 + 이모지 심볼 조합이다. 이미지 에셋을 한 장도 만들지 않았다.
| 전통 마작 | Mahjong Joy | 심볼 |
|---|---|---|
| 만수패 | 귤 | 🍊 |
| 통수패 | 곰 | 🐻 |
| 삭수패 | 꽃 | 🌸 |
| 자패 7종 | 날씨 | ☀️ ☁️ 🌧️ ❄️ 🌙 ⭐ 🌈 |
이모지 타일의 장점은 명확하다. 에셋 파이프라인이 없고, 모든 플랫폼에서 렌더링되고, 사이즈 조절이 fontSize 하나로 끝난다. 프로토타입 단계에선 최고의 선택이다. 상대 플레이어도 🐰토끼, 🧸곰돌이, 🐱야옹이로 이모지 아바타다.
Step 2: 컨트롤러 — 자동 진행 루프와 세대 토큰
UI와 엔진 사이엔 ChangeNotifier 컨트롤러를 뒀다. 핵심은 _drive()라는 자동 진행 루프다. AI 턴은 딜레이를 넣어 자동으로 진행하다가, 사람의 입력이 필요한 지점에서 루프가 멈추고 리턴한다.
Future<void> _drive() async {
final gen = _generation; // 세대 토큰
while (!_disposed && gen == _generation && !isFinished) {
if (game.phase == GamePhase.awaitingDiscard) {
if (game.current == humanSeat) return; // 사람 입력 대기
await Future.delayed(_aiDelay);
if (_disposed || gen != _generation) return; // 그 사이 새 판 시작?
// AI 턴 진행...
}
}
}
_generation은 새 판을 시작할 때마다 증가하는 정수다. await 후에 세대가 바뀌었으면 이전 판의 루프가 좀비처럼 살아서 새 판을 조작하는 사고를 막는다. 비동기 루프 + 재시작 가능한 상태 조합에서는 이 패턴(세대 토큰)이 거의 필수다.
Step 3: RotatedBox로 만드는 진짜 마작 테이블
첫 버전의 중앙 테이블은 버려진 패를 그냥 세로 목록으로 나열했는데, 스크린샷을 보니 휑한 게 영 마작 같지 않았다. 실제 마작 테이블처럼 각자의 강(버림패 구역)이 자기 자리 앞에, 상대의 패는 그 방향으로 회전되게 바꿨다.
Stack(children: [
Align(alignment: Alignment.topCenter,
child: RotatedBox(quarterTurns: 2, child: _River(seat: 2))), // 맞은편: 180도
Align(alignment: Alignment.centerLeft,
child: RotatedBox(quarterTurns: 1, child: _River(seat: 3))), // 왼쪽: 90도
Align(alignment: Alignment.centerRight,
child: RotatedBox(quarterTurns: 3, child: _River(seat: 1))), // 오른쪽: -90도
Align(alignment: Alignment.bottomCenter, child: _River(seat: 0)), // 나
Center(child: /* 남은 패 수 칩 */),
])
여기서 얻은 요령 두 가지.
① 강은 전부 "내 시점"으로 만들고 통째로 회전한다. _River 위젯은 방향을 모른다. 6장씩 줄을 만들고 새 줄이 위(중앙 쪽)로 자라는, 아래쪽 플레이어 기준으로만 구현했다. 방향은 RotatedBox가 알아서 해결한다. 좌표 계산이 필요 없다.
② verticalDirection: VerticalDirection.up. 첫 줄이 플레이어 쪽에 붙고 새 줄이 중앙 쪽으로 쌓이게 하려면 Column을 뒤집으면 된다. 이런 속성이 있는 걸 이번에 처음 알았다.
Column(
verticalDirection: VerticalDirection.up, // 첫 줄이 아래
children: rows,
)
Step 4: 타일 애니메이션 — TweenAnimationBuilder + key 트릭
"타일이 움직이는 것처럼 보이게 해달라"는 요구사항. AnimationController를 꺼내기 전에, 암시적 애니메이션으로 어디까지 되는지 먼저 시도했다. 결론: 충분했다.
class TileAppear extends StatelessWidget {
final Widget child;
final Offset from; // 시작 오프셋. Offset(0, 40) = 아래에서 날아옴
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: child,
builder: (context, t, child) => Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(from.dx * (1 - t), from.dy * (1 - t)),
child: Transform.scale(scale: 1.25 - 0.25 * t, child: child),
),
),
);
}
}
TweenAnimationBuilder는 위젯이 마운트될 때 자동으로 0→1 재생된다. 그럼 "새로 버려진 패에만" 애니메이션을 어떻게 걸까? 여기서 key 트릭이 나온다.
TileAppear(
key: ValueKey('river-$seat-${tiles.length}'), // 패 수가 바뀌면 새 위젯
from: const Offset(0, 44), // 플레이어 쪽에서 날아온다
child: tile,
)
버림패가 추가되면 tiles.length가 바뀌고 → key가 바뀌고 → Flutter가 새 위젯으로 취급해서 애니메이션이 다시 재생된다. 같은 상태로 리빌드만 되면 key가 같으니 재생되지 않는다. key 하나로 "언제 애니메이션을 재생할지"를 선언적으로 제어하는 것이다.
덤으로, 강을 전부 내 시점으로 만들고 회전시킨 덕분에 from: Offset(0, 44)(아래에서 등장) 하나면 어느 방향 플레이어든 자기 쪽에서 패가 날아온다. 회전이 오프셋 방향까지 같이 돌려주기 때문이다.
Step 5: 스크린샷이 잡아준 버그 — 뽑은 패 분리 표시
게임을 돌려보다가 스크린샷에서 이상한 점을 발견했다. 방금 뽑은 패를 강조하는 기능이 있는데, 같은 종류의 패 2장이 동시에 강조되고 있었다. 원인은 이 코드.
highlighted: myTurn && tile == gc.game.drawnTile // 같은 종류면 전부 true!
Tile의 ==는 종류 비교라서, 7🌸를 뽑았는데 손에 이미 7🌸가 있으면 둘 다 강조된다. 수정은 실제 마작의 관례를 따랐다. 뽑은 패를 손패 오른쪽에 분리해서 표시하는 것. 손패 목록에서 한 장만 remove하고, 분리된 자리에 강조 + 등장 애니메이션을 붙였다. 버그 수정이 오히려 UX를 마작답게 만들어준 경우다.
트러블슈팅 모음
① 위젯 테스트의 pending timer 에러. AI 턴이 Future.delayed로 돌아가는 앱은 테스트 종료 시점에 타이머가 남아 있으면 실패한다. 해결: 테스트를 "사람 입력 대기 지점"(타이머가 없는 상태)까지 tester.pump(duration) 루프로 진행시킨 뒤 종료한다.
② Flutter 웹 헤드리스 스크린샷. 레이아웃 검증용으로 Chrome 헤드리스 스크린샷을 찍는데, 디버그 웹 서버(flutter run -d web-server)는 빈 화면만 나왔다. 릴리즈 빌드(flutter build web)를 정적 서버로 띄우니 해결.
flutter build web
python3 -m http.server 8124 -d build/web &
chrome --headless=new --window-size=1500,1000 \
--virtual-time-budget=30000 --screenshot=game.png http://localhost:8124
--virtual-time-budget은 페이지 로딩을 가상 시간으로 빨리감기 해주는 옵션인데, CanvasKit 로딩이 느린 디버그 모드에서는 소용이 없었다.
정리
- 턴제 보드게임 UI는 Flame 없이 기본 위젯으로 충분하다
- 이모지 타일 = 에셋 제로 프로토타이핑
- 비동기 자동 진행 루프에는 세대 토큰으로 좀비 루프를 방지
- 방향별 UI는 "내 시점으로 만들고 RotatedBox로 회전" — 좌표 계산 불필요
TweenAnimationBuilder+ValueKey트릭으로 등장 애니메이션을 선언적으로 제어- 헤드리스 스크린샷 검증은 릴리즈 웹 빌드로
다음 편이 마지막이다. 점수 시스템(론 방총, 츠모 분담, 멘젠 2배)과 메인화면, 게임 설명서를 붙여서 "게임"을 완성한다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.