-->
Up to this point we have almost finished the game mechanics, missing only the "increase the speed of the falling piece", albeit that's a game mode specific rule. In this part we will finish the blinking of the blocks to be removed and then quite a few preparations for scoring and the graphical user interface.
In the previous part we implemented a temporary visual feedback, block fading, to make it clear about which blocks are to be removed from the grid. That also helps us a lot when hunting for bugs, namely, to know if the match checking algorithm is not returning some false positives. Now we will update that code and "blink" the blocks instead of just fading. The interesting thing is that we have most of the base code for that. We will call the ABlock::SetIntensity()
function with a value that changes over time rather than the hard coded 0.3
that I have set. The extra code we will need is related to how this value will be changed. In this regard we have quite a few options:
By far, option 1 is the easiest to implement since all that is needed is set a variable with a random number before the loop that sets the intensity of each matched block and then substitute 0.3
with that variable. I encourage you to test and see the result of this approach (remember, you can use FMath::RandRange()
function for this). If you do test this, notice how erratic the result seems to be! While it does tell us which blocks are involved, it also seem like the game is glitched or something.
Option 2 is also somewhat easy but we can't alternate the value at every single update. We have to keep the intensity level set for some time otherwise we won't have the desired effect. Doing this means we will need yet more time counting, in this case, not exactly the FTiming
struct we have created, mostly because the counting here is slightly different.
Option 3... well, it's actually an incredibly huge amount of options in there. Perhaps the easiest function to be used in this case is the sin()
. Or, if you prefer to start from full intensity, cos()
. Because both of those functions range from -1.0
to 1.0
, we could add 1.0
to the result so the range becomes [0..2] and then divide by 2
, obtaining the range [0..1], which is the one we want. The major problem here is the fact that those functions are computationally expensive. Still, in this project I will make use of the cos()
function because its implementation is almost as easy as option 1, requiring a single extra property, and also calling it (cos()
) a single time per update and only when the blocks are about to be removed won't be a real problem. The result will also look like something well controlled.
With that said, we need a property that will somewhat govern the speed in which the blinking will occur as well as one to tell how long this blinking should last. Like all other properties that can be tweaked, we will add those into the UColGameInstance
, private section, and create the usual "shortcut" functions inside the blueprint library. So, let's begin by the declarations in the game instance class:
UPROPERTY(EditAnywhere, meta = (DisplayName = "Blinking Speed"))
float mBlinkingSpeed;
UPROPERTY(EditAnywhere, meta = (DisplayName = "Blinking Time"))
float mBlinkingTime;
The public function to obtain the values:
UFUNCTION(BlueprintPure)
float GetBlinkingSpeed() const { return mBlinkingSpeed; }
UFUNCTION(BlueprintPure)
float GetBlinkingTime() const { return mBlinkingTime; }
Their initialization in the constructor:
UColGameInstance::UColGameInstance()
{
...
mBlinkingSpeed = 5.0f;
mBlinkingTime = 0.8f;
}
The shortcut functions in the blueprint library:
// Obtain the speed of the blinking when removing blocks from the grid
UFUNCTION(BlueprintPure, meta = (WorldContext = "WorldContextObject"))
static float GetBlinkingSpeed(const UObject* WorldContextObject);
// Obtain the how long the blinking will last
UFUNCTION(BlueprintPure, meta = (WorldContext = "WorldContextObject"))
static float GetBlinkingTime(const UObject* WorldContextObject);
And their implementation:
float UColBPLibrary::GetBlinkingSpeed(const UObject* WorldContextObject)
{
if (UColGameInstance* gi = GetColGameInstance(WorldContextObject))
{
return gi->GetBlinkingSpeed();
}
return 5.0f;
}
float UColBPLibrary::GetBlinkingTime(const UObject* WorldContextObject)
{
if (UColGameInstance* gi = GetColGameInstance(WorldContextObject))
{
return gi->GetBlinkingTime();
}
return 0.8f;
}
Finally, we update the Removing
state. The first change to be done is store the result of the blink time
update, since we will use that as part of the cos()
argument. Then the if()
statement compares this stored value rather than directly calling the FTiming::Update()
function. The last change is that before starting the loop to set the intensity level of the block class, we obtain the value through the usage of the FMath::Cos()
function. In there, we use the time update's return value to define the period of the function, but in order to control the speed we multiply by the property we have just created, GetBlinkingSpeed()
. And, as explained early, we shift the range into [0..1]:
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateRemovingBlock(float Seconds)
{
const float alpha = mBlinkTime.Update(Seconds);
if (alpha >= 1.0f)
{
...
}
else
{
const float intensity = (FMath::Cos(alpha * UColBPLibrary::GetBlinkingSpeed(this)) + 1.0f) / 2.0f;
for (int32 cell_index : mMatchedBlock)
{
mGridData[cell_index].BlockActor->SetIntensity(intensity);
}
return &AGameModeInGame::StateRemovingBlock;
}
return &AGameModeInGame::StateCheckPlayfield;
}
We will update the code to actually use the blinking time property shortly in this part, but for now we can test the blinking. Well, it works but it's way too slow! We see it does change the intensity level but it barely finishes the first "fade out" sequence! Let's open the BP_GameInstance
class and tweak the Blinking Speed
property. Again, this value ir meant to be your preference. If you want some reference, however, I did set this property to 22.0
after some experiments. Again, I encourage your to try out various different values and find your own preferred one.
At this point the game is already playable. However, we are not keeping track of the score. In this project we will define a relatively simple system, where each removed block will earn an specified number of points (In my sample project, 5
) and set a multiplier that will increase according to chained match runs. This means we will create two properties that can be tweaked, one for the block's score and the other that will define the delta to be applied into the score multiplier. However, in this case I will not add those to the Game Instance class. The reason is because I can't imagine this value being provided as a player preference. But then again, if you really want this possibility, feel free to follow the pattern that I have repeated multiple times in this tutorial. We will also need a property that will be used to keep "current multiplier". Granted, it is the value that will be changed throughout the game. All three properties will be added into the private section of the AGameModeInGame
:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Gameplay Settings", meta = (DisplayName = "Score Per Block", AllowPrivateAccess = true))
int32 mScorePerBlock;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Gameplay Settings", meta = (DisplayName = "Chained Multiplier Delta", AllowPrivateAccess = true))
float mChainedMultiDelta;
float mCurrentBonusMultiplier;
In the constructor we initialize with the default values. As mentioned, each block will earn 5
points, so mScorePerBlock = 5
. Then, at each chain matching run we will add 1.0
to the multiplier meaning that at a "first level chained match" the points will be doubled and at a "second level chained match" the points will tripled. If you want you can define a limit to this multiplier, however I won't in this project. With that said:
AGameModeInGame::AGameModeInGame()
{
...
mScorePerBlock = 5.0f;
mChainedMultiDelta = 1.0f;
mCurrentBonusMultiplier = 1.0f;
}
We now have to track the actual score! The question here is where should we hold the variable containing the player score? There isn't a concrete answer to this mostly because there isn't one the correct place to store the score. Granted, some classes are not exactly suitable for that depending on the project. As an example, the game mode is only present on the server, meaning that a multiplayer game would require to have player score stored elsewhere. If we study Unreal Engine's samples, most (if not all) of them keep track of the player score inside the Player Controller class. In a way it makes sense, since there is a one-to-one association between player and controller.
Our project is single player only and if desired we could track the score from the game mode class. We won't do that, however. We will follow the usual way of doing that from the player controller class. With that said, let's add a private property into the AColPlayerController
class:
UPROPERTY()
int32 mCurrentScore;
And a public function to obtain it:
UFUNCTION(BlueprintPure)
int32 GetCurrentScore() const { return mCurrentScore; }
Of course, we initialize it with a default value in the constructor:
AColPlayerController::AColPlayerController(const FObjectInitializer& ObjectInitializer)
{
mCurrentScore = 0;
}
We will certainly want to manipulate this value. Mostly, we may want to allow the player to restart the game, meaning a "reset the score function" is needed. And then, of course, we will add one function that will allow us to update the score. Both are public functions:
UFUNCTION(BlueprintCallable)
void Reset();
UFUNCTION(BlueprintCallable)
int32 ChangeScore(int32 Delta);
Their implementations are straightforward:
void AColPlayerController::Reset()
{
mCurrentScore = 0;
}
int32 AColPlayerController::ChangeScore(int32 Delta)
{
mCurrentScore += Delta;
return mCurrentScore;
}
You may have noticed that I have chosen a rather "generic" name for the Reset
function instead of specifying that it's resetting the score. The reason for this is the possibility of using the same function to perform other value resetting if necessary.
With this new code we have the foundation code to track the player score, but we don't actually have easy means to use that yet. Namely, from wherever we desire to access or manipulate the player score we have to obtain the pointer to the player controller, cast to AColPlayerController
and then call the relevant functions. We already have dealt with this kind of pattern throughout the tutorial, where we access the game instance through the blueprint function library. We will add one function to retrieve the controller pointer, already casted to AColPlayerController
. And then, the shortcut functions to obtain the current score and another to change it:
// Retrieve the player controller, caste to AColPlayerController
UFUNCTION(BlueprintPure, meta = (WorldContext = "WorldContextObject"))
static class AColPlayerController* GetColPlayerController(const UObject* WorldContextObject);
// Change the player score
UFUNCTION(BlueprintCallable, meta = (WorldContext = "WorldContextObject"))
static int32 ChangeScore(const UObject* WorldContextObject, int32 Delta);
// Obtain current player score
UFUNCTION(BlueprintPure, meta = (WorldContext = "WorldContextObject"))
static int32 GetCurrentScore(const UObject* WorldContextObject);
Because we will deal with the AColPlayerController
in the .cpp
file, we have to include the player controller's header:
#include "ColBPLibrary.h"
#include "Engine.h"
#include "ColGameInstance.h"
#include "ColPlayerController.h"
Before showing the implementation I have to mention something related to the way the player controller pointer is being retrieved here. In the project design, back in the introduction, I have mentioned about the fact that this is a single player game and we would be able to take some assumptions regarding the overall code. This is such a case. Remember that Unreal Engine associates a Player Controller to each connected player. Because of that, on multiplayer games there will be potentially multiple player controller pointers available and thus, the engine's API is built on top of this. The assumption we are going to make in here is the fact that we have a single player controller. With that said, let's see the implementation of the GetColPlayerController()
:
AColPlayerController* UColBPLibrary::GetColPlayerController(const UObject* WorldContextObject)
{
if (UWorld* world = GEngine->GetWorldFromContextObjectChecked(WorldContextObject))
{
return Cast<AColPlayerController>(world->GetFirstPlayerController());
}
return nullptr;
}
In a multiplayer game we would probably want to only access the local player controller. In this case, it would be necessary to iterate through each player controller and call IsLocalController
on each one of them. Then, there is the possibility of providing a more "generic" function allowing one to specify the index of the player controller. Again, we are working with a single player game which gives us the possibility to assume a single player controller, thus the usage of the GetFirstPlayerController()
function.
We now implement the ChangeScore()
and GetCurrentScore()
functions. On both of them we use the GetColPlayerController()
:
int32 UColBPLibrary::ChangeScore(const UObject* WorldContextObject, int32 Delta)
{
if (AColPlayerController* pc = GetColPlayerController(WorldContextObject))
{
return pc->ChangeScore(Delta);
}
return -1;
}
int32 UColBPLibrary::GetCurrentScore(const UObject* WorldContextObject)
{
if (AColPlayerController* pc = GetColPlayerController(WorldContextObject))
{
return pc->GetCurrentScore();
}
return -1;
}
Shortly we will use this new code, but before I will show some other unrelated thing, but mostly because that will also change the initialization state function. So, rather than changing now and then changing again, we can do that all in one step!
If you have played the game in its current state for a few minutes, there is the possibility that you have came in the situation where you land a piece and then, when the next one spawns you think "I wish I knew beforehand I was getting this piece so I would place the previous one in a different position". Although we are still a bit far from having the visual element, UMG, to actually display this information, we will at least add the necessary code so we will be able to easily add such functionality later.
Now, how exactly are we going to do such thing? Let's take a look at the snippet of code we are using to spawn the player piece:
mPlayerPiece.SpawnPiece([this, &spawn_row, &spawn_col](int32 Index)
{
return SpawnBlock(spawn_col, spawn_row+Index, PickRandomBlock(), false);
});
In there, we spawn a new block by specifying its type id, which if you don't fully remember is actually just an index into the block collection array inside our theme data. In this lambda function we are getting, as argument, the index of the block inside the player piece. We can use this same index to access a different array where we store the type id of each block we want to spawn. By doing that we can fill this array using PickRandomBlock()
for each entry in it and then, after spawning the player piece, substitute with new random type ids. With that said, let's add a new private array in the AGameModeInGame
class:
TArray<int32> mNextBlock;
Next we want an event system that will be used whenever we update the contents of the next piece array. At a later moment it will make things easier when dealing with the actual widget. If we go the usual pattern of creating a virtual function for native development and a blueprint implementable function, this will mean we will have to deal with the event from the game mode derived classes (C++ or Blueprint). Although fine, it also means we will have to work a little more in order to reach the widget. Instead, let's use dynamic delegate system we have created to handle the game window resize event. Just to recap there, we use a dynamic delegate to be used as a function argument and the dynamic multi cast delegate to be internally used by the game mode. In the case of this event, we want to send the array as argument. That said, after the include list and before the AGameModeInGame
declaration:
DECLARE_DYNAMIC_DELEGATE_OneParam(FOnNextPieceChangedDelegate, const TArray<int32>&, NextPiece);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnNextPieceChangedMultiDelegate, const TArray<int32>&, NextPiece);
Then, in the private section of the class (AGameModeInGame
), we declare the multi cast delegate:
UPROPERTY()
FOnNextPieceChangedMultiDelegate mOnNextPieceChanged;
Of course, we need a public function that allows us to bind functions to mOnNextPieceChanged
. So, let's declare (and implement) it:
UFUNCTION(BlueprintCallable, Category = "Event Binding")
void ListenNextPieceChanged(FOnNextPieceChangedDelegate NextPieceChangedDelegate) { mOnNextPieceChanged.Add(NextPieceChangedDelegate); }
In the Game Init
state we initialize the mNextBlock
array and fill with the IDs of the first player piece. To do that we require the size of the player piece, which we are already retrieving in that state. In order to save a call to that function, we will change things a little, by storing the return value in a variable and using that to perform our setup. And since we are changing the code in the initialization, let's also reset the player controller data! So, the updated state function:
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateGameInit(float Seconds)
{
// Obtain the player piece size
const int32 piece_size = UColBPLibrary::GetPlayerPieceSize(this);
// Initialize the player piece
mPlayerPiece.InitArray(piece_size);
// Initialize the "next piece" array:
mNextBlock.SetNum(piece_size);
for (int32 i = 0; i < piece_size; i++)
{
mNextBlock[i] = PickRandomBlock();
}
// Fire up the piece changed event
mOnNextPieceChanged.Broadcast(mNextBlock);
// Reset the player controller data
if (AColPlayerController* pc = UColBPLibrary::GetColPlayerController(this))
{
pc->Reset();
}
// Perform custom game initialization
CustomGameInit(Seconds);
// And transition into the Spawning state
return &AGameModeInGame::StateSpawning;
}
Finally, we can change the player piece spawning code to use the mNextBlock
array. Our lambda will change a little. We will take advantage of the fact that it will be called for each element of the player piece. First we store the type id of the block that must be spawned. Then substitute the relevant entry in the mNextBlock
and finally spawn the block, returning the pointer. Of course, right after that, we call the event function, OnNextPieceChanged()
:
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateSpawning(float Seconds)
{
...
mPlayerPiece.SpawnPiece([this, &spawn_row, &spawn_col](int32 Index)
{
// Store the type id of the block to be spawned
const int32 spawn_type_id = mNextBlock[Index];
// Substitute the next block type id with a new random one
mNextBlock[Index] = PickRandomBlock();
// Spawn the block
return SpawnBlock(spawn_col, spawn_row+Index, spawn_type_id, false);
});
// Fire up the next piece changed event
mOnNextPieceChanged.Broadcast(mNextBlock);
...
}
Ok, now that we have the initialization making sure the player score is 0
at the start of each game, we can now update the score. Just to recap, we have a value that will be "added" for each collected block and this result will be multiplied by a bonus that is increased by chained matches. If we take a look again at the state machine design, there is one very interesting fact in there that will greatly help detecting chained matches and when they end.
Whenever a player piece lands, we go through Check Match
, Removing
and Check Playfield
states in sequence when the landing resulted in a matching run. From the Check Playfield
we either move to the Spawning
or to the Check Match
state. This means that every time we go to the Spawning
state we are certainly out of chained matching runs and we can set the bonus multiplier to 1.0
in there. Then every time we enter the Check Match
we can increase the multiplier right before transitioning into the Removing Block
. With this, if there is a chained matching run, the next time we enter the Check Match
state the multiplier will already be at the correct value.
Let's begin our code changes by setting the mCurrentBonusMultiplier
to 1.0
inside the Spawning
state. Right after we reset the input timers, we can reset the bonus multiplier:
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateSpawning(float Seconds)
{
...
// Reset input timers
mShiftTimer = 0.0f;
mSideMoveTimer = 0.0f;
// Reset the bonus multiplier
mCurrentBonusMultiplier = 1.0f;
}
...
}
We now have to actually add to the player score as well update the bonus multiplier. We already know that we will update the bonus multiplier inside the Check Match
state. In this state, if we detect matching runs we also have easy means to know how many blocks did match. With that, we can perform a simple math operation to obtain the score delta () by simply multiplying the amount of matched blocks (), score per block () and bonus multiplier () together:
We already have access to all elements of the right side of this operation. Let's see how that is converted into code. Since we are updating the StateCheckMatch
, we will take advantage and "fix" the mBlinkTime
setup. Currently it's hard coding the wait time to 0.8
while we actually want to retrieve that from the game instance:
AGameModeInGame::StateFunctionProxy AGameModeInGame::StateCheckMatch(float Seconds)
{
if (CheckMatchingBlocks())
{
// Setup the blinking timer
mBlinkTime.Set(UColBPLibrary::GetBlinkingTime(this));
// Calculate the player score delta
const int32 score_delta = mMatchedBlock.Num() * mScorePerBlock * mCurrentBonusMultiplier;
// Add to the player score
UColBPLibrary::ChangeScore(this, score_delta);
// Increase the bonus multiplier
mCurrentBonusMultiplier += mChainedMultiDelta;
// Fire up the event
OnBlockMatched(mMatchedBlock);
// And transition into the removing block state.
return &AGameModeInGame::StateRemovingBlock;
}
return &AGameModeInGame::StateSpawning;
}
This is all fantastic, but we can't actually see the score! We can add a temporary blueprint code to display the score. In the previous part we have created an event function that is called whenever matching runs are detected. If we edit the BP_GMTraditional
blueprint class, we can right click an empty area in the blueprint editor, in the Event Graph
tab, search for event OnBlockMatched
(case is not sensitive in that search box) and then add the node, effectively allowing us to implement the blueprint version of this event function. From there we just add a Get Current Score
node and hook up directly into the In Text
input of the Print Text
node. The blueprint editor will automatically create the conversion from integer to text:
In this part we have incorporated some blinking into the blocks whenever those are about tho be removed from the grid. We have also implemented the scoring system with some rudimentary means to display the score value. We still need quite a bit of code before we can start working on the UMG widgets, and that code we will see in the next part.