Composición versus herencia, patrón de equipo y desarrollo del juego en general


Descargo de responsabilidad: en mi opinión, un artículo sobre arquitectura de software no debería ni puede ser ideal. Cualquier solución descrita puede no cubrir el nivel necesario para un programador, y para otro programador complicará la arquitectura innecesariamente. Pero debe dar una solución a las tareas que se ha establecido. Y esta experiencia, junto con todos los demás conocimientos de un programador que aprende, organiza información, perfecciona a los recién llegados y se critica a sí mismo y a los demás, esta experiencia se convierte en excelentes productos de software. El artículo cambiará entre el arte y la parte técnica. Este es un pequeño experimento y espero que sea interesante.
- Escucha, se me ocurrió una gran idea de juego. - El diseñador del juego Vasya estaba desaliñado y tenía los ojos rojos. Todavía bebía café y holívar en Habré para matar el tiempo antes del stand-up. Me miró expectante hasta que termine de escribir en los comentarios al hombre sobre lo que estaba equivocado. Vasya sabía que hasta que prevaleciera la justicia y no se defendiera la verdad, no tenía sentido continuar la conversación conmigo. Agregué la última oración y lo miré.

En pocas palabras: los magos con maná pueden lanzar hechizos, y los guerreros pueden luchar en combate cuerpo a cuerpo y gastar resistencia. Tanto los magos como los guerreros pueden moverse. Sí, aún será posible robar a las vacas, pero ya lo haremos en la próxima versión, en resumen. ¿Mostrará el prototipo después del stand-up, está bien?

Se escapó en su negocio de diseño de juegos, y abrí el IDE.

De hecho, el tema "composición versus herencia", "problema de mono-plátano", "problema de rombo (herencia múltiple)" son preguntas comunes en entrevistas en diferentes formatos y por una buena razón. El uso incorrecto de la herencia puede complicar la arquitectura, y los programadores inexpertos no saben cómo lidiar con esto y, como resultado, comienzan a criticar a OOP como un todo y comienzan a escribir código de procedimiento. Por lo tanto, los programadores experimentados (o aquellos que han leído cosas inteligentes en Internet) consideran que es su deber preguntar sobre tales cosas en una entrevista en una variedad de formas. La respuesta universal es "la composición es mejor que la herencia, y no se deben aplicar tonos de gris". Aquellos que acaban de leer cada una de esas respuestas estarán 100% satisfechos.

Pero, como dije al comienzo del artículo, cada arquitectura se adaptará a su proyecto y si la herencia es suficiente para su proyecto y todo lo que se necesita para resolver el problema es crear Monkey with Banana, créelo. Nuestro programador estaba en una situación similar. No tiene sentido rechazar la herencia solo porque el FPS se reirá de usted.

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--; } } 

El stand-up, como siempre, se prolongó. Me balanceé en una silla y colgué el teléfono mientras June Petya intentaba convencer al probador de que la imposibilidad de un control rápido con el botón derecho del mouse no es un error, porque no había tal posibilidad en ningún lado, lo que significa que debe dejar la tarea al departamento de preproducción. El probador argumentó que, dado que para los usuarios el control mediante el botón derecho parece ser obligatorio, esto es un error, no una característica. De hecho, como el único jugador de nuestro equipo que juega nuestro juego en servidores de batalla, quería agregar esta función lo antes posible, pero sabía que si la dejas en el departamento de preproducción, la máquina burocrática dejaría que se lance no antes de 4 meses, y habiéndolo publicado como un error, ya puede obtenerlo en la próxima compilación. El gerente del proyecto, como siempre, llegó tarde, y los muchachos estaban maldiciendo tan ferozmente que ya habían cambiado a colchonetas y, probablemente, las cosas pronto habrían llegado a una pelea si el director del estudio no se hubiera encontrado con la maldición y no los hubiera llevado a su oficina. Probablemente nuevamente por 300 dólares multados.

Cuando salí de la sala de rally, un diseñador de juegos corrió hacia mí y felizmente dijo que a todos les gustaba el prototipo, lo aceptaron y ahora este es nuestro nuevo proyecto para los próximos seis meses. Mientras caminábamos hacia mi mesa, él dijo con entusiasmo qué nuevas características habría en nuestro juego. Cuántos hechizos diferentes inventó y, por supuesto, habrá un paladín que pueda luchar y lanzar magia. Y todo el departamento de artistas ya está trabajando en nuevas animaciones, y China ya ha firmado un acuerdo según el cual nuestro juego se lanzará en su mercado. Miré en silencio el código de mi prototipo, pensé profundamente, destaqué todo y lo borré.
Creo que con el tiempo, cada programador, basado en su experiencia, comienza a ver problemas obvios que puede encontrar. Especialmente si trabajas en equipo con un diseñador de juegos durante mucho tiempo. Tenemos un montón de nuevos requisitos y características. Y nuestra vieja "arquitectura" obviamente no puede manejarlo.

Cuando se le pide una tarea similar en una entrevista, sin duda tratarán de atraparlo. Pueden tener muchas formas diferentes: cocodrilos, que pueden nadar y correr. Tanques que pueden disparar un cañón o una ametralladora, etc. La propiedad más importante de tales tareas es que tiene un objeto que puede hacer varias cosas diferentes. Y su herencia no puede hacer frente de ninguna manera, porque es imposible heredar de FlyingObject y SwimmingObject, y diferentes objetos pueden realizar diferentes acciones. En este punto, abandonamos la herencia y pasamos a la composición:

 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 acción posible ahora es una clase separada con su propio estado, y si es necesario, podemos crear personajes únicos lanzándoles el número requerido de habilidades. Por ejemplo, es muy fácil crear un árbol mágico inmortal:

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

Perdimos la herencia y apareció una composición en su lugar. Ahora creamos un personaje y enumeramos sus posibles habilidades. Pero esto no significa que la herencia siempre sea mala, solo en este caso no encaja. La mejor manera de entender si la herencia es correcta es responder a la pregunta por sí mismo qué relación representa. Si esta conexión es "es-a", es decir, indica que MeleeFightAbility es una habilidad, entonces es perfecta. Si la conexión se crea solo porque desea agregar una acción y muestra "has-a", entonces debe pensar en la composición.
Disfruté viendo un gran resultado. Funciona de manera inteligente y sin errores, ¡arquitectura soñada! Estoy seguro de que pasará más de una prueba de tiempo y durante mucho tiempo no tendremos que volver a escribirlo. Estaba tan entusiasmado con mi código que ni siquiera me di cuenta de cómo se me acercó June Petya.

La calle ya estaba oscura, lo que hacía aún más notable cómo brillaba de felicidad. Aparentemente, logró dejar la tarea y librarse de la penalización por colchonetas en la dirección de sus colegas, que se anunció la semana pasada.

"Los artistas pintaron simplemente animaciones divinas", dijo rápidamente, "no puedo esperar para que se jodan". Ventajas voladoras particularmente hermosas cuando se aplica un hechizo de curación. ¡Son tan verdes y esas ventajas!

Me maldije a mí mismo, porque olvidé por completo que todavía tenemos que atornillar la vista. Maldición, parece tener que reescribir la arquitectura.
Tales artículos generalmente describen solo el trabajo con el modelo, porque es abstracto y adulto, y también puede dar "imágenes para mostrar" a junio, sin importar la arquitectura que haya. Sin embargo, nuestro modelo debe proporcionar la máxima información para la vista para que pueda hacer su trabajo. En GameDev, el patrón "Equipo" se usa generalmente para esto. En pocas palabras: tenemos un estado sin lógica, y cualquier cambio debe ocurrir en los equipos correspondientes. Esto puede parecer una complicación, pero ofrece muchas ventajas:
- Se combinan muy bien cuando un equipo llama a otro
- Cada comando, cuando se ejecuta, es, de hecho, un evento al que puede suscribirse
- Podemos serializarlos fácilmente.

Por ejemplo, un comando de daño podría verse así. Es entonces cuando el guerrero lo usará cuando sea golpeado con una espada y el mago cuando sea golpeado por un hechizo de fuego. Ahora, por simplicidad, implementé la validación de comandos a través de excepciones, pero luego pueden reescribirse 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 ); } } 

Me gusta hacer comandos jerárquicos: cuando uno se ejecuta, da a luz muchos hijos, que luego ejecuta el motor. Entonces, ahora que tenemos la capacidad de infligir daño, podemos intentar implementar un ataque cuerpo a cuerpo

 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); ]); } } 

Estos dos equipos tienen todo lo que necesitas para nuestras animaciones. Un procesador puede simplemente suscribirse a eventos y mostrar todo lo que los artistas desean con el siguiente código:

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

Perdí la cuenta, que una vez seguí trabajando hasta el anochecer. Desde la infancia, el desarrollo de los juegos me ha atraído, me pareció algo mágico, e incluso ahora, cuando he estado haciendo esto durante muchos años, estoy ansioso por eso. A pesar de que aprendí un secreto sobre cómo se crean, no he perdido la fe en la magia. Y esta magia me hace sentarme con tanta inspiración por la noche y escribir mi código. Vasya se me acercó. Absolutamente no sabe programar, pero comparte mi actitud hacia los juegos.

- Aquí - el diseñador del juego puso delante de mí un Talmud de 200 páginas impreso en hojas A4. Aunque el documento de diseño se llevó a cabo en confluencia, nos gustó imprimirlo en etapas importantes para sentir este trabajo en forma física. Lo abrí en una página aleatoria y obtuve una gran lista de una variedad de hechizos que un mago y un paladín pueden hacer, una descripción de sus efectos, requisitos de inteligencia, precio de maná y una descripción aproximada para los artistas sobre cómo mostrarlos. Trabajo durante muchos meses, porque hoy volveré a quedarme en el trabajo.
Nuestra arquitectura facilita la creación de combinaciones complejas de hechizos. Es solo que cada hechizo puede devolver una lista de comandos que deben ejecutarse durante el lanzamiento

 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) ]; } } 

Un año y medio después

El stand-up, como siempre, se prolongó. Me balanceé en la silla y colgué en la computadora portátil mientras Middle Petya discutía con el probador sobre el error que se había iniciado. Con toda sinceridad, trató de convencer al probador de que la falta de control con el botón derecho del mouse en nuestro nuevo juego no debería marcarse como un error, porque esa tarea nunca se mantuvo y los diseñadores de juegos o bufones no la resolvieron. Tenía la sensación de deja vu, pero un nuevo mensaje en la discordia me distrajo:

- Escucha - escribió el diseñador del juego - Tengo una gran idea ...

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


All Articles