[Javascript] 틱택토 게임

hyejinJo·2023년 3월 6일
0
post-thumbnail

틱택토 게임은 오목게임과 룰이 같으며, 여기에선 3목으로 승부한다. 3 * 3 = 9개의 칸 안에 o 와 x 를 턴마다 삽입하여 세 개 연속 같은 모양일 경우 승리하는 게임이다.

웹게임 링크

html, css 코드

<style>
    table {
      border-collapse: collapse;
    }

    td {
      border: 1px solid;
      width: 100px;
      height: 100px;
      text-align: center;
    }
  </style>

</head>
<body>
  <!-- <table>       <= 이런 형태 (js 에서 table 태그 형태를 이용)
    <tr>
      <td></td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td></td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td></td>
      <td></td>
      <td></td>
    </tr>
  </table> -->
</body>
</html>

1. 이차원 배열로 표 만들기, 구조 분해 할당

표를 나타낼 때, 대부분은 자바스크립트의 이차원 배열을 이용하여 표현하면 된다.

const { body, createElement } = document;
// => const body = document.body 와 같은 의미
// '구조 분해 할당' 이라는 문법으로 불림
// 쓰이는 때: 객체의 속성과 담는 변수명이 같을 때
// (const 변수명 = document.속성), (변수명 === 속성)

const $table = document.createElement('table');
const $result = document.createElement('div');
const rows = [];
let turn = 'o'; // 첫 턴은 'o' 로 지정

// table 생성
for (let i = 0; i < 3; i++) {
  const $tr = document.createElement('tr');
  for (let i = 0; i < 3; i++) {
    // tr 이 한 번 생성될 때마다 td 가 그안에 3번 생성
    const $td = document.createElement('td');
    $td.addEventListener('click', (event) => {
      // 칸에 글자가 있는가? 글자가 이미 있으면 종료
      if (event.target.textContent) return;
      event.target.textContent = turn;
      // toggle 로 o, x 턴 바꾸기
      if (turn === 'o') {
        turn = 'x';
      } else if (turn === 'x') {
        turn = 'o';
      }
    });
    $tr.append($td);
  }
  $table.append($tr);
}

// body 에 dom 요소 추가하기
body.append($table);
body.append($result);

const rows = []; : 다음과 같은 배치로 배열을 만들면 좌표를 더 쉽게 찾을 수 있다.

// 아예 데이터 자체를 td 에 넣는 방법
// 3줄 3칸 짜리 표 만들기

arr = [
  [td, td, td],
  [td, td, td],
  [td, td, td]
]
arr[0][1].textContent // 첫 번째 줄의 두 번째 칸

구조 분해 할당

  • 객체의 속성과 담는 변수명이 같을 때 사용된다.
  • "document" 를 생략해 코드를 줄일 때 유용하게 사용된다.
const { body } = document;

ex) 장문의 코드를 같이 줄일 수 있다

// const body = document.body;
// document.body.append($table);
// document.body.append($result);
.
.
.
const { body } = document;
body.append($table);
body.append($result);

객체와 배열도 구조분해할당이 가능하다

const obj = { a: 1, b: 2 }

a = obj.a; 
b = obj.b;

const { a, b } = obj; // 위의 두 줄을 이렇게 한줄로 표현 가능

// 속성 명 = 변수 명
const array = [ 1, 2, 5 ]

const one = array[0];
const two = array[1];
const five = array[2];

const [ one, two, five ] = array; // 위의 세 줄을 이렇게 한줄로 표현 가능

2. 차례 전환하기

내가 누른 칸이 몇 번째 행, 몇 번째 줄인지 지정하여 위치를 파악할 수 있어야 한다.

const { body } = document;

const $table = document.createElement('table');
const $result = document.createElement('div');
const rows = [];
let turn = 'o'; // 첫 턴은 'o' 로 지정

const clickTd = (event) => { // 함수는 위로 빼주기
  // 칸에 글자가 있는가? 글자가 이미 있으면 종료
  if (event.target.textContent !== '') {
    alert('빈 칸에 입력해주세요')
    return
  }
  // o 턴으로 게임 시작
  event.target.textContent = turn;
  // toggle 로 o, x 턴 바꾸기
  // 삼항연산자 turn = (turn === 'o' ? 'x' : 'o')
  if (turn === 'o') {
    turn = 'x';
  } else if (turn === 'x') {
    turn = 'o';
  }
}

// table 생성
for (let i = 0; i < 3; i++) {
  const $tr = document.createElement('tr');
  const cells = [];
  for (let j = 0; j < 3; j++) {
    // 한 번 쓰인 변수는 제외하고 다른 이름의 변수명을 쓰는 것이 좋음(i, j ...)
    // tr 이 한 번 생성될 때마다 td 가 그안에 3번 생성
    const $td = document.createElement('td');
    cells.push($td)
    $td.addEventListener('click', clickTd);
    $tr.append($td);
  }
  $table.append($tr); // 화면상 표시 변경
  rows.push(cells) // 내부 데이터 값 변경
}

// body 에 dom 요소 추가하기
body.append($table);
body.append($result);

3. 이벤트 버블링, 캡쳐링

removeEventListener 를 쓸 때 : 확실하게 해당 이벤트를 완전히 종료하는 경우

clickTd() 콜백 함수를 통해 빈 칸이 아닌 조건 하에 return 을 이용하여 해당하는 하나의 버튼 클릭을 방지했는데, 승부가 결정(무승부, 승리)이 난 후 게임을 아예 종료해야 할 때 버튼을 누를 수 있는 여지를 지우는것이 좋으므로 모든 td 버튼의 이벤트를 종료해야 하는 상황이 필요하다. 이때는 9개의 td 전부에 removeEventListener 를 총 9번 실행시켜주어야 한다.

하지만 굳이 번거롭게 그럴 필요 없이 $td 에 들어가 있던 이벤트를 $table 에 붙여주면 된다.

$td.addEventListener('click', clickTd)$table.addEventListener('click', clickTd)

이때, clickTd() 이벤트는 $table 에 걸었는데 $td 에 이벤트가 발생하는 현상이 나타남 (이벤트 버블링)

이벤트 버블링 :

  • 부모요소에 이벤트를 걸고 자식 요소에 동작을 수행했을 때, 그 요소의 부모, 부모의 부모 등 부모의 태그를 거슬러 올라가면서 이벤트를 찾아 같이 실행됨 (부모 ⇒ 자식 인 반대상황 때는 이벤트 캡쳐링 이라한다.)
  • (td 의 동작이 부모요소인 table 의 이벤트 동작에 인식되면서 실행됨)
  • 이벤트 버블링 : 기본 옵션으로 되어있음

하지만 이때, 자식 요소가 아닌 $table 자체에만 이벤트를 거는 방법이 두 가지가 있다.

  1. event.currentTarget : 이벤트가 걸린 해당 요소만 선택됨
  2. event.stopPropogation(); : 이벤트 버블링 현상 자체를 막는 메서드

이벤트 캡쳐링 :

  • 버블링과 반대로, 부모의 이벤트가 자식으로 간다.
  • 기본 옵션이 아니며, 따로 설정을 해주어야함 $td.addEventListener('click', clickTd, true) ⇒ 기본값은 false 이며, true 로 바꿔주면 됨 false : 버블링 / true : 캡쳐링
  • 보통 팝업(popup)을 닫을 때 사용된다.
const { body } = document;
const $table = document.createElement('table');
const $result = document.createElement('div');
const rows = [];
let turn = 'o'; // 첫 턴은 'o' 로 지정

const clickTd = (event) => {
  // 칸에 글자가 있는가? 글자가 이미 있으면 종료
  // if (event.target.textContent) return;
  if (event.target.textContent !== '') { // $td 의 타겟
    alert('빈 칸에 입력해주세요');
    return;
    // event.currentTarget => $table 의 타겟
  }
  // o 턴으로 게임 시작
  event.target.textContent = turn;
  // toggle 로 o, x 턴 바꾸기
  // 삼항연산자 turn = (turn === 'o' ? 'x' : 'o')
  if (turn === 'o') {
    turn = 'x';
  } else if (turn === 'x') {
    turn = 'o';
  }
};

// table 생성
for (let i = 0; i < 3; i++) {
  const $tr = document.createElement('tr');
  const cells = [];
  for (let j = 0; j < 3; j++) {
    // 한 번 쓰인 변수는 제외하고 다른 이름의 변수명을 쓰는 것이 좋음(i, j ...)
    // tr 이 한 번 생성될 때마다 td 가 그안에 3번 생성
    const $td = document.createElement('td');
    cells.push($td);
    // $td.addEventListener('click', clickTd);
    $tr.append($td);
  }
  $table.append($tr); // 화면상 표시 변경
  rows.push(cells); // 내부 데이터 값 변경
}
$table.addEventListener('click', clickTd)

// body 에 dom 요소 추가하기
body.append($table);
body.append($result);

4. 승부 판단하기

  • 내가 클릭한 td 칸의 위치를 배열의 index 값을 통해 좌표형식 저장한다. (ex. 3번째 줄, 2번째 칸)
  • 배열로 얻은 좌표를 이용하여 승부의 여부를 결정시킨다.
  • 무승부의 조건은 td 에 빈칸이 모두 채워졌을 때를 조건으로 설정한다.
현재 rows[] 의 형태

rows = [
  [$td, $td, $td],
  [$td, $td, $td],
  [$td, $td, $td]
]
const {
  body
} = document;
const $result = document.createElement('div');
const rows = [];
let turn = 'o'; // 첫 턴은 'o' 로 지정

const checkWinner = (target) => {
  // target: 클릭된 td
  let rowIndex;
  let cellIndex;
  // 이중 반복문 (이차원 배열에서 자주 등장)
  rows.forEach((row, ri) => { // 줄 묶음에서 몇 번째 줄인가?
    row.forEach((cell, ci) => { // 줄에서 몇 번째 칸인가?
      if (cell === target) {
        // forEach() 를 통해 td 가 차례로 삽입되고, 클릭된 td 와 일치하는 td 가
        // 삽입되는 순간 해당 td 의 줄과 칸의 인덱스 값이 저장됨
        rowIndex = ri; // 몇 번째 줄
        cellIndex = ci; // 몇 번째 칸
      }
    });
  });

  // 세 칸이 다 채워졌나?
  let hasWinner = false; // boolean 검사를 할 시 항상 기본값은 false
  if (
    // 가로 줄 검사
    rows[rowIndex][0].textContent === turn &&
    rows[rowIndex][1].textContent === turn &&
    rows[rowIndex][2].textContent === turn
  ) {
    hasWinner = true;
  }

  // 세로 줄 검사
  if (
    rows[0][cellIndex].textContent === turn &&
    rows[1][cellIndex].textContent === turn &&
    rows[2][cellIndex].textContent === turn
  ) {
    hasWinner = true;
  }

  // 대각선 줄 검사
  if (
    rows[0][0].textContent === turn &&
    rows[1][1].textContent === turn &&
    rows[2][2].textContent === turn
  ) {
    hasWinner = true;
  }
  if (
    rows[0][2].textContent === turn &&
    rows[1][1].textContent === turn &&
    rows[2][0].textContent === turn
  ) {
    hasWinner = true;
  }
  return hasWinner; // 승자가 있으면 true, 없으면 false
};

const clickTd = (event) => {

  if (event.target.textContent !== '') {
    alert('빈 칸에 입력해주세요');
    return;
    // event.currentTarget => $table 의 타겟
  }

  event.target.textContent = turn;
  if (checkWinner(event.target)) { // 승부 판단하기
    $result.textContent = `${turn} 님의 승리 !`;
    $table.removeEventListener('click', clickTd);
    // td 에 이벤트를 걸었다면 일일히 9개의 td 에 
    // removeEventListener 를 삽입해야 했을 것이다.
    return;
  }

  // 위의 checkWinner()에서 false 가 나오면 다음 나올 동작의 조건: 
  // 무승부 검사
  let draw = true;
  rows.forEach((row) => {
    row.forEach((cell) => {
      if (!cell.textContent) { // td 에 하나라도 빈칸이 있으면
        draw = false;
      }
    })
  })
  if (draw) { // td 칸이 모두 꽉 차있으면
    $result.textContent = `무승부`;
    return;
  }

  // 삼항연산자 turn = (turn === 'o' ? 'x' : 'o')
  if (turn === 'o') {
    turn = 'x';
  } else if (turn === 'x') {
    turn = 'o';
  }
};

// table 생성
...

5. 부모자식 관계, 유사배열, every, some, flat

전 챕터에서 우리는 클릭된 td (target) 의 위치값을 구하기 위해 forEach 문을 사용했다. 하지만 그럴 필요 없이, 이미 event.target 은 자신의 index 값을 알고 있다. 다음의 코드를 수정해보자

// 수정 전 코드

const checkWinner = (target) => {
  let rowIndex;
  let cellIndex
  rows.forEach((row, ri) => { // 줄 묶음에서 몇 번째 줄인가?
    row.forEach((cell, ci) => { // 줄에서 몇 번째 칸인가?
      if (cell === target) {
        rowIndex = ri; // 몇 번째 줄
        cellIndex = ci; // 몇 번째 칸
      }
    });
  });
.
.
.
// 수정 후 코드

const checkWinner = (target) => {
  // target: 클릭된 td
  // td 의 부모 태그(tr)의 index 값
  const rowIndex = target.parentNode.rowIndex;
  // 클릭된 td 의 cell index 값
  const cellIndex = target.cellIndex;

parentNode 은 어떤 태그의 부모태그를 가져온다

rows[0][0] //<td>o</td>
rows[0][0].parentNode //tr
rows[0][0].parentNode.parentNode // table
rows[0][0].parentNode.parentNode.parentNode // body

parentNode 의 반대는 .children 로, 자식요소를 가져오고 배열처럼 생긴 유사객체이다.

document.body.children

// 결과 
HTMLCollection(2) [table, div]
0: table
1: div
length: 2
[[Prototype]]: HTMLCollection

document.body.children[0] => <table>...</table>

document.body.children[0].children => HTMLCollection(3) [tr, tr, tr]

배열이 아니므로 forEach() 가 적용되지 않는데, 이때 배열로 바꿔주는 메서드를 사용할 수 있다.

Array.from 으로 감싸면 배열로 바뀌고, forEach() 를 적용시킬 수 있다.

~~document.body.children[0].children.forEach(()=> {})~~ // => 에러 작동
Array.from(document.body.children[0].children)
(3) [tr, tr, tr]
// HTMLCollection 와 같은 문구가 없이, 완벽한 배열이 되었다.

Array.from(document.body.children[0].children).forEach((i)=> {console.log(i)}) 
// => 정상 작동

다음의 무승부 검사 코드는 forEach() 를 쓰면 빈칸을 한번 찾았음에도 나머지 요소들을 전부 검사하고자 반복문을 계속 돌기 때문에 비효율적인 코드라 할 수 있다. 따라서 모든 배열 요소를 판별해주는 every 라는 메서드를 사용한다.

하지만 every 는 일차원 배열때만 쓸 수 있는데, 이때 이차원 배열을 일차원 배열로 펴주는 역할을 하는 flat 를 쓴다.

조건: 빈 칸이 하나라도 있으면 false

// 수정 전 코드

// 무승부 검사
  let draw = true;
  rows.forEach((row) => {
    row.forEach((cell) => {
      if (!cell.textContent) { // td 에 하나라도 빈칸이 있으면
        draw = false;
      }
    })
  })
  if (draw) { // td 칸이 모두 꽉 차있으면
    $result.textContent = `무승부`;
    return;
  }

flat : 배열의 차원을 한단계 내려주는 메서드이다. (2차원 배열 ⇒ 1차원 배열 / 3차원 배열 ⇒ 2차원 배열)

rows = [
	0: (3) [td, td, td]
	1: (3) [td, td, td]
	2: (3) [td, td, td]
]

rows.flat() = [td, td, td, td, td, td, td, td, td]

every :

배열 안의 모든 요소가 주어진 판별 함수를 통과하는지 테스트 해주는 메서드로, Boolean 값을 반환하며 1차원 배열에만 적용이 가능하다 (모두)

⇒ 모두가 true 면 true, 하나라도 false 면 false

array.every((arr) => true / false 값이 들어감)
rows.flat().every((td) => td.textContent)
  // => td 의 textContent 가 '모두' 존재 해야 true
  // 첫 칸부터 빈칸이면 바로 false 가 출력되고 그후로 반복문이 재개되지 x

some :

배열 안의 어떤 요소라도 주어진 판별 함수를 통과하는지 테스트 (every 와 반대 개념, 하나라도) 해주며, 빈 배열에서 호출하면 무조건 false 를 반환한다.

⇒ 하나라도 true 면 true, 모두다 false 면 false

array.some((arr) => true / false 값이 들어감)
rows.flat().some((td) => td.textContent) 
  // => td 의 textContent 가 '하나라도' 존재 하면 true

최종 코드

// 수정 후 코드	

// 무승부 검사
  const draw = rows.flat().every((cell) => cell.textContent)
  // => td 의 textContent 가 '모두' 존재 해야 true
  // 첫 칸부터 빈칸이면 바로 false 가 출력되고 그후로 반복문이 재개되지 x

  // rows.flat().some((cell) => {cell.textContent})
  // => td 의 textContent 가 '하나라도' 존재 하면 true

  if(draw) {
    $result.textContent = `무승부`;
    return;
  }

6. 컴퓨터의 턴 만들기 (컴퓨터와 대결)

지금 껏 혼자하는 방식이었지만, 상대가 있어야 재미가 있어진다. 인공지능까지는 못만들지만, 비어있는 칸에 무작위로 x 를 채우게 하는 동작은 수행할 수 있다.

컴퓨터로 하여금 랜덤으로 게임에 참여하게 해보자. ⇒ 비어있는 칸을 모아 배열로 만든 후, 그 중 랜덤으로 하나의 요소를 무작위로 뽑아 td 에 x 를 넣어준다. 그리고 똑같이 승부, 무승부를 가리게 한다.

이때 승부와 무승부를 가리는 함수가 중복되므로 (o 턴때도 승부를 가리고 x 턴 때도 승부를 가림), 그 둘을 묶어 승부와 무승부를 동시에 가리는 기능을 checkWinnerAndDraw() 함수로 묶어준다.

const {
  body
} = document;
// => const body = document.body 와 같은 의미
// '구조 분해 할당' 이라는 문법으로 불림
// 쓰이는 때: 객체의 속성과 담는 변수명이 같을 때
// (const 변수명 = document.속성), (변수명 === 속성)
const $table = document.createElement('table');
const $result = document.createElement('div');
const rows = [];
let turn = 'o'; // 첫 턴은 'o' 로 지정

// 현재 rows[] 의 형태
// rows = [
//   [$td, $td, $td],
//   [$td, $td, $td],
//   [$td, $td, $td]
// ]

// (3) 승부, 무승부가 났는지 체크
const checkWinnerAndDraw = (target) => { // 리팩토링
  // 승부 판단하기
  const hasWinner = checkWinner(target);
  // 승자가 있으면
  if (hasWinner) {
    $result.textContent = `${turn} 님의 승리 !`;
    $table.removeEventListener('click', clickTd);
    return;
  }

  // 승자가 없으면 (무승부)
  const draw = rows.flat().every((cell) => cell.textContent);
  if (draw) {
    $result.textContent = `무승부`;
    return;
  }

  // 승부도, 무승부도 아닐 때 
  turn = turn === 'x' ? 'o' : 'x';
}

// (2) 승부가 났는지 체크
const checkWinner = (target) => {...};

// (1) 클릭했을 때 이벤트 => (4)
const clickTd = (event) => {
  // 클릭한 칸이 빈칸이 아닐 때
  if (event.target.textContent !== '') {
    alert('빈 칸에 입력해주세요');
    return;
  }

  // 빈칸일 때
  // 내(o) 차례 ----------------------------------
  event.target.textContent = turn;

  // 승부 판단하기
  checkWinnerAndDraw(event.target);

  // 컴퓨터(x) 차례 ----------------------------------
  if (turn === 'x') {
    setTimeout(() => {
      // 빈칸(textContent 가 없는)인 td 만을 색출하여 배열로 만듬
      // => (빈칸인 td 만 모여있는 배열)
      const emptyCells = rows.flat().filter((v) => !v.textContent);
      // 빈칸인 td 만 모여있는 배열 중 하나의 배열 요소를 랜덤으로 뽑아 저장
      const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];

      // 빈칸일 때
      randomCell.textContent = turn; // turn 이라고 해도되지 않을까..?

      // 승부 판단하기
      checkWinnerAndDraw(randomCell);
    }, 1000)
  }
};

// table 생성
...

7. 생각하는 척 하는 컴퓨터 만들기

랜덤으로 빈칸에 x 가 넣어질 때 바로 그려지기 때문에 정신이 없다. setTimeout 으로 화면에 x 가 그려지는 텀을 넣어주자.

setTimeout 으로 1초뒤에 x 를 넣는 기능을 추가했지만, 이때 내가 마구잡이로 빈칸에 클릭을 해버리면 1초 뒤에 값을 넣는 컴퓨터는 그 속도를 따라오지 못해 버그가 걸려버린다. 이를 해결하려면, flag 변수를 이용해 클릭할 수 있는 환경에 대한 여부를 조정해보자

⇒ 타이머 함수를 쓸 때 flag 변수를 이용하여 중간에 클릭을 방지하는 해결법은 많이 쓰이는 방법이다.

// (1) 클릭했을 때 이벤트 => (4)
let clickable = true;
const clickTd = (event) => {
  // setTimeout 이 돌아가는 동안 클릭막기
  if (!clickable) {
    return;
  } 

  // 클릭한 칸이 빈칸이 아닐 때
  // if (event.target.textContent) return;
  if (event.target.textContent !== '') {
    alert('빈 칸에 입력해주세요');
    return;
    // event.currentTarget => $table 의 타겟
  }

  // 빈칸일 때
  // 내(o) 차례 ----------------------------------
  event.target.textContent = turn;
  // 승부 판단하기
  checkWinnerAndDraw(event.target)

  // 컴퓨터(x) 차례 ----------------------------------
  if (turn === 'x') {
      // 빈칸(textContent 가 없는)인 td 만을 색출하여 배열로 만듬
      // => (빈칸인 td 만 모여있는 배열)
      const emptyCells = rows.flat().filter((v) => !v.textContent);
      // 빈칸인 td 만 모여있는 배열 중 하나의 배열 요소를 랜덤으로 뽑아 저 장
      const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];

      clickable = false;
      setTimeout(() => {
        // 빈칸일 때
        randomCell.textContent = turn; // turn 이라고 해도되지 않을까..?
        // 승부 판단하기
        checkWinnerAndDraw(randomCell);
        // setTimeout 1초 뒤에 클릭 가능
        clickable = true;
    }, 1000)
  }
};
profile
FE Developer 💡

0개의 댓글