최근에 리액트로 만들었던 tic-tac-toe 빙고게임 기능에 대해 차근히 정리해보는 시간을 가지려고 한다!
우선 내가 만들게 될 빙고게임의 규칙은 다음과 같다.
👻 1. 3X3, 4X4, 5X5 게임판의 크기와 승리조건, 선공 유저를 선택할 수 있다.
👻 2. 유저 1, 유저 2 각각 마크와 색상을 선택할 수 있다.
👻 3. 게임 시작 시, 15초의 타이머가 시작된다.
👻 4. 15초 타이머 동안 공격 유저가 셀을 선택하지 않으면 랜덤으로 셀이 선택된다.
👻 5. 셀을 하나씩 클릭해가며 승리조건에 맞아 떨어지는 유저가 승리하게 된다.
👻 6. 각 유저는 3번의 무르기를 할 수 있다.
👻 7. 어떤 유저가 어떤 마크로 어떤 셀을 눌렀는지, 누가 승리했는지에 대한 내역이 저장될 수 있어야 한다.
이번 포스팅으로는 기본적으로 만들 수 있는 기능(드롭다운 게임옵션)들을 제외하고, 딱 빙고게임 보드게임판 컴포넌트 기능들을 위주로 정리할 것이다.
게임판의 크기(boardSize), 승리조건(winnerValue), 선공 유저(user),
유저 1의 마크와 색상(user1Value), 유저 2의 마크와 색상(user2Value)을 넘겨받고 있다는 전제하에 시작해보겠다!
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const [gameBoard, setGameBoard] = useState(
Array.from(Array(boardSize), (_, row) =>
Array.from(Array(boardSize), (_, col) => ({
value: (row * boardSize + col).toString(),
color: '#ccc',
})),
),
);
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
>
{cell.value}
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
2차원 배열부터 만들어보자!
우선 나는 boardSize를 '3X3'를 선택했다면 3을, '4X4'을 선택했다면 4를, .. 이런식으로 넘겨받고 있다.
boardSize개의 행을 가진 1차 배열을 우선 생성 후,
boardSize개의 열을 가진 2차 배열을 생성한다.
이때 각 요소의 값은 해당 셀의 고유한 번호와 추후 유저에 맞는 색상을 입히기 위해 {value, color} 객체 형태로 담아주도록 했다.
위 코드와 같이 2차 배열을 map 반복으로 띄워주면 value 값이 그대로 보여지게 되는데,
나중에 유저별 마크의 모양으로 값을 보여주기 위해 조건이 필요하다.
[선택된 셀들을 담아놓을 상태값 만들고 조건에 따라 cell.value 띄우기]
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const [gameBoard, setGameBoard] = useState(
Array.from(Array(boardSize), (_, row) =>
Array.from(Array(boardSize), (_, col) => ({
value: (row * boardSize + col).toString(),
color: '#ccc',
})),
),
);
const [selectedCells, setSelectedCells] = useState<{ row: number; col: number }[]>([]); // 어떤 {row, col} 값이 선택되었는지 담기 위한 배열
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
이때 selectedCells 내에 알맞는 배열이 있다면, true를 반환하여 value 값을 보여주기 위해 some() 메소드를 사용했다.
게임판의 크기별로 이길 수 있는 승리조건이 모두 다르다.
예를 들어 3X3 크기의 게임판의 경우, 승리 조건이 다음과 같다.
행 별 가로 index 값, 열 별 세로 index 값, 2가지의 대각선 index 값을 모아놓은 승리조건 2차원 배열을 만들고, 한 유저가 승리조건 배열 내에 충족되는 배열을 하나라도 만들 시 승리하게 되는것이다!
나는 boardSize가 다른만큼 boardSize별 2차원 승리조건 배열이 필요했다.
게임시작을 눌러 페이지가 렌더링될 때 계산하면 되므로, 함수로 만들어 불러오도록 했다.
[useGetWinningLines.ts]
const useGetWinningLines = (boardSize: number) => {
const lines: number[][] = [];
// 가로 방향
/*
[0, 1, 2]
[3, 4, 5]
...
*/
for (let i = 0; i < boardSize; i++) {
const horizontalLine: number[] = [];
for (let j = 0; j < boardSize; j++) {
horizontalLine.push(i * boardSize + j);
}
lines.push(horizontalLine);
}
// 세로 방향
/*
[0, 3, 6]
[1, 4, 7]
...
*/
for (let i = 0; i < boardSize; i++) {
const verticalLine: number[] = [];
for (let j = 0; j < boardSize; j++) {
verticalLine.push(j * boardSize + i);
}
lines.push(verticalLine);
}
// 대각선 (왼 > 오 방향)
/*
3X3
[0, 4, 8]
4X4
[0, 5, 10, 15]
5X5
[0, 6, 12, 18]
...
*/
const diagonalLine1: number[] = [];
for (let i = 0; i < boardSize; i++) {
diagonalLine1.push(i * (boardSize + 1));
}
lines.push(diagonalLine1);
// 대각선 (오 > 왼 방향)
/*
3X3
[2, 4, 6]
4X4
[3, 6, 9, 12]
5X5
[4, 8, 12, 16]
*/
const diagonalLine2: number[] = [];
for (let i = 1; i <= boardSize; i++) {
diagonalLine2.push(i * (boardSize - 1));
}
lines.push(diagonalLine2);
return lines;
};
export default useGetWinningLines;
특히 대각선 부분은 반복문을 어떻게 작성해야 할지 고민하다 각 크기별 담겨야 하는 값들을 하나씩 정리하여 규칙을 알아낼 수 있었다.
왼쪽 위에서 오른쪽 아래로 내려오는 방향의 규칙은 다음과 같다.
boardSize 크기에서 1을 더한 값의 배수
오른쪽 위에서 왼쪽 아래로 내려오는 방향의 규칙은 다음과 같다.
boardSize 크기에서 1을 뺀 값의 배수
참고로 오른쪽 위에서 내려오는 규칙을 구할 땐, 반복문의 시작을 0이 아닌 1로 시작해야 올바른 값들로 담길 수 있다.
이렇게 만들어둔 함수를 이제 위에서 계속 작성하고 있었던 게임판 컴포넌트 파일로 불러온다!
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const winningLines = useGetWinningLines(boardSize); // 2차원 승리조건 배열 선택
/* 생략 ... */
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
winningLines 값은 유저가 승리조건 배열 내에 충족되는 배열을 가지고 있는지 판단하는 함수를 만들 때 사용할 수 있는데, 이는 추후 다음 게시글에서 만들어보려고 한다.
15초 내에 셀을 선택해야 한다는 규칙이 있다.
기본 타이머를 만들어보려고 한다.
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const [timer, setTimer] = useState(15); // 15초 부터 시작해야 하는 타이머의 상태값
/* 생략 ... */
useEffect(() => {
let interval: NodeJS.Timer | number = 0;
if (timer > 0) {
interval = setInterval(() => {
setTimer(prev => prev - 1);
}, 1000);
}
return () => clearInterval(interval);
}, [timer]);
return (
<BoardOption>
<span>시간:</span> {timer}
</BoardOption>
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
타이머는 setInterval() 메소드를 활용하여 만들었다.
설정해둔 timer 상태값이 0보다 큰 경우, 1초 간격으로 값을 감소시키는 인터벌 함수를 발생시킨다.
useEffect가 timer 값이 변경될때마다 실행되는만큼, 계속 새로운 interval이 생겨 메모리 누수가 생기지 않도록, clearInterval() 메소드를 활용하여 바로 이전에 설정했던 인터벌을 제거할 수 있도록 했다.
처음에는 interval을 0으로만 할당하여 진행하려고 하다 다음과 같은 에러를 만나게 되었다.
왜 그런지 이유를 찾아보았다.
NodeJS 컴파일러는 타입스크립트 환경에서 타이머 함수를 사용 시에 number 타입이 아닌, NodeJS.Timer 혹은 NodeJS.Timeout 반환하게 된다는 것을 알게 되었다.
interval에 NodeJS.Timer 타입을 추가하여 에러를 해결할 수 있었다.
다른 기능들에 대해선 다음 포스팅에서 진행해보도록 하겠다!