Создадим заготовку для игры.
<!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;
}
Если запустить код прямо сейчас, то увидим нечто странное - мяч будет двигаться, оставляя за собой "хвост".
Это происходит по простой причине - мы рисуем новые круги в каждом кадре, не стирая предыдущие круги. Существует функция 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
}
}
}
Если центр мяча находится внутри координат любого кирпича, меняем направление мяча. Это справедливо в следующих ситуациях.
Оформляем в виде кода.
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. Там же можно посмотреть промежуточные результаты кода, если у вас возникли трудности.