본문 바로가기
JavaScript

[JavaScript] 벽돌깨기 게임 만들기 (2) 공 튕기기

by teamnova 2024. 8. 18.
728x90

안녕하세요.

오늘은 벽돌깨기 게임만들기 두번째 시간입니다.

이전 편에서 우리는 HTML5 <canvas>와 자바스크립트를 사용하여 벽돌 깨기 게임의 기본 구조를 만들고, 캔버스에 공, 패들, 벽돌을 그렸습니다. 이번 편에서는 공이 벽이나 패들, 벽돌에 닿았는지 여부를 확인해서 공이 튕겨나갈 수 있도록 동작을 추가해 보겠습니다.

 

이전 시간에 작성한 코드에서 이어서 작성합니다. 이전 코드는 아래 링크를 참고해주세요.

2024.08.12 - [JavaScript] - [JavaScript] 벽돌깨기 게임 만들기 (1) 에 도형 그리기

 


1. 공이 캔버스 밖으로 벗어나지 않게 하기

이 작업은 draw() 함수에서 다음 공의 위치를 업데이트할 때 이루어집니다.

 

공이 캔버스 밖으로 빠져나가지 않게 하려면

(1) 좌,우 벽에 닿았을때 공을 튕겨내기

(2) 위쪽 벽에 닿았을때 공을 튕겨내기

 

두가지가 진행되어야 합니다.

 

먼저 (1) 좌, 우 벽에 닿았았을 때 공을 튕겨내기 부분 부터 살펴보겠습니다.

우선은 공이 좌,우 벽에 닿았는지 여부를 먼저 판단해야 합니다.

 

위 사진에서 보이는 것처럼 공의 x 좌표가 ballRadius(공의 반지름) 보다 작으면 벽에 좌측 벽에 닿은 것으로 볼 수 있습니다. 반대로 x 좌표가 canvas.width(캔버스 가로길이) - ballRadius(공의 반지름) 보다 크면 우측 벽에 닿은 것으로 볼 수 있습니다.

 

draw() 함수에서는 공의 x 좌표와 y 좌표를 업데이트 할 때, 현재 좌표에서 각각 dx와 dy 만큼을 더해서 계산하고 있는데요.

이때 현재 그려야 할 프레임의 x 좌표는 (x + dx) 이므로, 위 조건을 수식으로 쓰게 되면 다음처럼 쓸 수 있습니다.

// 현재 프레임에 그려질 x 좌표가 우측 벽에 닿거나, 좌측 벽에 닿을 때
if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {

}

 

 

이제 다음으로 공이 화면 밖으로 나가지 않도록 이동 방향을 조절해주어야 합니다.

 

 

위 그림처럼 공이 좌, 우측 벽에 닿았을때 공은 기존의 이동방향과 비교해서 x축 이동방향이 반전됩니다. 즉, 원래라면 dx 만큼 이동했어야할 x 좌표가 -dx 만큼 이동하게 됩니다. 이를 수식으로 작성하면 다음처럼 쓸 수 있습니다.

// 현재 프레임에 그려질 x 좌표가 우측 벽에 닿는 경우보다 크거나, 좌측 벽에 닿는 경우보다 작을ㄷ 때
if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
	dx = -x; // x축 방향 반전
}

 

 

(2) 위쪽 벽에 닿았을때 공을 튕겨내기의 경우도 y 좌표에 대해서 위 내용과 동일합니다. 해당 수식을 쓰면 아래와 같습니다.

 

// 현재 프레임에 그려질 공의 y 좌표가 위쪽 벽에 닿는 경우보다 작으면
if(y + dy < ballRadius) {
    dy = -dy; // y축 방향 반전
}

 

 

이 코드를 draw()에 반영하면 다음과 같습니다.

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawBricks();
    drawBall();
    drawPaddle();

    // 공이 좌,우측 벽에 닿을 경우, x축 방향 반전
    if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
        dx = -dx;
    }

    // 공이 위쪽 벽에 닿으면, y축 방향 반전
    if(y + dy < ballRadius) {
        dy = -dy;
    }

    // 공의 위치 업데이트
    x += dx;
    y += dy;

    requestAnimationFrame(draw);
}

 

 

여기까지 실행해보면 공이 벽에 부딪혔을때 튕겨나오는 것을 확인할 수 있습니다.

 

 

 

2. 패들로 공 튀기기

다음으로 패들이 공과 충돌 했을때, 공이 튕겨나가도록 설정합니다.

여기서는 이전에 위쪽 벽에 공이 닿았을 때 튕겨냈던 것과 비슷합니다.

 

(1) 패들이 공과 닿았는지 여부 확인하기

(2) 닿았다면 공의 y축 이동 방향을 반전시키기

 

먼저 패들에 공이 닿았다는 것을 판단해봅시다.

 

 

y 좌표를 기준으로 봤을 때, 공이 패들에 닿은 순간에는 y 좌표가 cnavas.height(캔버스 세로 길이) - paddleHeight(패들 세로 길이) - ballRadius(공의 반지름) 가 됩니다. 이걸 현재 프레임에 그려져야할 y 좌표에 대한 수식으로 작성하면 다음과 같습니다.

 

if (y + dy > canvas.height - paddleHeight - ballRadius) {

}

 

그런데 이때 x 좌표 또한 아래 그림처럼 패들 안쪽에 위치해야 합니다. 즉, 공의 x 좌표는 패들의 가장 왼쪽을 나타내는 paddleX보다는 크고, 패들의 가장 오른쪽 좌표인 paddleX + paddleWidth 보다는 작아야 공이 패들에 닿았다고 할 수 있습니다.

 

이 두가지 조건을 한번에 만족시킬 때 공을 y축 방향 반전하여 튕긴다면 다음과 같이 수식이 작성됩니다.

 

// 공이 패들에 닿았을 때
if ((y + dy > canvas.height - paddleHeight - ballRadius) 
	&& (x > paddleX && x < paddleX + paddleWidth)) {
    dy = -dy; // y축 방향 반전
}

 

 

그런데 y축 방향 전환을 한다는 점에서 공이 위쪽 벽에 닿았을 때와 실행문이 일치합니다. 그래서 다음과 같이 정리해서 draw() 코드에 적용해주겠습니다.

 

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawBricks();
    drawBall();
    drawPaddle();

    if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
        dx = -dx;
    }

    // 공이 위쪽 벽에 닿거나, 패들에 닿으면, y축 방향 반전
    if (y + dy < ballRadius || 
        ((y + dy > canvas.height - paddleHeight - ballRadius) && 
            (x > paddleX && x < paddleX + paddleWidth))) {
        dy = -dy;
    }

    // 공의 위치 업데이트
    x += dx;
    y += dy;

    requestAnimationFrame(draw);
}

 

 

여기까지 진행된 코드를 실행해보면, 다음처럼 패들에 닿았을 때 공이 잘 튕겨지는 것을 볼 수 있습니다.

 

 

 

 

3. 공으로 벽돌 부수기

 

이제 공과 벽돌이 닿았을 때 했을 때, 공은 튕겨나가고 부딧힌 벽돌은 사라지게 만들어보겠습니다.

 

기본적인 개념은 이전과 동일합니다. 공이 특정 벽돌에 닿았다고 판단하는 조건을 위 그림을 통해서 확인하면, 다음과 같이 조건문을 작성할 수 있습니다.

if(x > b.x && x < b.x + brickWidth && y > b.y && y < b.y + brickHeight) {
                    
}

 

그런데 벽돌은 하나가 아니고, 동적으로 부수어지기도 합니다. 따라서 매 프레임마다 벽돌 2차원 배열을 순회하면서 공이 닿았는지 여부를 확인하고, 벽돌의 상태를 업데이트 해주어야 합니다. 이를 메서드로 다음과 같이 작성하여 game.js 에 추가하겠습니다.

// 벽돌과 공의 충돌 감지
function collisionDetection() {
    for(let c = 0; c < brickColumnCount; c++) {
        for(let r = 0; r < brickRowCount; r++) {
            let b = bricks[c][r];
            if(b.status == 1) { // 벽돌이 남아있을 때만 충돌 확인
                if(x > b.x && x < b.x + brickWidth && y > b.y && y < b.y + brickHeight) {
                    dy = -dy; // 공의 방향 반전
                    b.status = 0; // 벽돌이 깨짐
                }
            }
        }
    }
}

 

그리고 매 프레임마다 해당 메서드가 실행될 수 있도록 draw() 를 다음과 같이 수정해주었습니다.

 

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawBricks();
    drawBall();
    drawPaddle();
    collisionDetection(); // 벽돌과 공 충돌 감지 추가

    if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
        dx = -dx;
    }

    if (y + dy < ballRadius || 
        ((y + dy > canvas.height - paddleHeight - ballRadius) && 
            (x > paddleX && x < paddleX + paddleWidth))) {
        dy = -dy;
    }

    x += dx;
    y += dy;

    requestAnimationFrame(draw);
}

 

 

여기까지 실행하게 되면 다음 동영상처럼 벽돌이 잘 깨지는 것을 확인 할 수 있습니다.

 

 

 

4. 현재까지 전체 코드

index.html
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>벽돌 깨기 게임</title>
    <style>
        canvas {
            background: #eee;
            display: block;
            margin: 0 auto;
            border: 1px solid #000;
        }
    </style>
</head>
<body>
    <canvas id="myCanvas" width="480" height="320"></canvas>
    <script src="game.js"></script>
</body>
</html>

 

game.js
// 캔버스와 2D 컨텍스트 가져오기
const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");

// 공의 위치와 반지름 설정
let x = canvas.width / 2; // 공의 최초 위치 x 좌표
let y = canvas.height - 30; // 공의 최초 위치 y 좌표
let dx = 2; // 공의 x축 이동 속도
let dy = -2; // 공의 y축 이동 속도
let ballRadius = 10; // 공의 반지름 크기

// 패들 설정
let paddleHeight = 10; // 패들 세로 길이
let paddleWidth = 75; // 패들 가로 길이
let paddleX = (canvas.width - paddleWidth) / 2; // 패들의 초기 위치

// 벽돌 설정
let brickRowCount = 3; // 벽돌 열 수
let brickColumnCount = 5; // 벽돌 행 수
let brickWidth = 75; // 벽돌 가로 길이
let brickHeight = 20;  // 벽돌 세로 길이
let brickPadding = 10;  // 벽돌 간 간격
let brickOffsetTop = 30; // 벽돌과 캔버스 위쪽 가장자리 사이의 간격
let brickOffsetLeft = 30; // 벽돌과 캔버스 왼쪽 가장자리 사이의 간격

/** 현재 화면에 존재하는 벽돌의 상태를 추적하고 관리하기 위해 2차원 배열 사용
 * bricks[c][r] 형태
 * x, y : 벽돌의 위치를 지정하기 위한 속성, 초기에 0으로 설정하고, 이후 실제 위치를 계산할 때 업데이트.
 * status : 벽돌이 화면에 표시되어 있는지(1), 깨져서 사라졌는지(0)을 나타냄
 */
let bricks = []; 
for(let c = 0; c < brickColumnCount; c++) {
    bricks[c] = []; // 열을 위한 빈 배열 생성
    for(let r = 0; r < brickRowCount; r++) {
        bricks[c][r] = { x: 0, y: 0, status: 1 }; // 벽돌 상태 초기화
    }
}

// 공 그리기 함수
function drawBall() {
    ctx.beginPath(); // 새로운 경로 시작
    ctx.arc(x, y, ballRadius, 0, Math.PI * 2); // 원 그리기
    ctx.fillStyle = "#0095DD"; // 공의 색상
    ctx.fill(); // 원 채우기
    ctx.closePath(); // 경로 닫기
}

// 패들 그리기 함수
function drawPaddle() {
    ctx.beginPath(); // 경로 그리기 시작
    ctx.rect(paddleX, canvas.height - paddleHeight, paddleWidth, paddleHeight); // 사각형 그리기
    ctx.fillStyle = "#0095DD"; // 패들 색상
    ctx.fill(); // 내부 채우기
    ctx.closePath(); // 경로 닫기
}

// 벽돌 그리기 함수 - 2차원 배열을 순회하면서 status가 1인 벽돌만 화면에 그린다.
function drawBricks() {
    for(let c = 0; c < brickColumnCount; c++) {
        for(let r = 0; r < brickRowCount; r++) {
            if(bricks[c][r].status == 1) {
                let brickX = (c * (brickWidth + brickPadding)) + brickOffsetLeft;
                let brickY = (r * (brickHeight + brickPadding)) + brickOffsetTop;
                bricks[c][r].x = brickX;
                bricks[c][r].y = brickY;
                ctx.beginPath();
                ctx.rect(brickX, brickY, brickWidth, brickHeight);
                ctx.fillStyle = "#0095DD";
                ctx.fill();
                ctx.closePath();
            }
        }
    }
}

// 벽돌과 공의 충돌 감지
function collisionDetection() {
    for(let c = 0; c < brickColumnCount; c++) {
        for(let r = 0; r < brickRowCount; r++) {
            let b = bricks[c][r];
            if(b.status == 1) { // 벽돌이 남아있을 때만 충돌 확인
                if(x > b.x && x < b.x + brickWidth && y > b.y && y < b.y + brickHeight) {
                    dy = -dy; // 공의 방향 반전
                    b.status = 0; // 벽돌이 깨짐
                }
            }
        }
    }
}

// 마우스 이동에 대한 이벤트 리스너 설정
document.addEventListener("mousemove", mouseMoveHandler, false);

// 마우스 이동시 패들이 마우스 위치를 따라가도록 구현
function mouseMoveHandler(e) {
    let relativeX = e.clientX - canvas.offsetLeft;
    if(relativeX > 0 && relativeX < canvas.width) {
        paddleX = relativeX - paddleWidth / 2;
    }
}

// 애니메이션 프레임마다 실행되는 함수
function draw() {
	// 이전 프레임을 지우고, 새로운 프레임을 그리기 위해 준비
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawBricks(); // 벽돌 그리기
    drawBall(); // 공 그리기
    drawPaddle(); // 패들 그리기
    collisionDetection(); // 공과 벽돌 충돌 감지

    // 공이 좌,우측 벽에 닿을 경우, x축 방향 반전
    if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
        dx = -dx;
    }

    // 공이 위쪽 벽에 닿거나, 패들에 닿으면, y축 방향 반전
    if (y + dy < ballRadius || 
        ((y + dy > canvas.height - paddleHeight - ballRadius) && 
            (x > paddleX && x < paddleX + paddleWidth))) {
        dy = -dy;
    }

    // 공의 위치 업데이트
    x += dx;
    y += dy;

    requestAnimationFrame(draw); // 다음 프레임 요청
}

draw(); // 첫 번째 프레임 실행