Integrate Kenney Starter Kit FPS assets + comprehensive test suite

Assets (CC0 license):
- 3D models: Blaster gun, walls, platforms, grass, clouds, enemy
- Sounds: blaster fire, impacts, footsteps, jumps, weapon change
- Sprites: crosshair, hit marker, muzzle flash, skybox, blob shadow
- Font: Lilita One

Code changes:
- weapon_manager.gd: load blaster.glb as gun model (replaces box mesh)
- player.tscn: use Kenney sounds (blaster.ogg, enemy_hurt/destroy) and crosshair

Test suite (100 unit tests + 4 integration scenarios):
- weapon_data.gd: 27 tests — all 6 weapons every stat verified
- economy.gd: 19 tests — constants, loss streak, buy thresholds
- bomb.gd: 15 tests — state machine, timing constants
- round_manager.gd: 10 tests — win conditions, elimination logic
- team_manager.gd: 8 tests — auto-balance, team assignment
- headless_test_bot.gd: integration bot with movement/idle/rounds scenarios
- run_multi_bot.sh: multi-client launcher
This commit is contained in:
2026-07-03 17:13:36 -04:00
parent 597d6dde2d
commit 012d038025
176 changed files with 4653 additions and 19 deletions
+8
View File
@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://b411hfkru5vs7"]
[ext_resource type="PackedScene" uid="uid://b2p7bbkuxf7m" path="res://models/blaster.glb" id="1_yqvvx"]
[node name="blaster" instance=ExtResource("1_yqvvx")]
[node name="blaster2" parent="." index="0"]
layers = 2
+19
View File
@@ -0,0 +1,19 @@
extends Node3D
var time = 0.0
var random_number = RandomNumberGenerator.new()
var random_velocity:float
var random_time:float
func _ready():
random_velocity = random_number.randf_range(0.1, 2.0)
random_time = random_number.randf_range(0.1, 2.0)
func _process(delta):
position.y += (cos(time * random_time) * random_velocity) * delta # Sine movement
time += delta
+1
View File
@@ -0,0 +1 @@
uid://bfgpo8fvf136p
+10
View File
@@ -0,0 +1,10 @@
[gd_scene load_steps=3 format=3 uid="uid://oqfhfp1a80qd"]
[ext_resource type="PackedScene" uid="uid://g2w2608bc2dy" path="res://models/cloud.glb" id="1_2mo3p"]
[ext_resource type="Script" uid="uid://bfgpo8fvf136p" path="res://objects/cloud.gd" id="2_rmotl"]
[node name="cube2" instance=ExtResource("1_2mo3p")]
script = ExtResource("2_rmotl")
[node name="cube" parent="." index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
+68
View File
@@ -0,0 +1,68 @@
extends Node3D
@export var player: Node3D
@onready var raycast = $RayCast
@onready var muzzle_a = $MuzzleA
@onready var muzzle_b = $MuzzleB
var health := 100
var time := 0.0
var target_position: Vector3
var destroyed := false
# When ready, save the initial position
func _ready():
target_position = position
func _process(delta):
self.look_at(player.position + Vector3(0, 0.5, 0), Vector3.UP, true) # Look at player
target_position.y += (cos(time * 5) * 1) * delta # Sine movement (up and down)
time += delta
position = target_position
# Take damage from player
func damage(amount):
Audio.play("sounds/enemy_hurt.ogg")
health -= amount
if health <= 0 and !destroyed:
destroy()
# Destroy the enemy when out of health
func destroy():
Audio.play("sounds/enemy_destroy.ogg")
destroyed = true
queue_free()
# Shoot when timer hits 0
func _on_timer_timeout():
raycast.force_raycast_update()
if raycast.is_colliding():
var collider = raycast.get_collider()
if collider.has_method("damage"): # Raycast collides with player
# Play muzzle flash animation(s)
muzzle_a.frame = 0
muzzle_a.play("default")
muzzle_a.rotation_degrees.z = randf_range(-45, 45)
muzzle_b.frame = 0
muzzle_b.play("default")
muzzle_b.rotation_degrees.z = randf_range(-45, 45)
Audio.play("sounds/enemy_attack.ogg")
collider.damage(5) # Apply damage to player
+1
View File
@@ -0,0 +1 @@
uid://b6udw8uhlp4ei
+36
View File
@@ -0,0 +1,36 @@
[gd_scene load_steps=5 format=3 uid="uid://d2g78tpqbyf5g"]
[ext_resource type="PackedScene" uid="uid://lde2xq3vq635" path="res://models/enemy-flying.glb" id="1_3v8nl"]
[ext_resource type="Script" uid="uid://b6udw8uhlp4ei" path="res://objects/enemy.gd" id="1_jg24b"]
[ext_resource type="SpriteFrames" uid="uid://dbv3sy5qjatnl" path="res://sprites/burst_animation.tres" id="3_iblw5"]
[sub_resource type="SphereShape3D" id="SphereShape3D_iix87"]
radius = 0.75
[node name="enemy-flying" type="Area3D"]
script = ExtResource("1_jg24b")
[node name="enemy-flying" parent="." instance=ExtResource("1_3v8nl")]
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.25, 0)
shape = SubResource("SphereShape3D_iix87")
[node name="RayCast" type="RayCast3D" parent="."]
target_position = Vector3(0, 0, 5)
[node name="MuzzleA" type="AnimatedSprite3D" parent="."]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, -0.45, 0.3, 0.4)
sprite_frames = ExtResource("3_iblw5")
frame = 2
[node name="MuzzleB" type="AnimatedSprite3D" parent="."]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0.45, 0.3, 0.4)
sprite_frames = ExtResource("3_iblw5")
frame = 2
[node name="Timer" type="Timer" parent="."]
wait_time = 0.25
autostart = true
[connection signal="timeout" from="Timer" to="." method="_on_timer_timeout"]
+7
View File
@@ -0,0 +1,7 @@
extends AnimatedSprite3D
# Remove this impact effect after the animation has completed
func _on_animation_finished():
queue_free()
+1
View File
@@ -0,0 +1 @@
uid://bwkexrmt718dx
+51
View File
@@ -0,0 +1,51 @@
[gd_scene load_steps=8 format=3 uid="uid://b7070gfoko4mo"]
[ext_resource type="Texture2D" uid="uid://dh0t42ubhuv0" path="res://sprites/hit.png" id="1_mdfft"]
[ext_resource type="Script" uid="uid://bwkexrmt718dx" path="res://objects/impact.gd" id="2_k826h"]
[sub_resource type="AtlasTexture" id="AtlasTexture_8c04i"]
atlas = ExtResource("1_mdfft")
region = Rect2(0, 0, 128, 128)
[sub_resource type="AtlasTexture" id="AtlasTexture_34j4g"]
atlas = ExtResource("1_mdfft")
region = Rect2(128, 0, 128, 128)
[sub_resource type="AtlasTexture" id="AtlasTexture_tk4oq"]
atlas = ExtResource("1_mdfft")
region = Rect2(0, 128, 128, 128)
[sub_resource type="AtlasTexture" id="AtlasTexture_q5m5l"]
atlas = ExtResource("1_mdfft")
region = Rect2(128, 128, 128, 128)
[sub_resource type="SpriteFrames" id="SpriteFrames_nwydm"]
animations = [{
"frames": [{
"duration": 1.0,
"texture": SubResource("AtlasTexture_8c04i")
}, {
"duration": 1.0,
"texture": SubResource("AtlasTexture_34j4g")
}, {
"duration": 1.0,
"texture": SubResource("AtlasTexture_tk4oq")
}, {
"duration": 1.0,
"texture": SubResource("AtlasTexture_q5m5l")
}],
"loop": false,
"name": &"shot",
"speed": 30.0
}]
[node name="AnimatedSprite3D" type="AnimatedSprite3D"]
cast_shadow = 0
pixel_size = 0.0025
double_sided = false
no_depth_test = true
sprite_frames = SubResource("SpriteFrames_nwydm")
animation = &"shot"
script = ExtResource("2_k826h")
[connection signal="animation_finished" from="." to="." method="_on_animation_finished"]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+285
View File
@@ -0,0 +1,285 @@
extends CharacterBody3D
@export_subgroup("Properties")
@export var movement_speed = 5
@export_range(0, 100) var number_of_jumps: int = 2
@export var jump_strength = 8
@export_subgroup("Weapons")
@export var weapons: Array[Weapon] = []
var weapon: Weapon
var weapon_index := 0
var mouse_sensitivity = 700
var gamepad_sensitivity := 0.075
var mouse_captured := true
var movement_velocity: Vector3
var rotation_target: Vector3
var input_mouse: Vector2
var health: int = 100
var gravity := 0.0
var previously_floored := false
var jumps_remaining: int
var container_offset = Vector3(1.2, -1.1, -2.75)
var tween: Tween
signal health_updated
@onready var camera = $Head/Camera
@onready var raycast = $Head/Camera/RayCast
@onready var muzzle = $Head/Camera/SubViewportContainer/SubViewport/CameraItem/Muzzle
@onready var container = $Head/Camera/SubViewportContainer/SubViewport/CameraItem/Container
@onready var sound_footsteps = $SoundFootsteps
@onready var blaster_cooldown = $Cooldown
@export var crosshair: TextureRect
# Functions
func _ready():
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
weapon = weapons[weapon_index] # Weapon must never be nil
initiate_change_weapon(weapon_index)
func _process(delta):
# Handle functions
handle_controls(delta)
handle_gravity(delta)
# Movement
var applied_velocity: Vector3
movement_velocity = transform.basis * movement_velocity # Move forward
applied_velocity = velocity.lerp(movement_velocity, delta * 10)
applied_velocity.y = - gravity
velocity = applied_velocity
move_and_slide()
# Rotation
container.position = lerp(container.position, container_offset - (basis.inverse() * applied_velocity / 30), delta * 10)
# Movement sound
sound_footsteps.stream_paused = true
if is_on_floor():
if abs(velocity.x) > 1 or abs(velocity.z) > 1:
sound_footsteps.stream_paused = false
# Landing after jump or falling
camera.position.y = lerp(camera.position.y, 0.0, delta * 5)
if is_on_floor() and gravity > 1 and !previously_floored: # Landed
Audio.play("sounds/land.ogg")
camera.position.y = -0.1
previously_floored = is_on_floor()
# Falling/respawning
if position.y < -10:
get_tree().reload_current_scene()
# Mouse movement
func _input(event):
if event is InputEventMouseMotion and mouse_captured:
input_mouse = event.relative / mouse_sensitivity
handle_rotation(event.relative.x, event.relative.y, false)
func handle_controls(delta):
# Mouse capture
if Input.is_action_just_pressed("mouse_capture"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
mouse_captured = true
if Input.is_action_just_pressed("mouse_capture_exit"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
mouse_captured = false
input_mouse = Vector2.ZERO
# Movement
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
movement_velocity = Vector3(input.x, 0, input.y).normalized() * movement_speed
# Handle Controller Rotation
var rotation_input := Input.get_vector("camera_right", "camera_left", "camera_down", "camera_up")
if rotation_input:
handle_rotation(rotation_input.x, rotation_input.y, true, delta)
# Shooting
action_shoot()
# Jumping
if Input.is_action_just_pressed("jump"):
if jumps_remaining:
action_jump()
# Weapon switching
action_weapon_toggle()
# Camera rotation
func handle_rotation(xRot: float, yRot: float, isController: bool, delta: float = 0.0):
if isController:
rotation_target -= Vector3(-yRot, -xRot, 0).limit_length(1.0) * gamepad_sensitivity
rotation_target.x = clamp(rotation_target.x, deg_to_rad(-90), deg_to_rad(90))
camera.rotation.x = lerp_angle(camera.rotation.x, rotation_target.x, delta * 25)
rotation.y = lerp_angle(rotation.y, rotation_target.y, delta * 25)
else:
rotation_target += (Vector3(-yRot, -xRot, 0) / mouse_sensitivity)
rotation_target.x = clamp(rotation_target.x, deg_to_rad(-90), deg_to_rad(90))
camera.rotation.x = rotation_target.x;
rotation.y = rotation_target.y;
# Handle gravity
func handle_gravity(delta):
gravity += 20 * delta
if gravity < 0 and is_on_ceiling():
gravity = 0
if gravity > 0 and is_on_floor():
jumps_remaining = number_of_jumps
gravity = 0
# Jumping
func action_jump():
Audio.play("sounds/jump_a.ogg, sounds/jump_b.ogg, sounds/jump_c.ogg")
gravity = - jump_strength
jumps_remaining -= 1
# Shooting
func action_shoot():
if Input.is_action_pressed("shoot"):
if !blaster_cooldown.is_stopped(): return # Cooldown for shooting
Audio.play(weapon.sound_shoot)
# Set muzzle flash position, play animation
muzzle.play("default")
muzzle.rotation_degrees.z = randf_range(-45, 45)
muzzle.scale = Vector3.ONE * randf_range(0.40, 0.75)
muzzle.position = container.position - weapon.muzzle_position
blaster_cooldown.start(weapon.cooldown)
# Shoot the weapon, amount based on shot count
for n in weapon.shot_count:
raycast.target_position.x = randf_range(-weapon.spread, weapon.spread)
raycast.target_position.y = randf_range(-weapon.spread, weapon.spread)
raycast.force_raycast_update()
if !raycast.is_colliding(): continue # Don't create impact when raycast didn't hit
var collider = raycast.get_collider()
# Hitting an enemy
if collider.has_method("damage"):
collider.damage(weapon.damage)
# Creating an impact animation
var impact = preload("res://objects/impact.tscn")
var impact_instance = impact.instantiate()
impact_instance.play("shot")
get_tree().root.add_child(impact_instance)
impact_instance.position = raycast.get_collision_point() + (raycast.get_collision_normal() / 10)
impact_instance.look_at(camera.global_transform.origin, Vector3.UP, true)
var knockback = random_vec2(weapon.min_knockback, weapon.max_knockback)
# print('knockback', knockback)
container.position.z += 0.25 # Knockback of weapon visual
camera.rotation.x += knockback.x # Knockback of camera
rotation.y += knockback.y
rotation_target.x += knockback.x
rotation_target.y += knockback.y
movement_velocity += Vector3(0, 0, weapon.knockback) # Knockback
# Toggle between available weapons (listed in 'weapons')
func action_weapon_toggle():
if Input.is_action_just_pressed("weapon_toggle"):
weapon_index = wrap(weapon_index + 1, 0, weapons.size())
initiate_change_weapon(weapon_index)
Audio.play("sounds/weapon_change.ogg")
# Initiates the weapon changing animation (tween)
func initiate_change_weapon(index):
weapon_index = index
tween = get_tree().create_tween()
tween.set_ease(Tween.EASE_OUT_IN)
tween.tween_property(container, "position", container_offset - Vector3(0, 1, 0), 0.1)
tween.tween_callback(change_weapon) # Changes the model
# Switches the weapon model (off-screen)
func change_weapon():
weapon = weapons[weapon_index]
# Step 1. Remove previous weapon model(s) from container
for n in container.get_children():
container.remove_child(n)
# Step 2. Place new weapon model in container
var weapon_model = weapon.model.instantiate()
container.add_child(weapon_model)
weapon_model.position = weapon.position
weapon_model.rotation_degrees = weapon.rotation
# Step 3. Set model to only render on layer 2 (the weapon camera)
for child in weapon_model.find_children("*", "MeshInstance3D"):
child.layers = 2
# Set weapon data
raycast.target_position = Vector3(0, 0, -1) * weapon.max_distance
crosshair.texture = weapon.crosshair
func damage(amount):
health -= amount
health_updated.emit(health) # Update health on HUD
if health < 0:
get_tree().reload_current_scene() # Reset when out of health
# Create a random knockback vector
static func random_vec2(_min: Vector2, _max: Vector2) -> Vector2:
var _sign = -1 if randi() % 2 == 0 else 1
return Vector2(randf_range(_min.x, _max.x), randf_range(_min.y, _max.y) * _sign)
+1
View File
@@ -0,0 +1 @@
uid://cu47grjp072jk
+78
View File
@@ -0,0 +1,78 @@
[gd_scene load_steps=9 format=3 uid="uid://dl2ed4gkybggf"]
[ext_resource type="Script" uid="uid://cu47grjp072jk" path="res://objects/player.gd" id="1_ffboj"]
[ext_resource type="Resource" uid="uid://cu2gtxlcmbb34" path="res://weapons/blaster-repeater.tres" id="2_6epbw"]
[ext_resource type="Texture2D" uid="uid://8ggihh27mlrr" path="res://sprites/blob_shadow.png" id="2_b0fo8"]
[ext_resource type="Script" uid="uid://dg01pkkc1c5vd" path="res://scripts/weapon.gd" id="2_i825w"]
[ext_resource type="Resource" uid="uid://c56y8pqoyk15f" path="res://weapons/blaster.tres" id="3_kr4p8"]
[ext_resource type="SpriteFrames" uid="uid://dbv3sy5qjatnl" path="res://sprites/burst_animation.tres" id="4_m6ukc"]
[ext_resource type="AudioStream" uid="uid://cydjn1ct3hps2" path="res://sounds/walking.ogg" id="5_ics1s"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_gdq8c"]
radius = 0.3
height = 1.0
[node name="Player" type="CharacterBody3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
script = ExtResource("1_ffboj")
weapons = Array[ExtResource("2_i825w")]([ExtResource("3_kr4p8"), ExtResource("2_6epbw")])
[node name="Collider" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.55, 0)
shape = SubResource("CapsuleShape3D_gdq8c")
[node name="Head" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
[node name="Camera" type="Camera3D" parent="Head"]
cull_mask = 1048573
current = true
fov = 80.0
[node name="SubViewportContainer" type="SubViewportContainer" parent="Head/Camera"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
stretch = true
[node name="SubViewport" type="SubViewport" parent="Head/Camera/SubViewportContainer"]
transparent_bg = true
handle_input_locally = false
msaa_3d = 1
size = Vector2i(1280, 720)
render_target_update_mode = 4
[node name="CameraItem" type="Camera3D" parent="Head/Camera/SubViewportContainer/SubViewport"]
cull_mask = 1047554
fov = 40.0
[node name="Container" type="Node3D" parent="Head/Camera/SubViewportContainer/SubViewport/CameraItem"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.2, -1, -2.25)
[node name="Muzzle" type="AnimatedSprite3D" parent="Head/Camera/SubViewportContainer/SubViewport/CameraItem"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.5, -0.75, -6)
layers = 2
sprite_frames = ExtResource("4_m6ukc")
frame = 2
[node name="RayCast" type="RayCast3D" parent="Head/Camera"]
exclude_parent = false
target_position = Vector3(0, 0, -10)
collide_with_areas = true
[node name="Shadow" type="Decal" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.9, 0)
size = Vector3(1, 2, 1)
texture_albedo = ExtResource("2_b0fo8")
modulate = Color(1, 1, 1, 0.705882)
normal_fade = 0.5
[node name="SoundFootsteps" type="AudioStreamPlayer" parent="."]
stream = ExtResource("5_ics1s")
volume_db = -5.0
autoplay = true
[node name="Cooldown" type="Timer" parent="."]
one_shot = true
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long