В предыдущей части был разбор существующих HTML5 технологий предназначенных для создания браузерных игр. Теперь нам предстоит создать архитектуру игрового проекта, написать логику и работать с ресурсами. В этот раз вместо слов перейдём к коду. Крепитесь, его будет много.
Если вы что-то пропустили, вот ссылка на предыдущую часть статьи.
Первым делом создаём файл index.html. В нём подключаем игровые стили, require-модуль, контейнер с игрой, панель загрузки и ссылку для привязки событий через клавиатуру (можно привязывать события напрямую к контейнеру, только будьте уверены что ваши привязки событий не будут конфликтовать с фреймворком). Старайтесь придерживаться чистоты в вёрстке как в .html, так и в .css шаблонах. Главное правило — сократить дублирование кода. Следите чтобы созданные стили и наименование идентификаторов или классов не перекрывали используемые значения в шаблонах.
Шаблон
INDEX.HTML
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>KineticJS Simple Game</title> <!-- Style Main -->
<link type="text/css" rel="stylesheet" href="app/stylesheets/main.css"/> <!-- Require Main -->
<script type="text/javascript" data-main="app/javascript/main" src="app/javascript/lib/require.js"></script>
</head>
<body> <!-- Kinetic Canvas -->
<div id="container">
<div id="loading-info">LOADING...</div>
</div> <!-- Key detection --> <a href="javascript:void(0);" id="anchor"></a></body>
</html>
Стили
MAIN.CSS
Файл main.css инкапсулирует все импортированные стили.
@import url('defaults.css');
@import url('body.css');
@import url('typography.css');
DEFAULTS.CSS
В defaults.css происходит перезагрузка встроенных стилей для браузеров. Эдакий хак, чтобы отображать содержимое одинаково на большинстве браузеров.
/* Defaults */
* {
padding: 0;
margin: 0;
border: 0;
position: relative;
color:#FFFFFF;
background-color: transparent;
letter-spacing: 0px;
}
TYPOGRAPHY.CSS
В файле typography.css объявляем загрузку шрифтов. Шрифты можно инициализировать как через загрузчик google, так и через файловую систему, установив в директорию файл в формате *.ttf
/* @import url(http://fonts.googleapis.com/css?family=Prosto+One); */
/* ИЛИ */
@font-face {
font-family: 'Prosto One';
src: url(../fonts/Prosto_One.ttf);
}
BODY.CSS
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background-color: transparent;
}
#container {
position: absolute;
padding: 10px;
margin: 10px;
width: 640px;
height: 360px;
box-shadow: 0px 0px 15px 0px rgba(0, 15, 60, 0.83);
}
#loading-info {
position: relative;
z-index: 999;
color: black;
text-align: center;
font-size: 72px;
margin: 144px auto;/* центрируем (360/2)-(72/2) */
padding: 0;
}
Скрипты
MAIN.JS
Require-конфигуратор. Здесь задаем пути для используемых библиотек, настраиваем основной путь к скриптовым ресурсам. Делаем инициализацию игры и привязку событий.
require.config({
baseUrl: './app/javascript/usr',
removeCombined : true,
optimize : 'none',
paths: {
//cdn или локальный ресурс
jquery : [
'//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min',
'../lib/jquery-2.0.3.min'
],
//only local
kinetic : '../lib/kinetic-v5.0.1.min',
domReady : '../lib/domReady'
}
});
require([
'game'
,'controls'
,'lvlGame'
,'jquery'
,'kinetic'
,'domReady'
], function(game, controls, lvlGame, $) {
"use strict";
var $anchor = $('#anchor'),
$container = $('#container');
$anchor.focus();
// привязка считывания собыий с клавиатуры или прочих устройств I/O
$anchor.on('keydown', controls.keyDown);
// привязка считывания клика и тапа
$container.on('click touchstart', controls.click);
// создание canvas контейнера
game.stage = new Kinetic.Stage({
container: 'container',
width: 640,
height: 360
});
// создание игры
lvlGame.initialize(function() {
$('#loading-info').remove();
});
}
);
GAME.JS
Это модуль содержит главный главный игровой объект Game.
define(
'game',
function( ) {
return window.Game || {};
}
);
CONTROLS.JS
Я рекомендую отделять события от содержимого и создать модуль controls.js. С его помощью будем настраивать управление.
define(
'controls',
['lvlGame'],
function(lvlGame) {
"use strict";
return {
keyDown : function(event) {
var keyCode = event.keyCode;
switch (keyCode) {
//enter, space or P-key
case 13 :
case 32 :
case 80 : {
//запуск игры
lvlGame.play();
break;
}
default: break;
}
},
click : function(event) {
lvlGame.play();
}
};
}
);
LVLGAME.JS
Главный файл содержащий всю игровую логику.
define(
'lvlGame',
['game'],
function(game) {
"use strict";
game.init = null;// прототип инициализатора
var timer = null;// интервал таймера
var cardSize = { w: 110, h: 110 };
var gameObj = {};
// загрузка игровых объектов
function initGameObj() {
gameObj.layerBackground = null;
gameObj.layerGame = null;
gameObj.startTimer = 30.0 ; // время для игры
}
// перезагрузка игровых объектов
function resetGameObj() {
updateGameTimerText("Let's go!", 'white');
gameObj.layerGame.removeChildren();
gameObj.cardAnimationsName = ['type1', 'type2', 'type3', 'type4', 'type5'];
gameObj.timer = 0.0; // текущее значение интервала
gameObj.tempTypes = []; // временные типы карт
gameObj.tempCards = []; // временные сохраненные карты
gameObj.cards = []; // карты на уровне
gameObj.cardGroup = null; // группа карт
gameObj.cardAnimations = { // анимации карт
// x, y, width, height (1 frame)
idle: [ 0, 0, cardSize.w, cardSize.h ],
type1: [ 110, 0, cardSize.w, cardSize.h ],
type2: [ 220, 0, cardSize.w, cardSize.h ],
type3: [ 0, 110, cardSize.w, cardSize.h ],
type4: [ 110, 110, cardSize.w, cardSize.h ],
type5: [ 220, 110, cardSize.w, cardSize.h ]
};
gameObj.gameTime = 0.0 // время игры
}
// события мыши
function mouseOver() {
// защита от дурака
if(!gameObj.timer) return;
if(document.body.style.cursor !== 'pointer') {
document.body.style.cursor = 'pointer';
}
}
function mouseOut() {
if(document.body.style.cursor !== 'default') {
document.body.style.cursor = 'default';
}
}
// упаковочные ресурсы игры
var sources = {
background: 'resource/backgrounds/background.png',
card: 'resource/sprites/cards.png'
};
// асинхронная установка картинок из упаковочных ресурсов игры
function loadImages(sources, callback) {
var images = {},
loadedImages = 0,
numImages = 0,
src = null;
for(src in sources) {
numImages++;
}
for(src in sources) {
images[src] = new Image();
images[src].onload = function() {
if(++loadedImages >= numImages) {
callback(images);
}
};
images[src].src = sources[src];
}
}
// прототип инициализации. содержит внутриигровые объекты
function Init() {
gameObj.layerBackground = new Kinetic.Layer({
clearBeforeDraw: true
});
gameObj.layerGame = new Kinetic.Layer({
clearBeforeDraw: true
});
}
Init.prototype.background = function(image) {
var backImage = new Kinetic.Image({
image: image,
width: game.stage.getWidth(),
height: game.stage.getHeight()
});
gameObj.layerBackground.add(backImage);
return this;
};
Init.prototype.cards = function(img) {
gameObj.cardGroup = new Kinetic.Group({});
var i = gameObj.cardAnimationsName.length,
j,
tempElem = [],
paddingLeft = 10,
paddingTop = 10,
left = 20,
top = 20,
tempAnimName = gameObj.cardAnimationsName;
while(i--) {
// выбор случайных карт
var randElem = tempAnimName.splice(Math.floor(Math.random() * (i + 1)), 1)[0];
// сразу вставляем по две карты
tempElem.push(randElem);
tempElem.push(randElem);
}
// псевдо-случайная сортировка карт по уровню
tempElem.sort(function() {
return parseInt( Math.random() * 10 ) % 2;
});
// располагаем карты в два ряда по пять карт
for(i = 0; i < 2; i++) {
for(j = 0; j < 5; j++) {
// очистка первой временной карты
var cardType = tempElem.pop();
if(cardType) {
gameObj.cards.push(
new card(
cardType,
img,
left + j * (cardSize.w + paddingLeft),
top + i * (cardSize.h + paddingTop)
)
);
}
}
}
return this;
};
Init.prototype.text = function(text) {
// создание текстового элемента
var textTimer = new Kinetic.Text({
x: 0,
y: game.stage.getHeight() - 64, //специально для двух строк (30*2 и расстояние)
width: game.stage.getWidth() - 10,
text: text || '',
fontFamily: 'Prosto One',
fontSize: 30,
fill: 'white',
shadowColor: "black",
shadowOpacity: .8,
shadowBlur: 8,
align: 'right',
listening: false,
id: 'textTimer'
});
// установка текста на игровой слой
gameObj.layerBackground.add(textTimer);
return this;
};
// обновление текста таймера
function updateGameTimerText(text, color) {
// получение элемента по id элемента
var timeElem = game.stage.get('#textTimer')[0];
if(text) {
timeElem.setText(text);
}
if(color) {
timeElem.setFill(color);
}
// быстрая перерисовка сцены
timeElem.getLayer().batchDraw();
}
// таймер игры
function gameTimer() {
var timerValue = --gameObj.timer;
updateGameTimerText("TIME:tt" + timerValue + "'s");
gameObj.gameTime = gameObj.startTimer - timerValue;
// когда таймер завершается - гамовер
if(gameObj.timer === 0) {
gameOver();
}
}
// название карты, картинка, координаты
var card = function(type, img, x, y) {
var _this = this; //ссылка на текущий объект
this.type = type; //уникальный тип карты
this.show = false; //флаг что карта открыта
// спрайт карты
var cardSprite = new Kinetic.Sprite({
x: x || 0,
y: y || 0,
image: img,
animations: gameObj.cardAnimations,
animation: 'idle',
index: 0,
frameRate: 0
});
// запил внутрь спрайта прямоугольник
// TODO это плохая практика. Убрать отсюда
cardSprite.rectBackground = new Kinetic.Rect({
x: x,
y: y,
width: cardSize.w + 2,
height:cardSize.h + 2,
fill: 'skyblue',
// есть глюк движка, когда внутри одного спрайта рисовать другой - начинает ползти тень
shadowColor: 'black',
shadowBlur: '12',
shadowOpacity:1,
// kinetic bug: Можно использовать либо stroke, либо shadow
// stroke: 'black',
// strokeWidth: 1,
opacity: 1,
listening: false
});
// событие клика внутри по текущей карте
function cardClick() {
// защита от дурака
if(!gameObj.timer ||
cardSprite.getAnimation() !== 'idle') {
return;
}
// количество открытых карт на данный момент
var pushedCount = gameObj.cardGroup.children.filter(function(elem) {
return elem.getAttr('pushed')
}).length;
// контейнеры для карт (используются как сохранение состояний уже выбранных карт)
if(pushedCount < 2) {
gameObj.tempCards.push(cardSprite);
gameObj.tempTypes.push(_this.type);
cardSprite.setAnimation(_this.type);
cardSprite.rectBackground.setFill('white');
cardSprite.setAttr('pushed', true);
}
// если выбраны две карты
if(gameObj.tempTypes.length === 2) {
var isSame = gameObj.tempTypes.every(function(value) {
return value === _this.type;
});
// если две карты одинаковые
if(isSame) {
//ставим флаг что карты открыты
gameObj.tempCards.forEach(function(value){
value.showed = true;
value.setAttr('pushed', false);
});
gameObj.cards.length -= 2;
// если карт не осталось - уровень пройден
if(gameObj.cards.length === 0) {
gameWin();
}
} else {
// иначе поворачиваем открытые карты обратно
gameObj.tempCards
.filter(function(value) {
return !value.showed;
})
.forEach(function(value) {
var _value = value;
setTimeout(function() {
_value.setAnimation('idle');
_value.rectBackground.setFill('skyblue');
_value.setAttr('pushed', false);
_value.getLayer().batchDraw();
}, 800);
});
}
// очистка контейнеров объектов карт и их типов
gameObj.tempCards.length = 0;
gameObj.tempTypes.length = 0;
}
// перерисовка состояния всего уровня
game.stage.clear()
game.stage.draw();
}
// привязка событий
cardSprite.on('mouseover', mouseOver);
cardSprite.on('mouseout', mouseOut);
cardSprite.on('click touchend', cardClick);
// формирование группы из карт
gameObj.cardGroup.add(cardSprite);
gameObj.layerGame.add(cardSprite.rectBackground)
// установка карты на игровой слой
gameObj.layerGame.add(cardSprite);
// z-index надо вызывать после объекта установки на слой
cardSprite.rectBackground.setZIndex(0);
cardSprite.setZIndex(1);
};
// проигрыш
function gameOver() {
clearInterval(timer);
updateGameTimerText(
"Game Over!" +
"nPress enter key to restart game",
'red'
);
removeCards();
}
// выигрыш
function gameWin() {
clearInterval(timer);
updateGameTimerText(
"CONGRATULATIONS!" +
"nYour time:t" + gameObj.gameTime + "'s",
'gold'
);
}
// удаление всех карт с уровня
function removeCards() {
if(gameObj.cardGroup.hasChildren()) {
// удаление масок карт
gameObj.cardGroup.children.filter(function(elem) {
return elem.rectBackground
}).forEach(function(elem) {
elem.rectBackground.destroy();
});
// удаление карт
gameObj.cardGroup.children.destroy();
}
}
// обертка для вызова через require.js
return {
// функция инициализации игрового мира
initialize : function(callback) {
initGameObj();
// загрузка ресурсов
// инициализация полустатических объектов
loadImages(sources, function(sources) {
game.init = new Init()
.background(sources.background)
.text("Press Enter or Click" +
"n to start the game"
);
// формирование заднего слоя
game.stage.add(gameObj.layerBackground);
callback();
});
},
play: function() {
if(!gameObj.timer) {
//очистка данных
resetGameObj();
clearInterval(timer);
// загрузка ресурсов и последующая за ним функция инициализаций уровня
loadImages(sources, function(sources) {
// инициализация игровых объектов
game.init.cards(sources.card);
// формирование игрового слоя
game.stage.add(gameObj.layerGame);
// обновляем значение интервала
gameObj.timer = gameObj.startTimer;
// перезапуск таймера
timer = setInterval(gameTimer, 1000);
});
}
}
};
}
);
Итог второй части
KineticJS показала нам насколько это гибкая технология для создания браузерных игр. Используя её вместе с библиотекой require.js можно строить мощные абстрактные слои для облегчения читаемости кода. KineticJS полностью совместим для разработки мобильных игр. В третьей части я расскажу про инструмент для портирования HTML5 игр на мобильные платформы (Android, iOS, WinPhone).
В третьей части постараемся оформить полученную игру в виде Android приложения.