Skip to content

Commit

Permalink
fix: update of survivors-like to new tutorial format (#466)
Browse files Browse the repository at this point in the history
#### Description

Updates the survivors-like tutorial to the new tutorial format, with
some very minor clarity changes.

Note: This does not address issues regarding content as outlined in #465
which will need to be addressed in a future PR
  • Loading branch information
lillianhidet authored Dec 22, 2024
1 parent 55255ae commit 5ec8e5c
Show file tree
Hide file tree
Showing 11 changed files with 875 additions and 695 deletions.
2 changes: 1 addition & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default defineConfig({
},
{
label: 'Survivors-Like',
link: 'game-design/godot/survivors',
link: 'game-design/godot/survivors/0-settingup/',
},
{
label: 'Top-down Dungeon Crawler',
Expand Down
694 changes: 0 additions & 694 deletions src/content/docs/game-design/godot/survivors.mdx

This file was deleted.

134 changes: 134 additions & 0 deletions src/content/docs/game-design/godot/survivors/0-settingup/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
type: tutorial
unitTitle: Setting up our character
title: Character setup
---

import Checklist from '/src/components/tutorial/Checklist.astro';
import Box from '/src/components/tutorial/Box.astro';
import { Steps } from '@astrojs/starlight/components';


**This guide assumes you've gone through the basics guide.**

Survivors-like games are games that are similar to the massively popular *Vampire **Survivors***.

Another popular name for the genre is Bullet Heaven. Bullet heaven games are the opposite of the ever-popular bullet hell genre.

If you are aware of the much more popular bullet hell genre but not of the bullet heaven/survivors-like genre then think of it like this: bullet hell games are you avoiding an insane amount of projectiles from a limited amount of enemies or a singular boss enemy. Survivors-like games, on the other hand, are where you have an oncoming horde of enemies but this time you're the one unleashing volley after volley of projectiles.

The player slowly accumulates a variety of abilities that activate automatically and fire an absurd amount of bullets at an equally absurd amount of enemies that slowly advance towards the player.

Survivors-like games are usually from a top-down perspective.

The genre initially got popular from the Unity game *Vampire Survivors*.
Although one of the most well-known Godot games, *Brotato*, has seen a good amount of fame after the popularity of Vampire Survivors brought attention to the concept of a survivors-like.

For this we will make a simple 2D top-down character controller. A selection of abilities that fire projectiles periodically. Enemies that spawn outside the screen and then slowly advance towards the player. Along the way, we'll also need a health and score system.

## Top-Down 2D Character Controller

As seen in the [basics guide](/game-design/godot/basics), the built-in Godot **CharacterBody2D** template is a side-on character controller.

Survivors-like games are typically top-down, so let's make a top-down 2D character controller.

### Setting Up
<Steps>
1. The player doesn't need a floor to stand on, so we don't need to make a 2D scene. You can just start with making a scene with the root node **CharacterBody2D**.

2. Make the following scene:
- **CharacterBody2D**
+ **CollisionShape2D** - Set shape to **New RectangleShape2D**
+ **Sprite2D** - Set texture to **New PlaceholderTexture2D**
+ **Camera2D**

3. Make the player's collider and sprite about an eighth the size of the camera's size, and a square. You want enough room between your player and the edge of the screen so they have time to react to enemies.

4. Set the **CharacterBody2D**'s **Motion Mode** property to **Floating** in the inspector. Grounded is for 2D side-on games, floating is for top-down.

5. Enable the camera's **Position Smoothing** property so it's clear if the player is moving or not.

6. Add up, down, left, and right to the **Input Map**. (Found in **Project -> Project Settings -> Input Map**)

7. Give the player the default **CharacterBody2D** script, we'll modify it.

</Steps>

Since we're editing the player scene instead of the main scene the actual game is going to run in, when you want to run your game use **F6** instead of **F5**, or the **Run Current Scene** button instead of the **Run Project** button.

#### Changing the Script

<Steps>

1. Before anything else give this script the class name **Player**. The enemies will also be **CharacterBody2D** so later when we make checks if we check a body against **CharacterBody2D** it'll return true for players and enemies, when we might only want enemies or the player.
To give the script the class name, before `extends CharacterBody2D`, add `class_name Player`.

2. Then, delete the `gravity` variable from the top of the script and then the section changing the y velocity based on gravity and jumping. There is no traditional gravity or jumping in top-down games.

3. The default script for a **CharacterBody2D** has this section to move the player:

```gdscript
# Get the input direction and handle the movement/deceleration.
# As good practice, you should replace UI actions with custom gameplay actions.
var direction = Input.get_axis("ui_left", "ui_right")
if direction:
velocity.x = direction * SPEED
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
move_and_slide()
```

The `Input.get_axis` function is essentially a shorthand for getting the action strength of each action and taking it away from one another. So it says in the documentation if we hold ctrl/cmd and click on `get_axis`:

```
float get_axis(negative_action: StringName, positive_action: StringName) const
Get axis input by specifying two actions, one negative and one positive.
This is a shorthand for writing Input.get_action_strength("positive_action") - Input.get_action_strength("negative_action").
```

The action strength is calculated by how far down an action is pressed. Typical keyboards will not have action strength being sent. It's useful for joysticks, Hall Effect keyboards, and a few other niche use-cases. There is no four-directional `get_axis` so we'll have to use `get_axis` twice, once for the x and once for the y-axis.

Change the `direction` variable to `x_direction`, and replace the UI actions, like the template suggests, with your own left and right actions.
Make a second `y_direction` variable with your up and down actions.

Change the default if-else statement to use `x_direction` instead of `direction`, and then copy paste it so that there's also one for `y_direction`.

4. Your script should look like this:

```gdscript
func _physics_process(_delta):
var x_direction = Input.get_axis("left", "right")
var y_direction = Input.get_axis("up", "down")
if x_direction:
velocity.x = x_direction * SPEED
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
if y_direction:
velocity.y = y_direction * SPEED
else:
velocity.y = move_toward(velocity.y, 0, SPEED)
move_and_slide()
```

5. Run your game with **F6** or **Run Current Scene** since this isn't the main game scene.

</Steps>

If you've done all correctly, it should look like your player is moving. Make sure the camera's position smoothing is enabled. Otherwise, when your player moves, the camera also moves perfectly with them. It results in looking like the player is perfectly still.


<Box>
## Checklist
<Checklist>
- [ ] I've created my player scene
- [ ] I've attached the player script
- [ ] I can move around!
</Checklist>
</Box>
176 changes: 176 additions & 0 deletions src/content/docs/game-design/godot/survivors/1-enemies/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
---
type: tutorial
unitTitle: Adding enemies
title: Adding enemies
---

import Checklist from '/src/components/tutorial/Checklist.astro';
import Box from '/src/components/tutorial/Box.astro';
import { Steps } from '@astrojs/starlight/components';



Enemies in survivors-like games are usually very simple. The difficulty comes from just how many of them there are and having to duck and dodge through the hoards while your weapons go to work.

Typically, there are a few types. But since most of the variation comes from just switching up how much health they have, how big their hitbox is, and other minor things, you can make new inherited scenes later after we build our basic enemy.

### Quick Main Scene

Since we need to test out the enemy scene in conjunction with the player scene, make a `main.tscn` scene with a **Node2D** root node.

If you pressed **F5**/**Run Project** instead of **F6**/**Run Current Scene** for your player and set your main scene to be the player scene, go to **Project > Project Settings... > Run** and change the main scene to your new scene.

Use the chain-looking button next to the **Add Child Node** button in the **Scene** dock to instantiate your player scene as a child of your **Node2D** root node.

### Setting Up

<Steps>
1. Make a new scene with the root node **CharacterBody2D** and call it `enemy.tscn`.

- **CharacterBody2D**
+ **CollisionShape2D** - Set shape to **New RectangleShape2D**
+ **Sprite2D** - Set texture to **New PlaceholderTexture2D**

2. Size them to be slightly smaller than the player, so we can fit more in and somewhat visually distinct them from the player.

3. Set the **CharacterBody2D**'s **Motion Mode** property to **Floating** like you did with the player.

4. Instantiate your enemy scene in your main scene and move the instantiated enemy away from the player. Run your game with **F5**/**Run Project** and set the main scene as the main scene of the project if you haven't already.

5. Finally, this enemy is going to need a way to reduce the player's health. However, the player has no health variable yet. In your singleton, which you can find how to make one on the **Universal Features** page, add a new `player_health` variable and set it to 100.
</Steps>

### Script
<Steps>

1. Get rid of everything below the line `func _physics_process(delta):` and above `move_and_slide()`, but not those lines, and delete `gravity` and `JUMP_VELOCITY`.

2. Give this script the class name `Enemy` with `class_name Enemy` before `extends CharacterBody2D`.

3. Reduce the speed from `300.0` to `100.0` or `150.0`. Enemies need to be slower than the player in survivors-like games because the challenge is from avoiding hoards of them while your weapons work. Not from conserving health for when an enemy unfairly tackles you out of nowhere and you can't do anything to get away from it until your weapons finally take it down.
</Steps>

#### Moving Towards the Player
<Steps>

1. To move towards the player, we're going to need to find the player.

In your singleton, add an `@onready` variable that holds the player.

In your player's script, add the built-in `_enter_tree()` function and set the Singleton's player_node variable to the player itself.

The singleton script should look like this:

```gdscript
extends Node
var player_health: int = 100
@onready var player_node: Player
```

2. The player script's ready function should look similar to:

```gdscript
func _enter_tree():
Singleton.player_node = self
```
</Steps>

:::note[Static typing]
Statically typing means that you are making sure that a variable stays as the type you want it to be. For example, if you don't want an integer (a whole number, without decimal places) to ever have decimal places, statically type the variable with `: int` after the name and before setting the value.

This is very, very useful for debugging later on, and making sure you don't make a mistake.

The reason we can statically type with `: Player` on the `player_node` variable is because we set the class name of the player script to be `Player`. In this case, it'll help ensure we don't accidentally set any other nodes to be the singleton's player node. It also helps Godot's code editor autocomplete any variables, functions, or signals we might have in the player script.

Other common use-cases are the primitive types like `bool` (boolean, true or false), `float` (floating-point number, number with possible decimal places), `String` (text, defined with "quotes around it, like this"), or to set variables, like the `player_node` variable, with complex types like **CharacterBody2D** to have access to code-completion in the Godot editor.
:::


Now, to finally edit the enemy script itself.

Before `move_and_slide()` add the *totally* simple line:

```gdscript
velocity = global_position.direction_to(Singleton.player_node.global_position) * SPEED
```

Breakdown of this line:

First, with `Singleton.player_node.global_position` we get the player's global position, which is a Vector2 since it's the x and y position.

Next, we use `global_position.direction_to()` to get the direction to the player from the `global_position` of the enemy. It gets this direction in a Vector2, in which both x and y add up to 1. We call this a normalized vector, since it's used for simple directions instead of anything like magnitude, position, or anything else. Normalized vectors always sum up to 1.

Then, we multiply that normalized vector with `SPEED` so now the vector that adds up to 1 in the direction of the player, will multiply up to 1 times the value of speed to also indicate how fast the player should move.

Since we now have a variable that says the speed in which the player should move and the direction it should be moving in, in the form of a Vector2, we can make that the velocity of the enemy.

:::note[Vector maths]
Vector maths is a large part of making any video game, but it's not very hard after you get the basic idea that a vector can represent a lot of things and begin to understand that. Godot's docs have a genuinely wonderful page about vector maths.

[Check it out here!](https://docs.godotengine.org/en/stable/tutorials/math/vector_math.html) Please do read it, vectors are essential to any game developer in any engine. It's not very long, but it is quite a bit to wrap your mind around.
:::

Anyway, the enemy should now move towards the player. Hooray!

#### Dealing Damage
<Steps>

1. Start by making a damage constant value at the top of your script next to the speed constant. Make it 5 and name it `DAMAGE` in all caps since it's a constant.

Every time you call `move_and_slide()` it's taking the velocity variable of the **CharacterBody2D**, then moves the player that much, then checks for collisions and slides the player along any collisions it makes, then it stores what collisions have been made.

2. If we look at the **CharacterBody2D** in **Search Help**, we can see a list of properties and methods that might be helpful:

```gdscript
void apply_floor_snap ( )
float get_floor_angle ( Vector2 up_direction=Vector2(0, -1) ) const
Vector2 get_floor_normal ( ) const
Vector2 get_last_motion ( ) const
KinematicCollision2D get_last_slide_collision ( )
Vector2 get_platform_velocity ( ) const
Vector2 get_position_delta ( ) const
Vector2 get_real_velocity ( ) const
KinematicCollision2D get_slide_collision ( int slide_idx )
int get_slide_collision_count ( ) const
Vector2 get_wall_normal ( ) const
bool is_on_ceiling ( ) const
bool is_on_ceiling_only ( ) const
bool is_on_floor ( ) const
bool is_on_floor_only ( ) const
bool is_on_wall ( ) const
bool is_on_wall_only ( ) const
bool move_and_slide ( )
```

Here we can see there's the `get_slide_collision` and `get_slide_collision_count` methods. Look at the description of `get_slide_collision` there's an explanation and a block of code showing you how to use both methods in conjunction to go through all the collided objects.

If you're eagle-eyed, you might have also spotted the `get_last_slide_collision` method. We don't want to use this because it only returns the last collision made, not all the collisions made. With how many enemies the game has it's not going to be unlikely that the enemy will hit the player and then slide into another enemy in the same frame. Just checking the last slide collision won't work, so we'll use the other two-slide collision methods to check every collision made.

2. Use the provided code-block in the `get_slide_collision` description and modify it so that instead of the print statement in the for-loop, add this:

```gdscript
if collision.get_collider() is Player:
Singleton.player_health -= DAMAGE
queue_free()
```

3. For this simple game, we're just going to run `queue_free()` so the player does damage and then disappears, so the next enemy can deal damage. If we didn't do this, it would deal damage every single frame the player is touching the enemy and it would deal insane amounts of damage very quickly.

</Steps>

If you want, you can use a **Timer** node, set it to one shot, replace `queue_free()` with starting the timer, and then check if the timer is stopped as part of the `if collision.get_collider() is Player:` if statement. This would stop the enemy from doing insane damage and limit it to once whenever the timer's value was set at.

In theory, what we have now though should work. Of course, we don't have anything to tell us how much health the player has, so let's quickly make death and a health bar.


<Box>
## Checklist
<Checklist>
- [ ] I've added enemies
- [ ] They move toward they player
- [ ] And they deal damage
</Checklist>
</Box>
50 changes: 50 additions & 0 deletions src/content/docs/game-design/godot/survivors/2-health/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
type: tutorial
unitTitle: Health System
title: Health and Death
---

import Checklist from '/src/components/tutorial/Checklist.astro';
import Box from '/src/components/tutorial/Box.astro';
import { Steps } from '@astrojs/starlight/components';

### Health Bar

Let's start with a simple health bar.
<Steps>
1. In your player scene, add a **ProgressBar** as a child of the root node. Position it above the player and make it wider so you can see the bar's progress.

2. Set the **Value** property of the **ProgressBar** to 100, this is your health bar.

3. Get the health bar in your player's script by opening the player's script, dragging the node to the top of the script where you want the variable, and before you release the left mouse button hold ctrl/cmd. This will make the entire onready variable for you. (If the line doesn't start with '@onready' you'll want to try again)

4. At the beginning of `_physics_process` add a line that sets the **ProgressBar**'s value property to the same value as your singleton's player health value. This is assuming your maximum health is 100. If it's something else you need to change the **Max Value** property on your health bar.
</Steps>
### Restarting the Game

Next comes the death.
<Steps>
1. In your health singleton, make a new constant for your player's max health. This is just so we can reset the health to this value. It's good practice to not have random numbers lying around your scripts, and instead use constants with descriptive names.

2. Add in the `_physics_process(delta)` function to your singleton.

3. Give it a simple if statement, checking if the player's health is below or equal to 0.

4. Inside that, set your player's health to the max health constant you made before. Then run `get_tree().reload_current_scene()`. It's important you do it in that order, reloading the scene could stop the script from running in other circumstances and not reset the player's health. It shouldn't in this case since this is an autoload script and won't be reset, but it's still best practice to reload or change scenes after doing all other reset or changing operations in case your scene has anything to change in the singleton itself.

5. Since we set the `player_node` variable during the player's `_ready()` function, we don't need to set it again. The player will automatically update the `player_node` variable when the player becomes ready again.

</Steps>

Booyah, the player can now die!



<Box>
## Checklist
<Checklist>
- [ ] I've added a healthbar
- [ ] It decreases when I take damage
- [ ] And the game restarts after I die
</Checklist>
</Box>
Loading

0 comments on commit 5ec8e5c

Please sign in to comment.