In this time, we will create a bullet hell aka barrage for a barrage shooting game. The bullet hell is a large number of bullets (or similar long-range attacks) like a curtain. A âbullet hell shooterâ is a game in which the player shoots at and defeats enemy characters while dodging and weaving through the gaps between the bullets. Some games specialize in simply dodging the bullets. Many games are based on the motif of spaceships and fighter planes that fit the image (in this tutorial, a ground battle between a wizard and a monster though).
In this tutorial, we will focus only on creating a bullet hell. Also, while bullet hell come in various shapes and sizes, we will focus on rotating-type bullet hell.
Environment
This tutorial was created in the following environment
ă»Godot version: 3.4.2
ă»Computer OS version: macOS 11.6.5
The project file for this tutorial is located at GitHub repository . After downloading the .zip file, import the âproject.godotâ file in the âStartâ folder into the Godot Engine, and you will be able to start with a project that has only been prepped. If you would like to see the completed project in a hurry, import the âproject.godotâ file from the âEndâ folder.
All the assets imported into the project were downloaded from KENNEY website. I used an asset pack called 1-Bit Pack . I canât help but be thankful for this wonderful free material.
Now, when you open the project in the âStartâ folder in Godot Engine, the following are already created.
1.Player character (wizard) 2.
2.Player characterâs long-range attack (magic) 3.
3.Enemy character (the monster who releases a barrage of bullets) 4.
4.Game world (the game world in which the above objects exist)
First of all, please run the project in the âStartâ folder, where only the preliminary work has been completed, to see how it works.
The game is set up as follows.
- The player character can control movement and magic long-range attacks with the keys below.
- move_up: W key - the player character moves up.
- move_down: S key â moves the player character down.
- move_right: D key - moves the player character right.
- move_left: A key - moves the player character left.
- magic: Spacebar or left mouse button - Casts a spell.
- The player character dies after 10 hits from enemy character bullets.
- If the player character dies, the game is over (debug panel closes automatically).
- The enemy character can only move for now.
- The enemy character will move toward the player character every 2 seconds.
- Enemy character dies after 5 magic hits.
- Magic is ineffective while Enemy characters are flashing red and white after a hit.
- When an enemy character dies, the next enemy character appears within a radius of 50px from the player character.
- The appearance of the enemy character changes randomly each time.
- The player characterâs magic disappears 1 second after it is released.
- The game world can be moved unlimitedly.
Creating a bullet scene
To create a bullet hell scene, we need each of the bullets that make it up. Therefore, we will start by creating the bullet scene.
Creating a scene
Create a new scene of a bullet by following the steps below.
- Select âSceneâ menu > âNew Sceneâ.
- Select âOther Nodeâ in âGenerate Root Nodeâ.
- Select a node of the âArea2Dâ class as a root node.
- Rename the root node âArea2Dâ to âBulletâ.
- Save the scene here once, setting the file path to âres://Enemy/Bullet.tscnâ.
Adding nodes to the scene
Next, add child nodes to the root node âBullet.
- add a node of the class âSpriteâ to the root node âBulletâ. This is the appearance of a bullet.
- Add a node of the âCollisionShape2Dâ class to the root node âBulletâ. This is used to detect when the bullet collides with the physical body.
- Add a node of the âVisibilityNotifier2Dâ class to the root node âBulletâ. This is used to detect when a bullet goes off the screen.
Official Godot Docs:
VisibilityNotifier2D
The scene tree should now look like this.
Editing the properties of the nodes
Continue to edit the properties of each node.
BulletïŒArea2DïŒnode
No editing of the properties of this root node is required.
Sprite node
In this case, we will use the method of setting the sprite texture by specifying the range of textures to be used from a single sprite sheet that contains a large number of textures.
- In the inspector, apply the resource file âres://colored-transparent_packed.pngâ to the âTextureâ property of the âSpriteâ node.
- Turn on âRegionâ > âEnabledâ
- Open the âTexture Regionâ panel at the bottom of the editor and specify the region of the texture you want to use in the sprite sheet.
- First, expand the panel to the top of the editor for easy viewing, and then enlarge the sprite sheet to a size that is easy to see.
- Select âGrid Snapâ in âSnap Modeâ at the top of the âTexture Regionâ panel.
- Set the âStepâ at the top of the panel to 16px 16px as well. This will make the grid the same size as the sprites on the sprite sheet.
- Select the texture you want to apply to the bullet on the sprite sheet. In this tutorial, we selected a skull texture. It is a horror that a monster will release a barrage of skull bullets.
- Return to the inspector and change the color to the bullet color of your choice in the Visibility > Modulate property. Here, as a sample, we have chosen the somewhat eerie purple color #9da4d8.
CollisionShape2D node
- In the inspector, apply the âNew CircleShape2Dâ resource to the âShapeâ property.
- In the 2D workspace, match the collision shape to the size of the âSpriteâ node texture.
- If entering directly in the inspector, set the âRadiusâ property of the âCircleShape2Dâ resource to 8.
VisibilityNotifier2D node
No editing of the properties of this root node is required.
Controlling bullets with a script
Next, letâs program a script to control bullets. Letâs attach a new script to the root node âBulletâ. Create the file path as âres://Enemy/Bullet.gdâ.
First, edit the script as follows.
### Bullet.gd ###
extends Node2D
# Seconds of a bullet
export var speed = 150
# Physical process method called 60 times/second
func _physics_process(delta):
# Move the bullet every frame by adding the current bullet position to..
# the current bullet direction x the current bullet direction x..
# the speed of the bullet per second x 1 frame of time
position += transform.x * speed * delta
Next, since the root node âBulletâ is an Area2D class, we will use its signal to make the bullet disappear when it hits the physical body. Select âBulletâ in the inspector and connect the signal âbody_entered(body)â to this âBullet.gdâ script in the Node Dock > Signal tab.
Execute method queue_free
in method _on_Bullet_body_entered
generated by connecting signals.
### Bullet.gd ###
# Methods called by signals sent out when physical bodies collide
func _on_Bullet_body_entered(body):
# free Bullet
queue_free()
Similarly, use the signal of the âVisibilityNotifier2Dâ node to make the bullet disappear when it goes off the screen. Letâs connect the âVisibilityNotifier2Dâ signal âscreen_exited()â to this âBullet.gdâ script.
Execute method queue_free
in method _on_VisibilityNotifier2D_screen_exited
generated by connecting the signal.
### Bullet.gd ###
# Method called by a signal that is sent out when Bullet go off-screen
func _on_VisibilityNotifier2D_screen_exited():
# free Bullet
queue_free()
This completes the Bullet scene. After this, the created bullet scene instance is added to the enemy characterâs scene tree so that the enemy character can fire bullets.
Creating a bullet hell
Controlling the bullet hell with a script
From here, we will edit the script âEnemy.gdâ attached to the root node âEnemyâ in the âEnemy.tscnâ scene of the enemy character to control the bullet hell. After opening âEnemy.gdâ in the script editor, letâs first define the necessary properties. In the following code, please add the code where it is commented â# Addâ.
### Enemy.gd ###
extends KinematicBody2D
signal died
# Add: reference to preloaded Bullet.tscn
const bullet_scn = preload("res://Enemy/Bullet.tscn")
var enemy_life = 5
var enemy_speed = 800
var delta_count = 0
# Add: Distance from the center of the Enemy to the firing position of the bullet
var radius = 20
# Add: Rotation speed of the bullet's firing position around the Enemy
export var rotate_speed = 40
# Add: Interval between firing bullets (seconds)
export var interval = 0.4
# Add: Number of bullets fired at one time
export var spawning_count = 4
It may be a little difficult to visualize from the comments in the code alone, so a diagram is attached.
As you can see, it is easy to imagine a circle with âEnemyâ as its center and the value of the property radius
as its radius. Of the bullets to be fired at a time specified by the property spawning_count
, the first firing position should always be (x: radius, y: 0), and the second and subsequent bullets should be placed from there at equal intervals (angle difference) around the circumference of this circle. The bullets are shifted by the property rotate_speed
in a clockwise direction every second specified by the property interval
, and then fired.
Then edit the _ready
method, which is called the first time the scene is loaded. In the _ready
method, add the code for initialization necessary to generate the bullet hell. It should be easier to understand if you can visualize the above diagram.
### Enemy.gd ###
func _ready():
anim_player.play("spawn")
randomize()
sprite.frame = randi() % sprite.hframes
### Add all of the following ###
# Define step as the interval (angle difference) between bullets fired at once
# step is 180° x 2 = 360° divided by the value of the property Spawning_count
var step = PI * 2 / spawning_count
# Loop for the number of values in spawning_count
# e.g) If spawning_count is 4, i will be filled with 0 ~ 3 in that order
for i in spawning_count:
# Create a new Node2D node to be used as a marker of the bullet's firing position...
# and define it as spawn_point
var spawn_point = Node2D.new()
# Define the firing position as pos
# Define pos as the position rotated by (step x i) from the base position (x: radius, y: 0)
var pos = Vector2(radius, 0).rotated(step * i)
# Place spawn_point at the bullet's firing position
spawn_point.position = pos
# Align the orientation of the spawn_point with..
# the angle from the positive x-axis to the firing position
spawn_point.rotation = pos.angle()
# Make spawn_point a child of the Rotater node (Node2D)...
# that has been prepared in advance as a node for rotation
rotater.add_child(spawn_point)
# Set the wait_time property of the Timer node with the value of the interval property
timer.wait_time = interval
# Wait until the animation of the AnimationPlayer node is finished
yield(anim_player, "animation_finished")
# Start the timer of the Timer node
timer.start()
Now, every time the âTimerâ node times out, place an instance of the âBullet.tscnâ scene you created earlier at the same position as the child node of the âRotaterâ node (the spawn_point
in the above code), and the bullets should fly automatically. Now letâs edit the _on_Timer_timeout
method called by the âtimeoutâ signal of the âTimerâ node. Since the signal has already been connected in the preparation, replace pass
in the method with the following content.
### Enemy.gd ###
# Methods called on the timeout signal of a Timer node
func _on_Timer_timeout():
# Loop over the child nodes of the Rotater node
for node2d in rotater.get_children():
# Instance of Bullet.tscn
var bullet = bullet_scn.instance()
# make Bullet instance node a child of its parent node (World node) rather than Enemy node
get_parent().add_child(bullet)
# Make the Bullet instance position the same as the Rotater child node position
bullet.position = node2d.global_position
# set the direction of the Bullet instance to be the same as...
# the direction of the child node of Rotater
bullet.rotation = node2d.global_rotation
The firing position still does not rotate, but for the time being, it should now fire the specified number of bullets at the specified time difference. Letâs run the project.
Next, letâs rotate the firing position of the bullets a bit to make it more like a bullet hell. This time, we will update the code in the _physics_process
method a little. Please add the comment â# Addâ in the following code.
### Enemy.gd ###
func _physics_process(delta):
delta_count += delta
if delta_count > 2:
delta_count = 0
if get_parent().has_node("Player"):
anim_player.stop()
anim_player.play("move")
var direction = global_position.direction_to(get_parent().get_node("Player").global_position)
var velocity = direction * enemy_speed
velocity = move_and_slide(velocity)
# Add: define next direction (angle) as new_rotation
# new_rotation is the current rotation of the Rotater node + rotate_speed x the time of 1 frame
var new_rotation = rotater.rotation + rotate_speed * delta
# Add: rotate the Rotater node by the remainder (angle) of new_rotation divided by 360
rotater.rotate(fmod(new_rotation, 360))
Now the bulletâs firing position should rotate every 0.4
seconds as specified in the interval
property. Letâs run the project again to see if it behaves as expected.
Customizing the bullet hell
Properties defined in scripts can be easily edited in the inspector if the export
keyword is added at the beginning. Letâs change the values to create a different bullet hell than the previous one.
Sample 1
- Rotate Speed: 45
- Interval: 0.5
- Spawning Count: 10
Sample 2
- Rotate Speed: 10
- Interval: 0.1
- Spawning Count: 8
Sample 2 has turned out to be a very devilish game. But what a thrilling and enjoyable experience.
Adding randomness
At the preliminary stage, the appearance of the enemy character (sprite texture) is coded to be randomly determined from six different types. If the properties of the bullet hell are also randomly determined, it would be interesting because it is impossible to predict what kind of bullet hell will be unleashed each time. In fact, it is surprisingly easy to implement this script by just adding a few codes to the script described so far.
Now, open the âEnemy.gdâ script in the Script Editor. First, define the upper and lower limits of each property to form the bullet hell that has already been prepared in a separate property. Letâs update the lines commented â# Addâ and â# Changeâ in the following code.
### Enemy.gd ###
var radius = 20
export var rotate_speed: float # Change: only define type and leave value undefined
export var max_rotate_speed = 80 # Add
export var min_rotate_speed = 20 # Add
export var interval: float # Change: only define type and leave value undefined
export var max_interval = 0.8 # Add
export var min_interval = 0.2 # Add
export var spawning_count: int # Change: only define type and leave value undefined
export var max_spawning_count = 10 # Add
export var min_spawning_count = 2 # Add
Next, edit the _ready
method, as we need to apply random values to each property at the time the scene is loaded. Letâs add the lines commented â# Addâ in the code below.
func _ready():
anim_player.play("spawn")
# enable random value generation
randomize()
sprite.frame = randi() % sprite.hframes # int, max: 5
# Add: assign a random fraction to rotate_speed with...
# min_rotate_speed as the lower limit and max_rotate_speed as the upper limit
rotate_speed = rand_range(min_rotate_speed, max_rotate_speed)
# Add: Assign a random fraction to interval with...
# min_interval as the lower limit and max_interval as the upper limit
interval = rand_range(min_interval, max_interval)
# Add: The upper limit is -1 from the specified value in the subsequent calculation...
# so it is adjusted by +1 first.
max_spawning_count += 1
# Add: Assign a random integer to spawning_count with...
# min_spawning_count as the lower limit and max_spawning_count as the upper limit
spawning_count = randi() % max_spawning_count + min_spawning_count
var step = PI * 2 / spawning_count
for i in spawning_count:
var spawn_point = Node2D.new()
var pos = Vector2(radius, 0).rotated(step * i)
spawn_point.position = pos
spawn_point.rotation = pos.angle()
rotater.add_child(spawn_point)
timer.wait_time = interval
yield(anim_player, "animation_finished")
timer.start()
A few additional notes on the generation of random values.
First, rand_range
returns a random float number with the first argument as the lower limit and the second argument as the upper limit. It is used for the properties rotate_speed
and interval
because these properties are defined as float types.
Next, randi
is a method that returns a random integer, but since it takes no arguments, it is not possible to specify an upper or lower limit.
Therefore, we use the fact that the remainder when the returned value is divided by the upper limit is 0 or more and less than (upper limit -1). Since the maximum value is the specified upper limit -1, put a code max_spawning_count += 1
one line earlier to add +1.
The symbol % (modulo) can be used to obtain the remainder obtained by dividing a by b using a % b. If we add c to that value as a % b + c, the result cannot be smaller than c. In other words, a random integer with upper and lower bounds can be expressed as randi() % upper bound + lower bound
. Remember that the maximum value returned is the upper limit -1.
In this case, the randomize
method was written first, but without it, the result will be the same every time, so if you use methods that return a random value, it is good to remember to write it at the beginning of the _ready
method.
Finally, letâs run the project to see if each monster that appears will have a different bullet hell.
Conclusion
In this tutorial, we created a bullet hell for a top-down shooter. For a rotating bullet hell like this one, the following points will be important.
- Set the radius of the circle, the rotation speed of the firing position, the time difference of firing, and the number of simultaneously fired bullets with properties
- Prepare a node for rotation.
- Calculate the firing position based on the radius of the circle and the number of rounds fired simultaneously.
- Add child nodes to the Rotater node to mark the firing position.
- Set the timer time to the time difference of the firing.
- In the physics process, always rotate the Rotater node according to the rotation speed
- When the timer times out, an instance of the bullet is created at the location of the marker node.
There might be better ways to implement this, and I hope that you will experiment with various methods and finally adopt the best one.
In addition, actual bullet shooters are not only rotating types like this one, but are also diverse, such as wave-shaped, fan-shaped, etc. If you are interested in this, please look into it.
Links
- Godot Docs: Matrices and transforms
- YouTube: How to Make a Bullet Hell Projectile Pattern Generator in Godot
- YouTube: How to Make a Bullet Hell Game in Godot [P1] - Basic Collisions
UPDATE
2022/05/25 Added keys control settings.