Разрабатываем Flappy Bird на Phaser


    Картинка для привлечения внимания

    Доброго времени суток, Хабр!

    Где-то месяц назад (на момент написания этого поста) я задался целью создать свой клон игры Flappy Bird. Но все никак не доходили до этого руки. Катализатором сего действия стал небольшой хакатон. «А почему бы и нет» — подумал я, и взялся за реализацию этой игры.

    Учитывая, что разработать нужно было за 2 дня, я не изобретал «велосипедов» и взял готовый игровой движок — Phaser.

    В этой части мы рассмотрим инициализацию игровой сцены, напишем «прелоадер» ресурсов и подготовим фундамент для игрового меню.


    Что такое Phaser?


    Phaser is a fast, free and fun open source game framework for making desktop and mobile browser HTML5 games. It uses Pixi.js internally for fast 2D Canvas and WebGL rendering.

    Phaser — это фреймворк, который позволяет нам очень быстро создавать игры. Я не утрирую, с его помощью создать игру действительно легко и быстро. Не отвлекаемся на Actor'ов, рендеринг, физику — фокусируемся на игровой логике.
    Его однозначными плюсами есть Pixi.js. Это один из быстрейших движков, который рендерит с помощью WebGL. А в случае, если WebGL не поддерживается — на Canvas.
    Также Phaser радует огромным набором готовых классов: SpriteAnimation, TileMap, Timer, GameState и много другое. В том числе, и компоненты физического движка: RigidBody, Physics и т.п.
    Наличие данных компонентов значительно упрощает разработку.

    Подключаем Phaser и другие зависимости


    Я не нагружал игру множеством зависимостей, поэтому список небольшой: Phaser, WebFont и Clay. Первый нужен для разработки игры, WebFont для загрузки шрифтов с Google Fonts и Clay для таблицы рекордов.

    Приведенный ниже код содержится в файле index.html.
    index.html
    <!DOCTYPE html>
    <head>
        <meta charset="utf-8">
        <title>Flappy Bird</title>
        <link rel="shortcut icon" href="/favicon.ico" />
        <style type="text/css">
        * {
            margin: 0;
            padding: 0;
        }
        </style>
    </head>
    <body>
        <script type="text/javascript">
        var Clay = Clay || {};
        Clay.gameKey = "gflappybird";
        Clay.readyFunctions = [];
        Clay.ready = function(fn) {
            Clay.readyFunctions.push(fn);
        };
        (function() {
            var clay = document.createElement("script");
            clay.async = true;
            clay.src = ("https:" == document.location.protocol ? "https://" : "http://") + "clay.io/api/api-leaderboard-achievement.js";
            var tag = document.getElementsByTagName("script")[0];
            tag.parentNode.insertBefore(clay, tag);
        })();
        </script>
        <script src="//ajax.googleapis.com/ajax/libs/webfont/1.4.7/webfont.js"></script>
        <script src="//cdnjs.cloudflare.com/ajax/libs/phaser/1.1.4/phaser.min.js"></script>
        <script src="js/Game.js"></script>
    </body>
    </html>

    В index.html мы просто подключаем зависимости, ничего лишнего. В том числе и наш скрипт Game.js, который мы рассмотрим позже. Не добавляем ни строчки HTML, т.к. Phaser рендерит сцену непосредственно в body.
    Phaser может рендерит и в созданный вами контейнер, если это необходимо.

    Подключаем шрифты


    В Game.js находится только одна функция — GameInitialize(). В замыкании этой функции и происходят все вычисления. Перед тем как ее вызвать, нужно дождаться загрузки шрифтов. Иначе, есть большая вероятность того, что шрифты не успеют загрузиться и они не будут доступны Phaser. Для этого используем WebFont:

    WebFont.load({
            google: {
                families: ['Press+Start+2P']
            },
            active: function() {
                GameInitialize();
            }
        });
    

    Мы «попросили» WebFont загрузить нам шрифт «Press Start 2P» с Google Fonts и при окончании загрузки вызываем функцию GameInitialize(), которая продолжит инициализацию всех необходимых игровых объектов.

    В дальнейшем содержание поста будет рассказываться исключительно в рамках функции GameInitialize().

    Объявляем константы, создаем экземпляр Phaser.Game, добавляем GameState'ы


    Для начала добавим переменные, которые будут иметь значения де-факто при использовании. Так как использование const не слишком «валидно», то используем переменные:
    Игровые константы
    var DEBUG_MODE = true, //рендерим отладочную информацию
        SPEED = 180, //скорость полета птички
        GRAVITY = 1800, //коэффициент гравитации в игровом мире
        BIRD_FLAP = 550, //с каким ускорением птичка "взлетает"
        PIPE_SPAWN_MIN_INTERVAL = 1200, //минимальная задержка перед следующей трубой
        PIPE_SPAWN_MAX_INTERVAL = 3000, //максимальная задержка
        AVAILABLE_SPACE_BETWEEN_PIPES = 130, //минимальное свободное пространство между трубами (по вертикали)
        CLOUDS_SHOW_MIN_TIME = 3000, //минимальная задержка перед следующим облаком
        CLOUDS_SHOW_MAX_TIME = 5000, //максимальная задержка перед следующим облаком
        MAX_DIFFICULT = 100, //на основе этого коэффициента также вычисляется расстояние между трубами
        SCENE = '', //идентификатор сцены, где нужно рендерить. В данном случае пусто (по умолчанию рендерит в body)
        TITLE_TEXT = "FLAPPY BIRD", //Название игры в главном меню
        HIGHSCORE_TITLE = "HIGHSCORES", //Название игрового меню
        HIGHSCORE_SUBMIT = "POST SCORE", //Название кнопки в рекордах для сохранения своего рекорда
        INSTRUCTIONS_TEXT = "TOUCH\nTO\nFLY", //Инструкция в главном меню
        DEVELOPER_TEXT = "Developer\nEugene Obrezkov\nghaiklor@gmail.com", //Куда ж без копирайтов :)
        GRAPHIC_TEXT = "Graphic\nDmitry Lezhenko\ndima.lezhenko@gmail.com",
        LOADING_TEXT = "LOADING...", //Сообщение о загрузке игры
        WINDOW_WIDTH = window.innerWidth || document.documentElement.clientWidth || document.getElementsByTagName('body')[0].clientWidth,
        WINDOW_HEIGHT = window.innerHeight || document.documentElement.clientHeight || document.getElementsByTagName('body')[0].clientHeight;
    

    Также нам понадобятся вспомогательные переменные для хранения всех созданных объектов Phaser:
    Переменные для Phaser-объектов
    var Background, //Игровой фон
        Clouds, CloudsTimer, //Облака и таймер для спауна облаков
        Pipes, PipesTimer, FreeSpacesInPipes, //Наши трубы, таймер и "прозрачный" объект, который будет "триггером" пролета
        Bird, //Птичка
        Town, //TileSprite города на фоне
        FlapSound, ScoreSound, HurtSound, //Звуки взлета, пролета трубы и проигрыша
        SoundEnabledIcon, SoundDisabledIcon, //Иконки включения\отключения звука
        TitleText, DeveloperText, GraphicText, ScoreText, InstructionsText, HighScoreTitleText, HighScoreText, PostScoreText, LoadingText, //все текстовые объекты
        PostScoreClickArea, //Зона клика для сохранения рекорда
        isScorePosted = false, //Флаг для проверки, был ли рекорд "запостен"
        isSoundEnabled = true, //Флаг для проверки, нужно ли воспроизводить звук
        Leaderboard; //И собственно Leaderboard объект от Clay.io
    

    Вкратце опишем, что за переменная и зачем она нужна.
    • Background — здесь храним Rectangle с цветом #53BECE.
    • Clouds — группа объектов. Каждый из них является обычным спрайтом.
    • CloudsTimer — таймер, который спаунит новые облака.
    • Pipes — группа объектов. Аналогично облакам, каждый объект является спрайтом.
    • PipesTimer — таймер, который спаунит новые трубы.
    • FreeSpacesInPipes — для того, чтобы определить, что птичка пролетела, нам нужно как-то это событие словить. В этой переменной как раз хранятся объекты без спрайта, который являются триггерами.
    • Bird — храним птичку, у которой есть RigidBody и SpriteMap для анимации.
    • Town — TileMap города, который двигается на фоне.
    • FlapSound — звук, который воспроизводим при щелчке мышкой (взмах крыльями).
    • ScoreSound — звук пролета через трубу.
    • HurtSound — звук окончания игры, коллизия с трубой либо выход за рамки игрового мира.
    • SoundEnabledIcon, SoundDisabledIcon — два спрайта с отображением иконки включенного звука, и выключенного аналогично.
    • TitleText, InstuctionsText, DeveloperText, GraphicText — элементы текста, который мы отображаем в игровом меню.
    • ScoreText — текст, который отображаем во время игры.
    • HighScoreTitleText, HighScoreText, PostScoreText — текст в таблице рекордов.
    • LoadingText — текст загрузки игры.
    • PostScoreClickArea — Rectangle, который будет помогать определить, нажал ли пользователя на кнопку Post Score.
    • isScorePosted — флаг, в целях защиты от повторного постинга этого же рекорда (если пользователь два раза нажмет Post Score в рекордах).
    • isSoundEnabled — флаг, по которому определяем, включенный\выключенный звук в игре.
    • Leaderboard — объект, который хранит респонс от Clay.io.

    После объявления всех переменных, можем начать инициализацию Phaser.Game и добавление в игру необходимых GameState'ов.

    Phaser.Game() принимает следующие параметры:
    new Game(width, height, renderer, parent, state, transparent, antialias)
    Нас интересует width, height, renderer, parent. Достаточно указать размеры холста, метод рендеринга и пустой контейнер, чтобы Phaser начал рендерить игровую сцену в body.

    Инициализируем Phaser.Game используя наши константы, объявленные раньше:

    var Game = new Phaser.Game(WINDOW_WIDTH, WINDOW_HEIGHT, Phaser.CANVAS, SCENE);
    

    Мы инициализировали игровую сцену, но у нас еще нету игровых State'ов. Нужно исправить эту оплошность.
    В Game.state хранится указатель на Phaser.StateManager. В нем есть нужная нам функция add() для добавления собственных State'ов. Ее сигнатура:
    add(key, state, autoStart)
    key — это строка для идентификации State'а (его ID), state — это объект Phaser.State, autoStart — запускать ли State сразу после его инициализации. В данном случае, autoStart нам не нужен, чтобы могли сами определять вызов State'ов в нужные моменты игры.
    Добавим все игровые State'ы в игровую сцену:

    Game.state.add('Boot', BootGameState, false);
    Game.state.add('Preloader', PreloaderGameState, false);
    Game.state.add('MainMenu', MainMenuState, false);
    Game.state.add('Game', GameState, false);
    Game.state.add('GameOver', GameOverState, false);
    

    Каждый из этих игровых State'ов будет рассмотрен дальше.

    Последним шагом, который запустит loop игрового процесса, является старт BootGameState'а.

    Game.state.start('Boot');
    

    Привожу полный код инициализации игры:
    Инициализация игры
    //Создаем instance игры на весь экран с использованием Canvas
    var Game = new Phaser.Game(WINDOW_WIDTH, WINDOW_HEIGHT, Phaser.CANVAS, SCENE);
        //Включаем поддержку RequestAnimationFrame
        Game.raf = new Phaser.RequestAnimationFrame(Game);
        Game.antialias = false;
        Game.raf.start();
        //Добавляем все игровые State в объект Game
        //В следующих частях каждый из State'ов будет подробно описан
        Game.state.add('Boot', BootGameState, false);
        Game.state.add('Preloader', PreloaderGameState, false);
        Game.state.add('MainMenu', MainMenuState, false);
        Game.state.add('Game', GameState, false);
        Game.state.add('GameOver', GameOverState, false);
        //Главным шагом является старт загрузки Boot State'а
        Game.state.start('Boot');
        //Получаю Clay Leaderboard и сохраняю в вспомогательную переменную
        Clay.ready(function() {
            Leaderboard = new Clay.Leaderboard({
                id: 'your-leaderboard-id'
            });
        });
    

    Как создавать игровые State'ы?


    В Phaser есть конструктор Phaser.State(). Все что нужно для создания игрового State'а — это вызвать этот конструктор:

    var BootGameState = new Phaser.State();
    

    После этого мы можем переопределить выполнение функций Phaser своими. В State можно выделить 4 основных loop'а: create, preload, render, update.
    • Phaser.State.create вызывается после успешной смены State'ов. Сюда можно писать инициализацию логики игры, заполнение переменных и т.п.
    • Phaser.State.preload вызывается и работает во время загрузки ресурсов. Если вам нужно загрузить какой-то спрайт или звук — делайте это здесь.
    • Phaser.State.render вызывается каждый раз, как рендерится кадр (frame). Здесь делаем операции по рендерингу.
    • Phaser.State.update вызывается после рендеринга. Здесь производим расчеты и, собственно, бизнес-логика игры.

    Теперь рассмотрим наш стартовый State, который инициализирует игровой loop.

    В дальнейших пунктах я буду указывать в скобках имя переменной, в которой хранится Phaser.State()

    Уведомим игрока, что загрузка началась (BootGameState)


    Создаем instance Phaser.State. После его успешной загрузки добавляем текст с надписью «Loading...» и располагаем по центру. Не забываем начать загрузку PreloaderState'а.

    var BootGameState = new Phaser.State();
        BootGameState.create = function() {
            LoadingText = Game.add.text(Game.world.width / 2, Game.world.height / 2, LOADING_TEXT, {
                font: '32px "Press Start 2P"',
                fill: '#FFFFFF',
                stroke: '#000000',
                strokeThickness: 3,
                align: 'center'
            });
            LoadingText.anchor.setTo(0.5, 0.5);
            Game.state.start('Preloader', false, false);
        };
    


    Пишем «прелоадер» ресурсов (PreloaderGameState)


    Чтобы загрузить спрайт, звук, анимацию и т.п., в Phaser, можно использовать Phaser.Loader. Указатель на него лежит в Game.load после того, как мы инициализировали сцену. Для нашей игры будет достаточно три метода:

    Phaser.Loader.spritesheet(key, url, frameWidth, frameHeight, frameMax, margin, spacing)
    Phaser.Loader.image(key, url, overwrite)
    Phaser.Loader.audio(key, urls, autoDecode)
    

    Используя эти методы, напишем функцию, которая будет загружать в игру ресурсы:

    var loadAssets = function loadAssets() {
        Game.load.spritesheet('bird', 'img/bird.png', 48, 35);
        Game.load.spritesheet('clouds', 'img/clouds.png', 64, 34);
    
        Game.load.image('town', 'img/town.png');
        Game.load.image('pipe', 'img/pipe.png');
        Game.load.image('soundOn', 'img/soundOn.png');
        Game.load.image('soundOff', 'img/soundOff.png');
    
        Game.load.audio('flap', 'wav/flap.wav');
        Game.load.audio('hurt', 'wav/hurt.wav');
        Game.load.audio('score', 'wav/score.wav');
    };
    

    Теперь перейдем к PreloaderGameState. Создаем новый Phaser.State().

    var PreloaderGameState = new Phaser.State();
    

    Переопределяем метод preload, в котором вызываем функцию loadAssets():

    PreloaderGameState.preload = function() {
        loadAssets();
    };
    


    После успешной загрузки ресурсов, вызывается функция create, в которой мы можем добавить анимацию исчезания Loading текста и загрузку MainMenuState.
    PreloaderGameState.create = function() {
        var tween = Game.add.tween(LoadingText).to({
            alpha: 0
        }, 1000, Phaser.Easing.Linear.None, true);
    
        tween.onComplete.add(function() {
            Game.state.start('MainMenu', false, false);
        }, this);
    };
    

    Полный исходный код PreloaderGameState():
    PreloaderGameState
    var PreloaderGameState = new Phaser.State();
        PreloaderGameState.preload = function() {
            loadAssets();
        };
    
        PreloaderGameState.create = function() {
            var tween = Game.add.tween(LoadingText).to({
                alpha: 0
            }, 1000, Phaser.Easing.Linear.None, true);
    
            tween.onComplete.add(function() {
                Game.state.start('MainMenu', false, false);
            }, this);
        };
    

    В итоге


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

    Полезные ссылки


    Phaser
    Phaser (GitHub)
    Phaser (документация)
    Phaser.Game()
    Phaser.Loader()
    Phaser.State()
    Phaser.StateManager()
    Pixi.js (GitHub)

    FlappyBird
    FlappyBird (GitHub)
    UPD: В недавних фиксах я убрал полноэкранный режим, так как многие жалуются на производительность.

    Хочу услышать мнение сообщества Хабрахабр. Интересно ли вам продолжение? Во второй части рассмотрим следующее:
    • Делаем игровое меню
    • Инициализируем все игровые объекты
    • Добавляем приятных мелочей
    • Подготавливаем базу для бесшовного перехода в сам игровой процесс

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 14

      +2
      Кто-то побил уже все рекорды :)
        +4
        Это все-таки игра, которая выполняется на клиенте, без серверной части. Поэтому о какой защите может идти речь? :)
          +3
          На старте запрос к серверу где вы регистриуете начало игры, получаете токен, по завершению отпраляете токен и очки, сервер сравнивает могли ли вы за такое время набрать столько очков. Очки то вы на сервере храните.
            0
            Такой принцип можно применить к клиентской части, т.к. подключение сервера для игры just for fun не стоит того.
              0
              Я тоже думал, почему не так, однако там используется сторонний сервис для хранения очков.
                0
                Вы правы, используется Clay.io для хранения очков.
                0
                т.е. методом простого перебора можно будет таки вписать максимально дозволенное количество очков и прописать себя первым?
                  0
                  На данный момент да. Я не зря уточнил о том факте, что дается всего 2 дня на разработку :)
                  Можно сделать эпилог, где будем улучшать защиту. Пара идей на этот счет у меня уже есть.
                    0
                    так я и не вам писал ;) а к комментарию про защиту на сервере.
                      0
                      Иногда в этих ветках можно запутаться :)
            0
            Я в js новичок, не могу понять в чем суть этого:
            var loadAssets = function loadAssets() {
            
              0
              Я объявляю переменную loadAssets, которой присваиваю именованную функцию loadAssets. Помогает при ошибках смотреть в стек-трейс. Вообще, пока вы новичок, выбирайте на свой вкус :)
              Я использую эти три варианта в зависимости от ситуации:
              1. var func = function() {}
              2. var func = function func() {}
              3. function func() {}
                0
                Разве третий вариант не оптимален во всех понятиях?
                  0
                  Не всегда. Иногда попадаются ситуации, где нам нужно обращаться к функции, «как будто к переменной». В таких случаях лучше использовать var. И это скорее дело вкуса, поверьте. Разница между ними насколько минимальная, что можно использовать все три варианта, кому как.

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

            Самое читаемое