틱택토 게임은 오목게임과 룰이 같으며, 여기에선 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>
표를 나타낼 때, 대부분은 자바스크립트의 이차원 배열을 이용하여 표현하면 된다.
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; // 위의 세 줄을 이렇게 한줄로 표현 가능
내가 누른 칸이 몇 번째 행, 몇 번째 줄인지 지정하여 위치를 파악할 수 있어야 한다.
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);
removeEventListener
를 쓸 때 : 확실하게 해당 이벤트를 완전히 종료하는 경우
clickTd()
콜백 함수를 통해 빈 칸이 아닌 조건 하에 return
을 이용하여 해당하는 하나의 버튼 클릭을 방지했는데, 승부가 결정(무승부, 승리)이 난 후 게임을 아예 종료해야 할 때 버튼을 누를 수 있는 여지를 지우는것이 좋으므로 모든 td
버튼의 이벤트를 종료해야 하는 상황이 필요하다. 이때는 9개의 td
전부에 removeEventListener
를 총 9번 실행시켜주어야 한다.
하지만 굳이 번거롭게 그럴 필요 없이 $td
에 들어가 있던 이벤트를 $table
에 붙여주면 된다.
$td.addEventListener('click', clickTd)
⇒ $table.addEventListener('click', clickTd)
이때, clickTd() 이벤트는 $table
에 걸었는데 $td
에 이벤트가 발생하는 현상이 나타남 (이벤트 버블링)
이벤트 버블링 :
하지만 이때, 자식 요소가 아닌 $table
자체에만 이벤트를 거는 방법이 두 가지가 있다.
event.currentTarget
: 이벤트가 걸린 해당 요소만 선택됨 event.stopPropogation();
: 이벤트 버블링 현상 자체를 막는 메서드이벤트 캡쳐링 :
$td.addEventListener('click', clickTd, true)
⇒ 기본값은 false 이며, true 로 바꿔주면 됨 false : 버블링 / true : 캡쳐링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);
현재 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 생성
...
전 챕터에서 우리는 클릭된 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;
}
지금 껏 혼자하는 방식이었지만, 상대가 있어야 재미가 있어진다. 인공지능까지는 못만들지만, 비어있는 칸에 무작위로 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 생성
...
랜덤으로 빈칸에 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)
}
};