효과는 굉장했다! 포켓몬 약점 계산기 만들기

창우·2024년 6월 16일
1

ToyProject

목록 보기
4/7
post-thumbnail

개요

포켓몬 약점 계산기 (poke-match-type)

  • 요즘들어 ‘포케로그’라는 게임이 스트리머 사이에서 유행이다.
  • 그래서인지 주변에서 하나, 둘 포켓몬 게임을 처음 접해보는 사람들도 생기고 있다.
  • 포켓몬의 전투 시스템은 철저한 계산을 바탕으로 설계되어 있어서 전투 수치를 이해하는것이 중요하다.
  • 그 중, 가장 기본이자 핵심 수치는 바로 ‘약점’ 시스템이다
  • 하지만 입문자의 경우 약점 계산을 헷갈려 하는 경우가 많다 ( 내가 그렇다…)
  • 마침, pokeAPI에서 포켓몬과 타입에 대한 API를 제공한다!
  • 또 마침 타입 스크립트 학습내용에 대한 적용이 필요하다!

구현 목표

  • 타입스크립트를 활용한 구현
  • 학습한 라이브러리 접목
    — tanstackQuery를 활용한 통신 데이터 캐싱, 동기화
    — useHookForm을 활용한 입력 데이터 관리
  • 모바일 우선 구현 + 반응형
  • 프론트엔드 서버 배포

구현

1. 기획 및 설계

  • FIgma를 통해 UI 디자인을 우선적으로 설계하였다
  • 모바일 해상도(360px)을 기준으로, 해상도가 커질수록 콘텐츠가 최대한 유연하게 확장할 수 있도록 설계하였다
  • 사용 데이터는 오픈 API인 POKEAPI 를 활용하였기에 서버 구축은 필요하지 않았다.
  • 프로젝트의 볼륨 역시 크지 않아 레이아웃 설계에도 별다른 고민을 하지 않았다.

2. 컴포넌트 구현

  • 타입 스크립트를 학습한 후, 처음으로 프로젝트에 적용하며 구현하였다
  • 사전에 오류를 전부 검출하고 예측하니, 배포를 위한 빌드까지 확실히 안정적인 코드를 작성할 수 있었다.
  • 명시적으로 타입을 지정함으로 협업을 함에 생길 수 있는 소통 오류를 방지( 협업을 하지는 않았지만) 할 수 있다는 점 또한 정말 매력적이고 유용하다 느꼈다.
  • 하지만 새로운 배움은 늘 그렇듯, 매끄럽게 사용하지 못한 점이 아쉬웠다.
  • 타입 모델을 미리 정의해두고, 타입 검사를 통해 오류를 검출하고 싶었지만, 오류에 대한 타입을 이후에 지정하는 경우가 많아 타입이 중복되는 등 유연한 관리가 되어지지 않았다.

3. 포켓몬 검색 및 연관검색어

  • 사용자의 입력을 받으면, 입력받은 내용과 연관된 내용을 출력하여 데이터에 접근할 수 있도록 하는 검색 기능을 구현하였다.
      const filteredSuggestions = pokemonNames.filter((pokemon) => {
        return pokemon.name.startsWith(searchTerm);
      });
  • 검색어를 입력하면, 실시간으로 검색 데이터와 비교하고 이를 특정 문자열로 시작되는지 확인하는 startsWith메소드로 filter 하여 연관 검색어를 받도록 하였다.
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (suggestions.length > 0) {
      if (event.key === "ArrowDown") {
        setActiveSuggestionIndex((prevIndex) =>
          prevIndex === suggestions.length - 1 ? 0 : prevIndex + 1
        );
      } else if (event.key === "ArrowUp") {
        setActiveSuggestionIndex((prevIndex) =>
          prevIndex === 0 ? suggestions.length - 1 : prevIndex - 1
        );
      } else if (event.key === "Enter") {
        if (
          activeSuggestionIndex >= 0 &&
          activeSuggestionIndex < suggestions.length
        ) {
          handleSuggestionClick(suggestions[activeSuggestionIndex]);
        }
      }
    }
  };
  • 연관 검색어는 리스트 형태로 구성되어있으며 다음과 같은 keyboardEvent를 제공해 키보드 입력을 통해 검색어에 접근할 수 있도록 하였다

검색 기능에서 가장 고민한 것은 연관 검색어에 필요한 데이터의 추출이였다.
pokeAPI에서 한글 도감 리스트를 제공하지 않았기 때문이다.
물론 한글 정보를 제공하지만 이를 위해서는 포켓몬 개별 ID로 접근하여 데이터를 통신해야하기때문에 연관 검색을 구현 하기 위해서는 불필요한 요청들이 다수 필요했다.
이를 해결하기 위해 Cheerio라이브러리를 활용한 스크래핑을 통해 포켓몬 도감 사이트에서 다음과 같은 검색에 필요한 데이터를 직접 추출하였다

/* 스크래핑 코드 추가*/
/* pokemonData.json */

[
  {
    "no": 1, // 도감번호 
    // (데이터 요청시 pokeAPI에서 해당 번호에 따라 구분된 데이터를 제공한다)
    "name": "이상해씨" // 연관검색어에 출력하는 데이터
  },
  {
    "no": 2,
    "name": "이상해풀"
  },
  {
    "no": 3,
    "name": "이상해꽃"
  },
.
.
]

4. 타입 선택

  • 전체 타입을 나열하고, 사용자가 임의 타입을 클릭하면 최대 두 개 까지의 해당하는 타입에 대한 정보를 제공하는 기능을 구현하였다.
/* SelectType.tsx */

const SelectType = ({ checkedType, setCheckedType }: SelectTypeProps) => {
  const handleSelect = (type: any) => {
    const isAlreadyChecked = checkedType.some(
      (checked) => checked.typeNo === type.no
    );

    if (isAlreadyChecked) {
      setCheckedType(
        checkedType.filter((checked) => checked.typeNo !== type.no)
      );
    } else {
      if (checkedType.length >= 2) return;
      setCheckedType([...checkedType, { typeNo: type.no, name: type.name }]);
    }
  };
  • Props로 받은 상태변수에 사용자가 특정 타입을 클릭하면 해당 타입ID를 담는다.
  • some 메소드를 통해 순회하여 배열에 이미 존재하는 타입ID인지 체크하고, 해당하는 경우에는 타입 배열에서 제거하는것으로 구현하였다.

5. 데미지 경감/추가 계산


/* getDetailType.ts */

export const getDetailType = async (searchTypes: number[]) => {
  const initialTypes: IDamageData[] = JSON.parse(
    JSON.stringify(defaultTypesData)
  ); // 데미지 경추감이 반영되지 않은 퓨어한 JSON 형식의 데이터를 받아온다

  const fetchAllDetails = async () => {
    const detailPromises = await fetchDetailType(searchTypes);
    const detailResponses = await Promise.all(detailPromises);
     // pokeAPI로 부터 타입 별 데미지 연관 데이터들을 가공하여 받아온다
    const allDamageRelations = detailResponses.flat();
    await getCirculType(initialTypes, allDamageRelations);
    // 퓨어한 데이터들의 데미지 연관 데이터를 적용시킨다
    return initialTypes;
  };

  const getCirculType = async (
    updateTypes: IDamageData[],
    damageRelations: IDamageRelations[]
  ) => {
    for (let relation of damageRelations) {
      relation.types.forEach((element) => {
        const typeToUpdate = updateTypes.find(
          (type) => type.name === element.name
        );
        if (typeToUpdate) {
          switch (relation.key) {
            case "doubleDamage":
              typeToUpdate.damage *= 2;
              break;
            case "halfDamage":
              typeToUpdate.damage *= 0.5;
              break;
            case "noDamage":
              typeToUpdate.damage *= 0;
              break;
          }
        }
      });
    }
  };
  const detailTypes = await fetchAllDetails();
  return detailTypes;
};
  • 해당 프로젝트에 가장 핵심이 되는 코드이다.
  • 의존하는 상태가 변경되는 경우( 포켓몬 검색 또는 타입 선택 ) 해당하는 함수가 실행되어진다.
  • 입력받은 타입 ID의 배열 numbers를 순회하며 pokeAPI로부터 타입의 2배, 0.5배, 0배 데미지 연관값들을 받아온다
  • 각 타입별로 경/추감이 기록되지 않은 퓨어한 데이터를 받아와 응답받은 연관값을 계산하여 반환한다.
  • 포켓몬 전투 시스템에서 2개 이상의 타입 데미지 계산은 각각 독립된 타입 데미지 계산의 곱이므로 이를 적용하여 구현하였다.

코드를 충분하게 짠 것 같아도 데미지 데이터 계산 값을 올바르게 반환하지 않는 이슈를 겪었다. 이는 해당 기능이 비동기적인 방식으로 데이터를 요청하고, 가공하는 과정을 가지고 있기에 비동기 순서를 명시하는 await-async 코드의 흐름이 중요했기 때문이었다. 각 요청별로 콘솔을 찍는 디버깅 후 흐름을 파악하여 수정함으로 해결할 수 있었다.

6. 포켓몬 특성 추가계산

  • 사용자로부터 특성을 입력받으면 특성에 따른 타입 데미지 계산을 추가록 하도록 하였다
  • 데미지 계산 로직은 동일하지만, 데미지 경/추감에 영향을 미치는 특성 데이터만을 추출하고, 그 값을 적용하는 로직이 필요했다
/* getAddAbility.ts */
type typeCalculatorType = { type: string; effects: number };

export const getAddAbility = async (
  types: IDamageData[],
  selectedAbility: string
) => {
  const typeCalculator: typeCalculatorType[] = [];

  switch (selectedAbility) {
    case "dry_skin":
      typeCalculator.push({ type: "fire", effects: 1.25 });
      typeCalculator.push({ type: "water", effects: 0 });
      break;
    case "heatproof":
      typeCalculator.push({ type: "fire", effects: 0.5 });
      break;
      .
      . // 20여개의 특성에 따른 계산값
      .
      }

  if (typeCalculator.length > 0) {
    types = types.map((type) => {
      typeCalculator.forEach((element) => {
        if (type.name === element.type) {
          type.damage *= element.effects;
        }
      });
      return type;
    });
  }

  return typeCalculator;
};
  • 사용자가 특성을 입력하면 해당하는 함수를 동작한다
  • 기존에 포켓몬 선택 또는, 타입 선택을 통해 이미 데미지 계산이 완료된 데이터 ‘types’와 특성 명을 인자로 받는다
  • 특성 명을 switch 구문으로 분기하여 특성에 영향을 받는 타입과, 해당 타입 데미지 경추감 데이터 effects를 객체로서 배열에 담는다
  • 기존에 계산이 완료된 데이터에 추가적인 계산을 하여 반환함으로 구현하였다.
/* typeCard.tsx */
  useEffect(() => {
    const fetchData = async () => {
      let result = await getDetailType(typeNo);
      if(selectedAbility && selectedAbility !== "") {
        getAddAbility(result, selectedAbility);
      }
      let groupResult = await getGroupType(result);
      setTypeRelations(groupResult);
    };
    fetchData();
  }, [MatchTypes, selectedAbility]);
  • 의존성이 변경되어 데미지 계산이 동작될 때, 특성이 체크되어있는지 확인하고, 체크가 된 경우에만 getAddAbility 함수를 실행한다.
  • 모든 계산이 끝난 데이터는 직관적이게 비교할 수 있도록 데미지 비율 (x4, x2 x1 … 등)으로 그룹화 하여 오름차순으로 제공하도록 하였다.

마치며

  • 아쉬운 점이 남는다면 사실 완전하게 구현하지는 못했다. (일부 특성 미반영, 폼 별 포켓몬 구분 없음)
  • 또한 새로 학습한 기술들 tanstackQuery, useHookForm을 활용하지 못했다.
  • 이 부분은 코드를 수정하고 리팩토링하면서 꼭 적용해보려고한다
  • 그래도 타입 스크립트를 처음 적용했고, 그 장점을 느낄 수 있어서 굉장히 유익했던 시간이었다.
  • 프론트엔드 서버 뿐만이지만, 빌드-배포 경험도 해보았다. 생각보다 엄청 간단했다.
  • 이틀 가까운 시간동안 정말 몰입하며 개발하였다. 너무 재미있었다.
profile
물을 줘야지😂

0개의 댓글