Создаем HTML5 игру. Часть 2: пишем игровую логику

Итак, вы установили необходимые программы. Запускаем ИСР. Создаем директории и файлы как показано на рисунке ниже:

В предыдущей части статьи был разбор существующих 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;
}

#anchor {
}

#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:\t\t" + 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).

Посмотреть результат
Читать далее