이번 포스트에서는 지뢰찾기룰렛 두 게임의 핵심 알고리즘을 다룹니다.


지뢰찾기

난이도 설정

const DIFFICULTY_CONFIG = {
  easy:   { rows: 9,  cols: 9,  mines: 10 },
  medium: { rows: 16, cols: 16, mines: 40 },
  hard:   { rows: 20, cols: 20, mines: 60 },
} as const;

첫 클릭 안전 보장

지뢰는 첫 번째 클릭이 발생한 후에 배치됩니다. 첫 클릭 위치와 그 주변 3×3 영역은 안전 지대로 설정합니다.

function placeMines(
  rows: number,
  cols: number,
  mineCount: number,
  safeRow: number,
  safeCol: number
): Set<number> {
  // 안전 지대 좌표 집합 생성
  const safe = new Set<number>();
  for (let dr = -1; dr <= 1; dr++) {
    for (let dc = -1; dc <= 1; dc++) {
      const r = safeRow + dr;
      const c = safeCol + dc;
      if (r >= 0 && r < rows && c >= 0 && c < cols) {
        safe.add(r * cols + c);
      }
    }
  }
  
  // 안전 지대를 제외한 위치에 무작위로 지뢰 배치
  const available = Array.from(
    { length: rows * cols },
    (_, i) => i
  ).filter(i => !safe.has(i));
  
  const mines = new Set<number>();
  while (mines.size < mineCount) {
    const idx = Math.floor(Math.random() * available.length);
    mines.add(available[idx]);
    available.splice(idx, 1);
  }
  
  return mines;
}

인접 지뢰 수 계산과 BFS 자동 열기

각 칸의 인접 지뢰 수를 계산하고, 인접 지뢰가 0인 칸을 클릭하면 연결된 빈 칸을 BFS로 자동 열어줍니다.

function getAdjacentCells(index: number, rows: number, cols: number): number[] {
  const r = Math.floor(index / cols);
  const c = index % cols;
  const adjacent: number[] = [];
  
  for (let dr = -1; dr <= 1; dr++) {
    for (let dc = -1; dc <= 1; dc++) {
      if (dr === 0 && dc === 0) continue;
      const nr = r + dr;
      const nc = c + dc;
      if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
        adjacent.push(nr * cols + nc);
      }
    }
  }
  return adjacent;
}

function revealConnectedEmpty(
  startIndex: number,
  board: Cell[],
  mines: Set<number>
): Set<number> {
  const revealed = new Set<number>();
  const queue = [startIndex];
  
  while (queue.length > 0) {
    const current = queue.shift()!;
    if (revealed.has(current) || mines.has(current)) continue;
    
    revealed.add(current);
    const adjacentMineCount = getAdjacentCells(current, rows, cols)
      .filter(i => mines.has(i)).length;
    
    // 인접 지뢰가 0이면 주변도 계속 열기
    if (adjacentMineCount === 0) {
      getAdjacentCells(current, rows, cols)
        .filter(i => !revealed.has(i))
        .forEach(i => queue.push(i));
    }
  }
  
  return revealed;
}

깃발 모드

모바일에서는 터치 탭이 기본 열기 동작이므로, 깃발 모드를 별도 토글로 제공합니다. PC에서는 우클릭으로도 깃발을 설치/제거할 수 있습니다.

const handleCellClick = (index: number) => {
  if (isFlagMode) {
    toggleFlag(index);
  } else {
    revealCell(index);
  }
};

const handleContextMenu = (e: React.MouseEvent, index: number) => {
  e.preventDefault();
  toggleFlag(index);
};

룰렛

슬롯 가중 확률

각 항목(Tag)에 슬롯 수를 부여하여 확률을 조정합니다. 슬롯이 많을수록 해당 항목이 뽑힐 확률이 높아집니다.

interface Tag {
  id: string;
  name: string;
  color: string;
  slots: number;  // 1~20 설정 가능
}

// 전체 슬롯 수 = 모든 태그의 slots 합산
const totalSlots = tags.reduce((sum, tag) => sum + tag.slots, 0);

// 각 태그의 당첨 확률 = tag.slots / totalSlots

세그먼트 생성

각 슬롯이 동일한 각도를 차지하도록 세그먼트를 생성합니다.

interface Segment {
  tagId: string;
  startAngle: number;
  endAngle: number;
  midAngle: number;
}

function buildSegments(tags: Tag[]): Segment[] {
  const totalSlots = tags.reduce((sum, tag) => sum + tag.slots, 0);
  const anglePerSlot = 360 / totalSlots;
  
  const segments: Segment[] = [];
  let currentAngle = 0;
  
  for (const tag of tags) {
    for (let i = 0; i < tag.slots; i++) {
      const startAngle = currentAngle;
      const endAngle = currentAngle + anglePerSlot;
      segments.push({
        tagId: tag.id,
        startAngle,
        endAngle,
        midAngle: (startAngle + endAngle) / 2,
      });
      currentAngle = endAngle;
    }
  }
  
  return segments;
}

회전 애니메이션과 당첨 계산

포인터는 상단(0° 위치)에 고정하고, 휠이 회전합니다. 당첨 세그먼트의 중간 각도가 포인터와 일치하도록 목표 각도를 계산합니다.

function computeSpinTarget(
  currentRotation: number,
  winningSegment: Segment
): number {
  // 당첨 세그먼트의 midAngle이 포인터(상단, 0°)와 만나려면
  // 휠이 (360 - midAngle)만큼 회전해야 함
  const targetAngle = 360 - winningSegment.midAngle;
  const diff = ((targetAngle - (currentRotation % 360)) + 360) % 360;
  const extraSpins = 5 + Math.floor(Math.random() * 3); // 5~7 바퀴 추가
  
  return currentRotation + extraSpins * 360 + diff;
}

Motion 라이브러리의 animate로 CSS transform: rotate(${target}deg)에 easing을 적용하면 자연스러운 감속 효과가 구현됩니다.

당첨 시 제외 옵션

// 슬롯 1개만 제거 (항목은 유지, 확률만 줄어듦)
function removeOneSlot(tags: Tag[], tagId: string): Tag[] {
  return tags.map(tag =>
    tag.id === tagId ? { ...tag, slots: Math.max(0, tag.slots - 1) } : tag
  ).filter(tag => tag.slots > 0);
}

// 항목 전체 제거
function removeTag(tags: Tag[], tagId: string): Tag[] {
  return tags.filter(tag => tag.id !== tagId);
}

연속 추첨 시 당첨된 항목을 제외하면 남은 항목들의 확률이 자동으로 재분배됩니다.


지뢰찾기 바로가기: https://utility.dreamurl.biz/minigame/minesweeper

룰렛 바로가기: https://utility.dreamurl.biz/minigame/roulette