프로젝트 소개
친구들이랑 모임에서 내기할 때 쓸 게임 모음 웹사이트를 만들었다. Vite + Vue3 + Tailwind CSS v4 스택으로 제작했고, 주사위 게임 / 제비뽑기 / 스톱워치 / 사다리 타기 총 4가지 게임을 구현했다.
배포: https://lucky-games-web.vercel.app
lucky-games-web
lucky-games-web.vercel.app
기술 스택
- 프론트엔드: Vue3 + Vite
- 스타일링: Tailwind CSS v4
- 배포: Vercel + GitHub Actions
- 분석: Google Analytics GA4
프로젝트 구조
lucky-games-web/
├── src/
│ ├── router/index.js
│ ├── views/
│ │ ├── IntroView.vue # 인트로 (게임 선택)
│ │ ├── DiceView.vue # 주사위 게임
│ │ ├── DrawView.vue # 제비뽑기
│ │ ├── TimerView.vue # 스톱워치
│ │ └── LadderView.vue # 사다리 타기
│ ├── components/
│ │ ├── AppHeader.vue
│ │ └── PlayerListInput.vue
│ ├── utils/
│ │ └── index.js # Fisher-Yates 셔플 유틸
│ ├── App.vue
│ └── main.js
구현한 내기 모음
🎲 주사위 게임
참가자 이름 입력 → 각자 주사위 굴리기 → 최고점 승자 판정 흐름이다.
핵심 패턴: phase 상태 관리
게임을 input → roll → result 3단계로 나눠서 관리했다. Vue의 ref()로 현재 단계를 추적하고 v-if로 UI를 전환하는 방식이다.
const phase = ref('input') // 'input' | 'roll' | 'result'
<winner computed>
const winner = computed(() => {
if (phase.value !== 'result') return null
const maxScore = Math.max(...players.value.map(p => p.result))
return players.value.find(p => p.result === maxScore)
})
Math.max는 배열을 못 받아서 스프레드 연산자(...)로 펼쳐서 넘기는 게 핵심이다.
🎫 제비뽑기
참가자 + 항목 입력 → Fisher-Yates 셔플 → 카드 한 명씩 뒤집기 흐름이다.
<Fisher-Yates 셔플>
// src/utils/index.js
export function shuffle(arr) {
const shuffled = [...arr]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
⚠️ ASI 함정: const j 다음 줄이 [로 시작하면 JavaScript가 배열 접근으로 잘못 파싱한다. ;[shuffled[i], shuffled[j]] 처럼 앞에 세미콜론을 붙여야 한다.
3D 카드 뒤집기 CSS
.card {
perspective: 600px;
transform-style: preserve-3d;
transition: transform 0.5s;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-back {
backface-visibility: hidden;
transform: rotateY(180deg);
}
카드 앞/뒷면을 겹쳐놓고 backface-visibility: hidden으로 한 면씩만 보이게 하는 게 핵심이다. 클릭 시 부모에 rotateY(180deg)를 추가하면 자연스럽게 뒤집힌다.
⏱️ 스톱워치
requestAnimationFrame 기반 정확한 타이머에 랩 기록 기능을 추가했다.
왜 setInterval 대신 requestAnimationFrame인가?
setInterval은 탭이 비활성화되거나 CPU 부하 시 drift(오차)가 생긴다. requestAnimationFrame은 브라우저 화면 갱신 주기에 맞춰 실행되고, 시간 계산은 Date.now() 기준이라 rAF가 늦게 실행돼도 오차가 없다.
function tick() {
elapsed.value = baseElapsed + (Date.now() - startedAt)
rafId = requestAnimationFrame(tick)
}
<일시정지/계속 구조>
function pause() {
baseElapsed = elapsed.value // 현재 시간 저장
cancelAnimationFrame(rafId)
}
function start() {
startedAt = Date.now() // 새로 시작 시각 찍기
tick()
}
<formatTime 구현>
function formatTime(ms) {
const minute = Math.floor(ms / 60000)
const second = Math.floor(ms / 1000) % 60
const cs = Math.floor(ms / 10) % 100
return `${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}.${String(cs).padStart(2, '0')}`
}
⚠️ 흔한 실수: minute.toString.padStart(2, '0')처럼 toString을 함수 참조로 쓰면 안 된다. toString()으로 호출해야 문자열이 반환된다. 또한 % 나머지 연산 없이 단순 나누기만 하면 단위가 겹친다. Math.floor(ms/1000) % 60처럼 각 단위를 독립적으로 분리해야 한다.
🪜 사다리 타기
SVG로 세로선/가로선을 직접 그리고, 경로 추적 알고리즘으로 결과를 계산했다.
<사다리 생성 로직>
// rungs[row][col] = col과 col+1 사이에 가로선이 있냐
const rungs = ref([])
function generateRungs(count, rows) {
return Array.from({ length: rows }, () => {
const row = new Array(count - 1).fill(false)
for (let col = 0; col < count - 1; col++) {
if (Math.random() > 0.6 && !row[col - 1]) {
row[col] = true
}
}
return row
})
}
!row[col - 1] 조건이 핵심이다. 바로 왼쪽에 이미 가로선이 있으면 추가하지 않는다. 이 조건이 없으면 같은 높이에 연속 가로선이 생겨서 경로가 꼬인다.
<tracePath 경로 추적>
function tracePath(startCol) {
let col = startCol
const points = [{ x: colX(col), y: TOP_Y }]
for (let row = 0; row < ROWS; row++) {
points.push({ x: colX(col), y: rowY(row) })
if (rungs.value[row][col]) {
col += 1
points.push({ x: colX(col), y: rowY(row) })
} else if (col > 0 && rungs.value[row][col - 1]) {
col -= 1
points.push({ x: colX(col), y: rowY(row) })
}
// 가로선 없으면 그냥 다음 row로
}
points.push({ x: colX(col), y: RESULT_Y })
return { points, endCol: col }
}
⚠️ 함정: else { return }처럼 가로선 없을 때 함수 전체를 종료하면 안 된다. 그냥 다음 row로 넘어가야 한다.
⚠️ const 재할당: points = [...points, { x, y }]는 const라 불가능하다. points.push({ x, y })로 같은 배열 내부를 수정해야 한다.
트러블슈팅
Tailwind v4 + 커스텀 CSS 충돌
문제: mb-16 같은 Tailwind 유틸리티 클래스가 적용이 안 됐다.
원인: style.css에 * { margin: 0; padding: 0; } 리셋 스타일이 Tailwind보다 우선순위가 높아서 덮어쓰고 있었다.
해결: Tailwind v4는 자체 리셋을 포함하고 있어서 커스텀 리셋을 제거했다.
Fisher-Yates ASI 문제
문제: Cannot access 'j' before initialization 에러 발생
원인: JavaScript ASI(자동 세미콜론 삽입)로 인해 const j 다음 [가 배열 접근으로 파싱됨
해결: [shuffled[i], shuffled[j]] 앞에 ; 추가
flex-1 오버플로우
문제: 사다리 입력폼에서 모바일 화면을 벗어남
원인: flex-1 자식들이 최소 너비를 초과
해결: min-w-0 추가
<input class="flex-1 min-w-0 bg-gray-800 ..." />
Vercel SPA 새로고침 404
문제: /dice 같은 경로에서 새로고침하면 404 에러
해결: vercel.json에 rewrites 추가
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
배포
- GitHub main / dev 브랜치 구조로 관리
- Vercel 자동 배포 연동
- Google Analytics GA4 추가
회고
이번 프로젝트에서 가장 많이 배운 것들이다.
Vue 패턴: phase 패턴으로 단계 관리, computed로 실시간 계산, v-model + emit 조합으로 컴포넌트 통신
JavaScript 함정: ASI 세미콜론 문제, const 배열 재할당 vs push, toString vs toString() 호출
CSS: 3D 카드 플립, Flexbox min-w-0 오버플로우 처리, Tailwind v4 충돌
알고리즘: Fisher-Yates 셔플, SVG 경로 추적, requestAnimationFrame 타이머
버그를 만나고 → 이유를 이해하고 → 직접 고치는 사이클을 계속 반복한 게 실력이 느는 방식이었다.