juni
PokéGotchi_게임 임시 구현 본문
웹 페이지에서 즐길 수 있는 간단한 게임 구현
- 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]"><Imagesrc="/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 ? (<buttonclassName="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 && (<QuestionCardquestionNr={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 ? (<buttonclassName="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 (<divkey={answer}className={`mb-2 transition-opacity duration-300 ease-in-out hover:opacity-80 ${buttonClassName}`}><buttonclassName="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를 붙여 이미지의 경로를 정상적으로 불러와 해결하였음
'프로젝트 > PokéGotchi' 카테고리의 다른 글
PokéGotchi_포켓볼 피하기, 퀴즈 게임 개선 (1) | 2024.07.12 |
---|---|
PokéGotchi_메인 페이지 포켓몬 조작 및 게임 (0) | 2024.07.11 |
PokéGotchi_메인 페이지 포켓몬 교체 (0) | 2024.07.10 |
PokéGotchi_프로젝트 설계 (0) | 2024.07.08 |