이번 포스트에서는 지뢰찾기와 룰렛 두 게임의 핵심 알고리즘을 다룹니다.
지뢰찾기
난이도 설정
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