Друзья, это начало нового цикла статей про создание игры жанра dungeon crawler с использованием фреймворка LeoECS Lite, и его задача – помочь вам быстро разобраться, как на практике применить LeoECS Lite для разработки игр на Unity и решить некоторые виды проблем.
LeoECS Lite - новая, более легковесная версия фреймворка LeoECS, о котором отдельно написаны туториалы. Пусть слово "Lite" не вводит вас в заблуждение – она подчеркивает именно легковесность фреймворка, а не простоту использования. Хотя сам по себе он простой, он может быть непривычным для тех, кто привык к API классической версии.
Список ключевых изменений в API
Чтобы добавить/удалить компонент у сущности, вам необходимо сначала получить доступ к пулу компонентов.
Dependency Injection через рефлекшн убран из ядра, поэтому вы не можете сразу пользоваться ссылками на мир, фильтры или новые пулы в экземплярах систем - нужно или запрашивать их в рантайме, или кешировать заранее.
Сущности теперь в чистом виде представляют собой обычные int'ы. Чтобы сохранить их где-то и иметь возможность проверить, не уничтожена ли энтити, нужно паковать их через мир.
Про остальные изменения можно прочитать в README репозитория.
С другой стороны, легковесность фреймворка заключается в том, что простым стало именно ядро. Оно стало модульным - большая часть фишек переехала в расширения, которые можно подключать опционально. С их помощью можно сделать приятный API, напоминающий классику, и вот какие мы будем использовать при создании игры: ecslite-di, ecslite-extended-systems, ecslite-unityeditor, ecslite-unity-ugui.
Итак, давайте перейдем к практике и начнем разработку с создания старптап-класса.
namespace Client {
sealed class Game : MonoBehaviour {
[SerializeField] SceneData _sceneData;
[SerializeField] Configuration _configuration;
EcsSystems _systems;
void Start () {
var world = new EcsWorld ();
_systems = new EcsSystems (world);
_systems
#if UNITY_EDITOR
.Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem ())
#endif
.Inject (_sceneData)
.Init ();
}
void Update () {
_systems?.Run ();
}
void OnDestroy () {
_systems?.Destroy ();
_systems?.GetWorld ()?.Destroy ();
_systems = null;
}
}
}
Для тех, кто знаком с LeoECS, изменений здесь не так уж и много. Мы будем использовать простой MonoBehaviour экземпляр класса SceneData, в котором будут храниться данные, связанные со сценой, а также класс Configuration, который является экземпляром Scriptable Object'а.
namespace Client {
sealed class SceneData : MonoBehaviour {
}
}
namespace Client {
[CreateAssetMenu]
sealed class Configuration : ScriptableObject {
// Ширина и высота сетки.
public int GridWidth;
public int Gridheight;
}
}
Первым делом нам нужна возможность создать карту клеток. Мы можем создать простой MonoBehaviour класс, который будет добавлен к префабу клетки. Благодаря нему мы сможем располагать клетки в сцене с определенным интервалом. При этом они будут магнититься к нужным местам, вычисляя правильные координаты.
namespace Client {
#if UNITY_EDITOR
[ExecuteAlways] // Код ниже должен исполняться всегда.
[SelectionBase] // Если вы кликнете на внутреннюю запчасть префаба, то выделится именно этот объект
#endif
sealed class CellView : MonoBehaviour {
public Transform Transform;
public float XzStep = 3f;
public float YStep = 1f;
void Awake () {
Transform = transform;
}
#if UNITY_EDITOR
void Update () {
if (!Application.isPlaying && Transform.hasChanged) {
var newPos = Vector3.zero;
var curPos = Transform.localPosition;
newPos.x = Mathf.RoundToInt (curPos.x / XzStep) * XzStep;
newPos.z = Mathf.RoundToInt (curPos.z / XzStep) * XzStep;
newPos.y = Mathf.RoundToInt (curPos.y / YStep) * YStep;
Transform.localPosition = newPos; // Магнитим клетку к сетке.
}
}
void OnDrawGizmos () {
var selected = Selection.Contains (gameObject); // Проверяем, выделен ли объект
Gizmos.color = selected ? Color.green : Color.cyan; // Если выделен, цвет гизмос будет зеленый, если нет - голубой
var yAdd = selected ? 0.02f : 0f; // Если выделен, то слегка приподнимем клетку, чтобы выделить ее
var curPos = Transform.localPosition; // Начинаем вычислять координаты квадрата
var leftDown = curPos - Vector3.right * XzStep / 2 - Vector3.forward * XzStep / 2 + Vector3.up * yAdd;
var leftUp = curPos - Vector3.right * XzStep / 2 + Vector3.forward * XzStep / 2 + Vector3.up * yAdd;
var rightDown = curPos + Vector3.right * XzStep / 2 - Vector3.forward * XzStep / 2 + Vector3.up * yAdd;
var rightUp = curPos + Vector3.right * XzStep / 2 + Vector3.forward * XzStep / 2 + Vector3.up * yAdd;
Gizmos.DrawLine (leftDown, leftUp); // Рисуем квадрат
Gizmos.DrawLine (leftUp, rightUp);
Gizmos.DrawLine (rightUp, rightDown);
Gizmos.DrawLine (rightDown, leftDown);
Gizmos.DrawSphere (curPos, 0.1f);
}
#endif
}
}
Как видите, мы также сделали удобное отображение клеток в окне редактора с Gizmos.
Нам нужно будет проинициализировать карту в самом начале, создав сущности для каждой клетки, на основе данных с уровня. То есть, левел дизайнер делает карту, размещает клетки, а мы в начале игры создаем сущности клеток и добавляем к ним компоненты.
Давайте вернемся к нашему классу SceneData и немного поменяем его:
namespace Client {
sealed class SceneData : MonoBehaviour {
public CellView[] Cells;
#if UNITY_EDITOR
[ContextMenu ("Find Cells")]
void FindCells () {
Cells = FindObjectsOfType<CellView> ();
Debug.Log ($"Successfully found {Cells.Length} cells!");
}
#endif
}
}
Благодаря этому контекстному меню мы сможем заранее найти и сохранить все клетки в массив. Конечно, можно делать это и на старте игры, но зачем его замедлять, если можно потратить пару минут времени на кеширование и ускорение старта?
Для того, чтобы хранить сетку, лучше создать отдельный сервис.
namespace Client {
sealed class GridService {
readonly int[] _cells;
readonly int _width;
readonly int _height;
public GridService (int width, int height) {
_cells = new int[width * height];
_width = width;
_height = height;
}
public (int, bool) GetCell (Int2 coords) {
var entity = _cells[_width * coords.Y + coords.X] - 1;
return (entity, entity >= 0);
}
public void AddCell (Int2 coords, int entity) {
_cells[_width * coords.Y + coords.X] = entity + 1;
}
}
}
Также создадим структуру Int2 для хранения двух целых чисел. Она будет более простая, чем штатный Vector2Int.
namespace Client {
struct Int2 {
public int X;
public int Y;
public Int2 (int x, int y) {
X = x;
Y = y;
}
public static Int2 operator + (Int2 a, Int2 b) {
return new Int2 (a.X + b.X, a.Y + b.Y);
}
public static Int2 operator * (Int2 a, int multiplier) {
return new Int2 (a.X * multiplier, a.Y * multiplier);
}
}
}
Можно, конечно, хранить все данные в компоненте на какой-то сущности, но удобнее будет создать сервис с API для добавления и получения клеток.
namespace Client {
// Компонент клетки.
struct Cell {
public CellView View;
}
}
Теперь давайте создадим инит-систему, которая будет заполнять данные сервиса.
namespace Client {
sealed class GridInitSystem : IEcsInitSystem {
readonly EcsCustomInject<GridService> _gs = default;
readonly EcsCustomInject<SceneData> _sceneData = default;
readonly EcsPoolInject<Cell> _cellPool = default;
public void Init (EcsSystems systems) {
var world = _cellPool.Value.GetWorld ();
for (var i = 0; i < _sceneData.Value.Cells.Length; i++) {
var cellView = _sceneData.Value.Cells[i];
var entity = world.NewEntity ();
ref var cell = ref _cellPool.Value.Add (entity);
var position = cellView.transform.position;
var x = (int) (position.x / cellView.XzStep);
var y = (int) (position.z / cellView.XzStep);
cell.View = cellView;
_gs.Value.AddCell (new Int2 (x, y), entity);
}
}
}
}
Отлично, мы соорудили сервис карты и собрали в нем данные о клетках на сцене.
Теперь давайте создадим отдельный сервис TimeService, - некую абстракцию от времени Unity - данные которого будем заполнять в начале каждого кадра.
namespace Client {
sealed class TimeService {
public float Time;
public float DeltaTime;
public float UnscaledDeltaTime;
public float UnscaledTime;
}
}
namespace Client {
sealed class TimeSystem : IEcsRunSystem {
readonly EcsCustomInject<TimeService> _ts = default;
public void Run (EcsSystems systems) {
_ts.Value.Time = Time.time;
_ts.Value.UnscaledTime = Time.unscaledTime;
_ts.Value.DeltaTime = Time.deltaTime;
_ts.Value.UnscaledDeltaTime = Time.unscaledDeltaTime;
}
}
}
Не забудьте обновить стартап, добавив туда создание экземпляров сервисов и новых систем:
namespace Client {
sealed class Game : MonoBehaviour {
[SerializeField] SceneData _sceneData;
[SerializeField] Configuration _configuration;
EcsSystems _systems;
void Start () {
var world = new EcsWorld ();
_systems = new EcsSystems (world);
var ts = new TimeService ();
var gs = new GridService (_configuration.GridWidth, _configuration.Gridheight);
_systems
.Add (new GridInitSystem ())
.Add (new TimeSystem ())
#if UNITY_EDITOR
.Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem ())
#endif
.Inject (ts, gs, _sceneData)
.Init ();
}
void Update () {
_systems?.Run ();
}
void OnDestroy () {
_systems?.Destroy ();
_systems?.GetWorld ()?.Destroy ();
_systems = null;
}
}
}
Теперь мы можем заняться спауном игрока.
namespace Client {
sealed class PlayerInitSystem : IEcsInitSystem {
readonly EcsPoolInject<Unit> _unitPool = default;
readonly EcsPoolInject<ControlledByPlayer> _controlledByPlayerPool = default;
public void Init (EcsSystems systems) {
var playerEntity = _unitPool.Value.GetWorld ().NewEntity ();
ref var unit = ref _unitPool.Value.Add (playerEntity);
_controlledByPlayerPool.Value.Add (playerEntity);
var playerPrefab = Resources.Load ("Player");
var playerGo = (GameObject) Object.Instantiate (playerPrefab, Vector3.zero, Quaternion.identity);
unit.Direction = 0;
unit.CellCoords = new Int2 (0, 0);
unit.Transform = playerGo.transform;
unit.Position = Vector3.zero;
unit.Rotation = Quaternion.identity;
// тестовые значения.
unit.MoveSpeed = 3f;
unit.RotateSpeed = 10f;
}
}
}
namespace Client {
struct Unit {
public Direction Direction;
public Int2 CellCoords;
public Transform Transform;
public Vector3 Position;
public Quaternion Rotation;
public float MoveSpeed;
public float RotateSpeed;
}
}
namespace Client {
struct ControlledByPlayer { }
}
Как вы заметили, к сущности игрока добавлены компоненты Unit и ControlledByPlayer. Почему именно так?
Дело в том, что в нашей игре и игрок, и монстры будут двигаться по одинаковым правилам. Логика (код) перемещения по клеткам будет одинаковый для всех юнитов. Единственное, что будет отличаться - источник команд. Юнит игрока будет принимать команды от пользовательского ввода, враги - от ИИ.
И даже если вы в силу неопытности этого не заметили бы и написали отдельно код и для игрока, и для врагов, повторяющиеся строки натолкнут вас на мысль о том, что имеет смысл вынести их в отдельный блок кода. И с ECS это все будет проще и быстрее отрефакторить, так как каждая сущность - набор компонентов.
Давайте сделаем аж три способа управления персонажем: через клавиатуру, через кнопки и через мышь. Но все по порядку.
Направления вперед-назад будут использоваться для движения, влево-вправо для поворотов.
Начнем с простого. Управление через клавиатуру. Создадим отдельную систему для этого.
namespace Client {
sealed class UserKeyboardInputSystem : IEcsRunSystem {
readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default;
readonly EcsPoolInject<MoveCommand> _moveCommandPool = default;
readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default;
public void Run (EcsSystems systems) {
foreach (var entity in _units.Value) {
var vertInput = Input.GetAxisRaw (Idents.Input.VerticalAxis);
var horizInput = Input.GetAxisRaw (Idents.Input.HorizontalAxis);
switch (vertInput) {
case 1f:
_moveCommandPool.Value.Add (entity);
break;
case -1f:
ref var moveCmd = ref _moveCommandPool.Value.Add (entity);
moveCmd.Backwards = true;
break;
}
if (horizInput != 0f) {
ref var rotCmd = ref _rotateCommandPool.Value.Add (entity);
rotCmd.Side = (int) horizInput;
}
}
}
}
}
namespace Client {
// Событие о команде движения
struct MoveCommand {
public bool Backwards;
}
}
namespace Client {
// И о повороте
struct RotateCommand {
public int Side;
}
}
Чтобы сохранить строки и иметь возможность быстро менять их везде, создадим статический класс Idents.
namespace Client {
static class Idents {
public static class Input {
public const string VerticalAxis = "Vertical";
public const string HorizontalAxis = "Horizontal";
}
}
}
Теперь займемся настройкой UI.
Создадим 4 кнопки и повесим на них нужные компоненты для обработки событий UI.
На корневой объект UI нужно будет добавить компонент EcsUguiEmitter. Давайте проинициализируем его в коде.
...
[SerializeField] EcsUguiEmitter _uguiEmitter; // новая строка в классе Game
...
...
.Inject (ts, gs, _sceneData)
.InjectUgui (_uguiEmitter)
.Init ();
...
В EcsLite рекомендуется использовать отдельный мир для короткоживущих энтити-ивентов, так как каждый мир имеет размер maxEntitiesCount * poolsCount. То есть, если у вас в мире 100 тысяч сущностей для юнитов, и вы вдруг создаете одну сущность-ивент с компонентом "Click", то для этого компонента будет создан пул с огромным размером, что в конечном итоге приведет к нерациональному распределению памяти.
namespace Client {
sealed class Game : MonoBehaviour {
[SerializeField] SceneData _sceneData;
[SerializeField] Configuration _configuration;
[SerializeField] EcsUguiEmitter _uguiEmitter;
EcsSystems _systems;
void Start () {
var world = new EcsWorld ();
_systems = new EcsSystems (world);
var ts = new TimeService ();
var gs = new GridService (_configuration.GridWidth, _configuration.Gridheight);
_systems
.Add (new GridInitSystem ())
.Add (new TimeSystem ())
.Add (new PlayerInitSystem ())
.DelHere<MoveCommand> ()
.Add (new UserKeyboardInputSystem ())
.AddWorld (new EcsWorld (), Idents.Worlds.Events)
#if UNITY_EDITOR
.Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem ())
.Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem (Idents.Worlds.Events))
#endif
.Inject (ts, gs, _sceneData)
.InjectUgui (_uguiEmitter, Idents.Worlds.Events)
.Init ();
}
void Update () {
_systems?.Run ();
}
void OnDestroy () {
_systems?.Destroy ();
_systems?.GetWorld ()?.Destroy ();
_systems = null;
}
}
}
Добавим название мира для событий и названия кнопок в класс Idents.
namespace Client {
static class Idents {
public static class Input {
public const string VerticalAxis = "Vertical";
public const string HorizontalAxis = "Horizontal";
}
public static class Worlds {
public const string Events = "Events";
}
public static class Ui {
public const string Forward = "Forward";
public const string Back = "Back";
public const string Left = "Left";
public const string Right = "Right";
}
}
}
Теперь создадим систему, которая будет ловить события с кнопок.
namespace Client {
sealed class UserButtonsInputSystem : EcsUguiCallbackSystem {
readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default;
readonly EcsPoolInject<MoveCommand> _moveCommandPool = default;
readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default;
[Preserve]
[EcsUguiClickEvent (Idents.Ui.Forward, Idents.Worlds.Events)]
void OnClickForward (in EcsUguiClickEvent e) {
foreach (var entity in _units.Value) {
_moveCommandPool.Value.Add (entity);
}
}
[Preserve]
[EcsUguiClickEvent (Idents.Ui.Back, Idents.Worlds.Events)]
void OnClickBack (in EcsUguiClickEvent e) {
foreach (var entity in _units.Value) {
ref var moveCmd = ref _moveCommandPool.Value.Add (entity);
moveCmd.Backwards = true;
break;
}
}
[Preserve]
[EcsUguiClickEvent (Idents.Ui.Left, Idents.Worlds.Events)]
void OnClickLeft (in EcsUguiClickEvent e) {
foreach (var entity in _units.Value) {
ref var rotCmd = ref _rotateCommandPool.Value.Add (entity);
rotCmd.Side = -1;
}
}
[Preserve]
[EcsUguiClickEvent (Idents.Ui.Right, Idents.Worlds.Events)]
void OnClickRight (in EcsUguiClickEvent e) {
foreach (var entity in _units.Value) {
ref var rotCmd = ref _rotateCommandPool.Value.Add (entity);
rotCmd.Side = 1;
}
}
}
}
Осталось лишь создать систему свайпов. Создадим отдельный полноэкранный невидимый виджет для этого. Не забудьте, что нужно поместить его под кнопки, иначе клики в них не пойдут.
И новую систему:
namespace Client {
sealed class UserSwipeInputSystem : EcsUguiCallbackSystem {
readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default;
readonly EcsPoolInject<MoveCommand> _moveCommandPool = default;
readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default;
const float MinSwipeMagnitude = 0.2f;
Vector2 _lastTouchPos = default;
[Preserve]
[EcsUguiDownEvent (Idents.Ui.TouchListener, Idents.Worlds.Events)]
void OnDownTouchListener (in EcsUguiDownEvent e) {
_lastTouchPos = e.Position;
}
[Preserve]
[EcsUguiUpEvent (Idents.Ui.TouchListener, Idents.Worlds.Events)]
void OnUpTouchListener (in EcsUguiUpEvent e) {
var swipe = e.Position - _lastTouchPos;
var swipeHorizontal = swipe.x / Screen.width;
var swipeVertical = swipe.y / Screen.height;
if (Mathf.Abs (swipeVertical) >= MinSwipeMagnitude) {
foreach (var entity in _units.Value) {
ref var moveCmd = ref _moveCommandPool.Value.Add (entity);
moveCmd.Backwards = swipeVertical < 0f;
break;
}
} else if (Mathf.Abs (swipeHorizontal) >= MinSwipeMagnitude) {
foreach (var entity in _units.Value) {
ref var rotCmd = ref _rotateCommandPool.Value.Add (entity);
var side = swipeHorizontal > 0f ? 1 : -1;
rotCmd.Side = side;
}
}
}
}
}
Теперь создадим системы для движения и поворотов.
namespace Client {
sealed class UnitStartMovingSystem : IEcsRunSystem {
readonly EcsFilterInject<Inc<Unit, MoveCommand>, Exc<Animating>> _units = default;
readonly EcsPoolInject<Animating> _animatingPool = default;
readonly EcsPoolInject<Moving> _movingPool = default;
readonly EcsPoolInject<Cell> _cellPool = default;
readonly EcsCustomInject<GridService> _gs = default;
public void Run (EcsSystems systems) {
foreach (var entity in _units.Value) {
ref var unit = ref _units.Pools.Inc1.Get (entity);
ref var cmd = ref _units.Pools.Inc2.Get (entity);
var step = cmd.Backwards ? -1 : 1;
var pos3d = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f) * Vector3.forward;
var newCellCoords = unit.CellCoords + new Int2 (Mathf.RoundToInt (pos3d.x), Mathf.RoundToInt (pos3d.z)) * step;
var (newCell, ok) = _gs.Value.GetCell (newCellCoords);
if (ok) {
ref var cell = ref _cellPool.Value.Get (newCell);
_animatingPool.Value.Add (entity);
ref var moving = ref _movingPool.Value.Add (entity);
moving.Point = cell.View.Transform.localPosition;
unit.CellCoords = newCellCoords;
}
}
}
}
}
Компонент Animating - это маркер, говорящий о том, что юнит сейчас занят, и никто не может принимать команды.
namespace Client {
struct Animating { }
}
namespace Client {
struct Moving {
public Vector3 Point;
}
}
Теперь система твининга между клетками:
namespace Client {
sealed class UnitMoveSystem : IEcsRunSystem {
readonly EcsFilterInject<Inc<Unit, Moving>> _movingUnits = default;
readonly EcsPoolInject<Animating> _animatedPool = default;
readonly EcsCustomInject<TimeService> _ts = default;
const float DistanceToStop = 0.001f;
public void Run (EcsSystems systems) {
foreach (var entity in _movingUnits.Value) {
ref var unit = ref _movingUnits.Pools.Inc1.Get (entity);
ref var move = ref _movingUnits.Pools.Inc2.Get (entity);
unit.Position = Vector3.Lerp (unit.Position, move.Point, unit.MoveSpeed * _ts.Value.DeltaTime);
if ((unit.Position - move.Point).sqrMagnitude <= DistanceToStop) {
unit.Position = move.Point;
_animatedPool.Value.Del (entity);
_movingUnits.Pools.Inc2.Del (entity);
}
unit.Transform.localPosition = unit.Position;
}
}
}
}
Теперь система для начала поворотов:
namespace Client {
sealed class UnitStartRotatingSystem : IEcsRunSystem {
readonly EcsFilterInject<Inc<Unit, RotateCommand>, Exc<Animating>> _units = default;
readonly EcsPoolInject<Animating> _animatingPool = default;
readonly EcsPoolInject<Rotating> _rotatingPool = default;
public void Run (EcsSystems systems) {
foreach (var entity in _units.Value) {
ref var unit = ref _units.Pools.Inc1.Get (entity);
ref var rot = ref _units.Pools.Inc2.Get (entity);
var newDir = (int) unit.Direction + rot.Side;
if (newDir == -1) {
newDir += 4;
}
newDir %= 4;
unit.Direction = (Direction) newDir;
var actualDir = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f);
ref var rotating = ref _rotatingPool.Value.Add (entity);
_animatingPool.Value.Add (entity);
rotating.Target = actualDir;
}
}
}
}
И теперь система поворотов:
namespace Client {
sealed class UnitRotateSystem : IEcsRunSystem {
readonly EcsFilterInject<Inc<Unit, Rotating>> _rotatingUnits = default;
readonly EcsPoolInject<Animating> _animatedPool = default;
readonly EcsCustomInject<TimeService> _ts = default;
const float DiffToStop = 0.001f;
public void Run (EcsSystems systems) {
foreach (var entity in _rotatingUnits.Value) {
ref var unit = ref _rotatingUnits.Pools.Inc1.Get (entity);
ref var rotate = ref _rotatingUnits.Pools.Inc2.Get (entity);
unit.Rotation = Quaternion.Lerp (unit.Rotation, rotate.Target, _ts.Value.DeltaTime * unit.RotateSpeed);
if ((unit.Rotation.eulerAngles - rotate.Target.eulerAngles).sqrMagnitude <= DiffToStop) {
unit.Rotation = rotate.Target;
_animatedPool.Value.Del (entity);
_rotatingUnits.Pools.Inc2.Del (entity);
}
unit.Transform.localRotation = unit.Rotation;
}
}
}
}
И не забудьте добавить все системы в стартап.
Отлично, теперь наш герой движется и поворачивается!
В следующей части мы разберем более сложные механики и перейдем к разработке поведения врагов.
PaHDoMbI4
Хороший пост, как всегда.
С высоты своего бесконечного опыта работы с LeoECS Lite (4 месяца) хочу отметить, что использование EcsCustomInject на этапе разработки усложняет рефакторинг, потому что контролировать что куда заинжектил можно только через комментарий к соответствующей системе, лучше пользоваться внедрением через конструктор.
И уже отдельно для автора, вот такой класс в эдитор неймспейсе заменит твой CellView и позволит не насиловать юнити атрибутом [ExecuteAlways]
supremestranger Автор
Спасибо.
Ну инжект в системы внедряет все данные сразу во все экземпляры систем.
Насчет CellView, там атрибуты под дефайном и будут работать только в редакторе, так что по идее норм. Вообще, скорее всего стоит разделить компонент на два: один отвечает за снап, другой за отрисовку клеток, так как магнитить к сетке нужно будет, скорее всего, не только клетки.