FlutterDart게임개발게임기획

Mahjong Joy 개발기 (5·완) — 점수 시스템과 메인화면으로 게임 완성하기

2026년 7월 10일4 분 읽기

Mahjong Joy 시리즈

  1. 기획 분석과 작업 계획
  2. 핵심 로직 — 승리 판정 알고리즘
  3. 게임 엔진과 AI 만들기
  4. 파스텔 UI와 타일 애니메이션
  5. 점수 시스템과 메인화면 ← 이번 글 (완결)

"한 판 이기면 끝"은 게임이 아니다

4편까지 만든 것은 정확히 말하면 "한 판짜리 데모"였다. 이기든 지든 다시 하기 버튼뿐. 게임에 긴장감을 만드는 건 판돈이다. 그래서 실제 마작의 지불 구조를 가져오되, 족보는 없으니 점수는 고정값으로 했다.

Step 1: 점수 설계 — 마작의 지불 구조만 이식

실제 마작에서 제일 재미있는 긴장감은 "내가 버린 패로 남이 나면(방총) 내가 다 낸다"는 규칙에서 나온다. 이 구조만 가져왔다.

상황지불
(남이 버린 패로 완성)그 패를 버린 사람이 전액 지불
츠모 (스스로 뽑아 완성)나머지 3명이 균등 분담
멘젠 보너스 (뺏어오기 없이 완성)점수 2배
유국변동 없음

수치는 이렇게 잡았다. 완성 가치 3,000점 고정 (멘젠이면 6,000점), 전원 10,000점 시작, 8판 대국, 누군가 0점 이하가 되면 즉시 종료.

멘젠 2배는 작은 규칙이지만 효과가 크다. "뺏어와서 빨리 완성할까, 참고 혼자 완성해서 2배를 노릴까"라는 선택지가 생기기 때문이다. 족보를 다 없앤 게임에 전략 축 하나를 돌려주는 규칙이다.

Step 2: MatchState — 정산 로직은 엔진 밖에

3편에서 엔진과 컨트롤러를 분리했던 것처럼, 점수도 한 판 엔진(Game)과 대국 점수판(MatchState)을 분리했다. Game에는 승리 방식(WinType.tsumo / ron)과 방총자(ronLoser) 기록만 추가하고, 정산은 전부 MatchState가 맡는다.

void applyGame(Game game) {
  final deltas = List<int>.filled(playerCount, 0);
  final w = game.winner;
  if (w != null) {
    final solo = game.players[w].melds.isEmpty; // 뺏어오기 없이 완성?
    final value = baseWinValue * (solo ? soloMultiplier : 1);

    if (game.winType == WinType.ron) {
      deltas[game.ronLoser!] = -value;        // 방총자 전액
    } else {
      final share = value ~/ (playerCount - 1); // 츠모 분담
      for (var i = 0; i < playerCount; i++) {
        if (i != w) deltas[i] = -share;
      }
    }
    deltas[w] = value;
  }
  // scores 반영, roundsPlayed++, lastResult 기록
}

deltas(좌석별 변동)를 결과 객체에 남겨두는 게 포인트다. 결과 화면에서 "누가 얼마를 냈는지"를 보여주려면 최종 점수만으로는 부족하다.

이중 정산 방지

판이 끝나는 경로는 여러 개다(사람 츠모, AI 츠모, 사람 론, AI 론...). 각 경로마다 정산 코드를 넣으면 언젠가 두 번 정산되는 버그가 난다. 해결책은 모든 상태 변경이 지나가는 길목 한 곳에서 처리하는 것.

void _notify() {
  if (_disposed) return;
  // 판이 끝나는 모든 경로는 _notify를 거치므로 여기서 한 번만 정산
  if (game.phase == GamePhase.finished && !_resultApplied) {
    match.applyGame(game);
    _resultApplied = true;
  }
  notifyListeners();
}

Step 3: 결과 화면 — 정산 내역이 곧 스토리

판이 끝나면 오버레이로 정산 내역을 보여준다. 승리 방식에 따라 문구가 달라진다.

  • 론: "🐰 토끼가 버린 패로 완성 — 3,000점 전액 지불"
  • 츠모: "스스로 뽑아서 완성 — 셋이서 3,000점을 나눠 지불"
  • 멘젠: "💫 뺏어오기 없이 혼자 힘으로! 점수 2배"

좌석별로 +3,000 / -1,000을 민트(이득)/핑크(손해) 색으로 구분하고, 현재 누적 점수를 함께 보여준다. 8판이 끝나거나 파산이 나오면 🥇🥈🥉 최종 순위 화면으로 전환된다.

숫자에 천 단위 콤마를 찍는 유틸은 정규식 한 줄이면 된다.

String _fmt(int n) => n.toString()
    .replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},');

Step 4: 메인화면과 게임 설명서

지금까지는 앱을 켜면 바로 대국이 시작됐다. 메인화면(타이틀 + 게임 시작 + 게임 설명서)을 추가하면서 Provider 구조도 정리했다. GameController를 앱 전역이 아니라 게임 화면 라우트에 종속시킨 것.

Navigator.of(context).push(MaterialPageRoute(
  builder: (_) => ChangeNotifierProvider(
    create: (_) => GameController(),
    child: const GameScreen(),
  ),
));

이러면 게임 화면을 나갈 때 컨트롤러가 자동으로 dispose되고, "게임 시작"을 누를 때마다 깨끗한 새 대국이 만들어진다. 전역 상태로 뒀다면 "이전 판이 남아있는" 버그를 만났을 것이다.

게임 설명서 화면에서는 규칙 설명에 실제 게임의 타일 위젯을 재사용했다. 스트레이트/트리플/머리 예시를 텍스트가 아니라 진짜 타일로 보여주니 설명이 확 짧아진다. 점수 수치도 하드코딩하지 않고 MatchState.baseWinValue 상수를 참조해서, 나중에 밸런스를 바꿔도 설명서가 자동으로 맞는다.

트러블슈팅: 테스트가 게임 밸런스를 알려줬다

"8판을 치르면 대국이 끝난다"는 테스트를 썼는데 계속 실패했다. 원인을 보니 버그가 아니라 8판 전에 파산으로 조기 종료되는 게 정상 동작이었다. 3,000~6,000점씩 잃으면 10,000점은 서너 판 만에 바닥난다.

// 수정된 테스트: 종료 사유는 둘 중 하나
expect(
  match.roundsPlayed == MatchState.totalRounds ||
      match.scores.any((s) => s <= 0),
  isTrue,
);

테스트를 고치면서 동시에 밸런스 감각도 얻었다. 파산 종료가 너무 잦다고 느껴지면 시작 점수를 20,000으로 올리면 된다 — 상수 하나만 바꾸면 되도록 설계해뒀으니. 시뮬레이션 테스트는 버그만 잡는 게 아니라 게임 밸런스 데이터도 준다.

또 하나, 설명서 화면의 위젯 테스트에서 find.text('💰 점수')가 실패했는데, ListView가 화면 밖 항목을 아예 빌드하지 않는 lazy 렌더링 때문이었다. tester.scrollUntilVisible()로 스크롤한 뒤 검증하면 된다.

시리즈 정리 — 전체 흐름 한눈에

5편에 걸친 여정을 요약하면:

  1. 기획: 족보 없는 마작이라는 컨셉, 문서 충돌 해소, 페이즈별 검증 기준 정의
  2. 핵심 로직: 34종 정수 인코딩 + 재귀 분해로 승리 판정, 함정 케이스 단위 테스트
  3. 엔진과 AI: 3-상태 머신, 엔진/컨트롤러 분리, 잠재력 점수 AI, 100판 시뮬레이션
  4. UI: 이모지 타일, RotatedBox 마작 테이블, TweenAnimationBuilder + key 트릭
  5. 완성: 마작식 지불 구조 이식, 라우트 종속 상태 관리, 설명서까지

최종 결과물: 테스트 37개, 순수 Dart 로직과 UI가 분리된 구조, 이미지 에셋 0장, 외부 패키지는 provider 하나. flutter run -d chrome으로 브라우저에서 바로 플레이할 수 있고, 같은 코드가 iOS/Android로 빌드된다.

남은 로드맵(Phase 4)은 효과음과 배경음악, 튜토리얼, 타일 스킨 수집, 그리고 AI 수비 로직(상대가 기다릴 만한 패 피해서 버리기)이다. 특히 AI 수비는 점수 시스템이 들어온 지금 난이도 조절 수단으로 의미가 생겼다.

족보를 다 걷어냈는데도, 남이 버린 패를 노려보며 "완성!"을 외치는 손맛은 그대로 남아 있었다. 마작의 재미는 족보가 아니라 짝맞추기 그 자체에 있었던 모양이다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.