Breakout

Создадим заготовку для игры.


<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Breakout</title>
<style>
	* { padding: 0; margin: 0; }
	canvas { background: #eee; display: block; margin: 0 auto; }
</style>
</head>
<body>

<canvas id="myCanvas" width="480" height="320"></canvas>

<script>
	// JavaScript code goes here
</script>

</body>
</html>

Игра будет происходить в элементе canvas. Инициализируем её в блоке script.


var canvas = document.getElementById("myCanvas");
var ctx = canvas.getContext("2d");

Нарисуем красный квадрат. Он будет находиться в 20 пикселях от левого края холста и 40 пикселях от верхнего края холста. Ширина и высота фигуры равна 50 пикселям. Цвет задан в свойстве fillStyle, режим заливки - сплошной цвет (fill).


ctx.beginPath();
ctx.rect(20, 40, 50, 50);
ctx.fillStyle = "#FF0000";
ctx.fill();
ctx.closePath();

Вы можете использовать и другие фигуры и другие режимы заливки.


var canvas = document.getElementById("myCanvas");
var ctx = canvas.getContext("2d");


После первого знакомства с рисованием фигур, удалим предыдущий код (оставим первые две строчки). Создадим новую функцию draw(), в котором будет происходить динамическая отрисовка кадров игры. Обновление экрана будет происходить за счёт функции setInterval() с интервалом 10 миллисекунд. Функция во время игры вызывается бесконечно и создаётся игровой цикл.


function draw() {
    // drawing code
}

setInterval(draw, 10);

Добавим мяч в функцию draw().


function draw() {
    ctx.beginPath();
    ctx.arc(50, 50, 10, 0, Math.PI * 2);
    ctx.fillStyle = "#0095DD";
    ctx.fill();
    ctx.closePath();
}

Мы получим неподвижный кружочек, который очень часто перерисовывается.

Заставим мяч двигаться. Чтобы управлять его движением, нужно создать две переменные, которые будут отвечать за его начальную позицию на поле. Разместим мяч в центре по горизонтали и с небольшим отступом от нижнего края поля.


var x = canvas.width / 2;
var y = canvas.height - 30;

Обновим одну строчку кода в функции draw().


ctx.arc(x, y, 10, 0, Math.PI * 2);

Мяч будет выводиться в заданной позиции. Чтобы он смещался, нужно создать две переменные, которые будут задавать величину смещения по экрану за каждое обновление кадра.


var dx = 2;
var dy = -2;

Обновляем значения x и y на заданное смещение в функции draw().


function draw() {
    ...
	
    x += dx;
    y += dy;
}

Если запустить код прямо сейчас, то увидим нечто странное - мяч будет двигаться, оставляя за собой "хвост".

Breakout

Это происходит по простой причине - мы рисуем новые круги в каждом кадре, не стирая предыдущие круги. Существует функция clearRect(), которая поможет нам стереть ненужные круги.


function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();
    ctx.arc(x, y, 10, 0, Math.PI * 2);
    ctx.fillStyle = "#0095DD";
    ctx.fill();
    ctx.closePath();
	
    x += dx;
    y += dy;
}

Функция стирает содержимое всего холста (мы указали всю область прямоугольника canvas) и заново рисует фигуру в новой позиции. Теперь мы увидим правильное движение мяча по диагонали.

Далее код будет усложняться. Чтобы не запутаться в нём, удобнее разделять код на отдельные блоки. Код, связанный с рисованием мяча вынесем в отдельную функцию.


function drawBall() {
    ctx.beginPath();
    ctx.arc(x, y, 10, 0, Math.PI * 2);
    ctx.fillStyle = "#0095DD";
    ctx.fill();
    ctx.closePath();
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
	
    drawBall();
	
	x += dx;
    y += dy;
}

Код скрипта на данный момент.


<script>
var canvas = document.getElementById("myCanvas");
var ctx = canvas.getContext("2d");

var x = canvas.width / 2;
var y = canvas.height - 30;

var dx = 2;
var dy = -2;

function drawBall() {
    ctx.beginPath();
    ctx.arc(x, y, 10, 0, Math.PI * 2);
    ctx.fillStyle = "#0095DD";
    ctx.fill();
    ctx.closePath();
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
	
    drawBall();
	
	x += dx;
    y += dy;
}

setInterval(draw, 10);
</script>

Запущенный мяч улетает за пределы экрана в бесконечность. Нам нужно научить мяч отскакивать от стенок.

Для вычислений момента столкновений необходимо знать размер мяча.


var ballRadius = 10;

Обновим функцию drawBall().


//ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.arc(x, y, ballRadius, 0, Math.PI * 2);

У игрового поля есть четыре стороны. Начнём с верхней стены - при её достижении мяч должен отразиться под углом в обратную сторону. Отслеживая позицию мяча, мы можем определить момент, когда значения станут отрицательными. В этом случае меняем знак у переменной смещения мяча и он начнёт двигаться вниз от верхней стенки.


if(y + dy < 0) {
    dy = -dy;
}

Похожим образом поступаем с нижней стенкой. Отслеживаем момент, когда позиция мяча станет больше высоты игрового поля и меняем знак на противоположный.


if(y + dy > canvas.height) {
    dy = -dy;
}

Оба условия имеют одинаковый код смены знака. Мы можем объединить два условия в один.


if(y + dy > canvas.height || y + dy < 0) {
    dy = -dy;
}

По той же схеме определяем момент достижения левой или правой стенки игрового поля, отслеживая переменную x.


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

Этот код следует поместить в draw() сразу после вызова drawBall(). Вроде работает, но есть небольшая проблема - мяч частично проваливается за пределы поля. Мы не учитывали размер мяча и в наших вычислениях момент столкновения происходит не совсем корректно. Проблему легко исправить, добавив созданную ранее переменную ballRadius.


function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
	
    drawBall();
	
	if(x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
        dx = -dx;
    }
	
    if(y + dy > canvas.height - ballRadius || y + dy < ballRadius) {
        dy = -dy;
    }
	
	x += dx;
    y += dy;
}

Запускаем пример и видим, что мяч корректно отражается от всех стенок. Мы получили бесконечную игру-заставку.

Следующий этап - создание ракетки, которой будем отбивать мяч. Создадим новые переменные - размеры и начальная позиция по центру.


var paddleHeight = 10;
var paddleWidth = 75;
var paddleX = (canvas.width - paddleWidth) / 2;

Создадим функцию для показа ракетки на экране.


function drawPaddle() {
    ctx.beginPath();
    ctx.rect(paddleX, canvas.height - paddleHeight, paddleWidth, paddleHeight);
    ctx.fillStyle = "#0095DD";
    ctx.fill();
    ctx.closePath();
}

Мы можем вывести ракетку на экран (после drawBall(), но толку от неё будет немного. Она неподвижна и бесполезна. Ракетка должна управляться с клавиатуры. Добавим новые переменные.


var rightPressed = false;
var leftPressed = false;

Перед функцией setInterval() добавим два слушателя.


document.addEventListener("keydown", keyDownHandler, false);
document.addEventListener("keyup", keyUpHandler, false);

function keyDownHandler(e) {
    if(e.key == "Right" || e.key == "ArrowRight") {
        rightPressed = true;
    }
    else if(e.key == "Left" || e.key == "ArrowLeft") {
        leftPressed = true;
    }
}

function keyUpHandler(e) {
    if(e.key == "Right" || e.key == "ArrowRight") {
        rightPressed = false;
    }
    else if(e.key == "Left" || e.key == "ArrowLeft") {
        leftPressed = false;
    }
}

Большинство браузеров используют ArrowLeft и ArrowRight, но для IE/Edge нужно использовать другие значения. Поэтому приходится поддерживать оба варианта.

Добавим в конец функции draw() условие, которое заставит ракетку двигаться.


if(rightPressed) {
paddleX += 7;
}
else if(leftPressed) {
    paddleX -= 7;
}

При нажатии ракетка смещается на 7 пикселей влево или вправо. Всё замечательно, но ракетка может улететь за пределы поля. Нужно добавить в код ограничение на перемещение ракетки.


if(rightPressed && paddleX < canvas.width - paddleWidth) {
    paddleX += 7;
}
else if(leftPressed && paddleX > 0) {
    paddleX -= 7;
}

Теперь мы можем управлять ракеткой. Осталось научиться отбивать ей мячи.

Но сначала мы напишем код об окончании игры, когда мы промахнёмся по мячу ракеткой. А происходит это в том случае, когда мяч касается нижней стороны игрового поля. Следовательно мы можем оставить код столкновения мяча с левой, правой и верхней части поля, а для случая с нижним краем код подредактируем. При столкновении с нижним краем будем выводить сообщение и перезапускать игру.

Перепишем вызов setInterval(), что она возвращала значение.


var interval = setInterval(draw, 10);

Заменим второе условие для столкновений.


if(y + dy < ballRadius) {
    dy = -dy;
} else if(y + dy > canvas.height - ballRadius) {
    alert("GAME OVER");
    document.location.reload();
    clearInterval(interval);
}

Можно запустить пример и убедиться, что игра заканчивается корректно.

Теперь нужно написать код для столкновений ракетки и мяча. Проверяем, где находится центр мяча и края ракетки.

Ещё раз модифицируем предыдущий код.


if(y + dy < ballRadius) {
    dy = -dy;
} else if(y + dy > canvas.height - ballRadius) {
    if(x > paddleX && x < paddleX + paddleWidth) {
        dy = -dy;
    }
	else {
        alert("GAME OVER");
        document.location.reload();
        clearInterval(interval);
	}
}

Следующий этап - создание кирпичей, которые предстоит разбивать. Заведём переменные для настроек кирпичей - их размеры, отступы друг от друга и краёв поля, количество рядов и столбцов.


var brickRowCount = 3;
var brickColumnCount = 5;
var brickWidth = 75;
var brickHeight = 20;
var brickPadding = 10;
var brickOffsetTop = 30;
var brickOffsetLeft = 30;

Большое количество кирпичей удобнее контролировать через двумерный массив, в котором можно задать ряды и столбцы.


var bricks = [];
// c - column, r - row
for(var c = 0; c < brickColumnCount; c++) {
    bricks[c] = [];
    for(var r = 0; r < brickRowCount; r++) {
        bricks[c][r] = { x: 0, y: 0 };
    }
}

Создадим функцию для рисования кирпичей.


function drawBricks() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            bricks[c][r].x = 0;
            bricks[c][r].y = 0;
            ctx.beginPath();
            ctx.rect(0, 0, brickWidth, brickHeight);
            ctx.fillStyle = "#0095DD";
            ctx.fill();
            ctx.closePath();
        }
    }
}

В таком виде есть проблема - все кирпичи рисуются в одном месте (накладываются друг на друга). Добавим новые переменные.


var brickX = (c * (brickWidth + brickPadding)) + brickOffsetLeft;
var brickY = (r * (brickHeight + brickPadding)) + brickOffsetTop;

Переменная brickX работает как сумма ширины и отступа кирпича, умноженная на номер столбца и плюс отступ слева от края поля. По похожему принципу работает brickY. Перепишем функцию.


function drawBricks() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            var brickX = (c * (brickWidth+brickPadding)) + brickOffsetLeft;
            var 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();
        }
    }
}

Добавим вызов функции в начале функции draw().


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

Кирпичики появились на экране, но мяч их "не замечает". Нужен код, определяющий столкновение мяча с кирпичами. Создадим функцию, в которой будет цикл. Проходя в цикле по всем кирпичам, будем сравнивать позицию каждого кирпича с координатами мяча.


function collisionDetection() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            var b = bricks[c][r];
            // calculations
        }
    }
}

Если центр мяча находится внутри координат любого кирпича, меняем направление мяча. Это справедливо в следующих ситуациях.

  • позиция X мяча больше, чем позиция X кирпича
  • позиция X мяча меньше, чем позиция X кирпича плюс его ширина
  • позиция Y мяча больше, чем позиция Y кирпича
  • позиция Y мяча меньше, чем позиция Y кирпича плюс его высота

Оформляем в виде кода.


function collisionDetection() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            var b = bricks[c][r];
            if(x > b.x && x < b.x + brickWidth && y > b.y && y < b.y + brickHeight) {
                dy = -dy;
            }
        }
    }
}

При столкновении нужно убрать кирпич с экрана. Вернёмся к коду инициализации кирпичей и добавим новое свойство status кирпичу.


for(var c = 0; c < brickColumnCount; c++) {
    bricks[c] = [];
    for(var r = 0; r < brickRowCount; r++) {
        bricks[c][r] = { x: 0, y: 0, status: 1 };
    }
}

В drawBricks() проверяем свойство перед выводом на экран. Если статус равен 1, тогда рисуем кирпич. Если статус будет равен 0, значит его коснулся мяч и нам нужно убрать кирпич с экрана.


function drawBricks() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
		    if(bricks[c][r].status == 1) {
                var brickX = (c * (brickWidth+brickPadding)) + brickOffsetLeft;
                var 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();
			}
        }
    }
}

В функции collisionDetection() будем менять статус при столкновении.


function collisionDetection() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            var 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;
				}
            }
        }
    }
}

Добавляем функция определения столкновения мяча с кирпичом collisionDetection() в функции draw() после вызова функции drawPaddle().


collisionDetection();

Запускаем пример, пытаемся отбить мяч ракеткой и видим, что кирпичики исчезают с экрана. Игра практически готова. Осталось прикрутить счёт, жизни игрока, момент победы.

Добавим новую переменную для счёта и функцию, которая будет выводить счёт на экране.


var score = 0;

function drawScore() {
    ctx.font = "16px Arial";
    ctx.fillStyle = "#0095DD";
    ctx.fillText("Score: " + score, 8, 20);
}

Увеличение счёта происходит, когда мяч разбивает очередной кирпичик. Добавим вызов созданной функции в collisionDetection(). В функции draw() вызываем drawScore()


function collisionDetection() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            var 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;
					score++;
				}
            }
        }
    }
}

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

Теперь мы можем набирать очки. Но когда мы разобьём все кирпичики, игра становится бессмысленной. Нужно отслеживать окончание игры и выводить сообщение о победе. Добавим новый код в collisionDetection()


function collisionDetection() {
    for(var c = 0; c < brickColumnCount; c++) {
        for(var r = 0; r < brickRowCount; r++) {
            var 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;
					score++;
					if(score == brickRowCount * brickColumnCount) {
                        alert("YOU WIN, CONGRATULATIONS!");
                        document.location.reload();
                        clearInterval(interval); // Needed for Chrome to end game
                    }
				}
            }
        }
    }
}

После того, как пользователь закроет сообщение о победе, игра запустится снова.

Кроме клавиатуры, можно управлять игрой с помощью мыши. Добавим новый слушатель после уже существующих для клавиатуры.


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

Функция для работы с мышью.


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

В функции мы вычисляем значение relativeX, которое равно позиции мыши по горизонтали с учётом расстояния между левым краем холста и левым краем клиентской области. С учётом полученной информации двигаем ракетку.

Отлично! Можем играть двумя способами.

Дадим игроку право на ошибку. Заведём новую переменную для жизни игрока. Функция будет выводить число жизней на экране.


var lives = 3;

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

Теперь не будем завершать игру в случае ошибки, а просто уменьшаем число оставшихся жизней. Заодно восстанавливаем позицию ракетки и мяча. Добавим код в draw() у блока else:


else {
    //alert("GAME OVER");
    //document.location.reload();
    //clearInterval(interval);
	
	lives--;
    if(!lives) {
        alert("GAME OVER");
        document.location.reload();
        clearInterval(interval); // Needed for Chrome to end game
    }
    else {
        x = canvas.width / 2;
        y = canvas.height - 30;
        dx = 2;
        dy = -2;
        paddleX = (canvas.width - paddleWidth) / 2;
    }
}

В той же функции после вызова drawScore() вызываем drawLives().


drawScore();
drawLives();

Последний штрих не связан с механикой игры, а позволяет улучшить анимацию. Заменяем последнюю строчку кода.


//var interval = setInterval(draw, 10);
draw();

Удаляем вызов функции из кода (в двух местах).


//clearInterval(interval); // Needed for Chrome to end game

В методе draw() перед закрывающей скобкой вызываем requestAnimationFrame(), которая обеспечит обновление экрана.


requestAnimationFrame(draw);

Игра готова. Поиграть можно на отдельной демо-странице.

На основе статьи MDN. Там же можно посмотреть промежуточные результаты кода, если у вас возникли трудности.

Реклама