Pull to refresh

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

Reading time 8 min
Views 5.6K
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 еще очень сырой и, похоже, лучше обрабатывает спрайты, нежели некоторые внутренние методы отрисовки. Эксперимент можно считать оконченым.
Благодарность всем тестерам.
Tags:
Hubs:
+43
Comments 54
Comments Comments 54

Articles