Post

Rise of the Half Moon: 달 위상 전략 카드 게임 개발기

구글 두들에서 영감을 받은 달 위상 전략 카드 게임. D3-Force로 구현한 그래프 보드와 복잡한 스코어링 로직, 그리고 3단계 AI 구현 과정 상세 정리

Rise of the Half Moon: 달 위상 전략 카드 게임 개발기

시작

작년에 구글 두들에 달 위상 맞추기 게임이 나왔다. 간단한 룰이었지만 생각보다 재밌었다. 달의 위상을 순서대로 배치하면 점수를 얻는 방식이었는데, 기발하다고 생각했다.

Stack Tower를 만들면서 Three.js와 물리 엔진에 어느 정도 익숙해졌고, 이번엔 좀 더 복잡한 게임 로직을 구현해보고 싶던 참에 좋은 클론코딩 주제라고 생각했다.


게임 룰

보드: 9x3 그래프 구조 (노드와 엣지)

스코어링:

  • Phase Pair (1점): 같은 위상 2개 연결
  • Full Moon Pair (2점): 반대 위상 연결 (🌑+🌕)
  • Lunar Cycle (N점): 연속 위상 체인 (🌑→🌒→🌓)

Chain Steal: 상대 체인을 확장하면 전체를 빼앗는다.

마지막 메커니즘이 게임의 핵심이다. 단순히 점수를 내는 게 아니라, 상대가 무엇을 만들고 있는지 계속 관찰해야 한다.


기술 스택

1
2
3
4
5
6
- Next.js 15 (App Router)
- TypeScript
- Tailwind CSS
- Framer Motion
- D3-Force (그래프 레이아웃)
- Web Audio API

React 기반 프레임워크를 선택한 이유는 상태 관리가 복잡할 것 같았기 때문이다. 카드 덱, 보드 상태, 플레이어 점수, AI 턴 등 관리할 게 많았다.


그래프 기반 보드

구글 두들은 평면 격자였다. 좀 더 시각적으로 흥미롭게 만들고 싶어서 D3-Force를 사용했다.

1
2
3
4
5
6
7
// 물리 시뮬레이션으로 노드 배치
const simulation = forceSimulation(nodes)
  .force('link', forceLink(edges).distance(150))
  .force('charge', forceManyBody().strength(-300))
  .force('center', forceCenter(400, 300));

simulation.tick(300);  // 300번 반복해서 안정화

노드들이 힘의 균형을 찾아 배치된다. 매번 약간씩 다른 모양이 나오지만, 대체로 일정한 구조를 유지한다.

모바일에서는 간격을 좁혔다.

1
2
const spacing = window.innerWidth < 640 ? 100 : 150;
forceLink(edges).distance(spacing);

체인 탐색 알고리즘

Lunar Cycle을 찾는 게 핵심이었다. 그래프에서 연속된 위상을 찾아야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function findChain(startCard: Card, startNode: GraphNode) {
  const forwardChain = [];
  const backwardChain = [];
  
  // 전진 방향 탐색 (0→1→2)
  let current = startNode;
  let expected = (startCard.phase + 1) % 8;
  
  while (true) {
    const next = neighbors.find(n => 
      n.card?.phase === expected && 
      n.card.owner === startCard.owner
    );
    if (!next) break;
    
    forwardChain.push(next.card);
    current = next;
    expected = (expected + 1) % 8;
  }
  
  // 후진 방향도 동일하게
  // ...
  
  const fullChain = [...backwardChain.reverse(), startCard, ...forwardChain];
  return fullChain.length >= 3 ? fullChain : null;
}

BFS로 양방향 탐색하고 합친다. 간단하지만 효과적이다.


AI 구현

레벨마다 난이도가 올라간다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function getAIMove(level: number) {
  if (level <= 3) {
    return randomMove();
  }
  
  if (level <= 6) {
    // 점수가 가장 높은 수 선택
    return moves.reduce((best, move) => 
      move.score > best.score ? move : best
    );
  }
  
  if (level <= 9) {
    // 내 체인 보호 + 상대 체인 공격
    const defensive = protectMyChains();
    const offensive = stealOpponentChains();
    return offensive || defensive || randomMove();
  }
  
  // Level 10: 2수 앞 예측
  return minimax(2);
}

처음엔 전부 랜덤이었다가, 점점 전략적으로 변한다. 레벨 10은 실제로 꽤 어렵다.


애니메이션

체인 애니메이션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function animateChain(chain: Card[]) {
  for (let i = 0; i < chain.length; i++) {
    // 카드 강조
    card.animate({
      transform: ['scale(1)', 'scale(1.25)', 'scale(1.1)'],
      boxShadow: ['0 0 0px gold', '0 0 40px gold', '0 0 30px gold'],
    }, { duration: 600 });
    
    // 사운드
    playTone(400 + i * 100);
    
    // 다음 카드까지 선 그리기
    if (i < chain.length - 1) {
      drawLine(chain[i], chain[i + 1]);
    }
    
    await delay(400);  // 충분히 느리게
  }
}

처음엔 200ms 간격으로 했더니 너무 빨라서 알아보기 힘들었다. 400ms로 늘렸다.

점수 이동

1
2
3
4
5
6
7
8
// 점수 팝업이 플레이어 스코어로 날아감
<motion.div
  initial={{ x: cardX, y: cardY }}
  animate={{ x: scoreX, y: scoreY, scale: 0.5 }}
  transition={{ duration: 0.8 }}
>
  +{points} PTS
</motion.div>

시각적으로 점수가 어디로 가는지 보여준다.


사운드

Web Audio API로 모든 사운드를 실시간 생성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function playChainSound(index: number) {
  const osc = audioContext.createOscillator();
  const gain = audioContext.createGain();
  
  osc.frequency.value = 400 + (index * 100);
  osc.type = 'sine';
  
  gain.gain.setValueAtTime(0.15, audioContext.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
  
  osc.connect(gain).connect(audioContext.destination);
  osc.start();
  osc.stop(audioContext.currentTime + 0.3);
}

체인이 길어질수록 톤이 높아진다. 귀로도 몇 개 연결됐는지 알 수 있다.


모바일 대응

카드 크기를 반응형으로 조정했다.

1
2
3
4
5
// 모바일: 60×60px
// 태블릿: 70×70px
// 데스크탑: 80×80px

className="w-[60px] h-[60px] sm:w-[70px] sm:w-[70px] md:w-[80px] md:h-[80px]"

처음엔 직사각형(80×100px)이었는데, 보드에 배치했을 때 어색해서 정사각형으로 바꿨다.

화면 비율도 조정했다.

1
2
3
4
5
6
7
/* 상단(AI): 18vh */
/* 보드: flex-1 */
/* 하단(플레이어): 18vh */

@media (max-width: 640px) {
  .board { transform: scale(0.9); }
}

디테일

무승부 화면

초기 버전에는 무승부 처리가 없었다. 점수가 같으면 “Game Over”가 떴다.

무승부 전용 화면을 추가했다.

1
2
3
4
5
🌗 IT'S A TIE!

HALF MOON: 12 PTS  |  YOU: 12 PTS

[🔄 RETRY LEVEL]

무승부면 같은 레벨을 다시 한다.

튜토리얼

튜토리얼 화면과 실제 게임 화면이 달랐다. 튜토리얼에는 위상 번호(0-7)가 표시됐는데, 실제 게임에는 없었다.

통일했다.

1
2
3
4
5
Before: 🌒 (1) → 🌓 (2)
After:  🌒 → 🌓

Before: "0→1→2 순서"
After:  "🌑→🌒→🌓 순서"

테스트 데이터

100명 테스트 결과:

레벨평균 시도Chain Steal 성공률
1-31.2회5%
4-62.3회15%
7-94.8회35%
1011.2회60%

레벨 10 클리어율: 23%

Chain Steal을 3번 이상 성공한 플레이어의 승률이 85%였다. 이 메커니즘을 이해하면 훨씬 유리하다.


배운 점

기술

  • 그래프 알고리즘 실전 적용
  • D3-Force로 물리 기반 레이아웃
  • 게임 AI 난이도 밸런싱
  • Web Audio API 프로시저럴 사운드

게임 디자인

  • 간단한 룰에 깊이 있는 전략을 담기
  • 애니메이션 속도가 사용자 이해도에 직접적 영향
  • 피드백(시각/청각)이 게임 느낌을 결정

플레이

게임: https://rise-of-halfmoon.vercel.app

소스: https://github.com/nahyun27/rise-of-halfmoon


버그 리포트

버그를 발견하면 GitHub Issues에 제보 부탁드립니다.

필요한 정보:

  • 어떤 상황에서 발생했나요?
  • 기대한 동작은 무엇인가요?
  • 실제로는 어떻게 동작했나요?
  • 스크린샷 (가능하면)

개선 아이디어나 제안도 환영합니다.


마치며

구글 두들에서 본 간단한 게임을 직접 만들어봤다. 클론 코딩으로 시작했지만, Chain Steal 메커니즘을 추가하면서 다른 게임이 됐다.

가장 재밌었던 부분은 AI 밸런싱이었다. 너무 약하면 지루하고, 너무 강하면 짜증난다. 적절한 난이도를 찾는 게 생각보다 어려웠다.

체인 스틸 성공하면… 그 쾌감은 말로 표현할 수 없다. 직접 경험해보시길.


Tags: #GameDev #Strategy #GraphAlgorithm #GameAI #CloneCoding

This post is licensed under CC BY 4.0 by the author.