Flutter 로컬 멀티플레이 게임에 턴 제한 타이머 넣기 — Football Dice 개발 일지
왜 턴 제한이 필요했나
제가 만들고 있는 Football Dice는 주사위와 카드로 진행하는 미식축구 보드게임 앱입니다. AI와 대전하는 모드도 있지만, 진짜 재미는 같은 와이파이에 있는 친구와 붙는 로컬 멀티플레이 모드에서 나옵니다. 공격 카드와 수비 카드를 서로 몰래 고른 다음 동시에 공개하는, 일종의 눈치 싸움이거든요.
그런데 직접 테스트해보니 문제가 하나 있었습니다. 상대가 카드를 오래 고민하면 게임이 그냥 멈춰버리는 겁니다. 화면에는 "상대의 카드 선택을 기다리는 중..."이라는 문구만 떠 있고, 언제 다음 플레이로 넘어갈지 알 수가 없었어요. 보드게임을 실제로 마주 앉아서 할 때는 어느 정도 눈치껏 진행되는데, 화면 너머로 하다 보니 이 부분이 확 늘어지는 체감이 컸습니다.
그래서 이번 업데이트의 핵심은 네트워크 대전에만 15초 턴 제한을 걸고, 5초 남으면 카운트다운을 보여주는 것이었습니다. 여기에 게임 방법 설명을 보강하고, 커지고 있던 메인 화면을 정리하는 작업까지 한 번에 묶어서 진행했습니다.
이 글에서는 세 가지를 다룹니다.
- 턴 제한 타이머를 어디에, 어떻게 넣었는지 (호스트/게스트 구조를 건드리지 않는 선에서)
- 게임 방법 다이얼로그에 텍스트 대신 실제 위젯을 재사용해서 시각 예시를 넣은 방법
- 커진 메인 화면을 설정 페이지로 분리하고, 버전을 두 번 올려 배포하기까지의 흐름
사전 구조 — 호스트가 판정하고, 클라이언트는 그리기만 한다
먼저 기존 구조를 짚고 가야 왜 이렇게 구현했는지 이해가 됩니다. 이 앱의 멀티플레이는 소켓 기반으로, 역할이 명확히 나뉘어 있습니다.
| 역할 | 클래스 | 하는 일 |
|---|---|---|
| 방을 만든 쪽 | HostSession | 게임 엔진을 직접 돌리고 판정, 상태를 게스트에게 브로드캐스트 |
| 참가한 쪽 | GuestSession | 선택을 호스트로 전송, 받은 상태를 그대로 그림 |
| 화면 | MpGameScreen | MpSession 인터페이스만 보고 UI를 그림 (호스트/게스트 구분 없음) |
여기서 중요한 설계 포인트는, 판정 권한은 항상 호스트에게만 있다는 겁니다. 그래서 턴 제한 타이머를 넣을 때도 이 원칙을 깨지 않는 방향을 택했습니다. 즉, "15초가 지나면 서버가 강제로 턴을 넘긴다" 같은 프로토콜 레벨의 기능을 새로 만드는 대신, 각 클라이언트가 자기 화면에서만 타이머를 돌리고, 시간이 다 되면 원래 있던 선택 함수를 그냥 호출하게 만들었습니다. 프로토콜(protocol.dart)은 한 줄도 건드리지 않았고, 구버전 클라이언트와의 호환성 문제도 생기지 않습니다.
Step 1. 지금 내가 결정할 차례인지 판단하기
타이머를 돌리기 전에 먼저 필요한 건 "지금 내가 뭔가 결정해야 하는 상황인가?"를 판단하는 함수입니다. 이 게임에는 결정 시점이 킥오프, 리턴, 공격/수비 카드 선택, 추가 득점까지 네 가지가 있어서, 상황별로 시간 초과 시 실행할 기본 동작을 다르게 정의했습니다.
VoidCallback? _pendingAutoAction() {
if (session.error != null) return null;
switch (s.phase) {
case GamePhase.kickoff:
if (s.kickingTeam != me) return null;
return () => session.chooseKickoff(onside: false); // 일반 킥
case GamePhase.returnChoice:
if (s.possession != me) return null;
return () => session.chooseReturn(touchback: true); // 터치백
case GamePhase.play:
if (session.iSubmitted) return null;
return s.possession == me
? () => _autoPickOffense(false)
: () => _autoPickDefense(false);
case GamePhase.extraPoint:
// ...추가 득점 처리
}
}
공격/수비 카드 자동 선택은 무작위가 아니라, 이미 선택해둔 카드가 있으면 그걸 쓰고, 없으면 상황별 추천 카드 중 첫 번째를 씁니다. 이 게임에는 원래 다운/거리 상황에 따라 추천 카드 3장을 보여주는 로직이 있었는데, 그걸 그대로 재사용한 겁니다.
void _autoPickOffense(bool twoPoint) {
final id = selectedOffense ?? _suggestedOffense(twoPoint).first.id;
session.chooseOffense(id);
}
Step 2. 타이머 시작/정지 타이밍 맞추기
여기서 살짝 까다로웠던 부분은 타이머를 언제 새로 시작하고, 언제 멈춰야 하는가였습니다. 판정이 끝나서 다음 플레이로 넘어갈 때는 당연히 리셋해야 하고, 상대가 먼저 카드를 냈다는 소식이 왔을 때는 내 타이머를 건드리면 안 됩니다. 이걸 구분하기 위해 "지금 결정 상황을 식별하는 키"를 만들어서, 키가 바뀔 때만 타이머를 재시작하도록 했습니다.
void _syncTurnTimer() {
final pending = _pendingAutoAction() != null;
final key = pending
? '${session.version}:${s.phase.name}:${session.awaiting2pt}'
: null;
if (key == _decisionKey) return; // 같은 결정 상황이면 그대로 둔다
_decisionKey = key;
_turnTicker?.cancel();
if (key == null) return; // 결정할 게 없으면 타이머 없음
_remaining = 15;
_turnTicker = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _remaining--);
if (_remaining <= 0) {
_turnTicker?.cancel();
_pendingAutoAction()?.call(); // 시간 초과 → 자동 진행
}
});
}
session.version을 키에 포함시킨 게 포인트입니다. 이 값은 새 판정이 나올 때마다 호스트가 올려서 브로드캐스트하는 값이라, 버전이 바뀌었다는 건 곧 새로운 플레이가 시작됐다는 뜻입니다. 여기에 updates 스트림이 갱신될 때마다 _syncTurnTimer()를 호출해주면, 상대 턴일 때는 타이머가 자동으로 꺼지고 내 턴이 오면 자동으로 켜집니다.
Step 3. 카운트다운 UI — 5초부터만 보여주기
15초 내내 숫자를 보여주면 오히려 정신없을 것 같아서, 남은 시간이 5초 이하일 때만 패널 제목 옆에 빨간 원형 배지로 카운트다운을 띄우기로 했습니다.
bool get _countdownVisible =>
_turnTicker != null && _remaining <= 5;
그리고 시간이 다 돼서 자동으로 선택이 진행되면, 사용자가 "어? 왜 내 카드가 아니라 이게 나갔지?" 하고 당황하지 않도록 스낵바로 안내 문구("시간 초과! 자동으로 선택했습니다")를 띄웠습니다. 작은 디테일이지만 이런 걸 빼먹으면 버그처럼 느껴지기 쉽더라고요.
Step 4. 게임 방법에 "말로 설명" 대신 "실제로 보여주기"
턴 제한과 별개로, 게임 방법 다이얼로그도 손을 봤습니다. 원래는 이런 식의 텍스트 설명뿐이었습니다.
예) 공격 D10이 9인데 수비 보정 -2를 받으면 7 → "7-8" 행, D12 합이 14면 "13-15" 열. 그 교차점의 숫자만큼 전진합니다.
글로만 읽으면 솔직히 잘 안 그려집니다. 그래서 이 설명을 대체할 위젯을 새로 만들었는데, 여기서 이미지 파일을 새로 그리는 대신 게임에서 쓰고 있는 주사위 위젯과 차트 위젯을 그대로 재사용하는 방법을 택했습니다.
final card = offenseById('short_pass');
ChartTable(
chart: card.chart,
highlightRow: rowIndex(9 - 2), // 실제 게임 로직으로 계산
highlightCol: columnIndex(6 + 8), // 하드코딩 좌표 아님
)
rowIndex()와 columnIndex()는 실제 판정 엔진(engine.dart)에서 주사위 값을 차트 행/열로 변환할 때 쓰는 함수 그대로입니다. 예시에 쓸 좌표를 숫자로 하드코딩하지 않고 이 함수로 계산하게 만든 이유는, 나중에 카드 차트 데이터가 바뀌어도 예시가 실제와 어긋나지 않게 하기 위해서였습니다. 정적 이미지였다면 데이터가 바뀔 때마다 새로 캡처해야 했을 텐데, 위젯 재사용 덕분에 그럴 일이 없습니다.
결과적으로 다이얼로그에는 실제 게임과 똑같은 색의 주사위 칩 4개(공격 D10, 수비 D10, D12 두 개)가 뜨고, 그 아래 차트에서 정답 칸이 금색으로 하이라이트됩니다. 텍스트 하나 읽는 것보다 훨씬 직관적이었습니다.
Step 5. 커진 메인 화면을 설정 페이지로 분리
기능이 하나둘 늘다 보니 메인 화면에 언어 선택, 난이도 선택, 새 게임/멀티플레이/게임 방법 버튼, 주사위·카드 연출 스위치, 효과음 스위치까지 다 몰려 있었습니다. 스크롤을 내려야 다 보일 정도였죠. 그래서 언어, 연출, 효과음 세 가지를 별도의 SettingsScreen으로 옮기고, 메인 화면에는 우상단에 톱니바퀴 아이콘 하나만 남겼습니다.
Future<void> _openSettings() async {
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
);
if (mounted) setState(() {}); // 언어가 바뀌었을 수 있으니 다시 그린다
}
여기서 놓치기 쉬운 부분이 있는데, 설정 화면에서 언어를 바꾸고 메인 화면으로 돌아왔을 때 화면이 갱신되지 않는 문제입니다. Navigator.push가 반환하는 Future가 완료되는 시점(=설정 화면에서 뒤로 가기를 누른 시점)에 setState를 한 번 더 호출해줘야, 바뀐 언어가 메인 화면 타이틀이나 버튼 문구에 바로 반영됩니다. 난이도 선택은 게임 시작 직전에 매번 고르는 항목이라 메인 화면에 그대로 남겨뒀습니다 — 모든 걸 설정으로 밀어넣는 게 능사는 아니더라고요.
버전 올리고 배포하기
기능 작업이 끝나면 그다음은 버전 관리와 빌드입니다. 이 프로젝트는 아직 Play Console 심사를 거치는 초기 단계라, 저는 안드로이드 앱 번들(AAB)을 버전별로 로컬에도 따로 보관하고 있습니다.
# pubspec.yaml: version: 1.1.0+2 → 1.2.0+3 로 수정 후
flutter analyze # 정적 분석
flutter test # 회귀 테스트
flutter build appbundle --release
+ 뒤에 붙는 숫자가 안드로이드의 versionCode인데, 이건 Play Console에 업로드할 때마다 반드시 이전보다 커야 해서 실수하기 쉬운 부분입니다. 이번에 올린 두 버전은 이렇습니다.
| 버전 | versionCode | AAB 용량 | 핵심 변경 |
|---|---|---|---|
| v1.0.0 | 1 | 약 52.1MB | 첫 출시 |
| v1.1.0 | 2 | 약 52.2MB | 네트워크 대전 15초 턴 제한, 게임 방법 시각 예시 |
| v1.2.0 | 3 | 약 52.2MB | 설정 화면 분리 |
빌드된 AAB는 apk_build_files/football_dice/v1.2.0/ 식으로 버전별 폴더에 복사해서 보관합니다. 나중에 특정 버전으로 롤백하거나 이전 버전과 용량을 비교할 일이 있을 수 있어서, 덮어쓰지 않고 버전마다 따로 남겨두는 편입니다.
그리고 store/CHANGELOG.md에 Play Console 릴리즈 노트용 문구를 한국어/영어로 미리 써둡니다. 이렇게 해두면 업로드할 때 "What's new" 칸에 바로 복사-붙여넣기만 하면 되니까, 심사 넣기 직전에 문구를 급하게 쥐어짜는 일이 없어집니다.
## v1.2.0 (2026-07-20)
### Play Console 릴리즈 노트 (What's new)
**한국어 (500자 이하)**
```
설정 화면이 새로 생겼습니다!
- 언어, 주사위·카드 연출, 효과음 설정을 별도 설정 페이지로 이동
- 메인 화면 우상단 톱니바퀴 아이콘으로 접근
```
자주 쓴 명령어 정리
| 명령어 | 용도 |
|---|---|
flutter analyze | 정적 분석, 커밋 전 필수로 확인 |
flutter test | 위젯/엔진/네트워크 테스트 실행 (이번 프로젝트는 13개) |
flutter build apk --release | 실기기 설치용 APK |
flutter install -d <device-id> | 연결된 기기에 바로 설치 |
flutter build appbundle --release | Play Console 업로드용 AAB |
flutter devices | 연결된 기기 목록 확인 (기기 ID 확인용) |
트러블슈팅 메모
작업하면서 실제로 걸렸던 부분 두 가지만 남겨둡니다.
1) 자동 진행 시 이미 골라둔 카드가 무시되는 문제
처음에는 시간 초과 시 무조건 추천 카드 첫 번째를 내도록 짰는데, 이러면 사용자가 카드를 이미 골라놓고 "실행" 버튼만 안 누른 상태에서도 엉뚱한 카드가 나가버립니다. selectedOffense ?? _suggestedOffense(...).first.id처럼 이미 선택된 값을 우선하는 널 병합 연산자로 고쳤습니다.
2) flutter install이 최신 빌드를 안 잡는 문제
flutter build apk와 flutter install을 따로 실행했더니, 이전에 빌드해둔 오래된 APK가 그대로 설치된 적이 있었습니다. flutter install은 기본적으로 build/app/outputs/flutter-apk/app-release.apk 경로를 그대로 설치하기 때문에, 코드를 고친 뒤에는 반드시 flutter build를 먼저 새로 돌리고 나서 설치해야 합니다. 당연한 얘기인데도 급하게 테스트하다 보면 순서를 건너뛰기 쉬운 부분이었습니다.
정리
이번 업데이트를 한 문장으로 요약하면, **"기존 구조를 건드리지 않는 선에서 사용자 경험만 다듬은 두 번의 마이너 릴리즈"**였습니다.
- 네트워크 대전이 늘어지는 문제 → 클라이언트 사이드 타이머 + 자동 진행으로 해결 (프로토콜 변경 없음)
- 텍스트로만 설명하던 게임 규칙 → 실제 게임 위젯을 재사용해 시각 예시로 대체
- 커진 메인 화면 → 설정 페이지로 분리해서 첫 화면을 단순하게 유지
- 버전은 1.1.0 → 1.2.0으로 두 단계 올리고, 각 버전의 AAB와 릴리즈 노트를 따로 보관
혼자 개발하다 보면 "일단 되니까 넘어가자"고 미뤄두는 UX 디테일이 꽤 쌓이는데, 이번처럼 한 번씩 몰아서 정리하는 시간이 필요하다는 걸 다시 느꼈습니다. 다음에는 iOS 빌드 설정과 심사 준비 과정을 정리해볼 예정입니다.
backtodev
A 40-something PM returns to code. Learning, failing, and growing.