특정 모듈을 구현할 때 클래스 형으로 OOP의 일환으로 클래스를 정의하고 이를 기반으로 객체를 생성하여 모듈을 구성하는 방식이 일반적(?)이다.
클래스는 속성(데이터)과 메서드(동작)을 포함하여, 다형성, 상속, 캡슐화 등 개념을 활용할 수 있기 때문이다.
하지만 난 함수형 프로그래밍으로 짤 것이다.
순수 함수 와 불변성을 기반으로 하는 프로그래밍 패러다임이다. 상태와 데이터를 변경하지 않고 함수를 조합하여 원하는 동작을 구현한다.
const originalArray = [1, 2, 3];
const newArray = [...originalArray, 4]; // originalArray는 변경되지 않음
const add = (a, b) => a + b; // 순수 함수
두더지 게임을 만들어보겠다. ( 포켓몬 세대로서 digda: 디그다게임으로 명명하겠다. )
const createMoleGame = () => {
// 여기서 게임을 만듭니다.
/**
* 기본 게임 설정을 반환합니다.
*
* @returns {Settings} 기본 설정 객체
*/
const getDefaultSettings = () => ({
moleCount: 9, // 두더지 수
gameDuration: 10000, // 게임 전체 시간 (ms)
moleDurationMin: 200, // 최소 나타나는 시간 (ms)
moleDurationMax: 300 // 최대 나타나는 시간 (ms)
})
/**
* 게임 설정을 업데이트하고 초기화합니다.
*
* @param {Settings} customSettings - 사용자 정의 설정
* @param {Settings} currentSettings - 현재 설정
* @returns {Settings} 업데이트된 설정
*/
const updateSettings = (customSettings, currentSettings) => ({
...currentSettings,
...customSettings
})
/**
* @typedef {Object} Mole
* @property {boolean} isVisible - 두더지의 표시 여부
* @property {NodeJS.Timeout | null} timeoutId - 두더지 타이머 ID
*/
/** @type {Mole[]} */
let moles = [] // 두더지 상태 배열
let settings = getDefaultSettings() // 게임 설정
/** @type {NodeJS.Timeout | null} */
let gameTimer = null
/** @type {boolean} */
let isPlaying = false // 게임 진행 여부
/** @type {number} */
let score = 0 // 점수
}
위의 기본 세팅을 가지고 시작한다.
return {
startGame, // 게임을 시작하는 마법의 단추
endGame, // 게임을 끝내는 마법의 단추
whackMole, // 두더지를 잡는 망치
getScore, // 점수를 확인하는 점수판
getState, // 게임 상태를 알려주는 안내판
subscribe: subscribersManager.subscribe.bind(subscribersManager) // 메서드 바인딩
}
이 규칙을 가지고 디그다 게임을 만들어 볼겁니다.
const startGame = (customSettings = {}) => {
// 여기서 게임 준비
}
게임을 시작하는 버튼이다.
if (isPlaying) {
console.warn('게임이 이미 진행 중입니다.')
return
}
error guard 게임이 진행 중이면 "이미 게임 진행중이다!"라고 알려주고 return ( 종료 ) 이건 마치 이미 달리고 있는 레이싱에서 "출발"이라고 다시 외치지 않는 것과 같은 이치
isPlaying = true
score = 0
settings = updateSettings(customSettings, settings)
moles = initMoles(settings)
isPlaying = true: "게임 시작!" 깃발을 올린다. score = 0: 점수판을 0으로 초기화한다. settings = updateSettings(...): 원하는 설정으로 게임을 세팅한다 (두더지 수, 게임 시간 등). moles = initMoles(settings): 두더지 구멍을 모두 준비한다.
initMoles의 기능 함수를 알아봐야겠지
/**
* 두더지 상태를 초기화합니다.
*
* 함수 인자 도입: settings를 함수 인자로 받아 외부 상태에 의존하지 않도록 합니다. 이는 함수의 순수성을 높이고, 테스트를 용이하게 합니다. 반환값 명확화:
* 초기화된 두더지 배열을 반환하여, 함수의 결과를 명확하게 알 수 있습니다.함수 인자 도입: settings를 함수 인자로 받아 외부 상태에 의존하지 않도록 합니다. 반환값
* 명확화: 초기화된 두더지 배열을 반환하여, 함수의 결과를 명확하게 알 수 있습니다.
*
* @param {Settings} settings - 현재 게임 설정
* @returns {Mole[]} 초기화된 두더지 배열
*/
const initMoles = (settings) =>
Array.from({ length: settings.moleCount }, () => ({
isVisible: false,
timeoutId: null
}))
moles 변수에 settings.moleCount 만큼 디그다를 초기 세팅한다. 두더지를 9마리 / 3 마리 등 세팅한 만큼 넣어놓는다.
console.log('게임이 시작되었습니다!')
notifySubscribers()
그것도 모두에게 알린다. ( 모두란 1인 게임 후 socket을 통한 2인게임을 위한 준비)
사용자 구독은 마지막에 다루도록 하겠습니다.
showMole()
// 게임 종료 예약
gameTimer = setTimeout(endGame, settings.gameDuration)
끝날시간도 알려준다.
const showMole = () => {
if (!isPlaying) return
const randomIndex = Math.floor(Math.random() * settings.moleCount)
if (!moles[randomIndex].isVisible) {
revealMole(randomIndex, settings)
} else {
// 이미 보이는 두더지면 다시 시도
showMole()
}
}
동작원리
// 랜덤으로 구멍을 선택한다.
const randomIndex = Math.floor(Math.random() * settings.moleCount)
여러 구멍 중에서 두더지가 튀어나올 구멍을 무작위로 고른다. index로 처리하면 됨
다만 두더지가 이미 담겨있는 경우도 있으니 이를 제외한다.
if (!moles[randomIndex].isVisible) {
revealMole(randomIndex, settings)
} else {
// 이미 보이는 두더지면 다시 시도
showMole()
}
이 함수는 마치 디그다 교통 정리 요원같다.
이 함수는 재귀 호출을 사용한단 점을 주목하자. 첫 번째로 선택한 구멍에 이미 두더지가 있으면 자기 자신을 다시 호출해서 새로운 구멍을 찾는다. 이렇게 하면 반드시 비어있는 구멍을 찾을 때까지 계속 시도하게 된다.
// 두더지 나오기 함수
const revealMole = (index, settings) => {
// digda 등장
moles[index].isVisible = true
console.log(`${index}번째 두더지가 나타났다!`)
notifySubscribers() // 상태 변경 알림
// 랜덤한 지속 시간 계산
const randomDuration = Math.floor(
Math.random() * (settings.moleDurationMax - settings.moleDurationMin + 1) +
settings.moleDurationMin
)
moles[index].timeoutId = setTimeout(() => hideMole(index), randomDuration)
}
두더지의 상태를 변경하고 화면에 보이게 한다. 그리고 이 변화를 구독자들에게 알린다. 마치 "자, 이제 두더지가 나타났으니 망치 준비하세요!"라고 외치는 것과 같다.
moles[index].isVisible = true
console.log(`${index}번째 두더지가 나타났다!`)
notifySubscribers()
두더지 상태를 변경하고 화면에 보이게 한다. 그리고 이 변화를 구독자들에게 알린다. 망치를 준비하세요!
const randomDuration = Math.floor(
Math.random() * (settings.moleDurationMax - settings.moleDurationMin + 1) +
settings.moleDurationMin
)
두더지가 얼마나 오래 밖에 있을지 200-300ms 사이 설정한 만큼 랜덤하게 결정한다.
moles[index].timeoutId = setTimeout(() => hideMole(index), randomDuration)
hideMole 함수를 시간 후 실행해서 두더지를 집에가게한다.
// 두더지를 숨기는 함수
const hideMole = (index) => {
if (moles[index].isVisible) {
moles[index].isVisible = false
console.log(`${index}번째 두더지가 사라졌다!`)
notifySubscribers()
// 새로운 두더지 표시
if (isPlaying) showMole()
}
}
여기까진 게임을 진행하는 설정 요소에 가깝다. 이제부턴 user가 두더지를 때리는 등 특별한 액션에 대한 이야기를 해보자 게임만시작하고 때려도 아무일이 안 일어나면 슬프다.
이 함수는 두더지 게임의 핵심! user가 두더지를 망치로 내려쳤을 때 실행되는 함수
// 두더지를 잡는 함수
const whackMole = (index) => {
if (!isPlaying || !moles[index].isVisible) return false
console.log(`${index}번째 두더지를 잡았다!`)
score += 1
moles[index].isVisible = false
// 기존 타이머 제거
clearTimeout(moles[index].timeoutId)
moles[index].timeoutId = null
notifySubscribers()
// 새로운 두더지 표시
if (isPlaying) showMole()
return true
}
1단계: 두더지 확인하기 먼저 게임이 진행 중인지, 그리고 잡으려는 두더지가 정말 밖에 나와 있는지 확인한다. 만약 게임이 끝났거나 두더지가 없으면 아무 일도 일어나지 않는다. 마치 "헛 것"을 본 것처럼!
if (!isPlaying || !moles[index].isVisible) return false
2단계: 점수 올리기 두더지를 성공적으로 잡았으니 점수를 1점 올린다. "짜잔! 점수 +1!"
score는 createMoleGame에서 전역으로 관리되고 있고 클로저로 변수은닉되어 사용자에게 보여진다.
console.log(`${index}번째 두더지를 잡았다!`)
score += 1
3단계: 두더지 숨기기 두더지를 잡았으니까 화면에서 사라지게 한다. "쿵! 두더지 퇴장~"
moles[index].isVisible = false
4단계: 타이머 정리하기 원래는 두더지가 일정 시간 후에 자동으로 사라지도록 타이머가 설정되어 있다. 하지만 우리가 이미 잡았으니까 그 타이머는 필요 없다네! "타이머야, 너의 임무는 끝났다!"
clearTimeout(moles[index].timeoutId)
moles[index].timeoutId = null
5단계: 변경사항 알리기
모든 친구들에게 "나 두더지 잡았어!" 라고 소식을 전한다. 이렇게 하면 화면이 업데이트된다.
notifySubscribers()
6단계: 새 두더지 부르기
게임이 아직 진행 중이라면 다른 두더지를 불러내. "다음 두더지 출동!"
if (isPlaying) showMole()
7단계: 성공 신호 보내기
모든 과정이 성공적으로 끝났다는 신호를 보낸다. "미션 완료!"
return true
/** 게임을 종료합니다. */
const endGame = () => {
if (!isPlaying) {
console.warn('게임이 진행 중이지 않습니다.')
return
}
isPlaying = false
clearTimeout(gameTimer) // 게임 종료 타이머 제거
// 모든 두더지 타이머 제거 및 숨김
moles.forEach((mole, index) => {
if (mole.timeoutId) {
clearTimeout(mole.timeoutId)
mole.timeoutId = null
}
if (mole.isVisible) {
mole.isVisible = false
console.log(`${index}번째 두더지가 사라졌습니다!`)
}
})
console.log(`게임 종료! 최종 점수: ${score}`)
// 상태 변경을 구독자에게 알림
notifySubscribers()
}
이거면 얼추 게임이 완성되었다.
getScore, // 점수 가져오기
getState, // 현재 상태 가져오기
subscribe: subscribersManager.subscribe.bind(subscribersManager) // 메서드 바인딩
// 점수 가져오기
const getScore = () => score
점수를 직접 접근하지 않고 함수화하여 제공한다. get 함수이다.
/**
* 현재 게임 상태를 반환합니다.
*
* @returns {GameState} 현재 상태
*/
const getState = () => ({
isPlaying,
score,
moles: moles.map((mole) => mole.isVisible)
})
cilent에게 현재 상황을 반환한다.
게임 정보를 어떻게 객관적으로 균질하게 공유할지 고민해보았다. 특히 socket기반 게임을 마련하려고 했을 때 이는 무엇보다 중요했다.
게임상태를 구독하는 행위이다.
옵저버 패턴으로 게임 구독하기 옵저버 패턴: 객체의 상태 변화가 있을 때, 이를 의존하는 여러 객체들에게 자동으로 통지하고 업데이트할 수 있도록 하는 디자인 패턴입니다. 주로 이벤트 핸들링이나 데이터 바인딩에 활용됩니다.
set을 사용해 구독자들으 저장하고 관리한다. set의 구조의 경우에 중복을 방지하는 역할을 할 수 있다.
const createSubscribers = () => {
const subscribers = new Set()
return {
/**
* 모든 구독자에게 상태를 알립니다.
*
* @param {GameState} state - 현재 게임 상태
*/
notify(state) {
subscribers.forEach((callback) => {
try {
callback(state)
} catch (error) {
console.error('구독자 알림 중 오류:', error)
}
})
},
/**
* 구독자를 추가합니다.
*
* @param {SubscriberCallback} callback - 상태 변경 시 호출될 콜백 함수
* @returns {Function} 구독 해제 함수
*/
subscribe(callback) {
console.log('새로운 구독자 추가')
subscribers.add(callback)
// 구독 해제 함수 반환
return () => {
console.log('구독자 제거 시도')
const removed = subscribers.delete(callback)
console.log('구독자 제거 성공:', removed)
console.log('남은 구독자 수:', subscribers.size)
}
}
}
}
notify(state) {
subscribers.forEach((callback) => {
try {
callback(state)
} catch (error) {
console.error('구독자 알림 중 오류:', error)
}
})
}
모든 구독자에게 현재 게임 상태를 알린다. 호출 시점 : 게임 상태가 변경될 때마다 (revealMole, hideMole, whackMole, startGame, endGame 등)
subscribe(callback) {
console.log('새로운 구독자 추가')
subscribers.add(callback)
return () => {
console.log('구독자 제거 시도')
const removed = subscribers.delete(callback)
// 구독 해제 로직
}
}
새 구독자 등록 및 구독 해제 함수 반환 return 에 구독제거 함수를 박아 놓는다. (클린업 함수)
구독 관리자 생성:
const subscribersManager = createSubscribers()
게임 상태 변경 통지 함수:
const notifySubscribers = () => {
const state = getState()
subscribersManager.notify(state)
}
변경 시점 추적:
두더지 등장: revealMole → notifySubscribers() 두더지 사라짐: hideMole → notifySubscribers() 두더지 잡기: whackMole → notifySubscribers() 게임 시작: startGame → notifySubscribers() 게임 종료: endGame → notifySubscribers() 외부 연결점 (client) :
return {
// ...
subscribe: subscribersManager.subscribe.bind(subscribersManager)
}
🔗 UI와의 연결 🌟 중요한 기술적 포인트 bind 사용 이유: subscribersManager의 this 컨텍스트 유지 Set 자료구조: 중복 구독 방지 및 효율적인 추가/삭제 클린업 함수: 메모리 누수 방지를 위한 구독 해제 매커니즘 오류 격리: 개별 구독자 오류가 전체 시스템에 영향 없음
import gameInstance from './digda.js';
// 바로 사용 가능
gameInstance.startGame();
편의성: 대부분의 경우 한 개의 게임만 필요하므로 바로 사용 가능 간편한 사용: 추가 코드 없이 바로 게임 기능 사용 가능 상태 공유: 앱 전체에서 동일한 게임 상태 공유
싱글톤 패턴: 애플리케이션 전체에서 단 하나의 인스턴스만 생성되고, 이에 대한 전역 접근점을 제공하는 디자인 패턴입니다. 주로 설정 관리나 로그 관리 등에 사용됩니다.
커스터마이징: 필요하면 여러 개의 게임 인스턴스 생성 가능 테스트 용이성: 테스트마다 격리된 새 인스턴스 사용 가능 다중 게임: 필요하면 여러 게임을 동시에 운영 가능
import { createMoleGame } from './digda.js';
// 새로운 게임 인스턴스 생성
const myCustomGame = createMoleGame();
게임을 실행하는 기본적인 규칙을 만들어보았다.
디그다를 실행해본다.
또 무리하게 함수형으로 모듈을 만들어보았다.
복잡한 듯 여러 기능이 구현된 디그다 게임을 만드는데 앗 정리해보니 좀 낫다.