b0c83af092
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>
202 lines
6.4 KiB
GDScript
202 lines
6.4 KiB
GDScript
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
|