Olá Habr! Apresento a você a tradução do wiki do projeto
Svelto.ECS , escrito por Sebastiano Mandalà.
O Svelto.ECS é o resultado de muitos anos de pesquisa e aplicação dos princípios do SOLID no desenvolvimento de jogos no Unity. Essa é uma das muitas implementações do padrão ECS disponíveis para C #, com vários recursos exclusivos introduzidos para solucionar as deficiências do próprio padrão.
Primeiro olhar
A maneira mais fácil de ver os recursos básicos do Svelto.ECS é baixar o
Vanilla Example . Se você quiser garantir sua facilidade de uso, mostrarei um exemplo:
Infelizmente, não é possível entender rapidamente a teoria por trás desse código, que pode parecer simples, mas confusa ao mesmo tempo. Para entender isso, você precisa gastar tempo lendo o “mural de texto” e tente os exemplos acima.
1. Introdução
Recentemente, tenho discutido
muito o Svelto.ECS com vários programadores, mais ou menos experientes. Reuni muitos comentários e fiz várias anotações que vou usar como ponto de partida para meus próximos artigos, onde falarei mais sobre teoria e boas práticas. Um pequeno spoiler: percebi que quando você começa a usar o Svelto.ECS, o maior obstáculo é
mudar o paradigma de programação . É incrível o quanto eu tenho que escrever para explicar os novos conceitos introduzidos pelo Svelto.ECS, em comparação com a pequena quantidade de código escrita para desenvolver a estrutura. De fato, embora a estrutura em si seja muito simples e leve, a transição do OOP com o uso ativo de herança ou componentes convencionais do Unity para o "novo" design modular e fracamente acoplado que o Svelto.ECS sugere que o uso impede que as pessoas se adaptem à estrutura.
O Svelto.ECS é usado ativamente no
Freejam (nota do tradutor - O autor é o diretor técnico desta empresa). Como sempre posso explicar aos meus colegas os conceitos básicos da estrutura, eles levam menos tempo para entender como trabalhar com ela. Embora o Svelto.ECS seja o mais difícil possível, é difícil superar os maus hábitos; portanto, os usuários tendem a abusar de alguma flexibilidade que lhes permite adaptar a estrutura aos “velhos” paradigmas com os quais se sentem confortáveis. Isso pode levar a um desastre devido a mal-entendidos ou distorção dos conceitos subjacentes à lógica da estrutura. É por isso que pretendo escrever o maior número possível de artigos, principalmente porque tenho certeza de que o paradigma ECS é a melhor solução no momento para escrever código eficaz e suportado para grandes projetos que mudam e retrabalham várias vezes ao longo de vários anos.
Robocraft e
Cardlife são a prova disso.
Não vou falar muito sobre as teorias subjacentes a este artigo. Apenas lembrarei por que me recusei a usar o
contêiner de IoC e comecei a usar exclusivamente a estrutura do ECS: O contêiner de IoC é uma ferramenta muito perigosa se for usado sem entender a essência da inversão de controle. Como você pode ver nos meus artigos anteriores, eu distingo entre a inversão do controle de criação (Inversão do controle de criação) e a inversão do controle de fluxo (Inversão do controle de fluxo). A inversão do controle de fluxo é como o princípio de Hollywood: "Não nos ligue, nós ligaremos para você". Isso significa que as dependências injetadas nunca devem ser usadas diretamente por meio de métodos públicos, pois, ao fazer isso, você simplesmente usa o contêiner de IoC como substituto de qualquer outra forma de injeção global, como o singleton. No entanto, se o contêiner de IoC for usado com base na Inversão de gerenciamento (IoC), basicamente tudo se resume a reutilizar o padrão "Método de modelo" para implementar gerentes que são usados apenas para registrar objetos que gerenciam. No contexto real de inversões de controle de fluxo, os gerentes são sempre responsáveis pelo gerenciamento de entidades. Isso se parece com um padrão ECS? Claro. Com base nesse raciocínio, peguei o padrão ECS e desenvolvi uma estrutura rígida baseada nele, e seu uso é equivalente à aplicação do novo paradigma de programação.
Raiz e motores de composiçãoRoot
A classe Main é a raiz da composição do aplicativo. A raiz da composição é o local onde as dependências são criadas e implementadas (falei bastante sobre isso em meus artigos). Uma raiz de composição pertence a um contexto, mas um contexto pode ter mais de uma raiz de composição. Por exemplo, a fábrica é a raiz da composição. Um aplicativo pode ter mais de um contexto, mas este é um cenário avançado e, neste exemplo, não o consideraremos.
Antes de mergulhar no código, vamos nos familiarizar com as primeiras regras da linguagem Svelto.ECS. ECS é a abreviatura Entity Component System. A infraestrutura da ECS foi bem analisada em artigos por muitos autores, mas, embora os conceitos básicos sejam comuns, as implementações variam amplamente. Primeiro de tudo, não há uma maneira padrão de resolver alguns problemas que surgem ao usar código orientado a ECS. É com relação a essa questão que faço a maior parte de meus esforços, mas falarei sobre isso mais tarde ou nos seguintes artigos. A teoria é baseada nos conceitos de Essência, Componentes (entidades) e Sistemas. Embora eu entenda por que a palavra Sistema foi usada historicamente, desde o início não a achei intuitiva o suficiente para esse fim, usei o Mecanismo como sinônimo para o Sistema, e você, dependendo de suas preferências, pode usar um desses termos.
A classe EnginesRoot é o núcleo do Svelto.ECS. Com sua ajuda, você pode registrar mecanismos e projetar toda a essência do jogo. Criar mecanismos dinamicamente não faz muito sentido; portanto, todos devem ser adicionados à instância EnginesRoot a partir da mesma raiz da composição em que foi criada. Por razões semelhantes, uma instância EnginesRoot nunca deve ser implantada e os mecanismos não devem ser excluídos após serem adicionados.
Para criar e implementar dependências, precisamos de pelo menos uma raiz da composição. Sim, em um aplicativo pode haver mais de um EnginesRoot, mas não abordaremos isso no artigo atual, que tento simplificar o máximo possível. Aqui está a aparência da raiz da composição com a criação do mecanismo e a injeção de dependência:
void SetupEnginesAndEntities() {
Este código é do exemplo de Sobrevivência, que agora é comentado e está em conformidade com quase todas as regras de boas práticas que proponho aplicar, incluindo o uso de lógica de mecanismo testada e independente de plataforma. Os comentários ajudarão você a entender a maioria deles, mas um projeto desse tamanho pode ser difícil de entender se você é novo na Svelto.
Entidades
A primeira etapa após criar a raiz vazia da composição e uma instância da classe EnginesRoot é identificar os objetos com os quais você deseja trabalhar primeiro. É lógico começar com o Entity Player. A essência do Svelto.ECS não deve ser confundida com o Unity Game Object (GameObject). Se você ler outros artigos relacionados ao ECS, poderá ver que em muitos deles, as entidades são frequentemente descritas como índices. Esta é provavelmente a pior maneira de introduzir o conceito de ECS. Embora verdadeiro para Svelto.ECS, ele está oculto nele. Desejo que o usuário do Svelto.ECS represente, descreva e identifique cada entidade em termos do idioma do domínio do design de jogos. A entidade no código deve ser o objeto descrito no documento de design do jogo. Qualquer outra forma de definição de entidade levará a uma maneira absurda de adaptar suas visualizações antigas aos princípios do Svelto.ECS. Siga esta regra fundamental e você não se enganará. A própria classe de entidade não existe no código, mas você ainda não deve defini-la abstratamente.
Motores
O próximo passo é pensar em qual comportamento pedir às Entidades. Cada comportamento é sempre modelado dentro do mecanismo; você não pode adicionar lógica a nenhuma outra classe dentro do aplicativo Svelto.ECS. Podemos começar movendo o personagem do jogador e definindo a classe
PlayerMovementEngine . O nome do mecanismo deve ser muito restrito, porque quanto mais específico, maior a probabilidade de o mecanismo seguir a Regra de responsabilidade única. A nomeação de classe adequada no Svelto.ECS é fundamental. E o objetivo não é apenas mostrar claramente suas intenções, mas também ajudá-lo a "vê-las".
Pelo mesmo motivo, é importante que seu mecanismo esteja em um espaço para nome muito especializado. Se você definir espaços para nome de acordo com a estrutura da pasta, adapte-se aos conceitos do Svelto.ECS. O uso de espaços para nome específicos ajuda a detectar erros de design quando entidades são usadas dentro de espaços para nome incompatíveis. Por exemplo, não se supõe que qualquer objeto inimigo seja usado dentro do espaço de nome do jogador, a menos que o objetivo seja violar as regras associadas à modularidade e ao acoplamento fraco de objetos. A idéia é que os objetos de um espaço para nome específico possam ser usados apenas dentro dele ou no espaço para nome pai. É muito mais difícil usar o Svelto.ECS para transformar seu código em espaguete, onde as dependências são injetadas à direita e à esquerda, e essa regra o ajudará a aumentar ainda mais a qualidade da barra de código quando as dependências forem abstraídas corretamente entre as classes.
No Svelto.ECS, a abstração avança algumas linhas, mas o ECS essencialmente ajuda a abstrair dados da lógica que deve processar os dados. As entidades são determinadas por seus dados, não por seu comportamento. Nesse caso, os mecanismos são um lugar onde você pode colocar o comportamento conjunto de entidades idênticas para que os mecanismos possam sempre trabalhar com um conjunto de entidades.
O Svelto.ECS e o paradigma ECS permitem ao codificador alcançar um dos santos grails da pura programação, que é o encapsulamento ideal da lógica. Os motores não devem ter funções públicas. As únicas funções públicas que devem existir são aquelas necessárias para implementar as interfaces da estrutura. Isso leva ao esquecimento da injeção de dependência e ajuda a evitar códigos incorretos que ocorrem ao usar a injeção de dependência sem inversão de controle. Os motores NUNCA devem ser incorporados a qualquer outro mecanismo ou qualquer outro tipo de classe. Se você pensa em implementar o mecanismo, simplesmente comete um erro fundamental no design do código.
Comparado ao Unity MonoBehaviours, os mecanismos já mostram a primeira grande vantagem, que é a capacidade de acessar todos os estados de entidades desse tipo a partir da mesma área de código. Isso significa que o código pode facilmente usar o estado de todos os objetos diretamente do mesmo local em que a lógica do objeto comum será executada. Além disso, os mecanismos individuais podem processar os mesmos objetos para que o mecanismo possa alterar o estado do objeto, enquanto o outro mecanismo pode lê-lo, usando efetivamente dois mecanismos para comunicação através dos mesmos dados da entidade. Um exemplo pode ser visto nos
mecanismos PlayerGunShootingEngine e
PlayerGunShootingFxsEngine . Nesse caso, dois mecanismos estão no mesmo espaço para nome, para que eles possam compartilhar os mesmos dados da entidade.
PlayerGunShootingEngine determina se um jogador (inimigo) foi danificado e grava o valor
lastTargetPosition do componente
IGunAttributesComponent (que é um componente
PlayerGunEntity ).
PlayerGunShootFxsEngine processa os efeitos gráficos da arma e lê a posição do alvo selecionado pelo jogador. Este é um exemplo de interação entre mecanismos por meio de pesquisa de dados. Mais adiante neste artigo, mostrarei como permitir que um mecanismo se comunique entre eles
pressionando dados (envio de dados) ou
ligação de dados (ligação de dados) . Logicamente, os mecanismos nunca devem armazenar estado.
Os mecanismos não precisam saber como interagir com outros mecanismos. A comunicação externa ocorre através da abstração, e o Svelto.ECS resolve a conexão entre os mecanismos de três maneiras oficiais diferentes, mas falarei sobre isso mais tarde. Os melhores mecanismos são aqueles que não requerem nenhuma comunicação externa. Esses mecanismos refletem um comportamento bem encapsulado e geralmente funcionam através de um loop lógico. Os loops são sempre modelados usando as tarefas Svelto.Task nos aplicativos Svelto.ECS. Como o movimento do jogador precisa ser atualizado a cada escala física, seria natural criar uma tarefa a ser executada em cada escala física. O Svelto.Tasks permite executar cada tipo de
IEnumerator em vários tipos de agendadores. Nesse caso, decidimos criar uma tarefa no
PhysicScheduler , que permite atualizar a posição do jogador:
public PlayerMovementEngine(IRayCaster raycaster, ITime time) { _rayCaster = raycaster; _time = time; _taskRoutine = TaskRunner.Instance.AllocateNewTaskRoutine() .SetEnumerator(PhysicsTick()).SetScheduler(StandardSchedulers.physicScheduler); } protected override void Add(PlayerEntityView entityView) { _taskRoutine.Start(); } protected override void Remove(PlayerEntityView entityView) { _taskRoutine.Stop(); } IEnumerator PhysicsTick() {
As tarefas do Svelto.Tasks podem ser executadas diretamente ou por
meio de objetos
ITaskRoutine . Não vou falar muito sobre Svelto. Tarefas aqui, pois escrevi outros artigos para ele. O motivo pelo qual decidi usar a rotina de tarefas em vez de iniciar a implementação do IEnumerator diretamente é bastante discricionário. Eu queria mostrar que você pode iniciar um ciclo quando o objeto de um jogador é adicionado ao mecanismo e pará-lo quando ele é excluído. , .
Svelto.ECS
, , . Svelto.ECS, . , , , . , .
,
SingleEntityViewEngine ,
MultiEntitiesViewEngine <EntityView1, ..., EntityViewN> . - , , .
IQueryingEntityViewEngine . . , - , , , , , , - . , , . , , .
EnemyMovementEngine , :
public void Ready() { Tick().Run(); } IEnumerator Tick() { while (true) { var enemyTargetEntityViews = entityViewsDB.QueryEntityViews<EnemyTargetEntityView>(); if (enemyTargetEntityViews.Count > 0) { var targetEntityView = enemyTargetEntityViews[0]; var enemies = entityViewsDB.QueryEntityViews<EnemyEntityView>(); for (var i = 0; i < enemies.Count; i++) { var component = enemies[i].movementComponent; component.navMeshDestination = targetEntityView.targetPositionComponent.position; } } yield return null; } }
.
Tick ().Run() IEnumerator Svelto.Tasks. IEnumerator , Enemy. , ( ), . Enemy Target ( !), , - . , Unity Nav Mesh System, , , NavMesh. , Unity NavMesh, , , Survival.
, Navmesh Unity. , , . ,
navMeshDestination Unity Nav Mesh.
, , , , . , , - , , .
, , . , 5 , Svelto.ECS, , , .
(Node) (,
ECS Ash ), , “” . EntityView , ,
(Model View Controller), Svelto.ECS View, EntityView — , . , , EntityMap, EntityView , . Svelto.ECS :

, . EntityViews. EntityViews, EntityViews. , Player, ,
PlayerEntityView . , , . EntityView . , ( . .),
PlayerPhysicEngine PlayerPhysicEntityView ,
PlayerGraphicEngine PlayerGraphicEntityView PlayerAnimationEngine PlayerAnimationEntityView . ,
PlayerPhysicMovementEngine PlayerPhysicJumpEngine ( . .).
, , , , . , EntityView — , (public) . , , :
, — . , Svelto.ECS. . .
« » (Interface Segregation Principle), , , , . ITransformComponent . , , ( , ).
Svelto.ECS , EntityView .
«». , ., . , ref, . , (data oriented), , . , ( !) . , , — , Unity. , Survival, , , Unity.
, . , , . , , EntityView — , . , , . , ECS. , . — , , . , , — . EntityDescriptor , . Player
PlayerEntityDescriptor . , , , - , ,
BuildEntity<PlayerEntityDescriptor>() , .
, EntityDescriptor, — EntityViews!!! EntityViews , , , .
PlayerEntityDescriptor :
using Svelto.ECS.Example.Survive.Camera; using Svelto.ECS.Example.Survive.HUD; using Svelto.ECS.Example.Survive.Enemies; using Svelto.ECS.Example.Survive.Sound; namespace Svelto.ECS.Example.Survive.Player { public class PlayerEntityDescriptor : GenericEntityDescriptor<HUDDamageEntityView, PlayerEntityView, EnemyTargetEntityView, DamageSoundEntityView, HealthEntityView, CameraTargetEntityView> { } }
( ) , .
PlayerEntityDescriptor EntityViews PlayerEntity.
EntityDescriptorHolder
EntityDescriptorHolder Unity . , Unity GameObject. , . , Robocraft , . . , GameObject MonoBehaviour's. , EntityDescriptorHolders , Svelto.ECS, . , :
void BuildEntitiesFromScene(UnityContext contextHolder) {
, ,
BuildEntity . . MonoBehaviour GameObject. . , , . , , MonoBehaviours , !
, Svelto.ECS,
. , , C# . , , «». :
- , .
- , , .
- . , .
- Svelto.ECS (third party) . . Unity, , , Monobehaviour . , Unity, OnTriggerEnter / OnTriggerExit , Unity. , . :
public class EnemyTriggerImplementor : MonoBehaviour, IImplementor, IEnemyTriggerComponent, IEnemyTargetComponent { public event Action<int, int, bool> entityInRange; bool IEnemyTriggerComponent.targetInRange { set { _targetInRange = value; } } bool IEnemyTargetComponent.targetInRange { get { return _targetInRange; } } void OnTriggerEnter(Collider other) { if (entityInRange != null) entityInRange(other.gameObject.GetInstanceID(), gameObject.GetInstanceID(), true); } void OnTriggerExit(Collider other) { if (entityInRange != null) entityInRange(other.gameObject.GetInstanceID(), gameObject.GetInstanceID(), false); } bool _targetInRange; }
, , . , .,
,
EnginesRoot ,
,
,
. .
(Entity Factory), EnginesRoot
GenerateEntityFactory . EnginesRoot IEntityFactory . , IEntityFactory .
IEntityFactory .
PreallocateEntitySlots BuildMetaEntity ,
BuildEntity BuildEntityInGroup .
BuildEntityInGroup , Survival , ,
BuildEntity :
IEnumerator IntervaledTick() {
, Svelto.ECS. -
BuildEntityInGroup , . Robocraft , , . , , , , — . , Svelto.Tasks , .
, , , … ( ):
MonoBehaviour . . , , . , . , , . , , , .
MonoBehaviour, . MonoBehaviour . , , json- , .
Svelto.ECS
, ECS, — . , , Svelto.ECS . — / , .
DispatchOnSet / DispatchOnChange
, (Data polling).
DispatchOnSet DispatchOnChange ( ), , T . , (Push) , , . , , , , .
DispatchOnSet DispatchOnChange , . , , , . Survival , ,
targetHit IGunHitTargetComponent .
DispatchOnSet DispatchOnChange , , , .
, Svelto.Tasks (IEnumerators). , . ,
DispatchOnSet DispatchOnChange , , , “” , . , , , , . , ! . IEnumerator Svelto Tasks «» «» .
/
, Svelto.ECS Svelto.ECS. , , , Svelto.ECS, , , , .