본문 바로가기

카테고리 없음

TeamProject_PokéGotchi - 2일차

웹 페이지에서 즐길 수 있는 간단한 게임 구현

 

  • 1. 포켓볼 피하기 게임
    • 참고 자료 : https://fe-developers.kakaoent.com/2022/220830-simple-canvas-game/
    • 전체 코드
      'use client';

      import Link from 'next/link';
      import React, { useCallback, useEffect, useRef, useState } from 'react';

      interface ItemPos {
        x: number;
        y: number;
        w: number;
        h: number;
      }

      export default function PokeBallGamePage() {
        const [state, setState] = useState<'play' | 'pause' | 'stop'>('stop');
        const [score, setScore] = useState(0);

        const ref = useRef<HTMLCanvasElement>(null);
        const minionRef = useRef<HTMLImageElement>(null);
        const bananaRef = useRef<HTMLImageElement>(null);
        const bananaSizeRef = useRef({ w: 0, h: 0 });
        const posRef = useRef<{
          bananas: ItemPos[];
          bananaAccel: number[];
          minion: ItemPos;
        }>({
          bananas: [],
          bananaAccel: [],
          minion: { x: 0, y: 0, w: 0, h: 0 }
        });
        const keyRef = useRef({
          isLeft: false,
          isRight: false
        });

        const W = 500;
        const H = 600;
        const VELOCITY = {
          minion: {
            left: 6,
            right: 6
          },
          bananaAccel: 0.02
        };
        const CREATE_BANANA_TIME = 500;
        const BANANA_SCORE = 50;
        const AVOID_BANANA_SCORE = 10;

        const drawImage = useCallback((ctx: CanvasRenderingContext2D, img: HTMLImageElement, { x, y, w, h }: ItemPos) => {
          ctx.drawImage(img, x, y, w, h);
        }, []);
        const loadImage = useCallback(
          (src: string) =>
            new Promise<HTMLImageElement>((resolve) => {
              const img = new Image();
              img.src = src;
              img.onload = () => resolve(img);
            }),
          []
        );

        const blockOverflowPos = useCallback(
          (pos: ItemPos) => {
            pos.x = pos.x + pos.w >= W ? W - pos.w : pos.x < 0 ? 0 : pos.x;
            pos.y = pos.y + pos.h >= H ? H - pos.h : pos.y < 0 ? 0 : pos.y;
          },
          [W, H]
        );

        const updateMinionPos = useCallback(
          (minionPos: ItemPos) => {
            const key = keyRef.current;
            if (key.isLeft) minionPos.x -= VELOCITY.minion.left;
            if (key.isRight) minionPos.x += VELOCITY.minion.right;
            blockOverflowPos(minionPos);
          },
          [blockOverflowPos]
        );

        const createBanana = useCallback(() => {
          if (!bananaRef.current) return;
          const size = bananaSizeRef.current;
          posRef.current.bananas.push({
            x: Math.random() * (W - size.w),
            y: -size.h,
            ...size
          });
          posRef.current.bananaAccel.push(1);
        }, [W]);

        const updateBananaPos = useCallback((bananaPos: ItemPos, index: number) => {
          const y = bananaPos.y;
          const accel = posRef.current.bananaAccel[index];
          posRef.current.bananaAccel[index] = accel + accel * VELOCITY.bananaAccel;
          bananaPos.y = y + accel;
        }, []);

        const deleteBanana = useCallback((index: number) => {
          posRef.current.bananas.splice(index, 1);
          posRef.current.bananaAccel.splice(index, 1);
          setScore((prevScore) => prevScore + AVOID_BANANA_SCORE);
        }, []);

        const catchBanana = useCallback(
          (bananaPos: ItemPos, index: number) => {
            const minionPos = posRef.current.minion;
            if (
              minionPos.x + minionPos.w >= bananaPos.x &&
              minionPos.x <= bananaPos.x + bananaPos.w &&
              minionPos.y + minionPos.h >= bananaPos.y &&
              minionPos.y <= bananaPos.y + bananaPos.h
            ) {
              alert(`점수: ${score}`);
              setState('stop');
            }
          },
          [score]
        );

        const initialGame = useCallback(
          (ctx: CanvasRenderingContext2D) => {
            ctx.clearRect(0, 0, W, H);
            const { w, h } = posRef.current.minion;
            posRef.current.bananaAccel = [];
            posRef.current.bananas = [];
            posRef.current.minion = {
              x: W / 2 - w / 2,
              y: H - h,
              w,
              h
            };
            keyRef.current.isLeft = false;
            keyRef.current.isRight = false;
            setScore(0);
          },
          [W, H]
        );

        useEffect(() => {
          const cvs = ref.current;
          const ctx = cvs?.getContext('2d');
          state === 'stop' && ctx && initialGame(ctx);
          if (!cvs || !ctx || state !== 'play') return;
          !minionRef.current &&
            loadImage('img1.png').then((img) => {
              (minionRef as any).current = img;
              const w = img.width;
              const h = img.height;
              posRef.current.minion = {
                x: W / 2 - w / 2,
                y: H - h,
                w,
                h
              };
            });
          !bananaRef.current &&
            loadImage('Pokeball.png').then((img) => {
              (bananaRef as any).current = img;
              bananaSizeRef.current.w = img.width;
              bananaSizeRef.current.h = img.height;
            });
          let timer: number | undefined;
          let rafTimer: number | undefined;
          const pos = posRef.current;
          const animate = () => {
            const minion = minionRef.current;
            const banana = bananaRef.current;
            ctx.clearRect(0, 0, W, H);
            if (minion) {
              updateMinionPos(pos.minion);
              drawImage(ctx, minion, pos.minion);
            }
            if (banana) {
              pos.bananas.forEach((bananaPos, index) => {
                updateBananaPos(bananaPos, index);
                drawImage(ctx, banana, bananaPos);
              });
              pos.bananas.forEach((bananaPos, index) => {
                if (bananaPos.y >= H) {
                  deleteBanana(index);
                } else {
                  catchBanana(bananaPos, index);
                }
              });
            }
            rafTimer = requestAnimationFrame(animate);
          };
          rafTimer = requestAnimationFrame(animate);
          timer = window.setInterval(createBanana, CREATE_BANANA_TIME);
          const onKeyDown = (e: KeyboardEvent) => {
            const key = e.key.toLowerCase();
            keyRef.current.isLeft = key === 'a' || key === 'arrowleft';
            keyRef.current.isRight = key === 'd' || key === 'arrowright';
          };
          const onKeyUp = () => {
            keyRef.current.isLeft = false;
            keyRef.current.isRight = false;
          };
          window.addEventListener('keydown', onKeyDown);
          window.addEventListener('keyup', onKeyUp);

          return () => {
            window.removeEventListener('keydown', onKeyDown);
            window.removeEventListener('keyup', onKeyUp);
            timer && window.clearInterval(timer);
            timer = undefined;
            rafTimer && cancelAnimationFrame(rafTimer);
            rafTimer = undefined;
          };
        }, [
          drawImage,
          loadImage,
          updateMinionPos,
          createBanana,
          updateBananaPos,
          deleteBanana,
          catchBanana,
          state,
          initialGame
        ]);

        return (
          <div className="flex min-h-screen items-center justify-center">
            <div className="h-[800px] w-[600px] bg-white p-4">
              <div className="mx-auto my-3 text-center">
                <div className="flex justify-center space-x-5">
                  <button type="button" onClick={() => setState('pause')}>
                    PAUSE
                  </button>
                  <button type="button" onClick={() => setState('play')}>
                    PLAY
                  </button>
                  <button type="button" onClick={() => setState('stop')}>
                    STOP
                  </button>
                  <Link className="text-red-500" href="/lobby">
                    GoLobby
                  </Link>
                </div>
                <p className="mt-2">현재 점수: {score}</p>
              </div>
              <canvas className="mx-auto block border border-black" ref={ref} width={W} height={H} />
            </div>
          </div>
        );
      }

      • 간단한 설명 : 포켓볼을 피하면서 점수를 획득하고 포켓볼을 맞을 시 종료
        • 실행 화면

 

  • 2. 퀴즈 맞히기 게임
    • 참고 자료: https://www.youtube.com/watch?v=F2JCjVSZlG0
    • 전체 코드 : page.tsx
      'use client';

      import Link from 'next/link';
      import React, { useState } from 'react';
      import Image from 'next/image';
      import { fetchQuizQuestions } from './api';
      import QuestionCard from './_components/QuestionCard';
      import { QuestionsState, Difficulty } from './api';

      export type AnswerObject = {
        question: string;
        answer: string;
        correct: boolean;
        correctAnswer: string;
      };

      const TOTAL_QUESTIONS = 10;

      const QuizGamePage: React.FC = () => {
        const [loading, setLoading] = useState(false);
        const [questions, setQuestions] = useState<QuestionsState[]>([]);
        const [number, setNumber] = useState(0);
        const [userAnswers, setUserAnswers] = useState<AnswerObject[]>([]);
        const [score, setScore] = useState(0);
        const [gameOver, setGameOver] = useState(true);

        const startTrivia = async () => {
          setLoading(true);
          setGameOver(false);
          const newQuestions = await fetchQuizQuestions(TOTAL_QUESTIONS, Difficulty.EASY);
          setQuestions(newQuestions);
          setScore(0);
          setUserAnswers([]);
          setNumber(0);
          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 + 1);
            const answerObject = {
              question: questions[number].question,
              answer,
              correct,
              correctAnswer: questions[number].correct_answer
            };
            setUserAnswers((prev) => [...prev, answerObject]);
          }
        };

        const nextQuestion = () => {
          const nextQ = number + 1;
          if (nextQ === TOTAL_QUESTIONS) {
            setGameOver(true);
          } else {
            setNumber(nextQ);
          }
        };

        return (
          <div className="flex min-h-screen flex-col items-center justify-center p-4">
            <div className="relative h-[800px] w-[600px]">
              <Image
                src="/bg1.png"
                alt="Background Image"
                layout="fill"
                objectFit="cover"
                quality={100}
                className="pointer-events-none"
              />
            </div>
            <div className="absolute z-10 flex flex-col items-center">
              <h1 className="mb-8 bg-gradient-to-b from-white to-blue-300 bg-clip-text text-center text-5xl font-bold text-transparent drop-shadow-lg">
                REACT QUIZ
              </h1>
              {gameOver || userAnswers.length === TOTAL_QUESTIONS ? (
                <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 ? <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 && (
                <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}
              <Link href="/lobby" className="mt-8 text-red-500">
                GoLobby
              </Link>
            </div>
          </div>
        );
      };

      export default QuizGamePage;
    • api.ts
      export type Question = {
        category: string;
        correct_answer: string;
        difficulty: string;
        incorrect_answers: string[];
        question: string;
        type: string;
      };

      export enum Difficulty {
        EASY = 'easy',
        MEDIUM = 'medium',
        HARD = 'hard'
      }

      export const shuffleArray = (array: any[]) => [...array].sort(() => Math.random() - 0.5);

      export type QuestionsState = Question & { answers: string[] };

      export const fetchQuizQuestions = async (amount: number, difficulty: Difficulty): Promise<QuestionsState[]> => {
        const endpoint = `https://opentdb.com/api.php?amount=${amount}&difficulty=${difficulty}&type=multiple`;
        const data = await (await fetch(endpoint)).json();
        return data.results.map((question: Question) => ({
          ...question,
          answers: shuffleArray([...question.incorrect_answers, question.correct_answer])
        }));
      };
    • QuestionCard.tsx
      import React from 'react';
      import { AnswerObject } from '../page';

      type Props = {
        question: string;
        answers: string[];
        callback: (e: React.MouseEvent<HTMLButtonElement>) => void;
        userAnswer: AnswerObject | undefined;
        questionNr: number;
        totalQuestions: number;
      };

      const QuestionCard: React.FC<Props> = ({ question, answers, callback, userAnswer, questionNr, totalQuestions }) => (
        <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>
            {answers.map((answer) => {
              const isCorrect = userAnswer?.correctAnswer === answer;
              const isUserClicked = userAnswer?.answer === answer;

              const buttonClassName = isCorrect
                ? 'bg-gradient-to-r from-green-400 to-green-600'
                : isUserClicked
                  ? 'bg-gradient-to-r from-red-400 to-red-600'
                  : 'bg-gradient-to-r from-blue-400 to-blue-600';

              return (
                <div
                  key={answer}
                  className={`mb-2 transition-opacity duration-300 ease-in-out hover:opacity-80 ${buttonClassName}`}
                >
                  <button
                    className="h-10 w-full cursor-pointer select-none rounded-lg text-sm text-white shadow-md"
                    disabled={!!userAnswer}
                    value={answer}
                    onClick={callback}
                  >
                    <span dangerouslySetInnerHTML={{ __html: answer }} />
                  </button>
                </div>
              );
            })}
          </div>
        </div>
      );

      export default QuestionCard;
      • 간단한 설명 : 퀴즈를 맞히고 점수를 획득
        • 실행 화면

 

게임 로비 페이지 구현

 

 

문제점

    !minionRef.current &&
      loadImage('img1.png').then((img) => {
        (minionRef as any).current = img;
        const w = img.width;
        const h = img.height;
        posRef.current.minion = {
          x: W / 2 - w / 2,
          y: H - h,
          w,
          h
        };
      });
    !bananaRef.current &&
      loadImage('Pokeball.png').then((img) => {
        (bananaRef as any).current = img;
        bananaSizeRef.current.w = img.width;
        bananaSizeRef.current.h = img.height;
      });

위 코드는 src 폴더와 동일 루트에 public 폴더를 기준으로 사용하여 문제가 없었으나

app 폴더와 동일 선상인 assets 폴더에 이미지로 전환하려니 문제가 발생하였음.

import PokeBallImage from '@/assets/default ball.png';
import PokemonImage from '@/assets/pokemon2.png';
    if (!minionRef.current) {
      const img = loadImage(PokemonImage.src);
      minionRef.current = img;
      const w = img.width;
      const h = img.height;
      posRef.current.minion = {
        x: W / 2 - w / 2,
        y: H - h,
        w,
        h
      };
    }
    if (!bananaRef.current) {
      const img = loadImage(PokeBallImage.src);
      bananaRef.current = img;
      bananaSizeRef.current.w = img.width;
      bananaSizeRef.current.h = img.height;
    }

위와 같이 수정하였더니 이미지가 보이지 않는 문제가 발생하였음

 

따라서, 기존 코드에서 재시도 결과 이미지 크기에따라서 이미지가 너무 커서 게임이 정상적으로

진행이 불가하다는 문제점도 발생하였음.

추가한 import를 유지하며 코드를

'use client';

import Link from 'next/link';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PokeBallImage from '@/assets/default ball.png';
import PokemonImage from '@/assets/pokemon2.png';

interface ItemPos {
  x: number;
  y: number;
  w: number;
  h: number;
}

export default function PokeBallGamePage() {
  const [state, setState] = useState<'play' | 'pause' | 'stop'>('stop');
  const [score, setScore] = useState(0);

  const ref = useRef<HTMLCanvasElement>(null);
  const minionRef = useRef<HTMLImageElement>(null);
  const bananaRef = useRef<HTMLImageElement>(null);
  const bananaSizeRef = useRef({ w: 50, h: 50 });
  const posRef = useRef<{
    bananas: ItemPos[];
    bananaAccel: number[];
    minion: ItemPos;
  }>({
    bananas: [],
    bananaAccel: [],
    minion: { x: 0, y: 0, w: 0, h: 0 }
  });
  const keyRef = useRef({
    isLeft: false,
    isRight: false
  });

  const W = 500;
  const H = 600;
  const VELOCITY = {
    minion: {
      left: 6,
      right: 6
    },
    bananaAccel: 0.02
  };
  const CREATE_BANANA_TIME = 500;
  const BANANA_SCORE = 50;
  const AVOID_BANANA_SCORE = 10;

  const drawImage = useCallback((ctx: CanvasRenderingContext2D, img: HTMLImageElement, { x, y, w, h }: ItemPos) => {
    const maxWidth = 50;
    const maxHeight = 50;
    const aspectRatio = img.width / img.height;

    if (w > maxWidth || h > maxHeight) {
      if (aspectRatio > 1) {
        w = maxWidth;
        h = maxWidth / aspectRatio;
      } else {
        w = maxHeight * aspectRatio;
        h = maxHeight;
      }
    }

    ctx.drawImage(img, x, y, w, h);
  }, []);

  const loadImage = useCallback(
    (src: string) =>
      new Promise<HTMLImageElement>((resolve) => {
        const img = new Image();
        img.src = src;
        img.onload = () => resolve(img);
      }),
    []
  );

  const blockOverflowPos = useCallback(
    (pos: ItemPos) => {
      pos.x = pos.x + pos.w >= W ? W - pos.w : pos.x < 0 ? 0 : pos.x;
      pos.y = pos.y + pos.h >= H ? H - pos.h : pos.y < 0 ? 0 : pos.y;
    },
    [W, H]
  );

  const updateMinionPos = useCallback(
    (minionPos: ItemPos) => {
      const key = keyRef.current;
      if (key.isLeft) minionPos.x -= VELOCITY.minion.left;
      if (key.isRight) minionPos.x += VELOCITY.minion.right;
      blockOverflowPos(minionPos);
    },
    [blockOverflowPos]
  );

  const createBanana = useCallback(() => {
    if (!bananaRef.current) return;
    const size = bananaSizeRef.current;
    posRef.current.bananas.push({
      x: Math.random() * (W - size.w),
      y: -size.h,
      ...size
    });
    posRef.current.bananaAccel.push(1);
  }, [W]);

  const updateBananaPos = useCallback((bananaPos: ItemPos, index: number) => {
    const y = bananaPos.y;
    const accel = posRef.current.bananaAccel[index];
    posRef.current.bananaAccel[index] = accel + accel * VELOCITY.bananaAccel;
    bananaPos.y = y + accel;
  }, []);

  const deleteBanana = useCallback((index: number) => {
    posRef.current.bananas.splice(index, 1);
    posRef.current.bananaAccel.splice(index, 1);
    setScore((prevScore) => prevScore + AVOID_BANANA_SCORE);
  }, []);

  const catchBanana = useCallback(
    (bananaPos: ItemPos, index: number) => {
      const minionPos = posRef.current.minion;
      if (
        minionPos.x + minionPos.w >= bananaPos.x &&
        minionPos.x <= bananaPos.x + bananaPos.w &&
        minionPos.y + minionPos.h >= bananaPos.y &&
        minionPos.y <= bananaPos.y + bananaPos.h
      ) {
        alert(`점수: ${score}`);
        setState('stop');
      }
    },
    [score]
  );

  const initialGame = useCallback(
    (ctx: CanvasRenderingContext2D) => {
      ctx.clearRect(0, 0, W, H);
      const { w, h } = posRef.current.minion;
      posRef.current.bananaAccel = [];
      posRef.current.bananas = [];
      posRef.current.minion = {
        x: W / 2 - w / 2,
        y: H - h,
        w,
        h
      };
      keyRef.current.isLeft = false;
      keyRef.current.isRight = false;
      setScore(0);
    },
    [W, H]
  );

  useEffect(() => {
    const cvs = ref.current;
    const ctx = cvs?.getContext('2d');
    state === 'stop' && ctx && initialGame(ctx);
    if (!cvs || !ctx || state !== 'play') return;
    !minionRef.current &&
      loadImage(PokemonImage.src).then((img) => {
        (minionRef as any).current = img;
        const w = 50;
        const h = 50;
        posRef.current.minion = {
          x: W / 2 - w / 2,
          y: H - h,
          w,
          h
        };
      });
    !bananaRef.current &&
      loadImage(PokeBallImage.src).then((img) => {
        (bananaRef as any).current = img;
        bananaSizeRef.current.w = 50;
        bananaSizeRef.current.h = 50;
      });
    let timer: number | undefined;
    let rafTimer: number | undefined;
    const pos = posRef.current;
    const animate = () => {
      const minion = minionRef.current;
      const banana = bananaRef.current;
      ctx.clearRect(0, 0, W, H);
      if (minion) {
        updateMinionPos(pos.minion);
        drawImage(ctx, minion, pos.minion);
      }
      if (banana) {
        pos.bananas.forEach((bananaPos, index) => {
          updateBananaPos(bananaPos, index);
          drawImage(ctx, banana, bananaPos);
        });
        pos.bananas.forEach((bananaPos, index) => {
          if (bananaPos.y >= H) {
            deleteBanana(index);
          } else {
            catchBanana(bananaPos, index);
          }
        });
      }
      rafTimer = requestAnimationFrame(animate);
    };
    rafTimer = requestAnimationFrame(animate);
    timer = window.setInterval(createBanana, CREATE_BANANA_TIME);
    const onKeyDown = (e: KeyboardEvent) => {
      const key = e.key.toLowerCase();
      keyRef.current.isLeft = key === 'a' || key === 'arrowleft';
      keyRef.current.isRight = key === 'd' || key === 'arrowright';
    };
    const onKeyUp = () => {
      keyRef.current.isLeft = false;
      keyRef.current.isRight = false;
    };
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);

    return () => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
      timer && window.clearInterval(timer);
      timer = undefined;
      rafTimer && cancelAnimationFrame(rafTimer);
      rafTimer = undefined;
    };
  }, [
    drawImage,
    loadImage,
    updateMinionPos,
    createBanana,
    updateBananaPos,
    deleteBanana,
    catchBanana,
    state,
    initialGame
  ]);

  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="h-[800px] w-[600px] bg-white p-4">
        <div className="mx-auto my-3 text-center">
          <div className="flex justify-center space-x-5">
            <button type="button" onClick={() => setState('pause')}>
              PAUSE
            </button>
            <button type="button" onClick={() => setState('play')}>
              PLAY
            </button>
            <button type="button" onClick={() => setState('stop')}>
              STOP
            </button>
            <Link className="text-red-500" href="/lobby">
              GoLobby
            </Link>
          </div>
          <p className="mt-2">현재 점수: {score}</p>
        </div>
        <canvas className="mx-auto block border border-black" ref={ref} width={W} height={H} />
      </div>
    </div>
  );
}

이와 같이 수정하며 이미지 크기를 조정하고 .src를 붙여 이미지의 경로를 정상적으로 불러와 해결하였음