moonstruck 개발일지: 타로 카드 데이터 구조 정하기, 타로 뽑기

그른손·2024년 10월 22일
0

늘 그렇듯 쉬운 것부터 시작합니다. 일단 유저 입력값을 받는 Input 컴포넌트를 만들고, 어떤 스프레드로 카드를 뽑을지(=카드를 몇 장 뽑을지) 정하는 SpreadSelector를 만들었습니다.

Input

입력창 내에서 줄바꿈을 할 수 있게 하기 위해 input태그 대신 textArea태그를 사용했습니다. 스타일링도 안하고 그냥 '입력만 가능하게' 간단하게 만들어둔 상태입니다.

import React, { useState } from 'react';

interface InputProps {
  onFormSubmit: (inputValue: string) => void;
}

export default function Input({ onFormSubmit }: InputProps) {
  const [inputValue, setInputValue] = useState('');

  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setInputValue(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setInputValue('');
    onFormSubmit(inputValue);
  };

  return (
    <div>
      <h2>User Input</h2>
      <form onSubmit={handleSubmit}>
        <textarea
          value={inputValue}
          onChange={handleInputChange}
          placeholder="아무 고민이나 적어보세요"
          rows={5}
        />
        <button type="submit">제출</button>
      </form>
    </div>
  );
}

얼마나 간단하냐면 이렇게 글에다 복붙해도 괜찮을 정도임

SpreadSelector

이건 처음에는 싱글-트리플 전환하는 토글로 만들려고 했다가, 나중에 스프레드가 다양해졌을 때 어차피 드랍다운으로 바꿔야 할 것 같아서 처음부터 드랍다운으로 했습니다

interface SpreadSelectorProps {
  cardCount: number;
  onCardCountChange: (newCardCount: number) => void;
}

export default function SpreadSelector({
  cardCount,
  onCardCountChange,
}: SpreadSelectorProps) {
  const handleCardCountChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    onCardCountChange(Number(e.target.value));
  };

  return (
    <div>
      <label htmlFor="card-count">어떤 스프레드로 뽑아볼까요?</label>
      <select
        id="card-count"
        value={cardCount}
        onChange={handleCardCountChange}
      >
        <option value={1}>싱글</option>
        <option value={3}>트리플</option>
        <option value={5}>파이브 카드 크로스</option>
        <option value={10}>켈틱 크로스</option>
      </select>
    </div>
  );
}

각 옵션마다 값이 정해져있고, 선택하면 App에서 cardCount 상태가 업데이트 됩니다. 진짜 완전 간단함

App

그리고 이것들을


App.tsx에 띄우면 요따구가 됩니다.

cardCount는 App에서 useState로 만들어준 상태입니다. 나중에 상태 관리 라이브러리를 쓰면 전역상태로 관리하겠지만 지금은 딱히 필요성을 느끼지 않아서 안 깔아뒀으니 App에서 간단하게 만들고 SpreadSelector로 내려줄겁니다.
Input의 onFormSubmit은 Input 내에 있는 form의 submit 버튼을 누르면 동작합니다. 여기에선 checkSubmittedForm이라는 App에서 정의한 함수를 실행시킵니다.

TarotCard

타로카드의 데이터 구조를 정하고 이걸 타입으로 만들어줍니다.

export interface TarotCard {
  id: number;
  name: {
    en: string;
    ko: string;
  };
  arcanaType: 'Major' | 'Minor';
  suit: 'Cup' | 'Pentacle' | 'Sword' | 'Wand' | null;
  number: number;
  upright: {
    keywords: string[];
    description: string;
  };
  reversed: {
    keywords: string[];
    description: string;
  };
}
  • 먼저 타로카드의 번호를 id로 두고, 이름은 en과 ko로 나눠서 각각 영어/한글 표기명을 넣습니다. 이렇게 해두면 나중에 혹시 언어 설정을 만들었을 때 더 유연하게 대응할 수 있을 거 같았음
  • 아르카나 타입(메이저 아르카나인지, 마이너 아르카나인지)과 수트(컵 펜타클 소드 완드중에 뭔지, 만약 메이저 아르카나라면 null)는 필요한 정보일지 아닐지 확신이 없었지만, 나중에 메이저 아르카나, 마이너 아르카나, 혹은 수트 별로 카드 레이아웃을 다르게 표기하거나 할 수도 있으니까 만들 때부터 데이터로 넣어줬습니다. 그렇게 복잡한 구조도 아니고, 나중에 필요해질때 수정하려면 손아플 거 같아서 지금 단계에서 넣어줬음 이정도는 괜찮잖아요
  • 넘버: 메이저 아르카나의 경우 0~21까지 번호가 들어가고, 마이너 아르카나의 경우 에이스 1부터 시작해서 2, 3, 4... 이렇게 수트 내에서의 넘버링을 표기해줍니다
  • 업라이트/리버스드: 정방향일때와 역방향일 때 키워드, 설명문을 각각 넣어줍니다. 카드를 뽑을 때 정/역방향이 정해지면 데이터를 가공해서 보냄으로써 '역방향 카드를 뽑아서 정보를 보내는데 불필요하게 정방향 데이터까지 모델에게 전송되는' 비효율을 피하려고 했습니다.

tarotDeck.ts

이제 타로 덱 78장의 데이터를 만들어줍니다. GPT가 도와줬습니다. 매달 2만5천원씩 꼬박꼬박 바치고 있는데 이정도는 해줘야겠죠?

어쩌구저쩌구... 이렇게 TarotCard 객체들이 78개 들어있는 배열을 만들어줬습니다.

  • 원래는 Json으로 할까 했는데, 어차피 모델한테 전송할 때 뽑힌 것만 json으로 바꿔주면 되겠다고 생각해서 그냥 ts파일 내에서 배열로 정의했습니다

drawRandomCards

이제 랜덤한 카드를 뽑는 함수를 만들겁니다.
es-toolkit의 shuffle 함수를 이용해서, tarotDeck 배열을 섞어주고 그걸 cardCount값만큼 slice합니다.

export function drawRandomCards(cardCount: number) {
  const shuffledDeck = shuffle([...tarotDeck]);

  const result = shuffledDeck
    .slice(0, cardCount);
  return result;
}

이러면 중복을 걱정할 일도 없겠죠?
다만 카드를 한장씩 뽑는다라는 타로카드의 메커니즘에 조금 위배되긴 합니다. 이건 덱을 셔플한 후에 맨 위에서 n장 통째로 가져오는 거거든요. 타로점을 즐겨 치는 친구는 이걸 불경한 행위이며, 타로의 신에게 천벌받을 것이라고 말했지만, 천벌이 가능하면 늦게 오길 바라는 수 밖에 없을 것 같습니다. 왜냐면 한 장씩 뽑게 하면 비효율적이잖아요. 이게 훨씬 나음

  • 나중에 애니메이션과 카드 직접 선택 기능을 넣어서 유저가 덱에서 직접 카드를 뽑게 하는 느낌만 주면 좀 더 타로점같고 좋을 것 같습니다. 지금은 이걸로 충분함

processCardDirection

뽑힌 카드에 정방향/역방향을 부여해줄겁니다.

function processCardDirection(card: TarotCard) {
  const direction: '정방향' | '역방향' =
    random(0, 1) > 0.5 ? '정방향' : '역방향';

  const result = {
    id: card.id,
    name: card.name,
    arcanaType: card.arcanaType,
    suit: card.suit,
    number: card.number,
    direction,
    keywords:
      direction === '정방향' ? card.upright.keywords : card.reversed.keywords,
    description:
      direction === '정방향'
        ? card.upright.description
        : card.reversed.description,
  };
  return result;
}

es-toolkit의 random 함수를 사용해서 0과 1 사이의 난수를 뽑고, 그 값이 0.5보다 크면 정방향, 작으면 역방향이 direction으로 정해집니다. 그 후 card객체에서 정보를 뽑아와 direction을 필드로 더한 새 객체를 만들어주는데, keywords와 description에는 원래 카드 정보에서 정방향/역방향 정보 중의 하나를 뽑아와서 지정해줄 겁니다.

  • 이 또한 타로를 좋아하는 친구의 분노를 샀는데, 제대로 타로를 본다면 정방향, 역방향은 이미 모든 카드에 부여된 상태이고 그걸 뽑기만 해야한다 는 의견이었습니다. 지금은 카드를 뽑은 후에 정/역방향을 지정하는 방식이니 알맞지 않다는 거죠.
  • 그치만 이게 더 효율적인걸... 뽑힐 일이 없는 카드에 굳이 정방향/역방향을 랜덤으로 부여하고서 n개 카드를 뽑는다니 너무 비효율적이고...
  • 그래서 꼬우면 직접 만들어라라고 말해주었습니다. 제가 개발자를 하나 더 태어나게 했을지도 모르는 일입니다.

DrawnTarotCard 타입 생성

이렇게 데이터에 정/역방향 부여와 그에 따른 정보 가공을 거친 후의 객체는 더 이상 TarotCard가 아닙니다. 타입으로 지정해줘야 나중에 이걸 써먹을 때 빨간줄이 안뜹니다.

export interface DrawnTarotCard {
  id: number;
  name: {
    en: string;
    ko: string;
  };
  arcanaType: 'Major' | 'Minor';
  suit: 'Cup' | 'Pentacle' | 'Sword' | 'Wand' | null;
  number: number;
  direction: '정방향' | '역방향';
  keywords: string[];
  description: string;
}

일케 말입니다

drawRandomCards에서 processCardDirection 적용시키기

export function drawRandomCards(cardCount: number) {
  const shuffledDeck = shuffle([...tarotDeck]);

  const result = shuffledDeck
    .slice(0, cardCount)
    .map((card) => processCardDirection(card));
  return result;
}

이런식으로, 슬라이스해온 카드에 map 걸어서 뽑힌 카드들의 정/역방향을 정하고 데이터를 가공해줍니다.

그래서 어떻게 만들어졌나요?


와...개못생겼죠? 처음에는 다 그런 법입니다.

어떻게 동작하나요?

이렇게요

이제 유저 입력도 받을 수 있고, 정/역방향이 랜덤으로 정해진 무작위 카드를 스프레드에 정해진 수 만큼 뽑을 수 있으니 절반은 끝났습니다. 시작은 했다는 뜻입니다. 시작이 반이랬거든요.

  • 다음으로 해야 할 건 가장 중요하고 또 무시무시한 AI 모델의 API로 정보를 보내고 답변을 받기 입니다. 이거 할 수 있으면 엔딩입니다.
  • 행운을 빌어주시길 바랍니다
profile
프론트엔드 개발자

0개의 댓글