Tutorial (Godot Engine v3 - GDScript) - Sprite formations!

in #utopian-io6 years ago (edited)

Godot Engine Logo v3 (Competent).png Tutorial

...learn how to create formations of sprites!

What Will I Learn?

This tutorial builds on the last, which explained how to create lots of Sprites!.

In this article, you will learn how to group the individual Invaders in a better way.

..." but they were moving in formation, were they not?"

... I hope to hear you cry!

Yes, but no! Yes, they did move in a coordinated manner, but the implementation of the script was deliberately poor! In fact, somebody (they know who they are) raised this concern to me; which is extremely pleasing and flattering because it confirmed I'm walking you at the pace that I had intended to in this tutorial!

As previously stated, there is NEVER a concrete right or wrong way to develop code. There are good designs, fast implementations or spagehti mess, but never PERFECT code because it can always be tweaked, transformed or rewritten; much like a novelist writing an epic story. Beauty can be found in well-constructed code, but often, it'll only deliver a specific need. Programmers can simply go mad in the quest of reaching perfection with a small routine, simply, just let it go!

I've already demonstrated the power of Godot Engine:

  • by using a single instance, to demonstrate how Invaders can be quickly created
  • by using a grouping of Nodes, to show cross communication between them

However, to do so, I deliberately had to choose not to optimise the example! My experience nagged and knawed at me, crying out how I was creating sub-optimal code. However, I held-off the cleansing and refactoring process, because otherwise, how will you learn?

In this session, I'm going to explain my thinking behind some code restructuring, so that you can learn. To force 'change', I've decided the following enhancements are desirable:

  1. Smooth the Invader movement
  2. Accelerate and de-accelerate at the borders (to protect the little Invaders from smashing their heads on the inside of their ships)
  3. Break the monotony of the static grid, by adding wave patterns to their movement
  4. Design an implementation that allows for future pattern changes
  5. Optimise the implementation

To achieve this, I was forced to make drastic simplifications. The net result was to improve the quality of the game, whilst ensuring actor responsibilities were delegated correctly.

Actors are as you would expect in a play or a film; the actors of your game; the Godot Scene's you design and develop and then instantiate

By the end of this tutorial, you will understand the improved script for forming waves of Invaders. They'll move with a smooth vigour whilst benefitting from a more efficient solution.


New Formation.gif

Note: I will not implement all of the new enhancements in this session.

Assumptions

You will

  • Change the Formation implementation
  • Amend the alignment of the Invaders in the grid
  • Detect the Formation reaching its goal and stopping any more movement

Requirements

You must have installed Godot Engine v3.0.

All the code from this tutorial will be provided in a GitHub repository. I'll explain more about this, towards the end of the tutorial.

Going forward - a small note

In my previous posts, I've stated opinion, advice and thoughts on different topics; such as scripting languages versus interpreted ones, how Trees are used in Computer Science and so forth.

I'd already considered splitting these out into separate articles and that mirrors feedback I have received from others.

These tutorials will, therefore, focus solely on the 'coding' and 'design' side of this specific development. I will try to minimise other subjects and will branch them off to their own posts where required.


Last time

I left you pondering over a question, to recap:

The Invaders are falling off the bottom of the screen. Why????

More of a proverbial question because I hope you were thinking ahead. Quite simply:

I'd not enforced a screen height check with the Invaders.

As soon as one detects the bottom (much like the side borders), it should shout the fact and the entire fleet would stop and triumph in the battle against humanity!

Well done if you managed to consider this and secondly, big A* to anyone who had the confidence to code it.

Implementation Design

A 'fag-packet' design has been shown below, to represent the existing Scene implementation:

Fag-packet: Cigarette boxes are, therefore it is said that any design that can be drawn on one, must be good; often drawn by geeks down the pub whilst supping a nice beverage


image.png

The diagram depicts the Game Scene, which has an attached script that creates the Invader Scene instances and places them in formation.

The Invader instances assume independent thought and movement. I.E. each moves, checks whether they encroach a border and then calls out to the others to change direction. That means every Invader must 'listen' to events thrown at them. Further to this, there was a need to record the fact that a 'shout' occurred and not to respond to duplicates.

This emits a 'design smell'; something that doesn't feel right, but what?

The only reason the Invaders need to talk to each other is to preserve the formation when changing direction. Instead, what if we delegated this to a controller? Someone with a big megaphone? Their job is to ensure all Invaders stay in position and follow its command.

For this tutorial, I'm going to call that controller Invaders, implement it as a Scene and add many Invader instances to it, as children:


image.png

The design doesn't change much, but this single change will ripple through space and time, causing simplification to the implementation. The yellow box has NOW evolved into a Scene.

When you stumble on a change that simplifies the solution, that often is the RIGHT choice. Remember, simplicity is OFTEN best for performance and readability (although there are always exceptions!)

It will be assigned the following responsibilities:

  • Create the formation of Invader instances
  • Determine how to move the Invader instances as one unit
  • Check each Invader, in turn, looking for when to reverse direction
  • Drop the formation down when a reverse occurs
  • Check for conquering the pesky humans; i.e. reached the bottom

From this list, you'll observe that most of these (if not all) responsibilities already exist in the Invader implementation. This is a good indicator that you had a poor design. By promoting the responsibilities upwards, into a controller, the entire design simplifies.

Let's implement this change!

Create new Invaders Scene

Please:

  • Create a new Scene
  • Add a Node2D as the root, because it has the capability to be placed on the screen
  • Save the scene (I saved it in a folder called Invaders. Then remember to save the scene)
  • ... finally, attach a new script

The following code should be pasted in:

extends Node2D

const INVADER = preload("res://Invader/Invader.tscn")

var screenWidth = ProjectSettings.get_setting("display/window/size/width")
var screenHeight = ProjectSettings.get_setting("display/window/size/height")

var direction = Vector2(400, 0)

func addAsGrid(size):
    for y in range (size.y):
        var newPos = Vector2(0, y)
        for x in range (size.x):
            newPos.x = x
            createInvader(newPos)

func createInvader(pos):
    var invader = INVADER.instance()
    invader.position = pos * invader.size
    add_child(invader)

func moveFormation(delta):
    position += direction * delta
    if direction.y > 0.0:
        position.y += direction.y
        direction.y -= 1.0

func checkBorderReached():
    for invader in get_children():
        if hitLeftBorder(invader) or hitRightBorder(invader):
            direction.x = -direction.x
            direction.y = 8.0
            break

func hitLeftBorder(invader):
    if direction.x < 0.0:
        if invader.global_position.x < 0:
            return true
    return false

func hitRightBorder(invader):
    if direction.x > 0.0:
        if invader.global_position.x > screenWidth:
            return true
    return false

func _process(delta):
    moveFormation(delta)
    checkBorderReached()

The code looks like this in my editor (remember to sort the tabulation):


image.png

Let's work through this code:

extends Node2D

const INVADER = preload("res://Invader/Invader.tscn")

var screenWidth = ProjectSettings.get_setting("display/window/size/width")
var screenHeight = ProjectSettings.get_setting("display/window/size/height")

var direction = Vector2(400, 0)
  • We extend the Node2D class
  • Set-up a constant to our Invader Scene (which we originally had in our Game Scene script)
  • Capture the screen dimensions in local variables
  • ...and finally set-up a Vector2 with our initial direction for the Invader movement

NOTE: the scale has changed, because we are going to be moving the single Node2D, which has all the Invader instances attached as children. By moving this node, the children move too. The 400 in the X-axis means move 400 pixels in 1 second. The screen is set to 1024 pixels wide, thus it will take a little over 3 seconds from border to border.

func addAsGrid(size):
    for y in range (size.y):
        var newPos = Vector2(0, y)
        for x in range (size.x):
            newPos.x = x
            createInvader(newPos)

This replaces the original init function found in the Game Scene script.

I've simplified the creation by delegating the creation of the invader into a secondary function:

func createInvader(pos):
    var invader = INVADER.instance()
    invader.position = pos * invader.size
    add_child(invader)

This function is a slave to the previous. It has been spilt this way because, for me, it makes it much more easier to understand. Secondly, each function has a specific task to perform, i.e. the first constructs the layout, whilst this second creates the Invader. There is also the added benefit that the second function may be reused (..that's my intention!)

One very important observation to make is that the invader position uses the 'size' of the invader itself. This is provided by the Invader instance (see the next section). This 'size' variable is very important because it allows each Invader to be placed precisely side by side without any overlap.

func moveFormation(delta):
    position += direction * delta
    if direction.y > 0.0:
        position.y += direction.y
        direction.y -= 1.0

This, when called with delta time, moves the current formation on screen. If direction down has been set, it decreases it per call, thereby ensuring the Invaders drop a little before resting.

I've omitted a discussion on delta time. This is a special unit of time that all game engines use to smooth movement and communication. When the process method in any Godot Engine node is called, the delta time is provided. Delta time is a simple topic, but one that is hard to grasp, therefore I will write a separate article shortly to explain its purpose and use.

For now, delta time is to used to ensure the formation travels 400 pixels every second, no matter the Frames Per Second that the game may execute at. Given the screen is 1024 pixels, you can, therefore, assume a movement from one side to the other takes a little over 3 seconds.

func checkBorderReached():
    for invader in get_children():
        if hitLeftBorder(invader) or hitRightBorder(invader):
            direction.x = -direction.x
            direction.y = 8.0
            break

This performs the function of determining whether any invader has touched a border and then reverses the formation's direction. It negates the need for the Node Group communication found in the previous article.

  • A loop for every child of the Invaders scene is used to gain a reference to each Invader
  • For the given Invader a check is called, via two utility functions; checking each of the horizontal borders
  • If either is true, the horizontal direction is reversed and the Invader is set to drop down
  • ... when true, break, a special script term which forces the current loop to stop, is called. In this case, we want to stop looking for any more Invaders crossing the border, because we already have one! We only need to check until the first case is found, thus this is more efficient than the Node Group communication method. Every Invader in that solution needed to listen, shout or react!
func hitLeftBorder(invader):
    if direction.x < 0.0:
        if invader.global_position.x < 0:
            return true
    return false

The first of the two border utility functions. This determines whether the Invader has crossed the left hand border.

  • It first checks the current movement is left (it would be -8.0)
  • If the Invader is moving left, then check whether it has crossed the left border
  • Given both states are true, then return true
  • Otherwise default to returning false

It is important to test that the Invader is moving left as well as having crossed the border, to avoid a specific bug.

It is possible for the Invader to cross the border in such a way that although the direction is reversed, the Invader still remains over the border edge; which results in a double reversal via another check. This results in a strange wobble effect and a random conclusion.

func hitRightBorder(invader):
    if direction.x > 0.0:
        if invader.global_position.x > screenWidth:
            return true
    return false

The second utility class is very similar to the first, but checks the Invader is moving right and has crossed the right border

func _process(delta):
    moveFormation(delta)
    checkBorderReached()

... finally, the process method is called every frame by the Godot Engine, along with the delta time. This relays the information to the moveFormation function and then checks for any border hit.

Note: although this is all you need to add for the Invaders scene, the game will fail if you try to run it. We need to alter the Invader and Game Scenes; given we have moved responsiblities and the functionality that goes with it.

Invader script alterations

The Invader is drastically simplified because it has now delegated the formation movement logic to the Invaders instance! This means you can delete all of the original lines and replace them with these:

extends Sprite

var size

func _init():
    size = texture.get_size() * get_scale()

In the editor:

image.png

As you can see, this is significantly less script. Let me explain it:

extends Sprite

Extend the Sprite class, as before

var size

Allocate a class variable called size, this can and IS accessed by the Invaders createInvader function. It is populated by the following init function.

NOTE: I've just noticed a mistake here! See if you can figure it out. Hint, think about the function that calls the createInvader; specifically, what its responsibility is and the problem I will have with reusing this function.

func _init():
    size = texture.get_size() * get_scale()

For every object created, the _init_ialise method will be called first; therefore, the calculation of the size of the Sprite is made here. The Size of the Texture is obtained and it is scaled. Unfortunately, there isn't a built-in Sprite property for this, but I personally think it would have been a useful exposure by Godot.

That's it! The Invader is very dumb now.





Although it would be sensible if you were to select the Invader root node, select Node > Groups in the Inspector and delete the Invaders label, because this is not now needed for the communication:



Game script alterations

The Game script becomes much more compact now, too:

extends Node

const INVADERS = preload("res://Invaders/Invaders.tscn")

func _ready():
    var invaders = INVADERS.instance()
    invaders.addAsGrid(Vector2(8, 4))
    add_child(invaders)

Or as seen in the editor:

image.png

Let's analyse the code:

extends Node

The same vanilla Node is extended

const INVADERS = preload("res://Invaders/Invaders.tscn")

A new constant is loaded with our new Invaders class (make sure you get the folder structure correct)

func _ready():
    var invaders = INVADERS.instance()
    invaders.addAsGrid(Vector2(8, 4))
    add_child(invaders)

A new ready function is added, which is automatically called once by Godot Engine after all other children nodes have been added to the Scene.

  • An Invaders instance is created
  • The instance is asked to create an 8 by 4 grid of Invaders
  • Finally, the Invaders instance is added as a child, and thus, shows on screen

NOW try running the game! You should experience a new, improved and rather smooth formation of Invaders:


New Formation.gif

Notice how the Invaders are all now butting up against each other; we'll fix that in a section below.

Amend the alignment of the invaders in the grid

Previously, I mentioned that I realised there was a bug in my Invaders createInvader function:

func createInvader(pos):
    var invader = INVADER.instance()
    invader.position = pos * invader.size
    add_child(invader)

The assumption I had when I created this was that it would be reused by other formation functions when added.

Currently, the addAsGrid function is the only type of formation and is the sole consumer of the createInvader function.

When I created the createInvader function, I knew that I wanted to space the Invader by its size, to ensure they are evenly spaced, to avoid touch, but be as close as possible. However, if I were to reuse this function, applying the size to the position may not be desired; in fact, highly unlikely.

Instead, what I needed to do was apply the size in the addAsGrid function and pass the absolute and final position to the createInvader function; thereby, allowing the create function to be used by other formation functions.

...but a catch-22 arose, because the Invader instance is not available in the addAsGrid function!

Therefore, it's not possible to calculate the position until the Invader is created, because the size is required to calculate the position, which is passed! DOH.

A compromise is required. This allows for the reusability of the createInvader function, by passing the reference back, thereby enabling the addAsGrid to set it's postion, like so:

func addAsGrid(size):
    for y in range (size.y):
        var newPos = Vector2(0, y)
        for x in range (size.x):
            newPos.x = x
            var invader = createInvader()
            invader.position = newPos * invader.size

func createInvader():
    var invader = INVADER.instance()
    add_child(invader)
    return invader

or as seen in the editor:

image.png

  • Line 15: Now calls the createInvader function with no parameter. In return, it expects to receive the new Invader instance
  • Line 16: Sets the position of the new instance, using the grid position * Invader size
  • Line 18: The pos parameter has been removed
  • Line 21: Returns the new instance of Invader

This now works well, but doesn't fix our shoulder to shoulder Invaders issue.

Let's give them a 5 pixel padding, by changing Line 16:

        invader.position = (newPos * invader.size) + Vector2(x*5, y*5)

For each (grid position * Invader size), we have to add 5 pixels to both coordinates per grid position

The script in the editor looks like this:

image.png

... and the result on the formation is great!


image.png

Detect the end of Humanity!

We need to address the Invaders wiping us out! Their goal is to reach the bottom of the screen or to KILL Humanities only hope, with a Fricking Lazer Beam (or a simple bullet).

The screen height can be used, much like the left and right border checks, therefore we need to add a couple of new methods and amend the proc in the Invaders script:

func checkForWin():
    for invader in get_children():
        if hitBottomBorder(invader):
            direction.x = 0
            break

func hitBottomBorder(invader):
    if invader.global_position.y > screenHeight:
        return true
    return false

func _process(delta):
    moveFormation(delta)
    checkBorderReached()
    checkForWin()

as seen in the editor:

image.png

Let's examine the code:

func checkForWin():
    for invader in get_children():
        if hitBottomBorder(invader):
            direction.x = 0
            break

The new method checks whether the Invaders have reached the bottom of the screen

  • Loop through all the invaders
  • If the current invader has hit the bottom
  • ... then set the horizontal movement to zero; freezing the formation (we'll need to change this in the future to change the game state to "end")
  • ... finally break out of the loop as the game has been ended
func hitBottomBorder(invader):
    if invader.global_position.y > screenHeight:
        return true
    return false

This method is a simplified version of the left and right border checks. The Invader global height position is checked against the height of the screen. If it has passed the threshold, it returns true, otherwise it returns false.

func _process(delta):
    moveFormation(delta)
    checkBorderReached()
    checkForWin()

The checkForWin function has been added to the list of calls to make for each process frame.

Finally

That concludes this issue. I will follow these up shortly with several more:

  1. How to add smooth transitions (acceleration and de-acceleration) to the formation
  2. Adding graphics to the backdrop and Invaders
  3. Adding the player ship and firing a weapon

Please do comment and ask questions! I'm more than happy to interact with you.

Sample Project

I hope you've read through this Tutorial, as it will provide you the hands-on skills that you simply can't learn from downloading the sample set of code.

However, for those wanting the code, please download from GitHub.

You should then Import the "Space Invaders (part 2)" folder into Godot Engine.

Other Tutorials

Beginners

Competent



Posted on Utopian.io - Rewarding Open Source Contributors

Sort:  

Thank you for the contribution. It has been approved.

You can contact us on Discord.
[utopian-moderator]

Hey @roj, I just gave you a tip for your hard work on moderation. Upvote this comment to support the utopian moderators and increase your future rewards!

@sp33dy, Upvote is the only thing I can support you.

Any support is great support; thanks

Great tutorials, how is your java? We could definitely need some good devs in our project =) (Minecolonies)

Java, I'm an expert :) Seriously, I'm an I/T Architect with a solid ~18 years Java background as a programmer. Unfortunately, I've got enough going on right now.

What a pity, we could need some additional manpower, we got a great team assembled already but we have a lot planned as well. We had around a million download in the last half year so if you're interested and with spare time one day hit me up =)

I definitely think we should talk at some stage. I even considered writing games in Java myself, but jumped on Godot, as it has easier support for different platforms. I assume you aren't hitting out at tablets/mobiles? My expertise is really eCommerce and back-office integrations, especially Java performance tuning.

Our application runs on the client and server of Minecraft.
It's quite a performance critical on the server as we have a lot of entities and at the same time, a lot of players and all of that currently runs on one core (The Minecraft world ticks entities only on one core).
Performance tuning could be definitely something we could need.
We also wanted to set up a nice system which logs us the performance of the different systems we have, so we discover our weak points more easily, but as I wrote before, we're missing manpower for those tasks.

Hey @sp33dy I am @utopian-io. I have just upvoted you!

Achievements

  • You have less than 500 followers. Just gave you a gift to help you succeed!
  • Seems like you contribute quite often. AMAZING!

Suggestions

  • Contribute more often to get higher and higher rewards. I wish to see you often!
  • Work on your followers to increase the votes/rewards. I follow what humans do and my vote is mainly based on that. Good luck!

Get Noticed!

  • Did you know project owners can manually vote with their own voting power or by voting power delegated to their projects? Ask the project owner to review your contributions!

Community-Driven Witness!

I am the first and only Steem Community-Driven Witness. Participate on Discord. Lets GROW TOGETHER!

mooncryption-utopian-witness-gif

Up-vote this comment to grow my power and help Open Source contributions like this one. Want to chat? Join me on Discord https://discord.gg/Pc8HG9x

1UP-Kayrex_tiny.png

You've got upvoted by Utopian-1UP!

You can give up to ten 1UP's to Utopian posts every day after they are accepted by a Utopian moderator and before they are upvoted by the official @utopian-io account. Install the @steem-plus browser extension to use 1UP. By following the 1UP-trail using SteemAuto you support great Utopian authors and earn high curation rewards at the same time.


1UP is neither organized nor endorsed by Utopian.io!

Coin Marketplace

STEEM 0.20
TRX 0.13
JST 0.030
BTC 65306.74
ETH 3488.89
USDT 1.00
SBD 2.51