[TS 과제 챌린지] Hangman Game - 구현 (바닐라로 useState() 적용)

조민호·2023년 10월 27일
0

TS스터디 팀원들과 함께 매주 주차별 과제를 진행합니다


https://github.com/bradtraversy/vanillawebprojects

  • 위의 링크에는 바닐라JS로 간단히 구현된 단일 페이지의 예시들이 있습니다
  • HTML/CSS는 기존에 작성된 예시를 그대로 사용하지만, JS로직은 전부 삭제하고 스스로의 로직대로 완전히 새로 작성합니다
    - 이때 , 모든 기능 구현을 JS대신 TS로 직접 변환해서 작성합니다


7주차 - Hangman Game

구현 코드

const correct = document.getElementById('word') as HTMLDivElement;
const wrong = document.getElementById('wrong-letters') as HTMLDivElement;
const duplication = document.getElementById('notification-container') as HTMLDivElement;
const popup = document.getElementById('popup-container') as HTMLDivElement;
const playAgainButton = document.getElementById('play-button') as HTMLButtonElement;
const finalMessage = document.getElementById('final-message') as HTMLElement;
const finalMessageRevealWord = document.getElementById('final-message-reveal-word') as HTMLElement;
const hangman = Array.from(document.querySelectorAll('.figure-part')) as HTMLElement[]; // HTML요소들이 들어있는 일반 배열

type answerState = {
  stateName: 'answerState';
  words: string[];
};
type wrongState = {
  stateName: 'wrongState';
  words: string[];
};
type playableState = {
  stateName: 'playableState';
  isPlayable: boolean;
};

type stateType = answerState | wrongState | playableState;

const useState = <T extends stateType>(status: T): [() => T, (state: T) => void] => {
  let initialState = status;

  const state = () => initialState as T;

  const setState = (newState: T) => {
    initialState = newState;

    // playableState상태가 변경 됐을 때는 리렌더링을 안 함
    if (newState.stateName === 'answerState') {
      answerRender();
    }
    if (newState.stateName === 'wrongState') {
      wrongRender();
    }
  };

  return [state, setState];
};

const [getAnswerState, setAnswerState] = useState({
  stateName: 'answerState',
  words: [],
} as answerState);

const [getwrongState, setwrongState] = useState({
  stateName: 'wrongState',
  words: [],
} as wrongState);

const [getPlayableState, setPlayableState] = useState({
  stateName: 'playableState',
  isPlayable: true,
} as playableState);

const getRandomAnswer = () : string => {
  const words = ['application', 'programming', 'interface', 'wizard'];

  let randomIndex = Math.floor(Math.random() * words.length);

  return words[randomIndex];
};

// 글자를 맞췄을 때의 리렌더링 로직
const answerRender = () : void => {
  const current : string [] = getAnswerState().words;
  correct.innerHTML = '';
  const compareResult = randomAnswer.split('').map((i) => {
    return current.includes(i) ? i : '';
  });
  console.log(compareResult)

  for (let i of compareResult) {
    correct.innerHTML += `<span class="letter">${i}</span>`;
  }

  let check = new Set(randomAnswer);
  if (current.length === check.size) {
    finalMessage.innerText = 'Congratulations! You won! 😃';
    finalMessageRevealWord.innerText = '';
    popup.style.display = 'flex';

    setPlayableState({
      stateName: 'playableState',
      isPlayable: false,
    });
  }
};

// 글자를 틀렸을 때의 리렌더링 로직
const wrongRender = () : void => {
  const current : string [] = getwrongState().words;

  if (current.length > 0) {
    wrong.innerHTML = `<p>Wrong</p>`;
    current.forEach((i) => {
      wrong.innerHTML += `<span>${i}</span>`;
    });
  }
  if (current.length === 0) {
    wrong.innerHTML = '';
  }

  for (let i = 0; i < hangman.length; i++) {
    if (i < current.length) hangman[i].style.display = 'block';
    if (i >= current.length) hangman[i].style.display = 'none';
  }

  if (current.length === hangman.length) {
    finalMessage.innerText = 'Unfortunately you lost. 😕';
    finalMessageRevealWord.innerText = `...the word was: ${randomAnswer}`;
    popup.style.display = 'flex';

    setPlayableState({
      stateName: 'playableState',
      isPlayable: false,
    });

    return;
  }
};

// 이미 입력했던 값을 입력했을 때 발생
const showDuplication = () : void => {
  duplication.classList.add('show');

  setTimeout(() => {
    duplication.classList.remove('show');
  }, 2000);
};

// 첫 판 시작
let randomAnswer = getRandomAnswer();
console.log(randomAnswer);
answerRender();

// 키보드 입력 이벤트
const keyboardEvent = (e: KeyboardEvent) : void  => {
  
  if (!getPlayableState().isPlayable) return;

  let currentAnswer = getAnswerState().words;
  let currentWrong = getwrongState().words;

  let keyboardInput = e.key.toLowerCase();

  if (randomAnswer.includes(keyboardInput)) {
    // 중복처리
    if (!currentAnswer.includes(keyboardInput)) {
      currentAnswer.push(keyboardInput);
      setAnswerState({
        stateName: 'answerState',
        words: currentAnswer,
      });
      return;
    }

    if (currentAnswer.includes(keyboardInput)) {
      showDuplication();
      return;
    }
  }

  if (!randomAnswer.includes(keyboardInput)) {
    // 중복처리
    if (!currentWrong.includes(keyboardInput)) {
      currentWrong.push(keyboardInput);
      setwrongState({
        stateName: 'wrongState',
        words: currentWrong,
      });
      return;
    }

    if (currentWrong.includes(keyboardInput)) {
      showDuplication();
      return;
    }
  }
};

// 재시작
const reStart = () : void => {
  
  // 정답 재생성
  randomAnswer = getRandomAnswer();
  console.log(randomAnswer);

  // 팝업창 지우기
  popup.style.display = 'none';

  // 모든 상태 초기화 및 초기화된 값을 기반으로 리렌더링
  setPlayableState({
    stateName: 'playableState',
    isPlayable: true,
  } as playableState);
  setAnswerState({
    stateName: 'answerState',
    words: [],
  } as answerState);
  setwrongState({
    stateName: 'wrongState',
    words: [],
  } as wrongState);
};

// 이벤트 할당
window.addEventListener('keydown', keyboardEvent);
playAgainButton.addEventListener('click', reStart);

기능 구현

  • 기본 세팅

    • 정답 글자수 만큼 빈 칸으로 된 문자열이 주어진다
    • 행맨이 그려지기 전 , 막대기만 존재한다
  • 키보드로 알파벳 입력

    • 정답시, 하단에 있는 빈 칸들이 하나씩 채워진다 이때 정답 문자열에 해당하는 모든 부분들이 동시에 채워져야 한다
      • 모든 빈칸을 채우게 되면 게임이 종료된다
    • 오답시, 오답 문자열이 화면에 표기되며 행맨이 하나씩 그려진다
      • 행맨은 머리 , 몸 , 왼 팔 , 오른 팔 , 왼쪽 다리 , 오른쪽 다리 순으로 그려지며 오른쪽 다리까지 그려지는 순간 게임은 종료된다
  • 게임이 끝나게 되면

    • 빈 칸을 모두 채운 경우, 성공 메세지와 재시작 여부를
    • 빈 칸을 모두 못 채운 경우, 실패 메세지와 정답을 알려주며 재시작 여부를 물어본다

css 적용 가이드

  • 글자를 맞췄을 때
    1. id="word” 인 div태그 안에 , 아래와 같이 span 요소로 입력 값을 추가해야 한다

      <div class="word" id="word">
      	<span class="letter">i</span>
      	<span class="letter">n</span>
      	<span class="letter">t</span>
      	<span class="letter"></span>
      	<span class="letter"></span>
      </div>
    2. 최종 정답을 맞춘다면

      <div class="popup-container" id="popup-container">
      	<div class="popup">
      		<h2 id="final-message"></h2>
      		<h3 id="final-message-reveal-word"></h3>
      		<button id="play-button">Play Again</button>
      	</div>
      </div>
      • .popup 요소의 display 속성을 flex로 (모달창 띄워주기)
      • h2에 성공 메세지
      • h3에 빈 문자열 (게임 실패시 정답을 알려주는 부분)
  • 글자를 틀렸을 때
    1. id="wrong-letters"인 div 태그 안에 , 아래와 같이 p요소와 span 요소로 입력값을 추가해야 한다

      <div class="wrong-letters-container">
          <div id="wrong-letters">
      	    <p>wrong!</p>
      	    <span>x</span>
      	    <span>y</span>
      			<span>z</span>
          </div>
      </div>
    2. 행맨을 그릴 때는 display속성을 block으로 해준다

    3. 행맨이 다 그려지는 횟수까지 진행됐다면 그 판은 실패한 것이다

      <div class="popup-container" id="popup-container">
          <div class="popup">
              <h2 id="final-message"></h2>
              <h3 id="final-message-reveal-word"></h3>
              <button id="play-button">Play Again</button>
          </div>
      </div>
      • .popup 요소의 display 속성을 flex로 (모달창 띄워주기)
      • h2에 실패 메세지
      • h3에 이번 판의 정답

이번 과제의 주요 구현 사항 입니다

  • 캡슐화를 통한 상태를 사용
  • 제네릭 제약조건을 통해 기존 상태들만 받아올 수 있도록 구현
  • 일방향적인 업데이트 로직 ( 오로지 상태 변경을 통해서만 화면이 리렌더링 )
  • 동일 로직 재사용 게임을 진행 할 때 , 게임 초기화 할 때 동일한 리렌더링 로직을 재사용

요소 가져오기

const correct = document.getElementById('word') as HTMLDivElement;
const wrong = document.getElementById('wrong-letters') as HTMLDivElement;
const duplication = document.getElementById('notification-container') as HTMLDivElement;
const popup = document.getElementById('popup-container') as HTMLDivElement;
const playAgainButton = document.getElementById('play-button') as HTMLButtonElement;
const finalMessage = document.getElementById('final-message') as HTMLElement;
const finalMessageRevealWord = document.getElementById('final-message-reveal-word') as HTMLElement;
const hangman = Array.from(document.querySelectorAll('.figure-part')) as HTMLElement[];

이번 로직에 사용될 DOM 요소들을 가져옵니다

여기서 행맨 그림을 그리는데 사용되는 .figure-part 의 경우, 여러개의 요소들을 가져와야 하므로

querySelectorAll로 가져오고 행맨 그림을 그릴 때, 이 요소들을 순회해야 합니다

TS에서 querySelectorAll() 을 사용하면 반환 타입이 NodeList가 되는데 NodeList형태는

Array.from을 통해서 일반 배열로 변환이 가능하므로 이 방법을 사용합니다

상태 타입 지정

type answerState = {
  stateName: 'answerState';
  words: string[];
};
type wrongState = {
  stateName: 'wrongState';
  words: string[];
};
type playableState = {
  stateName: 'playableState';
  isPlayable: boolean;
};

type stateType = answerState | wrongState | playableState;

이번 로직에서 사용되는 상태값은 3개 입니다

  • 입력한 값 중에서 정답들을 가지고 있는 상태
  • 입력한 값 중에서 오답들을 가지고 있는 상태
  • 게임이 끝났는지를 알려주는 상태

이번에도 지난주와 마찬가지로 타입에 따른 리렌더링 로직을 사용하기 때문에

discriminate union 으로 공통 키값인 stateName을 사용합니다

상태관리 함수

자세한 설명은 바닐라로 useState 구현해보기

const useState = <T extends stateType>(status: T): [() => T, (state: T) => void] => {
  let initialState = status;

  const state = () => initialState as T;

  const setState = (newState: T) => {
    initialState = newState;

    // playableState상태가 변경 됐을 때는 리렌더링을 안 함
    if (newState.stateName === 'answerState') {
      answerRender();
    }
    if (newState.stateName === 'wrongState') {
      wrongRender();
    }
  };

  return [state, setState];
};

지난주와 마찬가지로 useState를 사용하고 타입에 따른 리렌더링 로직을 구분 합니다

다만 게임이 끝났는지를 알려주는 상태인 playableState상태가 변경됐을 경우에는

리렌더링을 진행하지 않습니다

상태 선언

const [getAnswerState, setAnswerState] = useState({
  stateName: 'answerState',
  words: [],
} as answerState);

const [getwrongState, setwrongState] = useState({
  stateName: 'wrongState',
  words: [],
} as wrongState);

const [getPlayableState, setPlayableState] = useState({
  stateName: 'playableState',
  isPlayable: true,
} as playableState);
  • 위에서 언급한 상태들을 사용합니다 다만, 여기서 구현한 useState()는 실제 리액트처럼 상태를 변수로 반환하는게 아니라 함수 형태로 반환하므로 get이라는 단어를 앞에 추가해서 함수 형태라는 것을 명시합니다
  • 앞으로 키보드 입력을 할 때마다 정답 여부에 따라서 answerState , wrongState 가 변경되고 각 상태에 따른 리렌더링이 발생합니다
  • 또한 Type Assertion을 사용해서 최초의 상태값이 들어가게 되면 리터럴 형태로 타입이 고정되는 것을 막아줍니다

정답 선택

const getRandomAnswer = () : string => {
  const words = ['application', 'programming', 'interface', 'wizard'];
  
  let randomIndex = Math.floor(Math.random() * words.length);

  return words[randomIndex];
};

정답을 지정해두고 랜덤으로 0~3 사이의 인덱스를 생성해서 해당 단어를 정답으로 반환합니다

각 글자를 맞췄을 때의 리렌더링 로직

const answerRender = () : void => {
  const current : string [] = getAnswerState().words;
  correct.innerHTML = '';
  const compareResult = randomAnswer.split('').map((i) => {
    return current.includes(i) ? i : '';
  });

  for (let i of compareResult) {
    correct.innerHTML += `<span class="letter">${i}</span>`;
  }

  let check = new Set(randomAnswer);
  if (current.length === check.size) {
    finalMessage.innerText = 'Congratulations! You won! 😃';
    finalMessageRevealWord.innerText = '';
    popup.style.display = 'flex';

    setPlayableState({
      stateName: 'playableState',
      isPlayable: false,
    });
  }
};
💡 상태값을 기반으로 동작하므로 추후 게임을 초기화 할 때도 동일하게 재사용 합니다
  • 사용자가 현재까지 맞춘 문자열들이 들어있는 상태값을 가져온 다음
  • 정답 문자열과 현재 상태값을 매핑한 배열을 생성합니다 예를 들어 정답이 programming 이고 사용자가 r m i 를 입력하게 되면 매핑된 배열은 아래와 같습니다
    ['', 'r', '', '', 'r', '', 'm', 'm', 'i', '', '']
  • 이 배열을 바탕으로 span태그를 생성해서 innerHTML로 추가합니다 (미리 작성된 css를 통해 화면에 표시)
  • 현재 상태값은 중복값이 없습니다 만약 정답이 programming 이고 사용자가 m을 입력했다면 자동으로 mm이 채워지는 것입니다 그러므로 기존에 정답으로 사용되는 randomAnswer를 Set객체로 중복을 제거한 값과 비교해서 그 길이가 같다면 정답이 되는 것입니다
    progamin (8글자) === progamin (8글자)

그러므로 조건문을 사용해서 정답이 된다면 정답 관련된 로직을 진행하고

playableState상태를 false로 바꿉니다

각 글자를 틀렸을 때의 리렌더링 로직

const wrongRender = () : void => {
  const current : string [] = getwrongState().words;

  if (current.length > 0) {
    wrong.innerHTML = `<p>Wrong</p>`;
    current.forEach((i) => {
      wrong.innerHTML += `<span>${i}</span>`;
    });
  }
  if (current.length === 0) {
    wrong.innerHTML = '';
  }

  for (let i = 0; i < hangman.length; i++) {
    if (i < current.length) hangman[i].style.display = 'block';
    if (i >= current.length) hangman[i].style.display = 'none';
  }

  if (current.length === hangman.length) {
    finalMessage.innerText = 'Unfortunately you lost. 😕';
    finalMessageRevealWord.innerText = `...the word was: ${randomAnswer}`;
    popup.style.display = 'flex';

    setPlayableState({
      stateName: 'playableState',
      isPlayable: false,
    });

    return;
  }
};
💡 상태값을 기반으로 동작하므로 추후 게임을 초기화 할 때도 동일하게 재사용 합니다
  • 사용자가 현재까지 틀린 문자열들이 들어있는 상태값을 가져온 다음
  • 만약 오답이 한개라도 있으면 innerHTML을 통해 값을 추가해서 그 내용을 보여주고 (미리 작성된 css를 통해 화면에 표시)
  • 오답 길이만큼 행맨을 그려줍니다 이 때 , 나중에 게임을 재시작을 하게 되면 current.length가 0이 되는데 hangman[0]부터 none으로 초기화 해주기 위해 none 으로 바꿔주는 조건문에 > 대신 >=를 사용합니다 그렇지 않으면 i가 0일 때는 아무런 동작을 하지 않으므로 hangman[0]은 이전판의 block 속성이 그대로 남아있게 됩니다
  • 만약 오답의 갯수가 행맨 전체 갯수와 동일하다면 게임이 끝나게 되므로 오답 관련된 로직을 진행하고 playableState 상태값을 변경해 줍니다

중복된 값 입력시 경고문 발생

const showDuplication = () : void => {
  duplication.classList.add('show');

  setTimeout(() => {
    duplication.classList.remove('show');
  }, 2000);
};

키보드 입력 이벤트

const keyboardEvent = (e: KeyboardEvent) : void  => {
  
  if (!getPlayableState().isPlayable) return;

  let currentAnswer = getAnswerState().words;
  let currentWrong = getwrongState().words;

  let keyboardInput = e.key.toLowerCase();

  if (randomAnswer.includes(keyboardInput)) {
    // 중복처리
    if (!currentAnswer.includes(keyboardInput)) {
      currentAnswer.push(keyboardInput);
      setAnswerState({
        stateName: 'answerState',
        words: currentAnswer,
      });
      return;
    }

    if (currentAnswer.includes(keyboardInput)) {
      showDuplication();
      return;
    }
  }

  if (!randomAnswer.includes(keyboardInput)) {
    // 중복처리
    if (!currentWrong.includes(keyboardInput)) {
      currentWrong.push(keyboardInput);
      setwrongState({
        stateName: 'wrongState',
        words: currentWrong,
      });
      return;
    }

    if (currentWrong.includes(keyboardInput)) {
      showDuplication();
      return;
    }
  }
};
  • playableState가 false라면 게임이 종료된 것이므로 더이상 진행하지 않습니다
  • 상태값을 받아와서 정답이라면 정답 상태에 , 오답이라면 오답 상태에 각각 값을 추가하고 상태 업데이트 함수를 사용합니다 이때 위에 작성한 리렌더링 로직이 실행됩니다
  • 만약 중복된 값이라면 별다른 로직을 실행하지 않고 중복 경고창을 띄웁니다

재시작

const reStart = () : void => {
  
  // 정답 재생성
  randomAnswer = getRandomAnswer();
  console.log(randomAnswer);

  // 팝업창 지우기
  popup.style.display = 'none';

  // 모든 상태 초기화 및 초기화된 값을 기반으로 리렌더링
  setPlayableState({
    stateName: 'playableState',
    isPlayable: true,
  } as playableState);
  setAnswerState({
    stateName: 'answerState',
    words: [],
  } as answerState);
  setwrongState({
    stateName: 'wrongState',
    words: [],
  } as wrongState);
};
  • 전역으로 선언된 randomAnswer 변수에 getRandomAnswer() 를 통해 새로운 정답을 넣어줍니다
  • 팝업창을 지우고
  • 모든 상태들을 초기화 하며 동일한 리렌더링 로직을 재사용 합니다 이때 진행되는 리렌더링 로직은 상태를 기반으로 하기에 자동으로 행맨은 지워지고 , 하단의 정답 현황도 지워집니다
profile
웰시코기발바닥

0개의 댓글