본문 바로가기
JavaScript

[JavaScript] 벽돌깨기 게임 발전시키기 - 가속도와 마찰력 추가하기

by teamnova 2024. 9. 3.
728x90

안녕하세요.

오늘은 이전 글에 이어서 벽돌깨기 게임을 더 현실감 있게 만들기 위해 가속도와 마찰력을 추가해보겠습니다.

지난 포스팅까지 따라해보셨다면, 공의 움직임이 현실과는 다르고 좀 답답하다고 느끼셨을 수 있습니다. 그 이유는 공이 속도가 변하지 않는 등속운동을 하도록 구현되어 있기 때문인데요. 가속도와 마찰력 개념을 도입하면 공의 속도가 현실에서 이동하는 물체와 유사하게 변화하도록 할 수 있습니다.

이전 코드는 아래 링크를 참고해주세요.

2024.08.28 - [JavaScript] - [JavaScript] 벽돌깨기 게임 발전시키기 - 목숨 기능 추가하기

 

[JavaScript] 벽돌깨기 게임 발전시키기 - 목숨 기능 추가하기

안녕하세요.오늘은 이전에 만들었던 벽돌깨기 게임 예제를 더 발전시키기 위해서 목숨 기능을 추가해보겠습니다.목숨은 플레이어가 실수로 공을 놓쳤을 때 줄어들며, 목숨이 0이 되면 게임이

stickode.tistory.com

1. 가속도와 마찰력 개념 이해하기

가속도란 시간에 따라 물체의 속도가 변하는 정도를 의미합니다. 이는 물체에 힘이 가해질 때 발생하며, 그 힘이 물체의 속도를 변화시킵니다.

마찰력은 물체가 다른 물체와 접촉할 때 그 움직임을 방해하는 힘입니다. 이 힘의 결과로 물체의 속도가 줄어들 수 있습니다. 

 

예를 들어 테니스를 생각해봅시다.

 

테니스 공을 라켓으로 칠 때, 공에 힘이 가해지면서 공의 속도가 변하게 됩니다. 공을 강하게 때리면 공은 빠르게 날아가고, 약하게 때리면 공이 느리게 날아갑니다. 이 때, 라켓으로 공을 칠 때 가해지는 힘이 공의 속도를 변화시키며, 이 속도 변화의 정도가 바로 가속도입니다.

 

반대로 테니스 공이 공중을 날아갈 때, 점점 속도가 줄어듭니다. 그리고 공이 코트에 떨어져 바닥과 접촉하면, 공이 튀어오르면서도 그 속도는 점차 줄어듭니다. 이것은 마찰력의 영향 때문인데요.

공이 공중을 날아가는 동안 우리 눈에는 보이지 않지만 무수히 많은 공기 입자와 부딪히게 됩니다. 이때 공과 공기 입자 간의 마찰력이 작용하여 공의 속도가 점차 줄어듭니다. 마찬가지로, 공이 코트와 접촉할 때도 공과 코트 사이의 마찰력이 발생하여 공의 속도를 더욱 줄어들게 합니다.

 

2. 벽돌깨기 게임에 가속도와 마찰력 적용하기

이제 가속도와 마찰력을 벽돌깨기 게임에 적용해보겠습니다.

게임에서 패들이 공을 강하게 치면, 공의 속도는 빨라져야 합니다. 즉, 패들의 가속도가 공의 가속도에 영향을 주어야 합니다.

반면, 공이 공중을 날아가거나 벽, 벽돌 등과 부딪힐 때는 속도가 느려져야 합니다. 즉, 공은 마찰력의 영향을 받아 속도가 줄어들어야 합니다.

이를 위해서 가속도와 마찰력과 관련된 몇 가지 변수를 추가하고, draw 함수와 collisionDetection 함수에서 이를 적용할 수 있도록 코드를 수정할 것입니다.

 

2-1. 가속도와 마찰력 변수 추가

먼저, 가속도와 마찰력을 적용하기 위한 변수를 추가하겠습니다. 이 변수들은 공의 속도에 영향을 주는 요소로, 각각 패들의 움직임에 의해 발생하는 가속도와 공이 다른 물체에 부딪힐 때 발생하는 마찰력을 나타냅니다.

let previousPaddleX, accelerationFactor, friction, minSpeed;

 

  • previousPaddleX는 이전 프레임의 패들 위치를 저장해 두어 패들이 얼마나 움직였는지 계산할 때 사용됩니다.
  • accelerationFactor는 패들의 움직임이 공에 미치는 가속도 영향을 조절하는 역할을 합니다.
  • friction은 공의 속도가 서서히 줄어드는 것을 표현합니다.
  • minSpeed는 공이 너무 느려져서 멈추는 것을 방지하기 위해 설정하는 공의 최소 속도입니다.

 

 

2-2. init 함수에서 초기화 하기

이제 위에서 선언한 변수들을 init 함수에서 초기화합니다. 이렇게 하면 게임을 시작할 때마다 이 변수들이 적절한 값으로 초기화되어 정상적으로 동작할 수 있습니다.

function init() {
	//(생략)
    
    // 패들 설정
    paddleHeight = 10;
    paddleWidth = 75;
    paddleX = (canvas.width - paddleWidth) / 2;
    previousPaddleX = paddleX; // 초기 패들 위치 저장

	// (생략)

    // 가속도와 마찰력 변수 초기화
    accelerationFactor = 0.5; // 패들의 가속도가 공에 미치는 영향
    friction = 0.995; // 공이 벽에 부딪힐 때 속도를 감소시키는 마찰력
    minSpeed = 2; // 공의 최소 속도 설정 (멈추지 않도록)
}

 

2-3. collisionDetection 함수 수정

패들의 움직임에 따라 공의 속도를 변화시키기 위해 collisionDetection 함수에서 패들과 공이 충돌할 때 가속도를 적용합니다.

패들이 움직인 속도를 기반으로 공의 속도(dx)에 가속도를 더해줍니다. 패들이 빠르게 움직일수록 공이 더 빨라지게 됩니다.

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;
                }
            }
        }
    }

    // 패들과 공의 충돌 감지
    if(y + dy > canvas.height - ballRadius - paddleHeight) {
        if(x > paddleX && x < paddleX + paddleWidth) {
            let paddleSpeed = paddleX - previousPaddleX; // 패들의 움직임 속도 계산
            dx += paddleSpeed * accelerationFactor; // 패들의 가속도를 공에 적용
            dy = -dy; // 공의 방향을 반대로 바꿉니다.
        }
    }

    previousPaddleX = paddleX; // 현재 패들의 위치를 이전 위치로 저장
}

 

2-4. draw 함수 수정

공이 다른 물체에 부딪힐 때 속도가 줄어들도록 마찰력을 적용합니다. 이를 위해 draw 함수를 수정해서 공의 위치를 업데이트할 때 마찰력을 적용합니다.

또한, 공의 속도가 너무 느려져서 게임이 진행되지 않는 상황을 방지하게 위해 , 공의 최소 속도 제한을 설정합니다.

 

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawBricks();
    drawBall();
    drawPaddle();
    drawLives(); // 남은 목숨을 화면에 표시
    collisionDetection();

    if(checkBricks()) {
        gameClear();
        return;
    }

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

    if(y + dy > canvas.height - ballRadius) {
        lives--; // 목숨을 하나 줄입니다.
    
        if(!lives) {
            gameOver(); // 목숨이 0이면 게임 오버 처리
            return;
        } else {
            // 목숨이 남아 있으면 게임을 계속할 수 있도록 위치를 초기화합니다.
            x = canvas.width / 2;
            y = canvas.height - 30;
            dx = 2;
            dy = -2;
            paddleX = (canvas.width - paddleWidth) / 2;
        }
    }

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

    x += dx;
    y += dy;

    // 마찰력 적용
    dx *= friction;
    dy *= friction;

    // 최소 속도 제한 적용
    if (Math.abs(dx) < minSpeed) {
        dx = minSpeed * Math.sign(dx);
    }
    if (Math.abs(dy) < minSpeed) {
        dy = minSpeed * Math.sign(dy);
    }
}

 

  • Math.abs(dx) < minSpeed: 이 조건문은 현재 dx의 절대값이 minSpeed보다 작은지 확인합니다. 만약 공의 속도가 minSpeed보다 작다면, 공의 속도를 최소 속도로 설정합니다.
  • dx = minSpeed * Math.sign(dx);: 이 라인은 dx가 양수인지 음수인지에 따라 dx의 값을 minSpeed로 설정합니다. Math.sign(dx)는 dx의 부호를 반환합니다. 예를 들어, dx가 음수일 경우 Math.sign(dx)는 -1을 반환하여 dx는 -minSpeed가 됩니다. 이를 통해 공이 멈추지 않고 계속 이동하도록 합니다.
  • Math.abs(dy) < minSpeed 및 dy = minSpeed * Math.sign(dy); 부분도 동일한 방식으로 y축 속도(dy)에 대해 최소 속도를 설정합니다.

 

 

3. 시연영상

 

 

4. 전체 코드

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

let x, y, dx, dy, ballRadius, paddleHeight, paddleWidth, paddleX;
let brickRowCount, brickColumnCount, brickWidth, brickHeight, brickPadding, brickOffsetTop, brickOffsetLeft;
let bricks, gameInterval, gameStarted = false;

const startButton = document.getElementById("startButton");
const restartButton = document.getElementById("restartButton");

// 추가된 변수들
let lives;
let previousPaddleX, accelerationFactor, friction, minSpeed;


function init() {
    // 공의 위치와 이동 속도 설정
    x = canvas.width / 2;
    y = canvas.height - 30;
    dx = 2; // 공의 x축 이동 속도
    dy = -2; // 공의 y축 이동 속도
    ballRadius = 10;

    // 패들 설정
    paddleHeight = 10;
    paddleWidth = 75;
    paddleX = (canvas.width - paddleWidth) / 2;
    previousPaddleX = paddleX; // 초기 패들 위치 저장

    // 벽돌 설정
    brickRowCount = 3;
    brickColumnCount = 5;
    brickWidth = 75;
    brickHeight = 20;
    brickPadding = 10;
    brickOffsetTop = 30;
    brickOffsetLeft = 30;

    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 };
        }
    }

    lives = 3; // 플레이어에게 3개의 목숨을 부여
    gameStarted = false; // 게임이 아직 시작되지 않음

    // 가속도와 마찰력 변수 초기화
    accelerationFactor = 0.5; // 패들의 가속도가 공에 미치는 영향
    friction = 0.995; // 공이 벽에 부딪힐 때 속도를 감소시키는 마찰력
    minSpeed = 2; // 공의 최소 속도 설정 (멈추지 않도록)
}


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();
}

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;
                }
            }
        }
    }

    // 패들과 공의 충돌 감지
    if(y + dy > canvas.height - ballRadius - paddleHeight) {
        if(x > paddleX && x < paddleX + paddleWidth) {
            let paddleSpeed = paddleX - previousPaddleX; // 이전 프레임과 비교하여 패들이 얼마나 움직였는지 계산
            dx += paddleSpeed * accelerationFactor; // 패들의 가속도를 공에 적용

            dy = -dy; // 공의 방향을 반대로 바꿉니다.
        }
    }

    previousPaddleX = paddleX; // 현재 패들의 위치를 이전 위치로 저장
}

function mouseMoveHandler(e) {
    let relativeX = e.clientX - canvas.offsetLeft;
    if(relativeX > 0 && relativeX < canvas.width) {
        paddleX = relativeX - paddleWidth / 2;
    }
}

document.addEventListener("mousemove", mouseMoveHandler, false);

function checkBricks() {
    for(let c = 0; c < brickColumnCount; c++) {
        for(let r = 0; r < brickRowCount; r++) {
            if(bricks[c][r].status == 1) {
                return false;
            }
        }
    }
    return true;
}

function gameClear() {
    clearInterval(gameInterval);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.font = "24px Arial";
    ctx.fillStyle = "#0095DD";
    ctx.textAlign = "center";
    ctx.fillText("Game Clear!", canvas.width / 2, canvas.height / 2);
    restartButton.style.display = "block";
}

function gameOver() {
    clearInterval(gameInterval);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.font = "24px Arial";
    ctx.fillStyle = "#FF0000";
    ctx.textAlign = "center";
    ctx.fillText("Game Over", canvas.width / 2, canvas.height / 2 - 20);
    restartButton.style.display = "block";
}

function drawLives() {
    ctx.font = "16px Arial";
    ctx.fillStyle = "#0095DD";
    ctx.fillText("Lives: " + lives, canvas.width - 65, 20);
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawBricks();
    drawBall();
    drawPaddle();
    drawLives(); // 남은 목숨을 화면에 표시
    collisionDetection();

    if(checkBricks()) {
        gameClear();
        return;
    }

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

    if(y + dy > canvas.height - ballRadius) {
        lives--; // 목숨을 하나 줄입니다.
    
        if(!lives) {
            gameOver(); // 목숨이 0이면 게임 오버 처리
            return;
        } else {
            // 목숨이 남아 있으면 게임을 계속할 수 있도록 위치를 초기화합니다.
            x = canvas.width / 2;
            y = canvas.height - 30;
            dx = 2;
            dy = -2;
            paddleX = (canvas.width - paddleWidth) / 2;
        }
    }

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

    x += dx;
    y += dy;

    dx *= friction; // 공의 x축 속도에 마찰력 적용
    dy *= friction; // 공의 y축 속도에 마찰력 적용

    // 최소 속도 제한 적용
    if (Math.abs(dx) < minSpeed) {
        dx = minSpeed * Math.sign(dx);
    }
    if (Math.abs(dy) < minSpeed) {
        dy = minSpeed * Math.sign(dy);
    }

}

function startGame() {
    startButton.style.display = "none";
    restartButton.style.display = "none";
    let countdown = 3;
    gameInterval = setInterval(() => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.font = "24px Arial";
        ctx.fillStyle = "#0095DD";
        ctx.textAlign = "center";
        ctx.fillText(countdown, canvas.width / 2, canvas.height / 2);
        countdown--;

        if(countdown < 0) {
            clearInterval(gameInterval);
            gameInterval = setInterval(draw, 10); // 게임 시작
        }
    }, 1000);
}

startButton.addEventListener("click", () => {
    init();
    startGame();
});

restartButton.addEventListener("click", () => {
    init();
    startGame();
});