Разрабатываем Flappy Bird на Phaser (Часть I)

в 10:45, , рубрики: Flappy Bird, game development, Gamedev, javascript, phaser, метки: , , ,

Разрабатываем Flappy Bird на Phaser (Часть I)
Картинка для привлечения внимания

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

Где-то месяц назад (на момент написания этого поста) я задался целью создать свой клон игры 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 = "TOUCHnTOnFLY", //Инструкция в главном меню
    DEVELOPER_TEXT = "DevelopernEugene Obrezkovnghaiklor@gmail.com", //Куда ж без копирайтов :)
    GRAPHIC_TEXT = "GraphicnDmitry Lezhenkondima.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: В недавних фиксах я убрал полноэкранный режим, так как многие жалуются на производительность.

Хочу услышать мнение сообщества Хабрахабр. Интересно ли вам продолжение? Во второй части рассмотрим следующее:

  • Делаем игровое меню
  • Инициализируем все игровые объекты
  • Добавляем приятных мелочей
  • Подготавливаем базу для бесшовного перехода в сам игровой процесс

Оценочный план на будущие части.
Часть 2 (Меню)
Часть 3 (Игровой процесс)
Часть 4 (Таблица рекордов)

Автор: ghaiklor

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js