Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение60 мин
Количество просмотров25K

В статье описал разработку 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 ?

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

название

контекст

количество звёзд на github

ThreeJS

webgl

92k

PixiJS

2d, webgl

40k

PhaserJS

2d, webgl

34.5k

FabricJS

2d

24.9k

BabylonJS

webgl

20.7k

PlayCanvas

webgl

8.4k

Если бы я хотел заниматься только играми на 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<void> {}
        public static async changeScene (newScene: IScene): Promise<void> {
          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<void> {
        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<HTMLCanvasElement>
      constructor ({ app }: { app: Application<HTMLCanvasElement> }) {
        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<void> {
  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<HTMLCanvasElement>
  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 определяю соответствующую область.

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

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

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

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

Покемон: звук

Для воспроизведения звука использую HowlerJS библиотеку (21.7k звёзд) как и в видео. Подгрузкой аудио файлов библиотека занимается сама, т.к. звук не критичен, то его можно подгрузить уже после начала игры. Нужно помнить, что браузеры блокируют воспроизведение звука, если пользователь никак не взаимодействовал со страницей.

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

По правилам игры, персонаж, гуляя по полянке, может активировать битву между покемонами.

Покемон - сцена битвы
Покемон - сцена битвы
Покемон - сцена битвы

В битве у пользователя есть два варианта оружия (1, 2), показатель здоровья его покемона и врага. А также всплывающий диалог, с сообщением кто, кому, нанёс повреждение и кто проиграл.

В видео автор делает сцену битвы на HTML + 2d контекст. Я же нарисую сцену битвы полностью на PixiJS. Рисование занимает конечно больше времени, чем на чистом HTML+CSS. Рисую прямоугольники, где нужно использую спрайты, и где прямоугольники являются кнопками включаю интерактивность и подписываюсь на события pointer.... В зависимости от события могу также показывать состояние кнопки hover.

Для анимации полоски жизней использую GSAP библиотеку как в видео. Шрифт я подгружаю в самом CSS файле. Нужно помнить, что если шрифт не подгрузился, то браузер отображает шрифт по умолчанию - соответственно такой же будет нарисован и в webgl. Поэтому шрифт нужно подгрузить, а затем еще и добавить в DOM, т.е. как то использовать перед использованием в 2d/webgl.

Когда покемон стреляет огненным шаром, я добавляю на сцену соответствующий анимированный спрайт и поворачиваю его по направлению к врагу.

Игра 03: Стрелялки

Стрелялки: Описание

Ваш круг (персонаж) в центре экрана. На него нападают другие круги, которые создаются за пределами экрана и двигаются на персонажа. При нажатии на любое место экрана, персонаж выстреливает туда снаряд, тем самым убивая врага. В некоторых врагов нужно выстрелить несколько раз.

Оригинальное видео.

Стрелялки: загрузка

Здесь уже я добавил простую анимацию на чистом CSS. Пока подгружается PixiJS я показываю многоточие.

Стрелялки - загрузка
Стрелялки - загрузка
Стрелялки - загрузка

Инициализацию всего кода оборачиваю в try/catch, в случае ошибки - игра не запускается, а сообщение об ошибке я вывожу прямиком в div.

Экземпляр Application я создаю внутри SceneManager как статическое свойство app:

Scene Manager
abstract class SceneManager {
  private static app: Application<HTMLCanvasElement>
  public static async initialize (): Promise<void> {
    const app = new Application<HTMLCanvasElement>({
      autoDensity: true,
      resolution: window.devicePixelRatio ?? 1,
      width: SceneManager.width,
      height: SceneManager.height,
      resizeTo: window
    })

    SceneManager.app = app
  }
}

Стрелялки: контейнеры частиц

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

  1. Нужно знать размер контейнера заранее, чтобы выделить память.

  2. Потомками могут быть только спрайты Sprite у которых одинаковая текстура Texture.

  3. Не может быть никаких вложенностей внутри Sprite.

Из минусов, неудобно итерировать по потомкам в TypeScript из-за явного приведения типов, возможно в будущем это исправят.

Соответственно для врагов я делаю один контейнер частиц enemiesContainer, для снарядов - второй projectilesContainer и для взрывов - третий particlesContainer.

Стрелялки - контейнеры частиц
this.enemiesContainer = new ParticleContainer(2000, { scale: true, position: true, tint: true })
this.addChild(this.enemiesContainer)

this.projectilesContainer = new ParticleContainer(2000, { scale: true, position: true, tint: true })
this.addChild(this.projectilesContainer)

this.particlesContainer = new ParticleContainer(2000, { scale: true, position: true, tint: true })
this.addChild(this.particlesContainer)

scale: true, position: true, tint: true } - Эти свойства контейнера показывают, что я буду окрашивать tint, передвигать position и масштабировать scale каждого потомка индивидуально.

Порядок добавления контейнеров такой, чтобы взрывы рисовались поверх снарядов и врагов. А снаряды поверх врагов.

Стрелялки: создание текстур

PixiJS может создавать текстуры Texture из графики Graphics.

Для этого нужно вызвать renderer.generateTexture и передать нарисованную графику - на выходе получим текстуру:

Создание текстур из графики
import { Sprite, Graphics, type Application, type Texture } from 'pixi.js'
interface IParticleOptions {
  app: Application
  radius: number
  vx: number
  vy: number
  fillColor: number
}

class Particle extends Sprite {
  static textureCache: Texture
  setup (options: IParticleOptions): void {
    let texture = Particle.textureCache
    if (texture == null) {
      const circle = new Graphics()
      circle.beginFill(0xffffff)
      circle.drawCircle(0, 0, this.radius)
      circle.endFill()
      circle.cacheAsBitmap = true
      texture = options.app.renderer.generateTexture(circle)
      Particle.textureCache = texture
    }
    this.texture = texture
    this.scale.set(options.radius * 2 / texture.width, options.radius * 2 / texture.height)
    this.tint = options.fillColor
  }
}

Графику (круг) я рисую белым цветом 0xffffff и с большим радиусом, чтобы потом при уменьшении не было пикселизации и можно было окрашивать в любой цвет (tint). Сгенерированную текстуру я ложу в статическое свойство класса textureCache и затем переиспользую её для каждого спрайта в контейнере частиц.

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

Стрелялки: работа с контейнерами

При касании pointertap на экране я создаю снаряд Projectile, добавляю в контейнер снарядов и направляю движение снаряда в направлении от центра.

Счетчик в игре отсчитывает количество фреймов elapsedFrames, чтобы в определённое время создавать новых врагов Enemy за пределами экрана.

При столкновении снаряда с врагом я создаю эффект взрыва при помощи дополнительных частиц. Количество созданных частиц взрыва зависит от радиуса врага.

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

PixiJS совет 09: Удаление потомков

В PixiJS нет отдельной функции для очистки всего контейнера.
Для этого придётся пройтись вручную по всем потомкам и удалить каждого:

while (this.container.children[0] != null) {
  this.container.removeChild(this.container.children[0])
}

Или обход с самоудалением:

while (this.container.children[0] != null) {
  this.container.children[0].removeFromParent()
}

Удаление некоторых потомков во время итерации, например итерация с начала:

for (let i = 0; i < this.container.children.length; i++) {
  const child = this.container.children[i]
  if (isReadyForDelete(child)) {
    child.removeFromParent()
    i--
  }
}

Итерация с конца (можно не сбрасывать индекс итерации):

for (let i = this.container.children.length - 1; i >= 0; i--) {
  const child = this.container.children[i]
  if (isReadyForDelete(child)) {
    child.removeFromParent()
  }
}

Удаление нескольких потомков начиная со 2-го индекса и заканчивая 5-м:

this.container.removeChildren(2, 5)

Стрелялки: след от снаряда

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

Для PixiJS я нашел SimpleRope слишком поздно, поэтому делал по своему.

Если присмотреться ближе, то след от снаряда можно нарисовать дополнительными кругами. Эти круги должны "запаздывать" в движении от самого снаряда, могут быть меньше самого снаряда, или уменьшаться в радиусе, а также могут быть прозрачнее чем сам снаряд.

Стрелялки - след от снаряда
Стрелялки - след от снаряда
Стрелялки - след от снаряда

Соответственно, когда я создаю снаряд, я дополнительно создаю след из кругов меньшего радиуса и прозрачности. Так что самый последний круг в хвосте (следе) будет иметь самый маленький радиус и самую большую прозрачность, а также будет отставать на самое большое расстояние от снаряда. Таким образом я задаю каждому кругу отставание dt и если расстоние до снаряда превышает заданное, то я двигаю круг уже на заданном расстоянии:

Стрелялки - вычисление отставания от снаряда
if (dx > this.minDelta) {
  this.x += this.vx > 0 ? dx * dt : -dx * dt
} else {
  this.x = this.mainX
}

if (dy > this.minDelta) {
  this.y += this.vy > 0 ? dy * dt : -dy * dt
} else {
  this.y = this.mainY
}

Удаляю след из контейнера частиц когда определил что это частица следа isProjectile === false и если главный снаряд будет удалён тоже:

Удаление следа от снаряда
if (removedProjectileIds.length > 0) {
  let startIdx = -1
  let endIdx = -1
  this.projectilesContainer.children.forEach((child, idx) => {
    const projectileTrail: ProjectileTrail = child as ProjectileTrail
    if (!projectileTrail.isProjectile && removedProjectileIds.includes(projectileTrail.mainId)) {
      if (startIdx === -1) {
        startIdx = idx
      }
      endIdx = idx
    }
  })
  if (startIdx > -1 && endIdx > -1) {
    this.projectilesContainer.removeChildren(startIdx, endIdx)
  }
}

Стрелялки: заключение

Масштабирование игры происходит в режиме Responsive Scale - тем у кого больше экран - легче играть, т.к. можно заранее увидеть противников появляющихся из-за экрана. А вот модальное диалоговое окно StartModal я центрирую посередине без масштабирования. Сам же модальный диалог я показываю когда игра закончилась, в окне я показываю набранное количество очков, а также кнопку для перезапуска игры.

Стрелялки - модальное окно
Стрелялки модальное окно
Стрелялки модальное окно

Игра 04: Марио

Марио: Описание

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

Оригинальное видео.

Марио: загрузка

В этой игре я сделал двухэтапную загрузку:

  1. Когда PixiJS еще не подгрузилась - показываю CSS анимацию многоточий - как в игре Стрелялки.

  2. Когда PixiJS подгрузилась и начала работать - тогда показываю индикатор загрузки нарисованный в LoaderScene - эта сцена в свою очередь загружает манифест.

Марио - загрузка
Марио - загрузка
Марио - загрузка

После того, как манифест будет загружен подключаю SidescrollScene и убираю LoaderScene.

Марио: управление

Персонаж как и в других играх может управляться, клавиатурой, мышью или сенсорным экраном.

Для мышки и сенсорного экрана, есть области для нажатия. Так всё что выше головы - создаёт прыжок + движение в сторону от середины персонажа. А если ниже головы, то только движение.

Марио - управление
Марио - управление
Марио - управление

Сам персонаж также содержит анимированные спрайты для направлений влево или вправо, которые я показываю в зависимости от состояния.

Когда персонаж стоит я показываю анимацию простаивания (стояния) - эту анимацию я записываю в переменную idleAnimation. Это делаю для того, чтобы возвращаться после бега в это состояние, т.к. стоять влево и стоять вправо - разные вещи.

Марио: уровень/карта

Сама карта или уровень (всего один) состоит из двух изображений фона. Оба изображения кладу в background свойство.

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

Марио - смещение уровня
Марио - смещение уровня
Марио - смещение уровня

Размеры камеры в игре совпадают с размерами экрана как и в игре Покемон.
Для смещения уровня world относительно камеры/экрана я использую свойство pivot - которое обозначает точку поворота (начало координат). Позже я понял, что можно было обойтись и обычным свойством position (или просто x/y), однако на тот момент поиск дал такой результат.

Когда игрок Player перемещается по уровню влево-вправо я перемещаю позицию (position) world тоже, если игрок вышел за пределы которые показаны на рисунке выше. Персонаж игрока я добавил в контейнер world. Когда я двигаю персонажа, то я изменяю его позицию position относительно карты world.

Таким образом при смещении world контейнера, мне нужно пересчитать позицию, чтобы понять как смещать background, т.к. свойство pivot влияет на потомков тоже.

Скорость перемещения фона background в два раза меньше скорости перемещения персонажа - таким образом получается Parallax Scrolling эффект.

Итого: смещаю персонажа player.x, смещаю саму карту world.pivot.x - если нужно и смещаю фон background.pivot.x.

Марио: платформы

Платформы по которым прыгает персонаж бывают двух типов. Размер платформ получаю прямиком из размеров текстуры/изображения. Места расположения платформ задаётся прям в коде, в этой игре не использовал редактор тайловых карт как в игре Покемон.

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

На персонажа действует сила тяжести только если он в свободном падении. А вот когда персонаж стоит на платформе - не действует.
Для определения стоит или не стоит по вертикальной оси я использую немного изменённый вариант проверки. Беру позицию персонажа, она должна быть выше верхнего края платформы, вдобавок к этому позиция персонажа плюс смещение должны заходить за верхний края платформы - только в этом случае пресонаж останавливается на текущей платформе. Это сделано для того, чтобы можно было запрыгнуть на вышестоящую платформу находясь под ней.

Марио - прыжок на платформу
Марио - прыжок на платформу
Марио - прыжок на платформу

Игра 05: Драки

Драки: описание

Есть два персонажа, одним управляет игрок №1, другим управляет игрок №2. Персонажи могут передвигаться по уровню, наносить друг другу удары. Выигрывает тот персонаж, у которого осталось больше здоровья (жизни). На всё про всё у игроков есть 90 секунд.

Оригинальное видео.

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

Драки: подгрузка шрифтов

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

Манифест - путь к файлу шрифта
export const manifest: ResolverManifest = {
  bundles: [
    {
      name: 'bundle-1',
      assets: {
        spritesheet: 'assets/spritesheets/spritesheet.json',
        background: 'assets/images/background.png',
        font: 'assets/fonts/Press_Start_2P.woff2'
      }
    }
  ]
}

После этого я обнаружил баг в Firefox, из-за неканоничного названия шрифта Press Start 2p, т.к. цифра не должна быть после пробела. Пришлось немного поменять манифест и то, как описывается шрифт и всё заработало.

Т.е. используя стандартный загрузчик PixiJS для шрифтов вам не нужно добавлять DOM элемент с таким шрифтом, чтобы шрифт работал корректно в 2d контексте - все это делает сам загрузчик. Под капотом уже используется FontFace API для подгрузки шрифтов.

Драки: спрайты персонажей и масштабирование сцены

Каждый персонаж это экземпляр класса Fighter, который наследуется от контейнера Container.

Внутри класса Fighter есть перечисление всех возможных анимаций (а по сути и состояний):

Драки - перечисление визуальных состояний бойца
enum FighterAnimation {
  idle = 'idle',
  run = 'run',
  jump = 'jump',
  fall = 'fall',
  attack = 'attack',
  death = 'death',
  takeHit = 'takeHit',
}

Переключение между анимациями аналогично, как и в предыдущих играх.

Повторив опыт автора видео, я тоже сделал коэффициент масштаба персонажей в 2.5. Это в разы усложнило расчет позиции персонажей - поэтому пришлось писать дополнительные функции для перевода масштабированных параметров в параметры сцены.

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

Драки - габариты спрайта и габариты прямоугольника для столкновений
Драки - габариты спрайта и габариты прямоугольника для столкновений
Драки - габариты спрайта и габариты прямоугольника для столкновений
  1. Прямоугольник всего спрайта

  2. Прямоугольник участвующий в расчете столкновений

Для масштабирования сцены я выбрал Letterbox scale метод. Т.е. мне нужно всю сцену поместить внутрь экрана. Чтобы вычислить ширину и высоту сцены я использую width и height текстуры фона. И далее казалось бы просто выставить ширину и высоту нашей сцене согласно вычисленным параметрам и всё готово...

PixiJS совет 10: Контейнеры - ширина и высота

Контейнеры в PixiJS легче представлять как группу объектов. Контейнера как отдельного прямоугольника не существует.

Когда вы используете высоту container.height или ширину container.width контейнера срабатывает getter. Контейнер проходится по всем своим потомкам и аккумулирует положение, длину и ширину. В результате левый край самого левого потомка и правый край самого правого потомка и будут размерами контейнера.

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

Если же вам нужен контейнер с фиксированными параметрами ширины и высоты, то вам просто нужно обрезать контейнер при помощи маски Mask.
Создаём (рисуем) маску как прямоугольник необходимой ширины grMask = Graphics. Добавляем маску и контейнер, который нужно обрезать, в одного и того-же же родителя. И выставляем свойство .mask = grMask у контейнера который нужно обрезать.

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

const grMask = new Graphics()
grMask.beginFill(0xffffff)
grMask.drawRect(0, 0, 200, 200)
grMask.endFill()

const containerToCut = new Container()
containerToCut.mask = grMask

this.addChild(grMask)
this.addChild(containerToCut)

Но работа с контейнерами оказалась не так проста.

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

Драки - спрайты за пределами контейнера
Драки - спрайты за пределами контейнера
Драки - спрайты за пределами контейнера

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

Поисследовав исходный код я понял, что можно просто выключить из расчета спрайты, которые выходят за пределы свойством visible. А после выставления необходимой ширины - опять включить. Всё происходит синхронно, так что пользователь ничего не заметит. Коэффициенты масштабирования у потомков меняются даже, если они невидимы, а вот в расчёте обшей ширины не участвуют - то что нужно!

Драки - масштабирование
  this.player1.visible = false
  this.player2.visible = false
  this.x = x
  this.width = occupiedWidth
  this.y = y
  this.height = occupiedHeight
  this.player1.visible = true
  this.player2.visible = true

Драки: момент удара

Для применения самого удара я использую определение текущего кадра атакующей анимации. Для первого игрока это 5-й кадр, для второго - 3-й. В результате использую два свойства attackHitAvailable - показывает, что атака началась, attackHitProcessed - показывает, что я обработал атаку, иначе урон может быть нанесён множество раз (зависит от скорости) - например когда 1 фрейм анимации изменится за 4-ре фрейма счетчика.

Хотелось также внести разнообразие в силу удара, поэтому я определяю площадь пересечения, т.е. соотношение площади оружия AttackBounds к площади персонажа hitBox:

Соотношение площади
const attackBounds = this.player1.toAttackBounds()
const playerBounds = this.player2.toBounds()
const intersectionSquare = Collision.checkCollision(attackBounds, playerBounds)
if (intersectionSquare >= 0.05) {
  // take damage
}
Драки - соотношение площади
Драки - соотношение площади
  1. Площадь оружия

  2. Площадь атакуемого персонажа

Игра 06: Галактика

Галактика: описание

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

Мне эта игра больше известна под названием Galaxian (Галактика) - однако прародителем скорее всего была игра Space Invaders.

Оригинальное видео.

Галактика: контейнеры частиц

Здесь я использую контейнеры частиц ParticleContainer для всех:

  • для звёзд использую контейнер, прикинув при этом, сколько частиц (звёзд) нужно отображать, чтобы было не слишком много и похоже на звёздное небо. Каждая звезда Star - это спрайт. Все звёзды используют одну и ту же текстуру, эту текстуру я рисую как Graphics в виде многоугольника, чтобы было похоже на звезду. И далее для каждой звезды есть своя позиция и цвет. Весь контейнер просто обновляет свою позицию на каждый тик счетчика - что-то вроде Parallax Scrolling.

  • для пришельцев контейнер может менять только позицию. Текстура у всех пришельцев одинаковая и не подкрашивается.

  • для снарядов и частиц (от взрывов) контейнеры могут менять позицию и цвет.

Удаление потомков контейнеров происходит при выходе за пределы экрана/камеры isOutOfViewport или при столкновении с кораблём/пришельцем.

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

Галактика: интерфейс управления

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

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

Галактика - интерфейс управления
Галактика - интерфейс управления
Галактика - интерфейс управления

Середина корабля выставляется как this.anchor.set(0.5, 0.5).

Галактика: пришельцы

Пришельцы добавляются группами по времени. Если пришло время добавить следующую группу пришельцев, я проверяю есть ли для новой группы место вверху экрана.

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

Чем шире экран - тем больше возможных столбцов. Соответственно каждый пришелец занимает определённое место в группе.

Чтобы группа пришельцев действовала как единый организм - я прохожусь по группе и высчитываю "статистику" самого верхнего/левого/правого/нижнего пришельца в группе. Имея "статистику" можно менять направление всей группы если она столкнулась с краем. Также статистика помогает определять сколько занимает вся группа. И самое главное, статистика позволяет выбрать случайного пришельца из нижнего ряда самой нижней группы и выстрелить в сторону корабля.

Галактика: частицы для взрыва

Если снаряд корабля пересекается с пришельцем я удаляю пришельца и показываю взрыв из частиц.

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

Игра 07: Пакман

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

Игрок управляет обжорой пакманом - круг который поедает шарики (гранулы). Карта ограничена стенами через которые нельзя проходить. Цель игры съесть все гранулы и не попасться двум противникам (призракам). Если съесть супер-гранулу, то на какое-то время пакман становится неуязвим и можно успеть съесть и призраков.

Оригинальное видео.

Пакман: карта

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

На карте могут быть расположены стенки разного вида Boundary (отличаются только спрайтом отображения), гранулы Pellet, супер-гранулы PowerUp.

Под картой расположена подложка background для контраста.

Два призрака Ghost создаются в определённых местах.

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

Пакман: генерация текстур

В этой игре я нарисовал текстуры пакмана с помощью PixiJS. Мне нужно было нарисовать анимированный спрайт AnimatedSprite, который состоит из круга, который открывает и закрывает рот (секция круга увеличивается и уменьшается).

Для начала я определился, что фаза открытия/закрытия рта будет состоять из 10 фреймов. Для каждого фрейма я рисую арку с определённым углом. Получившуюся графику я преобразовываю в текстуру. Все полученные текстуры складываю в массив. Для фазы закрытия, копирую в обратном порядке полученные фреймы из фазы открытия.
Полученный анимированный спрайт подкрашиваю в нужный цвет.

Пакман - текстуры пакмана
Пакман - текстуры пакмана
Пакман - текстуры пакмана

Генерация текстур для призраков аналогична, только рисую я "бантик" - т.е. круг с увеличивающимися секторами вверху и внизу.

Пакман - текстуры призрака
Пакман - текстуры призрака
Пакман - текстуры призрака

Пакман: заключение

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

Пакман - интерфейс управления
Пакман - интерфейс управления
Пакман - интерфейс управления

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

Когда пакман съел все гранулы - игра окончена. Я показываю всё то-же диалоговое окно StartModal с кнопкой для перезапуска игры.

Игра 08: Башенки

Башенки: описание

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

Для разнообразия я сделал чтобы башня попеременно стреляла то камнями то огненным шаром.

Оригинальное видео.

Башенки: слои карты

Папка src-tiled содержит проект карты для Tiled Map Editor. Тайловая карта нарисована по слоям, путь для орков прописан в виде линий в слое Waypoints. Здесь я подкорректировал предыдущие типы слоёв для TypeScript т.к. появился новый тип слоя objectgroup.

тип слоя ObjectGroupLayer
interface IPolylinePoint {
  x: number
  y: number
}

interface IObject {
  class: string
  height: number
  id: number
  name: string
  polyline: IPolylinePoint[]
  rotation: number
  visible: boolean
  width: number
  x: number
  y: number
}

interface IObjectGroupLayer {
  draworder: 'topdown'
  id: number
  name: string
  objects: IObject[]
  opacity: number
  type: 'objectgroup'
  visible: boolean
  x: number
  y: number
}

Места где можно построить башни PlacementTile обозначены в отдельном тайловом слое Placement Tiles.

Башенки - слои карты
Башенки - слои карты
Башенки - слои карты

Линия вдоль дороги - это objectgroup слой
Зеленые квадраты - это tilelayer слой

Башенки: управление картой

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

Сперва я определяю максимально возможное смещение карты относительно камеры maxXPivot и maxYPivot - это возможно в том случае, если камера меньше карты.
Зажимая левую кнопку мыши или дотронувшись до экрана - пользователь может прокручивать карту. При срабатывании pointerdown события я сохраняю начальные координаты pointerXDown и pointerYDown. Затем при срабатывании события pointermove я определяю разницу в координатах, если разница превышает 10 пикселей - то смещаю карту и сохраняю флаг mapMoved.

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

Смещение карты происходит при изменении pivot свойства.

Башенки: свойства башен

Для постройки башни необходимо иметь 75 монет. За убийство каждого орка игроку начисляется 25 монет. У StatusBar компонента есть свойство _coins которое отвечает за количество монет в игре.

Места, на которых можно построить башни рассчитываются из Placement Tiles слоя - эти места PlacementTile, которые не заняты башнями я подсвечиваю полупрозрачным прямоугольником. PlacementTile - имеет два состояния с построенной башней и без.

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

При обновлении на каждый тик счетчика я обновляю башни тоже. У каждой башни я определяю не достаёт ли она до какого-нибудь орка путём присвоения поля target. Если такой орк найден, то башня начинает стрелять. При достижении определённого кадра анимации - башня выстреливает. Башня 3 раза подряд стреляет камнями, а затем один раз огненным шаром - поворачивая при этом его к орку. Анимацию огненного шара я взял из игры Покемон.

Камни летят быстро, но строго по заданной траектории, поэтому есть шанс промахнуться. При столкновении камня с противником - я рисую каменные осколки Explosion. Осколки добавляю в контейнер explosions из которого удаляю осколки закончившие анимацию.

Огненный шар летит медленно, зато автоматически корректирует свою траекторию полёта - поэтому есть шанс не догнать орка.

Башенки: орки

Орки создаются волнами. Каждая новая волна усложняется, орков становится всё больше, скорость орков тоже разная.

Орки Enemy удаляются если у них заканчиваются жизни или они вышли за пределы карты - в последнем случае я также вычитаю одно сердечко _hearts.

Игра 09: Скроллер

Скроллер: описание

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

В отличии от предыдущих игр, эта игра взята от другого автора.

Оригинальное видео.

Скроллер: подгрузка ресурсов

В программе Free texture packer я подготовил 3 атласа:

  1. Атлас для текстур пса, врагов и частиц

  2. Атлас для фона города

  3. Атлас для фона леса

Все три атласа загружаю перед началом игры.

Скроллер: частицы

Когда пёс бежит - я добавляю пыль из под ног. Каждая пылинка это нарисованный круг.

Когда пёс крутится - я добавляю частички огня.

Когда пёс приземляется в состоянии кручения - я добавляю взрыв из огненных искр.

В момент, когда главная MainScene сцена присоединена в дереву объектов PixiJS я подготавливаю текстуры для частичек.

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

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

Скроллер: фон

Фон Background состоит из 5-ти слоёв Layer. Каждый слой наследуется от TilingSprite чтобы бесконечно показывать одну и ту же текстуру. Также для каждого слоя есть разная скорость прокрутки speedModifier в зависимости от скорости игры.

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

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

Скроллер - полосы по вертикали
Скроллер - полосы по вертикали
Скроллер - полосы по вертикали

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

В игре сделал два фона. Один стартовый фон - это город, второй фон - это лес.

После того, как прошла половина времени игры - я плавно меняю фон с города на лес.

Скроллер: пёс

По аналогии в видео, я сделал отдельный класс для каждого состояния:

Скроллер - состояния пса
import { Container } from 'pixi.js'
import { Sitting, type PlayerState, EPlayerState, Running, Jumping, Falling, Rolling, Diving, Hit } from './playerStates'

class Player extends Container {
  public states!: Record<EPlayerState, PlayerState>
  public currentState!: PlayerState

  constructor (options: IPlayerOptions) {
    super()
    this.states = {
      [EPlayerState.SITTING]: new Sitting({ game: options.game }),
      [EPlayerState.RUNNING]: new Running({ game: options.game }),
      [EPlayerState.JUMPING]: new Jumping({ game: options.game }),
      [EPlayerState.FALLING]: new Falling({ game: options.game }),
      [EPlayerState.ROLLING]: new Rolling({ game: options.game }),
      [EPlayerState.DIVING]: new Diving({ game: options.game }),
      [EPlayerState.HIT]: new Hit({ game: options.game })
    }
    this.currentState = this.states.SITTING
  }
}

Каждое состояние наследуется от родительского класса PlayerState - соответственно имеются методы enter() и handleInput().

handleInput() - обрабатывает события ввода и меняет состояние если нужно. enter() - срабатывает при входе в это состояние.

Пёс не может выйти за левый/правый край уровня, а также не может опуститься ниже уровня земли.

Скроллер - интерфейс управления указателем
Скроллер - интерфейс управления указателем
Скроллер - интерфейс управления указателем

Управление с помощью сенсорного экрана или мышки упрощено - при дотрагивании выше уровня пса я перевожу пса в состояние кручения.

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

Если присесть псом - то прокрутка карты останавливается.

Скроллер: враги

Все враги наследуются от общего класса Enemy, однако каждый враг имеет свои уникальные свойства движения. Так муха FlyingEnemy летит по синусоиде. Растение GroundEnemy стоит на месте. Паук ClimbingEnemy - опускается и подымается на паутине.

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

Мухи создаются по счетчику в игре, а вот пауки и растения создаются только при прокрутке карты this.speed > 0.

При столкновении врага с псом - я показываю анимацию дыма Boom - которую удаляю при завершении анимации.

Если пёс при столкновении находился в состоянии кручения - то он неуязвим. Я добавляю всплывающий текст FloatingMessage, который показывает, что игрок получил дополнительные очки. Текст +1 всплывает от места столкновения к панели вверху, где отображены очки в игре.

Скроллер - всплывающий текст
Скроллер - всплывающий текст
Скроллер - всплывающий текст

Всплывающий текст удаляется после 100-го фрейма.

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

Скроллер: заключение

Сверху экрана я показываю панель статуса.

Скроллер - панель статуса
Скроллер - панель статуса
Скроллер - панель статуса

На панели я показываю количество очков, время игры и количество жизней. Для текста пришлось его дублировать и рисовать белый текст scoreTextShadow и с небольшим смещением scoreText черный поверх белого. Так получилось сделать тень - для лучшей читаемости.

Игра заканчивается когда время заканчивается. Затем я сравниваю полученное количество очков и показываю либо успешное сообщение либо проигрышное.

Игра 10: Комнаты

Комнаты: описание

Игрок управляет персонажем, который передвигается по уровню. Цель игры пройти 3 уровня за определённое время. Переход на другой уровень происходит когда персонаж открыл дверь.

Оригинальное видео.

Комнаты: ключевые особенности

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

Комнаты - положения дверей и персонажа
Комнаты - положения дверей и персонажа
Комнаты - положения дверей и персонажа

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

Игра 11: Платформер

Платформер: описание

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

Оригинальное видео.

Платформер: ключевые особенности

Отличия от предыдущих игр:

Платформер - границы камеры-прямоугольника
Платформер - границы камеры-прямоугольника
Платформер - границы камеры-прямоугольника

Игра 12: Эльф и орки

Эльф и орки: описание и подготовка

Игрок управляет эльфийкой, которая ходит по карте. Эльфийка может стрелять стрелами. На карте есть враги - орки. В ближнем бою орки убивают эльфийку, а вот стрелять стрелами из лука может только эльфийка. Игра заканчивается когда все орки повержены.

В оригинале авторк(а) выпустила игру в двух вариантах:

  1. Первый вариант описан вскользь в этом видео. Исходный код во контакте. Всё в одном файле, на ES5, без редактора карт - зато игра включает в себя весь необходимый функционал и работает.

  2. Второй вариант подробно описан в серии видеоуроков. Исходный код на github. Код разбит на модули, на ES6 - но до конца не работает - орк не атакует, можно выходить за пределы карты.

В итоге я совместил модульность 2-го варианта с функционалом 1-го, по возможности сохранял оригинальные названия классов. Решил оставить графику/спрайты обоих вариантов - сделал просто два уровня. Все спрайты порезал на маленькие кадры и создал один общий атлас. Включено только то, что используется.

Карту из первого варианта нарисовал полностью в Tiled Map Editor - т.к. она была просто описана в коде. Также добавил расставление орков через редактор для обоих вариантов (уровней).

Из первого варианта взял недостающий функционал и звуки.

Эльф и орки: интерфейс

Камера не использует собственного отображения, всё, что видно на экране и есть камера.
Однако камера следит за персонажем watchObject и имеет доступ к карте TileMap, чтобы смещать положение карты pivot. Если персонаж не выходит за границы окна за вычетом scrollEdge - то камера не двигает карту.

Панель статуса отображает с помощью текста количество оставшихся орков orcsText, текущий уровень levelText и время игры timeText. Движок игры использует публичные методы updateTime/updateLevel/updateOrcs соответственно.

Эльф и орки: OOP

Орк Orc и эльфийка Player наследуются от Body. Body класс реализует состояния Stand/Walk/Attack во всех направлениях. Анимация смерти только с направлением вниз DeadDown. При переключении на новый спрайт, я включаю анимацию с первого кадра gotoAndPlay(0), если это анимированный спрайт. Состояния персонажа PlayerState наследуются от состояний BodyState т.к. нужно обрабатывать пользовательский ввод InputHandler.

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

Управление эльфийкой происходит как обычно сенсорным вводом, мышкой или клавиатурой в классе InputHandler.

Управление орками берёт на себя простой ИИ в классе AI. Вкратце суть управления - это выбор случайного направления каждые 500-200 миллисекунд.

Класс Collider - берёт на себя функционал по расчету столкновений. При изменении уровня я сделал метод, который сбрасывает все контейнеры в новое состояние.

Эльф и орки - обработка столкновений
Эльф и орки - обработка столкновений
Эльф и орки - обработка столкновений

Класс Hitbox - используется как графическое отображение непроходимых блоков на карте. Описание и позиции блоков берётся из слоя Misc.

Эльф и орки: атака орка

У орка есть дистанция прыжка. Если эльфийка приближается к орку на эту дистанцию - то орк как бы "прыгает" к эльфийке и наносит удар, который убивает персонажа - игра окончена.

Игра 13: Стратегия

Стратегия: описание

Все предыдущие игры я делал 1 месяц, столько же делал и эту последнюю игру.
В далёком 2014 году попалась мне книжка Pro HTML5 Games за авторством Адитья Рави Шанкар (Aditya Ravi Shankar). Исходный код я нашел на github.

В 2016 году я снова вернулся к исходному коду - и переписал его местами. Решил избавиться от jQuery, добавить пару новых звуков - и звуковой движок переписать на Web Audio API. С задачей я справился, но желание переписать всю игру осталось.

И вот в 2023 году я нашел время вернуться и полностью переписать всё на TypeScript + PixiJS.

Жанр игры - стратегия в реальном времени. Получилась смесь из Command & Conquer + StarCraft. Игрок играет за одну команду, компьютер (CPU) за другую. Есть три режима игры:

  1. Прохождение или кампания. В оригинале состоит из 3-х миссий, я сделал 4.

  2. Одиночный матч против компьютера (в оригинале такого нет).

  3. Сетевая игра двух игроков против друг друга.

В оригинале игра была такой: есть база Base - главное здание - обычно его потеря означает проигрыш. База может строить на любой клетке турель GroundTurret или космопорт (завод) Starport. Нужно выбрать базу и справа на панели подсвечиваются доступные кнопки строительства, если достаточно денег. При выборе здания для строительства указываем мышкой где построить, нажатие подтверждает строительство.

Стратегия - устаревший интерфейс базы
Стратегия - устаревший интерфейс базы
Стратегия - устаревший интерфейс базы

Космопорт, может строить других юнитов - легкий танк ScoutTank, тяжелый танк HeavyTank, вертолёт Chopper, харвестер (комбайн) Harvester и самолёт Wraith. Если выбрать космопорт - справа на панели подсвечиваются доступные кнопки строительства юнитов, если достаточно денег. При выборе кнопки юнита - строится юнит и телепортируется как бы из космопорта.

Харвестер может только трансформироваться в нефтяную вышку OilDerrick, а остальные юниты военные.

Стратегия - устаревший интерфейс космопорта
Стратегия - устаревший интерфейс космопорта
Стратегия - устаревший интерфейс космопорта

Поискав в интернете, я нашел улучшенный исходный код этой игры, где автор совместил строительство зданий с помощью рабочего (SCV) как в StarCraft. Т.е. база не может строить здания. База строит рабочего или харвестера. Рабочий строит космопорт или турель. Графику для рабочего я тоже взял оттуда. Этот функционал я сделал и у себя в игре.

Сейчас страница автора Шанкара по игре уже не работает.

Стратегия: подготовка текстур

Как оказалось старые текстуры нарезать и собрать вместе - сложная задача. Когда резал текстуры на фреймы, пришлось исправлять смещение, на несколько пикселей. Например первый кадр анимации 40х40 выглядел отлично, а вот последующий обрезался со смещением. Текстуры иконок я взял из Font Awesome - открывал .svg файл в Gimp редакторе и сохранял в .png.

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

Стратегия - оригинальные карты

Так выглядела карта:

Стратегия - оригинальная карта
Стратегия - оригинальная карта

А так выглядела карта с режимом отладки:

Стратегия - оригинальная карта с режимом отладки
Стратегия - оригинальная карта с режимом отладки

Для отладки по сетке мне оригинальной карты не хватало, поэтому я решил нарисовать свою сетку поверх любой карты. При включённом режиме отладки для карты localStorage.getItem('debug') === 'rts-grid' я дополнительно рисую на каждой клетке карты её границы, и координаты x и y.

Стратегия - режим отладки карты
import { Text, Graphics } from 'pixi.js'

for (let x = 0; x < this.mapGridWidth; x++) {
  for (let y = 0; y < this.mapGridHeight; y++) {
    const gr = new Graphics()
    // ...
    gr.drawRect(0, 0, this.gridSize, this.gridSize)
    const text = new Text(`x=${x}\ny=${y}`)
    text.anchor.set(0.5, 0.5)
    text.position.set(this.gridSize / 2, this.gridSize / 2)
    gr.addChild(text)
    this.addChild(gr)
  }
}

Так это выглядит:

Стратегия - режим отладки карты
Стратегия - режим отладки карты

Карта состоит из 60х40 ячеек, что в сумме 2400 ячеек для отладки. Мой ноутбук запускает режим отладки карты на 2 секунды дольше, зато потом чувствуется вся мощь PixiJS - дальше идёт без тормозов, субъективно не могу отличить от обычного режима.

Я не хотел описывать карту в коде, поэтому пришлось воссоздать тайлы из оригинальной карты.

Стратегия - воссозданные тайлы
Стратегия - воссозданные тайлы
Стратегия - воссозданные тайлы

Затем уже нарисовал оригинальную карту в Tiled Map Editor. Даже две карты, первая используется для 3-х миссий как в оригинале. Вторая карта используется в 4-й миссии, режиме против компьютера или в сетевой игре. В редакторе я уже расставил нефтяные пятна и обозначил эти места в слое Oilfields, а также стартовые позиции для баз в слое Spawn-Locations.

Стратегия - слои карты
Стратегия - слои карты
Стратегия - слои карты

Стратегия: меню игры

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

Стратегия - главное меню
Стратегия - главное меню
Стратегия - главное меню

В оригинале меню было на HTML, мне же пришлось делать всё на PixiJS согласно моих планов. Мой первый компонент интерфейса - кнопка (Button). Используется чаще всех других компонентов, поэтому поддерживает много параметров.

В основном мне нужны 3 типа кнопок:

  1. Кнопка с текстом, но без иконки

  2. Кнопка без текста но с иконкой

  3. Кнопки с текстом и иконкой

В меню у меня три текстовые кнопки и кнопка-иконка настроек вверху.

Если выбрать Campaign (кампанию или прохождение) - то я дополнительно показываю список всех доступных миссий (кнопками с текстом), чтобы можно было выбрать любую. А также показываю кнопку-иконку "домик" - для возврата.

Стратегия - список миссий
Стратегия - главное меню
Стратегия - главное меню

  • Миссия 1 - Управляя тяжелым танком доехать до верхнего левого угла и сопроводить на базу два транспорта

  • Миссия 2 - Держать оборону, пока не прилетит подкрепление из 2-х вертолётов - тогда уничтожить вражескую базу

  • Миссия 3 - Спасти оставшийся транспорт, затем уничтожить противника

  • Миссия 4 - Построить харвестер который трансформировать в нефтевышку. Построить рабочего и с помощью него построить турель. Убить вражеского легкого танка. Построить космопорт, на нём построить легкий танк и вертолёт. Уничтожить вражеский лёгкий танк и базу.

Первые 3 миссии сделал как в оригинале, а вот 4-ю сделал в виде обучения - собрал все учебные миссии из улучшенного оригинала в одну.

Компонент Button имеет 4 состояния: обычное (idle), выбранное (selected), неактивное (disabled) и наведённое (hover). Внутри я рисую отдельные скруглённые прямоугольники для фона background, для границ border - всё белым цветом и потом окрашиваю. Если передаю иконку в виде текстуры, то добавляю сначала текстуру иконки в виде спрайта, а потом текст. Также инициализирую интерактивность для кнопки и подписываюсь на события указателя.

В зависимости от текущего события выставляю нужное состояние кнопки.

PixiJS совет 11: Вывод в консоль

Иногда нужно вывести экземпляр всего Application в консоль, для этого я использую модуль Debug.

import { Application } from 'pixi.js'
import debug from 'debug'

const app = new Application({})

export const logApp = debug('rts-app')

if (logApp.enabled) {
  logApp('window.app initialized!');
  (window as unknown as any).app = app
}

В таком случае я указываю в localStorage ключ debug со значением rts-app (к примеру) и после следующей загрузки страницы могу исследовать экземпляр.

Стратегия - вывод в консоль
Стратегия - вывод в консоль

Для лучшего понимания можно всегда использовать собственные классы унаследованные от стандартных:

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

class BorderRect extends Graphics {}
class BorderContainer extends Container {}

const borderRect = new BorderRect()
const borderContainer = new BorderContainer()

this.addChild(borderRect)
this.addChild(borderContainer)
Стратегия - улучшенный вывод в консоль
Стратегия - улучшенный вывод в консоль

Стратегия: звук

Чтобы было веселее, я взял старую добрую озвучку из неофициальной озвучки Фаргус из игры StarCraft.

Все звуки разделил на 4 категории:

  1. Голоса (voiceVolume) - когда юниты получили приказ на движение, строительство или атаку.

  2. Выстрелы (shootVolume) - когда юнит стреляет снарядом (Cannon), пулей (Bullet), ракетой (Rocket), лазером (Laser).

  3. Попадания (hitVolume) - когда снаряд/пуля/ракета/лазер попали в цель.

  4. Сообщения (messageVolume) - системные сообщения которые появляются на панели сообщений.

Весь звук конвертировал в .mp3 формат, который после завершения всех патентов отлично работает во всех браузерах. Звуки для 2-й и 3-й категории нашел на сайте freesound.org, за исключением тех, которые уже были в оригинальной игре.

Для воспроизведения использую всё ту же библиотеку HowlerJS - которая позволяет гибко подстраивать уровень громкости.

При инициализации класса Audio - я пытаюсь прочитать из localStorage - предыдущие пользовательские настройки звука, если таковые имеются.

Категория голоса подразделяется на разных персонажей + разные значения.

Значений может быть 3:

  • "двигаюсь/иду"

  • "подтверждаю/атакую/делаю"

  • "ошибка

К примеру если указать рабочему построить здание я проигрываю звук "рабочий" + "подтверждаю/атакую/делаю". В дополнение к этому я останавливаю все предыдущие звуки рабочего. Таким образом не происходит переполнения воспроизводимых голосов, если пользователь слишком быстро меняет приказ для одного и того же юнита.

Стратегия: настройка звука

Для настройки звука реализовал модальное окно SettingsModal. Его экземпляр я создаю единожды в MenuScene - а затем передаю уже в другие сцены. Тем самым другие сцены добавляют в качестве потомка этот же экземпляр (при этом я убираю его из потомков меню сцены).

При нажатии на кнопку настройки - я показываю модальное окно с настройками звука.

Стратегия - окно настройки звука
Стратегия - окно настройки звука
Стратегия - окно настройки звука

Для громкости реализовал новый компонент интерфейса - ползунок Slider (<input type="range" />). Каретка (Caret) нарисована двумя кругами, полоса нарисована двумя скруглёнными прямоугольниками.

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

Если пользователь подтвердил (Apply) выбранные настройки звука - я сохраняю настройки в localStorage, чтобы при следующей загрузке страницы восстановить.

Стратегия: панель сообщений

В оригинале перед началом миссии показывалась сцена брифинга - где отображалось название миссии и краткое описание.

Стратегия - сцена брифинга в оригинале
Стратегия - сцена брифинга в оригинале
Стратегия - сцена брифинга в оригинале

Я решил не делать эту сцену, а сделать панель сообщений StatusBar. Так при начале какой-то миссии я пишу название миссии и краткое описание в виде сообщения на панели. И далее любое сообщение может быть записано в эту панель. Как и в оригинале отправителем сообщения может быть разный портрет персонажа (1 из 4-х). Текстуры портретов отправителей я подгружаю в самом конце, т.к. это самые необязательные текстуры для игры.

Стратегия - панель сообщений
Стратегия - панель сообщений
Стратегия - панель сообщений

Любое сообщение состоит из портрета отправителя (1), текста сообщения (2) и текста времени в игре (3).

Т.к. сообщений может быть много, то панель должна прокручивать сообщения, чтобы пользователь мог прочитать сообщения, которые пропустил. Здесь встаёт всё та же задача с контейнерами - сделать контейнер фиксированных размеров, чтобы его потомки не отображались за пределами контейнера.

Реализовал я этот функционал через маску mask. Рисую маску как прямоугольник необходимых размеров, затем контейнеру, который нужно обрезать присваиваю эту маску container.mask. Прокрутку я реализовал с помощью свойства pivot.y - сначала определяю максимально возможное смещение - затем включаю интерактивность для контейнера this.eventMode = 'static' и подписываюсь на события pointer... для сенсорных устройств и wheel для мыши.

Если я добавляю сообщения программно - то я сделал плавную прокрутку до последнего сообщения.

Т.к. я следую Mobile‌ ‌First‌ подходу, то нужно было продумать как панель сообщений будет отображаться на маленьких экранах. Просто уменьшать текст - не выход, т.к. ничего невозможно будет прочитать. В результате я перерисовываю текст сообщений так, чтобы текст переносился на другую строку. Когда я рисую сообщение, я передаю максимально возможную ширину wordWrapWidth текста. Если происходит масштабирование, то я достаю все сообщения из контейнера и перерисовываю их с другой заданной шириной текста.

Стратегия: карта и мини-карта

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

Также для всех есть параметр радиус обзора (sightRadius) - который используется для расчета области видимости.

Стратегия - мини-карта
Стратегия - мини-карта
Стратегия - мини-карта

В верху справа возле панели сообщений я показываю мини-карту MiniMap. Для мини-карты я рисую карту уровня и всех юнитов и зданий. Карта уровня нарисована двумя повторяющимися спрайтами background и background2, обоим спрайтам я выставляю одинаковую текстуру карты уровня. background спрайт используется как тёмная область карты, куда не достаёт обзор юнитов/зданий из команды. background2 спрайт использую для отображения юнитов и зданий в области видимости команды. Для этого я рисую всех юнитов и зданий на карте, затем в отдельной графике рисую круг обзора для каждого здания и юнита. В конце устанавливаю маску mask для background2 спрайта - тем самым отображая только области видимости.

Юнитов я рисую как круги, здания - как прямоугольники, и раскрашиваю цветом команды team - синий или зеленый.

Прямоугольник желтого цвета обозначает позицию и размеры камеры.

Стратегия: панель приказов

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

Стратегия - панель приказов
Стратегия - панель приказов слева
Стратегия - панель приказов слева
Стратегия - панель приказов справа
Стратегия - панель приказов справа

У каждого юнита или здания могут быть свои специфические приказы. Описание возможных приказов для каждой сущности я сделал в виде массива commands: ECommandName[]. При выборе юнита или здания я рисую для каждого приказа кнопку с приказом. Самая первая кнопка всегда будет "убрать выделение". На стареньком iPhone 6s у меня помещается 5 кнопок в высоту - поэтому я всегда старался не выходить за этот лимит. Например я совместил кнопки приказа для движения куда-то, или движения за кем-то - если выбрать этот приказ и показать на сущность, то это будет приказ следовать за ней, если на местность - то идти.

Стратегия: масштабирование

Собрав все элементы интерфейса вместе я настроил масштабирование. Так верхняя панель TopBar состоит из панели сообщений StatusBar и мини-карты MiniMap. Слева или справа показываю боковую панель SideBar, которая состоит из статической панели и панели приказов CommandsBar. При масштабировании я передаю ширину экрана в TopBar - которая в свою очередь смещает положение мини карты (перерисовываю размеры камеры) и масштабирует панель сообщений. Для боковой панели масштабирование заключается в перерасчёте положения панели.

Стратегия - расположение панелей
Стратегия - расположение панелей
Стратегия - расположение панелей

1 - панель сообщений, максимальная ширина 500 пикселей, если есть еще место, оно делится равномерно по краям
2 - мини-карта, всегда фиксированного размера
3,4 - статическая панель и панель приказов - не масштабируются

Следует отличать масштабирование всей игры и масштабирование карты. По сути, если бы было 3-е измерение мы меняли бы z координату для увеличения или уменьшения карты (отдаление или приближение камеры). В игре я сделал увеличение карты двумя способами, с помощью мышки по событию wheel и с помощью сенсорного ввода (двумя пальцами - pinch zoom).

Стратегия - масштабирование карты пальцами
Стратегия - масштабирование карты пальцами
Стратегия - масштабирование карты пальцами

Два красных круга - это по сути два пальца (указателя).

В масштабировании карты всегда будет точка, относительно которой происходит масштабирование, если делать без этой точки, то вся карта увеличится/уменьшиться и сместиться от пользователя и покажет не то, что ожидалось. Алгоритм такой, что после изменения масштаба нужно вернуть камеру в положение, где неподвижная точка будет на том же месте. Так для события мыши wheel неподвижную точку легко установить - это и есть точка заданная параметрами x и y. Для сенсорных устройств нужно два пальца (указателя pointer), если я определяю, что два указателя активны и расстояние между ними меняется, то я нахожу неподвижную точку - как точку посередине линии, соединяющей два указателя.

Сам же коэффициент увеличения/уменьшения для события мыши будет постоянный, а вот для сенсорного ввода я определяю его как соотношение старого расстояния к новому расстоянию между пальцами.

PixiJS совет 12: Преобразование координат

Очень часто можно встретить ситуацию когда вам нужно преобразовать координаты из одного контейнера в другой. Сложность добавляет то, что контейнеры могут быть в разных места дерева объектов PixiJS, могут иметь разные масштабы.
Для преобразования нужно использовать toGlobal и toLocal функции - глобальное положение точки будет как промежуточный результат. Алгоритм такой, берем точку внутри контейнера, переводим её в глобальные координаты, затем глобальные координаты переводим в локальные уже другого контейнера.

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

const container1 = new Container()
container1.scale.set(0.5, 0.8)
const rect1 = new Graphics()
rect1.position.set(14, 18)
rect1.beginFill(0xff00ff)
rect1.drawRoundedRect(0, 0, 500, 500, 5)
rect1.endFill()
container1.addChild(rect1)
const container2 = new Container()

const local1Rect1Center = { x: rect1.x + rect1.width / 2, y: rect1.y + rect1.height / 2 }
const globalRect1Center = container1.toGlobal(local1Rect1Center)
const local2Rect1Center = container2.tolocal(globalRect1Center)

Стратегия: выбор и перезарядка

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

В игре я рисую дополнительную окружность вокруг юнита, если он выбран, и прямоугольник - около здания. Описание графики, которая рисуется при выборе, находится в поле drawSelectionOptions.

Стратегия - отображение выбора
Стратегия - отображение выбора здания
Стратегия - отображение выбора здания
Стратегия - отображение выбора юнита
Стратегия - отображение выбора юнита

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

Стратегия - выбор воздушных юнитов

Так было в оригинале:

Стратегия - выбор воздушных юнитов в оригинале
Стратегия - выбор воздушных юнитов в оригинале

Так сделал я:

Стратегия - выбор воздушных юнитов у меня
Стратегия - выбор воздушных юнитов у меня

Для каждой сущности я рисую полосу жизней, описание графики которой находится в drawLifeBarOptions.

Если юнит или здание может атаковать, то ниже полоски жизней я рисую полоску перезарядки оружия, описание графики находится в drawReloadBarOptions.

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

Стратегия - рамка для выделения
Стратегия - рамка для выделения
Стратегия - рамка для выделения

Т.к. событийная система в PixiJS просто имитирует DOM события, то нужно понимать, что вы не можете заблокировать событие от распространения. Например в DOM заблокировав mousedown событие путём e.preventDefault() я знаю, что click не произойдёт. Отсюда если нужно навесить много логики на один и тот же указатель (pointer) - нужно пользоваться pointerup, pointermove и pointerdown, а вот использовать при этом ещё и pointertap - нет смысла.

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

  • так если дотронуться pointerdown + pointerup до какого-то юнита - он выберется

  • так если два раза быстро дотронуться (pointerdown + pointerup)x2 на каком-то юните - выберутся все юниты данного типа в пределах видимости

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

Стратегия: приказы для юнитов

У всех юнитов в игре есть свойство _order - которое хранит текущий приказ который выполняется.

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

Линию от центра юнита к месту назначения я рисую прерывистую, из коробки такого решения нет в PixiJS, поэтому пришлось рисовать много маленьких линий вручную.

Стратегия - отображение приказа
Стратегия - отображение приказа для строительства
Стратегия - отображение приказа для строительства
Стратегия - отображение приказа для атаки
Стратегия - отображение приказа для атаки

По умолчанию любой юнит имеет приказ "стоять" (stand), для атакующих юнитов я в каждом тике проверяю не появился ли кто-нибудь в поле зрения, кого можно атаковать. И далее переключаю приказ на атаку attack вражеского юнита. Однако, если юнит видит недалеко, а его атакуют, то я всё равно выдаю атакующему приказ ответить.

Когда я сделал так, чтобы наземные юниты могли атаковать воздушных canAttackAir = true, понял, что алгоритм поиска пути не может рассчитать путь по которому можно подъехать к воздушному юниту, если тот "завис" над препятствием. После поиска в интернете я понял, что мне нужно подъезжать юнитом на соседнюю клетку. Однако выбор всех возможных вариантов, как подъехать и достать до противника может занять много времени, поэтому я решил просто взять все клетки в пределах 1-2 клеток вокруг воздушного юнита и выбрать случайную (за один тик) - если алгоритм поиска пути покажет, что на эту клетку можно подъехать - я подъезжаю.

Для передвижения юнитов можно использовать как обычное движение move, когда юнит игнорирует всех врагов, пока не подъедет к клетке назначения. Или можно использовать движение move-and-attack, когда юнит отвлечётся на атаку по пути.

Стратегия: туман войны

Туман войны - скрывать всё, что не попадает в радиус обзора. Как реализовать простой туман войны на PixiJS я придумал не сразу. Тем более, что я договорился не использовать плагины. Для оригинальной версии или для улучшенной версии использовался контекст рисования 2d, поэтому пришлось думать самому.

Сначала я подумал, что можно просто наложить сверху карты чёрный прямоугольник и наделать в нём отверстий с помощью beginHole/endHole

Стратегия - рисование отверстий
Стратегия - рисование отверстий
Стратегия - рисование отверстий

Здесь видно, что соединение отверстий работает слишком непредсказуемо. Поле видимости самолёта вообще не отображается.

Однако такой подход быстро вскрыл недостатки PixiJS, если отверстия накладываются друг на друга - об этом даже есть предупреждение, отверстия не должны накладываться друг на друга.

Поэтому маску обзора я рисую как круг для каждого юнита или здания в одном объекте Graphics.

В конечном счёте мне нужно три слоя карты:

  1. Слой рисуется размытый используя маску обзора (обзор увеличен на 1.3). Для размытия я использую стандартный BlurFilter.

  2. Слой рисуется полностью, но с полупрозрачным затенением

  3. Слой рисуется используя маску обзора

Стратегия - слои карты
Стратегия - 1-й слой карты
Стратегия - 1-й слой карты
Стратегия - 2-й слой карты
Стратегия - 2-й слой карты
Стратегия - 3-й слой карты
Стратегия - 3-й слой карты

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

Все юниты и здания которые, не попадают в поле зрения я не отображаю, используя при этом renderable = false свойство. Это же свойство используется и для отображения сущностей на мини-карте. По сути это простая логика реализации Culling алгоритма, чтобы не заставлять видеокарту рисовать то, что и так не будет видно.

Стратегия: строительство и производство

Для строительства юнитов на базе Base или заводе Starport я делаю следующее: проверяю есть ли место, чтобы юнит поместить возле здания, или на здании. Дальше я запускаю анимацию строительства, и когда анимация подошла к ключевому кадру, я проверяю еще раз место и количество денег и наконец добавляю юнит с анимацией телепортации - по сути круг поверх юнита, который становится прозрачным.

Для строительства зданий пришлось переработать функционал. Сама сетка для строительства осталась прежней как и в оригинале, это двухмерный массив, где 0 и 1 показатели, можно ли строить на данной клетке или нельзя. В Mobile‌ ‌First‌ подходе, я не могу показывать место для строительства при движении мыши по экрану. Я сделал по другому, при выборе объекта, который нужно построить я рисую сетку поверх карты. У сетки зелёные квадраты показывают куда можно поставить здание, красные - куда нельзя.

Стратегия - сетка для строительства

Сетка для строительства:

Стратегия - сетка для строительства
Стратегия - сетка для строительства

Сетка для установки нефтевышки:

Стратегия - сетка для нефтевышки
Стратегия - сетка для нефтевышки

Сетка для строительства должна обновляться на каждое движение всех юнитов rebuildBuildableGrid(), чтобы нельзя было выбрать строительство на месте, где стоят юниты. Если рисовать сетку как один общий Graphics (помните про 2400 клеток), добавляя к нему drawRect и менять у него цвет рисования beginFill - то это работает, но мой старенький iPhone 6s, который используется только для игр - не вытягивает, и после отображения сетки Safari зависает. Поэтому пройдясь по предложенным решениям я остановился на том, что рисую квадрат единожды, сохраняю его в текстуру, и затем переиспользую эту текстуру для всех клеток карты. Инициализацию всех клеток контейнера hitboxes я тоже делаю единожды - в этом случае разницы между Container и ParticleContainer не заметил. А при каждом тике, я прохожусь по всем потомкам сетки - и подкрашиваю текстуру в нужный цвет, те же клетки, которые за пределами камеры я вообще не отображаю (renderable = false).

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

Стратегия: оптимизация

Почему-то при количестве юнитов около 20, игра начинала заметно тормозить даже на моём ноутбуке. Поэтому я пошел смотреть в испектор, что же там твориться. И проанализировав несколько кадров, я понял, что алгоритм поиска пути слишком затратный. Один фрейм иногда длился около 40 миллисекунд.

Стратегия - поиск тормозов в инспекторе
Стратегия - поиск тормозов в инспекторе
Стратегия - поиск тормозов в инспекторе

Этот алгоритм я просто переписал с JavaScript на TypeScript. Для того, чтобы в нём разобраться потребуется много времени, поэтому я нашел подходящее решение в виде easystarjs библиотеки и заменил на неё.

Вторым что, вызвало подозрение, это слишком долгий вызов checkCollisionObjects. Поисследовав более детально, я понял, что обращаться к свойствам width и height графики Graphics - довольно дорогостоящая операция. Но т.к. у меня графика не меняется я переписал эти обращения к переменным, которые хранят ширину и высоту.

В итоге выполнение каждого кадра при том же количестве юнитов сократилось с 40 до 5 миллисекунд.

Стратегия - результат оптимизации
Стратегия - результат оптимизации
Стратегия - результат оптимизации

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

Стратегия: сетевая игра

При выборе режима Multiplayer - я отображаю экран сетевой игры. Первое, что нужно было сделать, это поле ввода для адреса сервера, чтобы подключаться к нему по веб-сокету. Без использования HTML - это слишком затратно, поэтому я выбрал компромиссный вариант - само поле я рисую на PixiJS, а при "фокусе" на это поле - я добавляю в DOM дерево текстовое поле с такими же стилями, и выставляю по нему фокус. Так появился новый компонент интерфейса - Input.

Стратегия - начало сетевой игры
Стратегия - начало сетевой игры
Стратегия - начало сетевой игры

Сетевой код я тоже переписал на TypeScript. Код не совершенен, и часто происходят рассинхронизации в состояниях между клиентами. Скорее всего нужно реализовывать движок игры отдельно от графической составляющей - в таком случае можно будет запустить движок на сервере и клиенты будут слать/получать только изменения.

Стратегия: заключение

Игру можно ещё улучшать и оптимизировать до бесконечности.

Баланс - здесь я старался подкорректировать каждого юнита, чтобы он был необходим. Лёгкий танк атакует по воздуху и земле, стреляет быстро, видит мало, ездит быстро. Тяжелый танк атакует только по земле, долго перезаряжается, крепкий. Вертолёт далеко видит и стреляет, но атакует только наземные цели. Самолёт атакует всех, но близко и дорого стоит. И т.д.

Играть против простого ИИ очень легко, т.к. ИИ в моём случае это просто набор скриптов, которые выполняются один за другим. Сначала я собираю статистику по всем ресурсам, и потом выполняю требуемую логику, как например построить еще завод или турель, послать в атаку и т.д. Вызов ИИ происходит один раз в 100 фреймов. ИИ сейчас также привязан жестко к карте, а хотелось бы, чтобы он "ориентировался" на любой карте.

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

Не хватает панели выделения, т.е. показать пользователю сколько всего юнитов выбрано.

Интерфейс хотелось бы настраивать. Т.к. панель сообщений или карта сейчас только вверху, а хотелось бы расположить панели в любой стороне экрана.

У Шанкара есть недоделанная игра Command & Conquer - HTML5 - которая по сути похожа на его игру Last Colony, а соответственно может легко быть переписана аналогично моей.

Описанные советы для PixiJS можно посмотреть в видео PixiJS - 12 советов для новичков

Исходный код всех игр:
Стратегия
Эльф и орки
Платформер
Комнаты
Скроллер
Башенки
Пакман
Галактика
Драки
Марио
Стрелялки
Покемон
Ферма

Интерактивый список всех игр

Теги:
Хабы:
Всего голосов 21: ↑20 и ↓1+23
Комментарии6

Публикации

Истории

Работа

Ближайшие события