우선 내가 만들게 될 빙고게임의 규칙은 다음과 같다.
👻 1. 3X3, 4X4, 5X5 게임판의 크기와 승리조건, 선공 유저를 선택할 수 있다.
👻 2. 유저 1, 유저 2 각각 마크와 색상을 선택할 수 있다.
👻 3. 게임 시작 시, 15초의 타이머가 시작된다.
👻 4. 15초 타이머 동안 공격 유저가 셀을 선택하지 않으면 랜덤으로 셀이 선택된다.
👻 5. 셀을 하나씩 클릭해가며 승리조건에 맞아 떨어지는 유저가 승리하게 된다.
👻 6. 각 유저는 3번의 무르기를 할 수 있다.
👻 7. 어떤 유저가 어떤 마크로 어떤 셀을 눌렀는지, 누가 승리했는지에 대한 내역이 저장될 수 있어야 한다.
tic-tac-toe 1탄 포스팅에서는 보드판 생성, 보드판 크기에 따른 승리조건 생성, 타이머 기능까지 만들고 마무리 했다.
게임판의 크기(boardSize), 승리조건(winnerValue), 선공 유저(user),
유저 1의 마크와 색상(user1Value), 유저 2의 마크와 색상(user2Value)을 넘겨받고 있다는 전제하에 시작해보겠다!
우선 이전 포스팅에서 보드판 크기별 승리조건을 구하는 함수에 대해 알아보았다.
winningLines를 가지고, 승리조건 2차 배열 내에 충족되는 배열을 가지고 있는지 판단하는 함수를 만들어보려고 한다.
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const winningLines = useGetWinningLines(boardSize); // 2차원 승리조건 배열 선택
const [winner, setWinner] = useState<string | null>(null); // 승자가 나오기 이전 빈 값으로 놔두기
const [timer, setTimer] = useState(15);
/* 생략 ... */
const calculateWinner = useCallback(() => {
for (let i = 0; i < winningLines.length; i++) {
const line = winningLines[i];
let isWinner = true;
// 마크가 있는지, 모든 셀의 마크가 동일한지 판단
for (let j = 0; j < Number(winnerValue); j++) {
const index = line[j];
const mark = gameBoard[Math.floor(index / boardSize)][index % boardSize].value;
if (
!mark ||
mark !== gameBoard[Math.floor(line[0] / boardSize)][line[0] % boardSize].value
) {
isWinner = false;
break;
}
}
if (isWinner) {
const winnerType =
gameBoard[Math.floor(line[0] / boardSize)][line[0] % boardSize].value === user1Value.mark
? user1Value.type
: user2Value.type;
setWinner(winnerType);
setTimer(0);
break;
}
}
}, [winnerValue])
return (
/* 생략 */
)
}
winningLines 2차 배열의 반복문을 우선 돌렸다.
그리고 winnerValue (3X3이라면 3, 4X4이라면 4, 5X5이라면 5)만큼 2차 배열 요소들에 대해 반복문을 돌리면서,
그 내부 요소들의 값이 유저 1의 마크와 모두 같다면 setWinner 상태값 변경을 통해 유저1을 승리자로,
유저 2의 마크와 모두 같다면 유저2를 승리자로 만들며 반복문을 중단시켰다.
만약 마크가 서로 다르다면 isWinner 값을 false로 변경시키고, 반복문을 중단시켰다.
이렇게 승자를 판단하는 함수를 만들어놓고, 유저별로 원하는 셀을 선택하는 함수에 불러와 그 유저의 마크가 승리조건에 해당하도록 배치되었는지 판단하도록 했다.
그리고 calculateWinner() 함수를 useCallback으로 감싼 이유는
우선 해당 함수는 아래에 정리할 handleClickCell() 함수에서 항상 불러와지게 되는데,
winnerValue 값만 변하지 않는다면 해당 함수를 불러올때마다 다시 만들 필요성이 없다고 생각하여 감싸주었다..!
빙고 게임인만큼 셀을 선택하는 함수가 가장 중요하다!
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const winningLines = useGetWinningLines(boardSize); // 2차원 승리조건 배열 선택
const [currentUser, setCurrentUser] = useState(user); // 선공할 유저를 default 값으로 담고, 셀 선택시 서로 변경되도록하기
const [gameBoard, setGameBoard] = useState(
Array.from(Array(boardSize), (_, row) =>
Array.from(Array(boardSize), (_, col) => ({
value: (row * boardSize + col).toString(),
color: '#ccc',
})),
),
);
const [winner, setWinner] = useState<string | null>(null); // 승자가 나오기 이전 빈 값으로 놔두기
const [timer, setTimer] = useState(15);
const [selectedCells, setSelectedCells] = useState<{ row: number; col: number }[]>([]);
// 📌 셀 선택 함수
const handleClickCell = (row: number, col: number) => {
if (!winner) {
const mark = currentUser === '첫 번째 유저' ? user1Value.mark : user2Value.mark;
const updatedGameBoard = [...gameBoard];
if (
updatedGameBoard[row][col].value !== user1Value.mark &&
updatedGameBoard[row][col].value !== user2Value.mark
) {
updatedGameBoard[row][col] = {
value: mark,
color: mark === user1Value.mark ? user1Value.markColor : user2Value.markColor,
};
setGameBoard(updatedGameBoard);
calculateWinner(); // 셀 선택시마다 승자가 있는지 판단하기 위해 해당 함수를 불러온다.
setCurrentUser(prevUser => (prevUser === '첫 번째 유저' ? '두 번째 유저' : '첫 번째 유저'));
setSelectedCells(prevSelectedCells => [...prevSelectedCells, { row, col }]);
dispatch(setRecordGame({ type: 'click', mark, cell: { row, col } }));
setTimer(15);
}
}
};
const calculateWinner = useCallback(() => {
for (let i = 0; i < winningLines.length; i++) {
const line = winningLines[i];
let isWinner = true;
// 마크가 있는지, 모든 셀의 마크가 동일한지 판단
for (let j = 0; j < Number(winnerValue); j++) {
const index = line[j];
const mark = gameBoard[Math.floor(index / boardSize)][index % boardSize].value;
if (
!mark ||
mark !== gameBoard[Math.floor(line[0] / boardSize)][line[0] % boardSize].value
) {
isWinner = false;
break;
}
}
if (isWinner) {
const winnerType =
gameBoard[Math.floor(line[0] / boardSize)][line[0] % boardSize].value === user1Value.mark
? user1Value.type
: user2Value.type;
setWinner(winnerType);
setTimer(0);
break;
}
}
}, [winnerValue])
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
onClick={() => handleClickCell(idx, colIdx)}
$color={cell.color}
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
셀 선택 함수에는 선택한 셀의 row(행), col(열) 값을 받아오도록 했다.
그리고 그 셀의 값을 어떤 마크로 표시할지는 현재 셀을 누르는 유저가 누구인지 판단 후,
그 셀의 위치에 해당 유저의 마크와 색상으로 변경될 수 있도록 했다.
셀을 누르면 승자가 있는지 판단하는 calculateWinner() 함수를 불러왔다.
다음 공격 유저로 변경하는 setCurrentUser(), 그리고 해당 셀이 선택되었는지 판단하기 위한 setSelectedCells()도 불러왔다.
다음 공격 유저로 변경되면 타이머도 새롭게 초기화되어야 하기 때문에 setTimer(15)도 추가해주었다.
이렇게 만들어둔 함수를 셀 컴포넌트에 onClick으로 적용시켜 셀이 눌리는 이벤트를 적용시킬 수 있도록 했다.
셀의 기본 색상은 #ccc이며, 유저별로 선택한 색상으로 변경시키기 위해선 나름의 기능 추가가 필요했다!
우선 나는 유저별로 색상을 선택할 때, '파랑', '빨강', '노랑', '초록' 이런식으로 한글로 받아오도록 .. 목업데이터를 만들어버려서
그 string 값을 넘겨받았을 때 변환이 필요했고, 유틸함수를 만들어 필요한 css 파일에 불러와 사용하도록 했다.
[getColorByMarkColor.ts]
const getColorByMarkColor = (markColor: string) => {
switch (markColor) {
case '파랑':
return '#0A31FB';
case '빨강':
return '#DD2626';
case '노랑':
return '#F3D953';
case '초록':
return '#0BA825';
default:
return '#ccc';
}
};
export default getColorByMarkColor;
넘겨받은 색상 정보를 hex코드로 변환한다.
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const winningLines = useGetWinningLines(boardSize); // 2차원 승리조건 배열 선택
const [currentUser, setCurrentUser] = useState(user); // 선공할 유저를 default 값으로 담고, 셀 선택시 서로 변경되도록하기
const [gameBoard, setGameBoard] = useState(
Array.from(Array(boardSize), (_, row) =>
Array.from(Array(boardSize), (_, col) => ({
value: (row * boardSize + col).toString(),
color: '#ccc',
})),
),
);
const [winner, setWinner] = useState<string | null>(null); // 승자가 나오기 이전 빈 값으로 놔두기
const [timer, setTimer] = useState(15);
const [selectedCells, setSelectedCells] = useState<{ row: number; col: number }[]>([]);
// 📌 셀 선택 함수
const handleClickCell = (row: number, col: number) => {
if (!winner) {
const mark = currentUser === '첫 번째 유저' ? user1Value.mark : user2Value.mark;
const updatedGameBoard = [...gameBoard];
if (
updatedGameBoard[row][col].value !== user1Value.mark &&
updatedGameBoard[row][col].value !== user2Value.mark
) {
updatedGameBoard[row][col] = {
value: mark,
color: mark === user1Value.mark ? user1Value.markColor : user2Value.markColor,
};
setGameBoard(updatedGameBoard);
calculateWinner(); // 셀 선택시마다 승자가 있는지 판단하기 위해 해당 함수를 불러온다.
setCurrentUser(prevUser => (prevUser === '첫 번째 유저' ? '두 번째 유저' : '첫 번째 유저'));
setSelectedCells(prevSelectedCells => [...prevSelectedCells, { row, col }]);
dispatch(setRecordGame({ type: 'click', mark, cell: { row, col } }));
setTimer(15);
}
}
};
const calculateWinner = useCallback(() => {
/* 생략 */
}, [winnerValue])
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
onClick={() => handleClickCell(idx, colIdx)}
$color={cell.color} // cell.color 값을 styled-component의 기능중 하나인 css props 전달을 해준다.
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
const BoardColWrap = styled.button<{
$color: string; // hex코드로 변환받은 값을 background-color 넘겨주기
}>`
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 80px;
height: 80px;
margin: 2px;
font-weight: bold;
font-size: 1.4rem;
border-radius: 10px;
color: white;
background-color: ${props => getColorByMarkColor(props.$color)};
@media (max-width: 768px) {
width: 60px;
height: 60px;
}
`;
참고로 내가 만드는 빙고 게임에는 15초가 지나기 전,
셀을 선택해야하며 15초가 지날동안 선택하지 않을 시 셀을 랜덤으로 선택할 수 있는 기능이 필요했다.
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const winningLines = useGetWinningLines(boardSize); // 2차원 승리조건 배열 선택
const [currentUser, setCurrentUser] = useState(user); // 선공할 유저를 default 값으로 담고, 셀 선택시 서로 변경되도록하기
const [gameBoard, setGameBoard] = useState(
Array.from(Array(boardSize), (_, row) =>
Array.from(Array(boardSize), (_, col) => ({
value: (row * boardSize + col).toString(),
color: '#ccc',
})),
),
);
const [winner, setWinner] = useState<string | null>(null); // 승자가 나오기 이전 빈 값으로 놔두기
const [timer, setTimer] = useState(15);
const [selectedCells, setSelectedCells] = useState<{ row: number; col: number }[]>([]);
// 📌 셀 선택 함수
const handleClickCell = (row: number, col: number) => {
if (!winner) {
const mark = currentUser === '첫 번째 유저' ? user1Value.mark : user2Value.mark;
const updatedGameBoard = [...gameBoard];
if (
updatedGameBoard[row][col].value !== user1Value.mark &&
updatedGameBoard[row][col].value !== user2Value.mark
) {
updatedGameBoard[row][col] = {
value: mark,
color: mark === user1Value.mark ? user1Value.markColor : user2Value.markColor,
};
setGameBoard(updatedGameBoard);
calculateWinner(); // 셀 선택시마다 승자가 있는지 판단하기 위해 해당 함수를 불러온다.
setCurrentUser(prevUser => (prevUser === '첫 번째 유저' ? '두 번째 유저' : '첫 번째 유저'));
setSelectedCells(prevSelectedCells => [...prevSelectedCells, { row, col }]);
dispatch(setRecordGame({ type: 'click', mark, cell: { row, col } }));
setTimer(15);
}
}
};
const calculateWinner = useCallback(() => {
for (let i = 0; i < winningLines.length; i++) {
const line = winningLines[i];
let isWinner = true;
// 마크가 있는지, 모든 셀의 마크가 동일한지 판단
for (let j = 0; j < Number(winnerValue); j++) {
const index = line[j];
const mark = gameBoard[Math.floor(index / boardSize)][index % boardSize].value;
if (
!mark ||
mark !== gameBoard[Math.floor(line[0] / boardSize)][line[0] % boardSize].value
) {
isWinner = false;
break;
}
}
if (isWinner) {
const winnerType =
gameBoard[Math.floor(line[0] / boardSize)][line[0] % boardSize].value === user1Value.mark
? user1Value.type
: user2Value.type;
setWinner(winnerType);
setTimer(0);
break;
}
}
}, [winnerValue])
useEffect(() => {
let interval: NodeJS.Timer | number = 0;
// 📌 승자가 있다면
if (winner) {
return () => clearInterval(interval);
}
// 📌 승자가 없는채로 모든 셀이 선택되었다면
if (!winner && selectedCells.length === boardSize ** 2) {
setWinner('무승부!!!');
return () => clearInterval(interval);
}
// 📌 그 이외 경우,
// 1. 타이머가 1초 이상인 경우 -1
if (timer > 0) {
interval = setInterval(() => {
setTimer(prev => prev - 1);
}, 1000);
} else if (timer === 0) {
// 2. 타이머가 0초가 되었을 때, 선택되지 않은 셀들 추출하여 랜덤으로 선택하기
const emptyCells = gameBoard
.flatMap((row, idx) =>
row.map((cell, colIdx) => ({ row: idx, col: colIdx, value: cell.value })),
)
.filter(
cell =>
!selectedCells.some(
selectedCell => selectedCell.row === cell.row && selectedCell.col === cell.col,
),
);
// 아직 선택되어 있지 않은 셀들이 남아있는 경우에만 실행!
if (emptyCells.length > 0) {
const randomIndex = Math.floor(Math.random() * emptyCells.length);
const randomCell = emptyCells[randomIndex];
handleClickCell(randomCell.row, randomCell.col);
}
}, [timer]);
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
onClick={() => handleClickCell(idx, colIdx)}
$color={cell.color}
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
</div>
)
}
이전 게시글에서 15초동안 흐르는 기본 타이머를 만들어두었다면,
이번엔 좀 더 기능들을 추가하도록 했다.
크게 3가지로 나뉘게 된다.
1. 승자가 있는 경우
2. 모든 셀을 선택할동안 승자가 없는 경우
3. 그 이외 경우
그리고 랜덤 셀 선택은 3번에 해당하게 된다.
timer === 0이 되었을 때,
어떤 셀들이 비어있는지부터 판단하게 되는데 flatMap과 filter 메소드를 활용하여 selectedCells에 존재하는 [row, col]인지 아닌지를 판단하여 그게 아닌것들만 필터링하도록 했다.
그리고 선택되지 않은 셀들이 1개 이상일 경우,
랜덤으로 인덱스를 선택하고 비어있는 셀의 row, col 값을 구해 handleClickCell() 함수에 넘겨주도록 했다!
그렇게 되면 0초가 되었을 때 해당 유저의 랜덤 선택이 완료된다!!
그리고 각 플레이어별로 무르기 기능을 3회씩 가지고 있다.
무르기를 선택하면 이전 결과로 돌아가게 되는데,
이렇게 3가지의 기능이 필요하다.
const GameBoard = ({ boardSize, winnerValue, user, user1Value, user2Value }) => {
const winningLines = useGetWinningLines(boardSize); // 2차원 승리조건 배열 선택
const [currentUser, setCurrentUser] = useState(user); // 선공할 유저를 default 값으로 담고, 셀 선택시 서로 변경되도록하기
const [gameBoard, setGameBoard] = useState(
Array.from(Array(boardSize), (_, row) =>
Array.from(Array(boardSize), (_, col) => ({
value: (row * boardSize + col).toString(),
color: '#ccc',
})),
),
);
const [winner, setWinner] = useState<string | null>(null); // 승자가 나오기 이전 빈 값으로 놔두기
const [timer, setTimer] = useState(15);
const [selectedCells, setSelectedCells] = useState<{ row: number; col: number }[]>([]);
// 📌 셀 선택 함수
const handleClickCell = (row: number, col: number) => {
if (!winner) {
const mark = currentUser === '첫 번째 유저' ? user1Value.mark : user2Value.mark;
const updatedGameBoard = [...gameBoard];
if (
updatedGameBoard[row][col].value !== user1Value.mark &&
updatedGameBoard[row][col].value !== user2Value.mark
) {
updatedGameBoard[row][col] = {
value: mark,
color: mark === user1Value.mark ? user1Value.markColor : user2Value.markColor,
};
setGameBoard(updatedGameBoard);
calculateWinner(); // 셀 선택시마다 승자가 있는지 판단하기 위해 해당 함수를 불러온다.
setCurrentUser(prevUser => (prevUser === '첫 번째 유저' ? '두 번째 유저' : '첫 번째 유저'));
setSelectedCells(prevSelectedCells => [...prevSelectedCells, { row, col }]);
dispatch(setRecordGame({ type: 'click', mark, cell: { row, col } }));
setTimer(15);
}
}
};
// 📌 무르기 함수
const handleClickUndoButton = (user: string) => {
const mark = currentUser === '첫 번째 유저' ? user1Value.mark : user2Value.mark;
if (selectedCells.length > 0 && (user1Value.undoCount >= 1 || user2Value.undoCount >= 1)) {
dispatch(setReduceUndoCount(user));
const lastData = selectedCells.pop(); // 가장 마지막 요소 뽑아내기
if (lastData) {
const { row, col } = lastData;
const updatedGameBoard = [...gameBoard];
updatedGameBoard[row][col] = {
value: (row * boardSize + col).toString(),
color: '#ccc',
}; // 다시 원래대로(숫자 및 #ccc 색상으로) 돌려놓기
setGameBoard(updatedGameBoard);
dispatch(setRecordGame({ type: 'undo', mark, cell: { row, col } }));
}
setCurrentUser(prevUser => (prevUser === '첫 번째 유저' ? '두 번째 유저' : '첫 번째 유저'));
setTimer(15);
}
};
/* 생략 */
return (
<div>
{gameBoard.map((row, idx) => (
<BoardRowWrap key={idx}>
{row.map((cell, colIdx) => (
<BoardColWrap
key={colIdx}
onClick={() => handleClickCell(idx, colIdx)}
$color={cell.color}
>
{selectedCells.some(cell => cell.row === idx && cell.col === colIdx)
? cell.value
: ''} // 선택된 셀이 있다면 cell.value 값을 보여주고, 아니라면 값 가리기
</BoardColWrap>
))}
</BoardRowWrap>
))}
<UndoButton
onClick={() => handleClickUndoButton(currentUser)}
disabled={
Boolean(winner) ||
(currentUser === '첫 번째 유저' && user1Value.undoCount <= 0) ||
(currentUser === '두 번째 유저' && user2Value.undoCount <= 0)
}
>
무르기
</UndoButton>
</div>
)
}
selectedCells의 가장 마지막 요소를 뽑아내서 row, col 값을 추출한다.
그리고 그걸 원래의 value 값과 기본 색상이었던 #ccc로 변경하여 다시 gameBoard 값으로 업데이트시켜주면 무르기 기능이 완성된다!