Creating an FPS ammo counter.

Godot Version

Godot Version 4.41

Question

I’m trying to create a skeleton for future FPS projects, and to do so I’ve been following this tutorial series up to episode 5 pretty closely. (https://www.youtube.com/watch?v=A3HLeyaBCq4&list=PLQZiuyZoMHcgqP-ERsVE4x4JSFojLdcBZ&ab_channel=LegionGames)

The tutorial’s great, but it doesn’t cover programming ammo, so I’ve been trying to set it up myself.

What I have so far is a script for one of the weapons (The pistol) that has ints for the clip ammo and total ammo (6 and 24) and loads a pair of labels in the UI. The idea is that the ints decrease or increase by following signals emitted by the player shooting and reloading. The labels then update the UI with the new ammo numbers. Unfortunately, the project doesn’t seem to be picking up this code. The player.can_shoot = false statement when the clipammo is below 0 doesn’t trigger, and I can’t figure out how to get the ints to display on the UI.

The code for the pistol can be found below. I’m new here, so please tell me if I need to elaborate on anything.

extends Node3D

#float clipammo(6)
@onready var clipammo : int = 6
@onready var totalammo : int = 24
const clipsize : int = 10
var player = load(“Map/NavigationRegion3D/Player”)
var clipammotext = load(“$UI/Ammocounter/ClipCounter”)
var totalammotext = load(“$UI/Ammocounter/TotalAmmoCounter”)

signal ammo_changed(clipammo : int, totalammo: int)

Called when the node enters the scene tree for the first time.
func _ready() → void:
emit_signal(“ammo_changed”, clipammo, totalammo)

pass # Replace with function body.

func change_text():
clipammotext.text = str(clipammo)
totalammotext.text = str(totalammo)

func _on_player_player_shoot() → void:
if clipammo > 0:
clipammo =- 1
ammo_changed.emit(clipammo, totalammo)
else:
player.can_shoot = false

func _on_player_player_reload() → void:
player.can_shoot = true
clipammo = 6
totalammo =-6

Called every frame. ‘delta’ is the elapsed time since the previous frame.

func _process(delta: float) → void:
pass

Hi,

For the text part, you don’t seem to be calling change_text() anywhere. Try adding it in _on_player_player_shoot() and _on_player_player_reload(), at the end of both functions.


For the can_shoot not being set to false there are two problems.

1/ I believe there’s a minor flaw in your algorithm:

if clipammo > 0:
    clipammo =- 1
    ammo_changed.emit(clipammo, totalammo)
else:
    player.can_shoot = false

That code basically says “if there’s ammo, shoot, else, you cannot shoot”. The problem is, you have to check if the player can still shoot after firing a bullet. Like this:

if clipammo > 0:
    clipammo =- 1
    ammo_changed.emit(clipammo, totalammo)
    
    if clipammo == 0:
        player.can_shoot = false

2/ Second, you wrote clipammo =- 1 which will assign -1 to your variable. What you want to do is clipammo -= 1 to decrement the value. I guess that’s a typing mistake.


Let me know if that helps.
If not, please share your code with a proper formatting (I’ll leave a link for some tips).

Hi again,

I’ve done as you suggested, as well as properly implemented the signals for _on_player_player_shoot and _on_player_player_reload (They now have the green box next to them), but the code still doesn’t seem to work. Upon shooting once, the game crashes with the following errors:

	clipammotext.change_text(clipammo)
	totalammotext.change_text(totalammo)

This section in my pistol code fails since clipammotext and totalammotext seem to be null instances. (In Godot’s words, "Attempt to call function ’ change_text’ in base ‘null instance’ on a null instance.")

func _on_player_player_shoot() -> void:

	if clipammo > 0:
		clipammo -= 1
		ammo_changed.emit(clipammo, totalammo)
	if clipammo == 0:
		player.can_shoot = false

In another part of the pistol script, Godot says “Invalid assignment of property or key ‘can_shoot’ with value of type ‘bool’ on a base object of type ‘null instance’.” Not really sure about this one tbh.

	if Input.is_action_pressed("Shoot") and can_shoot:
		match weapon:
			weapons.PISTOLS:
				_shoot_projectile_pistol()
				
			weapons.AUTO:
				_shoot_auto()

Finally, in the Player script, the _shoot_projectile_pistol() says “Invalid assignment of property or key ‘can_shoot’ with value of type ‘bool’ on a base object of type ‘null instance’.” Which is odd, since it was working before.

I believe all this may have something to do with the other scripts, so I’ll post them in full below just in case.

Player.GD (Not sure why this one is black and white, sorry)

extends CharacterBody3D

var speed
const WALK_SPEED = 5.0
const SPRINT_SPEED = 8.0
const JUMP_VELOCITY = 4.8
const SENSITIVITY = 0.004
const HIT_STAGGER = 5

#head bobbing variables.
const BOB_FREQ = 2.4
const BOB_AMP = 0.08
var t_bob = 0.0

#fov variables.
const BASE_FOV = 75.0
const FOV_CHANGE = 1.5

#signal for hit registration.
signal player_hit

#signal for ammo registration.
signal player_reload
signal player_shoot
signal player_switchweapon
# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity = 9.8

#Bullets
var bullet = load("res://Scenes/PistolBullet.tscn")
var bullet_trail = load("res://Scenes/BulletTrail.tscn")
var instance

# Weapon Switching. Add to this if you want more weapons.
enum weapons {
	PISTOLS,
	AUTO,
	
}
@export var weapon = weapons.PISTOLS
@export var can_shoot = true

#Identifies the head and camera as variables. Used for Camera.
@onready var head = $Head
@onready var camera = $Head/Camera3D
@onready var aim_ray = $Head/Camera3D/Aimray
@onready var aim_ray_end = $Head/Camera3D/AimrayEnd
# Guns.
@onready var pistol = $Head/Camera3D/Pistol_Projectile
@onready var rifle = $Head/Camera3D/Rifle

@onready var gun_anim = $Head/Camera3D/Pistol_Projectile/GunPlayer
@onready var gun_barrel = $Head/Camera3D/Pistol_Projectile/RayCast3D
@onready var rifle_anim = $Head/Camera3D/Rifle/RiflePlayer
@onready var rifle_barrel = $Head/Camera3D/Rifle/BarrelTracker
@onready var pistol_shoot_sound = $"FutureWeapons2-Blaster-SimpleLaserShot01"
@onready var rifle_shoot_sound = $MachineGunGuns01539
@onready var pistol_reload_sound = $Head/Camera3D/Pistol_Projectile/"FutureWeapons2-Blaster-Reload1"
@onready var rifle_reload_sound = $Head/Camera3D/Rifle/MachineGunFoleyReload
@onready var weapon_switching = $Head/Camera3D/WeaponSwitching

#Gets rid of the mouse cursor on load.
func _ready():

	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

#Camera movement business
func _unhandled_input(event):
	if event is InputEventMouseMotion:
		head.rotate_y(-event.relative.x * SENSITIVITY)
		camera.rotate_x(-event.relative.y * SENSITIVITY)
		camera.rotation.x = clamp(camera.rotation.x, deg_to_rad(-40), deg_to_rad(60))


func _physics_process(delta):
	# Adds the gravity.
	if not is_on_floor():
		velocity.y -= gravity * delta

	# This is the jump feature.
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY
	
	# Handle Sprint.
	if Input.is_action_pressed("sprint"):
		speed = SPRINT_SPEED
	else:
		speed = WALK_SPEED

	# Get the input direction and handle the movement/deceleration. Make sure the directional var is using the head as a basis.
	var input_dir = Input.get_vector("left", "right", "up", "down")
	var direction = (head.transform.basis * transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
	if is_on_floor():
		if direction:
			velocity.x = direction.x * speed
			velocity.z = direction.z * speed
		else:
			velocity.x = lerp(velocity.x, direction.x * speed, delta * 7.0)
			velocity.z = lerp(velocity.z, direction.z * speed, delta * 7.0)
	else:
		velocity.x = lerp(velocity.x, direction.x * speed, delta * 3.0)
		velocity.z = lerp(velocity.z, direction.z * speed, delta * 3.0)
	
	# Head bob. Calculates bobbing on how fast the character is moving.
	t_bob += delta * velocity.length() * float(is_on_floor())
	camera.transform.origin = _headbob(t_bob)
	
	# FOV
	var velocity_clamped = clamp(velocity.length(), 0.5, SPRINT_SPEED * 2)
	var target_fov = BASE_FOV + FOV_CHANGE * velocity_clamped
	camera.fov = lerp(camera.fov, target_fov, delta * 8.0)
	


	
	
	if Input.is_action_pressed("Shoot") and can_shoot:
		match weapon:
			weapons.PISTOLS:
				_shoot_projectile_pistol()
				
			weapons.AUTO:
				_shoot_auto()
		emit_signal("player_shoot")

#Reloading
	if Input.is_action_just_pressed("Reload"):
		match weapon:
			weapons.PISTOLS:
				_reload_pistol()
				
			weapons.AUTO:
				_reload_rifle()



	#Weapon Switching
		
	if Input.is_action_just_pressed("Weapon1") and weapon != weapons.PISTOLS:
		_raise_weapon(weapons.PISTOLS)	
		player_switchweapon.emit()
	
	if Input.is_action_just_pressed("Weapon2") and weapon != weapons.AUTO:
		_raise_weapon(weapons.AUTO)
		player_switchweapon.emit()
	
	move_and_slide()
 # Modifies velocity if there's a collision.


	
func _headbob(time) -> Vector3:
	var pos = Vector3.ZERO
	pos.y = sin(time * BOB_FREQ) * BOB_AMP
	pos.x = cos(time * BOB_FREQ / 2) * BOB_AMP
	return pos
	
func hit(dir):
	emit_signal("player_hit")
	velocity += dir * HIT_STAGGER
	if velocity.length() > SPRINT_SPEED:
		velocity = velocity.normalized() * SPRINT_SPEED


#Shooting
func _shoot_projectile_pistol():

	if !gun_anim.is_playing():
			gun_anim.play("Shoot")
			pistol_shoot_sound.play()
			instance = bullet.instantiate()
			instance.position = gun_barrel.global_position
			get_parent().add_child(instance)
			if aim_ray.is_colliding():
				instance.set_velocity(aim_ray.get_collision_point())
			else:
				instance.set_velocity(aim_ray_end.global_position)

func _shoot_auto():
	if !rifle_anim.is_playing():
		rifle_anim.play("Shoot")
		rifle_shoot_sound.play()
		instance = bullet_trail.instantiate()
		if aim_ray.is_colliding():
			instance.init(rifle_barrel.global_position, aim_ray.get_collision_point())
			get_parent().add_child(instance)
			if aim_ray.get_collider().is_in_group("enemy"):
				aim_ray.get_collider().hit()
				instance.trigger_particles(aim_ray.get_collision_point(),
											rifle_barrel.global_position, true)
			else:
				instance.trigger_particles(aim_ray.get_collision_point(),
											rifle_barrel.global_position, false)
		else:
			instance.init(rifle_barrel.global_position, aim_ray_end.global_position)
			get_parent().add_child(instance)




func _on_camera_3d_visibility_changed() -> void:
	pass # Replace with function body.
	
	
	#Plays the correct weapon lowering animation.
func _lower_weapon():
		match weapon:
			weapons.AUTO:
				weapon_switching.play("Rifle_Lower")
			weapons.PISTOLS:
				weapon_switching.play("Pistol_Lower")

func _raise_weapon(new_weapon):
	can_shoot = false
	_lower_weapon()
	await get_tree().create_timer(0.3).timeout
	match new_weapon:
		weapons.AUTO:
				weapon_switching.play_backwards("Rifle_Lower")
		weapons.PISTOLS:
				weapon_switching.play_backwards("Pistol_Lower")
	weapon = new_weapon
	can_shoot = true


func _reload_pistol():
	can_shoot = false
	pistol_reload_sound.play()
	gun_anim.play("pistol_reload")
	await get_tree().create_timer(1.0).timeout
	can_shoot = true
	player_reload.emit()
	
	
func _reload_rifle():
	can_shoot = false
	rifle_reload_sound.play()
	rifle_anim.play("Rifle_reload")
	await get_tree().create_timer(1.0).timeout
	can_shoot = true
	player_reload.emit()
	
func _out_of_ammo():
	can_shoot = false

UIScript.GD:

extends Control
@onready var crosshair = $UI/CrosshairControl/Crosshair
@onready var crosshair_hit = $UI/CrosshairControl/Crosshairhit
@onready var hit_rect = $UI/HitRect
@onready var clip_counter = $Ammocounter/ClipCounter
@onready var total_ammo = $Ammocounter/TotalAmmoCounter

@onready var player: Node = get_node("$../Map/NavigationRegion3D/Player")


var current_connected_weapon: Node = null

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	var weapon = get_node("$Head/Camera3D/Pistol_Projectile")  # Adjust this path to the actual weapon node
	if weapon:
		weapon.connect("ammo_changed", Callable(self, "_on_ammo_changed"))
#	clip_counter.text = str(weapon.clipammo)


	

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
	pass

func _on_ammo_updated(clipammo: int) -> void:
	clip_counter.text = "Ammo: %d" % clipammo

Thanks for the help so far!