7 minute read

부대에서 자바스크립트와 크롬으로 만든 게임들

부대에 있을 때 코로나19 때문에 병사들이 휴가도 못 나가고 일도 없어서 지루한 시간을 보내고 있었다.

모두가 힘든 상황에서 뭘 할 수 있을까 고민하다가 자바스크립트로 게임을 만들기 시작했고

게임 공장처럼 평균 2주에 한 개씩 찍어낸 게임이

  • 스와이프 벽돌 깨기
  • 2048
  • 장기
  • 오목
  • 테트리스
  • 미로 찾기
  • 스네이크
  • 스택

으로 총 8개에 트렐로 클론까지 만들었다.

덕분에 선후임들은 신나게 일과시간을 녹일 수 있었다.

개발환경

  • 크롬 브라우저 + 메모장

  • Google, Stack Overflow 사용 불가

  • NPM 라이브러리 사용 불가

  • IDE 사용 불가

제한사항

게시판에 파일 하나만 업로드할 수 있어서 모든 코드와 자원은 html 파일 하나에 들어가야 했다.

생각이 있었다면, 인트라넷에 있는 파이썬으로 css, js을 html 파일 하나로 모아주는 번들러를 만들었을 것 같다.

 

게임 소개

스와이프 벽돌 깨기

 

sbb

자바스크립트 버전은 세상에서 유일한 것 같다.

  • 있었으면 그냥 배꼈을 텐데, 없어서 게임을 백번 씩 플레이 해보고 코드, 로직 하나하나 직접 만들었다.

공 발사는 예전에 만들었던 당구 게임에서 착안했다.

  • 공 발사 각도는 두 점으로 각도를 구하는 아크 탄젠트(Math.atan2) 함수를 사용해서 구했다.

  • 그 뒤로 공 벽 반사, 공과 벽돌 충돌 감지, 보너스 공, 벽돌과 바닥 충돌 감지 등의 기능을 하나씩 추가하다보니 완성했다.

객체 지향으로 코드 구조화

SBB-diagram

  • 명령형으로 작성한 코드가 길어질 수록 이해하기 힘들고 스크롤만 위아래로 왔다갔다하는 일이 많아졌다.

  • 그래서 기능별로 코드를 쪼개고 계층화하고 권한을 위임했다.

  • Ball들은 Balls 클래스, Brick들은 Bricks 클래스에 들어있고

  • 각 요소 간 상호작용과 렌더링은 GameManager 클래스에서 담당한다.

객체 지향으로 구조를 잡고 함수형으로 메서드를 구현하는 방식이 나름 합리적인 개발 방식인 것 같다.

이외에

  • 파티클 시스템으로 Brick이 깨졌을 때 타격감 있는 이펙트를 제공한다.

  • 오리지날에 없는 공 내리기 기능으로 게임 진행 속도를 높였다.

  • 후임이 당직 때 10시간 동안 플레이해서 997점 달성했다. (내가 본 최고점수)

 

장기

 

janggji

말의 경로를 함수형 프로그래밍으로 모델링했다.

  • for 문과 변수가 없다.

  • 코드도 짧아서 경로 계산만 100줄 내외

    경로 계산 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
     calcPath () {
          const canForward = ([x, y]) => !table[y][x] || (table[y][x].team !== this.team && 'stop');
          const isEmptyPlace = ([x, y]) => !table[y][x];
          const isEnemy = ([x, y]) => table[y][x] && table[y][x].team !== this.team;
          const isEmptyOrEnemy = path => isEmptyPlace(path) || isEnemy(path);
          const toAbsoultePos = ([x, y]) => [this.x + x, this.y + y];
     
          if (this.text === '兵') {
              const paths = [
                  [[-10], [10], [01]].map(toAbsoultePos).filter(isInBoard).filter(canForward),
                  isIn([[37], [57]])(this.pos) ? [[48]].filter(canForward) : [],
                  this.posIs([48]) ? [[39], [59]].filter(canForward) : [],
              ].flat()
     
              return paths
          }
     
          if (this.text === '卒') {
              const paths = [
                  [[-10], [10], [0-1]].map(toAbsoultePos).filter(isInBoard).filter(canForward),
                  isIn([[32], [52]])(this.pos) ? [[41]].filter(canForward) : [],
                  this.posIs([41]) ? [[30], [50]].filter(canForward) : [],
              ].flat()
     
              return paths
          }
     
          else if (this.text === '漢' || this.text === '楚' || this.text === '士') {
              const isInPalace = this.team === 'red' ?
                  ([x, y]) => 3 <= x && x <= 5 && 0 <= y && y <= 2 :
                  ([x, y]) => 3 <= x && x <= 5 && 7 <= y && y <= 9;
     
              const isPalaceSideMiddle = this.team === 'red' ?
                  ([x, y]) => (y === 1 && (x === 3 || x === 5)) || (x === 4 && (y === 0 || y === 2)) :
                  ([x, y]) => (y === 8 && (x === 3 || x === 5)) || (x === 4 && (y === 7 || y === 9))
     
              const paths = [[-1-1], [-10], [-11], [0-1], [01], [1-1], [10], [11]]
                  .map(toAbsoultePos)
                  .filter(isInPalace)
                  .filter(([x, y]) => !isPalaceSideMiddle(this.pos) || !isPalaceSideMiddle([x, y]))
                  .filter(canForward)
     
              return paths
          }
     
          else if (this.text === '車') {
              const paths = [
                  range(this.x - 10).map(x => [x, this.y]).filter(isInBoard).takeWhile(canForward),
                  range(this.x + 18).map(x => [x, this.y]).filter(isInBoard).takeWhile(canForward),
                  range(this.y - 10).map(y => [this.x, y]).filter(isInBoard).takeWhile(canForward),
                  range(this.y + 19).map(y => [this.x, y]).filter(isInBoard).takeWhile(canForward),
                  this.posIs([37]) ? [[48], [59]].takeWhile(canForward) : [],
                  this.posIs([39]) ? [[48], [57]].takeWhile(canForward) : [],
                  this.posIs([57]) ? [[48], [39]].takeWhile(canForward) : [],
                  this.posIs([59]) ? [[48], [37]].takeWhile(canForward) : [],
                  this.posIs([48]) ? [[37], [39], [57], [59]].filter(canForward) : [],
                  this.posIs([30]) ? [[41], [52]].takeWhile(canForward) : [],
                  this.posIs([32]) ? [[41], [50]].takeWhile(canForward) : [],
                  this.posIs([50]) ? [[41], [32]].takeWhile(canForward) : [],
                  this.posIs([52]) ? [[41], [30]].takeWhile(canForward) : [],
                  this.posIs([41]) ? [[30], [32], [50], [52]].filter(canForward) : [],
              ].flat()
     
              return paths
          }
     
          else if (this.text === "包") {
              const calcPoPath = pathList => pathList
                  .filter(isInBoard)
                  .skipWhile(([x, y]) => !table[y][x] || (table[y][x] && table[y][x].text === '包'  && 'skipAll'))
                  .slice(1)
                  .takeWhile(([x, y]) => !table[y][x] || table[y][x].text !== '包' && (table[y][x].team !== this.team && 'stop'));
     
              const paths = [
                  calcPoPath(range(this.y - 10).map(y => [this.x, y])),
                  calcPoPath(range(this.y + 19).map(y => [this.x, y])),
                  calcPoPath(range(this.x - 10).map(x => [x, this.y])),
                  calcPoPath(range(this.x + 18).map(x => [x, this.y])),
                  this.posIs([37]) ? calcPoPath([[48], [59]]) : [],
                  this.posIs([39]) ? calcPoPath([[48], [57]]) : [],
                  this.posIs([57]) ? calcPoPath([[48], [39]]) : [],
                  this.posIs([59]) ? calcPoPath([[48], [37]]) : [],
                  this.posIs([30]) ? calcPoPath([[41], [52]]) : [],
                  this.posIs([32]) ? calcPoPath([[41], [50]]) : [],
                  this.posIs([50]) ? calcPoPath([[41], [32]]) : [],
                  this.posIs([52]) ? calcPoPath([[41], [30]]) : [],
              ].flat()
     
              return paths
          }
     
          else if (this.text === '馬') {
              const paths = [[0-1], [-10], [01], [10]]
                  .flatMap(p => [[p, p.map(i => i || -1)], [p, p.map(i => i || 1)]])
                  .map(([[x1, y1], [x2, y2]]) =>  [[x1, y1], [x1 + x2, y1 + y2]])
                  .map(ps => ps.map(toAbsoultePos))
                  .filter(ps => ps.every(isInBoard))
                  .filter(([[x1, y1], [x2, y2]]) => isEmptyPlace([x1, y1]) && isEmptyOrEnemy([x2, y2]))
                  .map(i => i[1])
     
              return paths
          }
     
          else if (this.text === '象') {
              const paths = [[0-1], [-10], [01], [10]]
                  .flatMap(p => [[p, p.map(i => i || -1), p.map(i => i || -1)], [p, p.map(i => i || 1), p.map(i => i || 1)]])
                  .map(([[x1, y1], [x2, y2], [x3, y3]]) => [[x1, y1], [x1 + x2, y1 + y2], [x1 + x2 + x3, y1 + y2 + y3]])
                  .map(ps => ps.map(toAbsoultePos))
                  .filter(ps => ps.every(isInBoard))
                  .filter(([[x1, y1], [x2, y2], [x3, y3]]) =>
                      isEmptyPlace([x1, y1]) && isEmptyPlace([x2, y2]) && isEmptyOrEnemy([x3, y3])
                  )
                  .map(i => i[2])
     
              return paths
          }
     
          return []
      }
    cs

CSS 변수를 사용했고 이 변수에 다른 변수들이 의존하게 했다.

  • 그래서 값 하나가 바뀌면 다른 값도 자동으로 바뀐다. (forward reference)

  • 하드 코딩을 했다면 값을 하나 바꾸기 위해 프로그램 전체를 뒤지며 나머지 값을 일일이 바꿔야 한다.

인공지능과 대전하기 기능을 추가하려고 했다.

  • 자신에겐 유리하고 상대에겐 불리한 선택 경로를 찾는 미니맥스 알고리즘과 불필요한 경로는 무시하는 가지치기 알고리즘을 공부했다.

  • 한편, 프로그램의 모델 부분과 뷰 부분이 강결합돼있는 바람에 미니맥스 알고리즘을 구현하려면 코드를 다 갈아엎어야 돼서 포기했다.

  • 다음부턴 뷰와 모델 사이에 컨트롤러를 꼭 추가해야겠다.

 

오목

 

gomoku

바둑 판자 / 바둑 선 / 바둑알 놓는 곳 3개로 레이어를 나눠서 쉽게 UI를 구현했다.

  • 다른 구현을 보면 위 3개를 한 번에 하려다 보니 코드가 길고 보기 어렵다.

승리 확인 부분도 함수형 프로그래밍으로 map, filter, reduce만 사용해서 구현했다.

승리 확인 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 상하좌우+대각선 5줄이 모두 같은 색이면 승리
 
checkWin() {
  const turnPositions = this.grid.flatMap((row, y) =>
    row.reduce(
      (acc, turn, x) => (turn === this.turn ? [...acc, [x, y]] : acc),
      []
    )
  );
 
  for (const [x, y] of turnPositions) {
    const right = zip(range(x, x + 4), repeat5(y));
    const left = zip(range(x, x - 4), repeat5(y));
    const top = zip(repeat5(x), range(y, y + 4));
    const bottom = zip(repeat5(x), range(y, y - 4));
    const bottomLeft = zip(range(x, x + 4), range(y, y + 4));
    const bottomRight = zip(range(x, x - 4), range(y, y + 4));
    const topRight = zip(range(x, x + 4), range(y, y - 4));
    const topLeft = zip(range(x, x - 4), range(y, y - 4));
 
    const toTurn = ([x, y]) => this.grid[y][x];
    const isSameTurn = (turn) => turn === this.turn;
 
    const fiveInRow = !![right, left, top, bottom, topRight, topLeft, bottomRight, bottomLeft]
      .filter((poss) => poss.every(isInBoard))
      .filter((poss) => poss.map(toTurn).every(isSameTurn)).length;
 
    if (fiveInRow) {
      return ui.win(this.turn);
    }
  }
}
cs

 

놓은 바둑알을 순서대로 스택에 넣어서 물리기 기능을 추가했다.

  • undo 기능을 처음으로 구현해봤다.

 

테트리스

 

tetris

100줄 테트리스의 로직과 데릭 아저씨의 테트리스의 UI를 합쳤다.

  • 100줄 테트리스는 함수형 프로그래밍스러운 로직을 짜서 코드 이해가 쉽지만 UI가 아쉽다.

  • 데릭 아저씨의 테트리스는 UI가 보기는 좋지만 로직에 오류가 있다.

  • 두 코드를 믹스 & 매치했다.

  • 남이 잘 짜놓은 코드 가져다가 내 것으로 만드는게 제일 행복하다.

다음 블록보기, 블록 한번에 내리기 기능 추가

  • 후임들의 요청으로 넣은 기능이다.

 

스택!

 

stack

Pure CSS Stack에 영감을 받았다.

  • Pure CSS Stack은 HTML과 CSS만 사용해서 3D Stack 애니메이션을 구현했다.

  • 대신에 50칸 밖에 쌓을 수 없어서 JS 버전으로 새로 만들었다.

  • three.js 없이 바닐라 Canvas로는 3D 구현이 어려워서 2D로 구현했다.

  • 3D 구현을 위해서는 3D 공간을 모델링하고 이를 레이 트레이싱한 것을 2D 캔버스에 투영하거나

  • WebGL을 사용해야하는데 이렇게 하려면 전문하사 신청해야 끝낼 수 있어서 안 했다.

게임 로직 만드는 데 3일 밖에 안 걸렸지만 게임 종료 애니메이션을 만드는 데 일주일 걸렸다.

stack end

  • 게임이 끝나면 화면이 서서히 축소되면서 지금까지 쌓아올린 블록을 보여준다.

  • 캔버스의 scaletranslate를 프레임마다 적절히 조정하는 게 어려웠다.

 

미로 찾기

 

maze

  • 리얼월드 알고리즘 1장, DFS로 미로 생성 프로그램을 만들기 과제를 풀다가 만들었다.

    스택기반 DFS는 코드가 정말 짧다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
      const initialCell = grid[0][0]
     
      initialCell.visit()
     
      const stack = [initialCell]
     
      let currentCell
     
      while (stack.length) {
        currentCell = stack.pop()
     
        const neighborCell = currentCell.randomNeighbor(grid)
     
        if (neighborCell) {
          stack.push(currentCell)
          removeWall(currentCell, neighborCell)
          neighborCell.visit()
          stack.push(neighborCell)
        }
      }
    cs
  • 프린터로 출력해서 펜으로 풀 수 있게 인쇄하기 버튼을 추가했다.

  • 사진처럼 방향키로 미로 탐색도 가능하다.

 

트렐로 클론

canban1

canban2

오프라인 저장을 위해 html을 통짜로 덤프 떠서 LocalStorage에 저장한다.

  • 다시 불러올 땐 html 덤프를 컨테이너의 innerHTML으로 설정하고 이벤트 리스너만 붙이면 끝

  • SPA 프레임워크를 쓴다면 이런 방식이 불가능하다.

CSS 변수를 사용해서 간단하게 다크 모드를 구현했다.

 

이외에

2048

 

canban2

싸지방에서 인쇄한 2048 코드를 하나하나 입력해서 구현했다.

기존 프로토타입 기반 코드를 ES6 클래스 문법으로 바꾸고

메서드는 함수형으로 리팩터링했더니 1000줄짜리 코드가 700줄로 줄였다.

 

스네이크

 

canban2

33줄로 구현하는 리액트를 구현하고

블로그에 예제로 나온 스네이크 게임도 그대로 가져다가 구현했다.

기존 코드에 최고 점수 기능, PC에 맞게 CSS 레이아웃을 추가했다.

느낀 점

  • 남들 다 장기나 공 튀기기 하나씩 만들길래 게임 제작을 쉽게 봤는데 힘든 점이 많았다.

  • 근데 하다보면 된다.

  • 크롬 최고