• Andrew Jonhardt

Godot instancing fun

Instancing in game engines typically relates to using code to generating a copy of an object while a program is running. This is not the best explanation for what instancing is. However, the full explanations trip and fall down confusing rabbit holes related to what a class is in scripting, and that's way more than anyone actually needs to know to read this blog post.


Say you have an enemy that needs to shoot something out from themselves that isn't bullets (bullets are usually handled differently). An enemy that hocks up a mouthful of toxic, corrosive spit and shoots it at your player. And, you need that spit to turn into a dangerous splotch on the wall when it hits.


I know, that's a very specific use case, but it's one of the instancing issues I found myself tackling these past 2 weeks, and it's the one I feel best equipped to go over.


First, let's start with the enemy, the Spitter:

extends KinematicBody2D

var spit_ref = preload("res://Enemies/Basic/FlyingSpit.tscn")

onready var detect = $DetectionZone
onready var timer = $SpitTimer

export(float) var wait_max = 3
export(float) var wait_min = 0.2
export(int) var health = 2

var wait_time = 0
var pause = true

The important variable here is spit_ref. The other variables relate to how long the Spitter waits between shots, how it notices the player is nearby, and how much health it has.


Somewhere else in my game, there is a scene I've named "FlyingSpit". My enemy, the Spitter, needs to know where the scene is and needs to preload it in order to effectively copy from it.


func _ready():
	randomize()
	wait_time = rand_range(wait_min,wait_max) 
	timer.wait_time = wait_time

At some point, I decided it would be interesting if each Spitter in my game shot spit after a random wait time. Randomization in Godot is kinda funny though. Every random element works off the same seed when the game starts. So, I used Godot's randomize() function to ensure each Spitter gets a different randomization seed (so each Spitter's wait time is actually random).


Unfortunately, randomizing how long Spitters wait to shoot makes approaching any given Spitter just hard enough to be annoying, so I'm planning to change this later.


func _process(delta):
	if detect.can_see_player() and pause == false:
		var dir = self.global_position.direction_to(detect.player.global_position)
		var spit = spit_ref.instance()
		spit.toward_player = dir
		self.call_deferred("add_child",spit)
		spit.global_position = Vector2(20,20) * dir
		timer.wait_time = wait_time
		timer.start()
		pause = true

Now, per the _process function above, the Spitter enemy doesn't move. It waits for the player to enter its range. Once the player is in range, the Spitter reaches into the "detect" variable, which is a reference to an Area2D node attached to the Spitter called "DetectionZone", and pulls out the player's real position in the game world. The Spitter then uses Godot's direction_to() function to compare the player's position to its own to determine a direction.


Now, fully equipped with the player's direction, the Spitter instances spit. Essentially, the scene that was preloaded earlier in the program is copied whole into the game. But, there's a problem: The instanced spit is also outside of the game world!


A brief aside that I'll tie back to the main point in a moment:

One of the things I like about Godot is the clarity of its structure. Each scene, and the engine itself while running, is broken down into a tree structure. I'm sure most game engines operate the same way, but Godot is the only game engine I've tried where you can view the structure while running your game.


















The above screenshot is from a running prototype level. Everything listed under "Prototype" composes the level a player sees and plays: the tilemaps I used to build the level, the enemies, the player character, the traps, and so on. Above Prototype are the 2 global scripts I'm currently using, Bounce and Global. Global carries information between scenes/levels, and Bounce is referenced by objects that need to physically bounce. Last but not least is the "root" node, which is the parent of all other nodes in a running scene.


The point here is that everything should be a child under the root, and anything you actually want to use in a scene should either be a child of the level itself or a global script of some kind. Instanced objects must be parented under another object in a scene, and in the script above I've used self.call_deferred("add_child",spit) to make the spit a child of the Spitter itself. I had to use "call_deferred" for technical reasons I don't fully understand.


Making an instanced attack a child of an attacker, as I've done with the Spitter and spit, is typically not advised. If the player kills the Spitter, any spit that is currently present will be destroyed soon after. I prefer this approach for Mask, however, because the levels are tight and it feels like a reward for getting the last hit on the Spitter.


The line

spit.toward_player = dir

reaches into the newly-instanced spit and modifies a variable called "toward_player" with the previously-obtained dir variable.


spit.global_position = Vector2(20,20) * dir

sets the instanced position of the spit to be in front of the Spitter in the direction facing the player.


The remaining code works in combination with

func _on_SpitTimer_timeout():
	pause = false

to force the Spitter to pause after each shot.


Now, wasn't that alot to go over? Well, now we gotta cover the spit itself!




extends Area2D

var splat_ref = preload("res://Enemies/Basic/WallSplit.tscn")

var toward_player = Vector2.ZERO
var speed = 100
var damage = 1
var end_self = false

Looky looky, the spit has it's own preloaded scene. This is how I solved the problem of the spit going splat and goo-ing up walls: The spit itself has preloaded its own splat, here called "WallSplit" because I occasionally mistype things and fail to notice until it'd be a hassle to change. The spit also has speed, damage, and a suicide variable.


func _process(delta):
	if end_self == true:
		queue_free()
	if toward_player == Vector2.ZERO:
		queue_free()
	else:
		self.position += toward_player * delta * speed

In the _process function, we first check if the spit still needs to exist. If it does, we then check that we have a player position. If we don't, bye-bye spit. If we do, the spit speeds towards the player position the Spitter gave it.


func _on_FlyingSpit_body_entered(body):
	if body.name == "Player":
		self.queue_free()
	else:
		var splat = splat_ref.instance()
		var main = get_tree().current_scene
		main.add_child(splat)
		splat.global_position = self.global_position
		end_self = true

Now, the spit is an Area2D, so it has the ability to easily check collisions (kinematicbodies can, too, but they're not as easy or reliable). The spit needs to be able to collide with 2 things: the player, and walls. If the spit hits the player, the damage value is relayed to the player and the spit destroys itself. However, if the spit misses and hits a wall, it instances a dangerous splotch of goo at the exact spot where it struck the wall before destroying itself.


In the case of the spit, we cannot have the splat made by the spit set as a child of the spit. The splat would just disappear with the spit when the spit destroys itself. So, we can use

get_tree().current_scene

to get the parent of the entire level, Prototype. We then make the splat a child of the Prototype level, and we set the splat to have the same position as the spit before the spit vanishes.


All well and good. But, what if spit hits a spot where spit has already been? Won't this cause an issue of stacking splotches on walls until the game crashes?


Probably, so here's my solution:

func _on_FlyingSpit_area_entered(area):
	if "WallSplit" in area.name:
		self.queue_free()

The above function checks if spit has entered a pre-existing splat. If so, no new splat is created. The spit just destroys itself.


Now, I could go over the wall splat, but all I needed was 1 line:

var damage = 1


I'm in the process of adding more, but that's for later.


Feel free to let me know if there's a better way to do what I've done above. 1 condition! You have to explain yourself.


My next blog post will be on the 5th of next month. Until then!

© 2023 by Andrew Jonhardt. Proudly created with Wix.com