====== Getting Started with Godot ====== This event runs monthly on the **2nd Monday** of the month. See the [[start|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 [[https://www.youtube.com/@AJsLearningLab|AJ's Learning Lab]], [[https://www.youtube.com/playlist?list=PL4vjw0qHwNZIQZScBFaON0WGkz-BMyoCh|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 ===== * Download Godot from https://godotengine.org/download/ and install it (it's available on all platforms) * Download the assets from AJ's Learning Lab here: https://ajslearninglab.itch.io/boganstein-3d-learn-godot-4-with-wolfenstein-3d/ ==== 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: 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 5x4 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: 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 7x2 * 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()