Fresh start: replace with naxIO/netfox-cs-sample foundation
Complete replacement of the tactical-shooter project with the netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built with Godot 4 and netfox. ## What's new - Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu - 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP - Bomb plant/defuse with 2 bombsites - Flashbang & smoke grenades - Proper netfox rollback netcode at 64 tick - Network popup UI for host/join - HUD, crosshair, round timer, scoreboard - All netfox singletons registered as autoloads (works in exported builds) ## Architecture - Listen-server (host from client, no dedicated server binary) - Multiplayer-fps game lives at examples/multiplayer-fps/ - Netfox addons registered as autoloads for exported build compat - Godot 4.7 with Forward+ renderer ## Removed - Old headless-server architecture (client_main, server_main, player.gd, etc.) - Custom netfox bootstrap with ENet fallback - Old ChaffGames FPS template (2,420 lines, 844 KB) - SimulationServer GDExtension stub - Godot-jolt physics (netfox sample uses default Godot physics) - Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc. - Server browser API Python venv (87 MB) - test_range map and modular assets ## Preserved - Git history - Server config at config/default_server_config.cfg - Windows export preset - Build directory (gitignored) Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
extends Node
|
||||
class_name Bomb
|
||||
|
||||
enum BombState { IDLE, CARRIED, PLANTING, DROPPED, PLANTED, DEFUSING, EXPLODED, DEFUSED }
|
||||
|
||||
signal bomb_planted(site_name: String)
|
||||
signal bomb_exploded()
|
||||
signal bomb_defused()
|
||||
signal bomb_dropped(position: Vector3)
|
||||
signal bomb_picked_up(carrier_id: int)
|
||||
|
||||
@export var plant_time: float = 3.0
|
||||
@export var defuse_time: float = 10.0
|
||||
@export var defuse_kit_time: float = 5.0
|
||||
@export var bomb_timer: float = 40.0
|
||||
|
||||
var state: BombState = BombState.IDLE
|
||||
var carrier_id: int = -1
|
||||
var position: Vector3 = Vector3.ZERO
|
||||
var planted_site: String = ""
|
||||
var planted_tick: int = -1
|
||||
|
||||
# Progress tracking (tick-based)
|
||||
var _plant_start_tick: int = -1
|
||||
var _defuse_start_tick: int = -1
|
||||
var _defuser_id: int = -1
|
||||
|
||||
var team_manager: TeamManager
|
||||
var round_manager: RoundManager
|
||||
|
||||
func _ready():
|
||||
team_manager = get_tree().current_scene.get_node_or_null("Network/TeamManager")
|
||||
round_manager = get_tree().current_scene.get_node_or_null("Network/RoundManager")
|
||||
|
||||
if multiplayer.is_server():
|
||||
NetworkTime.on_tick.connect(_server_tick)
|
||||
NetworkEvents.on_peer_leave.connect(_handle_peer_leave)
|
||||
|
||||
if round_manager:
|
||||
round_manager.round_started.connect(_on_round_started)
|
||||
|
||||
func reset():
|
||||
state = BombState.IDLE
|
||||
carrier_id = -1
|
||||
position = Vector3.ZERO
|
||||
planted_site = ""
|
||||
planted_tick = -1
|
||||
_plant_start_tick = -1
|
||||
_defuse_start_tick = -1
|
||||
_defuser_id = -1
|
||||
|
||||
func assign_to_player(peer_id: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
carrier_id = peer_id
|
||||
state = BombState.CARRIED
|
||||
_broadcast_state()
|
||||
bomb_picked_up.emit(carrier_id)
|
||||
|
||||
func drop_at(pos: Vector3):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if state != BombState.CARRIED and state != BombState.PLANTING:
|
||||
return
|
||||
state = BombState.DROPPED
|
||||
carrier_id = -1
|
||||
position = pos
|
||||
_plant_start_tick = -1
|
||||
_broadcast_state()
|
||||
bomb_dropped.emit(pos)
|
||||
|
||||
func try_start_plant(peer_id: int, bombsite_name: String) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
if state != BombState.CARRIED:
|
||||
return false
|
||||
if carrier_id != peer_id:
|
||||
return false
|
||||
if team_manager.get_team(peer_id) != TeamManager.Team.T:
|
||||
return false
|
||||
if round_manager.state != RoundManager.RoundState.ACTIVE:
|
||||
return false
|
||||
|
||||
state = BombState.PLANTING
|
||||
planted_site = bombsite_name
|
||||
_plant_start_tick = NetworkTime.tick
|
||||
_broadcast_state()
|
||||
return true
|
||||
|
||||
func cancel_plant():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if state != BombState.PLANTING:
|
||||
return
|
||||
state = BombState.CARRIED
|
||||
_plant_start_tick = -1
|
||||
_broadcast_state()
|
||||
|
||||
func try_start_defuse(peer_id: int) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
if state != BombState.PLANTED:
|
||||
return false
|
||||
if team_manager.get_team(peer_id) != TeamManager.Team.CT:
|
||||
return false
|
||||
if round_manager.state != RoundManager.RoundState.ACTIVE:
|
||||
return false
|
||||
|
||||
state = BombState.DEFUSING
|
||||
_defuser_id = peer_id
|
||||
_defuse_start_tick = NetworkTime.tick
|
||||
_broadcast_state()
|
||||
return true
|
||||
|
||||
func cancel_defuse():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if state != BombState.DEFUSING:
|
||||
return
|
||||
state = BombState.PLANTED
|
||||
_defuser_id = -1
|
||||
_defuse_start_tick = -1
|
||||
_broadcast_state()
|
||||
|
||||
func try_pickup(peer_id: int) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
if state != BombState.DROPPED:
|
||||
return false
|
||||
if team_manager.get_team(peer_id) != TeamManager.Team.T:
|
||||
return false
|
||||
carrier_id = peer_id
|
||||
state = BombState.CARRIED
|
||||
_broadcast_state()
|
||||
bomb_picked_up.emit(carrier_id)
|
||||
return true
|
||||
|
||||
func get_plant_progress() -> float:
|
||||
if state != BombState.PLANTING or _plant_start_tick < 0:
|
||||
return 0.0
|
||||
return NetworkTime.seconds_between(_plant_start_tick, NetworkTime.tick) / plant_time
|
||||
|
||||
func get_defuse_progress() -> float:
|
||||
if state != BombState.DEFUSING or _defuse_start_tick < 0:
|
||||
return 0.0
|
||||
var time_needed := _get_defuse_time()
|
||||
return NetworkTime.seconds_between(_defuse_start_tick, NetworkTime.tick) / time_needed
|
||||
|
||||
func get_bomb_time_remaining() -> float:
|
||||
if state != BombState.PLANTED and state != BombState.DEFUSING:
|
||||
return bomb_timer
|
||||
if planted_tick < 0:
|
||||
return bomb_timer
|
||||
var elapsed := NetworkTime.seconds_between(planted_tick, NetworkTime.tick)
|
||||
return maxf(0.0, bomb_timer - elapsed)
|
||||
|
||||
func _get_defuse_time() -> float:
|
||||
var spawner := get_tree().current_scene.get_node_or_null("Network/Player Spawner")
|
||||
if spawner and _defuser_id >= 0:
|
||||
var avatar: CharacterBody3D = spawner.avatars.get(_defuser_id) as CharacterBody3D
|
||||
if avatar and avatar.has_defuse_kit:
|
||||
return defuse_kit_time
|
||||
return defuse_time
|
||||
|
||||
func _server_tick(_delta: float, tick: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
var spawner := get_tree().current_scene.get_node_or_null("Network/Player Spawner")
|
||||
|
||||
# --- Phase 1: Validate + Cancel (before any completion) ---
|
||||
|
||||
# Validate ongoing plant
|
||||
if state == BombState.PLANTING and carrier_id >= 0:
|
||||
if spawner:
|
||||
var avatar: CharacterBody3D = spawner.avatars.get(carrier_id) as CharacterBody3D
|
||||
if avatar == null or avatar.is_dead:
|
||||
drop_at(position)
|
||||
return
|
||||
position = avatar.global_position
|
||||
# Check use_held from carrier's input
|
||||
var input_node = avatar.get_node_or_null("Input") as PlayerInputFPS
|
||||
if input_node and not input_node.use_held:
|
||||
cancel_plant()
|
||||
return
|
||||
# Check still on bombsite
|
||||
var on_site := false
|
||||
for site in get_tree().get_nodes_in_group("bombsite"):
|
||||
if site is Bombsite and site.is_player_inside(avatar):
|
||||
on_site = true
|
||||
break
|
||||
if not on_site:
|
||||
cancel_plant()
|
||||
return
|
||||
|
||||
# Track carrier position (non-planting)
|
||||
if state == BombState.CARRIED and carrier_id >= 0:
|
||||
if spawner:
|
||||
var avatar: CharacterBody3D = spawner.avatars.get(carrier_id) as CharacterBody3D
|
||||
if avatar and not avatar.is_dead:
|
||||
position = avatar.global_position
|
||||
else:
|
||||
drop_at(position)
|
||||
return
|
||||
|
||||
# Validate ongoing defuse
|
||||
if state == BombState.DEFUSING and _defuser_id >= 0:
|
||||
if spawner:
|
||||
var avatar: CharacterBody3D = spawner.avatars.get(_defuser_id) as CharacterBody3D
|
||||
if avatar == null or avatar.is_dead:
|
||||
cancel_defuse()
|
||||
return
|
||||
if avatar.global_position.distance_to(position) > 2.5:
|
||||
cancel_defuse()
|
||||
return
|
||||
# Check use_held from defuser's input
|
||||
var input_node = avatar.get_node_or_null("Input") as PlayerInputFPS
|
||||
if input_node and not input_node.use_held:
|
||||
cancel_defuse()
|
||||
return
|
||||
else:
|
||||
cancel_defuse()
|
||||
return
|
||||
|
||||
# --- Phase 2: Completion (only if validation passed) ---
|
||||
|
||||
match state:
|
||||
BombState.PLANTING:
|
||||
if _plant_start_tick >= 0 and NetworkTime.seconds_between(_plant_start_tick, tick) >= plant_time:
|
||||
_complete_plant(tick)
|
||||
BombState.PLANTED, BombState.DEFUSING:
|
||||
# Bomb timer (ticks during both planted and defusing)
|
||||
if planted_tick >= 0 and NetworkTime.seconds_between(planted_tick, tick) >= bomb_timer:
|
||||
_explode()
|
||||
|
||||
# Defuse completion (after bomb timer, so explosion takes priority)
|
||||
if state == BombState.DEFUSING and _defuse_start_tick >= 0:
|
||||
if NetworkTime.seconds_between(_defuse_start_tick, tick) >= _get_defuse_time():
|
||||
_complete_defuse()
|
||||
|
||||
func _complete_plant(tick: int):
|
||||
state = BombState.PLANTED
|
||||
planted_tick = tick
|
||||
position = _get_carrier_position()
|
||||
carrier_id = -1
|
||||
_plant_start_tick = -1
|
||||
_broadcast_state()
|
||||
bomb_planted.emit(planted_site)
|
||||
|
||||
func _complete_defuse():
|
||||
state = BombState.DEFUSED
|
||||
_defuse_start_tick = -1
|
||||
_defuser_id = -1
|
||||
_broadcast_state()
|
||||
bomb_defused.emit()
|
||||
if round_manager:
|
||||
round_manager.end_round(TeamManager.Team.CT)
|
||||
|
||||
func _explode():
|
||||
state = BombState.EXPLODED
|
||||
_broadcast_state()
|
||||
bomb_exploded.emit()
|
||||
if round_manager:
|
||||
round_manager.end_round(TeamManager.Team.T)
|
||||
|
||||
func _get_carrier_position() -> Vector3:
|
||||
var spawner := get_tree().current_scene.get_node_or_null("Network/Player Spawner")
|
||||
if spawner and carrier_id >= 0:
|
||||
var avatar: CharacterBody3D = spawner.avatars.get(carrier_id) as CharacterBody3D
|
||||
if avatar:
|
||||
return avatar.global_position
|
||||
return position
|
||||
|
||||
func _on_round_started(_round_number: int):
|
||||
reset()
|
||||
|
||||
func _handle_peer_leave(peer_id: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if carrier_id == peer_id and (state == BombState.CARRIED or state == BombState.PLANTING):
|
||||
drop_at(position)
|
||||
if _defuser_id == peer_id and state == BombState.DEFUSING:
|
||||
cancel_defuse()
|
||||
|
||||
func sync_to_peer(peer_id: int):
|
||||
_sync_state.rpc_id(peer_id, state, carrier_id, position, planted_site, planted_tick, _plant_start_tick, _defuse_start_tick, _defuser_id)
|
||||
|
||||
func _broadcast_state():
|
||||
_sync_state.rpc(state, carrier_id, position, planted_site, planted_tick, _plant_start_tick, _defuse_start_tick, _defuser_id)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_state(new_state: int, new_carrier: int, new_pos: Vector3, site: String, p_tick: int, plant_start: int, defuse_start: int, defuser: int) -> void:
|
||||
state = new_state as BombState
|
||||
carrier_id = new_carrier
|
||||
position = new_pos
|
||||
planted_site = site
|
||||
planted_tick = p_tick
|
||||
_plant_start_tick = plant_start
|
||||
_defuse_start_tick = defuse_start
|
||||
_defuser_id = defuser
|
||||
@@ -0,0 +1 @@
|
||||
uid://bom5rgyo67c5j
|
||||
@@ -0,0 +1,26 @@
|
||||
extends Area3D
|
||||
class_name Bombsite
|
||||
|
||||
@export var site_name: String = "A"
|
||||
|
||||
func _ready():
|
||||
monitoring = true
|
||||
monitorable = true
|
||||
add_to_group("bombsite")
|
||||
|
||||
# Create default collision shape if none assigned
|
||||
var has_shape := false
|
||||
for child in get_children():
|
||||
if child is CollisionShape3D and child.shape != null:
|
||||
has_shape = true
|
||||
break
|
||||
if not has_shape:
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = Vector3(8, 4, 8)
|
||||
var col := CollisionShape3D.new()
|
||||
col.shape = shape
|
||||
add_child(col)
|
||||
|
||||
func is_player_inside(player: CharacterBody3D) -> bool:
|
||||
var overlapping := get_overlapping_bodies()
|
||||
return player in overlapping
|
||||
@@ -0,0 +1 @@
|
||||
uid://75nbj6o8h0i
|
||||
@@ -0,0 +1,66 @@
|
||||
extends Node3D
|
||||
class_name BulletHole
|
||||
|
||||
## Spawns a bullethole
|
||||
|
||||
## A StandardMaterial3D to use as the bullethole decal.
|
||||
@export var material: StandardMaterial3D
|
||||
## Maximum number of bullet holes
|
||||
@export var instance_limit: int = 20
|
||||
## The minimum size of the hole
|
||||
@export var hole_minimum_size: float = 0.15
|
||||
## The maximum size of the hole
|
||||
@export var hole_maximum_size: float = 0.2
|
||||
|
||||
@export_flags_3d_render var decal_mask := 1
|
||||
@export_flags_3d_render var decal_layer := 1
|
||||
|
||||
var pool := NodePool.new()
|
||||
|
||||
func _ready():
|
||||
pool.pool_limit = instance_limit
|
||||
pool.spawn_root = get_tree().root.get_node("multiplayer-fps/Map/StaticBody3D")
|
||||
|
||||
var decal_node: Decal = Decal.new()
|
||||
decal_node.cull_mask = decal_mask
|
||||
decal_node.layers = decal_layer
|
||||
|
||||
pool.target_node = decal_node
|
||||
|
||||
## Creates a bullethole decal, applies the texture and rotation/position calculations and removes the bullethole after the pool is full
|
||||
func action(result: Dictionary) -> void:
|
||||
var decal_node: Decal = pool.next()
|
||||
create_bullethole.call_deferred(decal_node, result)
|
||||
|
||||
func create_bullethole(decal_node: Decal, result: Dictionary):
|
||||
decal_node.texture_albedo = material.albedo_texture
|
||||
decal_node.texture_emission = material.emission_texture
|
||||
decal_node.texture_normal = material.normal_texture
|
||||
decal_node.size = Vector3(
|
||||
randf_range(hole_minimum_size, hole_maximum_size),
|
||||
0.02,
|
||||
randf_range(hole_minimum_size, hole_maximum_size)
|
||||
)
|
||||
|
||||
# Extract position and normal from the result dictionary
|
||||
var collision_point: Vector3 = result.position
|
||||
var collision_normal: Vector3 = result.normal.normalized()
|
||||
|
||||
# Construct a basis where the z-axis aligns with the collision normal
|
||||
var z_axis = collision_normal
|
||||
var x_axis = z_axis.cross(Vector3.UP).normalized()
|
||||
if x_axis.length() == 0:
|
||||
x_axis = z_axis.cross(Vector3.RIGHT).normalized()
|
||||
var y_axis = z_axis.cross(x_axis).normalized()
|
||||
var collision_basis = Basis(x_axis, y_axis, z_axis)
|
||||
|
||||
# Rotate the decal 90 degrees around the x-axis
|
||||
var rotation_basis = Basis().rotated(Vector3(1, 0, 0), deg_to_rad(90))
|
||||
var transform = Transform3D(rotation_basis, Vector3())
|
||||
|
||||
# Combine the collision basis and the rotation
|
||||
decal_node.global_transform = Transform3D(collision_basis, collision_point) * transform
|
||||
|
||||
# Apply random rotation around the normal
|
||||
var random_rotation = Basis(collision_normal, randf_range(0, 2 * PI))
|
||||
decal_node.global_transform.basis = random_rotation * decal_node.global_transform.basis
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvmqldrouk2ox
|
||||
@@ -0,0 +1,22 @@
|
||||
extends Resource
|
||||
class_name WeaponData
|
||||
|
||||
enum FireMode { SEMI, AUTO, MELEE }
|
||||
|
||||
@export var weapon_id: int = 0
|
||||
@export var weapon_name: String = ""
|
||||
@export var slot: int = 0
|
||||
@export var damage: int = 0
|
||||
@export var fire_rate: float = 0.0
|
||||
@export var fire_mode: FireMode = FireMode.SEMI
|
||||
@export var magazine_size: int = 0
|
||||
@export var reserve_size: int = 0
|
||||
@export var move_speed: float = 5.0
|
||||
@export var recoil_per_shot: float = 0.0
|
||||
@export var recoil_recovery: float = 10.0
|
||||
@export var kill_reward: int = 300
|
||||
@export var armor_penetration: float = 0.0
|
||||
@export var price: int = 0
|
||||
@export var team: int = -1 # -1=all, 0=T, 1=CT
|
||||
@export var max_distance: float = 1000.0
|
||||
@export var reload_time: float = 2.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhcvlxssmwofr
|
||||
@@ -0,0 +1,24 @@
|
||||
extends Node
|
||||
|
||||
const NONE := 0
|
||||
const KNIFE := 1
|
||||
const GLOCK := 2
|
||||
const USP := 3
|
||||
const AK47 := 4
|
||||
const M4A1 := 5
|
||||
const AWP := 6
|
||||
|
||||
const WEAPONS := {
|
||||
1: preload("res://examples/multiplayer-fps/scripts/data/weapons/knife.tres"),
|
||||
2: preload("res://examples/multiplayer-fps/scripts/data/weapons/glock.tres"),
|
||||
3: preload("res://examples/multiplayer-fps/scripts/data/weapons/usp.tres"),
|
||||
4: preload("res://examples/multiplayer-fps/scripts/data/weapons/ak47.tres"),
|
||||
5: preload("res://examples/multiplayer-fps/scripts/data/weapons/m4a1.tres"),
|
||||
6: preload("res://examples/multiplayer-fps/scripts/data/weapons/awp.tres"),
|
||||
}
|
||||
|
||||
static func get_weapon(id: int) -> WeaponData:
|
||||
return WEAPONS.get(id, null)
|
||||
|
||||
static func get_default_pistol(team: int) -> int:
|
||||
return GLOCK if team == 0 else USP
|
||||
@@ -0,0 +1 @@
|
||||
uid://4eps7xgx8wxh
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_resource type="Resource" script_class="WeaponData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://examples/multiplayer-fps/scripts/data/weapon_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_id = 4
|
||||
weapon_name = "AK-47"
|
||||
slot = 2
|
||||
damage = 36
|
||||
fire_rate = 0.1
|
||||
fire_mode = 1
|
||||
magazine_size = 30
|
||||
reserve_size = 90
|
||||
move_speed = 4.5
|
||||
recoil_per_shot = 2.5
|
||||
recoil_recovery = 10.0
|
||||
kill_reward = 300
|
||||
armor_penetration = 0.77
|
||||
price = 2500
|
||||
team = 0
|
||||
max_distance = 1000.0
|
||||
reload_time = 2.5
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_resource type="Resource" script_class="WeaponData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://examples/multiplayer-fps/scripts/data/weapon_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_id = 6
|
||||
weapon_name = "AWP"
|
||||
slot = 2
|
||||
damage = 110
|
||||
fire_rate = 1.5
|
||||
fire_mode = 0
|
||||
magazine_size = 10
|
||||
reserve_size = 30
|
||||
move_speed = 4.0
|
||||
recoil_per_shot = 0.0
|
||||
recoil_recovery = 0.0
|
||||
kill_reward = 50
|
||||
armor_penetration = 0.97
|
||||
price = 4750
|
||||
team = -1
|
||||
max_distance = 1000.0
|
||||
reload_time = 3.7
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_resource type="Resource" script_class="WeaponData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://examples/multiplayer-fps/scripts/data/weapon_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_id = 2
|
||||
weapon_name = "Glock"
|
||||
slot = 1
|
||||
damage = 25
|
||||
fire_rate = 0.15
|
||||
fire_mode = 0
|
||||
magazine_size = 20
|
||||
reserve_size = 120
|
||||
move_speed = 5.5
|
||||
recoil_per_shot = 1.5
|
||||
recoil_recovery = 10.0
|
||||
kill_reward = 300
|
||||
armor_penetration = 0.47
|
||||
price = 400
|
||||
team = 0
|
||||
max_distance = 1000.0
|
||||
reload_time = 2.2
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_resource type="Resource" script_class="WeaponData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://examples/multiplayer-fps/scripts/data/weapon_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_id = 1
|
||||
weapon_name = "Knife"
|
||||
slot = 0
|
||||
damage = 50
|
||||
fire_rate = 0.4
|
||||
fire_mode = 2
|
||||
magazine_size = 0
|
||||
reserve_size = 0
|
||||
move_speed = 6.0
|
||||
recoil_per_shot = 0.0
|
||||
recoil_recovery = 0.0
|
||||
kill_reward = 1500
|
||||
armor_penetration = 1.0
|
||||
price = 0
|
||||
team = -1
|
||||
max_distance = 2.0
|
||||
reload_time = 0.0
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_resource type="Resource" script_class="WeaponData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://examples/multiplayer-fps/scripts/data/weapon_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_id = 5
|
||||
weapon_name = "M4A1"
|
||||
slot = 2
|
||||
damage = 33
|
||||
fire_rate = 0.09
|
||||
fire_mode = 1
|
||||
magazine_size = 30
|
||||
reserve_size = 90
|
||||
move_speed = 4.7
|
||||
recoil_per_shot = 2.0
|
||||
recoil_recovery = 10.0
|
||||
kill_reward = 300
|
||||
armor_penetration = 0.7
|
||||
price = 3100
|
||||
team = 1
|
||||
max_distance = 1000.0
|
||||
reload_time = 3.1
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_resource type="Resource" script_class="WeaponData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://examples/multiplayer-fps/scripts/data/weapon_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
weapon_id = 3
|
||||
weapon_name = "USP"
|
||||
slot = 1
|
||||
damage = 34
|
||||
fire_rate = 0.17
|
||||
fire_mode = 0
|
||||
magazine_size = 12
|
||||
reserve_size = 48
|
||||
move_speed = 5.5
|
||||
recoil_per_shot = 1.2
|
||||
recoil_recovery = 10.0
|
||||
kill_reward = 300
|
||||
armor_penetration = 0.5
|
||||
price = 500
|
||||
team = 1
|
||||
max_distance = 1000.0
|
||||
reload_time = 2.7
|
||||
@@ -0,0 +1,298 @@
|
||||
extends Node
|
||||
class_name EconomyManager
|
||||
|
||||
const START_MONEY := 800
|
||||
const MAX_MONEY := 16000
|
||||
const WIN_REWARD := 3250
|
||||
const LOSS_BASE := 1400
|
||||
const LOSS_INCREMENT := 500
|
||||
const LOSS_MAX := 3400
|
||||
const PLANT_BONUS := 300
|
||||
const KEVLAR_PRICE := 650
|
||||
const DEFUSE_KIT_PRICE := 400
|
||||
const FLASH_PRICE := 200
|
||||
const FLASH_MAX := 2
|
||||
const SMOKE_PRICE := 300
|
||||
const SMOKE_MAX := 1
|
||||
|
||||
# peer_id -> money (server-authoritative)
|
||||
var _money: Dictionary = {}
|
||||
# peer_id -> loss streak count
|
||||
var _loss_streak: Dictionary = {}
|
||||
|
||||
var team_manager: TeamManager
|
||||
var round_manager: RoundManager
|
||||
|
||||
func _ready():
|
||||
team_manager = $"../TeamManager"
|
||||
round_manager = $"../RoundManager"
|
||||
|
||||
if round_manager:
|
||||
round_manager.player_killed.connect(_on_player_killed)
|
||||
round_manager.round_ended.connect(_on_round_ended)
|
||||
|
||||
var bomb_node := get_tree().current_scene.get_node_or_null("Network/Bomb") as Bomb
|
||||
if bomb_node:
|
||||
bomb_node.bomb_planted.connect(_on_bomb_planted.bind(bomb_node))
|
||||
|
||||
func init_player(peer_id: int):
|
||||
_money[peer_id] = START_MONEY
|
||||
_loss_streak[peer_id] = 0
|
||||
if multiplayer.is_server():
|
||||
_sync_money.rpc_id(peer_id, START_MONEY)
|
||||
|
||||
func get_money(peer_id: int) -> int:
|
||||
return _money.get(peer_id, 0)
|
||||
|
||||
func add_money(peer_id: int, amount: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_money[peer_id] = clampi(_money.get(peer_id, 0) + amount, 0, MAX_MONEY)
|
||||
_sync_money.rpc_id(peer_id, _money[peer_id])
|
||||
|
||||
func try_buy_weapon(peer_id: int, weapon_id: int, avatar: CharacterBody3D) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
|
||||
var weapon := WeaponRegistry.get_weapon(weapon_id)
|
||||
if weapon == null:
|
||||
return false
|
||||
|
||||
# Must be alive
|
||||
if avatar.is_dead:
|
||||
return false
|
||||
|
||||
# Must be in freeze time
|
||||
if round_manager.state != RoundManager.RoundState.FREEZE_TIME:
|
||||
return false
|
||||
if NetworkTime.tick >= round_manager.freeze_end_tick:
|
||||
return false
|
||||
|
||||
# Team restriction
|
||||
if weapon.team >= 0:
|
||||
var player_team := team_manager.get_team(peer_id)
|
||||
if player_team != weapon.team:
|
||||
return false
|
||||
|
||||
# Price check
|
||||
var money := get_money(peer_id)
|
||||
if money < weapon.price:
|
||||
return false
|
||||
|
||||
# Already have same weapon = denied
|
||||
var wm: WeaponManager = avatar.weapon_manager
|
||||
if wm.inventory[weapon.slot] == weapon_id:
|
||||
return false
|
||||
|
||||
# Buy: deduct money, give weapon
|
||||
add_money(peer_id, -weapon.price)
|
||||
wm.give_weapon(weapon_id)
|
||||
return true
|
||||
|
||||
func try_buy_kevlar(peer_id: int, avatar: CharacterBody3D) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
|
||||
if avatar.is_dead:
|
||||
return false
|
||||
|
||||
if round_manager.state != RoundManager.RoundState.FREEZE_TIME:
|
||||
return false
|
||||
if NetworkTime.tick >= round_manager.freeze_end_tick:
|
||||
return false
|
||||
|
||||
if avatar.armor >= 100:
|
||||
return false
|
||||
|
||||
var money := get_money(peer_id)
|
||||
if money < KEVLAR_PRICE:
|
||||
return false
|
||||
|
||||
add_money(peer_id, -KEVLAR_PRICE)
|
||||
avatar.armor = 100
|
||||
return true
|
||||
|
||||
func try_buy_defuse_kit(peer_id: int, avatar: CharacterBody3D) -> bool:
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
|
||||
if avatar.is_dead:
|
||||
return false
|
||||
|
||||
if round_manager.state != RoundManager.RoundState.FREEZE_TIME:
|
||||
return false
|
||||
if NetworkTime.tick >= round_manager.freeze_end_tick:
|
||||
return false
|
||||
|
||||
# Only CTs
|
||||
if team_manager.get_team(peer_id) != TeamManager.Team.CT:
|
||||
return false
|
||||
|
||||
if avatar.has_defuse_kit:
|
||||
return false
|
||||
|
||||
var money := get_money(peer_id)
|
||||
if money < DEFUSE_KIT_PRICE:
|
||||
return false
|
||||
|
||||
add_money(peer_id, -DEFUSE_KIT_PRICE)
|
||||
avatar.has_defuse_kit = true
|
||||
return true
|
||||
|
||||
func try_buy_grenade(peer_id: int, grenade_type: int, avatar: CharacterBody3D) -> bool:
|
||||
## grenade_type: 0=flash, 1=smoke
|
||||
if not multiplayer.is_server():
|
||||
return false
|
||||
|
||||
if avatar.is_dead:
|
||||
return false
|
||||
|
||||
if round_manager.state != RoundManager.RoundState.FREEZE_TIME:
|
||||
return false
|
||||
if NetworkTime.tick >= round_manager.freeze_end_tick:
|
||||
return false
|
||||
|
||||
var price := FLASH_PRICE if grenade_type == 0 else SMOKE_PRICE
|
||||
var money := get_money(peer_id)
|
||||
if money < price:
|
||||
return false
|
||||
|
||||
var wm: WeaponManager = avatar.weapon_manager
|
||||
if not wm.add_grenade(grenade_type):
|
||||
return false # at max
|
||||
|
||||
add_money(peer_id, -price)
|
||||
return true
|
||||
|
||||
func _on_bomb_planted(_site_name: String, bomb_node: Bomb):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
# Plant bonus: all Ts get $300
|
||||
for peer_id in team_manager.get_team_members(TeamManager.Team.T):
|
||||
add_money(peer_id, PLANT_BONUS)
|
||||
|
||||
func _on_player_killed(victim_id: int, killer_id: int, weapon_id: int, _kill_tick: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if killer_id < 0:
|
||||
return
|
||||
var weapon := WeaponRegistry.get_weapon(weapon_id)
|
||||
var reward := weapon.kill_reward if weapon else 300
|
||||
add_money(killer_id, reward)
|
||||
|
||||
func _on_round_ended(winner: TeamManager.Team):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
for peer_id in team_manager.assignments:
|
||||
var team := team_manager.get_team(peer_id)
|
||||
if team == TeamManager.Team.NONE:
|
||||
continue
|
||||
|
||||
if team == winner:
|
||||
add_money(peer_id, WIN_REWARD)
|
||||
_loss_streak[peer_id] = 0
|
||||
else:
|
||||
var streak: int = _loss_streak.get(peer_id, 0)
|
||||
var loss_reward: int = mini(LOSS_BASE + streak * LOSS_INCREMENT, LOSS_MAX)
|
||||
add_money(peer_id, loss_reward)
|
||||
_loss_streak[peer_id] = streak + 1
|
||||
|
||||
func sync_to_peer(peer_id: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var money: int = _money.get(peer_id, START_MONEY)
|
||||
_sync_money.rpc_id(peer_id, money)
|
||||
|
||||
func remove_player(peer_id: int):
|
||||
_money.erase(peer_id)
|
||||
_loss_streak.erase(peer_id)
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func _sync_money(amount: int) -> void:
|
||||
var local_id := multiplayer.get_unique_id()
|
||||
_money[local_id] = amount
|
||||
|
||||
# --- Public buy API (handles host-direct-call vs client-RPC) ---
|
||||
|
||||
func request_buy_weapon(weapon_id: int):
|
||||
var peer_id := multiplayer.get_unique_id()
|
||||
if multiplayer.is_server():
|
||||
_do_buy_weapon(peer_id, weapon_id)
|
||||
else:
|
||||
_rpc_buy_weapon.rpc_id(get_multiplayer_authority(), weapon_id)
|
||||
|
||||
func request_buy_kevlar():
|
||||
var peer_id := multiplayer.get_unique_id()
|
||||
if multiplayer.is_server():
|
||||
_do_buy_kevlar(peer_id)
|
||||
else:
|
||||
_rpc_buy_kevlar.rpc_id(get_multiplayer_authority())
|
||||
|
||||
func request_buy_grenade(grenade_type: int):
|
||||
var peer_id := multiplayer.get_unique_id()
|
||||
if multiplayer.is_server():
|
||||
_do_buy_grenade(peer_id, grenade_type)
|
||||
else:
|
||||
_rpc_buy_grenade.rpc_id(get_multiplayer_authority(), grenade_type)
|
||||
|
||||
func request_buy_defuse_kit():
|
||||
var peer_id := multiplayer.get_unique_id()
|
||||
if multiplayer.is_server():
|
||||
_do_buy_defuse_kit(peer_id)
|
||||
else:
|
||||
_rpc_buy_defuse_kit.rpc_id(get_multiplayer_authority())
|
||||
|
||||
# --- Internal: resolve peer_id to avatar and execute ---
|
||||
|
||||
func _get_avatar(peer_id: int) -> CharacterBody3D:
|
||||
var spawner := get_tree().current_scene.get_node_or_null("Network/Player Spawner")
|
||||
if spawner == null:
|
||||
return null
|
||||
return spawner.avatars.get(peer_id) as CharacterBody3D
|
||||
|
||||
func _do_buy_weapon(peer_id: int, weapon_id: int):
|
||||
var avatar := _get_avatar(peer_id)
|
||||
if avatar:
|
||||
try_buy_weapon(peer_id, weapon_id, avatar)
|
||||
|
||||
func _do_buy_kevlar(peer_id: int):
|
||||
var avatar := _get_avatar(peer_id)
|
||||
if avatar:
|
||||
try_buy_kevlar(peer_id, avatar)
|
||||
|
||||
func _do_buy_grenade(peer_id: int, grenade_type: int):
|
||||
var avatar := _get_avatar(peer_id)
|
||||
if avatar:
|
||||
try_buy_grenade(peer_id, grenade_type, avatar)
|
||||
|
||||
func _do_buy_defuse_kit(peer_id: int):
|
||||
var avatar := _get_avatar(peer_id)
|
||||
if avatar:
|
||||
try_buy_defuse_kit(peer_id, avatar)
|
||||
|
||||
# --- RPCs: client → server ---
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _rpc_buy_weapon(weapon_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_do_buy_weapon(multiplayer.get_remote_sender_id(), weapon_id)
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _rpc_buy_kevlar() -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_do_buy_kevlar(multiplayer.get_remote_sender_id())
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _rpc_buy_grenade(grenade_type: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_do_buy_grenade(multiplayer.get_remote_sender_id(), grenade_type)
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _rpc_buy_defuse_kit() -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_do_buy_defuse_kit(multiplayer.get_remote_sender_id())
|
||||
@@ -0,0 +1 @@
|
||||
uid://6oqdm8uv2u4r
|
||||
@@ -0,0 +1,160 @@
|
||||
extends Node3D
|
||||
class_name Grenade
|
||||
|
||||
enum GrenadeType { FLASH, SMOKE }
|
||||
|
||||
@export var grenade_type: GrenadeType = GrenadeType.FLASH
|
||||
@export var fuse_time: float = 1.5
|
||||
@export var throw_force: float = 20.0
|
||||
@export var gravity: float = 15.0
|
||||
@export var bounce_factor: float = 0.4
|
||||
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
var _fuse_timer: float = 0.0
|
||||
var _detonated := false
|
||||
var _owner_id: int = -1
|
||||
|
||||
func setup(origin: Vector3, direction: Vector3, owner_id: int, type: GrenadeType):
|
||||
global_position = origin
|
||||
grenade_type = type
|
||||
_owner_id = owner_id
|
||||
velocity = direction * throw_force + Vector3.UP * 5.0
|
||||
|
||||
if type == GrenadeType.FLASH:
|
||||
fuse_time = 1.5
|
||||
else:
|
||||
fuse_time = 2.0
|
||||
|
||||
func _physics_process(delta: float):
|
||||
if _detonated:
|
||||
return
|
||||
|
||||
# Simple physics
|
||||
velocity.y -= gravity * delta
|
||||
var new_pos := global_position + velocity * delta
|
||||
|
||||
# Basic bounce off floor
|
||||
if new_pos.y < 0.1:
|
||||
new_pos.y = 0.1
|
||||
velocity.y = -velocity.y * bounce_factor
|
||||
velocity.x *= bounce_factor
|
||||
velocity.z *= bounce_factor
|
||||
|
||||
global_position = new_pos
|
||||
|
||||
# Fuse countdown
|
||||
_fuse_timer += delta
|
||||
if _fuse_timer >= fuse_time:
|
||||
_detonate()
|
||||
|
||||
func _detonate():
|
||||
if _detonated:
|
||||
return
|
||||
_detonated = true
|
||||
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
match grenade_type:
|
||||
GrenadeType.FLASH:
|
||||
_flash_effect()
|
||||
GrenadeType.SMOKE:
|
||||
_smoke_effect()
|
||||
|
||||
func _flash_effect():
|
||||
var space := get_world_3d().direct_space_state
|
||||
var flash_pos := global_position
|
||||
|
||||
# Check all players for flash
|
||||
var spawner := get_tree().current_scene.get_node_or_null("Network/Player Spawner")
|
||||
if spawner == null:
|
||||
queue_free()
|
||||
return
|
||||
|
||||
for peer_id in spawner.avatars:
|
||||
var avatar = spawner.avatars[peer_id] as CharacterBody3D
|
||||
if avatar == null or avatar.is_dead:
|
||||
continue
|
||||
|
||||
var head_pos: Vector3 = avatar.global_position + Vector3.UP * 0.7
|
||||
var distance := flash_pos.distance_to(head_pos)
|
||||
if distance > 30.0:
|
||||
continue
|
||||
|
||||
# LOS check
|
||||
var query := PhysicsRayQueryParameters3D.create(flash_pos, head_pos)
|
||||
query.exclude = [avatar.get_rid()]
|
||||
var result := space.intersect_ray(query)
|
||||
|
||||
if result.is_empty():
|
||||
# Direct line of sight — calculate flash duration
|
||||
var duration := _calculate_flash_duration(distance, avatar, head_pos, flash_pos)
|
||||
if duration > 0.0:
|
||||
_apply_flash.rpc_id(peer_id, duration)
|
||||
|
||||
# Cleanup
|
||||
call_deferred("queue_free")
|
||||
|
||||
func _calculate_flash_duration(distance: float, avatar: CharacterBody3D, head_pos: Vector3, flash_pos: Vector3) -> float:
|
||||
# Base duration: closer = longer flash
|
||||
var base := lerpf(3.5, 0.5, clampf(distance / 25.0, 0.0, 1.0))
|
||||
|
||||
# Facing check: looking at flash = full duration, away = reduced
|
||||
var head := avatar.get_node_or_null("Head") as Node3D
|
||||
if head:
|
||||
var to_flash := (flash_pos - head_pos).normalized()
|
||||
var facing := -head.global_transform.basis.z
|
||||
var dot := facing.dot(to_flash)
|
||||
if dot < 0.0:
|
||||
base *= 0.3 # Looking away
|
||||
else:
|
||||
base *= lerpf(0.5, 1.0, dot)
|
||||
|
||||
return base
|
||||
|
||||
func _smoke_effect():
|
||||
# Spawn smoke zone at detonation position
|
||||
_spawn_smoke.rpc(global_position)
|
||||
# Smoke grenade stays alive for smoke duration, then frees itself
|
||||
set_physics_process(false)
|
||||
var smoke_duration := 18.0
|
||||
get_tree().create_timer(smoke_duration).timeout.connect(queue_free)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _apply_flash(duration: float) -> void:
|
||||
# Client-side: white screen effect
|
||||
var canvas := CanvasLayer.new()
|
||||
canvas.layer = 100
|
||||
var rect := ColorRect.new()
|
||||
rect.color = Color(1, 1, 1, 1)
|
||||
rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
canvas.add_child(rect)
|
||||
get_tree().current_scene.add_child(canvas)
|
||||
|
||||
# Fade out
|
||||
var tween := get_tree().create_tween()
|
||||
tween.tween_property(rect, "color:a", 0.0, duration)
|
||||
tween.tween_callback(canvas.queue_free)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _spawn_smoke(pos: Vector3) -> void:
|
||||
# Client-side: visual smoke particles
|
||||
var smoke_node := Node3D.new()
|
||||
smoke_node.name = "SmokeCloud"
|
||||
smoke_node.global_position = pos
|
||||
get_tree().current_scene.add_child(smoke_node)
|
||||
|
||||
# Simple visual: grey sphere mesh as placeholder
|
||||
var mesh_inst := MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 3.0
|
||||
sphere.height = 4.0
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.6, 0.6, 0.6, 0.5)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
sphere.material = mat
|
||||
mesh_inst.mesh = sphere
|
||||
smoke_node.add_child(mesh_inst)
|
||||
|
||||
get_tree().create_timer(18.0).timeout.connect(smoke_node.queue_free)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxwydsaf3nb6c
|
||||
@@ -0,0 +1,38 @@
|
||||
extends Node
|
||||
class_name NodePool
|
||||
|
||||
## The maximum objects in the pool
|
||||
@export var pool_limit: int = 10
|
||||
## The node to duplicate
|
||||
@export var target_node: Node: set = _set_target_node
|
||||
## The flag number to duplicate with
|
||||
@export var duplicate_flag: int = 15
|
||||
## Where to spawn additional nodes
|
||||
@export var spawn_root: Node
|
||||
|
||||
var pool: Array[Node] = []
|
||||
|
||||
func _set_target_node(new_node: Node) -> void:
|
||||
target_node = new_node
|
||||
clear()
|
||||
_add_node_to_tree(target_node)
|
||||
pool = [target_node]
|
||||
|
||||
func _add_node_to_tree(node: Node) -> void:
|
||||
if !node.is_inside_tree():
|
||||
if is_instance_valid(spawn_root):
|
||||
spawn_root.add_child.call_deferred(node)
|
||||
else:
|
||||
add_child(node)
|
||||
|
||||
func next() -> Node:
|
||||
var node: Node = pool.pop_front() if pool.size() >= pool_limit else pool[0].duplicate(duplicate_flag)
|
||||
pool.append(node)
|
||||
_add_node_to_tree(node)
|
||||
return node
|
||||
|
||||
func clear():
|
||||
for node in pool:
|
||||
if node.is_instance_valid():
|
||||
node.queue_free()
|
||||
pool = []
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7nq6fh5eegbw
|
||||
@@ -0,0 +1,129 @@
|
||||
extends BaseNetInput
|
||||
class_name PlayerInputFPS
|
||||
|
||||
@export var mouse_sensitivity: float = 1.0
|
||||
@export var big_gun: MeshInstance3D
|
||||
@export var hud: CanvasGroup
|
||||
|
||||
@onready var camera: Camera3D = $"../Head/Camera3D"
|
||||
|
||||
# Config variables
|
||||
var is_setup: bool = false
|
||||
var override_mouse: bool = false
|
||||
|
||||
# Input latches (set in _input, consumed in _gather)
|
||||
var _fire_pressed_latch := false
|
||||
var _reload_pressed_latch := false
|
||||
var _next_weapon_latch := false
|
||||
var _prev_weapon_latch := false
|
||||
var _slot_1_latch := false
|
||||
var _slot_2_latch := false
|
||||
var _slot_3_latch := false
|
||||
var _slot_4_latch := false
|
||||
|
||||
# Input variables (frame-rate mouse accumulator)
|
||||
var mouse_rotation: Vector2 = Vector2.ZERO
|
||||
|
||||
# Rollback input properties (set in _gather)
|
||||
var look_angle: Vector2 = Vector2.ZERO
|
||||
var movement: Vector3 = Vector3.ZERO
|
||||
var jump: bool = false
|
||||
var fire_held: bool = false
|
||||
var fire_pressed: bool = false
|
||||
var reload_pressed: bool = false
|
||||
var next_weapon: bool = false
|
||||
var prev_weapon: bool = false
|
||||
var slot_1: bool = false
|
||||
var slot_2: bool = false
|
||||
var slot_3: bool = false
|
||||
var slot_4: bool = false
|
||||
var use_held: bool = false
|
||||
|
||||
func _notification(what):
|
||||
if what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
override_mouse = false
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if !is_multiplayer_authority(): return
|
||||
|
||||
if event is InputEventMouseMotion:
|
||||
mouse_rotation.y += event.relative.x * mouse_sensitivity
|
||||
mouse_rotation.x += event.relative.y * mouse_sensitivity
|
||||
|
||||
if event.is_action_pressed("escape"):
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
override_mouse = true
|
||||
|
||||
# Latched inputs — captured on press, consumed once in _gather
|
||||
if event.is_action_pressed("mouse_weapon_fire"):
|
||||
_fire_pressed_latch = true
|
||||
if event.is_action_pressed("weapon_reload"):
|
||||
_reload_pressed_latch = true
|
||||
if event.is_action_pressed("weapon_next"):
|
||||
_next_weapon_latch = true
|
||||
if event.is_action_pressed("weapon_prev"):
|
||||
_prev_weapon_latch = true
|
||||
if event.is_action_pressed("weapon_slot_1"):
|
||||
_slot_1_latch = true
|
||||
if event.is_action_pressed("weapon_slot_2"):
|
||||
_slot_2_latch = true
|
||||
if event.is_action_pressed("weapon_slot_3"):
|
||||
_slot_3_latch = true
|
||||
if event.is_action_pressed("weapon_slot_4"):
|
||||
_slot_4_latch = true
|
||||
|
||||
func _gather():
|
||||
if !is_setup:
|
||||
setup()
|
||||
|
||||
# Movement (continuous)
|
||||
var mx = Input.get_axis("move_west", "move_east")
|
||||
var mz = Input.get_axis("move_north", "move_south")
|
||||
movement = Vector3(mx, 0, mz)
|
||||
|
||||
jump = Input.is_action_pressed("move_jump")
|
||||
|
||||
# Fire: held state + one-shot pressed latch
|
||||
fire_held = Input.is_action_pressed("mouse_weapon_fire")
|
||||
fire_pressed = _fire_pressed_latch
|
||||
_fire_pressed_latch = false
|
||||
|
||||
# Weapon inputs (latched)
|
||||
reload_pressed = _reload_pressed_latch
|
||||
_reload_pressed_latch = false
|
||||
|
||||
next_weapon = _next_weapon_latch
|
||||
_next_weapon_latch = false
|
||||
|
||||
prev_weapon = _prev_weapon_latch
|
||||
_prev_weapon_latch = false
|
||||
|
||||
slot_1 = _slot_1_latch
|
||||
_slot_1_latch = false
|
||||
|
||||
slot_2 = _slot_2_latch
|
||||
_slot_2_latch = false
|
||||
|
||||
slot_3 = _slot_3_latch
|
||||
_slot_3_latch = false
|
||||
|
||||
slot_4 = _slot_4_latch
|
||||
_slot_4_latch = false
|
||||
|
||||
use_held = Input.is_action_pressed("use_action")
|
||||
|
||||
# Mouse look
|
||||
if override_mouse:
|
||||
look_angle = Vector2.ZERO
|
||||
mouse_rotation = Vector2.ZERO
|
||||
else:
|
||||
look_angle = Vector2(-mouse_rotation.y, -mouse_rotation.x)
|
||||
mouse_rotation = Vector2.ZERO
|
||||
|
||||
func setup():
|
||||
is_setup = true
|
||||
camera.current = true
|
||||
big_gun.hide()
|
||||
hud.show()
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnyn3dyybrwkj
|
||||
@@ -0,0 +1,159 @@
|
||||
extends Node
|
||||
|
||||
@export var player_scene: PackedScene
|
||||
|
||||
var t_spawn_points: Array[Marker3D] = []
|
||||
var ct_spawn_points: Array[Marker3D] = []
|
||||
var team_manager: TeamManager
|
||||
var round_manager: RoundManager
|
||||
var economy_manager: EconomyManager
|
||||
var bomb: Bomb
|
||||
var avatars: Dictionary = {}
|
||||
|
||||
func _ready():
|
||||
team_manager = $"../TeamManager"
|
||||
round_manager = $"../RoundManager"
|
||||
economy_manager = $"../EconomyManager"
|
||||
bomb = get_tree().current_scene.get_node_or_null("Network/Bomb")
|
||||
|
||||
# Discover spawn points from scene containers
|
||||
var scene_root := get_tree().current_scene
|
||||
var t_container := scene_root.get_node_or_null("T Spawns")
|
||||
if t_container:
|
||||
for child in t_container.get_children():
|
||||
if child is Marker3D:
|
||||
t_spawn_points.append(child)
|
||||
var ct_container := scene_root.get_node_or_null("CT Spawns")
|
||||
if ct_container:
|
||||
for child in ct_container.get_children():
|
||||
if child is Marker3D:
|
||||
ct_spawn_points.append(child)
|
||||
|
||||
NetworkEvents.on_client_start.connect(_handle_connected)
|
||||
NetworkEvents.on_server_start.connect(_handle_host)
|
||||
NetworkEvents.on_peer_join.connect(_handle_new_peer)
|
||||
NetworkEvents.on_peer_leave.connect(_handle_leave)
|
||||
NetworkEvents.on_client_stop.connect(_handle_stop)
|
||||
NetworkEvents.on_server_stop.connect(_handle_stop)
|
||||
|
||||
if round_manager:
|
||||
round_manager.round_started.connect(_handle_round_start)
|
||||
|
||||
func _handle_connected(id: int):
|
||||
_spawn(id)
|
||||
|
||||
func _handle_host():
|
||||
_spawn(1)
|
||||
# Auto-start match
|
||||
if round_manager:
|
||||
round_manager.start_match()
|
||||
|
||||
func _handle_new_peer(id: int):
|
||||
_spawn(id)
|
||||
if multiplayer.is_server():
|
||||
team_manager.sync_to_peer(id)
|
||||
if economy_manager:
|
||||
economy_manager.sync_to_peer(id)
|
||||
if bomb:
|
||||
bomb.sync_to_peer(id)
|
||||
if avatars.size() <= 2:
|
||||
# First opponent joined — restart round so both spawn alive
|
||||
round_manager.start_round()
|
||||
else:
|
||||
# 3+ players: sync state, spectate until next round
|
||||
round_manager.sync_to_peer(id)
|
||||
var avatar = avatars.get(id)
|
||||
if avatar and avatar.has_method("set_spectator"):
|
||||
avatar.call_deferred("set_spectator")
|
||||
|
||||
func _handle_leave(id: int):
|
||||
if not avatars.has(id):
|
||||
return
|
||||
|
||||
var avatar = avatars[id] as Node
|
||||
avatar.queue_free()
|
||||
avatars.erase(id)
|
||||
if economy_manager:
|
||||
economy_manager.remove_player(id)
|
||||
|
||||
func _handle_stop():
|
||||
for avatar in avatars.values():
|
||||
avatar.queue_free()
|
||||
avatars.clear()
|
||||
|
||||
func _handle_round_start(_round_number: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
# Respawn all players at their team spawn points
|
||||
for peer_id in avatars:
|
||||
var avatar = avatars[peer_id] as CharacterBody3D
|
||||
if avatar == null:
|
||||
continue
|
||||
# Fresh spawns (_has_played_round=false) always get default loadout
|
||||
var survived: bool = not avatar.is_dead and avatar._has_played_round
|
||||
var spawn_pos := get_team_spawn_point(peer_id)
|
||||
avatar.round_respawn(spawn_pos, survived)
|
||||
|
||||
# Assign bomb to a random T player
|
||||
if bomb:
|
||||
var t_members := team_manager.get_team_members(TeamManager.Team.T)
|
||||
if not t_members.is_empty():
|
||||
var idx := hash(_round_number * 31) % t_members.size()
|
||||
bomb.assign_to_player(t_members[idx])
|
||||
|
||||
func _spawn(id: int):
|
||||
# Auto-assign team on server
|
||||
if multiplayer.is_server():
|
||||
team_manager.auto_assign(id)
|
||||
|
||||
var avatar = player_scene.instantiate() as Node
|
||||
avatars[id] = avatar
|
||||
avatar.name += " #%d" % id
|
||||
add_child(avatar)
|
||||
avatar.global_position = get_team_spawn_point(id)
|
||||
|
||||
# Avatar is always owned by server
|
||||
avatar.set_multiplayer_authority(1)
|
||||
|
||||
# Pass references
|
||||
if avatar.has_method("setup_references"):
|
||||
avatar.setup_references(team_manager, round_manager)
|
||||
|
||||
# Init economy
|
||||
if multiplayer.is_server() and economy_manager:
|
||||
economy_manager.init_player(id)
|
||||
|
||||
print("Spawned avatar %s at %s" % [avatar.name, multiplayer.get_unique_id()])
|
||||
|
||||
# Avatar's input object is owned by player
|
||||
var input = avatar.find_child("Input")
|
||||
if input != null:
|
||||
input.set_multiplayer_authority(id)
|
||||
print("Set input(%s) ownership to %s" % [input.name, id])
|
||||
|
||||
# Notify RollbackSynchronizer about ownership changes
|
||||
var rollback_sync = avatar.find_child("RollbackSynchronizer")
|
||||
if rollback_sync and rollback_sync.has_method("process_settings"):
|
||||
rollback_sync.process_settings()
|
||||
|
||||
func get_team_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3:
|
||||
var team := team_manager.get_team(peer_id)
|
||||
var points: Array[Marker3D]
|
||||
|
||||
match team:
|
||||
TeamManager.Team.T:
|
||||
points = t_spawn_points
|
||||
TeamManager.Team.CT:
|
||||
points = ct_spawn_points
|
||||
_:
|
||||
# Fallback: use T spawns
|
||||
points = t_spawn_points
|
||||
|
||||
if points.is_empty():
|
||||
return Vector3.ZERO
|
||||
|
||||
var idx := hash(peer_id * 37 + spawn_idx * 19) % points.size()
|
||||
return points[idx].global_position
|
||||
|
||||
func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3:
|
||||
return get_team_spawn_point(peer_id, spawn_idx)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7dcdxh4qdxwv
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0jw6glwukfdf
|
||||
@@ -0,0 +1,381 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
@export var jump_strength = 5.0
|
||||
|
||||
@onready var display_name := $DisplayNameLabel3D as Label3D
|
||||
@onready var input := $Input as PlayerInputFPS
|
||||
@onready var tick_interpolator := $TickInterpolator as TickInterpolator
|
||||
@onready var head := $Head as Node3D
|
||||
@onready var hud := $HUD as CanvasGroup
|
||||
@onready var camera: Camera3D = $Head/Camera3D
|
||||
@onready var weapon_manager: WeaponManager = $Head/WeaponManager
|
||||
|
||||
static var _logger := NetfoxLogger.new("game", "Player")
|
||||
|
||||
var gravity = ProjectSettings.get_setting(&"physics/3d/default_gravity")
|
||||
var health: int = 100
|
||||
var death_tick: int = -1
|
||||
var respawn_position: Vector3
|
||||
var did_respawn := false
|
||||
var respawn_tick: int = -1
|
||||
var deaths := 0
|
||||
var is_dead := false
|
||||
|
||||
# Kill attribution (rollback state)
|
||||
var last_damage_attacker_id: int = -1
|
||||
var last_damage_weapon_id: int = 0
|
||||
var last_damage_tick: int = -1
|
||||
|
||||
# Armor and equipment
|
||||
var armor: int = 0
|
||||
var has_defuse_kit: bool = false
|
||||
|
||||
# Track deaths across tick loops to detect state changes
|
||||
var _ackd_deaths := 0
|
||||
var _was_hit := false
|
||||
var _needs_teleport := false
|
||||
var _has_played_round := false
|
||||
|
||||
# Frame-rate local camera
|
||||
var _is_local := false
|
||||
var _sim_transform: Transform3D
|
||||
var _sim_head_transform: Transform3D
|
||||
var _sim_yaw: float
|
||||
var _sim_pitch: float
|
||||
var _lerp_from_pos: Vector3
|
||||
var _lerp_to_pos: Vector3
|
||||
|
||||
var team_manager: TeamManager
|
||||
var round_manager: RoundManager
|
||||
|
||||
func _ready():
|
||||
display_name.text = name
|
||||
hud.hide()
|
||||
|
||||
NetworkTime.before_tick_loop.connect(_before_tick_loop)
|
||||
NetworkTime.after_tick_loop.connect(_after_tick_loop)
|
||||
NetworkTime.on_tick.connect(_bomb_tick)
|
||||
|
||||
func setup_references(tm: TeamManager, rm: RoundManager):
|
||||
team_manager = tm
|
||||
round_manager = rm
|
||||
|
||||
func _setup_local_camera():
|
||||
_is_local = true
|
||||
tick_interpolator._disconnect_signals()
|
||||
tick_interpolator.enabled = false
|
||||
_sim_transform = global_transform
|
||||
_sim_head_transform = head.transform
|
||||
_sim_yaw = rotation.y
|
||||
_sim_pitch = head.rotation.x
|
||||
_lerp_from_pos = global_position
|
||||
_lerp_to_pos = global_position
|
||||
|
||||
func _before_tick_loop():
|
||||
_ackd_deaths = deaths
|
||||
_needs_teleport = false
|
||||
if _is_local:
|
||||
global_transform = _sim_transform
|
||||
head.transform = _sim_head_transform
|
||||
|
||||
func _after_tick_loop():
|
||||
if _is_local:
|
||||
_sim_transform = global_transform
|
||||
_sim_head_transform = head.transform
|
||||
_sim_yaw = rotation.y
|
||||
_sim_pitch = head.rotation.x
|
||||
_lerp_from_pos = _lerp_to_pos
|
||||
_lerp_to_pos = global_position
|
||||
|
||||
if _needs_teleport:
|
||||
if _is_local:
|
||||
_lerp_from_pos = global_position
|
||||
_lerp_to_pos = global_position
|
||||
else:
|
||||
tick_interpolator.teleport()
|
||||
|
||||
# Death detected during rollback loop
|
||||
if deaths > _ackd_deaths:
|
||||
if _is_local:
|
||||
_lerp_from_pos = global_position
|
||||
_lerp_to_pos = global_position
|
||||
else:
|
||||
tick_interpolator.teleport()
|
||||
is_dead = true
|
||||
$DieSFX.play()
|
||||
_hide_player()
|
||||
# Server: report kill with attribution, drop bomb
|
||||
if is_multiplayer_authority():
|
||||
_logger.warning("%s died at tick %s", [name, death_tick])
|
||||
# Drop bomb if carrier
|
||||
var bomb_node := get_tree().current_scene.get_node_or_null("Network/Bomb") as Bomb
|
||||
if bomb_node and bomb_node.carrier_id == get_player_id():
|
||||
bomb_node.drop_at(global_position)
|
||||
if round_manager:
|
||||
round_manager.report_kill(get_player_id(), last_damage_attacker_id, last_damage_weapon_id, death_tick)
|
||||
_sync_death.rpc(death_tick)
|
||||
_ackd_deaths = deaths
|
||||
|
||||
if _was_hit:
|
||||
$HitSFX.play()
|
||||
_was_hit = false
|
||||
|
||||
func _process(_delta: float):
|
||||
if not _is_local:
|
||||
if input.is_multiplayer_authority():
|
||||
_setup_local_camera()
|
||||
else:
|
||||
return
|
||||
|
||||
# Smooth position interpolation between ticks
|
||||
var f := NetworkTime.tick_factor
|
||||
global_position = _lerp_from_pos.lerp(_lerp_to_pos, f)
|
||||
|
||||
# Frame-rate mouse look (recoil only affects raycast, not camera)
|
||||
rotation.y = _sim_yaw + (-input.mouse_rotation.y)
|
||||
head.rotation.x = clamp(_sim_pitch + (-input.mouse_rotation.x), -1.57, 1.57)
|
||||
head.rotation.y = 0
|
||||
head.rotation.z = 0
|
||||
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
# Handle round respawn teleport inside rollback
|
||||
if respawn_tick >= 0 and tick == respawn_tick:
|
||||
global_position = respawn_position
|
||||
velocity = Vector3.ZERO
|
||||
did_respawn = true
|
||||
_needs_teleport = true
|
||||
else:
|
||||
did_respawn = false
|
||||
|
||||
# Dead check
|
||||
if death_tick >= 0 and tick >= death_tick:
|
||||
return
|
||||
|
||||
# Death detection inside rollback
|
||||
if health <= 0:
|
||||
death_tick = tick
|
||||
deaths += 1
|
||||
return
|
||||
|
||||
# Freeze detection
|
||||
var is_frozen := round_manager != null and round_manager.freeze_end_tick >= 0 and tick < round_manager.freeze_end_tick
|
||||
|
||||
# Handle look (always allowed)
|
||||
rotate_object_local(Vector3(0, 1, 0), input.look_angle.x)
|
||||
head.rotate_object_local(Vector3(1, 0, 0), input.look_angle.y)
|
||||
head.rotation.x = clamp(head.rotation.x, -1.57, 1.57)
|
||||
head.rotation.z = 0
|
||||
head.rotation.y = 0
|
||||
|
||||
# Gravity + Jump
|
||||
_force_update_is_on_floor()
|
||||
if is_on_floor():
|
||||
if input.jump:
|
||||
velocity.y = jump_strength
|
||||
else:
|
||||
velocity.y -= gravity * delta
|
||||
|
||||
# Movement speed from active weapon
|
||||
var speed := weapon_manager.get_move_speed() if weapon_manager else 5.0
|
||||
|
||||
if is_frozen:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
velocity *= NetworkTime.physics_factor
|
||||
move_and_slide()
|
||||
velocity /= NetworkTime.physics_factor
|
||||
return
|
||||
|
||||
# Apply movement
|
||||
var input_dir = input.movement
|
||||
var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.z)).normalized()
|
||||
if direction:
|
||||
velocity.x = direction.x * speed
|
||||
velocity.z = direction.z * speed
|
||||
else:
|
||||
velocity.x = move_toward(velocity.x, 0, speed)
|
||||
velocity.z = move_toward(velocity.z, 0, speed)
|
||||
|
||||
velocity *= NetworkTime.physics_factor
|
||||
move_and_slide()
|
||||
velocity /= NetworkTime.physics_factor
|
||||
|
||||
func _force_update_is_on_floor():
|
||||
var old_velocity = velocity
|
||||
velocity = Vector3.ZERO
|
||||
move_and_slide()
|
||||
velocity = old_velocity
|
||||
|
||||
func damage(amount: int, weapon_id: int = 0, attacker_id: int = -1, is_new_hit: bool = true):
|
||||
# Friendly fire check
|
||||
if team_manager and attacker_id >= 0 and attacker_id != get_player_id():
|
||||
if team_manager.get_team(attacker_id) == team_manager.get_team(get_player_id()):
|
||||
return
|
||||
|
||||
# Armor absorption (Phase 3 — armor starts at 0)
|
||||
var armor_pen := 1.0
|
||||
var weapon := WeaponRegistry.get_weapon(weapon_id)
|
||||
if weapon:
|
||||
armor_pen = weapon.armor_penetration
|
||||
|
||||
var effective := amount
|
||||
if armor > 0 and armor_pen < 1.0:
|
||||
var max_absorb := int(amount * (1.0 - armor_pen))
|
||||
var actual_absorb := mini(max_absorb, armor)
|
||||
effective = amount - actual_absorb
|
||||
armor -= actual_absorb
|
||||
|
||||
health -= effective
|
||||
|
||||
# Kill attribution
|
||||
if attacker_id >= 0:
|
||||
last_damage_attacker_id = attacker_id
|
||||
last_damage_weapon_id = weapon_id
|
||||
last_damage_tick = NetworkRollback.tick
|
||||
|
||||
if is_new_hit:
|
||||
_was_hit = true
|
||||
_logger.warning("%s HP now at %s", [name, health])
|
||||
|
||||
func can_act(tick: int = -1) -> bool:
|
||||
if death_tick >= 0:
|
||||
return false
|
||||
var t := tick if tick >= 0 else NetworkRollback.tick
|
||||
if round_manager:
|
||||
if round_manager.freeze_end_tick >= 0 and t < round_manager.freeze_end_tick:
|
||||
return false
|
||||
if round_manager.round_end_tick >= 0 and t >= round_manager.round_end_tick:
|
||||
return false
|
||||
return true
|
||||
|
||||
func set_spectator():
|
||||
is_dead = true
|
||||
death_tick = NetworkTime.tick
|
||||
_hide_player()
|
||||
_sync_spectator.rpc(death_tick)
|
||||
|
||||
func round_respawn(spawn_pos: Vector3, survived: bool = false):
|
||||
is_dead = false
|
||||
health = 100
|
||||
deaths = 0
|
||||
death_tick = -1
|
||||
last_damage_attacker_id = -1
|
||||
last_damage_weapon_id = 0
|
||||
last_damage_tick = -1
|
||||
respawn_position = spawn_pos
|
||||
respawn_tick = NetworkTime.tick
|
||||
global_position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
did_respawn = true
|
||||
_has_played_round = true
|
||||
_show_player()
|
||||
|
||||
if not survived:
|
||||
armor = 0
|
||||
has_defuse_kit = false
|
||||
var team := team_manager.get_team(get_player_id()) if team_manager else TeamManager.Team.T
|
||||
weapon_manager.set_default_loadout(team)
|
||||
else:
|
||||
# Keep weapons + armor, just cancel reload
|
||||
weapon_manager.reload_start_tick = -1
|
||||
weapon_manager.recoil_accumulator = 0.0
|
||||
|
||||
_sync_round_respawn.rpc(spawn_pos, respawn_tick)
|
||||
|
||||
func _hide_player():
|
||||
$MeshInstance3D.hide()
|
||||
$Head/Nose.hide()
|
||||
$Head/BigGun.hide()
|
||||
weapon_manager.hide_weapons()
|
||||
$CollisionShape3D.disabled = true
|
||||
$DisplayNameLabel3D.hide()
|
||||
$Projection3D.hide()
|
||||
|
||||
func _show_player():
|
||||
$MeshInstance3D.show()
|
||||
$Head/Nose.show()
|
||||
$Head/BigGun.show()
|
||||
weapon_manager.show_weapons()
|
||||
$CollisionShape3D.disabled = false
|
||||
$DisplayNameLabel3D.show()
|
||||
$Projection3D.show()
|
||||
|
||||
func _bomb_tick(_delta: float, _tick: int):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if not is_multiplayer_authority():
|
||||
return
|
||||
if is_dead:
|
||||
return
|
||||
|
||||
var bomb_node := get_tree().current_scene.get_node_or_null("Network/Bomb") as Bomb
|
||||
if bomb_node == null:
|
||||
return
|
||||
|
||||
var pid := get_player_id()
|
||||
var team := team_manager.get_team(pid) if team_manager else TeamManager.Team.NONE
|
||||
|
||||
# T player: start plant or pickup (cancel/validation handled by bomb._server_tick)
|
||||
if team == TeamManager.Team.T:
|
||||
if bomb_node.state == Bomb.BombState.CARRIED and bomb_node.carrier_id == pid:
|
||||
if input.use_held:
|
||||
var bombsites := get_tree().get_nodes_in_group("bombsite")
|
||||
for site in bombsites:
|
||||
if site is Bombsite and site.is_player_inside(self):
|
||||
bomb_node.try_start_plant(pid, site.site_name)
|
||||
break
|
||||
elif bomb_node.state == Bomb.BombState.DROPPED:
|
||||
if global_position.distance_to(bomb_node.position) < 2.0:
|
||||
bomb_node.try_pickup(pid)
|
||||
|
||||
# CT player: start defuse (cancel/validation handled by bomb._server_tick)
|
||||
elif team == TeamManager.Team.CT:
|
||||
if bomb_node.state == Bomb.BombState.PLANTED:
|
||||
if input.use_held and global_position.distance_to(bomb_node.position) < 2.0:
|
||||
bomb_node.try_start_defuse(pid)
|
||||
|
||||
func get_player_id() -> int:
|
||||
return input.get_multiplayer_authority()
|
||||
|
||||
# --- RPCs ---
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_death(server_death_tick: int) -> void:
|
||||
is_dead = true
|
||||
death_tick = server_death_tick
|
||||
_hide_player()
|
||||
if _is_local:
|
||||
_lerp_from_pos = global_position
|
||||
_lerp_to_pos = global_position
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_spectator(server_death_tick: int) -> void:
|
||||
is_dead = true
|
||||
death_tick = server_death_tick
|
||||
_hide_player()
|
||||
if _is_local:
|
||||
_lerp_from_pos = global_position
|
||||
_lerp_to_pos = global_position
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_round_respawn(spawn_pos: Vector3, _respawn_tick: int) -> void:
|
||||
is_dead = false
|
||||
health = 100
|
||||
deaths = 0
|
||||
death_tick = -1
|
||||
last_damage_attacker_id = -1
|
||||
last_damage_weapon_id = 0
|
||||
last_damage_tick = -1
|
||||
respawn_position = spawn_pos
|
||||
respawn_tick = _respawn_tick
|
||||
global_position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
did_respawn = true
|
||||
_ackd_deaths = 0
|
||||
_show_player()
|
||||
if _is_local:
|
||||
_sim_transform = global_transform
|
||||
_sim_head_transform = head.transform
|
||||
_sim_yaw = rotation.y
|
||||
_sim_pitch = head.rotation.x
|
||||
_lerp_from_pos = spawn_pos
|
||||
_lerp_to_pos = spawn_pos
|
||||
@@ -0,0 +1 @@
|
||||
uid://q46d8xjl1rpk
|
||||
@@ -0,0 +1,201 @@
|
||||
extends Node
|
||||
class_name RoundManager
|
||||
|
||||
enum RoundState { WARMUP, FREEZE_TIME, ACTIVE, ROUND_END }
|
||||
|
||||
signal round_state_changed(new_state: RoundState)
|
||||
signal round_started(round_number: int)
|
||||
signal round_ended(winner: TeamManager.Team)
|
||||
signal player_killed(victim_id: int, killer_id: int, weapon_id: int, kill_tick: int)
|
||||
|
||||
@export var freeze_time: float = 5.0
|
||||
@export var round_time: float = 115.0
|
||||
@export var round_end_time: float = 5.0
|
||||
@export var max_rounds: int = 24
|
||||
@export var rounds_to_win: int = 13
|
||||
|
||||
var state: RoundState = RoundState.WARMUP
|
||||
var timer: float = 0.0
|
||||
var round_number: int = 0
|
||||
var score: Dictionary = { TeamManager.Team.T: 0, TeamManager.Team.CT: 0 }
|
||||
var freeze_end_tick: int = -1
|
||||
var active_end_tick: int = -1
|
||||
var round_end_tick: int = -1
|
||||
|
||||
# Tracking alive players per round (synced to clients via RPC)
|
||||
var _alive_players: Dictionary = {} # peer_id -> bool
|
||||
var _last_winner: TeamManager.Team = TeamManager.Team.NONE
|
||||
|
||||
var team_manager: TeamManager
|
||||
var bomb: Bomb
|
||||
|
||||
func _ready():
|
||||
team_manager = $"../TeamManager"
|
||||
bomb = get_tree().current_scene.get_node_or_null("Network/Bomb")
|
||||
set_process(false)
|
||||
NetworkEvents.on_peer_leave.connect(_handle_peer_leave)
|
||||
|
||||
func start_match():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
round_number = 0
|
||||
score = { TeamManager.Team.T: 0, TeamManager.Team.CT: 0 }
|
||||
_sync_score.rpc(score[TeamManager.Team.T], score[TeamManager.Team.CT])
|
||||
start_round()
|
||||
|
||||
func start_round():
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
round_number += 1
|
||||
freeze_end_tick = NetworkTime.tick + int(freeze_time * float(NetworkTime.tickrate))
|
||||
active_end_tick = freeze_end_tick + int(round_time * float(NetworkTime.tickrate))
|
||||
round_end_tick = -1
|
||||
_mark_all_alive()
|
||||
_change_state(RoundState.FREEZE_TIME, freeze_time)
|
||||
_sync_round_start.rpc(round_number, freeze_end_tick, active_end_tick)
|
||||
|
||||
func _process(delta: float):
|
||||
# Local timer countdown for HUD display only
|
||||
if timer > 0:
|
||||
timer -= delta
|
||||
|
||||
# Only server handles state transitions
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
var current_tick := NetworkTime.tick
|
||||
|
||||
# Tick-based transitions for gameplay-critical boundaries
|
||||
if state == RoundState.FREEZE_TIME and current_tick >= freeze_end_tick:
|
||||
_change_state(RoundState.ACTIVE, round_time)
|
||||
elif state == RoundState.ACTIVE and current_tick >= active_end_tick:
|
||||
# Time ran out — CTs win (only if bomb not planted)
|
||||
if bomb and (bomb.state == Bomb.BombState.PLANTED or bomb.state == Bomb.BombState.DEFUSING):
|
||||
pass # Round continues — bomb is ticking
|
||||
else:
|
||||
end_round(TeamManager.Team.CT)
|
||||
elif state == RoundState.ROUND_END and timer <= 0:
|
||||
if _check_match_over():
|
||||
return
|
||||
start_round()
|
||||
|
||||
func report_kill(victim_id: int, killer_id: int, weapon_id: int, kill_tick: int = -1):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var check_tick := kill_tick if kill_tick >= 0 else NetworkTime.tick
|
||||
if round_end_tick >= 0 and check_tick > round_end_tick:
|
||||
return
|
||||
if check_tick < freeze_end_tick:
|
||||
return
|
||||
if check_tick >= active_end_tick:
|
||||
return
|
||||
|
||||
_alive_players[victim_id] = false
|
||||
_sync_player_alive.rpc(victim_id, false)
|
||||
|
||||
# Emit kill event for economy, stats, kill-feed
|
||||
player_killed.emit(victim_id, killer_id, weapon_id, check_tick)
|
||||
|
||||
# Check if a whole team is eliminated
|
||||
var t_alive := _count_alive(TeamManager.Team.T)
|
||||
var ct_alive := _count_alive(TeamManager.Team.CT)
|
||||
|
||||
var bomb_planted := bomb and (bomb.state == Bomb.BombState.PLANTED or bomb.state == Bomb.BombState.DEFUSING)
|
||||
|
||||
if ct_alive == 0:
|
||||
end_round(TeamManager.Team.T)
|
||||
elif t_alive == 0 and not bomb_planted:
|
||||
# All Ts dead + bomb NOT planted → CT win
|
||||
end_round(TeamManager.Team.CT)
|
||||
elif t_alive == 0 and bomb_planted:
|
||||
pass # Round continues — bomb is planted
|
||||
|
||||
func end_round(winner: TeamManager.Team):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if round_end_tick >= 0:
|
||||
return
|
||||
|
||||
round_end_tick = NetworkTime.tick
|
||||
_last_winner = winner
|
||||
score[winner] += 1
|
||||
_sync_score.rpc(score[TeamManager.Team.T], score[TeamManager.Team.CT])
|
||||
_sync_round_end.rpc(winner, round_end_tick)
|
||||
_change_state(RoundState.ROUND_END, round_end_time)
|
||||
round_ended.emit(winner)
|
||||
|
||||
func is_player_alive(peer_id: int) -> bool:
|
||||
return _alive_players.get(peer_id, false)
|
||||
|
||||
func sync_to_peer(peer_id: int):
|
||||
_sync_state.rpc_id(peer_id, state, timer)
|
||||
_sync_score.rpc_id(peer_id, score[TeamManager.Team.T], score[TeamManager.Team.CT])
|
||||
_sync_round_start.rpc_id(peer_id, round_number, freeze_end_tick, active_end_tick)
|
||||
for pid in _alive_players:
|
||||
_sync_player_alive.rpc_id(peer_id, pid, _alive_players[pid])
|
||||
if state == RoundState.ROUND_END and _last_winner != TeamManager.Team.NONE:
|
||||
_sync_round_end.rpc_id(peer_id, _last_winner, round_end_tick)
|
||||
|
||||
func _count_alive(team: TeamManager.Team) -> int:
|
||||
var count := 0
|
||||
for peer_id in team_manager.get_team_members(team):
|
||||
if _alive_players.get(peer_id, false):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
func _mark_all_alive():
|
||||
_alive_players.clear()
|
||||
for peer_id in team_manager.assignments:
|
||||
if team_manager.get_team(peer_id) != TeamManager.Team.NONE:
|
||||
_alive_players[peer_id] = true
|
||||
for pid in _alive_players:
|
||||
_sync_player_alive.rpc(pid, _alive_players[pid])
|
||||
|
||||
func _change_state(new_state: RoundState, duration: float):
|
||||
state = new_state
|
||||
timer = duration
|
||||
set_process(true)
|
||||
_sync_state.rpc(new_state, duration)
|
||||
round_state_changed.emit(new_state)
|
||||
|
||||
func _handle_peer_leave(peer_id: int) -> void:
|
||||
_alive_players.erase(peer_id)
|
||||
|
||||
func _check_match_over() -> bool:
|
||||
if score[TeamManager.Team.T] >= rounds_to_win or score[TeamManager.Team.CT] >= rounds_to_win:
|
||||
return true
|
||||
if round_number >= max_rounds:
|
||||
return true
|
||||
return false
|
||||
|
||||
# --- RPCs: Server -> All Clients ---
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_state(new_state: int, duration: float) -> void:
|
||||
state = new_state as RoundState
|
||||
timer = duration
|
||||
set_process(true)
|
||||
round_state_changed.emit(state)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_score(t_score: int, ct_score: int) -> void:
|
||||
score[TeamManager.Team.T] = t_score
|
||||
score[TeamManager.Team.CT] = ct_score
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_round_start(round_num: int, _freeze_end: int, _active_end: int) -> void:
|
||||
round_number = round_num
|
||||
freeze_end_tick = _freeze_end
|
||||
active_end_tick = _active_end
|
||||
round_end_tick = -1
|
||||
round_started.emit(round_number)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_round_end(winner: int, _round_end_tick: int) -> void:
|
||||
round_end_tick = _round_end_tick
|
||||
round_ended.emit(winner as TeamManager.Team)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_player_alive(peer_id: int, alive: bool) -> void:
|
||||
_alive_players[peer_id] = alive
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8adu0soolfyq
|
||||
@@ -0,0 +1,56 @@
|
||||
extends Node
|
||||
class_name TeamManager
|
||||
|
||||
enum Team { NONE = -1, T = 0, CT = 1 }
|
||||
|
||||
signal team_changed(peer_id: int, team: Team)
|
||||
|
||||
# peer_id -> Team
|
||||
var assignments: Dictionary = {}
|
||||
|
||||
func _ready():
|
||||
NetworkEvents.on_peer_leave.connect(_handle_peer_leave)
|
||||
|
||||
func get_team(peer_id: int) -> Team:
|
||||
return assignments.get(peer_id, Team.NONE) as Team
|
||||
|
||||
func get_team_members(team: Team) -> Array[int]:
|
||||
var members: Array[int] = []
|
||||
for peer_id in assignments:
|
||||
if assignments[peer_id] == team:
|
||||
members.append(peer_id)
|
||||
return members
|
||||
|
||||
func get_team_name(team: Team) -> String:
|
||||
match team:
|
||||
Team.T: return "Terrorists"
|
||||
Team.CT: return "Counter-Terrorists"
|
||||
_: return "Unassigned"
|
||||
|
||||
func auto_assign(peer_id: int) -> Team:
|
||||
var t_count := get_team_members(Team.T).size()
|
||||
var ct_count := get_team_members(Team.CT).size()
|
||||
var team := Team.T if t_count <= ct_count else Team.CT
|
||||
assign_team(peer_id, team)
|
||||
return team
|
||||
|
||||
func assign_team(peer_id: int, team: Team) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
assignments[peer_id] = team
|
||||
_sync_assignment.rpc(peer_id, team)
|
||||
team_changed.emit(peer_id, team)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _sync_assignment(peer_id: int, team: int) -> void:
|
||||
assignments[peer_id] = team as Team
|
||||
team_changed.emit(peer_id, team as Team)
|
||||
|
||||
func sync_to_peer(peer_id: int) -> void:
|
||||
## Send all current team assignments to a late-joining peer.
|
||||
for pid in assignments:
|
||||
_sync_assignment.rpc_id(peer_id, pid, assignments[pid])
|
||||
|
||||
func _handle_peer_leave(peer_id: int) -> void:
|
||||
assignments.erase(peer_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ciyrjr3yhnqxf
|
||||
@@ -0,0 +1,113 @@
|
||||
extends Control
|
||||
class_name BombHUD
|
||||
|
||||
var bomb: Bomb
|
||||
var _progress_bar: ProgressBar
|
||||
var _timer_label: Label
|
||||
var _status_label: Label
|
||||
|
||||
const COLOR_T := Color("#FFCC00")
|
||||
const COLOR_CT := Color("#99CCFF")
|
||||
const COLOR_DANGER := Color("#FF4444")
|
||||
|
||||
func _ready():
|
||||
var scene_root := get_tree().current_scene
|
||||
bomb = scene_root.get_node_or_null("Network/Bomb")
|
||||
mouse_filter = MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
|
||||
func _build_ui():
|
||||
# Progress bar for plant/defuse
|
||||
_progress_bar = ProgressBar.new()
|
||||
_progress_bar.name = "BombProgress"
|
||||
_progress_bar.min_value = 0.0
|
||||
_progress_bar.max_value = 1.0
|
||||
_progress_bar.step = 0.01
|
||||
_progress_bar.show_percentage = false
|
||||
_progress_bar.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
var bg_style := StyleBoxFlat.new()
|
||||
bg_style.bg_color = Color(0.1, 0.1, 0.1, 0.7)
|
||||
_progress_bar.add_theme_stylebox_override("background", bg_style)
|
||||
|
||||
var fill_style := StyleBoxFlat.new()
|
||||
fill_style.bg_color = COLOR_T
|
||||
_progress_bar.add_theme_stylebox_override("fill", fill_style)
|
||||
|
||||
add_child(_progress_bar)
|
||||
_progress_bar.hide()
|
||||
|
||||
# Bomb timer label
|
||||
_timer_label = Label.new()
|
||||
_timer_label.name = "BombTimer"
|
||||
_timer_label.add_theme_font_size_override("font_size", 24)
|
||||
_timer_label.add_theme_color_override("font_color", COLOR_DANGER)
|
||||
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_timer_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
add_child(_timer_label)
|
||||
_timer_label.hide()
|
||||
|
||||
# Status label (BOMB PLANTED, DEFUSING, etc.)
|
||||
_status_label = Label.new()
|
||||
_status_label.name = "BombStatus"
|
||||
_status_label.add_theme_font_size_override("font_size", 16)
|
||||
_status_label.add_theme_color_override("font_color", COLOR_DANGER)
|
||||
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_status_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
add_child(_status_label)
|
||||
_status_label.hide()
|
||||
|
||||
func _process(_delta: float):
|
||||
if bomb == null:
|
||||
return
|
||||
|
||||
var vp := get_viewport_rect().size
|
||||
|
||||
match bomb.state:
|
||||
Bomb.BombState.PLANTING:
|
||||
_progress_bar.value = bomb.get_plant_progress()
|
||||
_progress_bar.size = Vector2(200, 12)
|
||||
_progress_bar.position = Vector2(vp.x / 2.0 - 100, vp.y * 0.65)
|
||||
_progress_bar.show()
|
||||
_status_label.text = "PLANTING..."
|
||||
_status_label.add_theme_color_override("font_color", COLOR_T)
|
||||
_status_label.size = Vector2(200, 24)
|
||||
_status_label.position = Vector2(vp.x / 2.0 - 100, vp.y * 0.65 - 28)
|
||||
_status_label.show()
|
||||
_timer_label.hide()
|
||||
|
||||
Bomb.BombState.PLANTED:
|
||||
_progress_bar.hide()
|
||||
var remaining := bomb.get_bomb_time_remaining()
|
||||
_timer_label.text = "%.1f" % remaining
|
||||
_timer_label.add_theme_color_override("font_color", COLOR_DANGER if remaining < 10.0 else COLOR_T)
|
||||
_timer_label.size = Vector2(100, 32)
|
||||
_timer_label.position = Vector2(vp.x / 2.0 - 50, vp.y * 0.35)
|
||||
_timer_label.show()
|
||||
_status_label.text = "BOMB PLANTED"
|
||||
_status_label.add_theme_color_override("font_color", COLOR_DANGER)
|
||||
_status_label.size = Vector2(200, 24)
|
||||
_status_label.position = Vector2(vp.x / 2.0 - 100, vp.y * 0.35 - 28)
|
||||
_status_label.show()
|
||||
|
||||
Bomb.BombState.DEFUSING:
|
||||
_progress_bar.value = bomb.get_defuse_progress()
|
||||
_progress_bar.size = Vector2(200, 12)
|
||||
_progress_bar.position = Vector2(vp.x / 2.0 - 100, vp.y * 0.65)
|
||||
_progress_bar.show()
|
||||
var remaining := bomb.get_bomb_time_remaining()
|
||||
_timer_label.text = "%.1f" % remaining
|
||||
_timer_label.add_theme_color_override("font_color", COLOR_DANGER if remaining < 10.0 else COLOR_T)
|
||||
_timer_label.size = Vector2(100, 32)
|
||||
_timer_label.position = Vector2(vp.x / 2.0 - 50, vp.y * 0.35)
|
||||
_timer_label.show()
|
||||
_status_label.text = "DEFUSING..."
|
||||
_status_label.add_theme_color_override("font_color", COLOR_CT)
|
||||
_status_label.size = Vector2(200, 24)
|
||||
_status_label.position = Vector2(vp.x / 2.0 - 100, vp.y * 0.65 - 28)
|
||||
_status_label.show()
|
||||
|
||||
_:
|
||||
_progress_bar.hide()
|
||||
_timer_label.hide()
|
||||
_status_label.hide()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ct2uyvqjqfe5n
|
||||
@@ -0,0 +1,229 @@
|
||||
extends Control
|
||||
class_name BuyMenu
|
||||
|
||||
var economy_manager: EconomyManager
|
||||
var team_manager: TeamManager
|
||||
var round_manager: RoundManager
|
||||
var _panel: PanelContainer
|
||||
var _vbox: VBoxContainer
|
||||
var _is_open := false
|
||||
|
||||
func _ready():
|
||||
var scene_root := get_tree().current_scene
|
||||
economy_manager = scene_root.get_node_or_null("Network/EconomyManager")
|
||||
team_manager = scene_root.get_node_or_null("Network/TeamManager")
|
||||
round_manager = scene_root.get_node_or_null("Network/RoundManager")
|
||||
|
||||
mouse_filter = MOUSE_FILTER_IGNORE
|
||||
_build_panel()
|
||||
hide()
|
||||
|
||||
func _build_panel():
|
||||
_panel = PanelContainer.new()
|
||||
_panel.name = "BuyPanel"
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.05, 0.08, 0.12, 0.92)
|
||||
style.border_color = Color(0.4, 0.4, 0.4, 0.6)
|
||||
style.set_border_width_all(1)
|
||||
style.set_content_margin_all(16)
|
||||
_panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
_vbox = VBoxContainer.new()
|
||||
_vbox.name = "BuyVBox"
|
||||
_panel.add_child(_vbox)
|
||||
add_child(_panel)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("buy_menu"):
|
||||
if _is_open:
|
||||
_close()
|
||||
elif _can_buy():
|
||||
_open()
|
||||
|
||||
if _is_open and event.is_action_pressed("escape"):
|
||||
_close()
|
||||
|
||||
# Number keys for quick buy
|
||||
if _is_open and event is InputEventKey and event.pressed:
|
||||
_handle_buy_key(event)
|
||||
|
||||
func _process(_delta: float):
|
||||
if not _is_open:
|
||||
return
|
||||
|
||||
# Close if no longer in freeze time
|
||||
if not _can_buy():
|
||||
_close()
|
||||
return
|
||||
|
||||
# Position panel
|
||||
var vp := get_viewport_rect().size
|
||||
_panel.size = Vector2(340, 400)
|
||||
_panel.position = Vector2(vp.x / 2.0 - 170, vp.y / 2.0 - 200)
|
||||
|
||||
func _can_buy() -> bool:
|
||||
if round_manager == null:
|
||||
return false
|
||||
# Tick-based check only — deterministic, no state-transition race
|
||||
if round_manager.freeze_end_tick < 0:
|
||||
return false
|
||||
if NetworkTime.tick >= round_manager.freeze_end_tick:
|
||||
return false
|
||||
# Must be alive (spectators/dead can't buy)
|
||||
var local_id := multiplayer.get_unique_id()
|
||||
if not round_manager.is_player_alive(local_id):
|
||||
return false
|
||||
return true
|
||||
|
||||
func _open():
|
||||
_is_open = true
|
||||
_rebuild_menu()
|
||||
show()
|
||||
|
||||
func _close():
|
||||
_is_open = false
|
||||
hide()
|
||||
|
||||
func _rebuild_menu():
|
||||
for child in _vbox.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var local_id := multiplayer.get_unique_id()
|
||||
var money := economy_manager.get_money(local_id) if economy_manager else 0
|
||||
var team := team_manager.get_team(local_id) if team_manager else TeamManager.Team.T
|
||||
|
||||
# Title
|
||||
var title := Label.new()
|
||||
title.text = "BUY MENU - $%d" % money
|
||||
title.add_theme_font_size_override("font_size", 18)
|
||||
title.add_theme_color_override("font_color", Color("#FFDD44"))
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_vbox.add_child(title)
|
||||
|
||||
var sep := HSeparator.new()
|
||||
_vbox.add_child(sep)
|
||||
|
||||
# Pistols
|
||||
_add_section("PISTOLS")
|
||||
if team == TeamManager.Team.T:
|
||||
_add_weapon_row("1", WeaponRegistry.GLOCK, money)
|
||||
else:
|
||||
_add_weapon_row("1", WeaponRegistry.USP, money)
|
||||
|
||||
# Rifles
|
||||
_add_section("RIFLES")
|
||||
if team == TeamManager.Team.T:
|
||||
_add_weapon_row("2", WeaponRegistry.AK47, money)
|
||||
else:
|
||||
_add_weapon_row("2", WeaponRegistry.M4A1, money)
|
||||
_add_weapon_row("3", WeaponRegistry.AWP, money)
|
||||
|
||||
# Grenades
|
||||
_add_section("GRENADES")
|
||||
_add_equipment_row("4", "Flashbang", EconomyManager.FLASH_PRICE, money)
|
||||
_add_equipment_row("5", "Smoke", EconomyManager.SMOKE_PRICE, money)
|
||||
|
||||
# Equipment
|
||||
_add_section("EQUIPMENT")
|
||||
_add_equipment_row("6", "Kevlar", EconomyManager.KEVLAR_PRICE, money)
|
||||
if team == TeamManager.Team.CT:
|
||||
_add_equipment_row("7", "Defuse Kit", EconomyManager.DEFUSE_KIT_PRICE, money)
|
||||
|
||||
func _add_section(text: String):
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size.y = 8
|
||||
_vbox.add_child(spacer)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = text
|
||||
lbl.add_theme_font_size_override("font_size", 12)
|
||||
lbl.add_theme_color_override("font_color", Color("#AAAAAA"))
|
||||
_vbox.add_child(lbl)
|
||||
|
||||
func _add_weapon_row(key: String, weapon_id: int, money: int):
|
||||
var weapon := WeaponRegistry.get_weapon(weapon_id)
|
||||
if weapon == null:
|
||||
return
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
var can_afford := money >= weapon.price
|
||||
|
||||
var key_lbl := Label.new()
|
||||
key_lbl.text = "[%s]" % key
|
||||
key_lbl.add_theme_font_size_override("font_size", 14)
|
||||
key_lbl.add_theme_color_override("font_color", Color.YELLOW if can_afford else Color.DARK_GRAY)
|
||||
key_lbl.custom_minimum_size.x = 40
|
||||
hbox.add_child(key_lbl)
|
||||
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = weapon.weapon_name
|
||||
name_lbl.add_theme_font_size_override("font_size", 14)
|
||||
name_lbl.add_theme_color_override("font_color", Color.WHITE if can_afford else Color.DARK_GRAY)
|
||||
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(name_lbl)
|
||||
|
||||
var price_lbl := Label.new()
|
||||
price_lbl.text = "$%d" % weapon.price
|
||||
price_lbl.add_theme_font_size_override("font_size", 14)
|
||||
price_lbl.add_theme_color_override("font_color", Color("#00FF00") if can_afford else Color.DARK_RED)
|
||||
price_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
hbox.add_child(price_lbl)
|
||||
|
||||
_vbox.add_child(hbox)
|
||||
|
||||
func _add_equipment_row(key: String, item_name: String, price: int, money: int):
|
||||
var hbox := HBoxContainer.new()
|
||||
var can_afford := money >= price
|
||||
|
||||
var key_lbl := Label.new()
|
||||
key_lbl.text = "[%s]" % key
|
||||
key_lbl.add_theme_font_size_override("font_size", 14)
|
||||
key_lbl.add_theme_color_override("font_color", Color.YELLOW if can_afford else Color.DARK_GRAY)
|
||||
key_lbl.custom_minimum_size.x = 40
|
||||
hbox.add_child(key_lbl)
|
||||
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = item_name
|
||||
name_lbl.add_theme_font_size_override("font_size", 14)
|
||||
name_lbl.add_theme_color_override("font_color", Color.WHITE if can_afford else Color.DARK_GRAY)
|
||||
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(name_lbl)
|
||||
|
||||
var price_lbl := Label.new()
|
||||
price_lbl.text = "$%d" % price
|
||||
price_lbl.add_theme_font_size_override("font_size", 14)
|
||||
price_lbl.add_theme_color_override("font_color", Color("#00FF00") if can_afford else Color.DARK_RED)
|
||||
price_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
hbox.add_child(price_lbl)
|
||||
|
||||
_vbox.add_child(hbox)
|
||||
|
||||
func _handle_buy_key(event: InputEventKey):
|
||||
if economy_manager == null:
|
||||
return
|
||||
|
||||
var local_id := multiplayer.get_unique_id()
|
||||
var team := team_manager.get_team(local_id) if team_manager else TeamManager.Team.T
|
||||
|
||||
match event.physical_keycode:
|
||||
KEY_1:
|
||||
var pistol_id := WeaponRegistry.GLOCK if team == TeamManager.Team.T else WeaponRegistry.USP
|
||||
economy_manager.request_buy_weapon(pistol_id)
|
||||
KEY_2:
|
||||
var rifle_id := WeaponRegistry.AK47 if team == TeamManager.Team.T else WeaponRegistry.M4A1
|
||||
economy_manager.request_buy_weapon(rifle_id)
|
||||
KEY_3:
|
||||
economy_manager.request_buy_weapon(WeaponRegistry.AWP)
|
||||
KEY_4:
|
||||
economy_manager.request_buy_grenade(0) # Flash
|
||||
KEY_5:
|
||||
economy_manager.request_buy_grenade(1) # Smoke
|
||||
KEY_6:
|
||||
economy_manager.request_buy_kevlar()
|
||||
KEY_7:
|
||||
if team == TeamManager.Team.CT:
|
||||
economy_manager.request_buy_defuse_kit()
|
||||
|
||||
# Rebuild to update money display
|
||||
call_deferred("_rebuild_menu")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bui6lde7owfhl
|
||||
@@ -0,0 +1,31 @@
|
||||
@tool
|
||||
class_name CrossHair extends Node2D
|
||||
|
||||
## Places a crosshair in the center of the screen
|
||||
|
||||
@export var crosshair_sprite: Texture2D
|
||||
@export var crosshair_scale: Vector2 = Vector2(1, 1)
|
||||
|
||||
var sprite2d: Sprite2D = Sprite2D.new()
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
get_tree().get_root().size_changed.connect(func(): _on_window_resized(DisplayServer.window_get_size()))
|
||||
_on_window_resized(DisplayServer.window_get_size())
|
||||
|
||||
func _enter_tree() -> void:
|
||||
if has_node("Sprite2D"): return
|
||||
|
||||
sprite2d.name = "Sprite2D"
|
||||
sprite2d.set_texture(crosshair_sprite)
|
||||
sprite2d.scale = crosshair_scale;
|
||||
add_child(sprite2d)
|
||||
|
||||
func _on_window_resized(new_size: Vector2) -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
sprite2d.position = new_size / 2
|
||||
sprite2d.scale = crosshair_scale
|
||||
@@ -0,0 +1 @@
|
||||
uid://68ntxlb14fan
|
||||
@@ -0,0 +1,6 @@
|
||||
extends ProgressBar
|
||||
|
||||
@export var player: CharacterBody3D
|
||||
|
||||
func _process(delta: float):
|
||||
value = player.health
|
||||
@@ -0,0 +1 @@
|
||||
uid://cf2qoxmc6v72y
|
||||
@@ -0,0 +1,71 @@
|
||||
@tool
|
||||
class_name Projection3D extends Node3D
|
||||
|
||||
## Whatever 2D nodes are added to Projection3D are projected onto a plane
|
||||
|
||||
## Width of the projection
|
||||
@export var projection_size: Vector2 = Vector2(1.0, 0.66)
|
||||
## Resolution of the projection
|
||||
@export var resolution_size: Vector2 = Vector2(640, 480)
|
||||
## Billboard mode (always face the player)
|
||||
@export var billboard: bool = true
|
||||
## Render in front of everything else
|
||||
@export var always_on_top: bool = false
|
||||
## No lighting or shading
|
||||
@export var disable_shading: bool = true
|
||||
|
||||
## Emitted when the Node is fully constructed
|
||||
signal on_constructed
|
||||
|
||||
var plane_mesh = PlaneMesh.new()
|
||||
var viewport = SubViewport.new()
|
||||
var original_children: Array[Node] = []
|
||||
|
||||
## Set the size of the projection and resolution
|
||||
func set_size(new_projection_size: Vector2, new_resolution_size: Vector2):
|
||||
plane_mesh.size = new_projection_size
|
||||
viewport.size = new_resolution_size
|
||||
|
||||
func _ready():
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
var children = get_children()
|
||||
original_children = children
|
||||
|
||||
var mesh = MeshInstance3D.new()
|
||||
plane_mesh.size = projection_size
|
||||
plane_mesh.orientation = PlaneMesh.FACE_Z
|
||||
mesh.gi_mode = GeometryInstance3D.GI_MODE_DISABLED
|
||||
mesh.cast_shadow = false
|
||||
mesh.mesh = plane_mesh
|
||||
add_child(mesh)
|
||||
|
||||
var viewport_container = SubViewportContainer.new()
|
||||
viewport_container.clip_children = CanvasItem.CLIP_CHILDREN_ONLY
|
||||
viewport_container.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
viewport.size = resolution_size
|
||||
# TODO: Figure out why the progressbar is slightly transparent.
|
||||
# It may not be a material issue but a rendering one (cleared one frame but drawn the next)
|
||||
viewport.transparent_bg = true
|
||||
viewport.disable_3d = true
|
||||
viewport.handle_input_locally = false
|
||||
viewport_container.add_child(viewport)
|
||||
add_child(viewport_container)
|
||||
|
||||
for child in children:
|
||||
child.reparent(viewport)
|
||||
|
||||
var material = StandardMaterial3D.new()
|
||||
material.albedo_texture = viewport.get_texture()
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_MAX
|
||||
if billboard:
|
||||
material.billboard_mode = BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
material.billboard_keep_scale = true
|
||||
if disable_shading:
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.disable_ambient_light = true
|
||||
material.no_depth_test = always_on_top
|
||||
plane_mesh.material = material
|
||||
|
||||
on_constructed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbrlsg5lrpgjb
|
||||
@@ -0,0 +1,312 @@
|
||||
extends Control
|
||||
class_name RoundHUD
|
||||
|
||||
var round_manager: RoundManager
|
||||
var team_manager: TeamManager
|
||||
|
||||
@onready var timer_label := $TimerLabel as Label
|
||||
@onready var score_label := $ScoreLabel as Label
|
||||
@onready var state_label := $StateLabel as Label
|
||||
@onready var scoreboard_panel := $ScoreboardPanel as PanelContainer
|
||||
|
||||
var scoreboard_vbox: VBoxContainer
|
||||
var _scoreboard_rebuild_timer := 0.0
|
||||
|
||||
# Ammo and money display
|
||||
var ammo_label: Label
|
||||
var money_label: Label
|
||||
|
||||
const COLOR_T := Color("#FFCC00")
|
||||
const COLOR_CT := Color("#99CCFF")
|
||||
const COLOR_DEAD := Color("#884444")
|
||||
const COLOR_GREEN := Color("#00FF00")
|
||||
const COLOR_HEADER := Color("#AAAAAA")
|
||||
const COLOR_AMMO := Color("#FFDD44")
|
||||
|
||||
func _ready():
|
||||
var scene_root := get_tree().current_scene
|
||||
round_manager = scene_root.get_node_or_null("Network/RoundManager")
|
||||
team_manager = scene_root.get_node_or_null("Network/TeamManager")
|
||||
|
||||
_setup_styles()
|
||||
scoreboard_panel.hide()
|
||||
state_label.hide()
|
||||
|
||||
if round_manager:
|
||||
round_manager.round_state_changed.connect(_on_state_changed)
|
||||
round_manager.round_ended.connect(_on_round_ended)
|
||||
|
||||
func _setup_styles():
|
||||
mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
# Timer
|
||||
timer_label.add_theme_font_size_override("font_size", 20)
|
||||
timer_label.add_theme_color_override("font_color", COLOR_GREEN)
|
||||
timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
timer_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
# Score
|
||||
score_label.add_theme_font_size_override("font_size", 16)
|
||||
score_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
score_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
# State
|
||||
state_label.add_theme_font_size_override("font_size", 36)
|
||||
state_label.add_theme_color_override("font_color", Color.YELLOW)
|
||||
state_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
state_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
state_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
# Scoreboard panel
|
||||
var panel_style := StyleBoxFlat.new()
|
||||
panel_style.bg_color = Color(0.05, 0.05, 0.1, 0.88)
|
||||
panel_style.border_color = Color(0.4, 0.4, 0.4, 0.6)
|
||||
panel_style.set_border_width_all(1)
|
||||
panel_style.set_corner_radius_all(0)
|
||||
panel_style.set_content_margin_all(12)
|
||||
scoreboard_panel.add_theme_stylebox_override("panel", panel_style)
|
||||
|
||||
var old_label := scoreboard_panel.get_node_or_null("Label")
|
||||
if old_label:
|
||||
old_label.queue_free()
|
||||
|
||||
scoreboard_vbox = VBoxContainer.new()
|
||||
scoreboard_vbox.name = "ScoreboardVBox"
|
||||
scoreboard_vbox.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
scoreboard_panel.add_child(scoreboard_vbox)
|
||||
|
||||
# Ammo label
|
||||
ammo_label = Label.new()
|
||||
ammo_label.name = "AmmoLabel"
|
||||
ammo_label.add_theme_font_size_override("font_size", 22)
|
||||
ammo_label.add_theme_color_override("font_color", COLOR_AMMO)
|
||||
ammo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
ammo_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
add_child(ammo_label)
|
||||
|
||||
# Money label
|
||||
money_label = Label.new()
|
||||
money_label.name = "MoneyLabel"
|
||||
money_label.add_theme_font_size_override("font_size", 18)
|
||||
money_label.add_theme_color_override("font_color", Color("#00FF00"))
|
||||
money_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
money_label.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
add_child(money_label)
|
||||
|
||||
func _process(_delta: float):
|
||||
if round_manager == null:
|
||||
return
|
||||
|
||||
var vp := get_viewport_rect().size
|
||||
|
||||
# Position elements
|
||||
timer_label.size = Vector2(100, 28)
|
||||
timer_label.position = Vector2(vp.x / 2.0 - 50, vp.y - 34)
|
||||
|
||||
score_label.size = Vector2(200, 24)
|
||||
score_label.position = Vector2(vp.x / 2.0 - 100, 6)
|
||||
|
||||
state_label.size = Vector2(500, 60)
|
||||
state_label.position = Vector2(vp.x / 2.0 - 250, vp.y / 2.0 - 30)
|
||||
|
||||
scoreboard_panel.size = Vector2(560, 420)
|
||||
scoreboard_panel.position = Vector2(vp.x / 2.0 - 280, vp.y / 2.0 - 210)
|
||||
|
||||
# Ammo label — bottom right
|
||||
ammo_label.size = Vector2(200, 30)
|
||||
ammo_label.position = Vector2(vp.x - 220, vp.y - 40)
|
||||
|
||||
# Money label — bottom left
|
||||
money_label.size = Vector2(200, 28)
|
||||
money_label.position = Vector2(20, vp.y - 40)
|
||||
|
||||
# Timer content
|
||||
var time_left: float = maxf(0.0, round_manager.timer)
|
||||
var minutes := int(time_left) / 60
|
||||
var seconds := int(time_left) % 60
|
||||
timer_label.text = "%d:%02d" % [minutes, seconds]
|
||||
|
||||
if round_manager.state == RoundManager.RoundState.ACTIVE and time_left <= 10.0:
|
||||
timer_label.add_theme_color_override("font_color", Color.RED)
|
||||
else:
|
||||
timer_label.add_theme_color_override("font_color", COLOR_GREEN)
|
||||
|
||||
# Score content
|
||||
var t_score := int(round_manager.score.get(TeamManager.Team.T, 0))
|
||||
var ct_score := int(round_manager.score.get(TeamManager.Team.CT, 0))
|
||||
score_label.text = "T %d : %d CT" % [t_score, ct_score]
|
||||
|
||||
# Ammo + money display
|
||||
_update_ammo_display()
|
||||
_update_money_display()
|
||||
|
||||
# Scoreboard (Tab)
|
||||
if Input.is_action_pressed("scoreboard"):
|
||||
_scoreboard_rebuild_timer -= _delta
|
||||
if _scoreboard_rebuild_timer <= 0:
|
||||
_rebuild_scoreboard()
|
||||
_scoreboard_rebuild_timer = 0.5
|
||||
scoreboard_panel.show()
|
||||
else:
|
||||
scoreboard_panel.hide()
|
||||
_scoreboard_rebuild_timer = 0.0
|
||||
|
||||
func _update_ammo_display():
|
||||
var local_player: CharacterBody3D = _find_local_player()
|
||||
if local_player == null or local_player.weapon_manager == null:
|
||||
ammo_label.text = ""
|
||||
return
|
||||
|
||||
var wm: WeaponManager = local_player.weapon_manager
|
||||
var weapon := wm.get_active_weapon()
|
||||
if weapon == null:
|
||||
ammo_label.text = ""
|
||||
return
|
||||
|
||||
if weapon.fire_mode == WeaponData.FireMode.MELEE:
|
||||
ammo_label.text = weapon.weapon_name
|
||||
else:
|
||||
ammo_label.text = "%s %d / %d" % [weapon.weapon_name, wm.ammo[wm.active_slot], wm.reserve[wm.active_slot]]
|
||||
|
||||
func _update_money_display():
|
||||
var economy := get_tree().current_scene.get_node_or_null("Network/EconomyManager") as EconomyManager
|
||||
if economy == null:
|
||||
money_label.text = ""
|
||||
return
|
||||
var local_id := multiplayer.get_unique_id()
|
||||
var money := economy.get_money(local_id)
|
||||
money_label.text = "$%d" % money
|
||||
|
||||
func _find_local_player() -> CharacterBody3D:
|
||||
var players_container := get_tree().current_scene.get_node_or_null("Network/Player Spawner")
|
||||
if players_container == null:
|
||||
return null
|
||||
for child in players_container.get_children():
|
||||
if child is CharacterBody3D:
|
||||
var input_node := child.get_node_or_null("Input")
|
||||
if input_node and input_node.is_multiplayer_authority():
|
||||
return child
|
||||
return null
|
||||
|
||||
func _on_state_changed(new_state: RoundManager.RoundState):
|
||||
match new_state:
|
||||
RoundManager.RoundState.FREEZE_TIME:
|
||||
state_label.text = "FREEZE TIME"
|
||||
state_label.add_theme_color_override("font_color", Color.YELLOW)
|
||||
state_label.show()
|
||||
RoundManager.RoundState.ACTIVE:
|
||||
state_label.hide()
|
||||
RoundManager.RoundState.ROUND_END:
|
||||
pass
|
||||
|
||||
func _on_round_ended(winner: TeamManager.Team):
|
||||
var winner_name := "ROUND OVER"
|
||||
var color := Color.YELLOW
|
||||
if team_manager:
|
||||
winner_name = team_manager.get_team_name(winner).to_upper() + " WIN"
|
||||
color = COLOR_T if winner == TeamManager.Team.T else COLOR_CT
|
||||
state_label.text = winner_name
|
||||
state_label.add_theme_color_override("font_color", color)
|
||||
state_label.show()
|
||||
|
||||
# --- Scoreboard building ---
|
||||
|
||||
func _rebuild_scoreboard():
|
||||
if team_manager == null or scoreboard_vbox == null:
|
||||
return
|
||||
|
||||
for child in scoreboard_vbox.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var t_members := team_manager.get_team_members(TeamManager.Team.T)
|
||||
var ct_members := team_manager.get_team_members(TeamManager.Team.CT)
|
||||
var t_score := int(round_manager.score.get(TeamManager.Team.T, 0))
|
||||
var ct_score := int(round_manager.score.get(TeamManager.Team.CT, 0))
|
||||
|
||||
_add_label_centered("Counter-Strike", Color.WHITE, 20)
|
||||
_add_label_centered("Round %d" % round_manager.round_number, Color.GRAY, 12)
|
||||
_add_spacer(8)
|
||||
|
||||
_add_row(["Name", "Score", "Deaths", "Latency"], COLOR_HEADER, 13)
|
||||
_add_separator()
|
||||
|
||||
_add_team_header("Terrorists", t_members.size(), t_score, COLOR_T)
|
||||
for peer_id in t_members:
|
||||
var alive := round_manager.is_player_alive(peer_id)
|
||||
var c := COLOR_T if alive else COLOR_DEAD
|
||||
var status := "ALIVE" if alive else "DEAD"
|
||||
_add_row([" Player #%d" % peer_id, "0", "0", status], c, 13)
|
||||
|
||||
_add_spacer(4)
|
||||
_add_separator()
|
||||
|
||||
_add_team_header("Counter-Terrorists", ct_members.size(), ct_score, COLOR_CT)
|
||||
for peer_id in ct_members:
|
||||
var alive := round_manager.is_player_alive(peer_id)
|
||||
var c := COLOR_CT if alive else COLOR_DEAD
|
||||
var status := "ALIVE" if alive else "DEAD"
|
||||
_add_row([" Player #%d" % peer_id, "0", "0", status], c, 13)
|
||||
|
||||
func _add_label_centered(text: String, color: Color, font_size: int):
|
||||
var lbl := Label.new()
|
||||
lbl.text = text
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
lbl.add_theme_color_override("font_color", color)
|
||||
lbl.add_theme_font_size_override("font_size", font_size)
|
||||
lbl.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
scoreboard_vbox.add_child(lbl)
|
||||
|
||||
func _add_team_header(team_name: String, player_count: int, team_score: int, color: Color):
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = "%s - %d players" % [team_name, player_count]
|
||||
name_lbl.add_theme_color_override("font_color", color)
|
||||
name_lbl.add_theme_font_size_override("font_size", 14)
|
||||
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_lbl.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
hbox.add_child(name_lbl)
|
||||
|
||||
var score_lbl := Label.new()
|
||||
score_lbl.text = str(team_score)
|
||||
score_lbl.add_theme_color_override("font_color", Color.WHITE)
|
||||
score_lbl.add_theme_font_size_override("font_size", 14)
|
||||
score_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
score_lbl.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
hbox.add_child(score_lbl)
|
||||
|
||||
scoreboard_vbox.add_child(hbox)
|
||||
|
||||
func _add_row(columns: Array, color: Color, font_size: int):
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
|
||||
var ratios := [5, 2, 2, 2]
|
||||
|
||||
for i in columns.size():
|
||||
var lbl := Label.new()
|
||||
lbl.text = columns[i]
|
||||
lbl.add_theme_color_override("font_color", color)
|
||||
lbl.add_theme_font_size_override("font_size", font_size)
|
||||
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
lbl.size_flags_stretch_ratio = ratios[i] if i < ratios.size() else 1
|
||||
lbl.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
if i > 0:
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
hbox.add_child(lbl)
|
||||
|
||||
scoreboard_vbox.add_child(hbox)
|
||||
|
||||
func _add_separator():
|
||||
var sep := HSeparator.new()
|
||||
sep.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
sep.add_theme_color_override("separator", Color(0.4, 0.4, 0.4))
|
||||
scoreboard_vbox.add_child(sep)
|
||||
|
||||
func _add_spacer(height: int):
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size.y = height
|
||||
spacer.mouse_filter = MOUSE_FILTER_IGNORE
|
||||
scoreboard_vbox.add_child(spacer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1wur66am5ly8
|
||||
@@ -0,0 +1,34 @@
|
||||
@tool
|
||||
class_name WindowSizeConnector extends Node
|
||||
|
||||
## Allows you to update node size to the window size
|
||||
|
||||
## The nodes to apply the new size to
|
||||
@export var target_nodes: Array[Node] = []: set = _target_nodes_changed
|
||||
## The percentage of the window size to fill (1.0 for full size)
|
||||
@export var size_multiplier: float = 1.0
|
||||
|
||||
signal window_size_updated(new_size: Vector2)
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
get_tree().get_root().size_changed.connect(func(): _on_window_resized(DisplayServer.window_get_size()))
|
||||
_on_window_resized(DisplayServer.window_get_size())
|
||||
|
||||
func _on_window_resized(new_size: Vector2) -> void:
|
||||
for node in target_nodes:
|
||||
if node and "size" in node:
|
||||
node.set_deferred("size", new_size * size_multiplier)
|
||||
window_size_updated.emit(new_size)
|
||||
|
||||
func _target_nodes_changed(new_value: Array[Node]):
|
||||
target_nodes = new_value
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
_on_window_resized(Vector2(
|
||||
ProjectSettings.get_setting(&"display/window/size/viewport_width"),
|
||||
ProjectSettings.get_setting(&"display/window/size/viewport_height")
|
||||
))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpp3g8xk4qngo
|
||||
@@ -0,0 +1,394 @@
|
||||
extends Node3D
|
||||
class_name WeaponManager
|
||||
|
||||
@onready var input: PlayerInputFPS = $"../../Input"
|
||||
@onready var player: CharacterBody3D = $"../.."
|
||||
@onready var sound: AudioStreamPlayer3D = $AudioStreamPlayer3D
|
||||
@onready var bullethole: BulletHole = $BulletHole
|
||||
@onready var fire_action := $"Fire Action" as RewindableAction
|
||||
@onready var rollback_synchronizer := %RollbackSynchronizer as RollbackSynchronizer
|
||||
|
||||
# Rollback state
|
||||
var inventory: PackedInt32Array = PackedInt32Array([0, 0, 0, 0])
|
||||
var active_slot: int = 0
|
||||
var ammo: PackedInt32Array = PackedInt32Array([0, 0, 0, 0])
|
||||
var reserve: PackedInt32Array = PackedInt32Array([0, 0, 0, 0])
|
||||
var reload_start_tick: int = -1
|
||||
var last_fire_by_slot: PackedInt32Array = PackedInt32Array([-1, -1, -1, -1])
|
||||
var recoil_accumulator: float = 0.0
|
||||
|
||||
# Grenade inventory (Phase 5)
|
||||
var grenade_counts: PackedInt32Array = PackedInt32Array([0, 0]) # [flash, smoke]
|
||||
var active_grenade_type: int = 0 # 0=Flash, 1=Smoke (only when slot 3 active)
|
||||
|
||||
var _pending_hit_result: Dictionary = {}
|
||||
var _pending_grenades: Array[Dictionary] = [] # Buffered throws, consumed in _after_loop
|
||||
|
||||
# Weapon models (loaded at runtime)
|
||||
var _knife_model: Node3D
|
||||
var _gun_model: MeshInstance3D
|
||||
var _grenade_model: Node3D
|
||||
var _current_visible_slot: int = -1
|
||||
var _knife_sound: AudioStreamPlayer3D
|
||||
|
||||
func _ready():
|
||||
fire_action.mutate(self)
|
||||
fire_action.mutate($"../../")
|
||||
NetworkTime.before_tick_loop.connect(_before_loop)
|
||||
NetworkTime.after_tick_loop.connect(_after_loop)
|
||||
_gun_model = $TinyGun
|
||||
_load_weapon_models()
|
||||
|
||||
func _load_weapon_models():
|
||||
# Knife model
|
||||
var knife_scene := load("res://examples/multiplayer-fps/models/knife.fbx") as PackedScene
|
||||
if knife_scene:
|
||||
_knife_model = knife_scene.instantiate()
|
||||
_knife_model.scale = Vector3(0.003, 0.003, 0.003)
|
||||
_knife_model.position = Vector3(0.15, -0.22, -0.35)
|
||||
_knife_model.rotation_degrees = Vector3(-90, 180, 0)
|
||||
add_child(_knife_model)
|
||||
_knife_model.hide()
|
||||
|
||||
# Grenade model
|
||||
var grenade_scene := load("res://examples/multiplayer-fps/models/grenade.glb") as PackedScene
|
||||
if grenade_scene:
|
||||
_grenade_model = grenade_scene.instantiate()
|
||||
_grenade_model.scale = Vector3(0.08, 0.08, 0.08)
|
||||
_grenade_model.position = Vector3(0.1, -0.18, -0.25)
|
||||
add_child(_grenade_model)
|
||||
_grenade_model.hide()
|
||||
|
||||
# Knife sound
|
||||
var slash_stream := load("res://examples/multiplayer-fps/sounds/knife/knife_slash1.wav")
|
||||
if slash_stream:
|
||||
_knife_sound = AudioStreamPlayer3D.new()
|
||||
_knife_sound.stream = slash_stream
|
||||
_knife_sound.max_distance = 10.0
|
||||
add_child(_knife_sound)
|
||||
|
||||
func _process(_delta: float):
|
||||
# Update weapon model visibility
|
||||
if active_slot != _current_visible_slot:
|
||||
_current_visible_slot = active_slot
|
||||
if _knife_model:
|
||||
_knife_model.visible = (active_slot == 0)
|
||||
if _gun_model:
|
||||
_gun_model.visible = (active_slot == 1 or active_slot == 2)
|
||||
if _grenade_model:
|
||||
_grenade_model.visible = (active_slot == 3)
|
||||
|
||||
func hide_weapons():
|
||||
if _knife_model:
|
||||
_knife_model.hide()
|
||||
if _gun_model:
|
||||
_gun_model.hide()
|
||||
if _grenade_model:
|
||||
_grenade_model.hide()
|
||||
_current_visible_slot = -1
|
||||
|
||||
func show_weapons():
|
||||
_current_visible_slot = -1 # Force refresh on next _process
|
||||
|
||||
func get_active_weapon() -> WeaponData:
|
||||
var wid := inventory[active_slot]
|
||||
if wid == 0:
|
||||
return null
|
||||
return WeaponRegistry.get_weapon(wid)
|
||||
|
||||
func get_move_speed() -> float:
|
||||
var weapon := get_active_weapon()
|
||||
if weapon:
|
||||
return weapon.move_speed
|
||||
return 5.0
|
||||
|
||||
func set_default_loadout(team: int):
|
||||
inventory = PackedInt32Array([WeaponRegistry.KNIFE, WeaponRegistry.get_default_pistol(team), 0, 0])
|
||||
active_slot = 0
|
||||
reload_start_tick = -1
|
||||
recoil_accumulator = 0.0
|
||||
last_fire_by_slot = PackedInt32Array([-1, -1, -1, -1])
|
||||
grenade_counts = PackedInt32Array([0, 0])
|
||||
active_grenade_type = 0
|
||||
for i in 4:
|
||||
var weapon := WeaponRegistry.get_weapon(inventory[i])
|
||||
if weapon:
|
||||
ammo[i] = weapon.magazine_size
|
||||
reserve[i] = weapon.reserve_size
|
||||
else:
|
||||
ammo[i] = 0
|
||||
reserve[i] = 0
|
||||
NetworkRollback.mutate(self)
|
||||
|
||||
func strip_weapons():
|
||||
inventory = PackedInt32Array([0, 0, 0, 0])
|
||||
active_slot = 0
|
||||
ammo = PackedInt32Array([0, 0, 0, 0])
|
||||
reserve = PackedInt32Array([0, 0, 0, 0])
|
||||
reload_start_tick = -1
|
||||
last_fire_by_slot = PackedInt32Array([-1, -1, -1, -1])
|
||||
recoil_accumulator = 0.0
|
||||
grenade_counts = PackedInt32Array([0, 0])
|
||||
active_grenade_type = 0
|
||||
NetworkRollback.mutate(self)
|
||||
|
||||
func give_weapon(weapon_id: int):
|
||||
var weapon := WeaponRegistry.get_weapon(weapon_id)
|
||||
if weapon == null:
|
||||
return
|
||||
inventory[weapon.slot] = weapon_id
|
||||
ammo[weapon.slot] = weapon.magazine_size
|
||||
reserve[weapon.slot] = weapon.reserve_size
|
||||
last_fire_by_slot[weapon.slot] = -1
|
||||
active_slot = weapon.slot
|
||||
NetworkRollback.mutate(self)
|
||||
|
||||
func _rollback_tick(delta: float, tick: int, _is_fresh: bool):
|
||||
if rollback_synchronizer.is_predicting():
|
||||
return
|
||||
|
||||
# Skip if player dead
|
||||
if player.death_tick >= 0 and tick >= player.death_tick:
|
||||
return
|
||||
|
||||
# Recoil recovery (always, even during freeze)
|
||||
var weapon := get_active_weapon()
|
||||
if weapon and recoil_accumulator > 0.0:
|
||||
recoil_accumulator = move_toward(recoil_accumulator, 0.0, weapon.recoil_recovery * delta)
|
||||
|
||||
# Can't act checks (pass tick for correct rollback evaluation)
|
||||
if not player.can_act(tick):
|
||||
return
|
||||
|
||||
# Handle weapon switching
|
||||
_handle_weapon_switch()
|
||||
|
||||
# Handle reload
|
||||
_handle_reload(tick)
|
||||
|
||||
# Handle firing
|
||||
_handle_fire(tick)
|
||||
|
||||
func _handle_weapon_switch():
|
||||
var new_slot := -1
|
||||
|
||||
if input.slot_1 and inventory[0] != 0:
|
||||
new_slot = 0
|
||||
elif input.slot_2 and inventory[1] != 0:
|
||||
new_slot = 1
|
||||
elif input.slot_3 and inventory[2] != 0:
|
||||
new_slot = 2
|
||||
elif input.slot_4 and _has_grenades():
|
||||
new_slot = 3
|
||||
elif input.next_weapon:
|
||||
new_slot = _find_next_occupied_slot(1)
|
||||
elif input.prev_weapon:
|
||||
new_slot = _find_next_occupied_slot(-1)
|
||||
|
||||
if new_slot >= 0 and new_slot != active_slot:
|
||||
active_slot = new_slot
|
||||
reload_start_tick = -1 # cancel reload on switch
|
||||
recoil_accumulator = 0.0
|
||||
elif new_slot == 3 and active_slot == 3:
|
||||
# Re-pressing slot 4 while on grenade slot: cycle grenade type
|
||||
_cycle_grenade_type()
|
||||
|
||||
func _has_grenades() -> bool:
|
||||
return grenade_counts[0] > 0 or grenade_counts[1] > 0
|
||||
|
||||
func _is_slot_occupied(slot: int) -> bool:
|
||||
if slot == 3:
|
||||
return _has_grenades()
|
||||
return inventory[slot] != 0
|
||||
|
||||
func _find_next_occupied_slot(direction: int) -> int:
|
||||
for i in range(1, 5):
|
||||
var slot := (active_slot + direction * i) % 4
|
||||
if slot < 0:
|
||||
slot += 4
|
||||
if _is_slot_occupied(slot):
|
||||
return slot
|
||||
return active_slot
|
||||
|
||||
func add_grenade(type: int) -> bool:
|
||||
## type: 0=flash, 1=smoke. Returns true if added.
|
||||
var max_count := 2 if type == 0 else 1
|
||||
if grenade_counts[type] >= max_count:
|
||||
return false
|
||||
grenade_counts[type] += 1
|
||||
NetworkRollback.mutate(self)
|
||||
return true
|
||||
|
||||
func _handle_reload(tick: int):
|
||||
var weapon := get_active_weapon()
|
||||
if weapon == null or weapon.fire_mode == WeaponData.FireMode.MELEE:
|
||||
reload_start_tick = -1
|
||||
return
|
||||
|
||||
# Start reload if requested
|
||||
if input.reload_pressed and reload_start_tick < 0:
|
||||
if ammo[active_slot] < weapon.magazine_size and reserve[active_slot] > 0:
|
||||
reload_start_tick = tick
|
||||
|
||||
# Complete reload
|
||||
if reload_start_tick >= 0:
|
||||
if NetworkTime.seconds_between(reload_start_tick, tick) >= weapon.reload_time:
|
||||
var needed := weapon.magazine_size - ammo[active_slot]
|
||||
var available := mini(needed, reserve[active_slot])
|
||||
ammo[active_slot] += available
|
||||
reserve[active_slot] -= available
|
||||
reload_start_tick = -1
|
||||
|
||||
func _handle_fire(tick: int):
|
||||
# Grenade throw (slot 3) — server-authoritative, no rollback
|
||||
if active_slot == 3 and _has_grenades():
|
||||
if input.fire_pressed:
|
||||
_throw_grenade()
|
||||
fire_action.set_active(false)
|
||||
return
|
||||
|
||||
var weapon := get_active_weapon()
|
||||
if weapon == null:
|
||||
fire_action.set_active(false)
|
||||
return
|
||||
|
||||
# Determine if we should fire
|
||||
var want_fire := false
|
||||
if weapon.fire_mode == WeaponData.FireMode.AUTO:
|
||||
want_fire = input.fire_held
|
||||
else: # SEMI or MELEE
|
||||
want_fire = input.fire_pressed
|
||||
|
||||
var can_fire := want_fire and _can_fire_weapon(weapon, tick)
|
||||
|
||||
fire_action.set_active(can_fire)
|
||||
match fire_action.get_status():
|
||||
RewindableAction.CONFIRMING, RewindableAction.ACTIVE:
|
||||
_fire(weapon, tick)
|
||||
RewindableAction.CANCELLING:
|
||||
_unfire()
|
||||
|
||||
func _can_fire_weapon(weapon: WeaponData, tick: int) -> bool:
|
||||
# Check cooldown (per-slot)
|
||||
if last_fire_by_slot[active_slot] >= 0:
|
||||
if NetworkTime.seconds_between(last_fire_by_slot[active_slot], tick) < weapon.fire_rate:
|
||||
return false
|
||||
# Check ammo (melee has no ammo)
|
||||
if weapon.fire_mode != WeaponData.FireMode.MELEE:
|
||||
if ammo[active_slot] <= 0:
|
||||
return false
|
||||
# Check reload
|
||||
if reload_start_tick >= 0:
|
||||
return false
|
||||
return true
|
||||
|
||||
func _fire(weapon: WeaponData, tick: int):
|
||||
last_fire_by_slot[active_slot] = tick
|
||||
|
||||
# Consume ammo
|
||||
if weapon.fire_mode != WeaponData.FireMode.MELEE:
|
||||
ammo[active_slot] -= 1
|
||||
|
||||
# Recoil
|
||||
recoil_accumulator += weapon.recoil_per_shot
|
||||
|
||||
# Raycast
|
||||
var hit := _raycast(weapon)
|
||||
if hit.is_empty():
|
||||
return
|
||||
|
||||
_on_hit(hit, weapon)
|
||||
|
||||
func _unfire():
|
||||
fire_action.erase_context()
|
||||
|
||||
func _raycast(weapon: WeaponData) -> Dictionary:
|
||||
var space := get_world_3d().direct_space_state
|
||||
var origin := global_transform.origin
|
||||
var direction := -global_transform.basis.z
|
||||
|
||||
# Apply recoil to direction
|
||||
if recoil_accumulator > 0.0:
|
||||
var recoil_rad := deg_to_rad(recoil_accumulator)
|
||||
direction = direction.rotated(global_transform.basis.x, recoil_rad)
|
||||
|
||||
var query := PhysicsRayQueryParameters3D.create(
|
||||
origin,
|
||||
origin + direction * weapon.max_distance
|
||||
)
|
||||
|
||||
return space.intersect_ray(query)
|
||||
|
||||
func _on_hit(result: Dictionary, weapon: WeaponData):
|
||||
var is_new_hit := false
|
||||
if not fire_action.has_context():
|
||||
fire_action.set_context(true)
|
||||
is_new_hit = true
|
||||
|
||||
if is_new_hit:
|
||||
_pending_hit_result = result
|
||||
|
||||
if result.collider.has_method("damage"):
|
||||
var attacker_id: int = player.get_player_id()
|
||||
result.collider.damage(weapon.damage, weapon.weapon_id, attacker_id, is_new_hit)
|
||||
NetworkRollback.mutate(result.collider)
|
||||
|
||||
func _cycle_grenade_type():
|
||||
# Cycle through available grenade types
|
||||
for i in range(1, 3):
|
||||
var next := (active_grenade_type + i) % 2
|
||||
if grenade_counts[next] > 0:
|
||||
active_grenade_type = next
|
||||
return
|
||||
|
||||
func _throw_grenade():
|
||||
if not _has_grenades():
|
||||
return
|
||||
# Ensure we have the selected type, otherwise cycle
|
||||
if grenade_counts[active_grenade_type] <= 0:
|
||||
_cycle_grenade_type()
|
||||
if grenade_counts[active_grenade_type] <= 0:
|
||||
return
|
||||
|
||||
grenade_counts[active_grenade_type] -= 1
|
||||
|
||||
# Buffer the throw — actual RPC fires in _after_loop (rollback-safe)
|
||||
_pending_grenades.append({
|
||||
"origin": global_transform.origin,
|
||||
"direction": -global_transform.basis.z,
|
||||
"owner_id": player.get_player_id(),
|
||||
"type": active_grenade_type
|
||||
})
|
||||
|
||||
# Auto-switch if no grenades left
|
||||
if not _has_grenades():
|
||||
active_slot = 0
|
||||
|
||||
func _before_loop():
|
||||
_pending_grenades.clear()
|
||||
|
||||
func _after_loop():
|
||||
# Grenade spawns (deferred from rollback — fires once per throw)
|
||||
for pg in _pending_grenades:
|
||||
_spawn_grenade.rpc(pg["origin"], pg["direction"], pg["owner_id"], pg["type"])
|
||||
_pending_grenades.clear()
|
||||
|
||||
if fire_action.has_confirmed():
|
||||
# Play weapon-appropriate sound
|
||||
if active_slot == 0 and _knife_sound:
|
||||
_knife_sound.play()
|
||||
else:
|
||||
sound.play()
|
||||
if not _pending_hit_result.is_empty():
|
||||
# No bullet holes for knife
|
||||
if active_slot != 0:
|
||||
bullethole.action(_pending_hit_result)
|
||||
_pending_hit_result = {}
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _spawn_grenade(origin: Vector3, direction: Vector3, owner_id: int, type_idx: int) -> void:
|
||||
var grenade := Grenade.new()
|
||||
grenade.name = "Grenade_%d_%d" % [owner_id, NetworkTime.tick]
|
||||
get_tree().current_scene.add_child(grenade)
|
||||
var gtype: Grenade.GrenadeType = Grenade.GrenadeType.FLASH if type_idx == 0 else Grenade.GrenadeType.SMOKE
|
||||
grenade.setup(origin, direction, owner_id, gtype)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bchdq0gvwhyxj
|
||||
Reference in New Issue
Block a user