Section 8: Finishing Touches

In this section, we'll add finishing touches to your game by adding a reward system, adding skills, such as bombs and lightning, and then finally applying effects with shaders and particles.

Section Intro - Finishing Touches

There are no notes for this lecture.

Preparing the Reward

In this lecture, we took the time to prepare the UI to display the reward and treasure chest to the player. We had the opportunity to explore how to work with spritesheets too. There are no notes for this lecture.

Creating a Reward Resource

In this lecture, we created a resource for storing a reward that will be given to a player when they open a treasure chest. Using a resource is great for when you need to share the same piece of information to multiple nodes across scenes. We created a class called RewardResource:

public partial class RewardResource : Resource
{
    [Export] public Texture2D SpriteTexture { get; private set; }

    [Export] public string Description { get; private set; }

    [Export] public Stat TargetSat { get; private set; }

    [Export(PropertyHint.Range, "1,100,1")]
    public float Amount { get; private set; }
}

In this class, we're storing four pieces of information which is the texture that will be displayed in the UI, a description of the reward, the stat to update, and the value to update the stat by.

In addition to creating the resource, we also toggled the icon above the treasure chest. To do this, we registered methods to the BodyEntered and BodyExited signals.

public override void _Ready()
{
    areaNode.BodyEntered += (body) => spriteNode.Visible = true;
    areaNode.BodyExited += (body) => spriteNode.Visible = false;
}

From these methods, we're just toggling the visibility, which is why we're using lambda functions.

Applying Rewards

In this lecture, we applied the reward to the player when they opened the chest. Most of what we did are things we've done before. The newest thing we learned was about the Monitoring property on an Area3D node. This property tells an area node that it should monitor for other area nodes or bodies.

By default, this property is enabled, but you will want to disable it so that the player can't redeem the same reward multiple times. We did so in our class.

areaNode.Monitoring = false;

Preparing the Bomb

In this lecture, we prepared the bomb by adding two animations for expanding the bomb and the explosion itself. There are no notes for this lecture.

Exploding the Bomb

In this lecture, we learned how to make the bomb explode by instantiating it in our game. First, we had to switch from the Expand animation to the Explosion animation. To do this, we subscribed to the AnimationFinished signal from the node responsible for playing the animation like so:

private void HandleExpandAnimationFinished(StringName animName)
{
    if (animName == GameConstants.ANIM_EXPAND)
    {
        playerNode.Play(GameConstants.ANIM_EXPLOSION);
    }
    else
    {
        QueueFree();
    }
}

Before playing the Explosion animation, we're checking if the current animation that just finished playing is the Expand animation with the help of the animName parameter, which contains the current animation name. If so, we're switching to the Explosion animation. Otherwise, we're deleting the bomb since after a bomb explodes, it should disappear from the scene.

In the PlayerDashState class, we're instantiating the bomb right when the state is entered. First, we need a reference to the scene.

[Export] private PackedScene bombScene;

The PackedScene is the class you'll want to use when trying to store a scene in a variable. Next, we instantiated the bomb with the following code:

Node3D bomb = bombScene.Instantiate<Node3D>();
GetTree().CurrentScene.AddChild(bomb);
bomb.GlobalPosition = characterNode.GlobalPosition;

To instantiate a scene, we're using the Instantiate method on the scene. In addition, we can pass in a generic to specify the root node's type.

Afterward, we're adding the scene to the root node of our scene since it's not automatically added after instantiation. We can use the AddChild method to perform this task, which accepts the scene to add.

Lastly, we're positioning the bomb in the same position as the player when they begin their dash.

Using Interfaces

In this lecture, we fixed an issue with only being ab le to damage enemies with attacks and abilities by using interfaces. Interfaces are a feature in C# that are similar to abstract classes. They allow us to describe the methods found in a class. Unlike abstract classes, methods in interfaces are not allowed to have implementations, thus forcing child classes to provide the implementation.

To define interfaces, you use the Interface keyword followed by the name of the hitbox.

public interface IHitbox
{
    public float GetDamage();
}

In this example, we're defining an interface called IHitbox. It's common practice to start interface names with a letter I to help other developers identify it as an interface. Secondly, methods in an interface, don't contain an implementation, so it's perfectly fine to end a method definition with a ; instead of curly brackets.

To apply an interface, you must add the interface with the inherited classes like so:

public partial class AttackHitbox : Area3D, IHitbox
{
}

Even though it's not allowed to inherit from multiple classes, it's acceptable to add multiple interfaces. All you have to do is comma separate the classes and interfaces like the example above.

Thunder Combo Damage

In this lecture, we took the time to add another ability for performing a lightning attack when a player performs a successful combo attack. In the PlayerAttackState, we're checking if all combo attacks were performed by comparing the current attack and the maximum combo attack.

private void HandleBodyEntered(Node3D body)
{
    if (comboCounter != maxComboCount) { return; }

    Node3D lightning = lightningScene.Instantiate<Node3D>();
    GetTree().CurrentScene.AddChild(lightning);
    lightning.GlobalPosition = body.GlobalPosition;
}

Afterward, we just instantiate the lightning scene and then move it over to the enemy's current position.

Creating a Shader

In this lecture, we learned how to create a shader. Shaders are programs for manipulating the graphics in our game. Most game engines use shader languages. Godot is no exception. It provides a language that is similar to GLSL.

For our first shader, we decided to create a shader to change the color of a sprite. In our shader file, we first set the rendor mode to spatial since we're trying to manipulate a 3D node.

shader_type spatial;

Next, we enabled the unshaded mode to prevent lighting from affecting our shader and the cull_disabled mode to apply the shader to both sides of a sprite.

render_mode unshaded, cull_disabled;

Afterward, we added uniform variables to allow them to be modified outside of the shader. First, we have a variable for keeping track of if the shader should be applied to the image. Secondly, we're storing a color. Lastly, we're storing the last texture stored in the sprite.

uniform bool active = false;
uniform vec4 flash_color: source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform sampler2D tex: source_color;

Afterward, we defined a fragment() function, which gives us access to each pixel in the sprite. In this function, we're storing the current color of the pixel with the help of the UV variable, which stores the coordinate of a specific pixel in the sprite.

Once we have the color, we're setting the ALPA to the current alpha of the image, then we proceed to check if the shader should be active. If it is, we'll override the color with the flash_color variable. Otherwise, we're using the original color of the image.

void fragment()
{
	vec4 color = texture(tex, UV);
	
	ALPHA = color.a;
	
	if (active == true)
	{
		color = flash_color;
	}
	
	ALBEDO = vec3(color.r, color.g, color.b);
}

Dynamically Applying a Shader

In this lecture, we're dynamically applying the shader to our sprites. First, we need a reference to the shader. To store the reference, we created a field with the ShaderMaterial type.

private ShaderMaterial shader;

Afterward, to store the shader, we're accessing the MaterialOverlay property, which contains our shader. Since materials can store different types of resources, we're casting the property to ShaderMaterial so that it's compatible with our variable.

shader = (ShaderMaterial)SpriteNode.MaterialOverlay;

Next, we subscribed to the TextureChanged signal to tell us when the texture changes on the sprite from the animation player node.

SpriteNode.TextureChanged += HandleTextureChanged;

From the method handler, we updated the shader's tex variable with the SetShaderParameter method.

shader.SetShaderParameter(
    "tex", SpriteNode.Texture
);

We used the same method for setting the active parameter too.

Stunning Enemies

In this lecture, we updated our interface to allow our attacks or abilities to stun the enemy. In the interface, we added the CanStun method like so:

public interface IHitbox
{
    public float GetDamage();
    public bool CanStun();
}

We applied this method in both the AttackHitbox and AbilityHitbox classes like so:

public bool CanStun()
{
    return false;
}

Adding Fire Particles

In this lecture, we learned how to use Godot's particle system to create a fire. There are no notes for this lecture.

Dash Cooldowns

In this lecture, we applied a dash cooldown to prevent players from constantly dashing. To perform this task, we added a delegate with the Func type. Unlike the Action type, we're allowed to return values from our functions. The return type must be specified as a generic.

public Func<bool> CanTransition = () => true;

By default, we're always going to assume the state can be transitioned into by calling this method. If a state wants to override this method, they can. In our dash state, we used the IsStopped method to prevent the player from transitioning into this state while the timer is running.

CanTransition = () => cooldownTimerNode.IsStopped();

Lastly, in our state machine, we're calling this method to check if we can transition into the new state.

if (!newState.CanTransition()) { return; }

Last updated