Привет!
При разработке игр на JavaScript часто возникает необходимость создавать множество объектов. О том, как правильно это сделать и не утонуть в коде, я рассказывал, примерно — месяц назад на Frontend Dev Conf в Минске. Возможно, доклад будет интересен тем, кто не был на конференции и сталкивался с проблемой создания множества объектов, либо является разработчиком HTML5 игр.
Под катом текст с картинками.
Классы и фабрики
Как уже говорилось выше, при создании игр возникает необходимость создания множества разнообразных объектов. Для достижения этой цели используется наследование на прототипах. Как выглядит классический класс при таком подходе:
function animal() {
...
}
animal.prototype.left = function() {
...
}
animal.prototype.right = function() {
...
}
Этот и аналогичные примеры вы можете встретить в множестве книг описывающих ООП на JavaScript. Этот же принцип копирует большая часть MVC фреймворков. Но что делать когда нам нужно получить много объектов разных классов? При подходе, описанном выше, нам приходится создавать множество классов. В результате у нас могут возникнуть трудности с последующей поддержкой проекта, поиском багов и пониманием того, как все это работает. Создаются бесконечные цепочки зависимых наследований одних объектов, от других. Выходом из данной ситуации является использование фабрики.
В С++ фабрика объектов может создавать только объекты определенного типа, которые используют единый интерфейс. Самыми главными преимуществами данного паттерна в С++, является упрощение создания объектов различных классов, использующих единый интерфейс. В JavaScript мы можем отойти от этого ограничения и в одном месте получать объекты с абсолютно разным набором свойств и методов.
Для построения прозрачной структуры нашего приложения составим список всех свойств и список всех прототипов, разбитых на группы. Например:
var properties = {
speed: {
x: 0,
y: 0
limit: {
x: 10,
y: 10
}
},
acceleration: {
x: 0,
y: 0
},
live: {
health: 100,
killing: 0,
level: 0
}
};
var prototypes = {
left: function() {
...
},
right: function() {
...
}
};
Чтобы заказать какой-либо объект на фабрике, нам всего-навсего необходимо указать список свойств и список прототипов, которые должен унаследовать объект. Например:
var objectA = factory([ "physics", "live" ], [ "left" ]);
var objectB = factory([ "live", "acceleration" ], [ "right" ]);
Что же произойдет на фабрике? Примерно следующее:
var object = {};
// наследуем свойства
for(var name in properties) {
object[name] = properties[name];
}
// наследуем методы
for(var method in prototypes) {
object.prototype[method] = prototype[method];
}
На самом деле, реальный механизм будет немного сложнее. Например, нужно будет перебрать свойства на наличие вложенных объектов, присвоить какие-то стандартные значения, проверить валидность входных данных, научиться динамически создавать, сохранять и доставать запрашиваемые классы, но основная суть не изменится.
Остается решить последнюю проблему — красиво описать свойства всех классов и объектов в простом виде. Это можно сделать, используя все те же списки. Рассмотрим это на примере класса блоков:
var classList = {
...
block: {
_properties: [ // Список свойств, которые наследуют блоки
"skin",
"dimensions",
"physics",
"coordinates",
"type"
],
_prototypes: [], // Список прототипов, которые наследуют блоки
_common: { // Список значений по умолчанию
_properties: {
move: false // Свойство move у всех блоков будет false
}
},
floor: { // Далее идет описание различных объектов класса
roughness: 0.37 // и их индивидуальных свойств
},
gold: {
roughness: 0.34
},
sand: {
roughness: 0.44 // Вы можете видеть, что у разных блоков,
}, // разная шероховатость поверхности
water: {
roughness: 0.25
}
}
};
Имея подобный список классов и объектов, мы также можем автоматически создать API. Например:
block.gold(); // создать блок золота
block.sand(); // создать блок песка
block.water(); // создать блок воды
Таким образом, мы получили фабрику, размер кода которой не меняется вне зависимости от количества и разнообразия объектов в игре и три списка типа JSON:
- Список свойств
- Список методов
- Список классов и объектов этих классов
Списки удобны тем, что их просто менять, просто покрывать документацией, а так же при увеличении числа объектов у нас не увеличивается количество кода (т.к. списки — это конфиги). Получается что написав один раз код фабрики, мы можем создавать сотни разнообразных объектов, с прозрачной структурой и совершенно не путаясь в них.
Пример файла документации свойств:
var properties = {
physics: {
speed: {
x: "Скорость по оси X",
y: "Скорость по оси Y",
limit: {
x: "Предел скорости по оси X, который объект может развить самостоятельно.",
y: "Предел скорости по оси Y, который объект может развить самостоятельно."
}
},
acceleration: {
x: "Постоянное ускорение по оси X. Сохраняется между тиками...",
y: "Постоянное ускорение по оси Y. Сохраняется между тиками..."
},
},
live: {
health: "Максимальное здоровье объекта.",
killing: "Количество убийств совершенных объектом.",
level: "Уровень прокачки объекта."
}
};
Кроме того, используя списки легко создавать новые виды объектов. Для этого, всего-навсего, надо вызывать фабрику с разными параметрами. Например, у нас есть машина, а нам необходимо сделать из неё танк. Для этого мы можем добавить в описание машины пачку прототипов, отвечающих за оружие. Таким образом, получим некую машину с оружием, а по сути — танк.
Как убедиться, что наша фабрика работает правильно?
Для этого нам необходимо создать несколько объектов, запустить дебагер и посмотреть адреса свойств и методов объектов в памяти.
- Адреса всех свойств должны отличаться, т.к. они уникальны для каждого объекта.
- Адреса всех методов должны совпадать, т.к. методы общие у всех объектов.
Стандартизация интерфейсов
Стандартизация интерфейсов объектов помогает писать общие модули для работы с ними. Суть метода заключается в том, чтобы привести API всех объектов к некому общему стандартному виду, несмотря на их отличия между собой. Рассмотрим метод на следующем примере:
Проблема:
Есть некий игровой персонаж и мир вокруг него. Когда игрок зажимает кнопку «использовать», возможны как минимум две ситуации:
- Персонаж находится около оружия. В этом случае он должен поместить оружие в рюкзак.
- Персонаж находится около транспорта. В этом случае он должен сесть за руль.
Решение:
- При нажатии кнопки использовать мы ищем все объекты вблизи нашего персонажа.
- Далее мы начинаем перебирать объекты, начиная от самого ближнего и проверять, есть ли у них метод use().
- Если предмет с таким методом найден, прокидываем туда нашего персонажа и завершаем поиск.
Понятно, что метод use() у оружия и транспорта будут отличаться, но как реализовать это в момент наследования прототипов на фабрике?
Ответ очень простой. Необходимо составить список замены и перед присвоением имени метода проверить, нет ли его в списке. Например:
// некий список замены названий методов прототипов
var replaceList = {
...
weapon: "use",
transport: "use"
}
// кусок фабрики, который отвечает за наследование прототипов
for(var method in prototypes) {
var name = replaceList[method] || method;
object.prototype[name] = prototype[method];
}
Таким образом, несмотря на то, что оружие наследовало прототип «weapon», а транспорт — «transport», в прототипах объектов будет всего один метод use, за которым у каждого объекта будут скрываться какие-то свои функции. Такой вот полиморфизм.
Как сохранить и загрузить объекты
Теперь, когда у нас есть большой мир с кучей разных объектов (представьте себе Minecraft), настало время решить следующую задачу — реализация функций сохранить/загрузить.
На первый взгляд все просто. Т.к. у нас есть множество объектов, мы можем преобразовать их в строку (JSON.stringify), но тут есть две проблемы:
- Объекты могут обладать множеством связей
- Цепочки прототипов будут потеряны
Разберем решение каждой задачи по отдельности.
Запрет на хранение объектов.
Суть метода заключается в том, что после создания объекта на фабрике, ему будет присвоен некий уникальный ID, и он должен попасть в единый реестр объектов. Задача единого реестра хранить все объекты и по требованию выдавать их по ID. При этом любому модулю и подсистеме запрещается без лишней необходимости использовать объект и строго запрещено сохранять ссылку на него. Вся система оперирует исключительно ID, и лишь в особых случаях запрашивает сам объект. Например:
Человек садится в автобус. Автобус имеет массив пассажиров. По правилам, он должен добавить в массив пассажиров ID вошедшего человека. Если же в массив будет добавлен сам объект человека — мы получим излишнюю вложенность. Это создаст нам кучу проблем, начиная от мифических утечек памяти, заканчивая необходимость перебора объектов в объектах при загрузке. Кроме того, если пассажир автобуса умрет по какой-либо причине в момент поездки, нам придется извлекать труп. Если же мы храним только ID пассажира, при извлечении объектов мы увидим, что пассажир с таким ID больше не существует и перейдем к следующей иттерации.
Или другой пример:
Марио берет монетку. На самом деле Марио должен положить в свой рюкзак только ID монетки. Если он где-то будет её использовать, система запросит сам объект монетки из реестра, но она обязана будет удалить ссылку на него сразу после завершения своих действий.
Также эта система помогает избежать багов при рендере. Например, когда мы по какой-то причине удаляем весь мир, а камера запрашивает какой-либо объект. При наличии реестра она поймет, что объекта больше не существует и удалит все свои настройки связанные с этим ID.
Восстановление цепочек прототипов.
При переводе объекта в строку мы потеряем прототипы, которыми обладал объект. Поэтому перед преобразованием необходимо добавить объекту свойство prototype_list и перечислить в нем все прототипы, которыми обладал объект (притом, сделать это необходимо, ещё на фабрике, т.к. после у всех объектов будет типовой интерфейс, который ничего не сообщит об их истинной начинке).
Далее при операции загрузки мы будем вновь отправлять объекты на фабрику, но только уже в цех реставрации. Там объекты будут проходить только вторую часть работы — наследование прототипов по списку.
Автор: