본문 바로가기

카테고리 없음

TeamProject_PokéGotchi - 5일차

DB구조

 

유저별 게임 점수 갱신 및 코인 획득

 

포켓볼 피하기 게임

 

퀴즈 게임

 

user 정보 route

import { createClient } from '@/supabase/server';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  try {
    const supabase = createClient();
    const {
      data: { user }
    } = await supabase.auth.getUser();

    if (!user) return NextResponse.json({ error: '유저 없음' }, { status: 401 });
    if (user) return NextResponse.json(user);
  } catch (error) {
    return NextResponse.json({ error: '유저 정보 fetch 중 네트워크 오류', details: error }, { status: 500 });
  }
}

위 코드를 활용하여 점수 및 코인 관련 supabase 연동

const updateScore = async (score: number, userId: string, userEmail: string) => {
  const { data, error } = await supabase.from('users').select('gameScore_ball, coins').eq('id', userId).single();
  if (error) {
    throw error;
  }

  const currentScore = data?.gameScore_ball ?? 0;
  const currentCoins = data?.coins ?? 0;
  const additionalCoins = Math.floor(score / 10);
  const newCoins = currentCoins + additionalCoins;

  const { data: upsertData, error: upsertError } = await supabase
    .from('users')
    .upsert(
      { id: userId, gameScore_ball: score > currentScore ? score : currentScore, coins: newCoins, email: userEmail },
      { onConflict: 'id' }
    );
  if (upsertError) {
    throw upsertError;
  }
};

Upsert란?

"Upsert"는 "Update"와 "Insert"의 합성어입니다. 데이터베이스에서 upsert는 주어진 조건에 따라 데이터가 이미 존재하면 업데이트하고, 존재하지 않으면 삽입하는 작업을 말합니다.

예시

  1. 존재하면 업데이트:
    • 데이터베이스에 특정 ID를 가진 레코드가 이미 존재할 때, 그 레코드의 값을 업데이트합니다.
  2. 존재하지 않으면 삽입:
    • 데이터베이스에 특정 ID를 가진 레코드가 없으면, 새로운 레코드를 삽입합니다.

용도

  • 데이터 중복을 피하면서 데이터를 효율적으로 관리할 때 유용합니다.
  • 실시간 데이터 입력 및 수정 작업에 많이 사용됩니다.

 

포켓볼 게임의 경우 난이도 설정 외에도 게임 종료와 재시작에서 자연스럽게 동작하기 위한 설계 기획

const catchPokeBall = useCallback(
    (pokeBallPos: ItemPos, index: number) => {
      const pokemonPos = posRef.current.pokemon;

      if (
        pokemonPos.x + pokemonPos.w >= pokeBallPos.x &&
        pokemonPos.x <= pokeBallPos.x + pokeBallPos.w &&
        pokemonPos.y + pokemonPos.h >= pokeBallPos.y &&
        pokemonPos.y <= pokeBallPos.y + pokeBallPos.h
      ) {
        if (!scoreUpdated) {
          mutation.mutate(score);
          setScoreUpdated(true);
          setState('pause');

          gameSwal
            .fire({
              title: `점수: ${score}`,
              icon: 'success',
              confirmButtonText: '게임 종료'
            })
            .then(() => {
              setState('stop');
            });
        }
      }
    },
    [score, mutation, scoreUpdated]
  );

  const initialGame = useCallback(
    (ctx: CanvasRenderingContext2D) => {
      ctx.clearRect(0, 0, W, H);
      const { w, h } = posRef.current.pokemon;
      posRef.current.pokeBallAccel = [];
      posRef.current.pokeBalls = [];

      posRef.current.pokemon = {
        x: W / 2 - w / 2,
        y: H - h,
        w,
        h
      };

      keyRef.current.isLeft = false;
      keyRef.current.isRight = false;

      setScore(0);
      setScoreUpdated(false);
      setCreatePokeBallTime(700);
      setPokeBallAccel(0.02);
    },
    [W, H]
  );

 

퀴즈 게임의 경우에도 마찬가지

  const startTrivia = async () => {
    setLoading(true);
    setGameOver(false);
    setScore(0);
    setUserAnswers([]);
    setNumber(0);

    const response = await fetch(`/api/quiz?amount=${TOTAL_QUESTIONS}&difficulty=${Difficulty.EASY}`);
    if (!response.ok) {
      setLoading(false);
      throw new Error('Failed to fetch questions');
    }

    const newQuestions = await response.json();
    setQuestions(newQuestions);
    setLoading(false);
  };

  const checkAnswer = (e: any) => {
    if (!gameOver) {
      const answer = e.currentTarget.value;
      const correct = questions[number].correct_answer === answer;

      if (correct) setScore((prev) => prev + 100);

      const answerObject = {
        question: questions[number].question,
        answer,
        correct,
        correctAnswer: questions[number].correct_answer
      };

      setUserAnswers((prev) => [...prev, answerObject]);
    }
  };

  const nextQuestion = () => {
    const nextQ = number + 1;

    setNumber(nextQ);
  };

  const submitQuiz = () => {
    mutation.mutate(score);
    gameSwal
      .fire({
        title: `점수: ${score}`,
        icon: 'success',
        confirmButtonText: '게임 종료'
      })
      .then(() => {
        setGameOver(true);
        setScore(0);
        setUserAnswers([]);
        setNumber(0);
        setQuestions([]);
      });
  };
        {gameOver ? (
          <button
            className="mb-4 rounded-xl border-2 border-orange-600 bg-gradient-to-b from-white to-orange-300 px-8 py-2 shadow-md"
            onClick={startTrivia}
          >
            Start
          </button>
        ) : null}
        {!gameOver && !loading && userAnswers.length !== TOTAL_QUESTIONS ? (
          <button
            className="mt-4 rounded-xl border-2 border-red-600 bg-gradient-to-b from-white to-red-300 px-8 py-2 shadow-md"
            onClick={startTrivia}
          >
            Restart Quiz
          </button>
        ) : null}
        {!gameOver ? <p className="mb-4 text-2xl text-white">Score: {score}</p> : null}
        {loading ? <p className="text-2xl text-white">Loading Questions...</p> : null}
        {!loading && !gameOver && questions.length > 0 && (
          <QuestionCard
            questionNr={number + 1}
            totalQuestions={TOTAL_QUESTIONS}
            question={questions[number].question}
            answers={questions[number].answers}
            userAnswer={userAnswers ? userAnswers[number] : undefined}
            callback={checkAnswer}
          />
        )}
        {!gameOver && !loading && userAnswers.length === number + 1 && number !== TOTAL_QUESTIONS - 1 ? (
          <button
            className="mt-4 rounded-xl border-2 border-orange-600 bg-gradient-to-b from-white to-orange-300 px-8 py-2 shadow-md"
            onClick={nextQuestion}
          >
            Next Question
          </button>
        ) : null}
        {!gameOver && !loading && userAnswers.length === TOTAL_QUESTIONS ? (
          <button
            className="mt-4 rounded-xl border-2 border-green-600 bg-gradient-to-b from-white to-green-300 px-8 py-2 shadow-md"
            onClick={submitQuiz}
          >
            Submit
          </button>
        ) : null}

더해서 포켓볼 피하기와는 다르게 랜더링에도 차이점이 필요하여 ui부분도 개선

 

supabase와 연동하여 정보를 불러오는 로직을 page.tsx에서 분리하여 관리하는 방법으로 개선이 필요해보임

 

react-quill

supabase와 연동하여 사용 시 에디터를 통해 이미지를 업로드 할 시 storage가 아니라 테이블 컬럼에 src값으로 저장되는데 base64형태로 저장되다보니 만 글자가 넘는 텍스트가 저장되는 문제가 있음

-> 해결하려면 관력 로직을 새로 짜야 하는 등 어려움이 있어 보임

 

 

퀴즈 게임 개선

 

퀴즈 페이지가 아닌 퀴즈카드 컴포넌트 수정으로 개선 성공

 

기존 코드

  <div className="max-w-screen-lg rounded-lg border-2 border-[#0085a3] bg-[#ebfeff] p-5 text-center shadow-lg">
    <p className="mb-4 text-lg">
      Question: {questionNr} / {totalQuestions}
    </p>
    <p className="mb-4 text-lg" dangerouslySetInnerHTML={{ __html: question }} />

개선코드

  <div className="w-[500px] rounded-lg border-2 border-[#0085a3] bg-[#ebfeff] p-5 text-center shadow-lg">
    <p className="mb-4 text-lg">
      Question: {questionNr} / {totalQuestions}
    </p>
    <p className="mb-4 break-words text-lg" dangerouslySetInnerHTML={{ __html: question }} />

 

break-words 클래스는 CSS의 overflow-wrap: break-word; 속성과 동일한 역할을 합니다. 이 클래스는 긴 단어가 요소의 너비를 초과할 때 단어를 중간에서 잘라 줄바꿈을 하도록 설정합니다.

사용법

Tailwind CSS에서 break-words 클래스를 사용하려면 해당 요소에 클래스를 추가하기만 하면 됩니다. 이렇게 하면 텍스트가 부모 요소의 너비를 초과할 경우 단어가 중간에서 잘려서 줄바꿈됩니다.

 

메인 페이지

기존 api에서 6마리를 보여줬다면 내가 보유한 6마리의 포켓몬을 보여주도록 개선

 

import supabase from '@/supabase/client';
import { useAuth } from '@/contexts/auth.context/auth.context';

type Pokemon에 넘버 추가

  pokemonNumber: number;

 

user_pokemons 테이블에서 내 포켓몬을 가져와서 메인 포켓몬을 선택 가능하도록 설정

  const { me } = useAuth();
  const [pokemons, setPokemons] = useState<any[]>([]);
  const handlePokemonClick = (index: number) => {
    if (!selectedPokemon) return;

    const newPokemonData = [...pokemonData];
    const clickedPokemon = newPokemonData[index];

    const tempId = clickedPokemon.pokemonNumber;
    newPokemonData[index].pokemonNumber = selectedPokemon.pokemonNumber;
    setSelectedPokemon({ ...selectedPokemon, pokemonNumber: tempId });

    setPokemonData(newPokemonData);
  };
  const fetchUserPokemons = async (userId: string) => {
    const { data, error } = await supabase.from('user_pokemons').select('*').eq('userId', userId);

    if (error) {
      console.error('Error fetching user pokemons:', error);
    } else {
      setPokemons(data || []);
    }
  };
  useEffect(() => {
    if (me?.id) {
      fetchUserPokemons(me.id);
    }
  }, [me]);

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, [selectedPokemon]);

  useEffect(() => {
    if (pokemons.length > 0) {
      const canvasWidth = canvasRef.current?.width || 800;
      const canvasHeight = canvasRef.current?.height || 600;
      const spacing = canvasWidth / (5 + 2);

      const updatedData = pokemons
        .slice(0, 6)
        .filter((pokemon) => pokemon.pokemonNumber !== selectedPokemon?.pokemonNumber)
        .map((pokemon, index) => ({
          ...pokemon,
          x: index < 5 ? spacing * (index + 1) - 90 : canvasWidth / 2 - 200,
          y: index < 5 ? 50 : canvasHeight - 30
        }));

      setPokemonData(updatedData);

      if (!selectedPokemon && updatedData.length > 0) {
        const lastIndex = updatedData.length - 1;
        setSelectedPokemon(updatedData[lastIndex]);
        setSelectedPokemonPos({ x: updatedData[lastIndex].x!, y: canvasHeight - 30 });
      }
    }
  }, [pokemons]);
      {pokemonData.map((pokemon, idx) => (
        <div
          key={idx}
          id={`pokemon-${idx}`}
          className="z-1 absolute cursor-pointer transition-transform duration-1000 hover:drop-shadow-[0_0_20px_rgba(255,255,0,0.8)]"
          style={{ transform: `translate(${pokemon.x}px, ${pokemon.y}px)` }}
          onClick={() => handlePokemonClick(idx)}
        >
          <Image
            alt={`포켓몬 이미지 ${pokemon.korean_name}`}
            width={100}
            height={100}
            onError={(e) => console.error('이미지를 불러올 수 없습니다:', e)}
          />
        </div>
      ))}

      {selectedPokemon && (
        <div
          id="selected-pokemon"
          className="z-1 absolute transition-transform duration-1000"
          style={{ transform: `translate(${selectedPokemonPos.x}px, ${selectedPokemonPos.y}px)` }}
        >
          <Image
            alt={`선택된 포켓몬 이미지 ${selectedPokemon.korean_name}`}
            width={200}
            height={200}
            onError={(e) => console.error('선택된 이미지를 불러올 수 없습니다:', e)}
          />
        </div>
      )}