Compare commits

...
15 Commits
Author SHA1 Message Date
Dotts 3bdf8f413b Merge pull request 'Feat/12 add effects and better animations to fpv' (#17) from feat/12-add-effects-and-better-animations-to-fpv into main
Reviewed-on: #17
2026-06-11 19:35:33 -07:00
DottsGit 3c512870f2 feat: implement movement state machine ground/air states and initialize weapon manager system 2026-06-10 22:37:17 -04:00
DottsGit 3dc7fda622 feat: implement modular movement state machine and ground, air, and wall climb states 2026-06-10 22:27:10 -04:00
DottsGit 18ab9da4a3 feat: implement MovementStateMachine to manage modular player movement states and mechanics 2026-06-10 22:21:41 -04:00
DottsGit da4a6442c7 feat: implement player movement controller with grounded state, stair stepping, and wall interaction mechanics 2026-06-10 22:11:00 -04:00
DottsGit def2925a43 feat: implement pause menu with loadout, settings, and input rebinding support 2026-06-10 21:58:44 -04:00
DottsGit 4cb27f991e feat: add PlayerMovementController for character locomotion, state machine integration, and multiplayer synchronization 2026-06-10 21:12:44 -04:00
DottsGit 5e4507ab24 feat: implement pause menu system with loadout and settings configuration management 2026-06-10 21:08:01 -04:00
DottsGit 887d9c1523 feat: implement PlayerMovementController and grenade projectile system 2026-06-09 23:04:32 -04:00
DottsGit e8e5391d9f feat: implement MovementStateMachine for handling player movement states and mechanics 2026-06-09 22:53:24 -04:00
DottsGit 86abae27dd feat: implement weapon manager with viewmodel viewport, procedural arms, and loadout synchronization 2026-06-09 22:47:27 -04:00
DottsGit cff83b6356 feat: implement WeaponManager with viewport-based viewmodel rendering and procedural animation support 2026-06-09 22:44:56 -04:00
DottsGit 3e6eb40add feat: implement base weapon system classes for hitscan and projectile mechanics 2026-06-09 22:40:52 -04:00
DottsGit f63187e493 feat: implement foundational weapon systems, including hitscan and projectile base classes, reload mechanics, and player movement/camera integration. 2026-06-09 22:33:40 -04:00
DottsGit 5102b770c8 chore: reorder and consolidate window display settings in project configuration 2026-06-09 22:15:46 -04:00
20 changed files with 543 additions and 81 deletions
+11 -1
View File
@@ -14,6 +14,7 @@ var _target_tilt: float = 0.0
var _current_tilt: float = 0.0
var _target_fov: float = 90.0
var _weapon_fov_override: float = -1.0
var _step_offset_y: float = 0.0
func _ready() -> void:
@@ -80,11 +81,14 @@ func _process(delta: float) -> void:
camera.position.y = lerp(camera.position.y, 0.0, 0.15)
camera.position.x = lerp(camera.position.x, 0.0, 0.15)
# ── Step Smoothing ─────────────────────────────────────────────────────
_step_offset_y = lerpf(_step_offset_y, 0.0, 15.0 * delta)
camera.position.y += _step_offset_y
# ── Wall-run tilt ─────────────────────────────────────────────────────
_current_tilt = lerpf(_current_tilt, _target_tilt, 1.0 - exp(-params.wall_run_tilt_speed * delta))
camera.rotation.z = deg_to_rad(_current_tilt)
## Called by the movement system to set wall-run tilt direction.
## side: -1.0 (left wall), 1.0 (right wall), 0.0 (no tilt)
func set_wall_tilt(side: float) -> void:
@@ -100,3 +104,9 @@ func set_weapon_fov(fov: float) -> void:
## Called to reset tilt (e.g., on landing).
func clear_wall_tilt() -> void:
_target_tilt = 0.0
## Adds an offset to the camera so that when the player physics body snaps up a step,
## the camera interpolates smoothly instead of snapping.
func add_step_offset(offset_y: float) -> void:
_step_offset_y += offset_y
+23
View File
@@ -13,6 +13,7 @@ var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
@@ -489,6 +490,17 @@ func _build_hud() -> void:
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
@@ -664,6 +676,17 @@ func _process(_delta: float) -> void:
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
+3
View File
@@ -27,6 +27,7 @@ var default_bindings = {
var current_bindings = {}
var show_debug_ui: bool = false
var show_movement_speed: bool = false
var username: String = "Player"
var mouse_sensitivity: float = 0.002
var ads_sensitivity: float = 0.001
@@ -134,6 +135,7 @@ func _save_settings() -> void:
var save_data = {
"bindings": current_bindings,
"show_debug_ui": show_debug_ui,
"show_movement_speed": show_movement_speed,
"username": username,
"mouse_sensitivity": mouse_sensitivity,
"ads_sensitivity": ads_sensitivity,
@@ -172,6 +174,7 @@ func _load_settings() -> void:
current_bindings[action] = loaded_bindings[action]
if data.has("username"): username = data["username"]
if data.has("show_debug_ui"): show_debug_ui = data["show_debug_ui"]
if data.has("show_movement_speed"): show_movement_speed = data["show_movement_speed"]
if data.has("mouse_sensitivity"): mouse_sensitivity = data["mouse_sensitivity"]
if data.has("ads_sensitivity"): ads_sensitivity = data["ads_sensitivity"]
if data.has("world_fov"): world_fov = data["world_fov"]
+2 -2
View File
@@ -47,12 +47,12 @@ class_name MovementParams
@export var wall_angle_threshold: float = 70.0
@export var wall_detect_distance: float = 0.7
@export var wall_ray_up_height: float = 0.6
@export var wall_ray_down_height: float = -0.3
@export var wall_ray_down_height: float = 0.0
# ── Wall Climb ────────────────────────────────────────────────────────────────
@export var wall_climb_speed: float = 6.0
@export var wall_climb_duration: float = 3.0
@export var wall_climb_max_angle: float = 55.0
@export var wall_climb_max_angle: float = 75.0
@export var wall_climb_vault_forward: float = 16.0
@export var wall_climb_vault_up: float = 14.0
@export var wall_climb_vault_height_check: float = 1.0
+10 -15
View File
@@ -46,6 +46,7 @@ var original_capsule_height: float = 1.8
var slide_cooldown_timer: float = 0.0 # Prevents instant slide re-entry
var wall_cooldown_timer: float = 0.0 # Prevents instant re-attachment after wall jump
var is_crouched: bool = false
var can_wall_climb: bool = true
func _ready() -> void:
@@ -221,7 +222,7 @@ func detect_wall_horizontal() -> Vector3:
var hit := space_state.intersect_ray(ray)
if not hit.is_empty():
var collider = hit.get("collider")
if collider is PlayerMovementController:
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO)
# Wall must be roughly vertical (normal mostly horizontal)
@@ -242,7 +243,7 @@ func detect_wall_horizontal() -> Vector3:
var hit := space_state.intersect_ray(ray)
if not hit.is_empty():
var collider = hit.get("collider")
if collider is PlayerMovementController:
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
continue
var n: Vector3 = hit.get("normal", Vector3.ZERO)
if absf(n.y) < 0.3 and n.length_squared() > 0.0:
@@ -260,21 +261,15 @@ func detect_wall_forward() -> Dictionary:
if not player:
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
var move_dir := Vector3.ZERO
if hvel.length_squared() > 0.1:
move_dir = hvel.normalized()
var move_dir := -player.global_transform.basis.z
move_dir.y = 0.0
if move_dir.length_squared() > 0.01:
move_dir = move_dir.normalized()
else:
move_dir = -player.global_transform.basis.z
move_dir.y = 0.0
if move_dir.length_squared() > 0.01:
move_dir = move_dir.normalized()
if move_dir.length_squared() < 0.1:
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var space_state := player.get_world_3d().direct_space_state
var dist := params.wall_detect_distance
var dist := 1.2 # Increased to reliably detect walls at an angle
# Lower ray (feet/knees)
var origin_low := player.global_position + Vector3.UP * params.wall_ray_down_height
@@ -294,8 +289,8 @@ func detect_wall_forward() -> Dictionary:
# Get the normal from the highest point we hit, or the lowest if we only hit low
var best_hit = hit_mid if not hit_mid.is_empty() else hit_low
if best_hit.get("collider") is PlayerMovementController:
var collider = best_hit.get("collider")
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var normal: Vector3 = best_hit.get("normal", Vector3.ZERO)
+162 -20
View File
@@ -47,6 +47,11 @@ var grapple_swing_player: AudioStreamPlayer
# UI
var hit_marker: Control
var _hit_marker_tween: Tween
var reload_ring: Control
var grenades: int = 2
var is_holding_grenade: bool = false
var trajectory_mesh: MultiMeshInstance3D
# Grapple
var grapple_rope: MeshInstance3D
@@ -96,6 +101,7 @@ func _ready() -> void:
_setup_audio()
_setup_grapple()
_setup_grenade_trajectory()
if is_multiplayer_authority():
_damage_layer = CanvasLayer.new()
@@ -214,6 +220,11 @@ func _setup_hit_marker() -> void:
rect.rotation = angle
hit_marker.add_child(rect)
# Reload Ring
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.set_anchors_preset(Control.PRESET_CENTER)
_damage_layer.add_child(reload_ring)
func _setup_grapple() -> void:
grapple_rope = MeshInstance3D.new()
var rope_mesh = CylinderMesh.new()
@@ -230,6 +241,24 @@ func _setup_grapple() -> void:
grapple_rope.visible = false
get_tree().current_scene.call_deferred("add_child", grapple_rope)
func _setup_grenade_trajectory() -> void:
trajectory_mesh = MultiMeshInstance3D.new()
var mm = MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.instance_count = 60
trajectory_mesh.multimesh = mm
var dot_mesh = SphereMesh.new()
dot_mesh.radius = 0.05
dot_mesh.height = 0.1
var dot_mat = StandardMaterial3D.new()
dot_mat.albedo_color = Color(1.0, 1.0, 1.0, 0.5)
dot_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
dot_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
dot_mesh.material = dot_mat
trajectory_mesh.multimesh.mesh = dot_mesh
trajectory_mesh.visible = false
get_tree().current_scene.call_deferred("add_child", trajectory_mesh)
var _last_hit_sound_time: int = 0
func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
@@ -645,27 +674,37 @@ func server_receive_inputs(input_dir: Vector2, wish_dir: Vector3, jump: bool, ju
func _physics_process(_delta: float) -> void:
# Local client captures input and sends it
if is_multiplayer_authority():
var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
var world_dir := (forward * (-raw_input.y) + right * raw_input.x)
if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized()
var input_jump = Input.is_action_pressed("jump")
var input_jump_just = Input.is_action_just_pressed("jump")
var input_crouch = Input.is_action_pressed("crouch")
var input_dash = Input.is_action_just_pressed("dash")
var input_grapple = Input.is_action_pressed("grapple")
var input_grapple_just = Input.is_action_just_pressed("grapple")
var raw_input := Vector2.ZERO
var input_jump := false
var input_jump_just := false
var input_crouch := false
var input_dash := false
var input_grapple := false
var input_grapple_just := false
var world_dir := Vector3.ZERO
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
flashlight.visible = !flashlight.visible
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
raw_input = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
world_dir = (forward * (-raw_input.y) + right * raw_input.x)
if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized()
input_jump = Input.is_action_pressed("jump")
input_jump_just = Input.is_action_just_pressed("jump")
input_crouch = Input.is_action_pressed("crouch")
input_dash = Input.is_action_just_pressed("dash")
input_grapple = Input.is_action_pressed("grapple")
input_grapple_just = Input.is_action_just_pressed("grapple")
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
flashlight.visible = !flashlight.visible
server_receive_inputs.rpc_id(1, raw_input, world_dir, input_jump, input_jump_just, input_crouch, input_dash, input_grapple, input_grapple_just)
@@ -770,6 +809,26 @@ func _process(delta: float) -> void:
shield_bar.value = shield
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
# Update Reload Ring
if is_instance_valid(reload_ring) and is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var slot = wman.get("active_slot")
if slot != null and wman.weapons.has(slot):
var w = wman.weapons[slot]
if "reloading" in w and w.reloading and "reload_timer" in w and "reload_time" in w:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
else:
reload_ring.progress = 0.0
if is_multiplayer_authority() and not is_dead:
if is_holding_grenade and grenades > 0:
_draw_trajectory()
elif is_instance_valid(trajectory_mesh):
trajectory_mesh.visible = false
if is_dead:
# Continuously follow the ragdoll torso
if is_instance_valid(ragdoll_instance) and is_instance_valid(camera):
@@ -1003,6 +1062,87 @@ func _input(event: InputEvent) -> void:
if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton:
if event.is_pressed() and not event.is_echo():
_on_respawn_pressed()
if not is_dead and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
if event.is_action_pressed("grenade") and grenades > 0:
is_holding_grenade = true
elif event.is_action_released("grenade") and is_holding_grenade:
is_holding_grenade = false
_throw_grenade()
func _draw_trajectory() -> void:
if not camera or not trajectory_mesh: return
trajectory_mesh.visible = true
var throw_speed = 20.0
var throw_up_speed = 5.0
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
var gravity = 15.0 # Typical default
var current_pos = camera.global_position + camera.global_transform.basis.z * -0.5
var time_step = 0.05
var bounciness = 0.5
var space_state = get_world_3d().direct_space_state
for i in range(trajectory_mesh.multimesh.instance_count):
var t = Transform3D(Basis(), current_pos)
trajectory_mesh.multimesh.set_instance_transform(i, t)
var next_vel = throw_vel
next_vel.y -= gravity * time_step
var next_pos = current_pos + throw_vel * time_step
var query = PhysicsRayQueryParameters3D.create(current_pos, next_pos)
query.exclude = [get_rid()]
var hit = space_state.intersect_ray(query)
if hit:
current_pos = hit.position + hit.normal * 0.05
throw_vel = next_vel.bounce(hit.normal) * bounciness
else:
current_pos = next_pos
throw_vel = next_vel
func _throw_grenade() -> void:
if grenades <= 0: return
grenades -= 1
var throw_speed = 20.0
var throw_up_speed = 5.0
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
var spawn_pos = camera.global_position + camera.global_transform.basis.z * -0.5
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
server_spawn_grenade.rpc_id(1, spawn_pos, throw_vel)
else:
_spawn_grenade_local(spawn_pos, throw_vel, 1)
@rpc("any_peer", "call_local", "reliable")
func server_spawn_grenade(pos: Vector3, vel: Vector3) -> void:
if not multiplayer.is_server(): return
var id = multiplayer.get_remote_sender_id()
client_spawn_grenade.rpc(pos, vel, id)
@rpc("authority", "call_local", "reliable")
func client_spawn_grenade(pos: Vector3, vel: Vector3, owner_id: int) -> void:
_spawn_grenade_local(pos, vel, owner_id)
func _spawn_grenade_local(pos: Vector3, vel: Vector3, owner_id: int) -> void:
var g_scene = load("res://weapons/grenade_projectile.gd")
if not g_scene: return
var g = g_scene.new()
g.global_position = pos
g.velocity = vel
var p = null
if owner_id == multiplayer.get_unique_id():
p = self
else:
var root = get_tree().current_scene
if root.has_node("Players/" + str(owner_id)):
p = root.get_node("Players/" + str(owner_id))
g.owner_player = p
get_tree().current_scene.add_child(g)
func _on_respawn_pressed() -> void:
if death_screen:
@@ -1042,6 +1182,8 @@ func rpc_respawn(spawn_pos: Vector3) -> void:
health = max_health
shield = max_shield
time_since_last_damage = 0.0
grenades = 2
is_holding_grenade = false
if is_instance_valid(ragdoll_instance):
if ragdoll_instance.has_meta("spring_arm"):
+10 -4
View File
@@ -101,7 +101,7 @@ func update(delta: float) -> void:
return
# ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
if machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0:
if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 and machine.wall_cooldown_timer <= 0.0:
var fwd_wall = machine.detect_wall_forward()
if fwd_wall.hit:
if fwd_wall.is_short:
@@ -132,7 +132,7 @@ func update(delta: float) -> void:
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
var moving_towards := wish_dir.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
if looking_towards and moving_towards:
if looking_towards and moving_towards and machine.can_wall_climb:
machine.wall_normal = fwd_wall.normal
machine.switch_to("wall_climb")
return
@@ -140,8 +140,14 @@ func update(delta: float) -> void:
# Fallback to wall run
var wall_n := machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO:
machine.switch_to("wall_run")
return
var w_look_dir := -player.global_transform.basis.z
var h_look := Vector3(w_look_dir.x, 0.0, w_look_dir.z).normalized()
# Require looking mostly along the wall (angle > 41 degrees from normal)
# and inputting mostly along the wall (angle > 31 degrees from normal, allows W+A/D diagonally into wall)
if absf(h_look.dot(wall_n)) < 0.75 and absf(wish_dir.dot(wall_n)) < 0.85:
machine.switch_to("wall_run")
return
# ── Dash ──────────────────────────────────────────────────────────────
if machine.input_dash:
+27 -14
View File
@@ -10,6 +10,7 @@ var _footstep_timer: float = 0.0
func enter(_data: Dictionary = {}) -> void:
machine.on_ground = true
machine.current_jump_count = 0
machine.can_wall_climb = true
# Clear wall tilt on landing
var rig = _get_camera_rig()
if rig:
@@ -96,13 +97,13 @@ func update(delta: float) -> void:
for i in range(player.get_slide_collision_count()):
var col = player.get_slide_collision(i)
if absf(col.get_normal().y) < 0.3: # Hit a wall
var max_step_height = 0.5
var max_step_height = 0.95
var space_state = player.get_world_3d().direct_space_state
var feet_y = player.global_position.y - (machine.original_capsule_height / 2.0)
# Raycast down from above the step
var fwd = wish_dir.normalized()
var ray_start = player.global_position + fwd * 0.5
var ray_start = player.global_position + fwd * 0.65
ray_start.y = feet_y + max_step_height + 0.1
var ray_end = ray_start - Vector3.UP * (max_step_height + 0.2)
@@ -121,14 +122,20 @@ func update(delta: float) -> void:
head_ray.exclude = [player.get_rid()]
if space_state.intersect_ray(head_ray).is_empty():
# We can safely step up
player.global_position.y += step_height + 0.01
# Push slightly forward to get onto the step
player.global_position += fwd * 0.1
# Restore horizontal velocity that was lost from hitting the wall
player.velocity.x = hvel.x
player.velocity.z = hvel.z
break
# Check if there is enough space to move forward after stepping up
var test_transform = player.global_transform
test_transform.origin.y += step_height + 0.05
if not player.test_move(test_transform, fwd * 0.15):
# We can safely step up
player.global_position.y += step_height + 0.01
if player.head_pivot and player.head_pivot.has_method("add_step_offset"):
player.head_pivot.add_step_offset(-(step_height + 0.01))
# Push slightly forward to get onto the step
player.global_position += fwd * 0.1
# Restore horizontal velocity that was lost from hitting the wall
player.velocity.x = hvel.x
player.velocity.z = hvel.z
break
_update_capsule_height()
@@ -156,7 +163,7 @@ func update(delta: float) -> void:
return
# ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
if machine.input_dir.length() > 0.1 and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
var fwd_wall = machine.detect_wall_forward()
if fwd_wall.hit:
if fwd_wall.is_short:
@@ -188,7 +195,7 @@ func update(delta: float) -> void:
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
var moving_towards := wish_dir.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
if looking_towards and moving_towards:
if looking_towards and moving_towards and machine.can_wall_climb:
machine.wall_normal = fwd_wall.normal
machine.switch_to("wall_climb")
return
@@ -197,8 +204,14 @@ func update(delta: float) -> void:
var wall_n := machine.detect_wall_horizontal()
if wall_n != Vector3.ZERO:
if not player.is_on_floor():
machine.switch_to("wall_run")
return
var w_look_dir := -player.global_transform.basis.z
var h_look := Vector3(w_look_dir.x, 0.0, w_look_dir.z).normalized()
# Require looking mostly along the wall (angle > 41 degrees from normal)
# and inputting mostly along the wall (angle > 31 degrees from normal, allows W+A/D diagonally into wall)
if absf(h_look.dot(wall_n)) < 0.75 and absf(wish_dir.dot(wall_n)) < 0.85:
machine.switch_to("wall_run")
return
# ── Dash ──────────────────────────────────────────────────────────────
if machine.input_dash:
+1
View File
@@ -93,6 +93,7 @@ func _detach() -> void:
machine.wall_normal = Vector3.ZERO
machine.wall_side = 0.0
machine.wall_cooldown_timer = 0.3
machine.can_wall_climb = false
machine.switch_to("air")
func _vault() -> void:
+12 -7
View File
@@ -22,6 +22,13 @@ LoadoutManager="*res://globals/loadout_manager.gd"
PauseMenu="*res://ui/pause_menu.gd"
NetworkManager="*res://globals/network_manager.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[input]
move_forward={
@@ -79,6 +86,11 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
grenade={
"deadzone": 0.0,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
reload={
"deadzone": 0.0,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
@@ -102,10 +114,3 @@ scoreboard={
[rendering]
rendering_device/driver.windows="d3d12"
[display]
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
window/size/viewport_width=1920
window/size/viewport_height=1080
+23
View File
@@ -10,6 +10,7 @@ var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
@@ -283,6 +284,17 @@ func _build_hud() -> void:
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
@@ -443,6 +455,17 @@ func _process(_delta: float) -> void:
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
@@ -10,6 +10,7 @@ var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
@@ -249,6 +250,17 @@ func _build_hud() -> void:
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
@@ -423,6 +435,17 @@ func _process(_delta: float) -> void:
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
+22 -4
View File
@@ -37,6 +37,7 @@ var _resolution_opt: OptionButton
var _fps_slider: HSlider
var _fps_input: LineEdit
var _show_fps_checkbox: CheckButton
var _show_movement_speed_checkbox: CheckButton
var _vol_master_slider: HSlider
var _vol_master_input: LineEdit
var _vol_sfx_slider: HSlider
@@ -270,14 +271,16 @@ func _build_ui() -> void:
var user_save = Button.new()
user_save.text = "Save"
user_save.pressed.connect(func():
SettingsManager.username = user_input.text
var final_name = user_input.text.strip_edges()
user_input.text = final_name
SettingsManager.username = final_name
SettingsManager._save_settings()
var nm = get_node_or_null("/root/NetworkManager")
if nm:
if nm.multiplayer.has_multiplayer_peer() and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
nm.update_username.rpc_id(1, user_input.text)
nm.update_username.rpc_id(1, final_name)
else:
nm.update_username(user_input.text)
nm.update_username(final_name)
)
user_hbox.add_child(user_save)
@@ -622,6 +625,15 @@ func _build_ui() -> void:
)
advanced_tab.add_child(_show_fps_checkbox)
_show_movement_speed_checkbox = CheckButton.new()
_show_movement_speed_checkbox.text = "Show Movement Speed Indicator"
_show_movement_speed_checkbox.button_pressed = SettingsManager.show_movement_speed
_show_movement_speed_checkbox.toggled.connect(func(toggled_on: bool):
SettingsManager.show_movement_speed = toggled_on
SettingsManager._save_settings()
)
advanced_tab.add_child(_show_movement_speed_checkbox)
var spacer_adv = Control.new()
spacer_adv.size_flags_vertical = Control.SIZE_EXPAND_FILL
advanced_tab.add_child(spacer_adv)
@@ -633,6 +645,8 @@ func _build_ui() -> void:
SettingsManager.show_debug_ui = false
_show_fps_checkbox.button_pressed = false
SettingsManager.show_fps = false
_show_movement_speed_checkbox.button_pressed = false
SettingsManager.show_movement_speed = false
SettingsManager._save_settings()
)
advanced_tab.add_child(reset_adv_btn)
@@ -784,6 +798,8 @@ func _show_settings() -> void:
_debug_ui_checkbox.button_pressed = SettingsManager.show_debug_ui
if _show_fps_checkbox:
_show_fps_checkbox.button_pressed = SettingsManager.show_fps
if _show_movement_speed_checkbox:
_show_movement_speed_checkbox.button_pressed = SettingsManager.show_movement_speed
if _world_fov_slider:
_world_fov_slider.value = SettingsManager.world_fov
_world_fov_input.text = str(round(SettingsManager.world_fov))
@@ -906,7 +922,9 @@ func _populate_options(opt: OptionButton, category: String, current_id: String)
func _save_current_loadout() -> void:
if editing_index >= 0 and editing_index < LoadoutManager.loadouts.size():
var l = LoadoutManager.loadouts[editing_index]
l["name"] = name_edit.text
if name_edit.text.strip_edges() != "":
l["name"] = name_edit.text.strip_edges()
name_edit.text = l["name"]
l["primary_1"] = primary1_opt.get_item_metadata(primary1_opt.get_selected_id())
l["primary_2"] = primary2_opt.get_item_metadata(primary2_opt.get_selected_id())
l["special"] = special_opt.get_item_metadata(special_opt.get_selected_id())
+22
View File
@@ -0,0 +1,22 @@
extends Control
class_name ReloadRing
var progress: float = 0.0:
set(val):
progress = clampf(val, 0.0, 1.0)
queue_redraw()
var radius: float = 16.0
var thickness: float = 4.0
var color: Color = Color(1.0, 1.0, 1.0, 0.8)
func _draw() -> void:
if progress <= 0.0 or progress >= 1.0:
return
# Draw background arc (darker)
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true)
# Draw progress arc
var end_angle = -PI / 2.0 + (PI * 2.0 * progress)
draw_arc(Vector2.ZERO, radius, -PI / 2.0, end_angle, 32, color, thickness, true)
+1
View File
@@ -0,0 +1 @@
uid://c5eanbqnnm4kn
+15 -7
View File
@@ -11,6 +11,7 @@ class_name BaseHitscanWeapon
@export var max_range: float = 100.0
@export var automatic: bool = true
@export var spread_angle: float = 0.01
@export var recoil_amplitude: float = 0.05
@export var penetration_count: int = 0
@export var penetration_damage_penalty: float = 0.5
@@ -46,15 +47,19 @@ func _process(delta: float) -> void:
fire_cooldown -= delta
if reloading:
reload_timer -= delta
var spin_speed = (PI * 2.0) / reload_time
var spin_speed = PI * 8.0
model_root.rotation.x += spin_speed * delta
if reload_timer <= 0.0:
current_ammo = max_ammo
reloading = false
model_root.rotation.x = 0.0
print(weapon_name, " Reloaded!")
if reload_timer > 0.0:
reload_timer -= delta
else:
# Continue the loop until facing normal direction (multiples of 2 PI)
var target_rot = ceil(model_root.rotation.x / (PI * 2.0)) * (PI * 2.0)
if target_rot - model_root.rotation.x < spin_speed * delta * 1.5:
current_ammo = max_ammo
reloading = false
model_root.rotation.x = 0.0
print(weapon_name, " Reloaded!")
elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0:
_fire()
@@ -93,6 +98,9 @@ func _fire() -> void:
_shoot_hitscan()
_play_muzzle_flash()
if get_parent() and get_parent().has_method("add_recoil"):
get_parent().add_recoil(recoil_amplitude, randf_range(-recoil_amplitude * 0.5, recoil_amplitude * 0.5))
if fire_sound:
# Slight pitch variation
fire_sound.pitch_scale = 1.0 + randf_range(-0.05, 0.05)
+15 -7
View File
@@ -11,6 +11,7 @@ class_name BaseProjectileWeapon
@export var max_range: float = 200.0
@export var automatic: bool = true
@export var spread_angle: float = 0.01
@export var recoil_amplitude: float = 0.05
@export var projectile_speed: float = 50.0
@export var projectile_gravity: float = 0.0
@@ -45,15 +46,19 @@ func _process(delta: float) -> void:
fire_cooldown -= delta
if reloading:
reload_timer -= delta
var spin_speed = (PI * 2.0) / reload_time
var spin_speed = PI * 8.0
model_root.rotation.x += spin_speed * delta
if reload_timer <= 0.0:
current_ammo = max_ammo
reloading = false
model_root.rotation.x = 0.0
print(weapon_name, " Reloaded!")
if reload_timer > 0.0:
reload_timer -= delta
else:
# Continue the loop until facing normal direction (multiples of 2 PI)
var target_rot = ceil(model_root.rotation.x / (PI * 2.0)) * (PI * 2.0)
if target_rot - model_root.rotation.x < spin_speed * delta * 1.5:
current_ammo = max_ammo
reloading = false
model_root.rotation.x = 0.0
print(weapon_name, " Reloaded!")
elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0:
_fire()
@@ -92,6 +97,9 @@ func _fire() -> void:
_shoot_projectile()
_play_muzzle_flash()
if get_parent() and get_parent().has_method("add_recoil"):
get_parent().add_recoil(recoil_amplitude, randf_range(-recoil_amplitude * 0.5, recoil_amplitude * 0.5))
if fire_sound:
fire_sound.pitch_scale = 1.0 + randf_range(-0.05, 0.05)
fire_sound.play()
+76
View File
@@ -0,0 +1,76 @@
extends BouncingProjectile
class_name GrenadeProjectile
func _ready() -> void:
super._ready()
fuse_time = 4.0
bounciness = 0.5
drop_gravity = 15.0
explosion_radius = 5.0
damage = 120.0
func _setup_effects() -> void:
# Audio
flight_sound = AudioStreamPlayer3D.new()
flight_sound.bus = "SFX"
flight_sound.stream = load("res://assets/sounds/rocket_flight.wav")
flight_sound.autoplay = true
if flight_sound.stream is AudioStreamWAV:
flight_sound.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
flight_sound.unit_size = 50.0
flight_sound.max_db = 6.0
flight_sound.volume_db = 0.0
add_child(flight_sound)
flight_sound.play()
# Grenade Body
var body_mesh = MeshInstance3D.new()
var sphere = SphereMesh.new()
sphere.radius = 0.1
sphere.height = 0.2
var b_mat = StandardMaterial3D.new()
b_mat.albedo_color = Color(0.2, 0.4, 0.2) # Dark green
b_mat.roughness = 0.6
b_mat.metallic = 0.5
sphere.material = b_mat
body_mesh.mesh = sphere
add_child(body_mesh)
# Smoke Trail (White translucent tail)
smoke_sys = GPUParticles3D.new()
var smoke_mat = ParticleProcessMaterial.new()
smoke_mat.direction = Vector3(0, 0, 1)
smoke_mat.spread = 5.0
smoke_mat.initial_velocity_min = 0.0
smoke_mat.initial_velocity_max = 0.5
smoke_mat.gravity = Vector3(0, 0.1, 0) # Less floaty
var grad = Gradient.new()
grad.add_point(0.0, Color(1.0, 1.0, 1.0, 0.5))
grad.add_point(1.0, Color(1.0, 1.0, 1.0, 0.0))
var grad_tex = GradientTexture1D.new()
grad_tex.gradient = grad
smoke_mat.color_ramp = grad_tex
var curve = Curve.new()
curve.add_point(Vector2(0.0, 0.5))
curve.add_point(Vector2(1.0, 1.5))
var curve_tex = CurveTexture.new()
curve_tex.curve = curve
smoke_mat.scale_curve = curve_tex
var smoke_mesh = QuadMesh.new()
smoke_mesh.size = Vector2(0.2, 0.2)
var s_mat = StandardMaterial3D.new()
s_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
s_mat.vertex_color_use_as_albedo = true
s_mat.albedo_texture = get_smoke_texture()
s_mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
s_mat.billboard_keep_scale = true
smoke_mesh.material = s_mat
smoke_sys.draw_pass_1 = smoke_mesh
smoke_sys.process_material = smoke_mat
smoke_sys.amount = 32
smoke_sys.lifetime = 1.0
smoke_sys.position = Vector3(0, 0, 0.0)
add_child(smoke_sys)
+1
View File
@@ -0,0 +1 @@
uid://3m8quupertk6
+84
View File
@@ -55,6 +55,21 @@ func _setup_viewmodel_viewport() -> void:
dir_light.light_energy = 0.5
add_child(dir_light)
var _bob_timer: float = 0.0
var _current_slide_tilt_z: float = 0.0
var _current_slide_tilt_x: float = 0.0
var _current_slide_offset_x: float = 0.0
var _current_slide_offset_y: float = 0.0
var _recoil_pitch: float = 0.0
var _recoil_yaw: float = 0.0
var _target_drift_offset: Vector3 = Vector3.ZERO
var _current_drift_offset: Vector3 = Vector3.ZERO
var _swap_pitch: float = 0.0
func add_recoil(pitch: float, yaw: float) -> void:
_recoil_pitch += pitch * 20.0 # Scale up for visual model recoil
_recoil_yaw += yaw * 20.0
func _process(_delta: float) -> void:
if is_instance_valid(vm_camera) and is_instance_valid(camera):
vm_camera.global_transform = camera.global_transform
@@ -68,6 +83,72 @@ func _process(_delta: float) -> void:
else:
var over_speed_factor = clampf((hspeed - 11.0) / 19.0, 0.0, 1.0) # maxes out at 30 m/s
target_fov = lerpf(72.0, 80.0, over_speed_factor)
# Weapon Viewmodel Bobbing, Drift, and Slide Tilt
var is_sliding = player.has_method("get_node") and player.get_node_or_null("MovementStateMachine") and player.get_node("MovementStateMachine").current_state == "slide"
if is_sliding:
_bob_timer = 0.0
# Target tilt when sliding (inwards towards center, pitched slightly down)
# Assuming weapon is on right side (standard FPS), tilting left (positive Z rotation)
_current_slide_tilt_z = lerpf(_current_slide_tilt_z, deg_to_rad(15.0), 12.0 * _delta)
_current_slide_tilt_x = lerpf(_current_slide_tilt_x, 0.0, 12.0 * _delta)
# Move closer to center (negative X) and slightly down (negative Y)
_current_slide_offset_x = lerpf(_current_slide_offset_x, -0.15, 12.0 * _delta)
_current_slide_offset_y = lerpf(_current_slide_offset_y, -0.1, 12.0 * _delta)
else:
_current_slide_tilt_z = lerpf(_current_slide_tilt_z, 0.0, 10.0 * _delta)
_current_slide_tilt_x = lerpf(_current_slide_tilt_x, 0.0, 10.0 * _delta)
_current_slide_offset_x = lerpf(_current_slide_offset_x, 0.0, 10.0 * _delta)
_current_slide_offset_y = lerpf(_current_slide_offset_y, 0.0, 10.0 * _delta)
if player.is_on_floor() and hspeed > 1.0:
# Scale bobbing frequency by walk speed
_bob_timer += _delta * 12.0 * (hspeed / 11.0)
var bob_y = sin(_bob_timer) * 0.015
var bob_x = cos(_bob_timer * 0.5) * 0.01
vm_camera.translate_object_local(Vector3(bob_x, bob_y, 0))
else:
_bob_timer = 0.0
if not player.is_on_floor():
# Airborne Drift
# Convert player velocity into camera's local space
var local_vel = camera.global_transform.basis.inverse() * player.velocity
_target_drift_offset = Vector3(local_vel.x * 0.004, local_vel.y * 0.004, 0)
# Clamp the drift
_target_drift_offset.x = clampf(_target_drift_offset.x, -0.05, 0.05)
_target_drift_offset.y = clampf(_target_drift_offset.y, -0.05, 0.05)
else:
_target_drift_offset = Vector3.ZERO
# Smoothly apply drift so jumping/landing doesn't snap
_current_drift_offset = _current_drift_offset.lerp(_target_drift_offset, 15.0 * _delta)
vm_camera.translate_object_local(_current_drift_offset)
vm_camera.rotate_object_local(Vector3.UP, deg_to_rad(-_current_drift_offset.x * 30.0))
vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_current_drift_offset.y * 30.0))
# Apply slide tilt and offset
if absf(_current_slide_tilt_z) > 0.001 or absf(_current_slide_offset_x) > 0.001:
vm_camera.translate_object_local(Vector3(_current_slide_offset_x, _current_slide_offset_y, 0))
vm_camera.rotate_object_local(Vector3.FORWARD, _current_slide_tilt_z)
vm_camera.rotate_object_local(Vector3.RIGHT, _current_slide_tilt_x)
# Visual Recoil
_recoil_pitch = lerpf(_recoil_pitch, 0.0, 15.0 * _delta)
_recoil_yaw = lerpf(_recoil_yaw, 0.0, 15.0 * _delta)
vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_recoil_pitch))
vm_camera.rotate_object_local(Vector3.UP, deg_to_rad(_recoil_yaw))
# Add a slight backward kick along the local Z axis
vm_camera.translate_object_local(Vector3(0, 0, _recoil_pitch * 0.01))
# Swap Animation
_swap_pitch = lerpf(_swap_pitch, 0.0, 15.0 * _delta)
if _swap_pitch > 0.1:
vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_swap_pitch))
vm_camera.translate_object_local(Vector3(0, -_swap_pitch * 0.005, 0))
vm_camera.fov = lerpf(vm_camera.fov, target_fov, 10.0 * _delta)
@@ -222,6 +303,9 @@ func _equip_slot(slot: int) -> void:
if w.has_method("unequip"):
w.unequip()
if active_slot != slot:
_swap_pitch = 60.0
active_slot = slot
if weapons.has(active_slot):