Composição versus herança, padrão de equipe e desenvolvimento de jogos em geral


Isenção de responsabilidade: Na minha opinião, um artigo sobre arquitetura de software não deve e não pode ser o ideal. Qualquer solução descrita pode não cobrir o nível necessário para um programador e, para outro programador, complicará a arquitetura desnecessariamente. Mas deve dar uma solução para as tarefas que se propôs. E essa experiência, juntamente com toda a bagagem de conhecimento de um programador que aprende, organiza informações, aprimora os novatos e critica a si mesmo e aos outros - essa experiência se transforma em excelentes produtos de software. O artigo alternará entre a arte e a parte técnica. Este é um pequeno experimento e espero que seja interessante.
- Escute, eu tive uma ótima idéia de jogo! - o designer de jogos Vasya estava despenteado e seus olhos estavam vermelhos. Eu ainda bebi café e holivar em Habré para matar o tempo antes do confronto. Ele olhou para mim com expectativa até eu terminar de escrever nos comentários para o homem o que ele estava errado. Vasya sabia que até que a justiça prevalecesse e a verdade não fosse defendida, não havia sentido em continuar a conversa comigo. Eu adicionei a última frase e olhei para ele.

- Em poucas palavras - mágicos com mana podem lançar feitiços, e guerreiros podem lutar em combate corpo a corpo e gastar resistência. Mágicos e guerreiros podem se mover. Sim, ainda será possível roubar as vacas, mas já o faremos na próxima versão, em resumo. Você mostrará o protótipo após o stand-up, ok?

Ele fugiu do negócio de design de jogos e eu abri o IDE.

De fato, o tópico “composição versus herança”, “problema do macaco-banana”, “problema do losango (herança múltipla)” são perguntas comuns em entrevistas em diferentes formatos e por boas razões. O uso incorreto da herança pode complicar a arquitetura, e programadores inexperientes não sabem como lidar com isso e, como resultado, começam a criticar o OOP como um todo e começam a escrever código processual. Portanto, programadores experientes (ou aqueles que leram coisas inteligentes na Internet) consideram seu dever perguntar sobre essas coisas em uma entrevista de várias formas. A resposta universal é "a composição é melhor que a herança, e nenhum tom de cinza deve ser aplicado". Quem acabou de ler todas essas respostas ficará 100% satisfeito.

Mas, como eu disse no começo do artigo, cada arquitetura será adequada ao seu projeto e se a herança for suficiente para o seu projeto e tudo o que for necessário para resolver o problema é criar o Monkey with Banana - crie-o. Nosso programador estava em uma situação semelhante. Não faz sentido recusar herança apenas porque o FPS rirá de você.

class Character { x = 0; y = 0; moveTo (x, y) { this.x = x; this.y = y; } } class Mage extends Character { mana = 100; castSpell () { this.mana--; } } class Warrior extends Character { stamina = 100; meleeHit () { this.stamina--; } } 

O stand-up, como sempre, se arrastou. Eu me balancei em uma cadeira e desliguei o telefone enquanto June Petya tentava convencer o testador de que a impossibilidade de controle rápido com o botão direito do mouse não é um bug, porque não havia essa possibilidade em nenhum lugar, o que significa que você deve deixar a tarefa para o departamento de pré-produção. O testador argumentou que, como para os usuários o controle via botão direito parece obrigatório, isso é um bug, não um recurso. De fato, como o único jogador em nossa equipe jogando nosso jogo em servidores de batalha, ele queria adicionar esse recurso o mais rápido possível, mas sabia que se você o colocasse no departamento de pré-produção, a máquina burocrática deixaria que fosse lançada não antes de 4 meses e emitido como um bug - você pode obtê-lo já na próxima versão. O gerente de projeto, como sempre, estava atrasado, e os caras estavam amaldiçoando tão ferozmente que eles já haviam mudado para tapetes e, provavelmente, as coisas logo virariam uma briga se o diretor do estúdio não tivesse caído na maldição e não levado os dois ao seu escritório. Provavelmente novamente por 300 dólares multados.

Quando saí da sala de rally, um designer de jogos correu até mim e felizmente disse que todos gostaram do protótipo, eles o aceitaram e agora esse é o nosso novo projeto para os próximos seis meses. Enquanto estávamos caminhando em direção à minha mesa, ele contou com entusiasmo quais seriam os novos recursos em nosso jogo. Quantos feitiços diferentes ele inventou e, é claro, que haverá um paladino que pode lutar e lançar magia. E todo o departamento de artistas já está trabalhando em novas animações, e a China já assinou um acordo sob o qual nosso jogo será lançado em seu mercado. Eu silenciosamente olhei para o código do meu protótipo, pensei profundamente, destaquei tudo e o apaguei.
Acredito que, com o tempo, todo programador, com base em sua experiência, começa a ver problemas óbvios que ele pode encontrar. Especialmente se você trabalha em equipe com um designer de jogos por um longo tempo. Temos vários novos requisitos e recursos. E nossa antiga "arquitetura" obviamente não pode lidar com isso.

Quando você recebe uma tarefa semelhante em uma entrevista, eles certamente tentarão pegá-lo. Eles podem ter muitas formas diferentes - crocodilos, que podem nadar e correr. Tanques que podem disparar um canhão ou uma metralhadora e assim por diante. A propriedade mais importante dessas tarefas é que você tem um objeto que pode fazer várias coisas diferentes. E sua herança não pode lidar de forma alguma, porque é impossível herdar o FlyingObject e o SwimmingObject.E objetos diferentes podem executar ações diferentes. Nesse ponto, abandonamos a herança e passamos à composição:

 class Character { abilities = []; addAbility (...abilities) { for (const a of abilities) { this.abilities.push(a); } return this; } getAbility (AbilityClass) { for (const a of this.abilities) { if (a instanceof AbilityClass) { return a; } } return null; } } /////////////////////////////////////// // //    ,      //       // /////////////////////////////////////// class Ability {} class HealthAbility extends Ability { health = 100; maxHealth = 100; } class MovementAbility extends Ability { x = 0; y = 0; moveTo(x, y) { this.x = x; this.y = y; } } class SpellCastAbility extends Ability { mana = 100; maxMana = 100; cast () { this.mana--; } } class MeleeFightAbility extends Ability { stamina = 100; maxStamina = 100; constructor (power) { this.power = power; } hit () { this.stamina--; } } /////////////////////////////////////// // //        // /////////////////////////////////////// class CharactersFactory { createMage () { return new Character().addAbility( new MovementAbility(), new HealthAbility(), new SpellCastAbility() ); } createWarrior () { return new Character().addAbility( new MovementAbility(), new HealthAbility(), new MeleeFightAbility(3) ); } createPaladin () { return new Character().addAbility( new MovementAbility(), new HealthAbility(), new SpellCastAbility(), new MeleeFightAbility(2) ); } } 

Cada ação possível agora é uma classe separada com seu próprio estado e, se necessário, podemos criar personagens únicos jogando o número necessário de habilidades. Por exemplo, é muito fácil criar uma árvore mágica imortal:

 createMagicTree () { return new Character().addAbility( new SpellCastAbility() ); } 

Perdemos a herança e uma composição apareceu. Agora criamos um personagem e listamos suas habilidades possíveis. Mas isso não significa que a herança seja sempre ruim, apenas neste caso não se encaixa. A melhor maneira de entender se a herança é correta é responder à pergunta por si mesmo, que relacionamento ela representa. Se essa conexão for "is-a", ou seja, você indica que MeleeFightAbility é uma habilidade, então é perfeita. Se a conexão for criada apenas porque você deseja adicionar uma ação e exibir "has-a", pense na composição.
Gostei de ver um ótimo resultado. Funciona de forma inteligente e sem erros, arquitetura de sonho! Estou certo de que passará mais de um teste de tempo e, por muito tempo, não precisaremos reescrevê-lo. Fiquei tão entusiasmado com meu código que nem percebi como June Petya se aproximou de mim.

A rua já estava escura, o que tornava ainda mais visível como ele brilhava de felicidade. Aparentemente, ele conseguiu adiar a tarefa e se livrar da penalidade para tapetes na direção dos colegas, anunciada na semana passada.

"Os artistas pintaram simplesmente animações divinas", ele rapidamente falou. "Mal posso esperar para estragar tudo". Vantagens de vôo particularmente deslumbrantes quando um feitiço de cura é aplicado. Eles são tão verdes e essas vantagens!

Amaldiçoei a mim mesma, porque esqueci completamente que ainda temos que estragar a vista. Porra, parece ter que reescrever a arquitetura.
Nesses artigos, geralmente apenas o trabalho com o modelo é descrito, porque ele é abstrato e adulto, e você também pode dar "fotos para mostrar" a junho, independentemente da arquitetura que exista. No entanto, nosso modelo deve fornecer o máximo de informações para a visualização para que ele possa fazer seu trabalho. No GameDev, o padrão "Equipe" geralmente é usado para isso. Em poucas palavras - temos um estado sem lógica e qualquer mudança deve ocorrer nas equipes correspondentes. Isso pode parecer uma complicação, mas oferece muitas vantagens:
- Eles combinam muito bem quando uma equipe chama outra
- Cada comando, quando executado, é, de fato, um evento no qual você pode se inscrever
- Nós podemos facilmente serializá-los.

Por exemplo, um comando de dano pode se parecer com isso. É então que o guerreiro o usará quando atingido por uma espada e o mágico quando atingido por um feitiço de fogo. Agora, por simplicidade, implementei a validação de comandos por meio de exceções, mas elas podem ser reescritas como códigos de retorno.

 class DealDamageCommand extends Command { constructor (target, damage) { this.target = target; this.damage = damage; } execute () { const healthAbility = this.target.getAbility(HealthAbility); if (healthAbility == null) { throw new Error('NoHealthAbility'); } const resultHealth = healthAbility.health - this.damage; healthAbility.health = Math.max( 0, resultHealth ); } } 

Eu gosto de fazer comandos hierárquicos - quando um é executado, dá à luz muitos filhos, que o mecanismo executa. Então, agora que temos a capacidade de causar dano, podemos tentar implementar um ataque corpo a corpo

 class MeleeHitCommand extends Command { constructor (source, target, damage) { this.source = source; this.target = target; this.damage = damage; } execute () { const fightAbility = this.source.getAbility(MeleeFightAbility); if (fightAbility == null) { throw new Error('NoFightAbility'); } this.addChildren([ new DealDamageCommand(this.target, fightAbility.power); ]); } } 

Essas duas equipes têm tudo o que você precisa para nossas animações. Um renderizador pode simplesmente se inscrever em eventos e exibir tudo o que os artistas desejam com o seguinte código:

 async onMeleeHit (meleeHitCommand) { await view.drawMeleeHit( meleeHitCommand.source, meleeHitCommand.target ); } async onDealDamage (dealDamageCommand) { await view.showDamageNumbers( dealDamageCommand.target, dealDamageCommand.damage ); } 

Perdi a conta, que de vez em quando fico no trabalho até escurecer. Desde a infância, o desenvolvimento de jogos me atraiu, pareceu-me algo mágico, e mesmo agora, quando faço isso há muitos anos, estou ansioso por isso. Apesar de ter aprendido um segredo sobre como eles são criados - não perdi a fé na magia. E essa mágica me faz sentar com tanta inspiração à noite e escrever meu código. Vasya veio até mim. Ele absolutamente não sabe programar, mas compartilha minha atitude em relação aos jogos.

- Aqui - o designer do jogo colocou na minha frente um Talmud de 200 páginas impressas em folhas A4. Embora o documento do projeto tenha sido realizado em confluência, gostávamos de imprimi-lo em estágios importantes para sentir esse trabalho em uma incorporação física. Abri-o em uma página aleatória e entrei em uma lista enorme de uma variedade de feitiços que um mago e um paladino podem fazer, uma descrição de seus efeitos, requisitos de inteligência, preço de mana e uma descrição aproximada para os artistas como exibi-los. Trabalho por muitos meses, porque hoje voltarei a trabalhar.
Nossa arquitetura facilita a criação de combinações complexas de feitiços. Só que cada feitiço pode retornar uma lista de comandos que devem ser concluídos durante o lançamento

 class CastSpellCommand extends Command { constructor (source, target, spell) { this.source = source; this.target = target; this.spell = spell; } execute () { const spellAbility = this.source.getAbility(SpellCastAbility); if (spellAbility == null) { throw new Error('NoSpellCastAbility'); } this.addChildren(new PayManaCommand(this.source, this.spell.manaCost)); this.addChildren(this.spell.getCommands(this.source, this.target)); } } class Spell { manaCost = 0; getCommands (source, target) { return []; } } class DamageSpell extends Spell { manaCost = 3; constructor (damageValue) { this.damageValue = damageValue; } getCommands (source, target) { return [ new DealDamageCommand(target, this.damageValue) ]; } } class HealSpell extends Spell { manaCost = 2; constructor (healValue) { this.healValue = healValue; } getCommands (source, target) { return [ new HealDamageCommand(target, this.healValue) ]; } } class VampireSpell extends Spell { manaCost = 5; constructor (value) { this.value = value; } getCommands (source, target) { return [ new DealDamageCommand(target, this.value), new HealDamageCommand(source, this.value) ]; } } 

Um ano e meio depois

O stand-up, como sempre, se arrastou. Balancei-me na cadeira e pendurei no laptop enquanto Middle Petya discutia com o testador sobre o bug que havia sido iniciado. Com toda sinceridade, ele tentou convencer o testador de que a falta de controle com o botão direito do mouse em nosso novo jogo não deveria ser marcada como um bug, porque essa tarefa nunca resistiu e não foi elaborada por designers ou designers gráficos. Senti um déjà vu, mas uma nova mensagem na discórdia me distraiu:

- Escute - escreveu o designer do jogo - Tenho uma ótima idéia ...

Source: https://habr.com/ru/post/pt438510/


All Articles