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:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
+300
View File
@@ -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
+160
View File
@@ -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
+381
View File
@@ -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