React로 구현한 Wordle 게임

woodylovesboota·2023년 8월 26일
0
post-thumbnail

React를 시작하는 방법부터 component, state, effect, props 등 react가 사용하는 기본적인 문법을 배웠다. 또한 style을 적용하는 여러가지 방법들을 배웠다.

배운걸 써먹어 보기위해 간단한 게임을 만들어보기로 하였다.

wordle


wordle은 숫자야구의 문자 버전이다.
https://wordlegame.org/
내가 입력한 알파벳이 위치까지 맞으면 초록색, 정답에 알파벳이 존재는 하는데 위치가 틀리다면 노란색으로 표시된다. 이 정보를 바탕으로 정답인 단어를 맞추면 승리하는 게임이다.

알파벳의 확인 과정 자체는 크게 어렵지 않아 React에서 원하는 기능을 생각대로 구현하는 것을 목표로 설정하고 프로젝트를 진행하였다.

Project


Spec

create-react-app을 이용한 javaScript의 React JS를 이용해 구현하였다.

Goal

이번 프로젝트를 통해 얻고자 했던 목표는 다음과 같다.

  • state, props, effect 등 공부했던 react skill 이용하기
  • styled components를 이용하여 css 적용하기
  • component 별로 기능을 최대한 분리하여 코드의 중복 최대한 없애기

react를 공부하며 배웠던 기능들을 최대한 활용해보고자 노력했고, vanilla js를 통해 개발할 때 가장 불편하다고 생각했던 코드의 중복을 최대한 배제한 체 구현하였다.

Code Detail

전반적인 component의 구조는 다음과 같다.

App
|___Word
|	|___Letter
|
|___Result

사용자가 입력하는 한 줄의 단어를 Word(form) 으로, 하나의 알파벳을 Letter(input)으로 각각 구현하였다. 또한 게임이 종료되었을 때 나타나는 ResultPage를 만들었다.

Letter

Letter은 사용자가 입력하는 알파벳 하나가 들어갈 수 있는 input이다. Letter에서 구현한 기능은 다음과 같다.

  • maxlength가 1인 input 만들기
  • 한 글자 입력하면 자동으로 커서 옮기기
    backspace 누르면 입력 지우고 커서 옮기기
  • 커서 안보이게 하기

기본적으로 styled components를 이용하여 구현하였기에 css의 maxlengthcaret-color 속성을 이용하여 기능을 구현할 수 있었다.

maxLength: 1
caret-color: transparent;

한 글자 입력하면 자동으로 커서 옮기기

key up event를 통해 자동으로 커서를 옮기는 문제를 해결하였다.

const moveCursor = (e) => {
if (e.target.value.length === 1) {
    e.target.nextSibling?.focus();
  }
};

onKeyUp eventListener를 이용하여 알페벳이 입력되면 자동으로 다음 형제 노드로 focus를 넘겼다.

비슷한 방법으로 만약 입력이 backspace일 경우 focus를 이전 형제노드로 옮기는 식으로 구현하였다.

Letter의 전체 코드는 다음과 같다.

import { styled } from "styled-components";

const LetterInput = styled.input.attrs({ maxLength: 1, required: true })`
  width: 55px;
  height: 55px;
  border-radius: 10px;
  margin: 2px;
  background-color: #dcdde1;
  border: none;
  text-align: center;
  vertical-align: center;
  font-size: 50px;
  color: white;
  text-transform: uppercase;
  transition: background-color 1s ease-in-out;
  caret-color: transparent;

  &:focus {
    outline: none;
  }
`;

const moveCursor = (e) => {
  if (e.keyCode === 8) {
    e.target.previousSibling?.focus();
  } else if (e.target.value.length === 1) {
    e.target.nextSibling?.focus();
  }
};

const Letter = ({ className }) => {
  return <LetterInput onKeyUp={moveCursor} className={className}></LetterInput>;
};

export default Letter;

Word

Word는 5개의 Letter로 이루어진 form 이다. 구현하고자 한 기능은 다음과 같다.

  • 단어의 정답여부 확인 및 정답여부에 따른 색 변경
  • submit 후 자동으로 다음 줄로 focus 넘기기

Word는 wordle의 main logic인 정답 확인 및 그에따른 배경색 변경이 구현된 component이다.

단어의 정답여부 확인 및 정답여부에 따른 색 변경

onSubmit eventListener를 이용하여 submit 되었을 때 문자 각각의 정답 여부를 파악하고 이를 기록하였다.

const [colors, setColors] = useState(["", "", "", "", ""]);

for (let i = 0; i < 5; i++) {
  res.push(e.target[i].value);
  if (res[i] === answer[i]) {
    green++;
    checkArr[i] = "G";
  } else if (answer.includes(res[i])) {
    checkArr[i] = "Y";
  } else {
    checkArr[i] = "B";
  }
}

setColors(checkArr);

.
.
.

colors.forEach((element, index) => {
  if (element === "G") letters.push(<Letter key={index} className={"green"}></Letter>);
  else if (element === "Y") letters.push(<Letter key={index} className={"yellow"}></Letter>);
  else if (element === "B") letters.push(<Letter key={index} className={"gray"}></Letter>);
  else letters.push(<Letter key={index} className={"normal"}></Letter>);
});
  .green {
    background-color: green;
  }
  .yellow{
    background-color:rgb(201, 201, 61);
  }
  .gray {
    background-color: gray;
  }

colors[] 라는 state를 만들어준 후 submit 할 때 결과를 업데이트 시켜주었다. state가 업데이트 되었으니 component의 re-rendering이 일어나게 된다. 이를 통해 colors의 정보에 따라 component의 class를 추가하였다.

마지막으로 코드의 중복을 줄이기 위해 forEach 문을 사용하여 Letter를 조건부 렌더링 해주었다.

const render = () => {
  let letters = [];

  colors.forEach((element, index) => {
    if (element === "G") letters.push(<Letter key={index} className={"green"}></Letter>);
    else if (element === "Y") letters.push(<Letter key={index} className={"yellow"}></Letter>);
    else if (element === "B") letters.push(<Letter key={index} className={"gray"}></Letter>);
    else letters.push(<Letter key={index} className={"normal"}></Letter>);
  });
  return letters;
};

return (
  <>
    <WordForm onSubmit={onSubmit}>
      {render()}
      <SubmitButton></SubmitButton>
    </WordForm>
    </>
);

submit 후 자동으로 다음 줄로 focus 넘기기

sumbit 시 커서를 넘기는것은 Letter 때와 마찬가지로 focus() method를 이용하여 구현하였다.

e.target.nextElementSibling.firstElementChild.focus();

App

App 은 가장 먼저 렌더링 되는 component이며 각각의 하위 컴포넌트들을 관리하는 역할을 하도록 구현하였다. 구현한 기능은 다음과 같다.

  • useEffect()를 이용하여 API를 통해 random한 answer 가져오기
  • isFinish, isWin 등의 state를 이용하여 게임의 종료와 승리 여부를 파악한 후 그에 맞는 결과 창 렌더링 하기

useEffect()를 이용하여 API를 통해 random한 answer 가져오기

게임의 정답은 랜덤한 5글자가 아닌 실제로 존재하는 5글자짜리 단어가 되어야 했다. 이를 위해 외부 API를 이용하여 길이가 5인 단어를 받아와 이를 정답으로 이용하였다.

이 때 불필요한 API call을 줄이기 위해 useEffect()를 이용하여 함수 호출이 한번만 일어나게 구현하였다.

const getWord = async () => {
  const res = await axios("https://random-word-api.herokuapp.com/word?length=5");
  setAnswer(res.data[0]);
};

useEffect(() => {
  getWord();
}, []);

isFinish, isWin 등의 state를 이용하여 게임의 종료와 승리 여부를 파악한 후 그에 맞는 결과 창 렌더링 하기

게임의 종료 여부를 판단하기 위한 state인 isFinish 와 승리 여부를 확인하는 isWin을 이용하여 결과창을 렌더링 해주었다.

isFinish, isWin은 실질적으로 Word 컴포넌트에서 확인하기 때문에 setIsWin, setIsFinish 함수를 props를 통해 Word에게 넘겨주었다.

// App.js
<Word key={i} answer={answer} checkResult={checkResult} checkIsWin={checkIsWin}></Word>
// Word.js
if (green === 5) setSuccess(true);
.
.
.
useEffect(() => {
  checkResult(success);
  checkIsWin(true);
}, [success]);

green===5 즉, 다섯 글자 모두가 정답일 때 state인 success를 업데이트 하였고, useEffect()를 이용하여 success가 바뀔 때 isFinishisWin을 변경하였다.

<Container>
  {isFinish ? (
    <ResultPage key={answer} answer={answer} isWin={isWin}></ResultPage>
  ) : (
    <>
      <Title>Wordle</Title>
      {render()}
      </>
  )}
</Container>

최종적으로 조건부 렌더링을 이용하여 isFinish 여부에 따라 ResultPage 컴포넌트를 렌더링하는 기능을 구현하였다.

ResultPage

ResultPage는 게임의 승패에 따른 결과창을 보여주는 컴포넌트이다. 구현한 기능은 다음과 같다.

  • 게임의 승패에 따라 다른 문구 보여주기
  • restart 버튼 구현하기

게임의 승패에 따라 다른 문구 보여주기

props를 통해 App에게 isWin 여부를 받아왔기 때문에 이를 이용하여 조건부로 문구를 렌더링 하였다.

<h1>{isWin ? "GOOD !!!!" : "So close..."}</h1>

또한 props로 받아온 answer를 결과창에서 확인할 수 있게 하였다.

<h1>The answer is...</h1>
<h2>{answer.toUpperCase()}</h2>

restart 버튼 구현하기
restart 버튼은 font awsome을 통해 화살표로 구현하였고, keyframes을 통해 마우스를 올려놓으면 돌아가는 간단한 animation을 만들었다.

onClick 하면 window를 새로고침 하는 방법을 이용해 restart 기능을 구현하였다.

const rotationAni = keyframes`
  0% {transform: rotate(0deg)};
  100% {transform: rotate(360deg)};
`;

const Button = styled.button`
  margin-top: 40px;
  border: none;
  color: white;
  background-color: black;
  cursor: pointer;
  font-size: 24px;
  span {
    display: block;
    font-size: 48px;
    &:hover {
      animation: ${rotationAni} 2s linear infinite;
    }
  }
`;

const refreshPage = () => {
  window.location.reload(false);
};

<Button onClick={refreshPage}>
  <span>
    <FontAwesomeIcon icon={faRotateRight}></FontAwesomeIcon>
  </span>
  <p>Restart</p>
</Button>

Issue

Word 구현 도중 enter를 눌러도 submit이 되지 않는 issue가 발생하였다. 찾아보니 하나의 forminput이 여러개 존재할 경우, enter를 통해 formsubmit 시키지 못한다는 것을 알게되었다.

submit button을 구현한다면 enter를 통해서 submit이 가능하다는 것을 알게되었고 submit button을 생성하고 안보이게 하는 조금의 편법을 통해 문제를 해결하였다.

const SubmitButton = styled.button.attrs({ type: "submit" })`
  display: none;
`;

Word에서 단어의 정답 여부를 파악하지만 만약 정답일 경우 Result screen을 렌더링 하는 것은 상위 component인 App 이다. 따라서 App 과 Word 사이의 정보 교환이 이루어 져야 한다.

상위 component인 App 에서 하위 component인 Word로의 정보교환은 props를 이용하여 간단하게 구현할 수 있다. 하지만 반대 방향으로의 정보를 구현하는 방법에 대해 생각해 보아야 했다.

비교적 간단한 방법으로 문제를 해결할 수 있었다. App 에서 Word로 함수를 props로 넘겨주고 Word에서 함수를 실행시키는 방식으로 각각의 두 component가 정보를 공유할 수 있었다.

// App.js
const [res, setRes] = useState(false);
const [isWin, setIsWin] = useState(true);

const checkResult = (result) => {
  setRes(result);
};

const checkIsWin = (result) => {
  setIsWin(result);
};

<Word key={i} answer={answer} checkResult={checkResult} checkIsWin={checkIsWin}></Word>
// Word.js
const Word = ({ answer, checkResult, checkIsWin }) => {
    useEffect(() => {
      checkResult(success);
      checkIsWin(true);
  }, [success]);
}

classList를 통해 class를 추가하였더니 추가가 되지 않았다.

input에 class를 추가하는 것이 색을 변경하는 가장 쉬운 방법이라고 생각하고 vanilla js를 이용할 때 처럼 class를 추가하였다.

let letter = document.querySelector("...");
letter.classList.add("green");

하지만 react는 위 방법대로 직접적으로 DOM을 통해 element에 접근하는 것이 부적적한 방법이라고 한다.

React classList 관련 이슈

React는 자체적인 virtual DOM을 통해 component를 렌더링 하기 때문에 직접 DOM을 건드리면 불필요한 렌더링이 발생하고 두가지 DOM이 섞이면서 이후 코드의 유지보수나 성능에 문제가 생긴다는 이유이다.

따라서 state를 통한 조건부 렌더링을 통해 component에 class를 추가하는 방법을 이용하였다.

const [colors, setColors] = useState(["", "", "", "", ""]);

for (let i = 0; i < 5; i++) {
  res.push(e.target[i].value);
  if (res[i] === answer[i]) {
    green++;
    checkArr[i] = "G";
  } else if (answer.includes(res[i])) {
    checkArr[i] = "Y";
  } else {
    checkArr[i] = "B";
  }
}

setColors(checkArr);

.
.
.

colors.forEach((element, index) => {
  if (element === "G") letters.push(<Letter key={index} className={"green"}></Letter>);
  else if (element === "Y") letters.push(<Letter key={index} className={"yellow"}></Letter>);
  else if (element === "B") letters.push(<Letter key={index} className={"gray"}></Letter>);
  else letters.push(<Letter key={index} className={"normal"}></Letter>);
});

마치며


React를 이용한 첫번째 프로젝트로 wordle을 만들었다. 혼자서 2일동안 진행하였고 작업시간은 7-8시간 정도 걸렸다. 예상보다 오래 걸렸는데 react를 처음 쓰다 보니 생각지 못한곳에서 에러가 생겼다. 위에 Issue에 있는 props를 통한 component 사이의 정보 전달 문제와 state를 통힌 동적 class 추가 부분에서 대부분의 시간을 사용하였다.

처음에 생각했던 Goal을 대부분 달성한 것 같다. state와 effect 를 최대한 다양한 방법으로 사용해 보려고 노력하였고, props를 다양하게 사용해 보며 props를 이용하는 새로운 방법을 알게되었다. 또한 거의 모든 style을 styled component를 이용해 적용하였으며, 조건부 rendering 등을 이용하여 중복된 코드를 최대한 줄였다.

React 사용이 아직은 익숙하지 않다. state가 변함에 따라 컴포넌트가 자동으로 re-render되는 것을 컨트롤 하는 것이 react를 잘 사용하는 것과 연결되는것 같은데 그 부분이 부족한 것 같다. 많이 해보는게 정답인 것 같다.

2개의 댓글

comment-user-thumbnail
2023년 10월 30일

My child loves playing Pokedoku, and it's great to see them learning new words.

답글 달기
comment-user-thumbnail
2024년 7월 4일

이것이 십자말풀이 WORDLE 이나 strands nyt 을 만드는 방법인가요? 나는 지금까지 이 게임을 어떻게 만들었는지 몰랐습니다. 가장 놀라운 점입니다.

답글 달기

관련 채용 정보