Files
tactical-shooter/examples/multiplayer-fps/scripts/round-manager.gd
T
shawn b0c83af092 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>
2026-07-02 20:55:20 -04:00

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