Web/React

[React🌀] React로 2048 게임 만들기 ! 🎮 / 3️⃣ - 알고리즘 구현

서상혁 2020. 8. 31. 12:11

2048 알고리즘

2048 알고리즘을 구현해볼 차례입니다! 아마 구현하는 방법은 사람이 생각하는거에 따라 엄청 다양하고 여러가지일 것 같습니다!

저는 이분의 유튜브에서 왼쪽으로 이동하고 터지는 부분을 참고해서 좀 더 추가하거나 변형해서 만들었습니다. 

 

www.youtube.com/watch?v=aDn2g8XfSMc

 


 

 

게임 작동 방식 알고리즘

작동 방식은 다음과 같이 이루어집니다.

 

우선 방향키를 누르게되면 누른 방향으로 0을 제외한 숫자들이 이동하게 되고, 변화가 있다면 랜덤한 위치에 가장 작은 숫자가 생성됩니다. 그리고 이동한 경우에 같은 숫자 2개가 만나면? 터집니다 ! ✨

터지게되면 점수를 획득하고, 두 숫자가 합쳐진 새로운 숫자가 만나죠. 이를 상 하 좌 우 로 똑같이 구현하면 됩니다.

 

 

우선 만들어야 할 메인스트림 함수들을 정리해봤고 다음과 같습니다.

 

  • 랜덤한 위치에 숫자 생성하는 함수

  • 왼쪽으로 단순히 이동시키는 함수

  • 이동된 숫자들을 합쳐주는 함수

  • 터진 점수를 계산하는 함수

  • 게임 종료인지 테스트하는 함수

하나씩 해보죠!😜

 

 

 


↔ 왼쪽 (오른쪽 이동)

 

0. 게임 보드는 2차원 배열로 구성

 - 예를들어 4*4 게임판이면  number[4][4] 로 구성돼 있겠죠? 특별한 거 없습니다.

 

1. 2차원 배열 내의 0을 제외한 숫자들을 왼쪽으로 옮깁니다. 

 

// 왼쪽으로 단순 옮기기
export const slideLeft = (board: number[][]) => {
  const version = board.length
  return board.map((row, r) => {
    let remain = row.filter((n) => n != 0)
    let zero_cnt = version - remain.length
    let newRow = remain.concat(Array(zero_cnt).fill(0))
    return newRow
  })
}

* version 은 저희 한변의 길이를 뜻합니다!  version 이 2라면 2*2 게임판이겠죠!

 

 

2. 옮겼는데 같은 숫자 2개가 함께 있다면 1번만 합쳐줍니다!

 

// 터트리기 (모여있던 같은숫자를 합쳐줍니다.)
export const combineLeft = (board: number[][]) => {
  const version = board.length
  const newBoard = Array.from(board)
  for (let row = 0; row < version; row++) {
    for (let col = 0; col < version - 1; col++) {
      if (newBoard[row][col] === newBoard[row][col + 1]) {
        newBoard[row][col] = newBoard[row][col] + newBoard[row][col + 1]
        newBoard[row][col + 1] = 0
      }
    }
  }
  return newBoard
}

 

3. 터졌으니 빈공간이 있을 수 있겠죠? 한번 다시 움직여줍니다. (slideLeft 한번 더)

 

4-0.  만약 1~3 과정에서 변화가 있었다면 새로운 가장작은 숫자를 랜덤한 위치에 생성해줍니다.

        만약 1~3 과정에서 변화가 없었다면 그대로 둡니다.

 

// 0 인 자리를 찾아 숫자 생성
export const generateRandom = (board: number[][]): number[][] => {
  if (!isGenerateAvailable(board)) {
    return board
  }
  const version = board.length
  let ranNum = Math.floor(Math.random() * version * version)
  const row = Math.floor(ranNum / version)
  const column = ranNum % version
  if (board[row][column] == 0) {
    board[row][column] = version !== 4 ? version : 2
    return board
  } else {
    return generateRandom(board)
  }
}

 

4-1.  이 과정을 모두 묶어서 하나의 함수로 만들어줍니다!

 

저는 뭔가 멋있고 깔끔하게 짜고싶은 욕심이 있어서 pipe 라는 함수를 묶는 함수를 만들었습니다!

const pipe = (...functions: any) => (input: any) =>
  functions.reduce((acc: Function, fn: Function) => fn(acc), input)

 

5-0. 최종적으로 나온 moveLeft, moveRight 함수입니다.

 

// 왼쪽 버튼 눌렀을 때
export const moveLeft = (board: number[][]) => {
  const nextBoard = pipe(slideLeft, combineLeft, slideLeft)(board)
  if (isSameBoard(board, nextBoard)) return board // 변화 없으면 그대로
  return generateRandom(nextBoard) // 변화 있으면 숫자 생성
}

// 오른쪽 버튼을 눌렀을 때
export const moveRight = (board: number[][]) => {
  const nextBoard = pipe(slideRight, combineLeft, slideRight)(board)
  if (isSameBoard(board, nextBoard)) return board // 변화 없으면 그대로
  return generateRandom(nextBoard) // 변화 있으면 숫자 생성
}

왼쪽 : 왼쪽이동 -> 합치기 -> 왼쪽이동 -> 랜덤숫자 생성(변화있으면)

오른쪽 : 오른쪽이동 -> 합치기 -> 오른쪽이동 -> 랜덤숫자 생성(변화있으면)

 

 

⬆⬇ 위쪽 (아래쪽 이동)

 

컴퓨터 2차원 배열 특성상, 열을 이동하는거는 쉬운데, 행간의 숫자를 이동하는 거는 쉽지 않죠!

그래서 제가 생각을 해봤는데 게임판을 돌려놓고 생각하면 그냥 왼쪽 이동하는 거랑 아에 같은 방식이더라구요. 그래서 반시계방향으로 돌리고 아까 왼쪽 이동처럼 열을 이동시키고, 다시 시계방향으로 돌리는 방식이 좋을 것 같다는 결론을 내렸습니다.

 

 - 시계반대방향 돌리기 함수

function transposeCCW(board: number[][]): number[][] {
  // 행렬 돌리기 (시계 반대)
  let version = board.length || 0
  let newBoard = Array.from(Array(version), () => Array(version).fill(0))
  for (let i = 0; i < version; i++) {
    newBoard[i] = []
    for (let j = 0; j < version; j++) {
      newBoard[i][j] = board[j][version - i - 1]
    }
  }
  return newBoard
}

function transposeCW(board: number[][]): number[][] {
  // 행렬 돌리기 (시계)
  let version = board.length || 0
  let newBoard = Array.from(Array(version), () => Array(version).fill(0))
  for (let i = 0; i < version; i++) {
    newBoard[i] = []
    for (let j = 0; j < version; j++) {
      newBoard[i][version - j - 1] = board[j][i]
    }
  }
  return newBoard
}

2차원 배열 for문으로 돌면서 해당 행열을 돌렸을 때의 행열의 규칙에 맞게 변경해주었습니다.

 

 

최종으로 나온 위, 아래로 이동 함수 입니다!

 

// 위쪽 버튼을 눌렀을 때
export const moveTop = (board: number[][]) => {
  const nextBoard = pipe(transposeCCW, slideLeft, combineLeft, slideLeft, transposeCW)(board)
  if (isSameBoard(board, nextBoard)) return board // 못움직이면 그대로
  return generateRandom(nextBoard)
}

// 아래쪽 버튼 눌렀을 때
export const moveBottom = (board: number[][]) => {
  const nextBoard = pipe(transposeCCW, slideRight, combineLeft, slideRight, transposeCW)(board)
  if (isSameBoard(board, nextBoard)) return board // 못움직이면 그대로
  return generateRandom(nextBoard)
}

 


📚 점수 계산 함수 

사실 이부분이 되게 생각하기 애매했습니다. 앞에 함수들 다 만들어놓고 나중에 점수 계산함수를 추가하는 방식을 생각해봤습니다. 처음 들은 생각은 combineLeft에서 터질 때 점수를 더해주는 방식을 떠올렸는데 그렇게 되면 전역변수 설정을 할 수가 없으니 아에 input output에 점수를 인수로 추가해줘야되고 함수들을 전부 다시 건들여야 될 것 같더군요.. 근데 저는 pipe 함수형 프로그래밍을 너무 쓰고 싶어서 그 방식은 포기하고 다른 방식을 떠올려봤습니다 ㅎㅎㅎ 

 

제가 생각한 거는 움직이기 이전 모양을 기억해뒀다가 사라진 숫자, 생성된 숫자를 바탕으로 점수를 계산하는 방식입니다! 이전에 4가 2개였는데 지금은 8이 1개고 4가 0개다? 그러면 당연히 4+4 점이 추가되죠! 

이 방식을 가장 낮은 숫자부터 차례데로 숫자를 올려가며 계산해주면 됩니다.

 

// 점수 계산하는 함수
export const calScore = (prev: number[][], now: number[][]) => {
  const Score_prev: any = {}
  const Score_now: any = {}
  let score = 0
  prev.map((row) => {
    // 이전 보드 숫자들 기록
    row.map((num) => {
      Score_prev[num] = Score_prev[num] ? Score_prev[num] + 1 : 1
    })
  })
  now.map((row) => {
    // 현재 보드 숫자들 기록
    row.map((num) => {
      Score_now[num] = Score_now[num] ? Score_now[num] + 1 : 1
    })
  })
  Object.keys(Score_prev).map((num) => {
    let prev_cnt = Score_prev[num]
    let now_cnt = Score_now[num] ? Score_now[num] : 0
    // 터트렸을 때
    if (prev_cnt > now_cnt) {
      let isInitiailNum = num === "2" || num === "3" || num === "5"
      let diff_cnt = isInitiailNum ? prev_cnt - now_cnt + 1 : prev_cnt - now_cnt // 초기값은 랜덤한 위치에 숫자 1개가 새로생긴다
      score += diff_cnt * parseInt(num)
      Score_now[parseInt(num) * 2] -= 1
    }
  })
  return score
}

 

 

🚫 게임 종료 확인

특별한 방법을 쓰지않았습니다. 그냥 왼쪽, 오른쪽, 아래, 위, 컴퓨터가 다 움직여보고 똑같으면 종료입니다. 더 효율적인 방법 있으면 피드백 부탁드립니다!

 

// 게임 끝났는지 확인
export const isGameOver = (board: number[][]) => {
  if (moveLeft(board) !== board) return false
  if (moveRight(board) !== board) return false
  if (moveTop(board) !== board) return false
  if (moveBottom(board) !== board) return false
  return true
}

 


최종 - 함수들 모아둔 파일 생성

 

앞서 구현한 알고리즘 관련 함수들을 모아둔 파일을 따로 만들어 모아뒀습니다.

 

Game2048Fun.ts

// 함수 파이핑
const pipe = (...functions: any) => (input: any) =>
  functions.reduce((acc: Function, fn: Function) => fn(acc), input)

// 같은 보드인지
const isSameBoard = (board1: number[][], board2: number[][]): boolean => {
  return board1.every((row, r) => {
    return row.every((n, c) => {
      return board2[r][c] === n
    })
  })
}

function transposeCCW(board: number[][]): number[][] {
  // 행렬 돌리기 (시계 반대)
  let version = board.length || 0
  let newBoard = Array.from(Array(version), () => Array(version).fill(0))
  for (let i = 0; i < version; i++) {
    newBoard[i] = []
    for (let j = 0; j < version; j++) {
      newBoard[i][j] = board[j][version - i - 1]
    }
  }
  return newBoard
}

function transposeCW(board: number[][]): number[][] {
  // 행렬 돌리기 (시계)
  let version = board.length || 0
  let newBoard = Array.from(Array(version), () => Array(version).fill(0))
  for (let i = 0; i < version; i++) {
    newBoard[i] = []
    for (let j = 0; j < version; j++) {
      newBoard[i][version - j - 1] = board[j][i]
    }
  }
  return newBoard
}

// 새로운 숫자 생성 가능한지 테스트
const isGenerateAvailable = (board: number[][]): boolean => {
  let check = false
  board.forEach((row) =>
    row.forEach((n) => {
      if (n == 0) {
        check = true
      }
    }),
  )
  return check
}

// 0 인 자리를 찾아 숫자 생성
export const generateRandom = (board: number[][]): number[][] => {
  if (!isGenerateAvailable(board)) {
    return board
  }
  const version = board.length
  let ranNum = Math.floor(Math.random() * version * version)
  const row = Math.floor(ranNum / version)
  const column = ranNum % version
  if (board[row][column] == 0) {
    board[row][column] = version !== 4 ? version : 2
    return board
  } else {
    return generateRandom(board)
  }
}

// 오른쪽으로 단순 옮기기
export const slideRight = (board: number[][]) => {
  const version = board.length
  const newBoard = board.map((row, r) => {
    let remain = row.filter((n) => n != 0)
    let zero_cnt = version - remain.length
    let newRow = Array(zero_cnt).fill(0).concat(remain)
    return newRow
  })
  return newBoard
}

// 왼쪽으로 단순 옮기기
export const slideLeft = (board: number[][]) => {
  const version = board.length
  return board.map((row, r) => {
    let remain = row.filter((n) => n != 0)
    let zero_cnt = version - remain.length
    let newRow = remain.concat(Array(zero_cnt).fill(0))
    return newRow
  })
}

// slideTop과 Bottom 은 쓸 일이 없다  (회전 방식으로 바꿈.)
// // 위쪽으로 단순 옮기기
// export const slideTop = (board: number[][]) => {
//   return pipe(transposeCCW, slideLeft, transposeCW)(board)
// }

// // 위쪽으로 단순 옮기기
// export const slideBottom = (board: number[][]) => {
//   return pipe(transposeCCW, slideRight, transposeCW)(board)
// }

// 터트리기 (모여있던 같은숫자를 합쳐줍니다.)
export const combineLeft = (board: number[][]) => {
  const version = board.length
  const newBoard = Array.from(board)
  for (let row = 0; row < version; row++) {
    for (let col = 0; col < version - 1; col++) {
      if (newBoard[row][col] === newBoard[row][col + 1]) {
        newBoard[row][col] = newBoard[row][col] + newBoard[row][col + 1]
        newBoard[row][col + 1] = 0
      }
    }
  }
  return newBoard
}

// 오른쪽 버튼을 눌렀을 때
export const moveRight = (board: number[][]) => {
  const nextBoard = pipe(slideRight, combineLeft, slideRight)(board)
  if (isSameBoard(board, nextBoard)) return board // 변화 없으면 그대로
  return generateRandom(nextBoard) // 변화 있으면 숫자 생성
}

// 왼쪽 버튼 눌렀을 때
export const moveLeft = (board: number[][]) => {
  const nextBoard = pipe(slideLeft, combineLeft, slideLeft)(board)
  if (isSameBoard(board, nextBoard)) return board // 변화 없으면 그대로
  return generateRandom(nextBoard) // 변화 있으면 숫자 생성
}

// 위쪽 버튼을 눌렀을 때
export const moveTop = (board: number[][]) => {
  const nextBoard = pipe(transposeCCW, slideLeft, combineLeft, slideLeft, transposeCW)(board)
  if (isSameBoard(board, nextBoard)) return board // 못움직이면 그대로
  return generateRandom(nextBoard)
}

// 아래쪽 버튼 눌렀을 때
export const moveBottom = (board: number[][]) => {
  const nextBoard = pipe(transposeCCW, slideRight, combineLeft, slideRight, transposeCW)(board)
  if (isSameBoard(board, nextBoard)) return board // 못움직이면 그대로
  return generateRandom(nextBoard)
}

// 점수 계산하는 함수
export const calScore = (prev: number[][], now: number[][]) => {
  const Score_prev: any = {}
  const Score_now: any = {}
  let score = 0
  prev.map((row) => {
    // 이전 보드 숫자들 기록
    row.map((num) => {
      Score_prev[num] = Score_prev[num] ? Score_prev[num] + 1 : 1
    })
  })
  now.map((row) => {
    // 현재 보드 숫자들 기록
    row.map((num) => {
      Score_now[num] = Score_now[num] ? Score_now[num] + 1 : 1
    })
  })
  Object.keys(Score_prev).map((num) => {
    let prev_cnt = Score_prev[num]
    let now_cnt = Score_now[num] ? Score_now[num] : 0
    // 터트렸을 때
    if (prev_cnt > now_cnt) {
      let isInitiailNum = num === "2" || num === "3" || num === "5"
      let diff_cnt = isInitiailNum ? prev_cnt - now_cnt + 1 : prev_cnt - now_cnt // 초기값은 랜덤한 위치에 숫자 1개가 새로생긴다
      score += diff_cnt * parseInt(num)
      Score_now[parseInt(num) * 2] -= 1
    }
  })
  return score
}

// 게임 끝났는지 확인
export const isGameOver = (board: number[][]) => {
  if (moveLeft(board) !== board) return false
  if (moveRight(board) !== board) return false
  if (moveTop(board) !== board) return false
  if (moveBottom(board) !== board) return false
  return true
}

export const MOVING_KEYCODE = [37, 38, 39, 40]

// 키 입력받은게 상하좌우중 한개인지
export const isMovingKey = (keycode: number) => {
  return MOVING_KEYCODE.find((element: number) => element === keycode) !== undefined
}

 

 


 

 | 리액트로 2048 만들기 🎮 | 

2020/08/17 - [웹 개발/React] - [React🌀] React로 2048 게임 만들기 ! / 1️⃣ - 기본 세팅 및 소개 / Clone Coding

2020/08/29 - [웹 개발/React] - [React🌀] React로 2048 게임 만들기 ! / 2️⃣ - 화면 레이아웃 잡기

 

 | 리액트 개발 관련 🌀 | 

 

2020/07/30 - [웹 개발/React] - [React🌌] React + Ant Design + TypeScript 적용하기! / antd 사용

2020/01/17 - [웹 개발/React] - [React🌌] 초보를 위한 create-react-app 없이 빌드하기 / ① 설치 및 세팅

2020/07/20 - [웹 개발/React] - [React] 마우스 비밀번호 입력기🖱 / 키보드 보안 입력기 / 2차 비밀번호

 

 

 

 

 

 

 

728x90