포트폴리오 컨셉을 게임으로 잡고 작업하던 중
컨셉이 게임이니까 간단한 게임을 넣으면 좋지 않을까? 하는 생각에 시작하게 되었다.
https://youtu.be/BgK7qM8-3dQ?si=jLCrRnbVzI6ipL8e
내가 원하는 게임과 비슷한 강의를 찾았는데 js 강의였다.
먼저 js로 만들어 실행해본 뒤 리액트로 바꾸어서 적용을 하기로 했다.
>> js 게임 전체 코드
>> 게임 실행화면

- 캔버스 배경화면은 css로 넣었다.
>> 변수
export const CANVAS_WIDTH = 700;
export const CANVAS_HEIGHT = 900;
export const ENEMY_SPEED = 2;
export const BULLET_SPEED = 4;
export const ROCKET_SPEED = 7;
export const BULLET_SIZE = 40;
export const ENEMY_SIZE = 64;
export const ROCKET_SIZE = 64;
// 이미지 불러오기, 그리기
export default function Game() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
const bulletsRef = useRef<Bullet[]>([]);
const enemiesRef = useRef<Enemy[]>([]);
const keysArrRef = useRef<string[]>([]);
const [score, setScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [gameWin, setGameWin] = useState(false);
const rocketXRef = useRef(CANVAS_WIDTH / 2 - ROCKET_SIZE / 2);
const rocketY = CANVAS_HEIGHT - ROCKET_SIZE;
// 이미지 불러오기
const loadImage = (src: string) => {
const image = new Image();
image.src = src;
return image;
};
// 이미지 그리기
const drawCanvas = () => {
if (!ctx) return;
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
const userRocket = loadImage(imgMyRocket);
const enemyImage = loadImage(imgEnemy);
const bulletImage = loadImage(imgBullet);
enemiesRef.current.forEach((enemy) => {
if (enemy.alive) {
enemy.update();
ctx.drawImage(enemyImage, enemy.x, enemy.y, ENEMY_SIZE, ENEMY_SIZE);
if (enemy.gameOver) {
setGameOver(true);
}
}
});
bulletsRef.current.forEach((bullet) => {
if (bullet.alive) {
bullet.update();
bullet.attack();
ctx.drawImage(
bulletImage,
bullet.x,
bullet.y,
BULLET_SIZE,
BULLET_SIZE
);
}
});
ctx.drawImage(
userRocket,
rocketXRef.current,
rocketY,
ROCKET_SIZE,
ROCKET_SIZE
);
};
- loadImage 이미지를 불러오는 함수를 만들어주고
- drawCanvas 캔버스에 이미지를 그린다. ( 적, 총알, 사용자 로켓)
: 적과 총알 각각 alive인 경우에만 실행하고 enemy인 경우 게임오버가 되면 setGameOver 상태를 변경해준다.
** 원래는 총알, 적, 사용자 로켓, 키입력을 usestate로 관리하고 useRef를 사용하여 상태를 연결한 후
useEffect를 통해 상태가 업데이트될 때마다 useRef에 반영하도록 했었다..
js 코드를 리액트로 옮기면서 확인해보니 누더기가 되어있었다..
그러고보니 가독성도 떨어지고 상태를 두 번 관리하는 게 너무 비효율적인 방법이라
useRef만을 사용하는 방법을 찾아 수정하였다.
>> 적, 총알 클래스
class Moving {
x: number;
y: number;
speed: number;
constructor(x: number, y: number, speed: number) {
this.x = x;
this.y = y;
this.speed = speed;
}
update() {
this.y += this.speed;
}
}
- 강의에서는 그냥 함수로 구현했지만
적과 총알의 기능에 공통된 부분이 있고 새로운 적과 총알을 계속 추가해야하기 때문에
클래스로 작성하는 것이 나을 것 같아서 수정해보았다.
(강의에서도 클래스로 작성하는 것이 좋다고 했다 :) )
- 클래스를 배우고 써먹지 않아 까먹어서 다시 찾아가면서 작성했다ㅜ
적과 총알이 움직이는 기능에서 x,y값/ 속도를 받아야하는 것이 공통되서 Moving으로 따로 빼주었다.
// 적 생성 class
export class Enemy extends Moving {
alive: boolean;
gameOver: boolean;
constructor(x: number, y: number, speed: number) {
super(x, y, speed);
this.alive = true;
this.gameOver = false;
}
update() {
if (this.alive) {
super.update();
if (this.y >= CANVAS_HEIGHT) {
this.gameOver = true;
}
}
}
}
- 적이 총을 맞은 것을 alive로 구분한다.
- alive true 상태일때만 업데이트가 되고 캔버스 사이즈를 적이 넘어가면 게임 오버가 된다.
// 총알 생성 class
export class Bullet extends Moving {
enemiesRef: React.MutableRefObject<Enemy[]>;
alive: boolean;
onUpdateScore: () => void;
constructor(
x: number,
y: number,
speed: number,
enemiesRef: React.MutableRefObject<Enemy[]>,
onUpdateScore: () => void
) {
super(x, y, speed);
this.enemiesRef = enemiesRef;
this.alive = true;
this.onUpdateScore = onUpdateScore;
}
update() {
this.y -= this.speed;
}
attack() {
if (!this.alive) return;
for (let i = 0; i < this.enemiesRef.current.length; i++) {
const enemy = this.enemiesRef.current[i];
if (enemy.alive) {
if (
// 총알 위치
this.x >= enemy.x - ENEMY_SIZE / 2 &&
this.x <= enemy.x + ENEMY_SIZE - 20 &&
this.y >= enemy.y &&
this.y <= enemy.y + ENEMY_SIZE
) {
this.alive = false;
enemy.alive = false;
this.enemiesRef.current = this.enemiesRef.current.filter(
(e) => e.alive
);
this.onUpdateScore();
}
}
}
}
}
- 원래는 enemiesRef를
enemiesRef: Enemy[]; 이렇게 일반 배열로 선언하고
addBullet에서 사용할 때 enemiesRef.current로 배열을 사용했었다.
그로인해 적이 생성되기 전 발사된 총알이 적을 맞추지 못하는 오류가 있는 것을 확인했다.
이유>>
enemiesRef가 단순 배열이기 때문에 현재 시점의 배열만 복사되어 값이 고정되어 버렸다.
그래서 적이 생성되기 전에 발사된 총알은
current 값을 추적할 수 없어 적을 맞추지 못하는 오류가 나왔던 것이다.
💡해결방법
MutableRefObject 를 사용하기
addBullet()에서 Bullet을 사용할 때 enemniesRef.current가 아닌 enemiesRef 자체를 넘겨주면
최신 상태의 current를 추적할 수 있다.
내가 js를 사용했다면 그냥 단순 배열로만 선언해도 오류가 안났을테지만
타입스크립트에서는 current값을 변경할 수 있는 MutableRefObject로 명시해줘야 타입 오류가 나지 않는다.
🔍 MutableRefObject<T>란?
useRef 는 2가지 리턴 타입이 있다.
1. MutableRefObject: current 값 변경한 경우에 사용
2. RefObject: current 값 변경이 불가능한 경우에 사용 (읽기 전용)
https://ch3coo2ca.github.io/2022-06-20/useref-types-in-typescript
- update(): 위에서 내려오는 적과는 반대로 아래에서 위로 향해야 하니까 -this.speed로 변경해준다.
- attack() :
현재 존재하는 적(enemiesRef.current)의 개수만큼 반복한다.
총알 위치를 구하기 위해 생성되는 적의 위치를 구하고

적과 총알의 위치를 이해하기 위해 임시로 그려보았다.
총알 타격의 위치를 고려하여 사이즈를 유효 영역의 사이즈를 조정하면 될 듯하다.
총알이 적을 타격했을 때 (유효영역에 들어왔을 때)
총알(this.alive)과 적(enemy.alive) 의 상태를 false로 변경하고
적 배열에서 살아있는(alive === true) 적만 남도록 필터링한다.
그리고 점수를 업데이트 해준다.
// 적 추가
const addEnemy = () => {
const newEnemy = new Enemy(
Math.random() * (CANVAS_WIDTH - ENEMY_SIZE),
0,
ENEMY_SPEED
);
enemiesRef.current = [
...enemiesRef.current.filter((enemy) => enemy.y < CANVAS_HEIGHT),
newEnemy
];
};
// 총알 추가
const addBullet = () => {
const newBullet = new Bullet(
rocketXRef.current,
CANVAS_HEIGHT,
BULLET_SPEED,
enemiesRef,
onUpdateScore
);
bulletsRef.current = [
...bulletsRef.current.filter((bullet) => bullet.y >= 0),
newBullet
];
};
// 점수 업데이트
const onUpdateScore = () => {
setScore((prev) => prev + 1);
};
// 키 이벤트
const onKeyDown = (e: KeyboardEvent) => {
if (!keysArrRef.current.includes(e.code)) {
keysArrRef.current = [...keysArrRef.current, e.code];
}
};
const onKeyUp = (e: KeyboardEvent) => {
keysArrRef.current = keysArrRef.current.filter((key) => key !== e.code);
if (e.code === 'Space') {
addBullet();
}
};
- onKeyDown
: 같은 키를 중복으로 추가하지 않기 위해서 keysArrRef.current.includes(e.code) 조건으로
해당 키가 배열에 없을 때만 새로운 키를 추가한다.
- onKeyUp
: keyup하면 keysArrRef 배열에서 해당 키 삭제
: 키가 space이면 addBullet 이벤트 발생
// 캔버스 그리기
// 캔버스 그리기
useEffect(() => {
if (canvasRef?.current) {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
setCtx(context);
}
const timer = setTimeout(() => {
if (!gameOver) {
setGameWin(true);
}
}, 60000);
return () => {
clearTimeout(timer);
};
}, [gameOver]);
- 캔버스를 초기화하고 게임을 그릴 준비
- 게임을 끝내는 기능이 없어서 승리상태를 추가하였다.
게임이 종료되지 않은 상태에서 1분이 지나면 gameWin 상태를 true로 변경한다.
// 게임 승리, 종료 이미지


// game
useEffect(() => {
let lastEnemyTime = Date.now();
let requestAnimationId: number;
// 애니메이션 처리
const onAnimation = () => {
if (gameOver || gameWin) {
return; // 게임오버
}
if (Date.now() - lastEnemyTime >= 1000) {
addEnemy();
lastEnemyTime = Date.now(); // 적 생성되는 시간
}
if (keysArrRef.current.includes('ArrowLeft')) {
rocketXRef.current = Math.max(rocketXRef.current - ROCKET_SPEED, 0); // 왼쪽 경계선 체크
}
if (keysArrRef.current.includes('ArrowRight')) {
rocketXRef.current = Math.min(
rocketXRef.current + ROCKET_SPEED,
CANVAS_WIDTH - ROCKET_SIZE
); // 오른쪽 경계선 체크
}
drawCanvas();
requestAnimationId = window.requestAnimationFrame(onAnimation);
};
// 리퀘스트 애니메이션 초기화
requestAnimationId = window.requestAnimationFrame(onAnimation);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
return () => {
// 기존 리퀘스트 애니메이션 캔슬
window.cancelAnimationFrame(requestAnimationId);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
};
}, [ctx, gameOver, gameWin]);
- 캔버스에서 게임 애니메이션을 실행하려면 requestAnimationFrame을 사용해야한다.
- onAnimation 함수를 반복호출하기.
: gameOver, gameWin -> 게임이 끝나면 실행하지 않는다.
: 사용자 로켓이 좌우로 움직일 때 캔버스 좌우를 넘어가지 않게 최소값, 최대값을 설정한다.
: 1초마다 적을 생성하기
❌오류
처음엔 setTimeout으로 했으나 탭이 비활성화되었다가 다시 활성화 될 경우
그동안 생성된 적들이 한꺼번에 내려오는 오류가 발생하였다.
문제는 << setTimeout이 한 번만 실행되므로 매번 새로운 setTimeout을 호출해야 하기 때문 >>
✅해결
Date.now()를 사용하여 마지막 적이 생성된 시점과 현재 시간을 비교해 1초가 경과했을 때만 적을 생성한다.
requestAnimationFrame 내에서 실행되므로 창이 비활성화 됐다가 활성화 되더라도
적 생성 간격이 유지된다.
https://east-star.tistory.com/34
https://noogoonaa.tistory.com/124
'언어 > React.js' 카테고리의 다른 글
[cors 오류] 알라딘 api cors 연결하기 (0) | 2025.01.11 |
---|---|
[React] typescript zustand 이용하기 (1) | 2024.12.19 |
useCallback() (0) | 2024.10.22 |
Side Effects 다루기 (0) | 2024.10.18 |
[React] 내가 보려고 쓴 리액트 (0) | 2023.10.26 |