HTML5 Canvas — создание аркады-скроллера по шагам

image

Предисловие

Это инструкция по созданию игры, которую я накодил за пару вечеров. Целью было не столько создание достойного представителя жанра, сколько проверка возможностей Canvas и ООП в JavaScript. Чтобы было интереснее, я поставил условие — никаких внешних файлов со спрайтами, вся графика рисуется встроенными методами. Также, не используется никаких фреймворков и библиотек. Просто потому, что в такой небольшой игре их использование ИМХО не оправдано.

В целом, Canvas молодая платформа, и может вызывать интерес перенесением на нее классических игровых концепций.

Задача

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

Исполнение

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

Для начала объявим переменные:

var c = document.getElementById('canv');
var ctx = c.getContext('2d');
var width = c.width;
var height = c.height;
var shipx = 100;
var shipy = 100;
var ship_w = 70;//ширина корабля
var ship_h = 15;//высота
var r_border = width - ship_w;//правая граница экрана, далее по аналогии
var l_border = 0;
var t_border = ship_h;
var b_border = height;
var bgr = new Array;//массив для фоновых объектов
var bullets = new Array;
var enemies = new Array;

var k_down = 0;
var k_up = 0;
var k_left = 0;
var k_right = 0;
var fires = 0;//стреляет ли корабль
//векторы движения корабля
var vx = 0; 
var vy = 0;

var cyclestep = 0;
var game_over = 0;
var score = 0;
var cset = 0;//флаг установки куков


Думаю, что примерно понятно, для чего используются вышесозданные переменные. Если нет, то это будет очевидно далее из кода. Создаем свои классы:


function enemy(hp,dx,dy,type,x,y,width){ //класс враг
		this.hp = hp;
		this.type = type;
		this.x = x;
		this.y = y;
		this.width = width;
		this.hwidth = width/2;
		this.dx = dx;
		this.dy = dy;
}
function bgObj(x,y,speed){ //класс фоновый объект
	this.x = x;
	this.y = y;
	this.speed = speed;
}
function bullet(x,y){ //класс пуля
	this.x = x;
	this.y = y;
	this.dx = 12;
	this.dy = 0;
}


Обратите внимание на параметр hwidth, который не что иное как половина ширины объекта. Он будет использоваться в проверке попадания пуль во вражеский объект. Ну а dx и dy — это скорость объекта по соответствующим осям координат.

Данные о набраных очках игрока будем хранить, естественно, в куках (cookies). Для удобства объявим функцию, которая их пишет:

function setCookie(name,value){
	var d = new Date();
	d.setDate(d.getDate()+1);
	document.cookie = name + "="+ escape(value)+";expires="+d.toGMTString();
}


Как видите, они хранятся один день. Почему именно один день… незнаю. Они нужны лишь, пока php-скрипт (или на чем у вас страница) не сравнит их с записаным ранее рекордом.
Итак, главная функция игры — draw():

function draw(){
	//устанавливаем векторы движения
	if (k_left) vx -= 2;
	else if (k_right) vx +=2;
	if (k_up) vy -=4;
	else if (k_down) vy +=4;	
	//предел скорости
	if(vx > 7) vx = 7;
	if(vy > 5) vy = 5;
	if (vy < -5) vy = -5;
	if (vx < -5) vx = -5;	
	//стреляем
	if (fires == 1 && cyclestep % 8 == 0 && game_over != 1){
			var b = new bullet(shipx+74,shipy-14);
			bullets.push(b);
	}		
	draw_bg();
	shoot();
	if (game_over != 1) draw_ship();	
	move_ship();
	draw_enemies();
	enemy_ai();	
	if(game_over == 1){
		ctx.fillStyle = "rgb(72,118,255)";
		ctx.font = "bold 30px Arial";
		ctx.textBaseline = "top";
		ctx.fillText("GAME OVER",130,150);		
		if(cset != 1){
		var uname = prompt('Enter your name:','player');
		if (uname == null || uname == "") uname = 'player';			
		setCookie('username',uname);
		setCookie('score',score);
		cset = 1;
		}
	}	
	cyclestep++;
	if (cyclestep == 128) make_wave(1,4,30);
	if (cyclestep == 256){
	cyclestep = 0;
	make_wave(2,4,20);
	}
}


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

Теперь функция отрисовки фона:

function draw_bg(){
	var distance; //"дальние" звезды более тусклые
	ctx.fillStyle = "rgb(0,0,0)";
	ctx.fillRect(0,0,width,height);	
	for (var i = 0; i < bgr.length; i++){
		distance = bgr[i].speed*40;
		if (distance < 100) distance = 100; //но не слишком 
		ctx.fillStyle = "rgb("+distance+","+distance+","+distance+")"; //цвет, как вы видите - градиент серого
		ctx.fillRect(bgr[i].x, bgr[i].y,1,1);
		bgr[i].x -=bgr[i].speed;
		if (bgr[i].x < 0){ //если звезда зашла за пределы экрана, отрисовываем заново (с другими координатами)
			bgr[i].x += width;
			bgr[i].y = Math.floor(Math.random() * height);
			bgr[i].speed = Math.floor (Math.random() * 4) + 1;
		}
	}
}


Добавляем звезд в массив и ставим частоту запуска основной функции отрисовки раз в 40 миллисекунд:

for (var i = 1; i < 50; i++){
	var b = new bgObj(Math.floor(Math.random()*height),Math.floor(Math.random()*width),Math.floor(Math.random()*4)+1);
	bgr.push(b);
	}
setInterval("draw();", 40);


Можете теперь закомментировать неопределенные пока функции в draw(), и запустить приложение. Должен появится фон с движущимися звездами. Теперь отрисовка корабля и набраных очков:

function draw_ship(){
	//Тело
	var sbpaint = ctx.createLinearGradient(shipx,shipy,shipx,shipy-15);//градиент
	sbpaint.addColorStop(0,'rgb(220,220,230)');
	sbpaint.addColorStop(1,'rgb(170,170,180)');
	ctx.fillStyle = sbpaint;
	ctx.beginPath();
	ctx.moveTo(shipx,shipy);
	ctx.lineTo(shipx+60,shipy);
	ctx.lineTo(shipx+50,shipy-15);
	ctx.lineTo(shipx+10,shipy-15);
	ctx.lineTo(shipx,shipy);
	ctx.fill();
	//Пушка
	var gpaint = ctx.createLinearGradient(shipx+50,shipy-12,shipx+70,shipy-12);
	gpaint.addColorStop(0,'rgb(190,190,200)');
	gpaint.addColorStop(1,'rgb(120,120,130)');
	ctx.fillStyle = gpaint;
	ctx.beginPath();
	ctx.moveTo(shipx+50,shipy-13);
	ctx.lineTo(shipx+70,shipy-13);
	ctx.lineTo(shipx+70,shipy-8);
	ctx.lineTo(shipx+50,shipy-8);
	ctx.lineTo(shipx+50,shipy-13);
	ctx.fill();	
	//отображаем очки
	ctx.fillStyle = "rgb(58,95,205)";
	ctx.font = "14px Arial";
	ctx.textBaseline = "top";
	ctx.fillText("Score:"+score,3,3);
}


Само собой, все рисовалось сначала на бумаге. Вот, к примеру, схематическое изображение корабля (размеры в пиклелях):

image

Так как внешних файлов я обещал не использовать, рисовать я могу только примитивы. Но, чтобы игра не выглядела как гость из начала 80-х, я накладываю на них градиенты.

Для экономии места, следующие несколько функций приведу подряд:

function move_ship(){//двигаем корабль - без комментариев
	shipx += vx;
	shipy +=vy;	
	if (shipx>r_border){
	shipx = r_border; 
	vx = 0;
	}
	if (shipx<l_border){ 
	shipx = l_border;
	vx = 0;
	}
	if (shipy>b_border){
	shipy = b_border;
	vy = 0;
	}
	if (shipy<t_border){
	shipy = t_border;
	vy = 0;
	}
}
function shoot(){
	var dead_bullets = new Array;

		for (var i = 0; i < bullets.length; i++) {
			ctx.fillStyle = "rgb(173,216,230)";
			ctx.fillRect(bullets[i].x,bullets[i].y,12,2);//пули прямоугольные, 12х2
		//проверяем выход за пределы экрана
		if (bullets[i].x > width) dead_bullets.push(i);
		//проверяем столкновение с врагом
		for (var j = 0;j < enemies.length;j++){
			if (enemies[j].type > 0){
				if (bullets[i].x >= enemies[j].x-enemies[j].hwidth && bullets[i].x < enemies[j].x+enemies[j].hwidth && bullets[i].y >= enemies[j].y-enemies[j].hwidth && bullets[i].y < enemies[j].y+enemies[j].hwidth){
					enemies[j].hp--;
				}
				if(enemies[j].hp < 0){ 
				enemies[j].type = -1;
				}
			}
		}	
	bullets[i].x += bullets[i].dx;
	bullets[i].y += bullets[i].dy;
		}
	//убираем "мертвые" пули
	for (var i = dead_bullets.length-1; i >= 0; i--){
		bullets.splice(dead_bullets[i],1);
	}
}
function make_wave(type,count,ewidth){
var h = Math.floor(Math.random()*(height-40))+40;

	for (var i = 0;i < count;i++){
		var n = new enemy(2,Math.floor(Math.random()* -4)-1,0,type,width+i*20,h+i*21,ewidth);
		enemies.push(n);
		}
}


Функция проверки столкновений врага с пулей выглядит с первого взгляда несеолько нелепо — используется параметр половины ширины врага. Но учтите, что функия затачивалась под врагов круговой формы, у которых координаты x,y в центре окружности, а пресловутая половина ширины — радиус. Отрицательный тип врага — это взрыв. Он описан вместе с остальной отрисовкой вражин:

function draw_enemies(){
	var dead_bad = new Array;
	for (var i = 1;i < enemies.length; i++){
		//Тип 1 - Полупрозрачный загадочный круг
		if(enemies[i].type == 1){
				var rg = ctx.createRadialGradient(enemies[i].x,enemies[i].y,0,enemies[i].x,enemies[i].y,enemies[i].hwidth);
				rg.addColorStop(0,"rgba(130,130,130,0.4)");
				rg.addColorStop(0.5,"rgba(125,125,125,0.5)");
				rg.addColorStop(1,"rgba(120,120,120,"+enemies[i].hp*0.4+")");				
				ctx.fillStyle = rg;
				ctx.beginPath();
				ctx.arc(enemies[i].x,enemies[i].y,15,0,Math.PI*2,true);
				ctx.fill();
		}
		//Тип 2 - Треугольник
		if(enemies[i].type == 2){
			var rg = ctx.createRadialGradient(enemies[i].x+10,enemies[i].y-10,0,enemies[i].x+10,enemies[i].y-10,enemies[i].width);
			rg.addColorStop(0,"rgba(240,240,0,"+enemies[i].hp*0.4+")");
			rg.addColorStop(1,"rgba(240,240,0,0.6");			
			ctx.fillStyle = rg;
			ctx.beginPath();
			ctx.moveTo(enemies[i].x,enemies[i].y);
			ctx.lineTo(enemies[i].x+10,enemies[i].y-20);
			ctx.lineTo(enemies[i].x+20,enemies[i].y);
			ctx.lineTo(enemies[i].x,enemies[i].y);
			ctx.fill();
		}
		//Бабах! Взрыв
		if(enemies[i].type < 0){
			ctx.fillStyle="rgb(250,250,250)";
			ctx.beginPath();
			ctx.arc(enemies[i].x,enemies[i].y,enemies[i].type * -4,0,Math.PI*2,true);
			ctx.fill();
		}
	if(enemies[i].type < 0) enemies[i].type--;
	if(enemies[i].type < -4){
	dead_bad.push(i);
	score+=2;
	}	
	if(enemies[i].x + enemies[i].width < 0) dead_bad.push(i);	
	if(enemies[i].y + 5 < 0) dead_bad.push(i);  
	if(enemies[i].y  > height+enemies[i].width) dead_bad.push(i);  	
	if(enemies[i].x < shipx+60 && enemies[i].x > shipx && enemies[i].y < shipy+15 && enemies[i].y > shipy) game_over = 1;	
	enemies[i].x += enemies[i].dx;
	enemies[i].y += enemies[i].dy;
	}
	for (var i = 0;i < dead_bad.length;i++){
		enemies.splice(dead_bad[i],1);
	}
}
function enemy_ai(){
	for (var i = 0;i < enemies.length;i++){
		if(enemies[i].type == 2){
			if(cyclestep % 4 == 0){
				if(shipy > enemies[i].y && enemies[i].y+20 < height && enemies[i].dy < 4 && enemies[i].x < width-100) enemies[i].dy++;
				if(shipy < enemies[i].y && enemies[i].y-20 > 0 && enemies[i].dy > -4 && enemies[i].x < width-100) enemies[i].dy--;
			}
		}
	}
}


Взрыв, как вы видите, продолжается аж 4 итерации, увеличиваясь в диаметре, а потом исчезает. Проверку столкновения с игроком и ИИ треугольников не описываю, думаю все очевидно (треугольник гоняется за игроком, по координате y). Ну и наконец обработка нажатий клавиш:
function get_key_down(e){
	if (e.keyCode == 37) k_left = 1;
	if (e.keyCode == 38) k_up = 1;
	if (e.keyCode == 39) k_right = 1;
	if (e.keyCode == 40) k_down = 1;	
	if(e.keyCode == 32) fires = 1;
}
function get_key_up(e){
	if (e.keyCode == 37) k_left = 0;
	if (e.keyCode == 38) k_up = 0;
	if (e.keyCode == 39) k_right = 0;
	if (e.keyCode == 40) k_down = 0;	
	if(e.keyCode == 32) fires = 0;
}


Все! Рабочий пример — вот тут. Процесс извлечения куков и их запись в файл на сервере/чтение файла на экран опущу, чтобы статья совсем не раздулась.

Послесловие


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

Спасибо за внимание.

Итог — выводы
У Chrome хронические проблемы с градиентами, так что отображать как задуманно, он не будет. Ошибка теперь ловится и отображается простая закраска, если с градиентом проблемы. Осталось исправить небольшую проблему с куками, но не в этом суть. Главное — Canvas еще очень сырой и, похоже, лучше обрабатывает спрайты, нежели некоторые внутренние методы отрисовки. Эксперимент можно считать оконченым.
Благодарность всем тестерам.
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 54
  • +2
    Главное улучшение — убрать скриллинг, который делает браузер при нажатии на используемые клавиши…
    • 0
      Поправил.
      • +1
        chrome 10.0.634.0 dev, осталось… экран дёргается на кнопках вверх/вниз
        • 0
          Переделал.
          8.0.552.237, все нормально.
          • 0
            opera 11 сборка 1156 linux — проблема присутствует
            • 0
              Бейте меня, но единственное кроссбраузерное решение проблемы, к которому я пришел — это запретить все стандартные функции клавиш на странице через .preventDefault()
              Если запрещать только для определенных кейкодов, то почему-то работает через раз.
              Ах, да, и где-то прочитал что у оперы проблемы с onkeydown/onkeyup, так что для нее специально добавил onkeypress =)
              Пока реализовал только в исправленной версии игры (Xenohunt+, она там рядом), перенесу также в оригинал.
    • +1
      Один раз появилась следующая радость (отключите нотисы или таки задайте начальное значение): Notice: Undefined index: score in /home/a/apeka/public_html/p-html/scroller.php on line 30

      Второй раз закончилась игра, когда корабль был на 90% ниже нижней границы, после показа геймовера на экране показывается только движущееся небо (без «врагов» и корабля)
      • +1
        а вот сейчас, внезапно появившийся круг расширился до всего игрового окна и оно стало полностью белым.

        Сыровато всё же, но за статью спасибо.
        • 0
          Значение задал, спасибо.
          А как вы, извините, нижнюю границу-то смогли преодолеть?
          • 0
            Ну как-то получилось… летел, опустил корабль вниз и вот корабль уже виден на 10% :)
            • 0
              И у меня появилась. Видимо появляется, когда впервые заходишь. Подозреваю, что там стоит
              $_COOKIE['score'], которые вы еще не указали.
              • 0
                Точно. Появляется при первом заходе.

                Notice: Undefined index: score in /home/a/apeka/public_html/p-html/scroller.php on line 47
                habrahabr: 2147483648
                • 0
                  Печенье указывается, но хром почему-то его не подцепляет.
                  • 0
                    Сглупил, вы правы =)
            • +1
              прописав в адресной строке javascript:score=1000; можно слегка считерить.
              =)
              • 0
                Да у меня хром сам считает, даже стрелять не надо. Уже за 13к перевалило, все жду астероид.
                • 0
                  Да, по-хорошему надо было использовать инкапсуляцию =)
                  • 0
                    По-хорошему, с этим ничего не сделаешь =( Инкапсуляция — только видимость защиты.
                    • 0
                      лечится инкапсуляция просто:
                      в той же адресной строке javascript:for (var i = 1; i < 50; i++){ p.scoreInc()};
                      =)
                      • 0
                        Уфф, как-то вылетело из головы что можно использовать циклы. Даже не знаю что на это ответить. Ограничение количества очков в единицу времени?
                        Не будет лень, реализую.
                        • +1
                          Не парьтесь. В JavaScript нету приватных переменных. То, что объявляется через var — не приватно, к нему можно получить доступ с лёгкостью. На крайний случай, я могу поставить breakpoint в firebug и вручную поменять значение на любое. И даже если не брать во внимание изменение значения вручную — я могу посмотреть, какие заголовки отправляются на сервер для значения в 100 очков и отправить заголовок со значением в 100000 очков.
                          • 0
                            Ок, пойду учить матчасть.
                  • +3
                    А картинка в начале статьи к чему? Ожидал увидеть ее в игре.
                    • 0
                      Картинка для привлечения внимания. Насколько могу судить, она на холсте (canvas). Хотел поставить скриншот, но он выглядел бы уродливо на белом фоне.
                      • +1
                        А мне картинка напомнила прекрасную анимацию на canvas: www.effectgames.com/demos/canvascycle
                    • +1
                      Неплохой пример, спасибо.
                      Одно только пожелание: побольше объяснительных комментариев в коде.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        • 0
                          От кого/чего? Читеров?
                          • НЛО прилетело и опубликовало эту надпись здесь
                            • 0
                              Какая «защита исходника», о чём вы?
                              • НЛО прилетело и опубликовало эту надпись здесь
                                • 0
                                  И что вы предлагаете?
                                  • НЛО прилетело и опубликовало эту надпись здесь
                                    • 0
                                      Чтоб на пиво заработать? На больше не заработаете, если предположить что игра такая большая, то ее зарегистрируют как продукт. И вот когда вы почувствуете безнаказанность, придут накажут за нарушение авторских прав значительно большей суммой чем заработали. Почему только на пиво? Потому что больше вы таким способом вряд ли заработаете, без официальной регистрации «игры» как некой компании, которой пользователи захотят переводить деньги.
                                      А в целом издержки есть везде…
                                      • НЛО прилетело и опубликовало эту надпись здесь
                                        • 0
                                          Нет. Могу предположить что все эти игры с серверной частью, где есть persistent storage. Соответственно получить это в полном объеме не сможете. Игры чисто на клиенте за такую цену можете показать?
                                          • НЛО прилетело и опубликовало эту надпись здесь
                                            • 0
                                              Понятно)
                                              1. Вы разницу между призом и ценой за игру видите?
                                              2. Где там игры что год писались? Или хотя бы полгода…
                                              3. Если «возьмете» чужую игру подделаете под свою. Вы как ее пиарить будете на серче?)) И сразу все бабло понесут как минимум по $4к))
                                              • НЛО прилетело и опубликовало эту надпись здесь
                                                • 0
                                                  1. Киньте!!!
                                                  • НЛО прилетело и опубликовало эту надпись здесь
                                    • 0
                                      А разве можно защитить flash- или java-код?
                                      • НЛО прилетело и опубликовало эту надпись здесь
                                        • 0
                                          Это фантастика. flash и java код можно защитить ровно на таком же уровне, как и javascript
                                          Вот пример: habrahabr.ru/blogs/java/112165/
                                          • НЛО прилетело и опубликовало эту надпись здесь
                                            • 0
                                              Сколько из них — необратимых? Ни одного! Цитата из вашего топика:

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


                                              На конференции разработчиков он-лайн игр профессиональный флешер говорил, что регулярно изучает код конкурентов и никакие ухищрения им не помогают

                                              Простая обфускация Javascript настолько же действенна, как и все методы защиты флеша.

                                              Единственный реальный способ защитить хоть как-то приложение(javascript, java, flash) — чтобы важная его часть была на сервере.
                                              • 0
                                                Вы заголовок то читали? Способы «защиты» flash-приложений
                                                Защита в кавычках видите? А почему она в кавычках не думали?
                                                • 0
                                                  Он не думал. Он троллил
                                                  • НЛО прилетело и опубликовало эту надпись здесь
                                                    • 0
                                                      «Ломают сейчас все». «Сейчас» нужно заменить на «всегда».
                                                      Если предположить что игра стоит $5к, как вы говорите, то конечно будут ломать. А как же иначе, все хотят получить кусок пирога. Защитить можно только одним способом — не давать код. Обфускация или какая-то своя проприетарная шифрация — это все временная защита. ИМХО эффективна только на мелких программах, когда усилия не стоят выгоды, все.
                            • +5
                              жесть ) это лучше никому не показывать вообще.
                              • +3
                                На фаерфоксе вполне ничего. А на других браузерах какой-то ад, извините.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.