User Tools

Site Tools


godot

Getting Started with Godot

This event runs monthly on the 2nd Monday of the month. See the home page for the date of our next monthly event.

For our regular monthly tutorial nights, we're basing our teaching on the wonderful series by AJ's Learning Lab, Godot FPS Tutorial - Learn Godot by making Wolfenstein 3D.

He has made the assets he uses freely available (please consider donating) here: https://ajslearninglab.itch.io/boganstein-3d-learn-godot-4-with-wolfenstein-3d/

All the code we use is available on AJ's Learning Lab github - https://github.com/AJsLearningLab/GodotFPS

First Steps

Part 1

Goal: Learn about Godot, create a world/room (floor and walls)

  • New scene: click 3D Scene, rename to 'world'
  • Create floor. Add MeshInstance3D to scene
  • Set Mesh type to 'New PlaneMesh'
  • Change size. Transform → scale to 20
  • Create collision mesh. Using the 'Mesh' top button, choose 'Trimesh Static Body'. That automatically creates a StaticBody3D of the correct type, and then adds a CollisionShape3D.
  • New wall. Create another new mesh. This time the Mesh should be New BoxMesh'. Move walls to edge, set z to 20
  • Tweak the wall height, 'y to the sky', set to 10
  • Duplicate for the other three walls

Part 2

Goal: Create a player character that can be controlled by the keyboard

  • New scene. Click + next to the world scene. Other node. Search for CharacterBody3D. Name it 'player'.
  • Add subnode. Click +, search for MeshInstance3D. Set Mesh shape to Capsule shape.
  • Fix the height. Transform, adjust Y to 1m.
  • Select player node, add new node, Camera3D.
  • Adjust Camera height to eye height.
  • Create script. Select player node. Use script+/scroll button. Select a template for Basic movement.
  • Add new variable. const TURN_SPEED = 0.05
  • Add new code
if Input.is_action_pressed("ui_left"):
  self.rotate_y(TURN_SPEED)
if Input.is_action_pressed("ui_right"):
  self.rotate_y(-TURN_SPEED)
  • Comment out the jump code
  • In the world scene, drag the player scene into the world
  • In the three dot menu (world scene), use 'Add Sun to scene', 'add environment to scene'
  • In the player scene, use the Mesh button to add a 'Simplified Convex Collision Sibling' mesh to the player's MeshInstance3D (not 'Single', that'll cause performance issues - why?)
  • Set up WASD controls in the Project Settings

Part 3

Goal: Add textures to the walls and floor

  • Import a texture by dragging it into the file system.
  • Select a wall. Use Geometry → Material Override. Select New StandardMaterial3D. Click the sphere. Expand Albedo. Drag the texture to the 'Texture' input.
  • Find UV1, experiment with the sizing, approx 30 x 10
  • Apply to floor/other walls.

Part 4

Goal: Add a UI/HUD and a knife

  • Create new scene, other node: 'CanvasLayer', rename to 'ui'
  • Create new node, AnimatedSprite2D
  • Create new node, ColorRect
  • In 2D view, size the ColorRect. Layout → Anchors → Bottom Wide. Set the colour to dark blue.
  • Import wolfweapons.png
  • Select Animated Sprite. Expand Animation → Sprite Frame. Click New SpriteFrames. Click it again to view the animation frame at the bottom.
  • Rename default to 'knife_idle', set as default.
  • Click grid, adjust, set up idle frame.
  • Adjust position of animatedspirteframe in 2D view, use Transform→Scale to size up to 5
  • Create new animation called 'stab'
  • Add new stab animations, disable loop, set FPS to 16
  • Click UI root node, add script. Enter the following code:
ui.gd
extends CanvasLayer
 
var ammo = 0
var current_weapon = "knife"
 
func _ready():
	$AnimatedSprite2D.animation_finished.connect(_on_AnimatedSprite2D_animation_finished)
 
func _process(delta):
	if Input.is_action_just_pressed("ui_select"):
		if current_weapon == "knife":
			$AnimatedSprite2D.play("stab")
		elif current_weapon == "gun":
			if ammo > 0:
				$AnimatedSprite2D.play("shoot")
				ammo -= 1
 
func _on_AnimatedSprite2D_animation_finished():
	if current_weapon == "knife":
		$AnimatedSprite2D.play("knife_idle")
	elif current_weapon == "gun":
		$AnimatedSprite2D.play("gun_idle")
  • Add the ui scene to the player scene (Drag drop from file system onto player node)

Part 5

Goal:Add a hand gun, firing, add a global script

  • Add new handfun sprite animations ('gun_idle' and 'shoot'). Disable looping. (Adjust to 5×4 for grid)
  • Edit the ui code, after the ammo -= 1 line, add:
else:
  current_weapon = "knife"
  $AnimatedSprite2D.play("knife_idle")
  • Set the ammo and current_weapon to 'gun'. Test.
  • Create a global script. File → New Script (based on Node). Name it global.
  • Move the ammo and current weapon variables from the player script to the global script.
  • Open the project settings, in Autoload, add the global script and mark it global
  • Tweak the project setting page, allow fullscreen too
  • Edit the ui.gd script and change all references to current_weapon and ammo to be Global.current_weapon and Global.ammo
  • Test everything still works

Part 6

Goal: More weapons

  • Edit the ui AnimatedSprite2D, add new animations/sprites
  • Rename 'shoot' to gun_shoot
  • Add new variables to ui.gd script
var time_since_last_shot = 0.0
var fire_rate = 1.0
  • Add a line to the _ready() function
$AnimatedSprite2D.play(Global.current_weapon + "_idle")
  • Add some lines to the start of the _process() function
time_since_last_shot += delta
var can_shoot = time_since_last_shot >= (1.0 / fire_rate)

if Global.current_weapon != "knife" and Global.ammo <= 0:
  Global.current_weapon = "knife"
  $AnimatedSprite2D.play("knife_idle")
  • Tweak first if statement, change to:
if Input.is_action_pressed("ui_select") and can_shoot:
  • Remove everything after the stab line, and add
else:
  $AnimatedSprite2D.play(Global.current_weapon + "_shoot")
  
time_since_last_shot = 0.0

if Global.current_weapon != "knife":
  if Global.ammo > 0:
    Global.ammo -= 1
    
match Global.current_weapon:
  "gun":
    fire_rate = 3.0
  "machine":
    fire_rate = 6.0
  "mini":
    fire_rate = 10.0
  "knife":
    fire_rate = 2.0
  _:
    fire_rate = 1.0

func _on_AnimatedSprite2D_animation_finished():
  $AnimatedSprite2D.play(Global.current_weapon + "_idle")

The full code for ui.gd should now be:

ui.gd
extends CanvasLayer
 
var time_since_last_shot = 0.0
var fire_rate = 1.0
 
 
func _ready():
	$AnimatedSprite2D.animation_finished.connect(_on_AnimatedSprite2D_animation_finished)
	$AnimatedSprite2D.play(Global.current_weapon + "_idle")
 
func _process(delta):
	time_since_last_shot += delta
	var can_shoot = time_since_last_shot >= (1.0 / fire_rate)
 
	if Global.current_weapon != "knife" and Global.ammo <= 0:
		Global.current_weapon = "knife"
		$AnimatedSprite2D.play("knife_idle")
 
	if Input.is_action_pressed("ui_select") and can_shoot:
 
		if Global.current_weapon == "knife":
			$AnimatedSprite2D.play("stab")
		else:
			$AnimatedSprite2D.play(Global.current_weapon + "_shoot")
 
		time_since_last_shot = 0.0
 
		if Global.current_weapon != "knife":
			if Global.ammo > 0:
				Global.ammo -= 1
 
	match Global.current_weapon:
		"gun":
			fire_rate = 3.0
		"machine":
			fire_rate = 6.0
		"mini":
			fire_rate = 10.0
		"knife":
			fire_rate = 2.0
		_:
			fire_rate = 1.0
 
func _on_AnimatedSprite2D_animation_finished():
	$AnimatedSprite2D.play(Global.current_weapon + "_idle")

Part 7

Goal: Add enemies

  • Create new scene. Other node→CharacterBody3D. Name it guard.
  • Add two children to the enemy node. A CollisionShape3D (set to capsule), and a AnimatedSprite3D.
  • Set the Y to 1, to make it on the floor. Adjust the capsule size to be 2m.
  • Import the guard sprite to the file system.
  • Select the AnimatedSprite3D, Click Sprite Frame→ New Sprite Frame. Click it again to set a default animation using the grid, set to 7×2
  • Adjust the eize, choose AnimatedSprite3D. Transform → y =1, scale = 4 ish.
  • A new animations, 'die' and 'shoot'
  • Select the AnimatedSprite3D, in the inspector find 'Flags', enable 'Billboard - Y:Billboard'
  • Edit the player script and add this line to the _ready() function:
add_to_group("player")
  • Select the guard root node. Create a new script, use the code below:
extends CharacterBody3D
 
@onready var player : CharacterBody3D = get_tree().get_first_node_in_group("player")
 
const SPEED = 5.0
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")  # Get the gravity from the project settings to be synced with RigidBody nodes.
var dead = false
var is_attacking = false  
var attack_range = 5
 
func _ready():
	add_to_group("enemy")
 
func _physics_process(delta):
	if dead or is_attacking:  # Check if the enemy is dead or attacking
		return
 
	if player == null:
		return
 
	var dir = player.global_position - global_position
	dir.y = 0.0
	dir = dir.normalized()
 
	velocity = dir * SPEED
	# Add the gravity.
	if not is_on_floor():
		velocity.y -= gravity * delta
 
	move_and_slide()
	attack()
 
func attack():
	var dist_to_player = global_position.distance_to(player.global_position)
	if dist_to_player > attack_range:
		return
	else:
		is_attacking = true  # Set the attacking flag
		$AnimatedSprite3D.play("shoot")
		await $AnimatedSprite3D.animation_finished  # Wait for the animation to finish
		is_attacking = false  # Reset the attacking flag
 
 
func die():
	dead = true  # Corrected variable scope
	$AnimatedSprite3D.play("die")
	$CollisionShape3D.disabled = true

Part 8

Goal: Enemy Death and Raycasting

  • Edit the ui.gd script. Tweak the can_shoot variable, set it in the object scope (top) and remove “var” from “var can_shoot” from the _process()
  • Access the 3D view of the player. Add a new node as a child of the camera 3D, type: RayCast3D
  • Set the target position to y:0, z: -20
  • Make sure the raycast has access to collision mask layers 1+2
  • Check the guard scene, collision should be on layer 2, mask 1+2
  • Check Player is layer 1, mask is 1+2
  • Edit the player script, add new const at the top
@onready var ui_script = $ui
@onready var ray = $Camera3D/RayCast3D
  • At the bottom, just above move_and_slide(), add the following code:
if Input.is_action_just_pressed("ui_accept"):
  if ui_script.can_shoot:
    shoot()
  • Add the shoot function
func shoot():
  if ray.is_colliding() and ray.get_collider().has_method("die"):
    ray.get_collider().die()
  • To allow strafing, change the If Input.is_action_just_pressed(“ui_accept”): to
if Input.is_action_pressed("ui_accept"):

Part 9

Goal: HUD labels and player death

  • Add a player health variable to the player.gd script
var player_health = 100
  • Create two new Label nodes as a child of the UI, name one 'health'.
  • Create a new function in the ui.gd script
function update_player_health():
  $health.text = str(get_parent().player_health)
  • Add the function to the bottom of the _process() function in the ui.gd script
update_player_health()
  • Edit the player script and add a new function:
func damage():
  player_health -= 10
  print(player_health)
  if player_health <= 0:
    queue_free()
  • Add a RayCast3D to the guard scene
  • Set Y = 0, Z = 5
  • After the play(shoot) line in the attack() function, add:
if $RayCast3D.is_colliding() and $RayCast3D.get_collider().has_method('damage'):
  $RayCast3D.get_collider().damage()

Part 10

Goal: Ammo pickups

Next Steps

Goal: Add sound effects

  • Import the sound effect files (gun.ogg, machine.ogg, mini.ogg)
  • In the player scene, add a new node, AudioStreamPlayer.
  • Add the following code to the player shoot() function:
var sound_player = $AudioStreamPlayer
match Global.current_weapon:
  "gun":
    sound_player.stream = preload("res://gun.ogg")
  "machine":
    sound_player.stream = preload("res://machine.ogg")
  "mini":
    sound_player.stream = preload("res://mini.ogg")
sound_player.play()
godot.txt · Last modified: 2024/04/08 19:33 by admin