Friday, May 13, 2011

Implementing a BoulderDash/HeartLight Clone in XNA

Download the source code and binaries of the game (590kB)

I always wanted to have some spare time to write a BoulderDash / HeartLight Clone game. And just few days ago, I’ve finally made up my mind, launched VS 2010 with XNA and started to code.

No, this time it took more than 100 minutes, I think that I’ve put 7 or 8 hours into it. I am still not quite satisfied with the effect but the result is finally ready to be published so someone can pick up here and add features.

Dołączona grafika

I’ve started with textures, painted them myself in Paint.NET, they are simple 40x40 PNG images. Then the game engine. I’ve decided to take the object-oriented approach so the board is not represented as usuall 20x12 array of values but instead, I hold a list of objects of BaseBlock type and have a class hierarchy to represent different block types: StoneBlock, HeartBlock etc.

This way, I can build my logic on object’s methods and properties so the way to express the logic is more natural, like

BaseBlock block = ...;
 
block.GetNeighbour( Directions.S ).Explode();

or

public override bool ApplyPhysics()
 {
     base.ApplyPhysics();
 
     if ( 
         this.MustExplode ||
          (
            this.IsFalling &&
            this.GetNeighbour( Directions.S ) != null &&
            this.GetNeighbour( Directions.S ).TriggersExplosion 
           )
         )
     {
         this.Explode();
     }
 
     return false;
 }

Basically, some blocks can be eaten, some blocks explode, some blocks can be pushed and it’s all represented by virtual boolean properties on the base class. Refer to the source code for more details.

Caveat 1 – blocks do fall

To my surprise, I’ve lost an hour or two to implement a “gravity” in a proper way (to mimic the way objects fall in Heartlight). You see, it’s not that blocks just fall. It’s also that when a square near a block becomes empty, the stone (heart or bomb) can move sideways so it falls into the newly created hole in the just next frame. Of course also, blocks have to be analyzed bottom-top per frame to get correct results. And then, to avoid the risk of moving a block twice in a single frame, I mark blocks which were moved in that frame and clear the mark in the next frame.

The final version of the ApplyPhysics method is then as follows:

protected bool IsFalling = false;
 public virtual bool ApplyPhysics()
 {
     // czy pod spodem coś jest?
     BaseBlock block = this.GetNeighbour( Directions.S );
     if ( block == null )
     {
         // bezwarunkowe spadanie
         this.MoveTo( Directions.S );
         IsFalling = true;
 
         return false;
     }
     else
     {
         // spadł na gracza
         if ( block is PlayerBlock && this.IsFalling )
         {
             block.ExplodeNeighbour( Directions.None );
             SoundFactory.Instance.PlayEffect( SoundType.Stone );
         }
 
         // stoi na kamieniu
         if (
              ( block.DoesFall && !block.IsFalling ) ||
              ( block.OthersFallFrom ) 
             )
         {
             // może spaść w prawo - lewo?
             if ( this.GetNeighbour( Directions.E ) == null &&
                  this.GetNeighbour( Directions.SE ) == null &&
                  ( this.GetNeighbour( Directions.NE ) == null || 
                   !this.GetNeighbour( Directions.NE ).DoesFall )
                 )
             {
                 this.MoveTo( Directions.E );
                 IsFalling = true;
 
                 return false;
             }
             if ( this.GetNeighbour( Directions.W ) == null &&
                  this.GetNeighbour( Directions.SW ) == null &&
                  ( this.GetNeighbour( Directions.NW ) == null || 
                   !this.GetNeighbour( Directions.NW ).DoesFall )
                 )
             {
                 this.MoveTo( Directions.W );
                 IsFalling = true;
 
                 return false;
             }
         }
     }
 
     IsFalling = false;
     return true;
 }

Note that if there’s nothing below the block, it falls with no conditions. However, to be able to fall sideways, both the left/right and bottom-left/bottom-right squares have to be empty and then, there’s a condition to also check the top-left/top-right square to prevent the block from move sideways and collide with another block that is just falling down.

Caveat 2 – player moves

The other caveat I’ve encountered concerns the way the input has to be read. Although XNA calls Update/Draw 60 times per second, the game engine has to skip frames so that only every eighth frame is drawn to match the speed of the original game. And while it’s easy to read input in XNA, I have found that:

a) if I let the engine move the player as long as I read that the arrow is pressed, the player sometimes moves one square too far away. It just stems from the fact that humans cannot control the keyboard with such precision. Frames are drawn almost 8 times per second and if you hold the arrow key just a fraction of a second too long, the game doesn’t know that your intention was to stop a square earlier.

b) if I let the engine to reset the information of a move everytime I move the player, it’s possible to precisely control the game as long as you hold control keys to move two or more squares. It’s then however sometimes impossible to move just one square if you tap the key for a fraction of a second. This stems from the fact that it’s possible to tap the key exactly between two consecutive calls to the Update method, so that when Update is called, it doesn’t see the key has been pressed in between.

In other words, I was able to implement either the controls which allow the player to sometimes move too far away or the controls which allow the player to sometimes do not move at all.

I had to rethink the approach and finally came up with a solution where I read both long hold and short tap independently. Then, in a single Update I decide where I should move the player according to the long holding of the arrow key or according to the short tap of the arrow key:

protected override void Update( GameTime gameTime )
  {
      // Allows the game to exit
      KeyboardState state = Keyboard.GetState();
 
      playerDirection = BoardBlocks.Directions.None;
 
      // warunki na zakończenie gry lub planszy
      if ( board.MustRestart )
          this.ReloadBoard();
      if ( board.Completed )
          this.MoveToNextBoard();
      if ( state.IsKeyDown( Keys.Escape ) )
          board.ExplodePlayer();
 
      // obsługa klawiatury - pyk
      if ( !state.IsKeyDown( Keys.Space ) && !prevState.IsKeyDown( Keys.Left ) && state.IsKeyDown( Keys.Left ) )
          playerKnock = BoardBlocks.Directions.W;
      if ( !state.IsKeyDown( Keys.Space ) && !prevState.IsKeyDown( Keys.Right ) && state.IsKeyDown( Keys.Right ) )
          playerKnock = BoardBlocks.Directions.E;
      if ( !state.IsKeyDown( Keys.Space ) && !prevState.IsKeyDown( Keys.Up ) && state.IsKeyDown( Keys.Up ) )
          playerKnock = BoardBlocks.Directions.N;
      if ( !state.IsKeyDown( Keys.Space ) && !prevState.IsKeyDown( Keys.Down ) && state.IsKeyDown( Keys.Down ) )
          playerKnock = BoardBlocks.Directions.S;
 
      // obsługa klawiatury - player
      if ( state.IsKeyDown( Keys.F10 ) )
          this.Exit();
      if ( !prevState.IsKeyDown( Keys.Left ) &&
           state.IsKeyDown( Keys.Space ) && state.IsKeyDown( Keys.Left ) )
          this.MoveToPrevBoard();
      if ( !prevState.IsKeyDown( Keys.Right ) &&
           state.IsKeyDown( Keys.Space ) && state.IsKeyDown( Keys.Right ) )
          this.MoveToNextBoard();
      if ( state.IsKeyDown( Keys.F1 ) && !prevState.IsKeyDown( Keys.F1 ) )
          HelpVisible = !HelpVisible;
 
      // obsługa klawiatury - przejscia między levelami
      if ( !state.IsKeyDown( Keys.Space ) )
      {
          if ( state.IsKeyDown( Keys.Left ) )
              playerDirection = BoardBlocks.Directions.W;
          else
              if ( state.IsKeyDown( Keys.Right ) )
                  playerDirection = BoardBlocks.Directions.E;
              else
                  if ( state.IsKeyDown( Keys.Up ) )
                      playerDirection = BoardBlocks.Directions.N;
                  else
                      if ( state.IsKeyDown( Keys.Down ) )
                          playerDirection = BoardBlocks.Directions.S;
      }
 
      // update świata co 6 ramek
      FrameNumber++;
      if ( FrameNumber >= FrameSkip )
      {
          FrameNumber = 0;
 
          board.UpdateBoard( gameTime, state );
 
          if ( playerDirection != BoardBlocks.Directions.None )
          {
              board.UpdatePlayer( playerDirection );
              playerDirection = BoardBlocks.Directions.None;
              playerKnock = BoardBlocks.Directions.None;
          }
          else
              if ( playerKnock != BoardBlocks.Directions.None )
              {
                  board.UpdatePlayer( playerKnock );
                  playerKnock = BoardBlocks.Directions.None;
              }
      }
 
      base.Update( gameTime );
 
      prevState = state;
  }

The only issue I am not quite completely satisfied with is the way I implement bomb explosions. In HeartLight, bombs explode in such way that their explosions show up with short delay, thus a line of bombs explode slowly during consecutive frames. In my implementation, bombs lying side by side explode too quickly thus making one of the levels to behave different than in the original implementation (bombs explode and fall in a different way).

Profile of the game

Press F1 during the game for the in-game manual. Press F10 to exit.

Levels are stored in a XML file located in /Levels folder. The structure is self-explanatory, you can add your levels or modify included levels. I’ve included few levels from the HeartLight game and the full credit goes to Janusz Pelc for the design

Textures are stored in the /Textures folder and loaded in the run-time so please feel free to swap them with your own textures.

Sounds are stored in /Sounds folder, do not delete *.wav files (credit goes to C:/Windows/Media directory) but if you copy some *.mp3 files there, the game will play them shuffling one-by-one in consecutive levels.

Source code is written in C#. If you just want to play the game, navigate to /bin/x86/Debug folder and invoke XNADash.exe from the shell. Please do not forget to install .NET Framework 4 and XNA Redist first.

Any comments or remarks are welcome.

No comments: