Учим PixiJS на играх

В статье описал разработку 13-ти игр на PixiJS. Около 70% текста — это описание механики игр, остальное — реализация на PixiJS. Получилось много текста, т.к. описывать советы для PixiJS интереснее с примером из игр.
Самая последняя игра будет самой сложной и интересной.

На чём рисовать?

Если мне нужно что-то нарисовать в HTMLCanvasElement у меня есть несколько опций:

  1. Использовать библиотеку или фреймворк

  2. Использовать контекст рисования напрямую 2d или webgl в виде API браузера CanvasRenderingContext2D, WebGLRenderingContext

Рисовать я хочу двухмерные объекты, изображения (текстуры) — двухмерные игры.

Вкратце описать процесс рисования можно так:

  • 2d контекст — рисует всё центральным процессором CPU

  • webgl контекст — рисует всё на видеокарте GPU, а точнее много маленьких процессоров на видеокарте распараллеливают процесс рисования

Для отрисовки двухмерного контента библиотека должна уметь использовать стандартный 2d контекст. Однако ничто не мешает рисовать двухмерный контент и на webgl. Для использования ресурсов видеокарты на полную конечно же лучше использовать webgl.

Нужно понимать, что есть вещи которые можно реализовать только на webgl, а есть которые наоборот в 2d. Например BLEND_MODES, такая вещь для «смешивания» пикселей на webgl ограничен, зато используя 2d контекст тут больше возможностей.

Я хочу рисовать двухмерный контент на webgl используя библиотеку.

Почему PixiJS?

Быстро пробежавшись по предлагаемым решениям в интернете можно увидеть следующую картину:

Если бы я хотел заниматься только играми на JavaScript, то PlayCanvas, PhaserJS или BabylonJS созданы именно для этого. Мне нужно будет писать меньше кода, не нужно будет ломать голову где взять движок для физики и т.д.

Однако более универсальные PixiJS / FabricJS / ThreeJS созданы не только для игр. Я решил использовать более универсальные инструменты на JS вначале. Для инди-игр мне хватит, а для более серьезных AAA игр мне всё равно нужно будет использовать компилируемый язык — и учить JS игровые движки без особой надобности. Из минусов, писать игры на универсальных библиотеках более затратно по времени.

Универсальные библиотеки также пригодятся для отрисовки графиков, интерактивно двухмерного и трёхмерного контента во фронтенде. А также будет хорошей строчкой в резюме.

Для более-менее долгоиграющих проектов хочется взять что-то популярное и поддерживаемое. FabricJS — умеет рисовать на сервере для NodeJS, но не умеет в webgl контекст, а для игр нужно рисовать быстро и много. ThreeJS — больше для трёхмерного контента.

В итоге я взял PixiJS как самую популярную, поддерживаемую универсальную библиотеку для отрисовки двухмерного контента на webgl.

PixiJS даже умеет рисовать в 2d контексте, но нужно использовать pixi.js-legacy — я такое делать не буду.

PixiJS введение

В 2016 году самый популярный браузер в мире Chrome перестаёт поддерживать Adobe Flash Player. В качестве замены предлагалось использовать HTML5 технологии, а именно:

  • 2d и webgl контексты для рисования

  • Web Audio API и HTMLMediaElement для звука и видео

  • WebSocket и WebRTC API для передачи данных и коммуникации в режиме реального времени.

Думаю своевременный выход PixiJS библиотеки и решение поставленных задач — помогли Flash разработчикам перейти на HTML5, а также обусловили популярность библиотеки.

Основной объект/класс в PixiJS — это DisplayObject. Но напрямую использовать я его не буду.

Я буду использовать объекты/классы унаследованные от DisplayObject:

  • спрайт Sprite для отрисовки изображений (текстур)

  • анимированный спрайт AnimatedSprite, т.е. массив из спрайтов, который меняет активный спрайт автоматически с помощью счетчика или вручную

  • отрисованную графику Graphics, т.е. линии, треугольники, квадраты, многоугольники, дуги, арки, круги и т.д.

  • текст Text

  • контейнер Container, куда всё вышеприведённое буду складывать и манипулировать (передвигать, поворачивать, масштабировать, подкрашивать/затенять, скрывать или показывать)

Container хранит дерево объектов-потомков. Соответственно для каждого объекта можно посмотреть его родителя parent, его потомков children. Добавить потомка addChild(), удалить потомка removeChild() или самоудалиться removeFromParent().

Sprite, AnimatedSprite, Graphics и Text наследуются от Container, поэтому в них тоже можно добавлять другие объекты и манипулировать ими.
С проверкой на добавление потомков, каждый потомок может иметь только одного родителя. Поэтому если вы добавляете уже добавленный объект куда-то ещё, то он самоудалиться из предыдущего родителя.

Всё это напоминает DOM-дерево, не так ли? А везде где есть дерево объектов фронтендер хочет использовать… правильно, React! Такое уже есть в виде Pixi React -, но я такое не буду использовать.

Вкратце моя игра на PixiJS состоит из следующего:

  • Сцена. Т.к. отдельного класса для сцены в PixiJS нет, то сценой можно считать любой главный контейнер, куда добавляются все остальные объекты. Есть корневой контейнер, который называется Stage.

    IScene
    import { Container, type DisplayObject } from 'pixi.js'
    
    interface IScene extends DisplayObject {
      handleUpdate: (deltaMS: number) => void
      handleResize: (options: { viewWidth: number, viewHeight: number }) => void
    }
    
    class DefaultScene extends Container implements IScene {
      handleUpdate (): void {}
      handleResize (): void {}
    }
    
  • Может быть несколько сцен. Например сцена загрузки ресурсов. Сцена главного меню. Сцена самой игры. Для манипулирования сценами использую SceneManager.

    SceneManager
    abstract class SceneManager {
        private static currentScene: IScene = new DefaultScene()
        public static async initialize (): Promise {}
        public static async changeScene (newScene: IScene): Promise {
          this.currentScene = newScene
        }
    }
    
  • Для подгрузки ресурсов использую Assets модуль (загрузчик). Который без проблем подгружает и парсит ресурсы в формате .jpg, .png, .json, .tiff/.woff2. В момент подгрузки ресурсов обычно показываю сцену загрузки. В сцене рисую индикатор загрузки в виде прямоугольника, который увеличивается по ширине. Все ресурсы можно перечислить в манифесте и потом запустить загрузчик передав ему манифест.

    передача манифеста в загрузчик
    import { Container, Assets, type ResolverManifest } from 'pixi.js'
    
    const manifest: ResolverManifest = {
      bundles: [
        {
          name: 'bundle-1',
          assets: {
            spritesheet: './spritesheet.json',
            background: './background.png',
            font: './font.woff2'
          }
        }
      ]
    }
    
    class LoaderScene extends Container implements IScene {
      async initializeLoader (): Promise {
        await Assets.init({ manifest })
        await Assets.loadBundle(manifest.bundles.map(bundle => bundle.name), this.downloadProgress)
      }
    
      private readonly downloadProgress = (progressRatio: number): void => {}
    }
    
  • Движок или ядро игры World/Game — запрашивает необходимые ресурсы у загрузчика, инициализирует экземпляр Application или использует уже готовый, добавляет объекты в сцену, подписывается на событие счетчика Ticker, подписывается на события resize, pointer..., key....

    World/Game
    import { type Application } from 'pixi.js'
    
    class World {
      public app: Application
      constructor ({ app }: { app: Application }) {
        this.app = app
        this.app.ticker.add(this.handleAppTick)
        this.container.on('pointertap', this.handleClick)
      }
    
      handleAppTick = (): void => {}
      handleClick = (): void => {}
    }
    
  • Любой компонент в игре может делать всё тоже самое, что и ядро игры, только в большем или меньшем объёме. За исключением создания экземпляра Application.

    пример StartModal компонента
    import { Container, Graphics, Text, Texture } from 'pixi.js'
    
    class StartModal extends Container {
      public background!: Graphics
      public text!: Text
      public icon!: Sprite
      constructor (texture: Texture) {
        super()
        this.setup(texture)
        this.draw()
      }
    
      setup (texture: Texture): void {
        this.background = new Graphics()
        this.addChild(this.background)
    
        this.text = new Text('Привет Habr!')
        this.addChild(this.text)
    
        this.icon = new Sprite(texture)
        this.addChild(this.icon)
      }
    
      draw (): void {
        this.background.beginFill(0xff00ff)
        this.background.drawRoundedRect(0, 0, 500, 500, 5)
        this.background.endFill()
      }
    }
    

Процесс разработки

Для разработки игр хочется использовать как можно больше инструментов из фронтенда. Разделять код на файлы и модули. Прописывать зависимости с помощью import и export. Использовать проверку синтаксиса кода и автоформатирование. Собирать все файлы сборщиком (bundler). Использовать типизацию (TypeScript). В режиме разработки автоматически пересобирать (compile) результирующий файл и перезагружать (hot-reload) страницу в браузере, когда я поменял исходный код.

TypeScript (91.4k звёзд) буду использовать повсеместно для типизации.

Webpack (61.3k звёзд) буду использовать для сборки проекта, для режима разработки Webpack Dev Server (7.6k звёзд). HTML Webpack Plugin (10.5k звёзд) для основной точки входа (начала сборки).

Проверкой синтаксиса и форматированием будет заниматься ESLint (22.7k звёзд) со стандартным конфигом для тайпскрипта eslint-config-standard-with-typescript. Форматирование будет выполнять Visual Studio Code запуская ESLint.

Для логгирования возьму Debug библиотеку (10.7k звёзд).

PixiJS буду использовать без дополнительных плагинов и шейдеров — только основная библиотека. Количество HTML элементов свожу к минимуму, любые экраны/интерфейсы в игре делаю на PixiJS. Все игры обязательно должны запускаться на мобильных устройствах Mobile‌ ‌First‌ и масштабироваться если нужно. Все исходники спрайтов в папке src-texture. Все исходники карты уровней в папке src-tiled.

Итак, вооружившись несколькими руководствами по PixiJS приступаю к разработке.

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

Игра 01: Ферма

Ферма: Описание

Ферма — игра где нужно выращивать корм для птиц и животных, а с животных и птиц получать всякие ресурсы и продавать их. И так по кругу.

Вот описание простой фермы:

  • поле фермы 8x8 клеток

  • на клетке могут располагаться сущности: пшеница, курица, корова, либо клетка может быть пустой

Свойства сущностей следующие:

  • пшеница вырастает за 10 сек, после чего можно собрать урожай, затем рост начинается заново

  • пшеницей можно покормить курицу и корову

  • если еды достаточно, то курица несёт яйца, а корова даёт молоко

  • яйца и молоко можно продать, получив прибыль

Поверхностный поиск по интернету не дал существенных результатов для примера. Фермы не так популярны для open-source игр на JS, поэтому делаю всё с нуля.

Ферма: Поиск и обработка изображений

Качественных изображений в свободном доступе очень мало. Возможно в будущем это изменится и можно будет генерировать через нейросеть.

Удалось собрать нарисованные иконки: зерно (кукуруза), яйцо, деньги (мешок с деньгами) и молоко.
Спрайт (изображение) травы на каждой клетке фермы будет самый простой.
С анимированными спрайтами (массивом изображений) пришлось сложнее, но я тоже нашёл курицу, корову и зерно.

Все спрайты обычно склеиваются в один результирующий файл. В этом есть два смысла:

  1. Браузер будет простаивать, если загружать много файлов сразу через HTTP 1.1 — будет открываться много соединений, а в браузере есть ограничение на максимальное количество открытых соединений.

  2. При загрузке текстур в память видеокарты лучше загружать всё одним изображением/текстурой.

Загрузчик в PixiJS может подгрузить и обработать текстурный атлас (Spritesheet) в формате .json. PixiJS после загрузки файла попытается загрузить изображение для атласа, путь к которому прописан в поле image. Достаточно соблюдать схему внутри json файла:

JSON текстурного атласа

{
  "frames": {
    "frame-name-00.png": {
      "frame": { "x": 0, "y": 0, "w": 100, "h": 50 },
      "rotated": false,
      "trimmed": false,
      "spriteSourceSize": { "x": 0, "y": 0, "w": 100, "h": 50 },
      "sourceSize": { "w": 100, "h": 50 },
      "pivot": { "x": 0, "y": 0 }
    }
  },
  "animations": {
    "animation-name-00": [
      "frame-name-00.png",
      "frame-name-01.png",
      "frame-name-02.png"
    ],
  },
  "meta": {
    "app": "...",
    "version": "...",
    "image": "spritesheet.png",
    "format": "RGBA8888",
    "size": {
      "w": 200,
      "h": 200
    },
    "scale": 1
  }
}

Вручную создавать .json файл вышеприведённой схемы я не буду, а воспользуюсь программой. На сайте предлагается использовать ShoeBox или TexturePacker. Т.к. я работаю в Linux, то мне остаётся использовать только TexturePacker. Однако бесплатная версия программы «портит» результирующий файл, если использовать нужные мне опции, заменяя некоторую его часть красным цветом (таким образом пытаясь стимулировать пользователей покупать программу):

Texture Packer — экспорт

Texture Packer - экспорт

Texture Packer — экспорт

Т.е. использовать программу в бесплатном режиме нет возможности, хотя мне требуется базовый функционал: собрать .json, собрать по возможности квадратный .png, добавить отступ (padding) 1 пиксель к каждому фрейму (кадру).

Поэтому я нашел другую программу Free texture packer, тоже под Linux и бесплатную.
Базового функционала достаточно, чтобы скомпоновать все изображения и сгенерировать результирующие .json и .png файлы для PixiJS.
Из минусов: не умеет работать с анимациями — для этого придётся вручную прописать массив фреймов, которые участвуют в анимации (смотри поле animations).
А также программа не умеет сохранять проект в относительном формате файлов, чтобы открывать на другом компьютере, так что имейте это ввиду, когда будете открывать мой файл проекта. К счастью вы можете открыть файл проекта в текстовом редакторе и подправить пути вручную.

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

Free Texture Packer — опция порезать на фреймы

Free Texture Packer - опция порезать на фреймы

Free Texture Packer — опция порезать на фреймы

Затем выбираем нужный нам размер фрейма и режем:

Free Texture Packer — нарезка

Free Texture Packer - нарезка

Free Texture Packer — нарезка

Добавляем все подготовленные изображения в проект, и подготавливаем результирующие файлы:

Free Texture Packer — проект

Free Texture Packer - проект

Free Texture Packer — проект

К каждому фрейму нужно добавлять 1 пиксель отступа, из-за специфики работы GPU.

Все файлы для Free Texture Packer я буду хранить в отдельной папке src-texture.

Ферма: Макет

В самом начале инициализирую экземпляр класса Application, загружаю необходимые ресурсы и запускаю движок игры World:

Application

import { Application } from 'pixi.js'

async function run (): Promise {
  const gameLoader = new GameLoader()
  await gameLoader.loadAll()
  const app = new Application({
    width: window.innerWidth,
    height: window.innerHeight,
    backgroundColor: 0xe6e7ea,
    resizeTo: window
  })
  const world = new World({ app, gameLoader })
  world.setupLayout()
}

run().catch(console.error)

После этого создаю главный макет.

Макет фермы

Макет фермы

Макет фермы

Сверху будет панель статуса StatusBar. Где буду показывать количество денег, количество собранного урожая и продуктов: зерна, яиц, молока. Иконка ресурса и рядом количество.
Посередине будет игровое поле FarmGrid — 8х8 ячеек.
Внизу будет панель покупки ShopBar: для покупки зерна, курицы или коровы.

Для унификации решил сделать универсальную ячейку, на которую можно будет нажимать — как на кнопку.

Универсальная ячейка

import { Container, Graphics } from 'pixi.js'

class Tile extends Container {

  public graphics!: Graphics
  constructor () {
    super()
    this.graphics = new Graphics()
    this.addChild(this.graphics)

    this.eventMode = 'static'
    this.cursor = 'pointer'
    this.on('mouseover', this.handleMouseOver)
    this.on('mouseout', this.handleMouseOut)
    this.on('pointertap', this.handleClick)
  }

  handleClick = (): void => {}
  handleMouseOver = (): void => {}
  handleMouseOut = (): void => {}
}

Интерактивность объекта включается свойством eventMode = 'static'.

PixiJS совет 01: События мыши и сенсорные события

Рекомендую использовать pointer... события вместо mouse... или touch.... Если вам нужно отличать одно от другого, то достаточно посмотреть на свойство pointerType:

import { type FederatedPointerEvent } from 'pixi.js'

this.on('pointerdown', (e: FederatedPointerEvent) => {
  if (e.pointerType === 'mouse') {    
    // e.pointerId
  } else if (e.pointerType === 'touch') {
    // e.pointerId
  }
})

Для событий мыши, pointerId всегда будет один и тот же. Для сенсорных событий pointerId будет уникальным для каждого указателя (обычно указателем считается палец)

В ячейку я передаю обработчик события onClick, пользовательские события не использую.

PixiJS совет 02: Пользовательские события

В PixiJS можно использовать свои названия событий.
Допустим потомок определяет событие, что на него нажали и передаёт выше уже своё пользовательское событие:

this.on('pointertap', () => {
  this.emit('custom-click', this)
})

Тогда в родителе можно будет подписаться на это событие:

this.someChild.on('custom-click', () => {})

Однако на практике для TypeScript нехватает поддержки типов, возможно в будущем это исправят.

Поэтому я использую передачу обработчика напрямую через конструктор:

class Child extends Container {
  public onClick!: () => void
  constructor(onClick) {
    this.onClick = onClick

    this.on('pointertap', () => {
      this.onClick()
    }
  }
}

При наведении мышкой в handleMouseOver, я рисую квадрат одного цвета (имитация состояния hover), при выбранном состоянии (isSelected = true) — другого (имитация состояния active).

PixiJS совет 03: Окрашивание графики и текстур

Если вам нужно поменять только цвет Graphics или Sprite — то лучше использовать окрашивание (Tinting или tint свойство).

Необязательно перерисовывать всю графику заново или подготавливать несколько разных спрайтов.
Достаточно просто понимать, что всё что вы нарисуете белым цветом 0xffffff или спрайт с белым цветом будет окрашен в цвет tint:

this.ting = 0xaaaaaa // всё белое окрасится в серый

Здесь работает техника умножения цвета. Поэтому белый умножить на tint цвет будет давать tint.

Главный макет состоящий из трёх компонентов:

Ферма — главный макет

import { type Application } from 'pixi.js'
import { ShopBar } from './ShopBar'
import { FarmGrid } from './FarmGrid'
import { ShopTile } from './ShopTile'

class World {
  public app: Application
  public statusBar!: StatusBar
  public farmGrid!: FarmGrid
  public shopBar!: ShopBar

  setupLayout (): void {
    this.statusBar = new StatusBar({})
    this.app.stage.addChild(this.statusBar)
    this.farmGrid = new FarmGrid({})
    this.app.stage.addChild(this.farmGrid)
    this.shopBar = new ShopBar({})
    this.app.stage.addChild(this.shopBar)
  }
}

Ферма: Панель статуса и магазина

Переменные, для количества денег, корма (кукурузы), яиц и молока хранит каждая ячейка (Tile) на панели статуса (лучше было-бы сделать глобальные переменные в ядре игры).

Ячейка кукурузы — хранит количество кукурузы и т.д. В каждую ячейку передаю текстуру иконки.

Status Bar Tile

import { type Texture, Sprite } from 'pixi.js'
import { type ITileOptions, Tile } from './models/Tile'

interface IStatusBarTileOptions extends ITileOptions {
  iconTextureResource: Texture
}

class StatusBarTile extends Tile {
  private _value = 0

  setup ({
    iconTextureResource
  }: IStatusBarTileOptions): void {
    const texture = new Sprite(iconTextureResource)
    this.addChild(texture)
  }
}

Внутри текстуру иконки оборачиваю в Sprite, а для текста использую BitmapText. Текст будет отображать количество value.

PixiJS совет 04: Чёткость текста

Чтобы текст был чёткий и хорошо различим необходимо выставлять ему большие значения fontSize, например 40 пикселей. Даже несмотря на то, что показывать текст вы будете как 16 пикселей в высоту.

import { Text } from 'pixi.js'

const text = new Text('Привет Habr!', {
  fontSize: 40,
})

text.height = 16

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

Shop Tile

import { BitmapText, Sprite, type Texture } from 'pixi.js'

enum ShopTileType {
  corn,
  chicken,
  cow
}

interface IShopTileOptions extends ITileOptions {
  type: ShopTileType
  cost: number
  moneyTextureResource: Texture
  itemTextureResource: Texture
}

class ShopTile extends Tile {
  setup ({
    itemTextureResource,
    moneyTextureResource,
    iconOptions: { width, height, marginLeft, marginTop }
  }: IShopTileOptions): void {
    const texture = new Sprite(itemTextureResource)
    this.addChild(texture)
    const textIcon = new Sprite(moneyTextureResource)
    this.addChild(textIcon)
    const text = new BitmapText(String(cost), {
      fontName: 'comic 30',
      fontSize: 16
    })
    this.addChild(text)
  }
}

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

PixiJS совет 05: Скорость отрисовки текста

Т.к. текст рисуется на GPU не напрямую, то он сначала рисуется например с помощью 2d контекста, а уже потом передаётся в виде текстуры на GPU. Поэтому быстро меняющийся текст лучше «пререндерить». Для этого нужно использовать BitmapText.
Сначала говорим PixiJS выделить память и отрисовать нужный шрифт, нужного размера и цвета:

import { BitmapFont } from 'pixi.js'

BitmapFont.from('comic 40', {
  fill: 0x141414,
  fontFamily: 'Comic Sans MS',
  fontSize: 40
})

Потом уже можем использовать шрифт и быстро менять его:

import { BitmapText } from 'pixi.js'

const bitmapText = new BitmapText(String(_value), {
  fontName: 'comic 40',
  fontSize: 16
})

function change() {
  bitmapText.text = Date.now()
  setTimeout(change)
}
change()

Ферма: Поле

Каждая ячейка поля может иметь несколько состояний:

  • пустое — отображается трава

  • кукуруза, корова или курица куплены

  • возможность посадить или поместить на эту ячейку кукурузу, корову или курицу

  • возможность покормить курицу или корову

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

FarmGridTile

Возможные типы поля фермы

Возможные типы поля фермы

enum FarmType {
  grass,
  possibleCorn,
  possibleChicken,
  possibleCow,
  corn,
  chicken,
  cow,
  possibleFeedChicken,
  possibleFeedCow
}

class FarmGridTile extends Tile {
  public type!: FarmType
  public cornBuildableSprite!: Sprite

  setType (type: FarmType): void {
    switch (type) {
      case FarmType.possibleCorn:
        this.hideAllSprites()
        this.cornBuildableSprite.visible = true
        break
        // ...
    }
    this.type = type
  }
}

PixiJS совет 06: Замена текстур

Если нужно менять отображаемую текстуру, совсем не обязательно для каждой текстуры создавать отдельный Sprite, можно менять свойство texture на ходу

import { Sprite } from 'pixi.js'

const sprite = new Sprite()

sprite.texture = someTexture
setTimeout(() => {
    sprite.texture = someTexture2
}, 1000)

Ферма: Покупка/продажа

Создаю глобальные состояния игры, как то покупка, простаивание и кормление:

UI State

enum UIState {
  idle,
  toBuildCorn,
  toBuildChicken,
  toBuildCow,
  toFeedCorn,
}

Нажатие на ячейке яиц или молока вверху — продаёт соответствующий ресурс.

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

Ферма — состояние игры — покормить

Ферма - состояние игры - покормить

Ферма — состояние игры — покормить

handleStatusBarClick = (tile: StatusBarTile): void => {
  if (tile.isSelected && tile.type === StatusBarTile.TYPES.corns) {
    if (tile.value >= 1) {
      this.setUIState(UIState.toFeedCorn)
    } else {
      this.statusBar.deselectAll()
    }
  }
}

Нажатие на ShopTile ячейке — переводит режим игры в возможность купить кукурузу, курицу или корову. Прохожусь по всем свободным плиткам на поле и показываю соответствующую сущность в розовом цвете.

Ферма — состояние игры — купить

handleShopBarClick = (tile: ShopTile): void => {
  this.statusBar.deselectAll()
  if (tile.isSelected) {
    if (tile.cost > 0 && this.statusBar.money >= tile.cost) {
      switch (tile.type) {
        case ShopTile.TYPES.corn:
          this.setUIState(UIState.toBuildCorn)
          break
        //...
      }
    } else {
      this.shopBar.deselectAll()
    }
  } else {
    this.setUIState(UIState.idle)
  }
}

Купить кукурузу:

Ферма - состояние игры - купить кукурузу

Ферма — состояние игры — купить кукурузу

Купить курицу:

Ферма - состояние игры - купить курицу

Ферма — состояние игры — купить курицу

Купить корову:

Ферма - состояние игры - купить корову

Ферма — состояние игры — купить корову

Если пользователь выбирает незанятую плитку, тогда списываю деньги и размещаю купленную сущность на клетке. Анимация для AnimatedSprite начинает проигрываться, у анимаций свой собственный счетчик. Однако можно менять кадры анимации и по своему усмотрению, тогда не нужно запускать анимацию play()/gotoAndPlay(0).

Ферма: счетчик и индикаторы

Теперь нужно «оживить» игру. Подписываюсь на событие счетчика и распространяю эти события дальше на поле фермы. А та в свою очередь добавляет часть сгенерированного ресурса (кукуруза, яйцо или молоко) и, если это курица или корова — то, отнимаю часть еды.
Соответственно для каждой клетки с курицей или коровой создаю переменные для хранения сгенерированного ресурса (и для кукурузы) _generated и для оставшейся еды _food.

Подписка на событие счетчика

this.app.ticker.add(this.handleAppTick)

handleAppTick = (): void => {
  this.farmGrid.handleWorldTick(this.app.ticker.deltaMS)
}

Для отображения запасов еды и сгенерированного ресурса добавляю индикаторы.

Индикаторы еды и сгенерированного ресурса

Индикаторы еды и сгенерированного ресурса

Индикаторы еды и сгенерированного ресурса

Рисую их как прямоугольники Graphics. И перерисовываю на каждый тик, хотя можно было бы просто менять ширину нарисовав от начала координат.
Для индикатора генерации выбираю один цвет: белый для яиц или синий для молока. А вот для еды, сделал интерполяцию цвета, чем больше осталось еды — тем зеленее цвет индикатора, наоборот — тем краснее.

PixiJS совет 07: Позиционирование графики и масштабирование

Когда рисуете Graphics и впоследствии собираетесь её масштабировать — всегда предпочитайте рисовать от начала координат (0, 0). Так изменение ширины width будет работать корректно.

  this.drawRect(0, 0, initWidth, initHeight)
  this.endFill()

В противном случае изменение ширины приведёт к масштабированию не только графики, но и отступа графики от начала координат.
Например изменение ширины нарисованного индикатора будет работать корректно только если вы рисовали прямоугольник из начала координат.

Когда ресурс сгенерирован, то для наглядности показываю прямоугольник определённого цвета, чтобы пользователь мог собрать ресурс. Нажатие на ячейке поля со сгенерированным ресурсом собирает его, только если игра в режиме ожидания.

Ферма: масштабирование

При масштабировании любой игры есть два варианта:

  1. Подогнать размеры игры под окно (viewport/window или camera) — Letterbox scale. Оставшееся свободное место желательно поделить пополам — т.е. отцентрировать.

  2. Обрезать игру, если она выходит за пределы окна — Responsive Scale

Есть ещё экзотический способ просто растянуть/сузить по высоте и ширине, нарушая при этом соотношение сторон — такое я не буду делать.

Для фермы я выбрал 1-й вариант. Для этого вычисляю ширину и высоту всей игры и вписываю в существующие размеры окна.

Для этого подписываюсь на событие изменения размеров окна window.addEventListener('resize', this.resizeDeBounce), однако обработчик вызываю не сразу, а с задержкой, чтобы предотвратить постоянное масштабирование когда пользователь тянет за край окна браузера. В этом случае обработчик сработает только один раз после нескольких миллисекунд.

Игра 02: Покемон

Покемон: Описание

У нас есть персонаж (человек), который ходит по карте. В определённых местах на лужайках он встречает врагов — покемонов. Однако он сражается с ними не сам, а своим покемоном против вражеского. Во время сражения показывается экран битвы, где пользователь играет за покемона. При окончании битвы пользователь возвращается на карту играя за персонажа.

В этом видео полный процесс разработки игры. Дальше будет много игр с этого канала.

Покемон: редактор карт

В видео познакомился с программой Tiled Map Editor которая тоже работает под Linux. В ней можно просто и удобно по слоям рисовать 2-х мерную тайловую карту. На выходе при экспорте в формат .json получаем удобное описание всех слоёв на карте в виде массива:

JSON — тайловой карты

{
  "layers": {
    "data": [0, 1, 0, 1],
    "name": "Layer name",
    "type": "tilelayer"
  }
}

А также при экспорте в .png формат получаем готовую отрисованную карту. Только не забудьте правильно выставить видимые слои.

Tiled Map Editor на Linux

Tiled Map Editor на Linux

Tiled Map Editor на Linux

В видео автор уже нарисовал карту, я немного её подправил из-за неработающих ссылок, остальное сразу заработало. Исходные файлы для Tiled Map Editor я буду хранить в папке src-tiled.

Автор скорее-всего ввиду упрощения предлагает просто скопировать массив данных collisions слоя из экспортируемого .json файла. Я же поисследовав .json файл написал описание типов и буду использовать полученные массивы данных для определённого слоя прямиком из .json файла.

Далее в игре подгружаю .json и .png файлы для карты (уровня). Изображение прямиком оборачиваю в Sprite.

Прохожусь по массиву данных слоя и добавляю либо прямоугольники для ограничения движения по карте, либо прямоугольники для активации экрана битвы:

Обработка слоёв карты

setupLayers ({ collisionsLayer, battleZonesLayer }: IMapScreenOptions): void {
    const { tilesPerRow } = this
    for (let i = 0; i < collisionsLayer.data.length; i += tilesPerRow) {
      const row = collisionsLayer.data.slice(i, tilesPerRow + i)
      row.forEach((symbol, j) => {
        if (symbol === 1025) {
          const boundary = new Boundary({})
          this.addChild(boundary)
        }
      })
    }

    for (let i = 0; i < battleZonesLayer.data.length; i += tilesPerRow) {
      const row = battleZonesLayer.data.slice(i, tilesPerRow + i)
      row.forEach((symbol, j) => {
        if (symbol === 1025) {
          const boundary = new Boundary({})
          this.addChild(boundary)
        }
      })
    }
  }

Используя библиотеку Debug я могу включать режим отладки. Для этого в браузере в localStorage я прописываю ключ debug (с маленькой буквы), а в значение записываю например poke-boundary:

Отладка непроходимых участков

Отладка непроходимых участков

Отладка непроходимых участков

import debug from 'debug'

const logBoundary = debug('poke-boundary')

class Boundary {
  draw() {
    if (logBoundary.enabled) {
      this.visible = true
      this.alpha = 0.3
    } else {
      this.visible = false
    }
  }
}

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

Покемон: сцены и масштабирование

В игре я рисую две сцены:

  • Одна сцена MapScreen показывается, когда игрок ходит по карте.

  • Вторая сцена BattleScreen включается, когда игрок находится в режиме битвы. Также создаю глобальное состояние, которое контроллирует текущую сцену:

Ферма — состояние для текущей сцены

enum WorldScreen {
  map,
  battle,
}

class World {
  public activeScreen!: WorldScreen
}

У каждой сцены соответственно должны быть методы активации activate и деактивации deactivate.

Масштабирование соответственно будет разное. Для сцены карты, я использую весь экран — чем больше экран, тем больше можно увидеть на карте Responsive Scale + центрирую камеру относительно персонажа. Для сцены битвы наоборот пытаюсь показать всю сцену Letterbox scale.

PixiJS совет 08: Отладка

В PixiJS нет режима отладки из коробки, его придётся рисовать вручную (можете попробовать браузерное расширение). Например после того, как нарисовали все Graphics и добавили все Sprites и AnimatedSprites добавляем еще один полупрозрачный Graphics используя ширину и высоту текущего контейнера:

import { Container, Sprite, type Texture } from 'pixi.js'

class Some extends Container {
  constructor(texture: Texture) {
    super()
    const gr = new Graphics()
    gr.beginFill(0xff00ff)
    gr.drawRoundedRect(0, 0, 500, 500, 5)
    gr.endFill()
    this.addChild(gr)

    const spr = new Sprite(texture)
    this.addChild(spr)
    
    if (debug) {
      const dgr = new Graphics()
      dgr.beginFill(0xffffff)
      dgr.drawRect(0, 0, this.width, this.height)
      dgr.endFill()
      dgr.alpha = 0.5
      this.addChild(dgr)
    }
  }
}

Переход между сценами должен быть плавный, как в оригинальном видео. Для этого пришлось использовать GreenSock Animation Platform, однако сейчас понимаю, что для таких простых анимаций не нужно было тянуть целую библиотеку.

Для переходов между сценами использую чёрный прямоугольник SplashScreen. И показываю этот прямоугольник с анимацией alpha свойства.

Покемон: сцена карты — персонаж игрока

Подготовка спрайтов аналогична: нарезать на отдельные фреймы и собрать всё в один атлас.

Для показа персонажа использую контейнер, который содержит сразу все AnimatedSprite для всех направлений движения. В зависимости от направления движения показываю только нужный спрайт, а остальные скрываю. Для этого у персонажа есть переменная direction:

Направление движения персонажа

enum PlayerDirection {
  up,
  down,
  left,
  right
}

class Player extends Container {
  private _direction!: PlayerDirection
}

Если персонаж идёт, то анимация проигрывается, а если стоит — то анимация на паузе.

Для управления клавиатурой подписываюсь на события keyup и keydown. Из события event лучше использовать code вместо key — так работает даже на русской раскладке (а вот keyCode — устарело). На каждый тик счетчика прибавляю скорость персонажу, если нажаты соответствующие кнопки. Если пользователь зажимает несколько клавиш, и потом какие-то отжимает, то я определяю какие остаются нажатыми, чтобы персонаж продолжал движение согласно нажатым клавишам.

Для реализации управления с помощью pointer событий я делю область окна на сектора, и при событии pointerdown определяю соответствующую область.

Покемон — области для управления

Покемон - области для управления

Покемон — области для управления

Если пользователь попадает в область/прямоугольник персонажа, ничего не делаю. Если попадает на линии «креста» — персонаж идёт в соответствующем направлении. Для диагональных направлений добавляю скорость в обоих направления по вертикали и горизонтали. Тут по хорошему для диагональных направлений нужно нормализовать вектор, а то получается, что по диагонали персонаж идёт быстрее чем по «кресту».

На каждый тик счетчика я двигаю персонажа в зависимости от полученных направлений движения. Проверяю также столкновения с&n

© Habrahabr.ru