На прошлой неделе я выпустил Ash — Entity System фреймворк для разработки игр на Actionscript и многие люди задали мне вопрос «Что такое Entity System Framework?». Вот мой длинный ответ.
Entity-системы становятся популярными благодаря хорошо знакомым вещам вроде Unity и менее известными ActionScript фреймворками вроде Ember2, Xember и моим собственным Ash. Для этого есть хорошие причины: упрощенная архитектура игры, поощряющая разделение ответственности в коде, простая в использовании.
Этим постом я последовательно покажу, как архитектура, основанная на сущностях (Entity) появляется из старомодного игрового цикла. Это займет некоторое время. Приведенные примеры будут на Actionscript, поскольку это именно то, что я использую в настоящий момент, но сама архитектура подойдет для любого языка программирования.
Примеры
В этой статье я буду в качестве примера использовать простую игру Asteroids. Я использую Астероиды как пример, поскольку она включает в себя многие вещи, необходимые в больших играх — систему рендера, физики, ИИ, контроль игроком объекта и не-контролируемые обьекты.
Игровой цикл
Чтобы действительно понять, зачем мы используем системы сущностей, вы должны четко понимать, как работает старый, добрый игровой цикл. Для Астероидов он может выглядеть как-то так:
function update( time:Number ):void { game.update( time ); spaceship.updateInputs( time ); for each( var flyingSaucer:FlyingSaucer in flyingSaucers ) { flyingSaucer.updateAI( time ); } spaceship.update( time ); for each( var flyingSaucer:FlyingSaucer in flyingSaucers ) { flyingSaucer.update( time ); } for each( var asteroid:Asteroid in asteroids ) { asteroid.update( time ); } for each( var bullet:Bullet in bullets ) { bullet.update( time ); } collisionManager.update( time ); spaceship.render(); for each( var flyingSaucer:FlyingSaucer in flyingSaucers ) { flyingSaucer.render(); } for each( var asteroid:Asteroid in asteroids ) { asteroid.render(); } for each( var bullet:Bullet in bullets ) { bullet.render(); } }
Этот игровой цикл вызывается с постоянным интервалом, обычно 60 или 30 раз в секунду, чтобы обновить игру. Порядок операций в цикле важен, поскольку мы обновляем разнообразные игровые объекты, проверяем столкновения между ними и затем их всех отрисовываем. Каждый кадр.
Это очень простой игровой цикл, поскольку:
1. Игра проста сама по себе.
2. Игра имеет только одно состояние.
В прошлом, я работал над консольными играми, где игровой цикл, единственная функция, состояла из более 3000 строк кода. Это было не красиво и это было глупо. Но это был способ, по которому создавались игры и нам приходилось с этим жить.
Архитектура систем сущностей пришла из попытки решить проблемы игрового цикла. Она делает игровой цикл ядром игры и предполагает, что упрощение игрового цикла важнее, чем все остальное в архитектуре современной игры. Это важнее, чем отделить вид от контролера, к примеру.
Процессы
Первый шаг в этой эволюции — это объекты, называемые процессами. Это объекты, которые могут инициализироваться, обновляться и разрушаться. Интерфейс процесса выглядит примерно так:
interface IProcess { function start():Boolean; function update( time:Number ):void; function end():void; }
Мы можем упростить игровой цикл, если разобьем его на несколько процессов, которые будут отвечать, к примеру, за рендер, передвижение объектов или обработку столкновений. Чтобы управлять этими процессами, мы создадим менеджер процессов.
class ProcessManager { private var processes:PrioritisedList; public function addProcess( process:IProcess, priority:int ):Boolean { if( process.start() ) { processes.add( process, priority ); return true; } return false; } public function update( time:Number ):void { for each( var process:IProcess in processes ) { process.update( time ); } } public function removeProcess( process:IProcess ):void { process.end(); processes.remove( process ); } }
Это некая упрощенная версия менеджера процессов. В частности, мы должны убедиться, что обновляем процессы в нужном порядке (который определяется параметром приоритета в методе add) и мы должны обрабатывать ситуацию, когда процесс удаляется в течении игрового цикла. Но эта упрощенная версия передает саму идею. Если наш игровой цикл разбит на несколько процессов, тогда метод update нашего менеджера процессов и есть наш новый игровой цикл и процессы уже становятся ядром игры.
Процесс рендера
Давайте посмотрим, к примеру, на процесс рендера. Мы можем просто вытащить код рендера из игрового цикла и разместить его в процессе, получив что-то вроде этого:
class RenderProcess implements IProcess { public function start() : Boolean { // Инициализируем систему рендера return true; } public function update( time:Number ):void { spaceship.render(); for each( var flyingSaucer:FlyingSaucer in flyingSaucers ) { flyingSaucer.render(); } for each( var asteroid:Asteroid in asteroids ) { asteroid.render(); } for each( var bullet:Bullet in bullets ) { bullet.render(); } } public function end() : void { // Очищаем систему рендера } }
Используем интерфейсы
Но это не слишком эффективно. Мы все еще должны вручную отрисовать все возможные виды игровых объектов. Если бы у нас был общий интерфейс всех видимых объектов, мы бы могли многое упростить
interface IRenderable { function render(); } class RenderProcess implements IProcess { private var targets:Vector.<IRenderable>; public function start() : Boolean { // Инициализируем систему рендера return true; } public function update( time:Number ):void { for each( var target:IRenderable in targets ) { target.render(); } } public function end() : void { // Очищаем систему рендера } }
Тогда класс нашего космического корабля будет содержать подобный код:
class Spaceship implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render():void { view.x = position.x; view.y = position.y; view.rotation = rotation; } }
Этот код основан на диспейных списках Flash. Если бы мы использовали буфера или stage3d, он был бы другим, но принципы будут те же. Нам нужна картинка, которую нужно отрисовать, позиция и вращение, чтобы ее отрисовать. И функция render, выполняющая вывод на экран.
Используем базовые классы и наследование
По факту, в коде корабля нет ничего уникального. Весь его код мог бы использоваться всеми видимыми объектами. Единственная вещь, которая их отличает это display object, привязанный по свойству view, а также позиция и угол вращения. Давайте обернем это в базовый класс и используем наследование.
class Renderable implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render():void { view.x = position.x; view.y = position.y; view.rotation = rotation; } } class Spaceship extends Renderable { }
Разумеется, все рисуемые объекты будут расширять базовый класс и мы получим иерархию наподобие этой:
Процесс движения
Чтобы понять следующий шаг, сперва мы должны посмотреть на другой процесс и класс, с которым он работает. Попробуем представить процесс движения, который обновляет информацию о позиции объектов
interface IMoveable { function move( time:Number ); } class MoveProcess implements IProcess { private var targets:Vector.<IMoveable>; public function start():Boolean { return true; } public function update( time:Number ):void { for each( var target:IMoveable in targets ) { target.move( time ); } } public function end():void { } } class Moveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move( time:Number ):void { position.x += velocity.x * time; position.y += velocity.y * time; rotation += angularVelocity * time; } } class Spaceship extends Moveable { }
Множественное наследование
Это все выглядит неплохо, но, к сожалению, мы хотим, чтобы наш корабль и двигался и отрисовывался, но многие языки программирования не позволяют осуществить множественное наследование. И даже в тех языках, которые его поддерживают, мы столкнемся с проблемой, когда позиция и вращение в Movable классе должно быть тем же, что и в классе Renderable.
Решение может быть в создании цепочки наследования, когда Movable будет расширять Renderable.
class Moveable extends Renderable implements IMoveable { public var velocity:Point; public var angularVelocity:Number; public function move( time:Number ):void { position.x += velocity.x * time; position.y += velocity.y * time; rotation += angularVelocity * time; } } class Spaceship extends Moveable { }
Теперь наш космический корабль способен и передвигаться, и отрисовываться. Мы можем применить те же принципы и к другим игровым объектам и получить эту иерархию классов.
Мы даже можем получить статичные объекты, которые, просто расширяют Renderable.
Moveable, но не Renderable
Но что, если мы захотим создать подвижные объекты, которые не должны отрисовываться? К примеру, невидимые игровые объекты? Здесь наша иерархия классов ломается и нам нужны альтернативные реализации интерфейса Movable, который не наследуется от Renderable.
class InvisibleMoveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move( time:Number ):void { position.x += velocity.x * time; position.y += velocity.y * time; rotation += angularVelocity * time; } }
В простой игре это грязно, но управляемо, а в сложных играх использование наследования для привязки процессов к объектам быстро делает код неуправляемым и скоро вы обнаружите вещи в игре, которые не вкладываются в простое дерево наследования, вроде тех, что представлены выше.
Предпочитайте композицию наследованию.
Есть старый принцип ООП: предпочитайте композицию наследованию. Применение этого принципа может спасти от потенциальной путаницы в наследовании.
Нам все еще нужны классы Renderable и Movable, но, вместо того, чтобы их наследовать для создания класса космического корабля, мы создадим класс корабля, который будет содержать экземпляры каждого из этих классов.
class Renderable implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render():void { view.x = position.x; view.y = position.y; view.rotation = rotation; } } class Moveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move( time:Number ):void { position.x += velocity.x * time; position.y += velocity.y * time; rotation += angularVelocity * time; } } class Spaceship { public var renderData:IRenderable; public var moveData:IMoveable; }
Этим способом мы можем комбинировать различные поведения любым способом, не получая каких-либо проблем с наследованием.
Эти объекты, сделанные при помощи данной композиции: Static Object, Spaceship, Flying Saucer, Asteroid и Force Field — все вместе называются сущностями (Entities).
Наши процессы при этом не изменяются
interface IRenderable { function render(); } class RenderProcess implements IProcess { private var targets:Vector.<IRenderable>; public function update(time:Number):void { for each(var target:IRenderable in targets) { target.render(); } } } interface IMoveable { function move(); } class MoveProcess implements IProcess { private var targets:Vector.<IMoveable>; public function update(time:Number):void { for each(var target:IMoveable in targets) { target.move( time ); } } }
Но мы не будем добавлять объект корабля к каждому процессу, вместо этого мы добавим его компоненты. И, таким образом мы получим что-то вроде этого:
public function createSpaceship():Spaceship { var spaceship:Spaceship = new Spaceship(); ... renderProcess.addItem( spaceship.renderData ); moveProcess.addItem( spaceship.moveData ); ... return spaceship; }
Такой подход выглядит неплохо. Он дает свободу перемешивать и сочетать поддержку процессов в различных игровых объектах без каши цепочек наследования или самоповторений. Но есть и одна проблема.
Что делать с общей информацией?
Свойства позиции и поворота в объекте класса Renderable должны иметь те же значения, что и позиция с поворотом в объекте Movable, поскольку процесс Move должен изменять их значения, а процессe Render они нужны для отрисовки.
class Renderable implements IRenderable { public var view:DisplayObject; public var position:Point; public var rotation:Number; public function render():void { view.x = position.x; view.y = position.y; view.rotation = rotation; } } class Moveable implements IMoveable { public var position:Point; public var rotation:Number; public var velocity:Point; public var angularVelocity:Number; public function move( time:Number ):void { position.x += velocity.x * time; position.y += velocity.y * time; rotation += angularVelocity * time; } } class Spaceship { public var renderData:IRenderable; public var moveData:IMoveable; }
Чтобы разрешить эту проблему, нам нужно быть уверенными, что оба объекта ссылаются на одинаковые экземпляры этих свойств. В ActionScript это означает, что эти свойства должны быть объектами, поскольку объекты могут быть переданы по ссылке, а примитивные типы передаются по значению.
Итак, мы представляем другой набор классов, которые мы зовем компонентами. Эти компоненты представляют собой обертку над значениями свойств, чтобы их расшаривать между процессами.
class PositionComponent { public var x:Number; public var y:Number; public var rotation:Number; } class VelocityComponent { public var velocityX:Number; public var velocityY:Number; public var angularVelocity:Number; } class DisplayComponent { public var view:DisplayObject; } class Renderable implements IRenderable { public var display:DisplayComponent; public var position:PositionComponent; public function render():void { display.view.x = position.x; display.view.y = position.y; display.view.rotation = position.rotation; } } class Moveable implements IMoveable { public var position:PositionComponent; public var velocity:VelocityComponent; public function move( time:Number ):void { position.x += velocity.velocityX * time; position.y += velocity.velocityY * time; position.rotation += velocity.angularVelocity * time; } }
Когда мы создаем класс нашего космического корабля, мы должны быть уверены, что объекты Movable и Renderable ссылаются на один и тот же экземпляр PositionComponent.
class Spaceship { public function Spaceship() { moveData = new Moveable(); renderData = new Renderable(); moveData.position = new PositionComponent(); moveData.velocity = new VelocityComponent(); renderData.position = moveData.position; renderData.display = new DisplayComponent(); } }
Это изменение все еще не затрагивает процессы.
И это хорошее место передохнуть.
Сейчас у нас есть четкое разделение задач. Игровой цикл «крутит» процессы, вызывая метод update для каждого. Каждый процесс состоит из коллекции объектов, которые реализуют интерфейс, по которому с ними можно взаимодействовать и (процесс) вызывает нужные методы для этих объектов. Такие объекты выполняют единственное задание со своей информацией. При помощи компонентов, эти объекты имеют общую информацию и комбинация различных процессов может создавать сложные взаимодействия между игровыми объектами, при этом сохраняя каждый процесс относительно простым.
Эта архитектура похожа на многие системы сущностей в гейм девелопменте. Она хорошо реализует принципы ООП и это работает. Но есть кое-что еще и это может свести вас с ума.
Избегая хорошей объектно-ориентированной практики
Текущая архитектура использует такие принципы объектно-ориентированного программирования, как инкапсуляция и разделение ответственности — IRenderable и IMovable закрывают значения и логику по ответственностям, обновляя игровые объекты каждый кадр. И композиция — объект космического корабля создается комбинированием имплементаций интерфейсов IRenderable и IMovable. Используя систему компонентов, мы уверены что, там, где нужно, значения одинаково доступны для различных объектов data-классов.
Следующий шаг эволюции систем объектов может показаться интуитивно непонятным и разрушающим тенденции самой сути объектно-ориентированноего програмиирования. Мы разломаем инкапсуляцию информации и логики в реализациях Renderable и Movable. В частности, мы переместим логику из этих классов в процессы.
Итак, это:
interface IRenderable { function render(); } class Renderable implements IRenderable { public var display:DisplayComponent; public var position:PositionComponent; public function render():void { display.view.x = position.x; display.view.y = position.y; display.view.rotation = position.rotation; } } class RenderProcess implements IProcess { private var targets:Vector.<IRenderable>; public function update( time:Number ):void { for each( var target:IRenderable in targets ) { target.render(); } } }
Станет этим:
class RenderData { public var display:DisplayComponent; public var position:PositionComponent; } class RenderProcess implements IProcess { private var targets:Vector.<RenderData>; public function update( time:Number ):void { for each( var target:RenderData in targets ) { target.display.view.x = target.position.x; target.display.view.y = target.position.y; target.display.view.rotation = target.position.rotation; } } }
А это:
interface IMoveable { function move( time:Number ); } class Moveable implements IMoveable { public var position:PositionComponent; public var velocity:VelocityComponent; public function move( time:Number ):void { position.x += velocity.velocityX * time; position.y += velocity.velocityY * time; position.rotation += velocity.angularVelocity * time; } } class MoveProcess implements IProcess { private var targets:Vector.<IMoveable>; public function move( time:Number ):void { for each( var target:Moveable in targets ) { target.move( time ); } } }
Станет этим:
class MoveData { public var position:PositionComponent; public var velocity:VelocityComponent; } class MoveProcess implements IProcess { private var targets:Vector.<MoveData>; public function move( time:Number ):void { for each( var target:MoveData in targets ) { target.position.x += target.velocity.velocityX * time; target.position.y += target.velocity.velocityY * time; target.position.rotation += target.velocity.angularVelocity * time; } } }
Сразу может быть не очевидно, зачем мы это натворили, но доверьтесь мне. Мы избавились от потребности в интерфейсах и процессы сейчас делают нечто более важное — вместо просто того, чтобы просто делегировать свою работу в реализации IRenderable или IMovable, он делает эту работу сам.
Первый очевидный вывод заключается в том, что все сущности должны иметь одинаковый метод отрисовки, с тех пор как код рендера теперь в RenderProcess. Но не только в этом суть. Мы можем, к примеру, создать два процесса, RenderMovieClip и RenderBitmap и они могут оперировать разными наборами сущностей. Таким образом, мы не потеряем в гибкости кода.
То, что мы получаем — это способность существенно отрефакторить наши сущности, чтобы получить архитектуру с более понятным разделением и простой конфигурацией. Рефакторинг начнется с вопроса.
Нужны ли нам классы-значения?
В настоящий момент наша сущность
class Spaceship { public var moveData:MoveData; public var renderData:RenderData; }
Содержит два класса
class MoveData { public var position:PositionComponent; public var velocity:VelocityComponent; } class RenderData { public var display:DisplayComponent; public var position:PositionComponent; }
Эти классы с данными содержат три компонента:
class PositionComponent { public var x:Number; public var y:Number; public var rotation:Number; } class VelocityComponent { public var velocityX:Number; public var velocityY:Number; public var angularVelocity:Number; } class DisplayComponent { public var view:DisplayObject; }
И эти классы-значения используются в двух процессах:
class MoveProcess implements IProcess { private var targets:Vector.<MoveData>; public function move( time:Number ):void { for each( var target:MoveData in targets ) { target.position.x += target.velocity.velocityX * time; target.position.y += target.velocity.velocityY * time; target.position.rotation += target.velocity.angularVelocity * time; } } } class RenderProcess implements IProcess { private var targets:Vector.<RenderData>; public function update( time:Number ):void { for each( var target:RenderData in targets ) { target.display.view.x = target.position.x; target.display.view.y = target.position.y; target.display.view.rotation = target.position.rotation; } } }
Но сущность не должны заботить классы данных. Все компоненты содержат состояние самой сущности. Классы данных существуют для удобства процессов. Мы отрефакторим код так, чтобы класс Spaceship содержал сами компоненты вместо классов данных.
class Spaceship { public var position:PositionComponent; public var velocity:VelocityComponent; public var display:DisplayComponent; } class PositionComponent { public var x:Number; public var y:Number; public var rotation:Number; } class VelocityComponent { public var velocityX:Number; public var velocityY:Number; public var angularVelocity:Number; } class DisplayComponent { public var view:DisplayObject; }
Избавляясь от классов информации и используя вместо них составные компоненты чтобы определить космический корабль, мы убрали всякую необходимость для сущности знать, какие процессы могут на него влиять. Корабль теперь содержит компоненты, которые и определяют его состояние. Любая необходимость комбинировать эти компоненты в другие классы-значения теперь переходит под ответственность других классов.
Системы и узлы.
Некая часть ядра фреймворка Entity System (к которой мы перейдем через минуту) будет динамически создавать эти объекты так, как это нужно для процессов. В этом упрощенном контексте, классы значений будут ни чем иным, как узлами, или листьями, в коллекциях (массивах, связных списках или любых других), которые используют процессы. Так что для ясности мы переименуем их в узлы.
class MoveNode { public var position:PositionComponent; public var velocity:VelocityComponent; } class RenderNode { public var display:DisplayComponent; public var position:PositionComponent; }
Сами процессы не поменяются, но, сохраняя более общие правила наименования, мы переименуем их в системы.
class MoveSystem implements ISystem { private var targets:Vector.<MoveNode>; public function update( time:Number ):void { for each( var target:MoveNode in targets ) { target.position.x += target.velocity.velocityX * time; target.position.y += target.velocity.velocityY * time; target.position.rotation += target.velocity.angularVelocity * time; } } } class RenderSystem implements ISystem { private var targets:Vector.<RenderNode>; public function update( time:Number ):void { for each( var target:RenderNode in targets ) { target.display.view.x = target.position.x; target.display.view.y = target.position.y; target.display.view.rotation = target.position.rotation; } } } interface ISystem { function update( time:Number ):void; }
И чем же является сущность?
Одно последнее изменение — в классе Spaceship нет ничего особенного. Это просто контейнер для компонентов. Так что просто назовем его Entity и дадим ему массив компонентов. Мы можем получить доступ к этим компонентам через их тип класса (а теперь покажите мне это на плюсах — прим. переводчика).
class Entity { private var components : Dictionary; public function add( component:Object ):void { var componentClass : Class = component.constructor; components[ componentClass ] = component' } public function remove( componentClass:Class ):void { delete components[ componentClass ]; } public function get( componentClass:Class ):Object { return components[ componentClass ]; } }
И вот так мы будем создавать наши космические корабли:
public function createSpaceship():void { var spaceship:Entity = new Entity(); var position:PositionComponent = new PositionComponent(); position.x = Stage.stageWidth / 2; position.y = Stage.stageHeight / 2; position.rotation = 0; spaceship.add( position ); var display:DisplayComponent = new DisplayComponent(); display.view = new SpaceshipImage(); spaceship.add( display ); engine.add( spaceship ); }
Класс ядра движка
Мы не должны забывать о менеджере систем, ранее известном как менеджер процессов:
class SystemManager { private var systems:PrioritisedList; public function addSystem( system:ISystem, priority:int ):void { systems.add( system, priority ); system.start(); } public function update( time:Number ):void { for each( var system:ISystem in systemes ) { system.update( time ); } } public function removeSystem( system:ISystem ):void { system.end(); systems.remove( system ); } }
Этот класс будет расширен и станет сердцем нашего фреймворка. Мы добавим к его функциональности вышеупомянутую возможность динамически создавать узлы для систем.
Сущности имеют дело только с компонентами, а системы — только с узлами. И, чтобы завершить наш фреймворк сущностей и систем (застрелите меня, но я не знаю, как лучше это перевести — прим. переводчика), мы должны закодить наблюдение за сущностями и при их изменении добавлять или удалять компоненты в коллекции узлов, используемых системами. Поскольку этот кусочек кода знает и о сущностях, и о системах, мы поставим его прямо в центр игры. Я называю его классом движка и это и есть расширенная версия менеджера систем.
Каждая сущность и каждая система добавляется и удаляется из класса движка когда мы начинаем и заканчиваем ее использовать. Движок отслеживает компоненты и сущности и создает и удаляет узлы по мере потребности, добавляя эти узлы в массивы. Движок также обеспечивает доступ систем к коллекциям узлов, которые им нужны.
public class Engine { private var entities:EntityList; private var systems:SystemList; private var nodeLists:Dictionary; public function addEntity( entity:Entity ):void { entities.add( entity ); // создание узлов из компонентов сущностей и добавление их в список узлов // наблюдение за последующим добавлением и удалением компонентов к // сущности и соответствующие изменения в зависимых узлах } public function removeEntity( entity:Entity ):void { // удаление узлов, содержащих компоненты сущности // и их извлечение из списка узлов entities.remove( entity ); } public function addSystem( system:System, priority:int ):void { systems.add( system, priority ); system.start(); } public function removeSystem( system:System ):void { system.end(); systems.remove( system ); } public function getNodeList( nodeClass:Class ):NodeList { var nodes:NodeList = new NodeList(); nodeLists[ nodeClass ] = nodes; // create the nodes from the current set of entities // and populate the node list return nodes; } public function update( time:Number ):void { for each( var system:ISystem in systemes ) { system.update( time ); } } }
Чтобы посмотреть, как это выглядит в деле, скачайте мой Ash Entity System Framework и посмотрите реализацию игры Asteroids.
Заключение
Кратко говоря, Entity Systems произошли из-за желания упростить игровой цикл. Из этого получилась архитектура сущностей, которая представляет состояние игры, и системы, которые оперируют состояниями в игре. Системы обновляются каждый кадр — это и есть игровой цикл. Сущности сделаны из компонентов и системы оперируют только сущностями, имеющими необходимые для них (систем) компоненты. Движок мониторит системы и сущности, и обеспечивает каждой системе доступ к коллекции тех сущностей, которые имеют необходимые компоненты.
Однако системы в действительности не занимаются в сущностями в целом, а только компонентами, из которых они состоят. Для оптимизации архитектуры и обеспечения дополнительной ясности, системы оперируют узлами, которые содержат необходимые компонены, где все эти компоненты принадлежат к одной сущности.
Entity System Framework обеспечивает простейший каркас для подобной архитектуры, без предоставления каких-либо классов сущностей или систем. Вы строите свою игру, создавая необходимые сущности и системы.
Переведено Профессиональными Программистами:
Очепятки, хъюсизмы и лучшие варианты перевода приветствуются в ЛС 🙂
ссылка на оригинал статьи http://habrahabr.ru/post/197920/
Добавить комментарий