Thursday, May 5, 2011

Building Your Own Game Engine - Gameplay

This article will focus on gameplay, the part of the code that allows making the game fun. In a smaller game, like ours, the gameplay code can take substantial chunk of the whole code-base. The gameplay in The Jelly Reef was not scripted and completely integrated with the rest of the code; and as such written in C#. The separation between what can be considered engine code and gameplay code was mostly logical but still some physical structure was kept.
There are generally four places where gameplay code goes in our engine. Of course, our primary gameplay element is the water itself as the player interacts with it. The implementation of the water waited for the concept of the game to be completely defined, so after we know what our game will be about we started building the water dynamics. Our first try was with simulated fluid dynamics, but after playing with few games and simulators that use fluid dynamics we realized that it might not be the best fit for The Jelly Reef. Fluid dynamics are somewhat difficult to program and even more difficult to make them run efficiently on a larger scale. But the real problem was that they were not a good fit for gameplay and can be notoriously time consuming to tweak. My solution was to fake it and I am talking Shanghai Rolex kind of fake. The water in The Jelly Reef is a grid of 2D vectors representing velocities, what we (not physically correctly) called flow field. The grid is treated as a gray-scale image and every update is filtered using a set of low-pass filters. The resulting image summed with the current image using a tweakable weight factor. The low-pass filtering is done by convolution with a 3 by 3 kernel, which is chosen based on the direction of the water at that cell of the grid. There are 4 kernels in total; horizontal, vertical and two diagonal ones. Basically the value of atan2 is mapped directly into an index in the kernel array. What follows is a piece of code that does exactly that and in some way is the heart of our game.
// FlowField.cs
public class FlowField : ISceneNode
{
  #region Weights

  static float lon = 3;
  static float lat = 0.5f;

  private static float[,] verticalKernel =
    new float[,]{ {lat, lat, lat},
                  {lon, lon, lon},
                  {lat, lat, lat}};
        
  private static float[,] horizontalKernel =
    new float[,]{ {lat, lon, lat},
                  {lat, lon, lat},
                  {lat, lon, lat}};

  private static float[,] diagonalKernel0 =
    new float[,]{ {lat, lat, lon},
                  {lat, lon, lat},
                  {lon, lat, lat}};

  private static float[,] diagonalKernel1 =
    new float[,]{ {lon, lat, lat},
                  {lat, lon, lat},
                  {lat, lat, lon}};

  private static float[][,] kernels = new float[][,]         
  {
    horizontalKernel,
    diagonalKernel1,
    verticalKernel,
    diagonalKernel0,
    //
    horizontalKernel,
    diagonalKernel1,
    verticalKernel,
    diagonalKernel0
  };

  #endregion

  public void Update(float dt)
  {
    // Some update code...

    // Go through all the cells
    for (int i = iFrom; i < iTo; i++)
    {
      for (int j = jFrom; j < jTo; j++)
      {
        // Get vector angle
        float theta = (float)Math.Atan2(vectorField[i, j].Y, vectorField[i, j].X);
        //
        theta += MathHelper.Pi / 8; // Add half a section
        // get index
        int kindex = (int)Math.Floor(theta * (8.0f / MathHelper.TwoPi));
        kindex %= 8;                // Remove extra sections
        //
        float[,] kernel = kernels[kindex];

        // Convolve using this kernel!
      }
    }
  }
}

All the kernels are energy-preserving, so a dampening factor is used to stabilize the velocity field. All hand movements add energy to the system so without dampening the simulation would in fact exploded. This system made interacting with the water intuitive while giving us the ability to easily tweak its behavior. The video below shows a visualization of the velocity field, something that we stared a lot when designing the first levels. The video below shows the debug view of the our flow field.
Most of the levels in our game had regions that were not part of the simulated water field, usually rocks and other such big obstacles. The water should not flow through these obstacles so those regions should be excluded from the flow field. For this purpose, when loading the level we employ a flood fill algorithm, that expands until it hits a wall, that marks all the cells that are part of the flow field. By skipping calculations on those cells we also optimized the flow field significantly. The flow field’s value can be read using nearest neighbor or bilinear sampling, depending on the quality and performance needs. Bubbles for example use nearest neighbor as there are many of them and the physical simulation takes care of the trajectory smoothing. For the jellies on the other side we used bilinear sampling as motion quality and responsiveness was paramount.

Next place where gameplay can be added is the update method of the GameWorld class. The GameWorld has been designed to be extended and new functionality can be added by the inheriting class. The update method is the place where game wide game-play code should be, like checking winning and losing conditions or similar stuff. The inherited class would automatically keep all the functionality of the GameWorld class, like the streaming and real time editing but it needs to do XmlInclude for all the entity types in the class attributes. Much of the gameplay code is in the Update method of the different entities and most entities are in fact built for specifically for that purpose. Enemies, propellers and pipes are all examples of such entities. If the entity is dynamic it can inherit the MovingEntity class and get all the functionality for physically simulated motion. Finally the game implements a mechanism to respond to collision events. The user could subscribe collisions of different pairs of entity types. For example a collision of a jelly with a cannon ball would result in instant death and the event can be used to play a sound, death animation and so on. What follows is are parts of the code doing exactly that.
// JellyWorld.cs

// Include all types that might be used in the game
[XmlInclude(typeof(Jelly))]
// More includes ...
public class JellyWorld : GameWorld
{
   public void SetEvents()
   {
      // Add Jelly vs Propeller
      int hash = EntityTypes.HashCode(EntityType.Jelly, EntityType.Propeller);
      this.collisionManager.SubscribeForCollision(hash, JellyPropellerCollision);

      // Set more events ...
   }

   public void JellyPropellerCollision(ref Contact contact)
   {
      // Play gruesome death, scary sound and remove jelly
   }

   // More JellyWorld code ...
}
From the two entity types hash is created and is used to index the event method to be called. The hash is calculated by using the first 16 bits to store the first type and the second 16 bits for the second type. As long as there are less than 65536 entity types this works. The method gets a reference of the contact information which can be edited too. The above code for example sets the handled flag to true, so that the contact is ignored by the contact resolver in the physics pipeline. Consequently, physics and collision handling is exactly what the next article will be about. I'll end this post with a montage of our game.

The Jelly Reef from Adriaan de Jongh on Vimeo.

No comments:

Post a Comment