Canvas로 객체 실습해보기

목차

시작

MDN에서는 JS 객체의 실습을 해보라고 Canvas API를 이용한 예제를 제공한다. 공이 날아다니는 걸 구현해보는 예제인데 생각보다 재미있다.

1. 기본 구조

1.1. HTML

다음과 같은 HTML을 만든다.

<!DOCTYPE html>
<html lang="en-us">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width">
    <title>공튀기기</title>
    <link rel="stylesheet" href="index.css">
  </head>
 
  <body>
    <h1>공튀기기</h1>
    <canvas></canvas>
 
    <script src="main.js"></script>
  </body>
</html>

1.2. JS

그리고 여기에 있는 css, js 파일을 복사하자. 스크롤바를 숨기고 캔버스를 화면에 꽉 차게 만들며 기본적인 함수 선언이 된 코드다.

그리고 JS 파일에 다음처럼 공 클래스를 작성한다.

class Ball{
  /*
  x, y는 공의 2차원 좌표, velX, velY는 공의 2차원 속도
  color,size는 공의 색상과 크기(px단위 반지름)
  */
  constructor(x, y, velX, velY, color, size){
    this.x = x;
    this.y = y;
    this.velX = velX;
    this.velY = velY;
    this.color = color;
    this.size = size
  }
 
  // 공을 canvas 객체(ctx)에 그린다.
  draw(){
    ctx.beginPath();
    ctx.fillStyle = this.color;
    ctx.arc(this.x, this.y, this.size, 0, 2*Math.PI);
    ctx.fill();
  }
}

이제 Ball 클래스 생성자를 통해 공을 만들고 ball.draw()를 호출하면 공을 그릴 수 있다. 그리고 공의 위치를 업데이트하는 함수를 클래스 내에 작성한다. 공이 벽에 부딪쳐서 튕겨나오는 것을 구현한 것을 볼 수 있다.

update(){
  // 공이 반대 방향으로 튀기는 것
  if(this.x+this.size>=width){
    this.velX = -(this.velX);
  }
 
  if(this.x-this.size<=0){
    this.velX = -(this.velX);
  }
 
  if(this.y+this.size>=height){
    this.velY = -(this.velY);
  }
 
  if(this.y-this.size<=0){
    this.velY = -(this.velY);
  }
  //공 위치 업데이트
  this.x+=this.velX;
  this.y+=this.velY;
}

이제 공들을 캔버스에 만들어 보자. 다음 코드는 랜덤하게 위치와 속도, 크기를 지정한 공을 만들고 캔버스에 그리는 코드다.

const balls=[];
 
while(balls.length<25){
  let size = random(10,20);
  let ball = new Ball(
    random(0+size,width-size),
    random(0+size,height-size),
    random(-7,7),
    random(-7,7),
    randomRGB(),
    size
  );
  balls.push(ball);
}
 
function loop(){
  ctx.fillStyle = 'rgba(0,0,0,0.25)';
  ctx.fillRect(0,0,width,height);
 
  for(let i=0;i<balls.length;i++){
    balls[i].draw();
    balls[i].update();
  }
  // loop를 재귀적으로 호출
  requestAnimationFrame(loop);
}
 
loop();

이를 브라우저에서 열어보면 검은 배경에 공들이 움직이는 걸 볼 수 있다.

2. 충돌 구현

충돌도 구현해보자. 다음 메서드를 Ball 생성자에 추가한다.

collisionDetect(){
  for(const ball of balls){
    if(this!==ball){
      const dx = this.x-ball.x;
      const dy = this.y-ball.y;
      const distance = Math.sqrt(dx*dx+dy*dy);
      // 물리적인 충돌 시뮬레이션이 목적은 아니므로, 충돌시 색만 같게 한다.
      if(distance<this.size+ball.size){
        ball.color = this.color = randomRGB();
      }
    }
  }
}

그리고 loop 함수의 모든 공을 순회하는 부분에 공 각각의 collisionDetect 메서드를 호출하도록 추가한다.

function loop(){
  ctx.fillStyle = 'rgba(0,0,0,0.25)';
  ctx.fillRect(0,0,width,height);
 
  for(let i=0;i<balls.length;i++){
    balls[i].draw();
    balls[i].update();
    // 이 부분 추가
    balls[i].collisionDetect();
  }
  // loop를 재귀적으로 호출
  requestAnimationFrame(loop);
}

공들이 화면에서 서로 부딪히면 색이 바뀌는 걸 볼 수 있다.

3. 예제 발전시키기

3.1. 사용자 조작 기능

사용자가 조작할 수 있는 공을 추가하고 거기에 공이 닿으면 공이 사라지도록 해보자. 또한 클래스 설계를 좀더 잘해보자.

먼저 사용자가 조작할 수 있는 공과 그냥 공이 상속받을 일반적인 Shape클래스를 정의하자. Shape클래스는 Ball클래스와 EvilCircle클래스의 공통점인 위치와 속도만을 담는다.

class Shape{
  constructor(x,y,velX,velY){
    this.x = x;
    this.y = y;
    this.velX = velX;
    this.velY = velY;
  }
}

그리고 Ball 클래스가 Shape를 상속받는 걸로 하고 생성자를 바꾸자. canvas에 공이 존재하는지를 뜻하는 exists 프로퍼티도 추가하고 충돌 감지 함수도 exist 시에만 체크하도록 변경한다.

class Ball extends Shape{
  constructor(x, y, velX, velY, color, size){
    super(x,y,velX,velY);
    this.color = color;
    this.size = size
    // 공이 존재하는지(사용자 공에게 먹히면 false가 됨)
    this.exists = true;
  }
  // ...
 
  collisionDetect(){
    for(const ball of balls){
      if(this!==ball && ball.exists){
        const dx = this.x-ball.x;
        const dy = this.y-ball.y;
        const distance = Math.sqrt(dx*dx+dy*dy);
        // 물리적인 충돌 시뮬레이션이 목적은 아니므로, 충돌시 색만 같게 한다.
        if(distance<this.size+ball.size){
          ball.color = this.color = randomRGB();
        }
      }
    }
  }
}

이제 사용자가 조작할 공, 공을 먹어치우는 EvilCircle을 정의하자. EvilCircle은 Shape를 상속받고, 공을 먹어치우는 메서드를 추가한다. 이런 공은 하나밖에 없을 예정이긴 하지만 연습을 위해 클래스를 사용한다. 예제에서 시키는 그대로 하면 된다.

class EvilCircle extends Shape{
  constructor(x,y){
    super(x,y,20,20);
    this.color = 'white';
    this.size = 10;
    // 사용자가 키로 조작할 수 있도록 키다운 이벤트 리스너 추가
    window.addEventListener('keydown',(e)=>{
      switch(e.key){
        case 'a':
          this.x-=this.velX;
          break;
        case 'd':
          this.x+=this.velX;
          break;
        case 'w':
          this.y-=this.velY;
          break;
        case 's':
          this.y+=this.velY;
          break;
      }
    })
  }
 
  // EvilCircle을 canvas 객체(ctx)에 그린다.
  draw(){
    ctx.beginPath();
    ctx.lineWidth = 5;
    ctx.strokeStyle = this.color;
    ctx.arc(this.x, this.y, this.size, 0, 2*Math.PI);
    ctx.stroke();
  }
 
  // 공이 화면을 벗어나지 않도록 한다.
  update(){
    // 공이 밖으로 벗어나는 걸 막는다
    if(this.x+this.size>=width){
      this.x=width-this.size;
    }
 
    if(this.x-this.size<=0){
      this.x=this.size;
    }
 
    if(this.y+this.size>=height){
      this.y=height-this.size;
    }
 
    if(this.y-this.size<=0){
      this.y=this.size;
    }
  }
 
  collisionDetect(){
    for(const ball of balls){
      // 존재하는 공만 따지면 된다.
      if(ball.exists){
        const dx = this.x-ball.x;
        const dy = this.y-ball.y;
        const distance = Math.sqrt(dx*dx+dy*dy);
        // 물리적인 충돌 시뮬레이션이 목적은 아니므로, 충돌시 색만 같게 한다.
        if(distance<this.size+ball.size){
          ball.exists = false;
        }
      }
    }
  }
}

그리고 evilCircle을 생성하고 loop 함수에 추가하자.

const evilCircle = new EvilCircle(50,50);
 
function loop(){
  ctx.fillStyle = 'rgba(0,0,0,0.25)';
  ctx.fillRect(0,0,width,height);
 
  evilCircle.draw();
  evilCircle.update();
  evilCircle.collisionDetect();
 
  for(let i=0;i<balls.length;i++){
    if(balls[i].exists){
      balls[i].draw();
      balls[i].update();
      balls[i].collisionDetect();
    }
  }
  // loop를 재귀적으로 호출
  requestAnimationFrame(loop);
}
 
loop();

3.2. 공 개수 표시하기

이제 공의 개수를 화면에 표시해 보자. h1 요소 아래 p태그를 배치한다. 스타일링은 예제에 있는 걸 복사하자.

<h1>공튀기기</h1>
<p>Ball count :</p>
<canvas></canvas>

그리고 공의 개수를 loop 함수의 호출마다 세주어서 p 태그의 내용을 업데이트한다.

function loop(){
  ctx.fillStyle = 'rgba(0,0,0,0.25)';
  ctx.fillRect(0,0,width,height);
 
  evilCircle.draw();
  evilCircle.update();
  evilCircle.collisionDetect();
  // ball 개수 업데이트
  let ballCount=0;
 
  for(let i=0;i<balls.length;i++){
    if(balls[i].exists){
      // 존재하는 공만 센다.
      ballCount++;
      balls[i].draw();
      balls[i].update();
      balls[i].collisionDetect();
    }
  }
  para.textContent = `Ball count: ${ballCount}`;
  // loop를 재귀적으로 호출
  requestAnimationFrame(loop);
}

이다음 브라우저에서 실행해 보면 유저가 w,a,s,d로 컨트롤할 수 있는 하얀색 원이 생기고, 공을 먹어치우면 공의 개수가 줄어드는 걸 볼 수 있다.

참고

객체 만들기 실습 https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object_building_practice

https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Adding_bouncing_balls_features