처음 만든 사다리는 평범했다. 여섯 명이 내려가면 여섯 칸 중 한 칸이 「당첨」이고 나머지는 「세이프」다. 당첨된 한 사람이 커피값을 전부 낸다.

9월 21일 새벽, 폰에 깔아 직접 해 보고 바꾸기로 했다. 이름 옆과 도착 칸에 등수를 보여 주고, 한 명이 다 내는 대신 여러 명이 순위별로 금액을 나눠 내게 하는 것이다.

같은 날 낮에 「등수 표시와 순위별 커피값 분담」 커밋이 들어갔다. 이 글에서는 그때 만든 분담 공식 GameData.ComputeShares와, 그것을 게임과 결과 화면에 붙인 과정을 정리한다.

왼쪽: 처음 사다리(6명 중 1명 당첨) / 오른쪽: 지금 사다리(30,000원을 등수대로 분담)

규칙

커밋 메시지에 적은 규칙은 이렇다.

  • 상위 2명은 면제한다.
  • 하위 최대 4명이 등차 가중치로 나눠 낸다. 4명이 나누면 40:30:20:10이다.
  • 100원 단위로 맞추고, 남는 금액은 꼴등이 낸다.
  • 총액이 0이면 금액 없이 꼴등만 벌칙이다.

돈을 내는 인원은 PayerCount 한 줄로 정한다.

/// <summary>분담 인원. 상위 2명은 면제하고, 하위 최대 4명이 나눠 낸다.</summary>
public static int PayerCount(int playerCount)
{
    return Mathf.Clamp(playerCount - 2, 1, 4);
}

playerCount - 2를 1~4 사이로 자른다. 인원별로 풀어 보면 다음과 같다.

인원 내는 사람 (PayerCount) 면제 가중치 (꼴등부터) 비율
2명 1 1 1 꼴등 100%
3명 1 2 1 꼴등 100%
4명 2 2 2 : 1 약 67 : 33
5명 3 2 3 : 2 : 1 50 : 약 33 : 약 17
6명 4 2 4 : 3 : 2 : 1 40 : 30 : 20 : 10
7명 4 3 4 : 3 : 2 : 1 40 : 30 : 20 : 10
8명 4 4 4 : 3 : 2 : 1 40 : 30 : 20 : 10

「상위 2명 면제」는 3명 이상일 때 이야기다. 2명이면 아래쪽 한계 1에 걸려 꼴등 한 명이 내고 1등만 면제된다. 7명 이상이면 위쪽 한계 4에 걸려 면제 인원이 3명, 4명으로 늘어난다.

ComputeShares

public static List<int> ComputeShares(int total, int playerCount)
{
    var shares = new List<int>();
    for (int i = 0; i < playerCount; i++) shares.Add(0);
    if (total <= 0 || playerCount <= 0) return shares;

    int payers = PayerCount(playerCount);
    int weightSum = payers * (payers + 1) / 2;
    int assigned = 0;
    // i = 0 이 꼴등. 꼴등 가중치가 payers 로 가장 크다.
    for (int i = 0; i < payers; i++)
    {
        int weight = payers - i;
        int amount = (int)((long)total * weight / weightSum / 100 * 100);
        shares[playerCount - 1 - i] = amount;
        assigned += amount;
    }
    shares[playerCount - 1] += total - assigned; // 100원 단위로 자르고 남은 금액은 꼴등이
    return shares;
}

반환 순서는 1등부터 꼴등까지다. 가중치는 꼴등이 payers, 그 위가 payers - 1 … 순으로 1씩 줄고, 합은 payers * (payers + 1) / 2다. 4명이면 10이라서 4:3:2:1이 곧 40:30:20:10이 된다.

핵심은 한 줄짜리 금액 계산이다. 계산 순서는 이렇다.

  1. (long)total * weight — 곱셈을 먼저 한다. total이 int라서 큰 금액에 4를 곱하면 넘칠 수 있으니 long으로 올린다. 금액 입력칸은 8자리까지라 실제로는 넘지 않지만, 함수 자체는 int 전체를 받는다.
  2. / weightSum — 정수 나눗셈이라 소수점 아래는 버린다.
  3. / 100 * 100 — 한 번 더 버려서 100원 단위로 내린다.

이렇게 내린 금액을 모두 더한 assigned와 총액의 차이를 마지막 줄에서 꼴등 몫에 얹는다. 그래서 몫의 합은 언제나 총액과 정확히 같다.

손으로 계산해 보기

30,000원 · 6명 (payers 4, weightSum 10)

등수 weight 30,000 x w ÷ 10 100원 내림
6등(꼴등) 4 120,000 12,000 12,000
5등 3 90,000 9,000 9,000
4등 2 60,000 6,000 6,000
3등 1 30,000 3,000 3,000

합이 30,000이라 나머지는 0이다. 1·2등은 0원이다. 위 사다리 스크린샷의 12,000 / 9,000 / 6,000 / 3,000과 같다.

25,000원 · 6명도 같은 비율이라 10,000 / 7,500 / 5,000 / 2,500으로 딱 떨어진다. 나머지가 생기는 예를 몇 개 더 계산해 보면 이렇다.

23,500원 · 6명 (weightSum 10)

  • 6등: 94,000 ÷ 10 = 9,400 → 9,400
  • 5등: 70,500 ÷ 10 = 7,050 → 7,000
  • 4등: 47,000 ÷ 10 = 4,700 → 4,700
  • 3등: 23,500 ÷ 10 = 2,350 → 2,300
  • assigned 23,400, 나머지 100원 → 꼴등 9,500원

25,000원 · 5명 (payers 3, weightSum 6)

  • 5등: 75,000 ÷ 6 = 12,500 → 12,500
  • 4등: 50,000 ÷ 6 = 8,333 → 8,300
  • 3등: 25,000 ÷ 6 = 4,166 → 4,100
  • assigned 24,900, 나머지 100원 → 꼴등 12,600원

10,000원 · 4명 (payers 2, weightSum 3)

  • 4등: 20,000 ÷ 3 = 6,666 → 6,600
  • 3등: 10,000 ÷ 3 = 3,333 → 3,300
  • assigned 9,900, 나머지 100원 → 꼴등 6,700원

10,050원 · 4명 — 총액이 100원 단위가 아닐 때

  • 4등: 20,100 ÷ 3 = 6,700 → 6,700
  • 3등: 10,050 ÷ 3 = 3,350 → 3,300
  • assigned 10,000, 나머지 50원 → 꼴등 6,750원

100원 단위로 맞추는 건 꼴등을 뺀 사람들뿐이고, 끝자리는 전부 꼴등이 떠안는다.

10,000원 · 3명은 payers가 1이라 가중치 합도 1이다. 꼴등 혼자 10,000원을 내고, 나머지 두 명은 0원이다.

게임 선택 화면: 입력하는 순간 보여 준다

금액은 게임 선택 화면 맨 위의 「커피값 총액」 칸에 넣는다. 값은 PlayerPrefs에 저장돼 다음 판에도 남는다. 입력이 바뀔 때마다 UpdateHint가 같은 ComputeShares를 불러 안내 문구를 다시 만든다.

void UpdateHint()
{
    int n = GameData.Players.Count;
    int total;
    if (!int.TryParse(_amount.text, out total)) total = 0;

    if (total <= 0)
    {
        _hint.text = "비워 두면 금액 없이 꼴등만 정합니다";
        _hint.color = UIFactory.TextDim;
        return;
    }
    var shares = GameData.ComputeShares(total, n);
    _hint.text = n + "명 중 하위 " + GameData.PayerCount(n) + "명이 나눠 냅니다  ·  꼴등 " + GameData.Won(shares[n - 1]);
    _hint.color = UIFactory.Accent;
}

게임 선택 화면: 30000 입력 시 "6명 중 하위 4명이 나눠 냅니다 · 꼴등 12,000원"

게임을 시작하기 전에 꼴등이 얼마를 내는지 미리 알게 된다. 금액 표시는 12000을 "12,000원"으로 바꾸는 GameData.Won 하나로 통일했다.

게임마다 등수를 정하는 방법

공식은 등수만 받으면 된다. 그래서 게임 쪽 약속은 하나다. 끝나면 1등부터 꼴등까지 순서대로 GameData.SetResult(ranking)을 부른다.

public static void SetResult(List<Player> ranking)
{
    LastRanking = ranking ?? new List<Player>();
    LastShares = ComputeShares(TotalAmount, LastRanking.Count);
    LastLoser = LastRanking.Count > 0 ? LastRanking[LastRanking.Count - 1] : null;
}

처음 두 게임(사다리, 눈치)은 이 커밋에서 등수를 매기도록 고쳤고, 뒤에 추가한 게임 13개는 처음부터 이 약속에 맞춰 만들었다. 각 게임의 기준은 커밋 메시지와 코드에 남은 대로 정리하면 다음과 같다.

유형 게임 등수 기준
운 사다리 타기 아래 칸마다 1등~꼴등을 무작위로 섞어 둔다. 도착한 칸이 곧 등수
운 룰렛 바늘이 가리킨 사람이 꼴등, 바늘에서 멀수록 안전
운 제비뽑기 제비 안에 1등~꼴등이 섞여 있다. 꼴등 제비는 「당첨!」
순발력 눈치 게임 먼저 누른 순서. 벌칙자가 꼴등, 안 누른 사람은 뒤로
순발력 폭탄 돌리기 터질 때 든 사람이 꼴등, 나머지는 폭탄을 적게 든 순서
서바이벌 369 · 바니바니 · 순서 외우기 · 31 게임 끝까지 남은 사람이 1등, 먼저 탈락할수록 아래
서바이벌 업다운 먼저 맞혀 탈출할수록 위, 끝까지 남은 한 명이 꼴등
서바이벌 제일 작은 숫자 안 겹친 숫자는 작은 수부터 등수 확정, 겹친 사람끼리 다시
점수 초성 퀴즈 점수 많은 순 → 맞힌 시간 합이 짧은 순
점수 딱 멈춰! 총점 순 (같으면 한 라운드 최고 점수로)
점수 연타 대결 많이 누른 순 (같으면 무작위)
점수 끝까지 버텨! 오래 버틸수록 위, 폭발까지 누른 사람은 맨 아래 (그들끼리는 무작위)

사다리는 원래 「당첨」 칸 하나만 무작위로 골랐다. 이제는 칸 번호 → 등수 배열을 Fisher–Yates로 섞는다. 코드 주석에는 이유가 한 줄 적혀 있다. 왼쪽이 늘 1등이면 재미가 없다.

// 칸마다 등수를 무작위로 배치 (왼쪽이 늘 1등이면 재미가 없다)
_slotRank = new int[_n];
for (int i = 0; i < _n; i++) _slotRank[i] = i;
for (int i = _n - 1; i > 0; i--)
{
    int j = Random.Range(0, i + 1);
    int tmp = _slotRank[i];
    _slotRank[i] = _slotRank[j];
    _slotRank[j] = tmp;
}

색 네 가지

등수가 생기면서 「당첨/세이프」 두 색으로는 부족해졌다. 사다리·눈치·결과 화면 모두 같은 규칙으로 색을 고른다.

/// <summary>등수별 색. 1등 금색, 면제 초록, 분담 주황, 꼴등 빨강.</summary>
static Color RankColor(int rank, int count, int share)
{
    bool pays = share > 0 || (GameData.TotalAmount <= 0 && rank == count - 1);
    if (!pays) return rank == 0 ? UIFactory.Accent : UIFactory.Safe;
    return rank == count - 1 ? UIFactory.Danger : Warn;
}

「돈을 내는가」는 몫이 0보다 큰지로 판단하고, 총액을 안 넣은 경우에만 꼴등을 내는 사람으로 친다. 사다리에서는 도착하면 위쪽 이름 옆에 「현우 6등」처럼 등수가 붙고, 아래 칸에는 등수와 금액(면제면 「세이프」)이 같은 색으로 뜬다. 눈치 게임은 꼴등 칸 배경이 빨강이라 그 칸만 이름 글씨를 어둡게 바꿨다.

결과 화면, 카톡 문구, 이번 달 기록

결과 화면은 꼴등을 크게 보여 주고 그 아래에 1등부터 꼴등까지 순위표를 깐다. 돈을 내는 줄만 패널 배경을 깔아서 면제와 분담이 한눈에 갈린다. 면제 줄의 금액 자리는 「-」, 총액 없이 꼴등만 정한 판이면 꼴등 줄에 「쏨!」이 들어간다.

결과 화면: "현우님이 12,000원!", 1~6등 순위표

위 화면이 앞의 사다리 판 결과다. 「결과 복사」를 누르면 카카오톡에 붙여 넣을 글이 클립보드에 들어간다. 이 판이라면 BuildShareText가 만드는 글은 다음과 같다.

[오늘 누가 쏘냐?]
사다리 타기 결과  ·  총 30,000원
1등 영희  0원
2등 민수  0원
3등 지영  3,000원
4등 수진  6,000원
5등 철수  9,000원
6등 현우  12,000원

총액을 넣으면 면제자도 0원으로 찍힌다. 화면의 「-」와 다르지만, 코드는 총액이 있으면 모든 줄에 금액을 붙인다. 총액이 0이면 금액 없이 꼴등 줄 끝에만 ← 쏨!이 붙는다. 「이미지 공유」는 버튼을 숨기고 워터마크를 붙여 이 화면을 그대로 캡처한다. Android 쪽 저장·공유 처리는 9편에서 다룬다.

결과 화면이 뜰 때 RecordLoss가 꼴등 이름으로 벌칙 1회를 기록한다. 기록은 "yyyy-MM" 월 문자열과 이름을 키로 PlayerPrefs에 JSON으로 쌓이고, 「벌칙 기록 보기」 화면이 이번 달 것만 꺼내 횟수 순으로 최대 8명까지 막대로 보여 준다. 분담 금액이 아니라 꼴등 횟수만 센다. 결과 화면 부제에도 「이번 달 1번째 벌칙」처럼 몇 번째인지가 붙는다.

정리

  • 분담 인원은 Mathf.Clamp(n - 2, 1, 4). 2·3명은 꼴등 혼자, 4·5명은 2·3명이 나누고, 6명 이상은 하위 4명이 40:30:20:10으로 낸다.
  • 금액은 (long)total * weight / weightSum / 100 * 100 순서로 계산한다. 곱셈을 먼저, 내림은 두 번, 나머지는 꼴등에게 몰아서 합이 총액과 정확히 맞는다.
  • 게임은 1등부터 꼴등까지 줄 세워 SetResult만 부른다. 분담 계산·색·결과 화면·복사 문구는 공통이라 게임을 추가해도 손댈 곳이 없다.
  • 게임 선택 화면의 안내와 실제 결과가 같은 함수를 쓰므로, 시작 전에 본 꼴등 금액이 끝나고 나오는 금액과 같다.

다음 글 6편 (10월 5일 공개)에서는 서버 없이 여러 폰으로 같이 하는 LAN 기능을 다룬다.