Até agora, os sistemas de componentes de entidade que eu usei funcionaram principalmente como artemis de Java:
- Todos os dados nos componentes
- Sistemas independentes sem estado (pelo menos na medida em que eles não exigem entrada na inicialização) iterando sobre cada entidade que contém apenas os componentes nos quais o sistema em particular está interessado
- Todos os sistemas processam suas entidades um tique e depois tudo começa de novo.
Agora, estou tentando aplicar isso a um jogo baseado em turnos pela primeira vez, com vários eventos e respostas que devem ocorrer em uma ordem definida em relação um ao outro, antes que o jogo possa seguir em frente. Um exemplo:
O jogador A recebe dano de uma espada. Em resposta a isso, a armadura de A entra em ação e diminui o dano recebido. A velocidade de movimento de A também é reduzida como resultado de ficar mais fraca.
- O dano sofrido é o que desencadeia toda a interação
- A armadura deve ser calculada e aplicada ao dano recebido antes que o dano seja aplicado ao jogador
- A redução da velocidade de movimento não pode ser aplicada a uma unidade até que o dano tenha sido efetivamente causado, pois depende do valor final do dano.
Eventos também podem acionar outros eventos. Reduzir o dano da espada usando armadura pode fazer com que a espada se quebre (isso deve ocorrer antes que a redução de dano seja concluída), que por sua vez pode causar eventos adicionais em resposta a ela, essencialmente uma avaliação recursiva dos eventos.
Em suma, isso parece levar a alguns problemas:
- Muitos ciclos de processamento desperdiçados: a maioria dos sistemas (exceto itens que sempre são executados, como renderização) simplesmente não tem nada que valha a pena fazer quando não é a "vez deles" de trabalhar, e passa a maior parte do tempo esperando o jogo entrar um estado de trabalho válido. Isso afeta todo sistema com verificações que continuam crescendo em tamanho, à medida que mais estados são adicionados ao jogo.
- Para descobrir se um sistema pode processar entidades que estão presentes no jogo, ele precisa de alguma maneira de monitorar outros estados de entidade / sistema não relacionados (o sistema responsável por causar dano precisa saber se a armadura foi aplicada ou não). Isso atrapalha os sistemas com múltiplas responsabilidades ou cria a necessidade de sistemas adicionais sem outra finalidade além de varrer a coleção de entidades após cada ciclo de processamento e se comunicar com um conjunto de ouvintes, dizendo a eles quando é bom fazer alguma coisa.
Os dois pontos acima assumem que os sistemas funcionam no mesmo conjunto de entidades, que acabam mudando de estado usando sinalizadores em seus componentes.
Outra maneira de resolver isso seria adicionar / remover componentes (ou criar entidades inteiramente novas) como resultado de um único sistema trabalhar para progredir no estado dos jogos. Isso significa que sempre que um sistema realmente possui uma entidade correspondente, ele sabe que tem permissão para processá-lo.
No entanto, isso torna os sistemas responsáveis por acionar os sistemas subseqüentes, dificultando o raciocínio sobre o comportamento dos programas, uma vez que os bugs não aparecem como resultado de uma única interação do sistema. A adição de novos sistemas também fica mais difícil, pois eles não podem ser implementados sem saber exatamente como eles afetam outros sistemas (e os sistemas anteriores podem ter que ser modificados para acionar os estados nos quais o novo sistema está interessado), meio que frustrando o propósito de ter sistemas separados. com uma única tarefa.
Isso é algo que eu vou ter que viver? Todos os exemplos de ECS que eu vi foram em tempo real e é realmente fácil ver como esse loop de uma iteração por jogo funciona nesses casos. E eu ainda preciso disso para renderização, parece realmente inadequado para sistemas que pausam a maioria dos aspectos de si sempre que algo acontece.
Existe algum padrão de design para avançar o estado do jogo que seja adequado para isso, ou devo apenas mover toda a lógica para fora do loop e, em vez disso, acioná-la somente quando necessário?
fonte
Respostas:
Meu conselho aqui é proveniente de experiências anteriores em um projeto de RPG em que usamos um sistema de componentes. Eu direi que eu odiava trabalhar nesse código do lado do jogo porque era um código de espaguete. Portanto, não estou oferecendo muita resposta aqui, apenas uma perspectiva:
A lógica que você descreve para lidar com dano de espada a um jogador ... parece que um sistema deve estar encarregado de tudo isso.
Em algum lugar, há uma função HandleWeaponHit (). Ele acessaria o ArmorComponent da entidade jogador para obter a armadura relevante. Teria acesso ao WeaponComponent da entidade da arma atacante para talvez quebrar a arma. Depois de calcular o dano final, ele tocaria no MovementComponent para o jogador alcançar a redução de velocidade.
Quanto aos ciclos de processamento desperdiçados ... HandleWeaponHit () só deve ser acionado quando necessário (ao detectar o golpe da espada).
Talvez o ponto que estou tentando enfatizar seja: certamente você deseja um lugar no código onde possa colocar um ponto de interrupção, atingi-lo e depois prosseguir com toda a lógica que deve correr quando ocorrer um golpe de espada. Em outras palavras, a lógica não deve estar espalhada pelas funções tick () de vários sistemas.
fonte
É uma pergunta de um ano, mas agora estou enfrentando os mesmos problemas com meu jogo caseiro enquanto estudava ECS, portanto, um pouco de necromancia. Espero que acabe em uma discussão ou em pelo menos alguns comentários.
Não tenho certeza se isso viola os conceitos de ECS, mas e se:
Exemplo:
Prós:
Contras:
fonte
Publicando a solução em que finalmente decidi, semelhante à de Yakovlev.
Basicamente, acabei usando um sistema de eventos, pois achei muito intuitivo seguir sua lógica em turnos. O sistema acabou sendo responsável pelas unidades do jogo que aderiam à lógica baseada em turnos (jogador, monstros e qualquer coisa com a qual eles possam interagir); tarefas em tempo real, como renderização e pesquisa de entrada, foram colocadas em outro lugar.
Os sistemas implementam um método onEvent que recebe um evento e uma entidade como entrada, sinalizando que a entidade recebeu o evento. Todo sistema também assina eventos e entidades com um conjunto específico de componentes. O único ponto de interação disponível para os sistemas é o singleton do gerenciador de entidades, usado para enviar eventos para entidades e recuperar componentes de uma entidade específica.
Quando o gerente da entidade recebe um evento associado à entidade para a qual é enviado, coloca o evento na parte de trás de uma fila. Enquanto houver eventos na fila, o evento principal é recuperado e enviado para todos os sistemas que assinam o evento e estão interessados no conjunto de componentes da entidade que recebe o evento. Esses sistemas, por sua vez, podem processar os componentes da entidade, bem como enviar eventos adicionais ao gerente.
Exemplo: o jogador sofre dano, então a entidade do jogador recebe um evento de dano. O DamageSystem assina eventos de dano enviados a qualquer entidade com o componente de integridade e possui um método onEvent (entidade, evento) que reduz a saúde no componente de entidades pela quantidade especificada no evento.
Isso facilita a inserção de um sistema de armadura que assina eventos de dano enviados a entidades com um componente de armadura. Seu método onEvent reduz o dano no evento pela quantidade de armadura no componente. Isso significa que especificar a ordem em que os sistemas recebem eventos afeta a lógica do jogo, pois o sistema de armadura deve processar o evento de dano antes do sistema de dano para funcionar.
Às vezes, um sistema precisa sair da entidade receptora. Para continuar com minha resposta a Eric Undersander, seria trivial adicionar um sistema que acesse o mapa do jogo e procure entidades com o FallsDownLaughingComponent dentro de x espaços da entidade que está recebendo dano e envie um FallDownLaughingEvent para eles. Esse sistema teria que ser programado para receber o evento após o sistema de dano; se o evento de dano não tiver sido cancelado nesse ponto, o dano foi causado.
Um problema que surgiu foi como garantir que os eventos de resposta sejam processados na ordem em que são enviados, uma vez que algumas respostas podem gerar respostas adicionais. Exemplo:
O jogador se move, solicitando que um evento de movimentação seja enviado à entidade do jogador e apanhada pelo sistema de movimentação.
Na fila: Movimento
Se o movimento for permitido, o sistema ajustará a posição dos jogadores. Caso contrário (o jogador tentou entrar em um obstáculo), ele marca o evento como cancelado, fazendo com que o gerente da entidade o descarte em vez de enviá-lo para os sistemas subseqüentes. No final da lista de sistemas interessados no evento, está o TurnFinishedSystem, que confirma que o jogador gastou sua vez em mudar o personagem e que sua vez terminou. Isso resulta em um evento TurnOver sendo enviado para a entidade do jogador e colocado na fila.
Na fila: TurnOver
Agora diga que o jogador pisou em uma armadilha, causando danos. O TrapSystem recebe a mensagem de movimento antes do TurnFinishedSystem, para que o evento de dano seja enviado primeiro. Agora, a fila fica assim:
Na fila: Danos, TurnOver
Tudo está bem até agora, o evento de dano será processado e o turno terminará. No entanto, e se eventos adicionais forem enviados como resposta ao dano? Agora a fila de eventos ficaria assim:
Na fila: Danos, TurnOver, ResponseToDamage
Em outras palavras, o turno terminaria antes que qualquer resposta ao dano fosse processada.
Para resolver isso, acabei usando dois métodos de envio de eventos: send (evento, entidade) e responder (evento, eventToRespondTo, entidade).
Todo evento mantém um registro de eventos anteriores em uma cadeia de respostas e, sempre que o método respond () é usado, o evento que está sendo respondido (e todos os eventos em sua cadeia de respostas) termina na cabeça da cadeia no evento usado para responda com. O evento de movimento inicial não possui tais eventos. A resposta a danos subsequente tem o evento de movimento em sua lista.
Além disso, uma matriz de comprimento variável é usada para conter várias filas de eventos. Sempre que um evento é recebido pelo gerente, o evento é adicionado a uma fila em um índice na matriz que corresponde à quantidade de eventos na cadeia de resposta. Portanto, o evento de movimento inicial é adicionado à fila em [0], e os danos, bem como os eventos TurnOver, são adicionados a uma fila separada em [1], pois ambos foram enviados como respostas ao movimento.
Quando as respostas ao evento de dano são enviadas, esses eventos conterão o próprio evento de dano, bem como o movimento, colocando-os em uma fila no índice [2]. Enquanto o índice [n] tiver eventos em sua fila, esses eventos serão processados antes de passar para [n-1]. Isso fornece uma ordem de processamento de:
Movimento -> Dano [1] -> ResponseToDamage [2] -> [2] está vazio -> TurnOver [1] -> [1] está vazio -> [0] está vazio
fonte