게임캔버스디자인

추첨기에 귀여운 걸 붙였더니 물리가 거짓말을 했다

2026.07.30

캐릭터 스킨으로 돌아가는 코인 밀기 추첨기. 콩·병아리·고양이·곰·토끼·개구리가 판에 가득 쌓여 있고, 참가자는 이름표를 단 파란 고양이다

코인 밀기 추첨기를 만들고 나서 며칠 뒤에, 판 위에 쏟아지는 걸 코인 말고 다른 걸로 바꿔보고 싶어졌다. 은화랑 동화만 계속 보다 보니 재미가 없었다.

겉모습만 바꾸는 거니까 반나절이면 되겠다고 생각했다. 실제로는 그리기 규칙 네 개를 찾느라 하루가 갔다. 물리를 하나도 안 건드렸는데 물리가 잘못된 것처럼 보이는 문제가 계속 나왔기 때문이다.

스킨은 렌더 전용이어야 한다

먼저 정한 선이 하나 있다. 스킨은 그리는 방식만 바꾼다. 물리·이벤트·추첨 로직은 한 줄도 건드리지 않는다.

/**
 * 판 위에 무엇이 쌓이는지만 바꾸는 겉모습 설정. 물리·이벤트·추첨 결과에는 전혀 영향이 없다 —
 * 같은 시드로 같은 스킨을 골라도, 다른 스킨을 골라도 당첨자는 똑같이 나온다.
 */
export type SkinId = "coin" | "character";

이게 중요한 이유는 지난 글에서 편향을 잡느라 300판씩 돌려가며 χ²를 쟀기 때문이다. 스킨이 물리에 손을 대는 순간 그 측정을 스킨 개수만큼 다시 해야 한다. 같은 시드면 어떤 스킨을 골라도 당첨자가 같다는 걸 보장해두면, 공정성 검증은 한 번으로 끝난다.

스킨 선택 카드 두 장. 왼쪽 「코인」, 오른쪽 「귀여운 친구들 — 콩·병아리·고양이·곰·토끼·개구리가 밀려 떨어진다」가 선택돼 있고 버튼 문구가 「친구들 쏟아붓기」로 바뀌어 있다

바꾸는 건 문구까지다. 시작 버튼이 「코인 쏟아붓기」에서 「친구들 쏟아붓기」로, 섞을 때 배너가 「코인을 뒤섞는다!」에서 「친구들을 뒤섞는다!」로, 당첨 부제의 🪙이 🐾로 바뀐다. 스킨마다 이 문구 묶음을 통째로 들고 있게 했다.

1. 귀는 위로만 삐져나온다

토끼를 그리려면 귀가 필요하다. 그런데 귀를 그리는 순간 실루엣이 원보다 커진다.

물리에는 아무 영향이 없다. 충돌 반경은 여전히 원래 반지름이다. 문제는 사람이 눈에 보이는 실루엣을 몸집으로 읽는다는 것이다. 귀가 좌우로 튀어나오면 보이는 몸집과 실제 충돌 반경이 어긋나고, 그 차이는 전부 "닿지도 않았는데 밀렸다"로 보인다.

그래서 규칙을 하나 박아뒀다.

/**
 * 1. 실루엣은 반지름 안에 들어온다. 귀만 위로 삐져나온다 — 좌우로 튀어나오면 눈에 보이는
 *    몸집과 실제 충돌 반경이 어긋나 "닿지도 않았는데 밀렸다"처럼 보인다.
 */

귀를 위로만 뺀 건 위쪽이 화면 안쪽(원근상 뒤)이라 옆 캐릭터와 겹쳐 보여도 밀림으로 읽히지 않기 때문이다. 좌우는 안 된다. 밀리는 방향이 좌우라서 그렇다.

2. 두께가 없으면 더미가 안 읽힌다

캐릭터를 윗면만 그렸더니 판이 스티커를 뿌려놓은 것처럼 보였다. 코인은 원판 위에 옆면을 겹쳐 그려서 두께가 있는데, 캐릭터는 납작했다.

그래서 캐릭터도 윗면/옆면 두 겹을 코인과 같은 순서로 그리게 했다. 두께 비율은 코인보다 두툼하게 잡았다.

/** 몸 두께 대 반지름 비. 코인(5/14)보다 두툼해 더 인형처럼 보인다. */
const THICKNESS_RATIO = 0.42;

코인이 0.36 정도인데 0.42로 올렸다. 인형은 납작하면 인형처럼 안 보인다.

3. 크기 6종이 곧 종족 6종이다

코인 모드에는 이미 정해둔 게 하나 있었다. 중립 코인은 크기가 6종이고, 작을수록 밝고 클수록 진하게 색을 달리한다. 그래야 겹쳐 쌓인 더미의 모양이 읽힌다.

캐릭터에서는 색만으로 그걸 하기가 어렵다. 그래서 크기 6종을 종족 6종에 그대로 붙였다.

/** 이 배열은 NEUTRAL_RADII와 길이·순서가 같아야 한다. */
export const NEUTRAL_SPECIES: readonly Species[] = [
  { kind: "bean",   top: "#f7e6bd", side: "#dcc691" },
  { kind: "chick",  top: "#ffe08a", side: "#e2bd57" },
  { kind: "cat",    top: "#ffd3b3", side: "#e2a97f" },
  { kind: "bear",   top: "#d9c0a4", side: "#b2947b" },
  { kind: "rabbit", top: "#f4e9e4", side: "#d5c3bc" },
  { kind: "frog",   top: "#c1e4b2", side: "#95be83" },
];

작은 콩부터 큰 개구리까지 순서가 곧 크기 순서다. 색이 하던 일을 종족이 대신하니까, 종족을 알아보는 것만으로 크기가 읽힌다.

이 배열은 크기 배열과 길이·순서가 같아야만 성립하는데, 주석으로만 적어두면 언젠가 깨진다. 그래서 테스트로 묶었다.

✓ 중립 종족 목록은 크기 목록과 길이가 같다
✓ 크기마다 다른 종족이 배정된다
✓ 목록에 없는 크기도 종족이 배정된다

4. 참가자는 전부 같은 캐릭터여야 한다

중립 캐릭터는 종족이 6종인데, 참가자도 그렇게 하면 안 된다.

판에는 백 마리가 넘게 쌓이고 그중 스무 마리가 참가자다. 참가자끼리 종족이 다 다르면 내 캐릭터를 찾을 때 눈이 걸릴 데가 없다. 예쁜 것보다 찾을 수 있는 게 먼저다.

그래서 참가자는 종족 대신 강조색 고양이 하나로 고정했다.

export function speciesFor(coin: Coin, playerColor: string): Species {
  if (coin.kind === "player") {
    return { kind: "cat", top: playerColor, side: playerColor, sideShade: 0.22 };
  }
  const i = NEUTRAL_RADII.indexOf(coin.radius as (typeof NEUTRAL_RADII)[number]);
  return NEUTRAL_SPECIES[i < 0 ? Math.floor(NEUTRAL_SPECIES.length / 2) : i];
}

이름은 입 자리에 이름표로 넣었다. 파란 고양이 무리를 먼저 찾고, 그 안에서 이름을 읽는 두 단계가 된다.

판 위 접사. 곰·토끼·병아리·콩이 섞여 쌓여 있고 귀는 위로만 나와 있다. 몇몇은 한쪽 눈을 감고 있으며, 파란 고양이들만 이름표를 달고 있다

눈이 지글거렸다

마지막 문제는 표정이었다.

캐릭터가 전부 같은 얼굴이면 심심해서, 일부는 한쪽 눈을 감기기로 했다. 처음엔 회전각(spin)에 걸었다. 코인마다 회전각이 다르니까 자연스럽게 섞이겠거니 했다.

돌려보니 밀려 움직이는 동안 눈이 지글거렸다. 회전각은 매 프레임 바뀌는 값이라, 눈이 감겼다 떠졌다를 초당 수십 번 반복했다.

고친 건 한 줄이다.

/**
 * 눈을 감은 표정을 줄지. 코인 번호로만 정한다 — 회전각처럼 매 프레임 바뀌는 값에 걸면
 * 밀려 움직이는 동안 눈이 지글거린다.
 */
return coinId % 5 === 2;

코인 번호는 생성 시점에 정해지고 절대 안 바뀐다. 다섯에 하나가 눈을 감고, 그 하나는 게임이 끝날 때까지 계속 감고 있다.

이 글을 쓰면서 character.ts를 다시 열어봤더니, 회전각을 쓰던 시절의 주석이 아직 남아 있었다.

/** 종족별 얼굴. 회전각(spin)으로 눈 하나를 감겨 판 위가 정지 화면처럼 보이지 않게 한다. */
function drawFace(...)

spin은 이 파일 어디에도 없다. 이 주석 한 줄이 유일한 등장이다. 구현을 바꾸면서 주석을 안 고친 것이고, 지금은 코드가 하지 않는 일을 설명하는 문장이 됐다. 화석은 화석대로 증거가 되지만 주석으로는 해롭다.

애니메이션에서 "매 프레임 바뀌는 값"과 "개체를 식별하는 값"을 구분하지 않으면 이런 게 나온다. 표정은 개체의 성질이지 자세가 아니었다.

작은 크기에서 눈이 사라지는 것도 같이 막았다. 콩은 반지름이 작아서 비율대로 그리면 눈이 1px 밑으로 내려간다.

/** 눈이 이 크기(px) 아래로 내려가면 표정이 뭉개지므로 하한을 둔다. */
const MIN_EYE = 1.1;

미리보기는 같은 코드로 그린다

스킨 선택 카드의 미리보기는 따로 그리지 않는다. 게임과 똑같은 그리기 함수를 쓴다.

drawSkinSample(ctx, { w, h }, paletteRef.current, skin, "나");

카드용 그림을 따로 만들면 언젠가 실제 판 위 모습과 어긋난다. 같은 함수를 쓰면 어긋날 방법이 없다. 카드 폭이 화면 크기에 따라 바뀌니까 ResizeObserver로 리사이즈마다 다시 그린다.

돌려본 결과

당첨자 「예은」이 뜬 결과 화면. 부제가 「가장 먼저 떨어졌습니다 🐾」로 바뀌어 있다

12명으로 한 판 돌렸다. 26초쯤 걸렸고 예은이가 먼저 떨어졌다. 부제의 발바닥 이모지까지 스킨을 따라왔다.

겉모습만 바꾸는 작업이라고 생각했는데, 실제로 한 일은 "보이는 것"과 "계산되는 것"이 어긋나지 않게 맞추는 일이었다. 물리 코드는 정말 한 줄도 안 건드렸다. 그런데 귀를 좌우로 뺐을 때, 두께를 안 그렸을 때, 표정을 회전각에 걸었을 때 — 매번 물리가 고장 난 것처럼 보였다.

렌더는 물리를 바꾸지 않지만, 물리에 대한 사람의 판단은 렌더가 만든다.

직접 골라서 돌려보실 수 있다. 코인 밀기 추첨기

같이 읽으면 좋은 글