8/17 과제 제출 - AI 서비스 완성! AI 웹개발 취업캠프 [NIPA/정보통신산업진흥원]

Alicia·2023년 8월 17일
0

AI_Web_nipa

목록 보기
20/31
post-thumbnail

리액트 카드 앱 만들기 프로젝트

카드를 소개하는 간단한 프로젝트..!
지금까지 이론적으로 배웠던 로직들과 문법들을 실제로 적용해보면서 실습하는 미니프로젝트였다.
주제는 카드를 만들어 주제(아이템)에 대한 소개를 만드는 거였는데
어떤 주제로 할지 생각해보다가
요즘 인기있는 오픈형 mmorpg 게임 원신을 자주 플레이 함으로써
마침 인게임 컨텐츠인 "일곱 성인의 소환"이라는 유희왕처럼 카드형 미니게임이 떠올라서
캐릭터들을 애정하는 만큼 딱 카드소개 이기도 하니까 이걸로 결정하였다..! ㅎㅎ

<원신> 일곱성인의 소환 캐릭터 카드 덱

https://jungae-card-app.netlify.app/


제일 처음에 만나는 화면

위에 로고 똭 박아주고 제목


App.js

import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Menu } from "./styledComp";
import Cards from "./Cards";
import Detail from "./Detail";
import SubCards from "./SubCard";
import { useSelector } from "react-redux";

function App() {
  return (
    <BrowserRouter>
      <Menu>
        <img src="/img/white-logo-removebg-preview.png" alt="logo" style={{ width: 120, height: 100 }}></img>
        <h2 style={{ margin: 0 }}>일곱 성인의 소환</h2>
      </Menu>
      <Routes>
      
      /* 서브카드로 이동 ex) /원소이름(불) */
        {useSelector((state) => state.contents).map((content, idx) => {
          return <Route path={content.path} key={idx} element={<SubCards subCards={idx} />} />;
        })}

		/* 서브카드의 상세페이지로 이동*/
        {useSelector((state) => state.contents).map((content, idx) => {
          return content.detail.subCards.map((subCard, subIdx) => (
            <Route
              path={subCard.path}
              key={subIdx}
              element={<Detail content={subCard} />} // 서브 카드의 전체 정보를 전달
            />
          ));
        })}

        <Route path="/" element={<Cards />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

★★ 리덕스에 활용할 배열 객체 만들기

import { createStore } from "redux";

// 카드 개별 항목에 대한 내용을 담은 배열
// detail: 상세 페이지에 담고 싶은 내용을 객체 리터럴로 표현한 키

const contents = [
  {
    path: "/wind",
    imagePath: "/img/wind.jpg",
    title: "바람",
    detail: {
      subCards: [
        {
          path: "/wind/jin", // 서브카드1의 경로
          imagePath: "/img/character/wind/jin.jpg",
          title: "진",
          detailText: {
            type: "한손검",
            world: "몬드",
            imagePath: "/img/character/wind/jin.jpg",
            name: "민들레 기사 · 진",
            sub: "그녀는 언제나 자신이 아직 멀었다고 생각한다. 최종 승리를 거머쥐기 전까지는",
            description:
              "별도의 원소 반응 없이 적의 캐릭터를 강제로 다음 캐릭터로 넘길 수 있는 원소전투 스킬을 통해 적의 운영을 방해하고, 무난한 수준의 자체 딜링 능력과 원소폭발의 힐을 통해 원본 캐릭터처럼 하이브리드로 활용할 수 있다. 설탕이 이전 캐릭터로 교체시킨다면 진은 다음 캐릭터로 교체시킨다는 차이점이 있다.[72] \n   \n  특성상 확산 반응을 쓰지 않으면 설탕과 마찬가지로 딜 고점이 아쉬운 캐릭터가 되기 때문에 원소 부여를 잘 해줄 수 있는 캐릭터와의 조합이 필수적이다. 확산시킨 뒤에는 원소가 지워지기 때문에, 다시 원소를 발라줄 수 있는 캐릭터(행추, 향릉, 피슬) 등이 어울린다. 현재 시점에서 바람 법구 캐릭터인 설탕에 비해 일반 공격으로 바람 부여가 안 된다는 단점 때문에 단독으로 들어가는 경우는 잘 없고, 설탕과 타 원소 캐릭터를 넣어 확산 광역딜에 유지력까지 챙긴 덱에서 사용된다. 바람 공명을 통한 빠른 교체를 통해 적의 출전 캐릭터를 내 입맛대로 움직일 수 있는 방식. 힐 능력도 나쁘지 않은 수준이지만 그것만 보고 채용하기에는 좀 아쉽기에, 여러모로 원본 캐릭터의 장단점을 답습하는 느낌의 캐릭터 카드이다. \n   \n  민들레 영역이 소환물 취급이기 때문에 호두에 맞으면 너무 쉽게 카운터 당할 수 있다는 점이 치명적이다.",
          },
        },
        {
          path: "/wind/benti", // 서브카드1의 경로
          imagePath: "/img/character/wind/benti.jpg",
          title: "벤티",
          detailText: {
            type: "활",
            world: "몬드",
            imagePath: "/img/character/wind/benti.jpg",
            name: "바람의 시인 · 벤티",
            sub: "「사계절이 지나도 서풍은 절대 그치지 않아」 \n  「뭐, 당연하게도 이 노래의 주역은 그들이 아니라 나야」 \n  「음유시인이 없다면 누가 이걸 노래하겠어?」",
            description:
              "3.7 업데이트로 추가된 캐릭터 카드. 원소 스킬을 사용시 이벤트 카드인 근무 교대 시간을 2회 사용할 수 있으며[75], 원소 폭발로 소환물을 소환해서 턴 종료시에 상대를 타격하면 강제로 자신이 마주보는 캐릭터로 끌려오도록 하는 하드 CC기를 보유하고 있다. 원소 폭발로 끌려오는 효과는 벤티가 쓰러져도 무관하게 앞으로 끌려오게 된다. \n   \n  전용 장비를 착용하면 근무 교대 시간에 더해서 북극 훈제 닭을 추가로 부여한다. \n   \n  첫 턴에 바람 공명과 벤티의 전용 장비를 모두 발동했을 때, 상대하는 입장에서는 황당한 초동이 가능한데, 아무 캐릭터가 원소를 묻힘 -> 바람 공명 -> 벤티가 전용 장비를 착용하여 확산 -> 다시 원래 캐릭터로 평타를 치는 것이 가능하다. 궁 게이지 요구 캐릭터라면 단순히 그냥 2번 때리는 것과 동일하게 게이지를 풀로 채웠는데 상대는 대기 캐릭터까지 피해를 입은 데다가 벤티도 궁 게이지를 1개 채웠고 교대 주사위 감소 -1까지 남았으니 다음 턴에 압도적인 리턴을 챙길 수 있는 셈. \n   \n  약점으로는 역시나 단기 폭딜 덱이다. 벤티가 궁을 쓰기 전에 쓰러지면 플랜이 다소 어그러질 수 있다.",
          },
        },
        {
          path: "/wind/kazuha", // 서브카드1의 경로
          imagePath: "/img/character/wind/kazuha.jpg",
          title: "카즈하",
          detailText: {
            type: "한손검",
            world: "이나즈마",
            imagePath: "/img/character/wind/kazuha.jpg",
            name: "파도를 쫓는 단풍·카에데하라 카즈하",
            sub: "꽃과 새를 주우며, 달빛 비치는 바람의 먼 길을 간다",
            description:
              "[폭풍_베기] 상태 \n  부착 캐릭터가 낙하 공격 발동 시: 가하는 원신 물리 아이콘물리 피해가 원신 바람원소 유색바람 원소 피해로 전환 및 가하는 피해+1pt \n  캐릭터 스킬 사용 후: 해당 효과가 제거된다.",
          },
        },
        {
          path: "/wind/xiao", // 서브카드1의 경로
          imagePath: "/img/character/wind/xiao.jpg",
          title: "소",
          detailText: {
            type: "장병기",
            world: "리월",
            imagePath: "/img/character/wind/xiao.jpg",
            name: "호법야차 · 소",
            sub: "호법야차, 정요항마",
            description:
              "[야차의_가면] 출전 상태 \n  부착 캐릭터가 가하는 원신 물리 아이콘물리 피해가 원신 바람원소 유색바람 원소 피해로 전환되며, 캐릭터가 가하는 원신 바람원소 유색바람 원소 피해가 1pt 증가한다. \n  부착 캐릭터가 낙하 공격 시: 피해 추가+2pt \n  부착 캐릭터가 출전 캐릭터일 때 아군이 「캐릭터 교체」 행동 시: 원소 주사위 소모 개수-1 (턴당 1회) \n  지속 턴: 2 \n   \n  [낙하_공격] \n  캐릭터가 「출전 캐릭터」로 교체된 후, 이번 턴 내의 다음 전투 행동이 「일반 공격」일 경우 「낙하 공격」으로 간주된다.",
          },
        },
      ],
    },
  },
  {
    path: "/stone",
    imagePath: "/img/stone.jpg",
    title: "바위",
    detail: {
      subCards: [
        {
          path: "/stone/eto", // 서브카드1의 경로
          imagePath: "/img/character/stone/eto.jpg",
          title: "이토",
          detailText: {
            type: "양손검",
            world: "이나즈마",
            imagePath: "/img/character/stone/eto.jpg",
            name: "하나미자카 쾌걸 · 아라타키 이토",
            sub: "「아라타키 카드게임 왕중왕 이토」",
            description:
              "[난신의_괴력] 상태 \n  부착 캐릭터가 강공격 시: 가하는 피해+1pt. 만약 사용 가능 횟수가 2 이상일 경우 해당 스킬 발동 시 소모하는 원소 주사위 임의무색 원소가 1개 감소한다. \n   \n  사용 가능 횟수: 1 (중첩 가능, 최대 중첩수: 3회) \n  [분노의_귀왕] 상태 \n  부착 캐릭터의 일반 공격으로 가하는 피해+2pt, 가하는 원신 물리 아이콘물리 피해가 원신 바위원소 유색바위 원소 피해로 전환된다. \n   \n  지속 턴: 2 \n  부착 캐릭터가 일반 공격 후: 부착 캐릭터에게 난신의 괴력을 부착한다. (턴당 1회)",
          },
        },
        {
          path: "/stone/jongli", // 서브카드1의 경로
          imagePath: "/img/character/stone/zongli.jpg",
          title: "종려",
          detailText: {
            type: "장병기",
            world: "리월",
            imagePath: "/img/character/stone/zongli.jpg",
            name: "속세 한유 · 종려",
            sub: "감춰진 옥은 온 세상을 밝히고, 하늘의 찬란한 별은 자유롭기 그지없다",
            description:
              "3.7 업데이트로 추가된 캐릭터 카드. 원소 전투 스킬은 바위 소환물을 기본으로 깔아주고 주사위 코스트를 더 지불하면 데미지 증가와 실드 효과가 추가로 적용되며, 원소 폭발은 적당한 데미지에 단독으로 상대를 석화시키는 고성능으로 무장했다. \n   \n  종려를 주력으로 쓴다면 같이 등장한 관홍의 창과 견고한 천암 착용을 목표로 하며, 최소 2턴/가능하면 3턴 이상 동안 바위 원소와 반응하는 소환물이 필요하다. \n  빌드가 끝난 종려는 난공불락이라고 볼 수 있는데, 기본적으로 관홍의 창과 실드 유지로 상시 +2의 데미지가 추가되면서 기본 평타만 해도 4가 되는데다가 실드 효율 증가에 바위 결정과 옥홀 방패와 바위 공명 카드로 끊임없이 실드를 생성하다가 천성으로 사이클을 망가뜨리기까지 한다. \n   \n  종려는 실드로 눌러앉는 스타일이지만 장기전으로는 부적합한데, 즉발 실드를 얻는데 5코스트나 들고 CC기는 3게이지가 필요한 천성뿐이라 종려 단독으로는 억제력이 떨어지므로 5~6턴 안에 주사위를 펌핑해서 빠르게 승부를 보는 전략을 권장한다. \n   \n  하이퍼캐리 카드들이 그렇듯이 맨몸으로는 약하고 주사위 소모가 극심하므로, 소환물을 깔아주는 캐릭터가 버틸 동안 최대한 빠르게 빌드를 할 줄 알면서 주사위 관리도 능숙한 중상급자 지향 캐릭터 카드로 평가할 수 있다.",
          },
        },
        // 추가적인 서브카드도 이렇게 계속 추가 가능
      ],
    },
  },
  {
    path: "/spark",
    imagePath: "/img/spark.jpg",
    title: "번개",
    detail: {
      subCards: [
        {
          path: "/spark/saino", // 서브카드1의 경로
          imagePath: "/img/character/spark/saino.jpg",
          title: "사이노",
          detailText: {
            type: "장병기",
            world: "수메르",
            imagePath: "/img/character/spark/saino.jpg",
            name: "비밀 심판 · 사이노",
            sub: "카드에는 뜨거운 사막의 태양과도 같은 대풍기관의 열정이 간직되어 있다",
            description:
              "패시브의 빙의 스택을 활용한 빌드업으로 자신을 강화시키는 캐릭터. 빙의는 2스택시 평타에 번개부여, 4스택 이후 추뎀 2라는 꽤 걸출한 버프지만 스택 적립 조건이 턴 종료시 1스택 추가 혹은 원소 폭발 사용시 2스택 추가라는 그다지 간단한 조건은 아니다보니 실전운용이 다소 난해하고 상대 조합에 따라 플레이가 꼬이기 쉽다.",
          },
        },
        {
          path: "/spark/raiden", // 서브카드1의 경로
          imagePath: "/img/character/spark/raiden.jpg",
          title: "라이덴",
          detailText: {
            type: "장병기",
            world: "이나즈마",
            imagePath: "/img/character/spark/raiden.jpg",
            name: "일심정토·라이덴 쇼군",
            sub: "뇌명 적멸, 부세의 포영",
            description:
              "3.7 업데이트로 등장한 카드. 원소폭발 발동시 나머지 캐릭터 카드가 원소 에너지를 2pt 획득한다. 라이덴을 쓴다면 백안지륜도 필드에 있을테니 효과로 원소폭발의 피해를 1 높일 수 있는것도 큰 이점. \n   \n  다만 행추와 같은 이유로 상대가 1턴에 한 명 자르고 시작할 수 있는 조합이라면 사용시 주의가 필요하다. 또한 첫 2턴을 내줘야 한다는 점[66]도 큰데, 상대가 그동안 배째고 빌드업을 해버릴 수 있기 때문. \n   \n  원소폭발의 미친 성능에 묻혀지는 감이 있으나, 원소스킬 자체의 부착력도 좋은 편이라 템포가 느린 덱이라면 피슬 대신 써도 무방하다.",
          },
        },
        {
          path: "/spark/miko", // 서브카드1의 경로
          imagePath: "/img/character/spark/miko.jpg",
          title: "미코",
          detailText: {
            type: "법구",
            world: "이나즈마",
            imagePath: "/img/character/spark/miko.jpg",
            name: "속세를 비웃는 백 가지 자태·야에 미코",
            sub: "「지혜와 미모를 겸비한 야에 미코 님」",
            description: "[천호_뇌정] 상태 \n  아군이 행동하기 전: 원신 번개원소 유색번개 원소 피해를 3pt 가한다. \n  사용 가능 횟수: 1",
          },
        },
      ],
    },
  },
  
.
.
.
.

// 이 앱에서는 상태에 대한 변경(수정 및 삭제)을 진행하지 않는다.
function reducer(state, action) {
  return { contents };
}

// 리듀서 : 상태 관리를 담당하는 함수
// 리듀서를 전달받아서 저장소를 생성하는 함수가 바로 createstore

export const store = createStore(reducer);

Cards.js

import React from "react";
import { NavLink } from "react-router-dom";
import { Items, Item, Image } from "./styledComp";
import { useSelector } from "react-redux";

const Cards = () => {
  const contents = useSelector((state) => state.contents); // 배열 카드 항목으로 구성된 배열
  return (
    <Items>
      {contents.map((content, idx) => {
        return (
          <Item key={idx}>
            <NavLink to={content.path}>
              <Image url={content.imagePath}></Image>
            </NavLink>
            <h1 style={{ textAlign: "center" }}>{content.title}</h1>
          </Item>
        );
      })}
    </Items>
  );
};

export default Cards;

메인화면에 있는 카드들의 컴포넌트 구성하기

subCard.js

import React from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { Items, SubItem, Image, Button } from "./styledComp";
import { useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";

const SubCards = (props) => {
  const contents = useSelector((state) => state.contents); // 배열 카드 항목으로 구성된 배열
  const navigate = useNavigate();
  
  // 원소별로 있는 서브카드들의 인덱스로 있는 카드정보 가져오기
  const subCards = contents[props.subCards].detail.subCards;

  console.log(subCards);

  return (
    <>
    
    // 버튼은 FontAwesome으로 장식
      <Button onClick={() => navigate(-1)}>
        <FontAwesomeIcon icon={faArrowLeft} />
      </Button>
      <Items>
        {subCards.map((content, idx) => {
          return (
            <SubItem key={idx}>
              <NavLink to={content.path}>
                <Image url={content.imagePath}></Image>
              </NavLink>
              <h3 style={{ textAlign: "center", backgroundColor: "black", borderRadius: "20px" }}>{content.title}</h3>
            </SubItem>
          );
        })}
      </Items>
    </>
  );
};

export default SubCards;

Detail.js

import React from "react";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom"; // useNavigate : 컴포넌트 이동 기록을 다루는 함수
import { Button, DetailBox } from "./styledComp";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";

//props.content
const Detail = (props) => {
  const navigate = useNavigate();
  
  /* Detail에게 content children인 detailText의 구조분해 할당 */
  const { name, sub, description, imagePath, type, world } = props.content.detailText;

  return (
    <>
      <Button onClick={() => navigate(-1)}>
        <FontAwesomeIcon icon={faArrowLeft} />
      </Button>
      <DetailBox>
        <img src={imagePath} alt="img" style={{ width: 120, height: 220 }} />
        <span
          style={{
            backgroundColor: "#F4F1D0",
            fontWeight: "bold",
            borderRadius: "40px",
            fontSize: 14,
            padding: "0 10px",
            margin: "20px 5px 0 10px",
          }}>
          {type}
        </span>
        <span
          style={{
            backgroundColor: "#559282",
            color: "white",
            borderRadius: "40px",
            fontSize: 14,
            padding: "0 10px",
            marginBottom: "20px",
          }}>
          {world}
        </span>
        <h2>{name}</h2>
        
        /* 표 레이아웃으로 깔끔한 라벨 표시 */
        <table>
          <tr>
            <th style={{ width: 100, border: "2px solid #ffffff", backgroundColor: "#333333", color: "white" }}>스토리</th>
            <td style={{ padding: "1rem", fontWeight: "600" }}>{sub}</td>
          </tr>
          <tr>
            <th style={{ width: 100, border: "2px solid #ffffff", backgroundColor: "#2F3030", color: "white" }}>특성</th>
            <td style={{ whiteSpace: "pre-line", padding: "1rem", fontWeight: "600" }}>{description}</td>
          </tr>
        </table>
        {/* 다른 내용들을 렌더링하는 부분 */}
      </DetailBox>
    </>
  );
};

export default Detail;

styledComponent.js

import styled from "styled-components";

// 상단에 제목이 표시되는 바
export const Menu = styled.div`
  position: sticky;
  top: 0;
  width: 100%;
  height: 100px;
  font-size: 20px;
  color: #ffffff;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  margin: 1.2rem 0;
`;

//카드 여러개가 포함되는 컨테이너 블록
export const Items = styled.div`
  display: flex;
  flex-wrap: wrap;
  width: 80%;
  margin: 0 auto;

  @media all and (max-width: 1500px) {
    width: 80%;
  }
  @media all and (max-width: 1000px) {
    width: 100%;
  }
`;

//카드 한장
// 스타일드컴포넌트에서 &의 의미 : 자기 자신

export const Item = styled.div`
  cursor: pointer;
  width: 20%;
  height: 500px;
  margin: 2%;
  border-radius: 20px;
  color: #ffffff;
  background-color: #393939;
  overflow: hidden;
  &:hover {
    transform: translate(0, -20px);
  }
  @media all and (max-width: 800px) {
    width: 46%;
  }
  @media all and (max-width: 500px) {
    width: 98%;
  }
`;

//서브카드 스타일 컴포넌트
export const SubItem = styled.div`
  cursor: pointer;
  width: 21%;
  height: 420px;
  margin: 2%;
  border-radius: 20px;
  color: #ffffff;
  background-color: #393939;

  &:hover {
    transform: translate(0, -20px);
  }
  @media all and (max-width: 800px) {
    width: 46%;
  }
  @media all and (max-width: 500px) {
    width: 98%;
  }
`;

// 카드 내부에 이미지를 표시할 필요가 있을 때 사용하는 태그
export const Image = styled.div`
  height: 410px;
  background-image: url(${(props) => props.url});
  background-size: cover;
  background-repeat: no-repeat;
  @media all and (max-width: 500px) {
    background-size: 100% 100%;
  }
`;
export const ColorBox = styled.div`
  height: 250px;
  background-color: ${(props) => props.color};
  background-repeat: no-repeat;
  background-size: cover;
  @media all and (max-width: 500px) {
    background-size: 100% 100%;
  }
`;

export const Button = styled.button`
  margin: 3px 120px;
  width: 5%;
  background-color: transparent;
  color: white;
  font-size: 22px;
`;


//Detail 화면에서 사용되는 스타일 컴포넌트 . 배경 투명도 조정 
export const DetailBox = styled.div`
  background-color: grey;
  opacity: 0.8;
  margin: 2rem;
  padding: 2rem;
  border-radius: 10px;
  @media all and (max-width: 500px) {
    background-size: 100% 100%;
  }
`;

0개의 댓글