Topdown Tank Battle: Part 9
Thu, May 31, 2018In this tutorial series, we’ll walk through the steps of building a 2D top-down tank game using Godot 3.0. The goal of the series is to introduce you to Godot’s workflow and show you various techniques that you can apply to your own projects.
This is Part 9: Obstacles (and tool scripts)
You can watch a video version of this lesson here:
Introduction
In this part, we’re going to add obstacles to the world and begin to think about how levels are going to be designed.
Small additions
Before tackling the obstacles, a couple of small changes to the code.
First, we’re adding a machine gun turret, which is a stationary enemy. It inherits
from EnemyTank
and has all the same features, but its speed is set to 0
so
that it doesn’t move.
Second, change the EnemyTank script so that it doesn’t use the LookAhead raycasts when it’s not a moving object:
func control(delta):
if parent is PathFollow2D:
if $LookAhead1.is_colliding() or $LookAhead2.is_colliding():
speed = lerp(speed, 0, 0.1)
else:
speed = lerp(speed, max_speed, 0.05)
parent.set_offset(parent.get_offset() + speed * delta)
position = Vector2()
else:
# other movement code
pass
Obstacles
In the art pack there are sandbags, trees, barriers, and many other useful objects to decorate the world and make it less empty. However, because they are not all the same size or shape, they will require different collision shapes. This means we can’t really use a TileMap to place them. In addition, we also want to be able to place them at any angle, not just the orthogonal rotations allowed in TileMaps.
So what approach should we use? We could make a bunch of different StaticBody objects, but that is going to be hard to manage with so many. Instead, we can make a single collision object that can be made to represent all of the possible obstacles. The idea is that as we get further into the project, we could turn this into a full level-design tool.
Obstacle scene
Create a new Obstacle
scene with a StaticBody, Sprite, and CollisionShape2D, saving
it in a folder called “environment”. Drop the asset sheet into the Sprite’s Texture
and set its Region on.
Now, rather than selecting the region manually in the editor, we want to automatically grab all the obstacle images out of the sheet. Fortunately, when Kenney creates his art packs, he includes an xml file like the following along with the spritesheets. This file lists the coordinates of every image, making it easy to find them programmatically. Since we don’t need all of the objects, just the obstacles, we can create a new file listing just those objects:
<TextureAtlas imagePath="onlyObjects_retina.png">
<SubTexture name="barrelBlack_side.png" x="652" y="532" width="40" height="56"/>
<SubTexture name="barrelBlack_top.png" x="645" y="220" width="48" height="48"/>
<SubTexture name="barrelGreen_side.png" x="652" y="476" width="40" height="56"/>
<SubTexture name="barrelGreen_top.png" x="597" y="220" width="48" height="48"/>
<SubTexture name="barrelRed_side.png" x="648" y="420" width="40" height="56"/>
<SubTexture name="barrelRed_top.png" x="645" y="172" width="48" height="48"/>
<SubTexture name="barrelRust_side.png" x="652" y="588" width="40" height="56"/>
<SubTexture name="barrelRust_top.png" x="597" y="172" width="48" height="48"/>
<SubTexture name="barricadeMetal.png" x="596" y="532" width="56" height="56"/>
<SubTexture name="barricadeWood.png" x="596" y="72" width="56" height="56"/>
<SubTexture name="fenceRed.png" x="243" y="336" width="96" height="32"/>
<SubTexture name="fenceYellow.png" x="128" y="216" width="104" height="32"/>
<SubTexture name="sandbagBeige.png" x="436" y="164" width="64" height="44"/>
<SubTexture name="sandbagBeige_open.png" x="348" y="518" width="84" height="55"/>
<SubTexture name="sandbagBrown.png" x="440" y="622" width="64" height="44"/>
<SubTexture name="sandbagBrown_open.png" x="248" y="596" width="84" height="55"/>
<SubTexture name="treeBrown_large.png" x="0" y="0" width="128" height="128"/>
<SubTexture name="treeBrown_small.png" x="592" y="694" width="72" height="72"/>
<SubTexture name="treeGreen_large.png" x="0" y="128" width="128" height="128"/>
<SubTexture name="treeGreen_small.png" x="520" y="694" width="72" height="72"/>
</TextureAtlas>
Click here to download this file.
Rotating spritesheet coordinates
However, now we have a problem. At the beginning of the project we rotated the spritesheet by 90 degrees so that it was oriented pointing right to match Godot’s zero angle. That means that the coordinates in the xml file are no longer valid!
Fortunately, I have a Python script from a previoius project that parses the Kenney xml, so we can use that. Python is a great choice for tool scripts like this.
Once we’ve parsed the xml and extracted the x, y, width, and height values, we need to do a little math to transform them to the new orientation:
In this image, you can see how a point rotated 90 degrees counter-clockwise is
transformed. In addition to this, since Godot expects x
& y
to represent the
rectangle’s top-left corner, we also have to subtract h
from y
.
Here is the Python script to do all that:
# Extract sprite coordinates from rotated
# Kenney spritesheet
import xml.etree.ElementTree as ET
datafile = 'obstacles.xml'
enum_name = 'Items'
sheet_width = 782
sheet_height = 782
# parse Kenney XML file
tree = ET.parse(datafile)
rects = {}
for node in tree.iter():
if node.attrib.get('name'):
name = node.attrib.get('name').replace('.png', '')
rects[name] = []
rects[name].append(int(node.attrib.get('x')))
rects[name].append(int(node.attrib.get('y')))
rects[name].append(int(node.attrib.get('width')))
rects[name].append(int(node.attrib.get('height')))
enum = 'enum Items {'
for name in rects:
enum += name + ', '
enum += '}'
print(enum)
print()
print("var regions = {")
for name, rect in rects.items():
x, y, w, h = rect
# offset center
x -= sheet_width//2
y -= sheet_height//2
# rotate 90deg counter-clockwise
# and use new top-left corner
x, y = y, -x
w, h = h, w
y -= h
# remove offset
x += sheet_width//2
y += sheet_height//2
print("\t%s.%s: Rect2(%s, %s, %s, %s)," % (enum_name, name, x, y, w, h))
print("}")
In addition to applying the transform, we’re also printing the result in a particular
format, so that we can use it in our GDScript code. We will be left with an enum
called Items
listing the various obstacles and a dictionary called regions
containing the coordinates as Rect2
objects.
Obstacle Script
Paste the Python script’s output into the Obstacle script:
extends StaticBody2D
enum Items {barrelBlack_side, barrelBlack_top, barrelGreen_side,
barrelGreen_top, barrelRed_side, barrelRed_top,
barrelRust_side, barrelRust_top, barricadeMetal,
barricadeWood, fenceRed, fenceYellow, sandbagBeige,
sandbagBeige_open, sandbagBrown, sandbagBrown_open,
treeBrown_large, treeBrown_small, treeGreen_large,
treeGreen_small}
var regions = {
Items.barrelBlack_side: Rect2(532, 90, 56, 40),
Items.barrelBlack_top: Rect2(220, 89, 48, 48),
Items.barrelGreen_side: Rect2(476, 90, 56, 40),
Items.barrelGreen_top: Rect2(220, 137, 48, 48),
Items.barrelRed_side: Rect2(420, 94, 56, 40),
Items.barrelRed_top: Rect2(172, 89, 48, 48),
Items.barrelRust_side: Rect2(588, 90, 56, 40),
Items.barrelRust_top: Rect2(172, 137, 48, 48),
Items.barricadeMetal: Rect2(532, 130, 56, 56),
Items.barricadeWood: Rect2(72, 130, 56, 56),
Items.fenceRed: Rect2(336, 443, 32, 96),
Items.fenceYellow: Rect2(216, 550, 32, 104),
Items.sandbagBeige: Rect2(164, 282, 44, 64),
Items.sandbagBeige_open: Rect2(518, 350, 55, 84),
Items.sandbagBrown: Rect2(622, 278, 44, 64),
Items.sandbagBrown_open: Rect2(596, 450, 55, 84),
Items.treeBrown_large: Rect2(0, 654, 128, 128),
Items.treeBrown_small: Rect2(694, 118, 72, 72),
Items.treeGreen_large: Rect2(128, 654, 128, 128),
Items.treeGreen_small: Rect2(694, 190, 72, 72)
}
Next, we want to be able to choose the item from the Inspector, so we need to
export a variable that uses the Items
enum:
export (Items) var type setget _update
Setting the type
variable will trigger the _update()
function, which sets
the Region and creates a correctly sized collision rectangle:
func _update(_type):
type = _type
$Sprite.region_rect = regions[type]
var rect = RectangleShape2D.new()
rect.extents = $Sprite.region_rect.size / 2
$CollisionShape2D.shape = rect
We need this code to run when we’re in the editor, not when the game is running,
so add tool
as the first line of the script. This tells Godot that we want the
code to run in the editor.
Adding Obstacles
Now add some obstacle instances to the map, choosing some different types and arranging them as you like. However, when you try and run the game, you’ll see an error message:
Invalid set index 'region_rect' (on base: 'null instance') with value of type 'Rect2'.
This happens because the setget
is running before the Obstacle scene has
finished loading - specifically before its child node Sprite
has loaded. We can
fix this by telling the node to wait until it’s ready when being run in the scene
tree (i.e. not in the editor):
func _update(_type):
type = _type
if !Engine.editor_hint:
yield(self, 'tree_entered')
$Sprite.region_rect = regions[type]
var rect = RectangleShape2D.new()
rect.extents = $Sprite.region_rect.size / 2
$CollisionShape2D.shape = rect
Now you can run the game and test that the obstacles are working:
Conclusion
That completes Part 9 of this series. Still to come: pickup items (i.e. supplies, goals, etc.) and more weapon types.
Please comment below with your questions and suggestions.