언어/React.js

[React.js] canvas 를 이용한 게임

홍시_코딩기록 2025. 2. 3. 23:26

포트폴리오 컨셉을 게임으로 잡고 작업하던 중

컨셉이 게임이니까 간단한 게임을 넣으면 좋지 않을까? 하는 생각에 시작하게 되었다. 

 

https://youtu.be/BgK7qM8-3dQ?si=jLCrRnbVzI6ipL8e

내가 원하는 게임과 비슷한 강의를 찾았는데 js 강의였다.

먼저 js로 만들어 실행해본 뒤 리액트로 바꾸어서 적용을 하기로 했다.

 

>> js 게임 전체 코드 

더보기
let canvas;
let ctx;
canvas = document.querySelector(".game_canvas");
ctx = canvas.getContext("2d");
canvas.width = "500";
canvas.height = "800";

const scoreTextEl = document.querySelector(".score");
const gameoverEl = document.querySelector(".gameover");
const youWinEl = document.querySelector(".you_win");

// 1분 지나면 you win 하기

let backgroundImage, spaceshipImage, bulletImage, enemyImage, gameOverImage;
let gameOver = false; // true면 게임 끝남.
let score = 0;

// 우주선 좌표
let spaceshipX = canvas.width / 2 - 32;
let spaceshipY = canvas.height - 60;

let gameTime = 0;
const oldTime = Date.now();

setInterval(() => {
  const currentTime = Date.now();
  // 경과한 밀리초 가져오기
  const diff = currentTime - oldTime;

  // 초(second) 단위 변환하기
  const sec = Math.floor(diff / 1000);

  gameTime = sec;
}, 1000);

let bulletList = []; // 총알 저장 리스트
function Bullet() {
  this.x = 0;
  this.y = 0;
  this.alive = true; // true 살아있는 총알
  this.init = function () {
    this.x = spaceshipX + 14;
    this.y = spaceshipY;

    bulletList.push(this);
  };

  this.update = function () {
    this.y -= 7;
  };
  // 총알이 적군에 닿았는지 확인
  this.checkHit = () => {
    for (let i = 0; i < enemyList.length; i++) {
      if (
        this.x >= enemyList[i].x - 30 && // 총알의 x 좌표가 적의 왼쪽 경계보다 크거나 같고
        this.x <= enemyList[i].x + 30 && // 총알의 x 좌표가 적의 오른쪽 경계보다 작거나 같으며
        this.y >= enemyList[i].y && // 총알의 y 좌표가 적의 상단보다 크거나 같고
        this.y <= enemyList[i].y + 60 // 총알의 y 좌표가 적의 하단보다 작거나 같음
      ) {
        score++;
        this.alive = false;
        enemyList.splice(i, 1);
      }
    }
  };
}

function enemyRandomValue(min, max) {
  let randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
  return randomNum;
}

let enemyList = []; // 적군 저장 리스트
function Enemy() {
  this.x = 0;
  this.y = 0;
  this.init = function () {
    this.y = 0;
    this.x = enemyRandomValue(0, canvas.width - 60);

    enemyList.push(this);
  };
  this.update = function () {
    this.y += 2; // 속도 조절

    if (this.y >= canvas.height - 60) {
      gameOver = true;
      console.log("gameover");
    }
  };
}
function loadImage() {
  backgroundImage = new Image();
  backgroundImage.src = "../assets/image/background.jpg";

  spaceshipImage = new Image();
  spaceshipImage.src = "../assets/image/my_rocket.png";

  bulletImage = new Image();
  bulletImage.src = "../assets/image/bullet.png";

  enemyImage = new Image();
  enemyImage.src = "../assets/image/rocket.png";

  // gameOverImage = new Image();
  // gameOverImage.src = '../assets/image/gameover.jpg';
}

let keysDown = {};
function setupKeyboardListener() {
  document.addEventListener("keydown", function (e) {
    keysDown[e.keyCode] = true;
  });
  document.addEventListener("keyup", function (e) {
    delete keysDown[e.keyCode];

    if (e.keyCode === 32) {
      createBullet();
    }
  });
}


function createBullet() {
  let b = new Bullet();
  b.init();
}

function createEnemy() {
  const interval = setInterval(() => {
    let e = new Enemy();
    e.init();
  }, 1000);
}

function update() {
  if (39 in keysDown) {
    spaceshipX >= canvas.width - 64
      ? (spaceshipX = canvas.width - 64)
      : (spaceshipX += 5);
  }
  if (37 in keysDown) {
    spaceshipX >= 0 ? (spaceshipX -= 5) : 0;
  }

  for (let i = 0; i < bulletList.length; i++) {
    if (bulletList[i].alive) {
      bulletList[i].update();
      bulletList[i].checkHit();
    }
  }
  for (let i = 0; i < enemyList.length; i++) {
    enemyList[i].update();
  }
}

function render() {
  ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
  scoreTextEl.innerHTML = score;

  for (let i = 0; i < enemyList.length; i++) {
    ctx.drawImage(enemyImage, enemyList[i].x, enemyList[i].y, 40, 40);
  }
  for (let i = 0; i < bulletList.length; i++) {
    if (bulletList[i].alive) {
      ctx.drawImage(bulletImage, bulletList[i].x, bulletList[i].y, 40, 40);
    }
  }
  ctx.drawImage(spaceshipImage, spaceshipX, spaceshipY);
}

function main() {
  if (!gameOver && gameTime <= 10) {
    update();
    render();
    requestAnimationFrame(main);
    console.log(keysDown)
  } else if (gameTime >= 10) {
    youWinEl.classList.add("active");
  } else {
    gameoverEl.classList.add("active");
  }
}

loadImage();
setupKeyboardListener();
createEnemy();
main();

// 총알 만들기
// 1. 스페이스바 누르면 총알 발사
// 2. 총알이 발사 = 총알의 y값이 --, 총알의 x값은? 스페이스를 누른 순간의 우주선의 x좌표
// 3. 발사된 총알들은 총알 배열에 저장을 함.
// 4. 총알들은 x,y 좌표값이 있어야 함.
// 5. 총알 배열을 가지고 render 그려준다.

 

 

>> 게임 실행화면

- 캔버스 배경화면은 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