6.6 Morphs de Spacewar!

6.6.1 Todos los Morphs

Anteriormente definimos a los actores del juego como subclases de la clase muy general Object (véase Ejemplo 3.14). Sin embargo, el juego, la estrella central, las naves y los torpedos son objetos visuales, cada uno con una forma gráfica específica:

Por lo tanto, tiene sentido convertir estos actores en tipos de Morphs, la entidad visual de Cuis-Smalltalk. Para ello, dirija un navegador del sistema a la definición de clase de cada actor, sustituya la clase padre Object por PlacedMorph21, y luego guarde la definición de clase con Ctrl-s.

Por ejemplo, la clase torpedo tal y como se ve en Ejemplo 3.14 se modifica como:

PlacedMorph subclass: #Torpedo
   instanceVariableNames: 'position heading velocity lifeSpan'
   classVariableNames: ''
   poolDictionaries: ''
   category: 'Spacewar!'

Además, un placed Morph ya conoce su posición y orientación en la pantalla: se puede arrastrar y mover en la pantalla y girar con el cursor del ratón. Por lo tanto, las variables de instancia position y heading son redundantes y deben eliminarse. Por ahora las mantenemos, pero se eliminarán más adelante, cuando sepamos cómo sustituir cada uno de sus casos de uso por su equivalente Morph adecuado.

 CuisLogo Modifica SpaceWar, CentralStar y SpaceShip para que sean subclases de la clase PlacedMorph.

Ejercicio 6.1: Hacer todos los Morphs

Como se ha explicado en las secciones anteriores de este capítulo, un morph puede estar incrustado dentro de otro morph. En Spacewar!, una instancia morph SpaceWar que presenta el juego, es la propietaria de los morphs de la estrella central, la nave espacial y los torpedos. En otras palabras, la estrella central, las naves espaciales y los torpedos son submorphs de una instancia morph SpaceWar.

El código SpaceWar>>initializeActors en Ejemplo 4.17 no está completo sin añadir y posicionar la estrella central y las naves espaciales como submorfos del juego Spacewar!:

SpaceWar>>initializeActors
   centralStar := CentralStar new.
   self addMorph: centralStar.
   centralStar morphPosition: 0 @ 0.
   torpedoes := OrderedCollection new.
   ships := Array with: SpaceShip new with: SpaceShip new.
   self addAllMorphs: ships.
   ships first 
      morphPosition: 200 @ -200;
      color: Color green.
   ships second 
      morphPosition: -200 @ 200;
      color: Color red

Ejemplo 6.3: Completar el código para inicializar los actores de Spacewar!

Hay dos mensajes importantes: #addMorph: y #morphPosition:. El primero le pide al receptor morph que incruste su argumento morph como un submorph, el segundo le pide que establezca las coordenadas del receptor en el marco de referencia de su propietario. Al leer el código, se deduce que el origen del marco de referencia del propietario es su centro, de hecho, nuestra estrella central está en el centro del juego.

Hay un tercer mensaje que no aparece aquí, #morphPosition, para solicitar las coordenadas del receptor en el marco de referencia de su propietario.

Recuerda nuestra discusión sobre la variable de instancia position. Ahora entiendes claramente que es redundante y la eliminamos de las definiciones SpaceShip y Torpedo. Cada vez que necesitamos acceder a la posición, simplemente escribimos self morphPosition y cada vez que necesitamos modificar la posición escribimos self morphPosition: newPosition. Más adelante hablaremos más sobre esto.

6.6.2 El arte de refactorizar

En nuestro modelo newtoniano explicamos que las naves espaciales están sujetas a la aceleración del motor y a la fuerza gravitatoria de la estrella central. Las ecuaciones se describen en la Figura 2.4.

Basándonos en estas matemáticas, escribimos el método SpaceShip>>update: para actualizar la posición de la nave según el tiempo transcurrido; véase Ejemplo 4.19.

Hasta ahora, en nuestro modelo, un torpedo no está sujeto a la fuerza gravitatoria de la estrella central ni a la aceleración de su motor. Se supone que su masa es cero, lo cual es poco probable. Por supuesto, el método Torpedo>>update: es más sencillo que el de la nave espacial (véase Ejemplo 4.18. Sin embargo, es más preciso y aún más divertido que los torpedos estén sujetos a la fuerza gravitatoria22 y a la aceleración de su motor; un piloto de nave espacial hábil podría utilizar la asistencia gravitatoria para acelerar un torpedo disparado con una trayectoria cercana a la estrella central.

¿Qué repercusiones tienen estas consideraciones en las entidades torpedo y nave espacial?

  1. Compartirán estados comunes como la masa, la posición, el rumbo, la velocidad y la aceleración.
  2. Compartirán comportamientos comunes como los cálculos necesarios para actualizar la posición, la dirección, la fuerza de gravedad y la velocidad.
  3. Tendrán diferentes estados: un torpedo tiene un estado de vida útil, mientras que una nave espacial tiene un estado de capacidad del depósito de combustible y un estado de reserva de torpedos.
  4. Tendrán comportamientos diferentes: un torpedo se autodestruye cuando expira su vida útil, una nave espacial dispara torpedos y acelera mientras su depósito de combustible y su recuento de torpedos no sean cero.

Los estados y comportamientos compartidos sugieren una clase común. Los estados y comportamientos no compartidos sugieren subclases especializadas que incorporan las diferencias. Así que «extraigamos» los elementos compartidos de las clases SpaceShip y Torpedo en una clase ancestral común, una más especializada que la clase Morph que comparten actualmente.

Realizar este análisis en el modelo informático del juego forma parte del esfuerzo de refactorización para evitar duplicaciones de comportamiento y estado, al tiempo que se hace más evidente la lógica común en las entidades. La idea general de la refactorización del código es reelaborar el código existente para hacerlo más elegante, comprensible y lógico.

Para ello, introduciremos una clase Mobile, un tipo de PlacedMorph con comportamientos específicos de un objeto móvil sometido a aceleraciones. Sus estados son la masa, la posición, el rumbo, la velocidad y la aceleración. Bueno, ya que estamos hablando de refactorización, el estado de masa no tiene mucho sentido en nuestro juego, ya que la masa de nuestro móvil es constante. Solo necesitamos un método que devuelva un número literal y entonces podremos eliminar la variable de instancia mass. Además, como se ha explicado anteriormente, una instancia PlacedMorph ya conoce su posición y rumbo, por lo que también eliminamos estos dos atributos, aunque hay comportamientos comunes a una nave espacial y un torpedo.

El resultado es esta definición de Mobile:

PlacedMorph subclass: #Mobile
   instanceVariableNames: 'velocity acceleration color'
   classVariableNames: ''
   poolDictionaries: ''
   category: 'Spacewar!'

Ejemplo 6.4: Mobile en el juego

 CuisLogo ¿Cuáles deberían ser las definiciones refactorizadas de las clases SpaceShip y Torpedo?

Ejercicio 6.2: Refactorizar SpaceShip y Torpedo

Los primeros comportamientos que añadimos a nuestro Mobile son su inicialización y su masa:

Mobile>>initialize
  super initialize.
  velocity := 0 @ 0.
  acceleration := 0
        
Mobile>>mass
  ^ 1

Los siguientes métodos que se añadirán son los relativos a los cálculos físicos. En primer lugar, el código para calcular la aceleración gravitatoria:

Mobile>>gravity
"Compute the gravity acceleration vector"
   | position |
   position := self morphPosition.
   ^ -10 * self mass * owner starMass / (position r raisedTo: 3) * position

Ejemplo 6.5: Calcular la fuerza de la gravedad

Este método merece algunos comentarios:

El método para actualizar la posición y la velocidad del móvil es prácticamente el mismo que en Ejemplo 4.19. Por supuesto, las versiones SpaceShip>>update: y Torpedo>>update: deben eliminarse ambas. A continuación se muestra la versión completa con la forma en que morph accede a la posición del móvil:

Mobile>>update: t
"Update the mobile position and velocity"
  | ai ag newVelocity |
  "acceleration vectors"
  ai := acceleration * self direction.
  ag := self gravity.
  newVelocity := (ai + ag) * t + velocity.
  self morphPosition:
     (0.5 * (ai + ag) * t squared)
     + (velocity * t)
     + self morphPosition.
  velocity := newVelocity.	
  "Are we out of screen? If so we move the mobile to the other corner
  and slow it down by a factor of 2"  
  (self isInOuterSpace and: [self isGoingOuterSpace]) ifTrue: [
     velocity := velocity / 2.
     self morphPosition: self morphPosition negated]

Ejemplo 6.6: Método update: de Mobile

Ahora debemos añadir los dos métodos para detectar cuándo un móvil se dirige al espacio profundo.

Pero primero definimos el método localBounds en cada uno de nuestros objetos Morph. Devuelve una instancia Rectangle definida en las coordenadas Morph por su origen y extensión:

SpaceWar>>localBounds
   ^ -300 @ -300 extent: 600 @ 600

CentralStar>>localBounds
   ^ Rectangle center: 0 @ 0 extent: self morphExtent

Mobile>>localBounds
   ^ Rectangle encompassing: self class vertices

Ejemplo 6.7: Límites de nuestros objetos Morph

Mobile>>isInOuterSpace
"Is the mobile located in the outer space? (outside of the game
play area)"
   ^ (owner localBounds containsPoint: self morphPosition) not

Mobile>>isGoingOuterSpace
"is the mobile going crazy in the direction of the outer space?"
   ^ (self morphPosition dotProduct: velocity) > 0

Ejemplo 6.8: Comprobar que un mobile está «fuera del espacio»

Como puedes ver, estos métodos de prueba son sencillos y cortos. Al escribir código Cuis-Smalltalk, esto es algo que valoramos mucho y no dudamos en dividir un método largo en varios métodos pequeños. Mejora la legibilidad y la reutilización del código. El mensaje #containsPoint: pregunta al rectángulo receptor si el punto del argumento se encuentra dentro de su forma.

Cuando se actualiza un móvil, se actualizan su posición y velocidad. Sin embargo, las subclases Mobile, SpaceShip o Torpedo pueden necesitar actualizaciones específicas adicionales. En la programación orientada a objetos existe un mecanismo especial denominado sobrescritura (overriding) para lograrlo.

Observa la definición de Torpedo>>update::

See the Torpedo>>update: definition:

Torpedo>>update: t
   "Update the torpedo position"
   super update: t.
   "orientate the torpedo in its velocity direction, nicer effect
   while inaccurate"
   self heading: (velocity y arcTan: velocity x).
   lifeSpan := lifeSpan - 1.
   lifeSpan isZero ifTrue: [owner destroyTorpedo: self].
   acceleration > 0 ifTrue: [acceleration := acceleration - 500]

Aquí, el método update: está especializado para las necesidades específicas del torpedo. El cálculo mecánico realizado en Mobile>>update: sigue utilizándose para actualizar la posición y la velocidad del torpedo: esto se hace mediante super update: t. Ya hemos hablado de super. En el contexto de Torpedo>>update:, significa buscar un método @.msg update:@ en la clase padre de Torpedo, en la clase padre de esa clase, y así sucesivamente hasta encontrar el método; si no se encuentra, se señala un error de Message Not Understood (Mensaje no comprendido).

Entre los comportamientos añadidos específicos, la orientación del torpedo a lo largo de su vector de velocidad es inexacta, pero tiene un aspecto agradable. Para orientar el torpedo adecuadamente, ajustamos su rumbo con el rumbo de su vector de velocidad.

El control de la vida útil, la secuencia de autodestrucción y la aceleración del motor también se gestionan de forma específica. Cuando se dispara un torpedo, la aceleración de su motor es enorme, pero luego disminuye rápidamente.

Con el navegador del sistema apuntando al método Torpedo>>update:, observa el botón inheritance. Es de color verde claro, lo que indica que el mensaje también se envía a super. Esto es un recordatorio de que el método proporciona un comportamiento especializado. La información sobre herramientas del botón explica el significado de los colores resaltados en el texto del método. Al pulsar el botón inheritance, se pueden explorar todas las implementaciones del método update: dentro de esta cadena de herencia.

ch06-20-TorpedoUpdateInheritance

Figura 6.14: Botón inheritance de update:

Ya hemos visto un ejemplo de sobrescritura al inicializar una instancia de nave espacial: véase Ejemplo 3.17. En el contexto de la refactorización de nuestra clase, la sobrescritura de initialize abarca toda la jerarquía Mobile:

Mobile>>initialize
   super initialize.
   color := Color gray.
   velocity := 0 @ 0.
   acceleration := 0

SpaceShip>>initialize
   super initialize.
   self resupply

Torpedo>>initialize
   super initialize.
   lifeSpan := 500.
   acceleration := 3000

Ejemplo 6.9: Sobreescribir Initialize en la jerarquía Mobile hierarchy

Observa cómo cada clase solo es responsable de la inicialización de su estado específico:

  1. SpaceShip. Sus estados mecánicos se configuran con super initialize y, a continuación, se reabastece la nave con combustible y torpedos:
    SpaceShip>>resupply
       fuel := 500.
       torpedoes := 10
    
  2. Torpedo. Estados mecánicos heredados inicializados; añadir inicialización de secuencia de autodestrucción y aceleración ajustada para imitar el impulso del torpedo al dispararse.

Los comportamientos específicos de cada móvil se establecen con métodos adicionales. El SpaceShip viene con sus métodos de control que ya describimos anteriormente en Ejemplo 5.8 y Ejemplo 5.9, por supuesto no hay ninguno para un Torpedo.

Otro comportamiento específico importante es cómo se dibuja cada tipo de Mobile en el juego, lo cual se tratará en el próximo capítulo sobre los fundamentos de Morph.


Notas al pie

(21)

Un PlacedMorph es un tipo de Morph con un atributo location suplementario, por lo que se le puede indicar que se mueva, se escale y gire en la pantalla.

(22)

Por lo tanto, un torpedo debe tener masa.