Учим PixiJS на играх
- среда, 14 июня 2023 г. в 00:00:19
В статье описал разработку 13-ти игр на PixiJS
. Около 70% текста - это описание механики игр, остальное - реализация на PixiJS
. Получилось много текста, т.к. описывать советы для PixiJS
интереснее с примером из игр.
Самая последняя игра будет самой сложной и интересной.
Если мне нужно что-то нарисовать в HTMLCanvasElement
у меня есть несколько опций:
Использовать библиотеку или фреймворк.
Использовать контекст рисования напрямую 2d
или webgl
в виде API браузера CanvasRenderingContext2D
, WebGLRenderingContext
.
Рисовать я хочу двухмерные объекты, изображения (текстуры) - двухмерные игры.
Вкратце описать процесс рисования можно так:
2d
контекст - рисует всё центральным процессором CPU
.
webgl
контекст - рисует всё на видеокарте GPU
, а точнее много маленьких процессоров на видеокарте распараллеливают процесс рисования.
Для отрисовки двухмерного контента библиотека должна уметь использовать стандартный 2d
контекст. Однако ничто не мешает рисовать двухмерный контент и на webgl
. Для использования ресурсов видеокарты на полную конечно же лучше использовать webgl
.
Нужно понимать, что есть вещи которые можно реализовать только на webgl
, а есть которые наоборот в 2d
. Например BLEND_MODES, такая вещь для "смешивания" пикселей на webgl
ограничен, зато используя 2d
контекст тут больше возможностей.
Я хочу рисовать двухмерный контент на webgl
используя библиотеку.
Быстро пробежавшись по предлагаемым решениям в интернете можно увидеть следующую картину:
Если бы я хотел заниматься только играми на JavaScript
, то PlayCanvas
, PhaserJS
или BabylonJS
созданы именно для этого. Мне нужно будет писать меньше кода, не нужно будет ломать голову где взять движок для физики и т.д.
Однако более универсальные PixiJS
/ FabricJS
/ ThreeJS
созданы не только для игр. Я решил использовать более универсальные инструменты на JS
вначале. Для инди-игр мне хватит, а для более серьезных AAA
игр мне всё равно нужно будет использовать компилируемый язык - и учить JS
игровые движки без особой надобности. Из минусов, писать игры на универсальных библиотеках более затратно по времени.
Универсальные библиотеки также пригодятся для отрисовки графиков, интерактивно двухмерного и трёхмерного контента во фронтенде. А также будет хорошей строчкой в резюме.
Для более-менее долгоиграющих проектов хочется взять что-то популярное и поддерживаемое. FabricJS
- умеет рисовать на сервере для NodeJS
, но не умеет в webgl
контекст, а для игр нужно рисовать быстро и много. ThreeJS
- больше для трёхмерного контента.
В итоге я взял PixiJS
как самую популярную, поддерживаемую универсальную библиотеку для отрисовки двухмерного контента на webgl
.
PixiJS
даже умеет рисовать в 2d
контексте, но нужно использовать pixi.js-legacy - я такое делать не буду.
В 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.
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
.
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...
.
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
.
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
приступаю к разработке.
Примечание: исходный код содержит практики, которые можно было бы сделать лучше исходя из полученного опыта, однако я оставляю всё как есть. Постараюсь описать что можно сделать по-другому в статье.
Ферма - игра где нужно выращивать корм для птиц и животных, а с животных и птиц получать всякие ресурсы и продавать их. И так по кругу.
Вот описание простой фермы:
поле фермы 8x8
клеток;
на клетке могут располагаться сущности: пшеница, курица, корова, либо клетка может быть пустой.
Свойства сущностей следующие:
пшеница вырастает за 10
сек, после чего можно собрать урожай, затем рост начинается заново;
пшеницей можно покормить курицу и корову;
если еды достаточно, то курица несёт яйца, а корова даёт молоко;
яйца и молоко можно продать, получив прибыль.
Поверхностный поиск по интернету не дал существенных результатов для примера. Фермы не так популярны для open-source игр на JS
, поэтому делаю всё с нуля.
Качественных изображений в свободном доступе очень мало. Возможно в будущем это изменится и можно будет генерировать через нейросеть.
Удалось собрать нарисованные иконки: зерно (кукуруза), яйцо, деньги (мешок с деньгами) и молоко.
Спрайт (изображение) травы на каждой клетке фермы будет самый простой.
С анимированными спрайтами (массивом изображений) пришлось сложнее, но я тоже нашёл курицу, корову и зерно.
Все спрайты обычно склеиваются в один результирующий файл. В этом есть два смысла:
Браузер будет простаивать, если загружать много файлов сразу через HTTP 1.1 - будет открываться много соединений, а в браузере есть ограничение на максимальное количество открытых соединений.
При загрузке текстур в память видеокарты лучше загружать всё одним изображением/текстурой.
Загрузчик в PixiJS
может подгрузить и обработать текстурный атлас (Spritesheet
) в формате .json
. PixiJS
после загрузки файла попытается загрузить изображение для атласа, путь к которому прописан в поле image
. Достаточно соблюдать схему внутри 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
. Однако бесплатная версия программы "портит" результирующий файл, если использовать нужные мне опции, заменяя некоторую его часть красным цветом (таким образом пытаясь стимулировать пользователей покупать программу):
Т.е. использовать программу в бесплатном режиме нет возможности, хотя мне требуется базовый функционал: собрать .json
, собрать по возможности квадратный .png
, добавить отступ (padding
) 1 пиксель к каждому фрейму (кадру).
Поэтому я нашел другую программу Free texture packer, тоже под Linux
и бесплатную.
Базового функционала достаточно, чтобы скомпоновать все изображения и сгенерировать результирующие .json
и .png
файлы для PixiJS
.
Из минусов: не умеет работать с анимациями - для этого придётся вручную прописать массив фреймов, которые участвуют в анимации (смотри поле animations
).
А также программа не умеет сохранять проект в относительном формате файлов, чтобы открывать на другом компьютере, так что имейте это ввиду, когда будете открывать мой файл проекта. К счастью вы можете открыть файл проекта в текстовом редакторе и подправить пути вручную.
Все изображения, которые содержат фреймы для анимации нужно порезать на отдельные изображения, для этого есть опция:
Затем выбираем нужный нам размер фрейма и режем:
Добавляем все подготовленные изображения в проект, и подготавливаем результирующие файлы:
К каждому фрейму нужно добавлять 1 пиксель отступа, из-за специфики работы GPU.
Все файлы для Free Texture Packer
я буду хранить в отдельной папке src-texture
.
В самом начале инициализирую экземпляр класса Application
, загружаю необходимые ресурсы и запускаю движок игры World
:
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'
.
Рекомендую использовать 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
можно использовать свои названия событий.
Допустим потомок определяет событие, что на него нажали и передаёт выше уже своё пользовательское событие:
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
).
Если вам нужно поменять только цвет 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
) на панели статуса (лучше было-бы сделать глобальные переменные в ядре игры).
Ячейка кукурузы - хранит количество кукурузы и т.д. В каждую ячейку передаю текстуру иконки.
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
.
Чтобы текст был чёткий и хорошо различим необходимо выставлять ему большие значения fontSize
, например 40
пикселей. Даже несмотря на то, что показывать текст вы будете как 16
пикселей в высоту.
import { Text } from 'pixi.js'
const text = new Text('Привет Habr!', {
fontSize: 40,
})
text.height = 16
Панель магазина состоит тоже из универсальных ячеек. Каждая ячейка отображает сущность которую можно купить, иконку денег и текст, который показывает стоимость покупки.
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)
}
}
Далее при инициализации моих панелей, передаю необходимые загруженные текстуры и выставляю позицию каждой ячейки.
Т.к. текст рисуется на 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()
Каждая ячейка поля может иметь несколько состояний:
пустое - отображается трава;
кукуруза, корова или курица куплены;
возможность посадить или поместить на эту ячейку кукурузу, корову или курицу;
возможность покормить курицу или корову.
Трава будет всегда отображаться, а вот поверх травы уже будут разные спрайты исходя из логики.
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
}
}
Если нужно менять отображаемую текстуру, совсем не обязательно для каждой текстуры создавать отдельный Sprite
, можно менять свойство texture
на ходу
import { Sprite } from 'pixi.js'
const sprite = new Sprite()
sprite.texture = someTexture
setTimeout(() => {
sprite.texture = someTexture2
}, 1000)
Создаю глобальные состояния игры, как то покупка, простаивание и кормление:
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
. И перерисовываю на каждый тик, хотя можно было бы просто менять ширину нарисовав от начала координат.
Для индикатора генерации выбираю один цвет: белый для яиц или синий для молока. А вот для еды, сделал интерполяцию цвета, чем больше осталось еды - тем зеленее цвет индикатора, наоборот - тем краснее.
Когда рисуете Graphics
и впоследствии собираетесь её масштабировать - всегда предпочитайте рисовать от начала координат (0, 0). Так изменение ширины width
будет работать корректно.
this.drawRect(0, 0, initWidth, initHeight)
this.endFill()
В противном случае изменение ширины приведёт к масштабированию не только графики, но и отступа графики от начала координат.
Например изменение ширины нарисованного индикатора будет работать корректно только если вы рисовали прямоугольник из начала координат.
Когда ресурс сгенерирован, то для наглядности показываю прямоугольник определённого цвета, чтобы пользователь мог собрать ресурс. Нажатие на ячейке поля со сгенерированным ресурсом собирает его, только если игра в режиме ожидания.
При масштабировании любой игры есть два варианта:
Подогнать размеры игры под окно (viewport
/window
или camera
) - Letterbox scale. Оставшееся свободное место желательно поделить пополам - т.е. отцентрировать.
Обрезать игру, если она выходит за пределы окна - Responsive Scale.
Есть ещё экзотический способ просто растянуть/сузить по высоте и ширине, нарушая при этом соотношение сторон - такое я не буду делать.
Для фермы я выбрал 1-й вариант. Для этого вычисляю ширину и высоту всей игры и вписываю в существующие размеры окна.
Для этого подписываюсь на событие изменения размеров окна window.addEventListener('resize', this.resizeDeBounce)
, однако обработчик вызываю не сразу, а с задержкой, чтобы предотвратить постоянное масштабирование когда пользователь тянет за край окна браузера. В этом случае обработчик сработает только один раз после нескольких миллисекунд.
У нас есть персонаж (человек), который ходит по карте. В определённых местах на лужайках он встречает врагов - покемонов. Однако он сражается с ними не сам, а своим покемоном против вражеского. Во время сражения показывается экран битвы, где пользователь играет за покемона. При окончании битвы пользователь возвращается на карту играя за персонажа.
В этом видео полный процесс разработки игры. Дальше будет много игр с этого канала.
В видео познакомился с программой Tiled Map Editor которая тоже работает под Linux
. В ней можно просто и удобно по слоям рисовать 2-х мерную тайловую карту. На выходе при экспорте в формат .json
получаем удобное описание всех слоёв на карте в виде массива:
{
"layers": {
"data": [0, 1, 0, 1],
"name": "Layer name",
"type": "tilelayer"
}
}
А также при экспорте в .png
формат получаем готовую отрисованную карту. Только не забудьте правильно выставить видимые слои.
В видео автор уже нарисовал карту, я немного её подправил из-за неработающих ссылок, остальное сразу заработало. Исходные файлы для 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
нет режима отладки из коробки, его придётся рисовать вручную (можете попробовать браузерное расширение). Например после того, как нарисовали все 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
.
Когда покемон стреляет огненным шаром, я добавляю на сцену соответствующий анимированный спрайт и поворачиваю его по направлению к врагу.
Ваш круг (персонаж) в центре экрана. На него нападают другие круги, которые создаются за пределами экрана и двигаются на персонажа. При нажатии на любое место экрана, персонаж выстреливает туда снаряд, тем самым убивая врага. В некоторых врагов нужно выстрелить несколько раз.
Здесь уже я добавил простую анимацию на чистом CSS. Пока подгружается PixiJS
я показываю многоточие.
Инициализацию всего кода оборачиваю в try
/catch
, в случае ошибки - игра не запускается, а сообщение об ошибке я вывожу прямиком в div
.
Экземпляр Application
я создаю внутри SceneManager
как статическое свойство app
:
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
.
Здесь есть ряд ограничений.
Нужно знать размер контейнера заранее, чтобы выделить память.
Потомками могут быть только спрайты Sprite
у которых одинаковая текстура Texture
.
Не может быть никаких вложенностей внутри 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
нет отдельной функции для очистки всего контейнера.
Для этого придётся пройтись вручную по всем потомкам и удалить каждого:
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
я центрирую посередине без масштабирования. Сам же модальный диалог я показываю когда игра закончилась, в окне я показываю набранное количество очков, а также кнопку для перезапуска игры.
Персонаж похожий на космонавта, бегает по платформам. Также может прыгать через ямы и запрыгивать на вышестоящие платформы. Персонаж не может выйти за пределы уровня влево или вправо. Персонаж врят-ли выйдет за верхний край уровня из-за гравитации. Если персонаж касается нижней части уровня - игра проиграна.
В этой игре я сделал двухэтапную загрузку:
Когда PixiJS
еще не подгрузилась - показываю CSS
анимацию многоточий - как в игре Стрелялки
.
Когда 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
я не меняю относительное положение игрока к платформам.
На персонажа действует сила тяжести только если он в свободном падении. А вот когда персонаж стоит на платформе - не действует.
Для определения стоит или не стоит по вертикальной оси я использую немного изменённый вариант проверки. Беру позицию персонажа, она должна быть выше верхнего края платформы, вдобавок к этому позиция персонажа плюс смещение должны заходить за верхний края платформы - только в этом случае пресонаж останавливается на текущей платформе. Это сделано для того, чтобы можно было запрыгнуть на вышестоящую платформу находясь под ней.
Есть два персонажа, одним управляет игрок №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 пикселей, это сделано для того, чтобы персонаж (фрейм) не смещался, когда он атакует. Отсюда возникла необходимость в учёте прямоугольника, который я использую для столкновений с землёй. Этот прямоугольник меньше чем весь спрайт.
Прямоугольник всего спрайта
Прямоугольник участвующий в расчете столкновений
Для масштабирования сцены я выбрал Letterbox scale
метод. Т.е. мне нужно всю сцену поместить внутрь экрана. Чтобы вычислить ширину и высоту сцены я использую width
и height
текстуры фона. И далее казалось бы просто выставить ширину и высоту нашей сцене согласно вычисленным параметрам и всё готово...
Контейнеры в 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
}
Площадь оружия
Площадь атакуемого персонажа
Игрок управляет космическим кораблём, и сражается с пришельцами. Корабль может двигаться влево-вправо до пределов карты и стрелять. Пришельцы появляются группами, группа движется к какой-то стороне экрана, дойдя до стороны группа перемещается вниз на один ряд и ускоряется. Иногда кто-то из группы пришельцев стреляет в направлении корабля.
Мне эта игра больше известна под названием Galaxian
(Галактика) - однако прародителем скорее всего была игра Space Invaders
.
Здесь я использую контейнеры частиц ParticleContainer
для всех:
для звёзд использую контейнер, прикинув при этом, сколько частиц (звёзд) нужно отображать, чтобы было не слишком много и похоже на звёздное небо. Каждая звезда Star
- это спрайт. Все звёзды используют одну и ту же текстуру, эту текстуру я рисую как Graphics
в виде многоугольника, чтобы было похоже на звезду. И далее для каждой звезды есть своя позиция и цвет. Весь контейнер просто обновляет свою позицию на каждый тик счетчика - что-то вроде Parallax Scrolling
.
для пришельцев контейнер может менять только позицию. Текстура у всех пришельцев одинаковая и не подкрашивается.
для снарядов и частиц (от взрывов) контейнеры могут менять позицию и цвет.
Удаление потомков контейнеров происходит при выходе за пределы экрана/камеры isOutOfViewport
или при столкновении с кораблём/пришельцем.
Частицы взрывов также удаляются при достижении абсолютной прозрачности.
Корабль состоит всего из одной текстуры. Когда корабль движется в сторону, я немного поворачиваю спрайт корабля.
Чтобы было удобно играть с сенсорных устройств или мышкой - я сдел так, чтобы любое касание выше середины корабля производило выстрел + движение в сторону.
Середина корабля выставляется как this.anchor.set(0.5, 0.5)
.
Пришельцы добавляются группами по времени. Если пришло время добавить следующую группу пришельцев, я проверяю есть ли для новой группы место вверху экрана.
Пришельцев добавляю в один контейнер частиц, однако каждый пришелец принадлежит определённой группе Grid
. Группа состоит из случайного количества строк и столбцов в заданных пределах.
Чем шире экран - тем больше возможных столбцов. Соответственно каждый пришелец занимает определённое место в группе.
Чтобы группа пришельцев действовала как единый организм - я прохожусь по группе и высчитываю "статистику" самого верхнего/левого/правого/нижнего пришельца в группе. Имея "статистику" можно менять направление всей группы если она столкнулась с краем. Также статистика
помогает определять сколько занимает вся группа. И самое главное, статистика
позволяет выбрать случайного пришельца из нижнего ряда самой нижней группы и выстрелить в сторону корабля.
Если снаряд корабля пересекается с пришельцем я удаляю пришельца и показываю взрыв из частиц.
Игрок проиграл, если его корабль столкнулся с пришельцем или был поражен снарядом пришельцев. В обоих случаях я показываю взрыв корабля - однако игру останавливаю не сразу а по истечении некоторого времени.
Игрок управляет обжорой пакманом - круг который поедает шарики (гранулы). Карта ограничена стенами через которые нельзя проходить. Цель игры съесть все гранулы и не попасться двум противникам (призракам). Если съесть супер-гранулу, то на какое-то время пакман становится неуязвим и можно успеть съесть и призраков.
Карта (уровень) создаётся из текстового описания, описание расположено прям в коде. Строковое описание тайла карты преобразовывается в объект карты.
На карте могут быть расположены стенки разного вида Boundary
(отличаются только спрайтом отображения), гранулы Pellet
, супер-гранулы PowerUp
.
Под картой расположена подложка background
для контраста.
Два призрака Ghost
создаются в определённых местах.
Призраки управлются простым искусственным интеллектом (ИИ) - проверяются все доступные виды движений (вверх, вправо, вниз, влево) и выбирается случайное из доступных.
В этой игре я нарисовал текстуры пакмана с помощью PixiJS
. Мне нужно было нарисовать анимированный спрайт AnimatedSprite
, который состоит из круга, который открывает и закрывает рот (секция круга увеличивается и уменьшается).
Для начала я определился, что фаза открытия/закрытия рта будет состоять из 10 фреймов. Для каждого фрейма я рисую арку с определённым углом. Получившуюся графику я преобразовываю в текстуру. Все полученные текстуры складываю в массив. Для фазы закрытия, копирую в обратном порядке полученные фреймы из фазы открытия.
Полученный анимированный спрайт подкрашиваю в нужный цвет.
Генерация текстур для призраков аналогична, только рисую я "бантик" - т.е. круг с увеличивающимися секторами вверху и внизу.
Для сенсорных устройств и мыши решил тоже использовать направление куда показывает пользователь. Впоследствии понял, что на телефоне приходится управлять закрывая при этом самого пакмана - что неудобно.
При столкновении призрака и пакмана наступает чья-то смерть: призрака - если идёт действие супер-гранулы, пакмана - в остальных случаях.
Когда пакман съел все гранулы - игра окончена. Я показываю всё то-же диалоговое окно StartModal
с кнопкой для перезапуска игры.
Карта состоит из дороги и мест по краям дороги, где можно построить башни. По дороге идут орки. Задача не пропустить орков на другой конец карты, для этого башни должны убить всех орков. За убийство каждого орка начисляются деньги, за которые можно построить ещё башен. Игра заканчивается если игрок пропустил более 10 орков.
Для разнообразия я сделал чтобы башня попеременно стреляла то камнями то огненным шаром.
Папка src-tiled
содержит проект карты для Tiled Map Editor
. Тайловая карта нарисована по слоям, путь для орков прописан в виде линий в слое Waypoints
. Здесь я подкорректировал предыдущие типы слоёв для TypeScript
т.к. появился новый тип слоя objectgroup
.
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
.
Игрок управляет псом. Пёс бежит слева направо, ему мешают враги: растения, мухи, пауки. Пёс может крутиться в прыжке, тем самым убивая врагов. За каждого врага начисляются очки. Если пёс сталкивается с врагами в режиме бега, то вычитается жизнь - всего 5 жизней. Цель игры за определённое время набрать нужное количество очков.
В отличии от предыдущих игр, эта игра взята от другого автора.
В программе Free texture packer
я подготовил 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
черный поверх белого. Так получилось сделать тень - для лучшей читаемости.
Игра заканчивается когда время заканчивается. Затем я сравниваю полученное количество очков и показываю либо успешное сообщение либо проигрышное.
Игрок управляет персонажем, который передвигается по уровню. Цель игры пройти 3 уровня за определённое время. Переход на другой уровень происходит когда персонаж открыл дверь.
В игре очень много кода, который уже описывал неоднократно. Отличий от других игр немного:
Вначале я подгружаю только одну часть ресурсов - для первого уровня. Остальные части я подгружаю в фоне.
Стартовые положения дверей и персонажа описываются в Tiled Map Editor
в слое Player and Door
. Далее я расставляю игрока и двери согласно описанию.
Если персонаж стоит у дверей, то касание или нажатие по персонажу открывает дверь. А точнее стартует анимацию открывания дверей + анимацию захода в дверь для персонажа.
Далее, когда дверь открылась, я запускаю плавное затенение между уровнями. Для затенения использую нарисованный прямоугольник и плавно меняю ему прозрачность. Сначала чтобы полностью затенить экран. При тёмном экране меняю уровень и затем плавно убираю затенение.
Игрок управляет персонажем, который передвигается по уровню и может запрыгивать на платформы.
Отличия от предыдущих игр:
Вокруг персонажа рисую camerabox
в виде невидимого прямоугольника. Если этот прямоугольник касается любого края экрана - то я прокручиваю карту pivot
если есть куда.
Игрок управляет эльфийкой, которая ходит по карте. Эльфийка может стрелять стрелами. На карте есть враги - орки. В ближнем бою орки убивают эльфийку, а вот стрелять стрелами из лука может только эльфийка. Игра заканчивается когда все орки повержены.
В оригинале авторк(а) выпустила игру в двух вариантах:
Первый вариант описан вскользь в этом видео. Исходный код во контакте. Всё в одном файле, на ES5
, без редактора карт - зато игра включает в себя весь необходимый функционал и работает.
Второй вариант подробно описан в серии видеоуроков. Исходный код на github. Код разбит на модули, на ES6
- но до конца не работает - орк не атакует, можно выходить за пределы карты.
В итоге я совместил модульность 2-го варианта с функционалом 1-го, по возможности сохранял оригинальные названия классов. Решил оставить графику/спрайты обоих вариантов - сделал просто два уровня. Все спрайты порезал на маленькие кадры и создал один общий атлас. Включено только то, что используется.
Карту из первого варианта нарисовал полностью в Tiled Map Editor
- т.к. она была просто описана в коде. Также добавил расставление орков через редактор для обоих вариантов (уровней).
Из первого варианта взял недостающий функционал и звуки.
Камера не использует собственного отображения, всё, что видно на экране и есть камера.
Однако камера следит за персонажем watchObject
и имеет доступ к карте TileMap
, чтобы смещать положение карты pivot
. Если персонаж не выходит за границы окна за вычетом scrollEdge
- то камера не двигает карту.
Панель статуса отображает с помощью текста количество оставшихся орков orcsText
, текущий уровень levelText
и время игры timeText
. Движок игры использует публичные методы updateTime
/updateLevel
/updateOrcs
соответственно.
Орк Orc
и эльфийка Player
наследуются от Body
. Body
класс реализует состояния Stand
/Walk
/Attack
во всех направлениях. Анимация смерти только с направлением вниз DeadDown
. При переключении на новый спрайт, я включаю анимацию с первого кадра gotoAndPlay(0)
, если это анимированный спрайт. Состояния персонажа PlayerState
наследуются от состояний BodyState
т.к. нужно обрабатывать пользовательский ввод InputHandler
.
Для корректной установки положения спрайтов использую setCollisionShapePosition()
метод - т.к. положение всего спрайта отличается от прямоугольника, который используется в расчетах столкновений.
Управление эльфийкой происходит как обычно сенсорным вводом, мышкой или клавиатурой в классе InputHandler
.
Управление орками берёт на себя простой ИИ
в классе AI
. Вкратце суть управления - это выбор случайного направления каждые 500-200
миллисекунд.
Класс Collider
- берёт на себя функционал по расчету столкновений. При изменении уровня я сделал метод, который сбрасывает все контейнеры в новое состояние.
Класс Hitbox
- используется как графическое отображение непроходимых блоков на карте. Описание и позиции блоков берётся из слоя Misc
.
У орка есть дистанция прыжка. Если эльфийка приближается к орку на эту дистанцию - то орк как бы "прыгает" к эльфийке и наносит удар, который убивает персонажа - игра окончена.
Все предыдущие игры я делал 1 месяц, столько же делал и эту последнюю игру.
В далёком 2014 году попалась мне книжка Pro HTML5 Games за авторством Адитья Рави Шанкар (Aditya Ravi Shankar). Исходный код я нашел на github.
В 2016 году я снова вернулся к исходному коду - и переписал его местами. Решил избавиться от jQuery
, добавить пару новых звуков - и звуковой движок переписать на Web Audio API. С задачей я справился, но желание переписать всю игру осталось.
И вот в 2023 году я нашел время вернуться и полностью переписать всё на TypeScript
+ PixiJS
.
Жанр игры - стратегия в реальном времени. Получилась смесь из Command & Conquer + StarCraft. Игрок играет за одну команду, компьютер (CPU
) за другую. Есть три режима игры:
Прохождение или кампания. В оригинале состоит из 3-х миссий, я сделал 4.
Одиночный матч против компьютера (в оригинале такого нет).
Сетевая игра двух игроков против друг друга.
В оригинале игра была такой: есть база 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 типа кнопок:
Кнопка с текстом, но без иконки
Кнопка без текста но с иконкой
Кнопки с текстом и иконкой
В меню у меня три текстовые кнопки и кнопка-иконка настроек вверху.
Если выбрать Campaign
(кампанию или прохождение) - то я дополнительно показываю список всех доступных миссий (кнопками с текстом), чтобы можно было выбрать любую. А также показываю кнопку-иконку "домик" - для возврата.
Миссия 1 - Управляя тяжелым танком доехать до верхнего левого угла и сопроводить на базу два транспорта
Миссия 2 - Держать оборону, пока не прилетит подкрепление из 2-х вертолётов - тогда уничтожить вражескую базу
Миссия 3 - Спасти оставшийся транспорт, затем уничтожить противника
Миссия 4 - Построить харвестер который трансформировать в нефтевышку. Построить рабочего и с помощью него построить турель. Убить вражеского легкого танка. Построить космопорт, на нём построить легкий танк и вертолёт. Уничтожить вражеский лёгкий танк и базу.
Первые 3 миссии сделал как в оригинале, а вот 4-ю сделал в виде обучения - собрал все учебные миссии из улучшенного оригинала в одну.
Компонент Button
имеет 4 состояния: обычное (idle
), выбранное (selected
), неактивное (disabled
) и наведённое (hover
). Внутри я рисую отдельные скруглённые прямоугольники для фона background
, для границ border
- всё белым цветом и потом окрашиваю. Если передаю иконку в виде текстуры, то добавляю сначала текстуру иконки в виде спрайта, а потом текст. Также инициализирую интерактивность для кнопки и подписываюсь на события указателя.
В зависимости от текущего события выставляю нужное состояние кнопки.
Иногда нужно вывести экземпляр всего 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 категории:
Голоса (voiceVolume
) - когда юниты получили приказ на движение, строительство или атаку.
Выстрелы (shootVolume
) - когда юнит стреляет снарядом (Cannon
), пулей (Bullet
), ракетой (Rocket
), лазером (Laser
).
Попадания (hitVolume
) - когда снаряд/пуля/ракета/лазер попали в цель.
Сообщения (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
, могут иметь разные масштабы.
Для преобразования нужно использовать 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.3
). Для размытия я использую стандартный BlurFilter
.
Слой рисуется полностью, но с полупрозрачным затенением
Слой рисуется используя маску обзора
Если совместить все три слоя и добавить остальные сущности поверх - получится результат, который видит конечный пользователь.
Все юниты и здания которые, не попадают в поле зрения я не отображаю, используя при этом 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 советов для новичков
Исходный код всех игр:
Стратегия
Эльф и орки
Платформер
Комнаты
Скроллер
Башенки
Пакман
Галактика
Драки
Марио
Стрелялки
Покемон
Ферма