Part 12 - Match Checking
April 11, 2019

We have finally finished working with the main functionality of the player piece, horizontal and vertical smooth movements, order swapping through player input as well as correct behavior when reaching the destination, not to mention the "collision" with blocks previously added into the grid data. Yet, the main objective of the game, matching blocks of equal types, is not being detected. In this part we will deal with that!

Back in part 8 we added some code, eight functions, to count blocks in order to avoid matching sequences in the initial spawned blocks. In there I also mentioned that those functions were designed to be reused during the game play. Indeed we will do so! However, in order to react with the desired functionality like destroying matched blocks and so on, we have to store some data when using those counting functions. To make things less complicated, we will create some "wrapping" functions meant to count horizontal, vertical and both diagonal matches while automatically storing the necessary extra data.

Auxiliary Data

The first information that we will need in order to check matching runs is an array of the blocks that will be used as "center" in the algorithm (remember, it was explained in part 8). The blocks that should be used in this case are always those that have just landed, be this from a player piece finishing the vertical movement or from blocks ending reposition animation. While we didn't work with the repositioning yet, in the last part we did deal with the player piece and we also did save the indices of the just landed blocks in one private array, mLandedBlock. Of course we will make use of this data!

Next, if we do have matching sequences, we will need to store each involved block. This will be necessary so we can setup some visual feedback of the involved blocks (IE.: blinking them) as well to remove them from the grid. We will also be able to use this to setup some other effects if desired. With that in mind, let's create a new private array in the AGameModeInGame class that will hold the indices of the matched blocks, pointing directly to their storage within the mGridDataArray:

GameModeInGame.h
TArray<int32> mMatchedBlock;

When we run the match checking algorithm we will fill this array with the relevant values. Before we proceed, let's add an event that will be fired up whenever matching sequences occur. We will follow the exact same logic we used with the "OnPlayerPieceLanded()" function. This means, in the public section we add the native C++ virtual function and the blueprint implementable version:

GameModeInGame.h
// Native C++ event code called whenever blocks form matching runs
virtual void OnBlockMatched(const TArray<int32>& MatchedBlocks) { BP_OnBlockMatched(MatchedBlocks); }

// Blueprint event called whenever blocks form matching runs
UFUNCTION(BlueprintImplementableEvent, meta = (DisplayName = "OnBlockMatched"))
void BP_OnBlockMatched(const TArray<int32>& MatchedBlocks);

Helper Functions

As mentioned we will create some "wrapping" helper functions to make use of those counting functions created in part 8. To be more specific, we will create four new functions, CountHorizontalMatches(), CountVerticalMatches(), CountDiagonal1Matches() and CountDiagonal2Matches(). Those new functions are meant to fill the mMatchedBlock array based on the return values of the 8 functions that will be wrapped by them.

On top of that, we will create another helper function, CheckMatchingBlocks() that will wrap all of those four functions. The objective of doing this is merely to make things a little simpler when dealing with the conversion between column/row indexing and the array indexing. The thing is, the previously defined functions need column/row index values while we have array index values to deal with. Moreover, from this "top wrapper" we will perform all of the necessary setup when matching sequences are found.

Let's begin by declaring the four intermediary functions, in the private section of the AGameModeInGame class:

GameModeInGame.h
void CountHorizontalMatches(int32 Column, int32 Row, int32 BlockType);
void CountVerticalMatches(int32 Column, int32 Row, int32 BlockType);
void CountDiagonal1Matches(int32 Column, int32 Row, int32 BlockType);
void CountDiagonal2Matches(int32 Column, int32 Row, int32 BlockType);

Notice that none of those functions have any kind of explicit output. The reason is that those are meant to directly fill the mMatchedBlock array. Alternatively we could add an extra reference argument and then the functions would fill that argument instead of the class' property. Really, this is up to you!

Each of those new functions will call two of the previously defined block counting functions. The overall idea is to sum the return values of the two called functions and add 1 to include the central block in the math (let's call this MM = Matched block count). We then obtain the minimum matching run size, GetMinimumMatchRunSize() (mm = minimum run size). Finally, if MmM \geqslant m then we have a matching sequence. In this case, we then use the information to fill the mMatchedBlock array with the indices of the involved blocks using the AddUnique() TArray's function in order to not have duplicated entries in this array. The reason why we don't want duplicated entries will be explained when we get to work with the data in the array.

All that said, let's begin implementing the CountHorizontalMatches() function. This will call both GetLeftMatch() and GetRightMatch() functions. We first individually store the return values of each of those functions and then compute the total match count. Then, if we do have a matching sequence, we setup a loop to fill the mMatchedBlock array. In this loop we have to add the horizontal blocks that are forming the matching run. Considering our indexing, horizontally "navigating" through the grid is as simple as adding or subtracting 1 from the reading index.

GameModeInGame.cpp
void AGameModeInGame::CountHorizontalMatches(int32 Column, int32 Row, int32 BlockType)
{
   const int32 left = GetLeftMatch(Column, Row, BlockType);
   const int32 right = GetRightMatch(Column, Row, BlockType);
   int32 read_index = GetCellIndex(Column, Row) - left;   // read "left" columns to the left from the central block

   const int32 total_matches = left + right + 1;
   if (total_matches >= UColBPLibrary::GetMinimumMatchRunSize(this))
   {
      // Setup the loop to fill the mMatchedBlock array
      for (int32 i = 0; i < total_matches; i++)
      {
         mMatchedBlock.AddUnique(read_index);
         read_index++;      // move to the right
      }
   }
}

Moving on, let's implement the CountVerticalMatches(). With the same logic of the CountHorizontalMatches(), we will call GetUpMatch() and GetDownMatch(). In the array filling loop we will have to "walk" vertically, that is, add/subtract GetColumnCount() to/from the read index.

GameModeInGame.cpp
void AGameModeInGame::CountVerticalMatches(int32 Column, int32 Row, int32 BlockType)
{
   const int32 up = GetUpMatch(Column, Row, BlockType);
   const int32 down = GetDownMatch(Column, Row, BlockType);
   int32 read_index = GetCellIndex(Column, Row) - (down * GetColumnCount());  // read "down" rows downwards from the central block

   const int32 total_matches = up + down + 1;
   if (total_matches >= UColBPLibrary::GetMinimumMatchRunSize(this))
   {
      for (int32 i = 0; i < total_matches; i++)
      {
         mMatchedBlock.AddUnique(read_index);
         read_index += GetColumnCount();    // move one row up
      }
   }
}

Now, for the CountDiagonal1Matches(). In this case we will consider up-left to down-right blocks, passing through the center block. We will have to incorporate both reading "movements", horizontal and vertical and in this function, inside the loop, we will "walk" from left to right. This means that will will have to add 1 (to go one column to the right) and subtract GetColumnCount() from the reading index in order to move one row down.

GameModeInGame.cpp
void AGameModeInGame::CountDiagonal1Matches(int32 Column, int32 Row, int32 BlockType)
{
   const int32 up_left = GetUpLeftMatch(Column, Row, BlockType);
   const int32 down_right = GetDownRightMatch(Column, Row, BlockType);
   const int32 walk_delta = -GetColumnCount() + 1;     // shortcut to "walk" one row down and one column to the right
   int32 read_index = GetCellIndex(Column, Row) - up_left + (up_left * GetColumnCount());   // read up_left columns to the left and up_left rows up

   const int32 total_matches = up_left + down_right + 1;
   if (total_matches >= UColBPLibrary::GetMinimumMatchRunSize(this))
   {
      for (int32 i = 0; i < total_matches; i++)
      {
         mMatchedBlock.AddUnique(read_index);
         read_index += walk_delta;
      }
   }
}

Finally, we implement CountDiagonal2Matches(), which will go from down-left to up-right. Again, we will need to use both horizontal and vertical "walking" through the grid and in this function, it will be adding 1 to the reading index in order to move one column to the right and adding GetColumnCount() in order to move up one row.

GameModeInGame.cpp
void AGameModeInGame::CountDiagonal2Matches(int32 Column, int32 Row, int32 BlockType)
{
   const int32 down_left = GetDownLeftMatch(Column, Row, BlockType);
   const int32 up_right = GetUpRightMatch(Column, Row, BlockType);
   const int32 walk_delta = GetColumnCount() + 1;   // shortcut to "walk" one row up and one column to the right
   int32 read_index = GetCellIndex(Column, Row) - down_left - (down_left * GetColumnCount());   // read down_left columns to the left and down_left rows down

   const int32 total_matches = down_left + up_right + 1;
   if (total_matches >= UColBPLibrary::GetMinimumMatchRunSize(this))
   {
      for (int32 i = 0; i < total_matches; i++)
      {
         mMatchedBlock.AddUnique(read_index);
         read_index += walk_delta;
      }
   }
}

There is one thing to mention here: We are calculating the read index multiple times. One could think about optimizing this code by moving the array filling into the previously defined block counting functions and take advantage of the fact that we are already obtaining the reading index. To do so we would have to incorporate a flag to avoid this array filling when not necessary. An example of such case is the time we call those functions to avoid having matching runs in the initial play field. Moreover, with current code we are filling the arrays only when there are confirmed matching runs. The design I presented here is clear, easier to explain and simpler to code (when comparing to the alternative method I have explained).

We can now work on the main wrapper function, CheckMatchingBlocks(), which will make use of those new four functions. The main objective of CheckMatchingBlocks() is to ensure the mMatchedBlock array will be in the correct state (empty) prior to calling the counting functions and return true if there were matching runs. The code is relatively simple. We have to iterate through each entry in the mLandedBlock array, using those blocks as "center" in the counting functions. Because this array is storing indices pointing to entries in the mGridData array, we have to convert those into Column/Row indexing, since our block counting functions expect those. Yes, I know, we have to convert into one indexing system just to convert back into the one that we are actually going to use. The problem here is that if we wanted to avoid this, we would have to refactor the old block counting functions to directly receive the array index rather than Column/Row. That's an unfortunate consequence of a design mistake done in part 8, when we implemented the base counting functions.

To convert from array into Column/Row indexing we somewhat perform the reverse of the math we did in the GetCellIndex() function. Just as a reminder, we converted from Column/Row using (GetColumnCount() * Row) + Column. If we take the reading index (a value stored in the mLandedBlock, for example) and divide by GetColumnCount() then the integral resulting value is the Row while the remainder is the Column. In other words, we have to perform two division operations. One to obtain the integral part and the other the remainder. Just for the sake of consistency with the rest of the code design, let's create a helper function do that math for us. In the public section of the AGameModeInGame:

GameModeInGame.h
UFUNCTION(BlueprintPure)
void GetColumnRowFromCellIndex(int32 CellIndex, int32& OutColumn, int32& OutRow) const;

The implementation is a direct use of the math:

GameModeInGame.cpp
void AGameModeInGame::GetColumnRowFromCellIndex(int32 CellIndex, int32& OutColumn, int32& OutRow) const
{
   if (CellIndex >= 0 && CellIndex < mGridData.Num())
   {
      OutColumn = CellIndex % GetColumnCount();
      OutRow = CellIndex / GetColumnCount();
   }
   else
   {
      OutColumn = OutRow = -1;
   }
}

Ok, let's declare the CheckMatchingBlocks() public function:

GameModeInGame.h
// Check if there are matching sequences - this will fill an internal array that will be sent to the OnBlockMatched event
UFUNCTION(BlueprintCallable)
bool CheckMatchingBlocks();

The functionality of this function has already been explained, so let's see the code:

GameModeInGame.cpp
bool AGameModeInGame::CheckMatchingBlocks()
{
   // First, cleanup the internal array
   mMatchedBlock.Empty();

   for (int32 cell_index : mLandedBlock)
   {
      // Obtain the block type
      const int32 block_type = mGridData[cell_index].BlockActor->GetTypeID();
      // And the column/row index for current cell
      int32 column, row;
      GetColumnRowFromCellIndex(cell_index, column, row);

      // Count the blocks trying to find matching runs
      CountHorizontalMatches(column, row, block_type);
      CountVerticalMatches(column, row, block_type);
      CountDiagonal1Matches(column, row, block_type);
      CountDiagonal2Matches(column, row, block_type);
   }

   // Remember, the counting functions will only add anything to the array if a matching run is found
   if (mMatchedBlock.Num() > 0)
   {
      // Empty the landed array since the data is not necessary anymore
      mLandedBlock.Empty();

      return true;
   }
   return false;
}

There are two remarks to be done here. The first is related to the fact that at the end of the loop every single block that is part of matching sequences will have its index added into the mMatchedBlock, including the "center" block. We will use those indices later not only to perform visual feedback, but also to correctly remove the involved blocks from the grid. That's the main reason the counting functions have used the AddUnique() function in order to avoid duplicate values inside the array. The second requires a bit more of explanation. Some people may immediately think that we don't need to call the CountVerticalMatches() at every single loop iteration. Well, we actually have to. The easiest situation that "forces" us to do that is that we will use this very same CheckMatchingBlock() function whenever blocks are repositioned and horizontal or diagonal matches means that we will have indices in the mLandedBlock pointing to different columns. The other situation becomes easier to explain through the usage of an image. In it, suppose we have a game mode in which the player piece is formed by 5 blocks and it has just landed. The surroundings are irrelevant for this case:

Matching Example

In this image I have placed a red frame around the blocks forming the player piece. Those will be the ones iterated by the algorithm. If we call the CountVerticalMatches() only once, for the very first iterated block (which will be the bottom most), only the square symbols will get removed from the grid while the spirals at the top will be ignored. In other words, this is another reason why it's necessary to actually call that function multiple times, even though we will end up visiting some blocks multiple times. Note that in this case it's way more trouble to avoid this than to let the algorithm stay in its current form. But then again, we will most certainly not have an amount of blocks to be visited enough to cause troubles. Besides that, this algorithm will not be called at every single frame, so let's try to rest our this is not an efficient algorithm minds!

Moving on, we have to actually use this function and fire the OnBlockMatched() event. Remember that when the player piece lands we transition from the Play Time state into the Check Match state. This means that we already have the place where we will call our new check match function, StateCheckMatch(). Remembering our state machine design, when we are in the Check Match state, if we do get matching runs we have to transition into the Removing Block state, otherwise we go back into the Spawning state. With that in mind, let's update the StateCheckMatch() state function:

GameModeInGame.cpp
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateCheckMatch(float Seconds)
{
   if (CheckMatchingBlocks())
   {
      // Fire up the event
      OnBlockMatched(mMatchedBlock);
      // Transition into the removing block state
      return &AGameModeInGame::StateRemovingBlock;
   }
   return &AGameModeInGame::StateSpawning;
}

If you did "short-circuit" the state machine to go from the PlayTime directly into Spawning after the player piece lands, revert the change so it goes into the correct state, CheckMatch.

Removing Blocks

If you test the game in its current state, whenever there is a matching run the game will seem frozen while if you avoid forming matches, new player pieces will be spawned. This is happening because whenever matching runs are found we transition into an "empty" state, Removing Block. This state is meant to update the visual feedback to give some more time for the player to see what has been matched. This feedback is also a very helpful debugging tool! Momentarily we will skip this visual feedback thing and just remove the blocks from the grid. So, in the StateRemovingBlock() we iterate through each entry in the mMatchedBlock array and perform the data removal as well as the actor destruction. We directly transition into the StateCheckPlayfield() in this case, but later we will change this behavior a little bit:

GameModeInGame.cpp
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateRemovingBlock(float Seconds)
{
   for (int32 cell_index : mMatchedBlock)
   {
      mGridData[cell_index].BlockActor->Destroy();
      mGridData[cell_index].BlockActor = nullptr;
   }
   mMatchedBlock.Empty();

   return &AGameModeInGame::StateCheckPlayfield;
}

Checking the PlayField

Testing the game at this moment will indeed remove the blocks forming matches but then, that's it. The game will "freeze" again, potentially with a few gaps left in the grid. Currently, once a matching sequence is found we will "get stuck" at the Check Playfield state. In this state, we have to scan the grid looking for gaps and setting up the above blocks to reposition (fall down). Once all of the setup is complete we directly transition into the Repositioning state and, in there, we animate the repositioning of the blocks.

Besides finding gaps and setting up the repositioning data, the algorithm will also take care of updating the floor level. Bellow a brief overview of the steps for each column.

First, take a variable, "new_floor" and assign 0 to it, effectively assuming the floor level of current column is 0. Then, start iterating through each row, from 0. If current cell is not empty, then:

Notice that we can setup the row iteration to stop when the row matches the floor level of the column prior to the algorithm. Another thing is that with current code we somewhat have to iterate through the rows even when not necessary. The thing is, ideally we would loop over only the columns that got changed by removed blocks. We will come to this at a later moment, but for now let's make things work.

In the algorithm description it's mentioned about setting up "vertical movement" for the block that we just added into the repositioning array. This means we will need some timing to animate and, of course, the amount of time necessary to move a single cell, just like the other animations we have done. However, we probably want flexibility while tweaking those timing values, meaning a separate property just for the block repositioning. Because of that, we will need a new private property in the UColGameInstance (again, the complete pattern has appeared throughout the tutorial numerous times, so there will be no explanation in here):

ColGameInstance.h
UPROPERTY(EditAnywhere, meta = (DisplayName = "Reposition Move Time"))
float mRepositionMoveTime;

The public function to retrieve it:

ColGameInstance.h
UFUNCTION(BlueprintPure)
float GetRepositionMoveTime() const { return mRepositionMoveTime; }

Initialize in the constructor:

ColGameInstance.cpp
UColGameInstance::UColGameInstance()
{
   ...
   mRepositionMoveTime = 0.35f;
}

The shortcut function in the blueprint function library:

ColBPLibrary.h
// Obtain the time to reposition a block over a single cell
UFUNCTION(BlueprintPure, meta = (WorldContext = "WorldContextObject"))
static float GetRepositionMoveTime(const UObject* WorldContextObject);

And implementation:

ColBPLibrary.cpp
float UColBPLibrary::GetRepositionMoveTime(const UObject* WorldContextObject)
{
   if (UColGameInstance* gi = GetColGameInstance(WorldContextObject))
   {
      return gi->GetRepositionMoveTime();
   }
   return 0.35f;
}

Ok, at this moment we have means to tweak the time limit to reposition the blocks in the grid. This means we will need to have objects of the timing struct that we have created early in the tutorial (part 10). However, potentially we will have blocks that have to fall through different amounts of cells, meaning that we can't use a single timing object. The easiest thing here is to hold one timing object for each block that needs repositioning. Of course this will result in having multiple timing objects performing the exact same math. While I will keep things like this, if you really want to optimize things, you can work through a system where there is a timing object for each amount of gap levels and have an array associated to each gap level/timing object. Know that there won't be any noticeable saving in terms of processing in this project, thus the reason I will keep one timing object for each block that needs repositioning (IE.: duplicated math).

One last detail is the fact that a repositioned block will have to be used as "center" in the match checking algorithm. In other words, we will need the array index of the cell where it landed.

With that said, the simplest thing now is create a new struct to hold one timing object, the block actor pointer and the destination cell index. In the helpers.h file we can add this struct (just make sure it's declared after FTiming struct):

helpers.h
struct FRepositioningBlock
{
public:
   explicit FRepositioningBlock(float TimeLimit = 0.0f, int32 Index = -1, class ABlock* Block = nullptr)
      : BlockActor(Block)
      , CellIndex(Index)
   {
      Timing.Set(TimeLimit);
   }

   FTiming Timing;
   int32 CellIndex;
   class ABlock* BlockActor;
};

Back to the algorithm description, there is one "repositioning_blocks" array mentioned. Let's declare this array in the private section of the AGameModeInGame:

GameModeInGame.h
TArray<FRepositioningBlock> mRepositioningBlock;

Moving on, we incorporate the described algorithm inside the StateCheckPlayfield() state function:

GameModeInGame.cpp
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateCheckPlayfield(float Seconds)
{
   // Make sure we have an empty array otherwise we risk some unpleasant bugs
   mRepositioningBlock.Empty();

   const int32 column_count = GetColumnCount();
   for (int32 col = 0; col < column_count; col++)
   {
      int32 new_floor = 0;
      int32 read_index = GetCellIndex(col, 0);

      for (int32 row = 0; row < mColumnFloor[col]; row++)
      {
         // Obtain the pointer to the actor and make sure it's valid
         ABlock* block = mGridData[read_index].BlockActor;

         if (block)
         {
            const int32 gap_level = row - new_floor;

            if (gap_level > 0)
            {
               // Cell is not empty - the block in there must be moved down since there is a gap
               // Because we already have the pointer, clear the data from the grid
               mGridData[read_index].BlockActor = nullptr;

               // Total time limit is easy since it's the time for a single cell, while gap_level holds the amount of cells that must be moved down
               const float total_time = (float)gap_level * UColBPLibrary::GetRepositionMoveTime(this);

               // Update the repositioning array
               mRepositioningBlock.Add(FRepositioningBlock(total_time, GetCellIndex(col, new_floor), block));

               // Obtain the destination Z coordinate in order to setup vertical movement
               const float dest_z = GetCellLocation(col, new_floor).Z;

               // Setup the original position
               block->InitOriginalPosition();

               // Setup the vertical movement
               block->SetupVertical(dest_z);
            }

            new_floor++;
         }

         // Move the reading index into the row above
         read_index += GetColumnCount();
      }

      // Update the floor level
      mColumnFloor[col] = new_floor;
   }

   return (mRepositioningBlock.Num() > 0 ? &AGameModeInGame::StateRepositioning : &AGameModeInGame::StateSpawning);
}

Repositioning

Now we have to update the positioning, that's why we have a separate state function for that. And indeed we transitioned into the relevant state, StateRepositioning(). The code in there will be very similar to the one we have added into the FPlayerPiece::Tick(), that is, update the timing objects and use them to interpolate the locations where the block actors have to be placed.

Again, looking back at our state machine design, in the Repositioning state, we stay at it if at least one block didn't finish its repositioning. As soon as all blocks have finished the repositioning then the conditions are met to transition back into the CheckMatch state.

One of the approaches to develop the algorithm is iterate through the entries in the mRepositioning array and remove the objects from it once they have finished the movement. With that, whenever the array becomes empty we could transition into the CheckMatch state. While this approach avoids iterating through objects that have already finished the movement, there is also the potential problem that removing objects from the middle of the array will make the other objects change memory position. Instead we could add a boolean flag into the FRepositioningBlock struct and use that to indicate when we can ignore the updating. We will still iterate through objects, however we will be able to skip performing some unnecessary operations. With that said, let's add the flag into the struct:

helpers.h
bool RepositionFinished;

By default this value should be false, meaning that the block does need updating, so we change the constructor:

helpers.cpp
explicit FRepositioningBlock(float TimeLimit = 0.0f, int32 Index = -1, class ABlock* Block = nullptr)
   : BlockActor(Block)
   , CellIndex(Index)
   , RepositionFinished(false)
{
   Timing.Set(TimeLimit);
}

Now, however, we will need a different approach to tell us when all the blocks have finished repositioning. In this case, we will use a simple boolean operation trick. Before looping through the objects in the FRepositionBlock array we set a flag assuming that all blocks have finished repositioning. Then we just use something like flag = flag AND reposition_finished. Then, if the object does need update, the flag will be set to false and will remain so throughout the rest of the loop. The flag will only remain true if all objects have finished the repositioning. If this confuses you, all you have to remember is that the only way for the AND operator to return true is if both sides of the operation are true. If only one or both sides are false, then the result is false.

Another thing relates to the state machine design. If we take a look at the transitions (and their conditions), when we are at the Repositioning state, we either stay at it or transition into the Check Match when all blocks have finished repositioning. As stated before, the reason is because the repositioned blocks may cause new running matches. This also means we have to re-fill the mLandedBlock array so the match checking algorithm has something to look at. Just remember that this array is meant to hold indices to the data stored in the mGridData array, meaning that we have to add the block pointer into this grid data. With all that said, let's see the StateRepositioning() code:

GameModeInGame.cpp
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateRepositioning(float Seconds)
{
   bool finished = true;      // Assume all blocks have finished the repositioning

   for (FRepositioningBlock& rep_block : mRepositioningBlock)
   {
      if (!rep_block.RepositionFinished)
      {
         // Update the timing
         const float alpha = rep_block.Timing.Update(Seconds);

         // We need some coordinates otherwise the actor will warp around when calling "SetActorLocation()"
         FVector interp_pos = rep_block.BlockActor->GetActorLocation();

         // And interpolate the Z coordinate
         rep_block.RepositionFinished = rep_block.BlockActor->InterpolateVertical(alpha, interp_pos.Z);

         // Update the location
         rep_block.BlockActor->SetActorLocation(interp_pos);

         // And the internal flag - after the Update() RepositionFinished variable may be different
         finished = finished & rep_block.RepositionFinished;

         if (rep_block.RepositionFinished)
         {
            // Previously this block was still repositioning, now it finished. Prepare things for the match check algorithm
            mLandedBlock.Add(rep_block.CellIndex);

            // Make sure the grid data is holding this block
            AddBlockToGridData(rep_block.CellIndex, rep_block.BlockActor);
         }
      }
   }

   if (finished)
   {
      // Cleanup the repositioning array and transition into the match checking state
      mRepositioningBlock.Empty();

      return &AGameModeInGame::StateCheckMatch;
   }
   
   return &AGameModeInGame::StateRepositioning;
}

Right now, after performing some tests, the falling speed of the block is slower than the speed of the repositioning of the blocks. We can tweak that from the editor, without having to recompile the project, through the BP_GameInstance blueprint class. Since we want this repositioning to be a little faster, we have to decrease the default value, 0.35. In my case, I set it to 0.2 and consider this a reasonable good speed.

Preliminary Feedback

If you play the game in its current state, you will notice that it's a bit hard to tell if the matching algorithm is correctly working or not, specially when there are chained matches. To make things a little simpler to test, we will add the preliminary code for the visual feedback to "highlight" the blocks involved in the running matches. By that I mean, just fade the block for a short time. Now, the way we will implement this will also serve as the base for the "blinking".

I have just mentioned about fading the block for a short time. This means we have to count time, again. The blinking of the blocks will also need some time so the object we will create now will not be temporary. The overall idea is to keep the state machine in the Removing state for some time. Basically, we update the time object and if its return value indicates the limit has been reached then we transition into the Check Playfield state. With that said, we declare the timing object in the private section of the AGameModeInGame class:

GameModeInGame.h
FTiming mBlinkTime;

Before implementing the new code in the state function, we have to update the Block class so we can "fade" it. But, how? Remember in part 5 when the block material was created? In there, I have added a parameter, Intensity, meant to be manipulated by code. And indeed, when the block is spawned, we also create a dynamic material instance so we can directly change this parameter. This is how we will make the block blink later and, in this part, temporarily fade. So, let's create a public function meant to change the intensity parameter of the material of the block class:

Block.h
UFUNCTION(BlueprintCallable)
void SetIntensity(float Intensity);

In its implementation we just access the dynamic material and set the corresponding parameter to the value received through its argument:

Block.cpp
void ABlock::SetIntensity(float Intensity)
{
   if (mMaterial)
   {
      mMaterial->SetScalarParameterValue("Intensity", Intensity);
   }
}

There is one thing to mention here. This code somewhat forces that all materials meant to be used by blocks to include a parameter named Intensity. It is possible to create a system with slightly more flexibility regarding the naming but it's not part of the plans for this tutorial.

Now, let's update the Removing state function. Its code has already been explained:

GameModeInGame.cpp
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateRemovingBlock(float Seconds)
{
   if (mBlinkTime.Update(Seconds) >= 1.0f)
   {
      for (int32 cell_index : mMatchedBlock)
      {
         mGridData[cell_index].BlockActor->Destroy();
         mGridData[cell_index].BlockActor = nullptr;
      }
      mMatchedBlock.Empty();
   }
   else
   {
      for (int32 cell_index : mMatchedBlock)
      {
         mGridData[cell_index].BlockActor->SetIntensity(0.3f);
      }

      return &AGameModeInGame::StateRemovingBlock;
   }

   return &AGameModeInGame::StateCheckPlayfield;
}

For the moment ignore the fact that we are calling SetIntensity with the very same value numerous times. This is temporary and we will update this section of the code later. But before we can test this, we have to initialize the timing object, right before transitioning into the Removing state:

GameModeInGame.cpp
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateCheckMatch(float Seconds)
{
   if (CheckMatchingBlocks())
   {
      // Setup the blinking timer
      mBlinkTime.Set(0.8f);
      // Fire up the event
      OnBlockMatched(mMatchedBlock);
      // Transition into the removing block state
      return &AGameModeInGame::StateRemovingBlock;
   }
   return &AGameModeInGame::StateSpawning;
}

Note that I'm temporarily hard coding the amount of the time the machine will be kept in the Removing state.


In this part we advanced quite a bit in the project! Indeed, regarding game mechanics we are almost done! In the next part we will implement the blinking blocks and work on some other details, preparing to work on the interface through Unreal Motion Graphics (UMG).

Introduction
Previous1234567891011
12
1314151617181920212223Next