바닐라js로 틱택토를 만들면서 기본기를 다져보자.
틱택토는 오목과 룰이 동일하나, 가로, 세로, 대각선 중 한 방향으로 5개를 자기 것으로 칠해야하는 오목과 달리, 3개만 자기 것으로 칠해도 되는 룰을 가진다.
여기서 사용되는 문법은 다음과 같다. (모르더라도 이번 포스팅을 보면서 알면 될 듯하다.)
- querySelector, createElement
html 태그를 선택해주는 querySelector, 또는 html 태그를 추가해주는 createElement.
html 파일을 생성해서 <div><table><tr></tr> ... 와 같은 html 파일을 만들어도 되나, 여기서는 js로 대체하도록 한다.
만약 html 파일을 생성해서 진행한다면 querySelector로 태그를 선택하면 된다.
- 콜백함수와 고차함수 개념
고차함수가 말이 거창해보이지, 사실상 아래 코드와 같다.
// callback함수 설정
$td.addEventListener('click', selectCol(i, j));
// selectCol 함수
const selectCol=(i, j)=>(e)=>{
// ...
}
위와 같이 화살표함수가 두 개로 이루어진 함수를 의미하는데,
이번 포스팅에서 왜 이렇게 했는지 설명할 예정이다.
- 이차원배열을 다루는 알고리즘
틱택토이니 당연하다. 별찍기를 자유자재로 다룰 수준이면 충분할 듯하다.
(재귀가 필요한 어려운 별찍기 제외...)
- addEventListener
click할 때마다 이벤트를 발생시켜야하므로 당연히 필요하다.
이 개념을 잘 모른다면 틱택토를 만들기 어렵다.
*유튜브 조현영님의 ES2021 강좌를 공부하면서 만든 틱택토입니다. 이 코드에서 추가 및 수정작업을 거쳤습니다.*
Html, CSS
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>틱택토</title>
<style>
table{
border-collapse: collapse;
}
td{
border: 1px solid black;
width: 40px;
height: 40px;
text-align: center;
}
</style>
</head>
<body>
<script>
</script>
</body>
</html>
심플하다.
테이블 구조 생성
const { body }=document;
// 태그 생성 및 선택
const $table=document.createElement('table');
const $result=document.createElement('div');
const rows=[];
// turn: 현재 turn이 O의 차례인지 X의 차례인지
// count: 9칸 중 몇 칸이 채워졌는지
// finished: 게임이 끝났는지
let turn='O', count=0, finished=false;
// rows에 행과열을 추가해주자. 즉, 이차원배열 형태로 하자.
// [[td, td, td], [td, td, td], [td, td, td]]
for(let i=0;i<3;i++){
const $tr=document.createElement('tr');
const cells=[];
for(let j=0;j<3;j++){
const $td=document.createElement('td');
cells.push($td);
// 클릭 이벤트
$td.addEventListener('click', selectCol(i, j));
$tr.append($td);
}
rows.push(cells);
$table.append($tr);
}
body.append($table);
body.append($result);
body 태그를 아예 객체 비구조화 할당으로 받았다.
const body = document.body; 코드와 같다.
클릭 이벤트가 발생하면, selectCol(i, j) 콜백함수를 호출한다.
여기서 고차함수 개념이 쓰이는데, 왜 쓰이는지 말하자면, eventListener에서 selectCol(i, j)가 위치하는 곳에 함수를 호출하면 안된다.
예를 들어서 selectCol 함수가 파라미터를 넘겨주지 않아도 되는 함수였다면 selectCol()이 아닌 selectCol로 작성했을 것이다. 만약 selectCol()로 썼다면 함수를 호출한 것이기 때문에 selectCol 함수가 콜백으로 진행되는 것이 아닌, 함수에서 return한 것을 받는다. 즉, 콜백은 실행되지 않고 undefined 또는 true false와 같은 return한 변수값을 실행하는 것이다.
그런데, 우리는 selectCol 함수에 인자로 행렬 정보인 i, j를 파라미터로 보내줄 것이기 때문에 selectCol(i, j)와 같이 썼으며, 이는 함수 호출에 해당하는 ()를 썼기 때문에 고차함수가 필요한 것이다. 그렇지 않으면 함수를 실행하는 게 아닌 리턴한 변수값을 실행할테니 말이다.
append 대신 appendChild를 써도 상관없다.
리턴 여부의 차이인데, 여기선 딱히 리턴이 필요없기 때문에 아무거나 상관없다.
여기까지 하면 아래 그림처럼 화면이 나올 것이다.
selectCol 고차함수 살펴보기
// 칸 선택함수
const selectCol=(i, j)=>(e)=>{
// 이미 게임이 끝났다면
if(finished) return;
// 이미 선택했던 칸이라면
if(e.target.textContent) return;
e.target.textContent=turn;
count++;
console.log(e.target.textContent);
// 승부가 났는가?
if(checkWinner(e.target, i, j)){
$result.textContent=`${turn}님의 승리!`;
finished=true;
return;
}
// 9칸(모든 칸)을 선택했는가?
else if(count===9){
$result.textContent=`무승부!`;
finished=true;
return;
}
// 턴 체인지
turn==='O'?turn='X':turn='O';
}
특정 칸을 선택했을 때 발생하는 콜백함수이다.
행렬 정보를 파라미터로 (i, j)로 받기 때문에 selectCol=()=>(event)=>{} 와 같이 화살표함수를 2개로 작성하였다.
이를 고차함수라 한다.
고차함수를 쓰는 이유는 위에 서술했으므로 넘어간다. 요약해서 한번 더 말하자면, 파라미터를 주고 받기 때문이다.
게임이 끝난 상태라면 클릭할 때 콜백함수가 진행되지 않도록 finished 변수가 true인지 체크해주었다.
만약 이 코드가 없다면, 이미 다른 애가 승리했는데도, 칸을 선택하면 O 또는 X의 턴이라고 textContent에 입력될 것이다.
승부가 났는지 판단하는 checkWinner(i, j) 함수는 아래에서 살펴보도록 한다.
만약 모든 칸이 다 칠해졌으면 무승부이므로 count===9일 경우 finished=true로 바꿔주고 return 시키자.
+) 21.10.21. 추가
무승부를 파악하는 방법 중에, count 변수를 이용하지 않고 flat()과 every() 함수를 이용하는 방법도 있다.
// 9칸(모든 칸)을 선택했는가?
else if(rows.flat().every((cell)=>cell.textContent)){
$result.textContent=`무승부!`;
finished=true;
return;
}
flat()은 이차원 이상의 배열을 평평하게 일차원으로 나타내주는 함수이고,
every() 함수는 and연산자라 보면 된다.
forEach를 돌면서 모든 조건이 만족해야 true, 그렇지 않으면 false를 리턴한다.
forEach는 중간에 멈출 방법이 없기 때문에 flat() 후 every() 함수를 이용하면 편하다.
이러한 편한 기능이 있음에도 불구하고, count 변수를 이용하여 체크해주는 방법을 이용한 이유는 시간복잡도 때문이다.
우선 js의 flat()의 시간복잡도가 O(N)이 소요되며, every함수가 바로 break를 해준다해도, 시간복잡도는 O(N) 까지 갈 수 있기 때문이다. 따라서 총 시간복잡도는 9번(N) 반복에 O(N)이므로 O(N^2)이다.
그럴 바에, count 변수를 생성하여 N번 반복 * O(1) => O(N)으로 해주는 것이 낫다고 판단했기 때문이다.
강의에서는 flat, every 함수를 소개해주기 위해, 그리고 어차피 3목이라 N이 크지 않기 때문에 flat, every함수를 사용한 것으로 보인다.
이 코드에선 사용하지 않았지만, flat, every, array.from(유사 배열객체를 배열로 바꿔주는 함수) 등은 중요하므로 알아두는 것이 좋아보인다.
-------------- 다시 추가하기 이전 원래 포스팅)
참고로 finished 변수를 쓰지 않고 이벤트버블링을 이용해서 $table의 이벤트리스너에 removeEventListener를 이용해서 게임이 끝나면 진행하지 않도록 할 수도 있다.
이벤트 버블링이란, 자식 태그에 이벤트가 발생했고 이벤트리스너는 없지만, 부모 태그에 이벤트리스너가 있을 경우, 그 이벤트를 진행하는 것이다.
이벤트 버블링이 존재하지 않는다면, 태그 안에 <span>이나 <p>태그 등의 글자가 있을 경우, 칸이 아닌 글자가 선택돼 따로 추가작업을 해주어야하는 불편함을 겪을 수 있기 때문에, 자바스크립트에서 이벤트 버블링은 default로 작동되도록 설정했다고 한다.
이런 성질 덕분에 $table.removeEventListener를 진행하면 table이 아닌 td를 선택해도 이벤트리스너가 삭제돼 아무런 일이 일어나지 않는 것이다. 반대의 의미로 이벤트 캡쳐링 또한 존재하나, default로 작동하지 않고 capture: true를 설정해주어야한다. 여기선 이벤트 캡쳐링은 이용하지 않는다.
강의에선 이벤트 버블링을 이용하여 $table.removeEventListener를 사용하여 진행했다.
그러나, 이렇게 진행하면 승부가 났는지 판단하는 checkWinner 함수가 조금 길어져 나는 그냥 finished 변수를 추가하고 removeEventListener는 사용하지 않는 방법으로 진행했다.
여기까지 하면 아래 그림처럼 진행될 것이다.
승부 파악 함수를 작성하지 않았으므로 아직은 승부파악을 하지 못하는 모습이다.
승부파악을 결정하는 checkWinner 함수
const checkWinner=(target,rowIndex,colIndex)=>{
let garo=true, sero=true, diagonal=true, revDiagonal=true;
for(let i=0;i<3;i++){
if(rows[i][colIndex].textContent!==turn)
sero=false;
if(rows[rowIndex][i].textContent!==turn)
garo=false;
if(rows[i][i].textContent!==turn)
diagonal=false;
if(rows[i][2-i].textContent!==turn)
revDiagonal=false;
}
return garo||sero||diagonal||revDiagonal;
}
빙고를 이루었다면 true를, 빙고가 아니라면 false를 반환하는 함수다.
일단 가로, 세로, 대각선, 역대각선 모두 true라 하자.
for문을 돌아 하나라도 자신의 턴으로 칠하지 않았다면 그것에 해당하는 방향의 줄을 false로 바꿔주는 것이다.
textContent가 무슨 의미인지 모른다면 js 기본이 좀 부족한 상황이므로 추가적인 공부를 하고 오자. 작년의 내가 그랬었다...
하나라도 true이면 true를 반환해야 하므로 or연산자인 ||를 이용한다.
여기까지 코드를 작성하면 틱택토 완성이다.
전체코드
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>틱택토</title>
<style>
table{
border-collapse: collapse;
}
td{
border: 1px solid black;
width: 40px;
height: 40px;
text-align: center;
}
</style>
</head>
<body>
<script>
const { body }=document;
const $table=document.createElement('table');
const $result=document.createElement('div');
const rows=[];
let turn='O', count=0, finished=false;
// 승부결정 파악 함수
const checkWinner=(target,rowIndex,colIndex)=>{
let garo=true, sero=true, diagonal=true, revDiagonal=true;
for(let i=0;i<3;i++){
if(rows[i][colIndex].textContent!==turn)
sero=false;
if(rows[rowIndex][i].textContent!==turn)
garo=false;
if(rows[i][i].textContent!==turn)
diagonal=false;
if(rows[i][2-i].textContent!==turn)
revDiagonal=false;
}
return garo||sero||diagonal||revDiagonal;
}
// 칸 선택함수
const selectCol=(i, j)=>(e)=>{
// 이미 게임이 끝났다면
if(finished) return;
// 이미 선택했던 칸이라면
if(e.target.textContent) return;
e.target.textContent=turn;
count++;
console.log(e.target.textContent);
// 승부가 났는가?
if(checkWinner(e.target, i, j)){
$result.textContent=`${turn}님의 승리!`;
finished=true;
return;
}
// 9칸(모든 칸)을 선택했는가?
else if(count==9){
$result.textContent=`무승부!`;
finished=true;
return;
}
// 턴 체인지
turn==='O'?turn='X':turn='O';
}
for(let i=0;i<3;i++){
const $tr=document.createElement('tr');
const cells=[];
for(let j=0;j<3;j++){
const $td=document.createElement('td');
cells.push($td);
// 클릭 이벤트
$td.addEventListener('click', selectCol(i, j));
$tr.append($td);
}
rows.push(cells);
$table.append($tr);
}
body.append($table);
body.append($result);
</script>
</body>
</html>
https://github.com/kth990303/TH-s-Web/blob/master/es2021/tictactoe.html
github에 작성한 코드는 현재코드에서 추가적으로 수정될 확률이 높다.
사실 이제 자바스크립트 자체는 웬만해선 다 알고있지 않을까? 생각했으나...
이벤트 버블링, 이벤트 캡쳐링이란 새로운 개념을 알게 된 하루였다.
아직 갈 길이 멀다. 특히 리액트 같은 프레임워크쪽은 더더더욱 훨씬 갈 길이 먼 듯하다.
개인적으로, 리액트랑 타입스크립트 배우고 나서, BE: 스프링부트, FE: ts+react로 개인 프로젝트를 진행해보고 싶다.
'JS > VanillaJS' 카테고리의 다른 글
[ERROR] Uncaught SyntaxError: Invalid shorthand property initializer 에러 해결 (0) | 2021.10.24 |
---|---|
[VanillaJS] 바닐라js로 틱택토를 만들어보자 (2) (0) | 2021.10.22 |
[VanillaJS] 비동기 setTimeOut 함수와 연속클릭 (0) | 2021.10.14 |
[VanillaJS] 바닐라JS의 setTimeOut을 이용한 간단한 로또추첨기 만들기 (0) | 2021.10.11 |
[VanillaJS] addEventListener, 콜백 함수 알아보기 (0) | 2021.10.09 |