## RoundManager — Server-authoritative round state machine. ## ## Controls round lifecycle: INACTIVE → WAITING_FOR_PLAYERS → WARMUP → LIVE ## → POST_ROUND → (next round: WARMUP) or MATCH_END → INACTIVE. ## ## Win condition (elimination): ## The round ends when only one team has alive players. ## If the round timer expires, the team with more alive players wins. ## A draw occurs if both teams are eliminated simultaneously or have equal ## alive count at time limit. ## ## Match scoring: ## Best-of-N configured by ServerConfig.win_limit. ## First team to reach win_limit rounds wins the match. ## ## Respawn: ## Elimination mode: no mid-round respawns. All players respawn at round start. ## (Future: TDM/deathmatch modes may use respawn_time_seconds config.) ## ## Spectate: ## Dead players are moved to spectator mode. They can watch alive teammates ## via the spectate system. Spectating state is tracked per-player. ## ## Integration with PluginManager: ## RoundManager emits signals that PluginManager dispatches to all loaded ## plugins via their round hooks (on_round_start, on_round_end, etc.). ## ## ============================================================================= extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when the round state machine transitions to a new state. ## state: the new RoundState enum value. signal round_state_changed(state: int) ## Emitted at the start of each round. ## round_number: 1-based round number. ## warmup_time: warmup duration in seconds. signal round_started(round_number: int) ## Emitted when the round ends. ## winner_team: 0=draw, 1=TeamA, 2=TeamB. ## reason: string describing the end condition. signal round_ended(winner_team: int, reason: String) ## Emitted when a player dies. ## victim_id: peer id of the killed player. ## killer_id: peer id of the killer (may equal victim_id for suicide). ## weapon: weapon name string. signal player_died(victim_id: int, killer_id: int, weapon: String) ## Emitted when a player respawns at round start. signal player_respawned(peer_id: int) ## Emitted when a player enters spectator mode. signal player_spectating(peer_id: int) ## Emitted when a player exits spectator mode (respawned). signal player_unspectated(peer_id: int) ## Emitted when the match ends. ## winner_team: 0=draw, 1=TeamA, 2=TeamB. signal match_ended(winner_team: int) ## Emitted when team scores change. signal score_changed(team_a_score: int, team_b_score: int) ## Emitted when the spectate target for a player changes. signal spectate_target_changed(viewer_id: int, target_id: int) # --------------------------------------------------------------------------- # netfox Self-RPC methods — called via rpc() from server, execute on all peers # --------------------------------------------------------------------------- ## Replicate round state transition to all peers. ## Self-RPC pattern: server calls .rpc(), executes locally (call_local) and on clients. @rpc("authority", "call_local", "reliable") func _rpc_round_state(state: int) -> void: round_state = state round_state_changed.emit(state) ## Replicate round start to all peers. @rpc("authority", "call_local", "reliable") func _rpc_round_started(round_num: int, time_seconds: int) -> void: round_number = round_num round_time_seconds = time_seconds round_started.emit(round_num) ## Replicate round end to all peers. Includes score snapshot for client UI. @rpc("authority", "call_local", "reliable") func _rpc_round_ended(winner_team: int, reason: String, a_score: int, b_score: int) -> void: team_a_score = a_score team_b_score = b_score round_ended.emit(winner_team, reason) ## Replicate match end to all peers. @rpc("authority", "call_local", "reliable") func _rpc_match_ended(winner_team: int, a_score: int, b_score: int) -> void: team_a_score = a_score team_b_score = b_score match_ended.emit(winner_team) ## Replicate score change to all peers. @rpc("authority", "call_local", "reliable") func _rpc_score_changed(a_score: int, b_score: int) -> void: team_a_score = a_score team_b_score = b_score score_changed.emit(team_a_score, team_b_score) ## Replicate player death to all peers. @rpc("authority", "call_local", "reliable") func _rpc_player_died(victim_id: int, killer_id: int, weapon: String) -> void: player_died.emit(victim_id, killer_id, weapon) ## Replicate player respawn to all peers. @rpc("authority", "call_local", "reliable") func _rpc_player_respawned(peer_id: int) -> void: player_respawned.emit(peer_id) # --------------------------------------------------------------------------- # Round state enum # --------------------------------------------------------------------------- enum RoundState { INACTIVE = 0, # Server starting up, no game running WAITING_FOR_PLAYERS = 1, # Not enough players to start WARMUP = 2, # Pre-round warmup period LIVE = 3, # Round is active, players fighting POST_ROUND = 4, # Brief result display period MATCH_END = 5, # A team reached win_limit } # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- ## Default post-round delay before next round starts (seconds). const DEFAULT_POST_ROUND_DELAY: float = 5.0 ## Minimum players per team required to start a round. const MIN_PLAYERS_PER_TEAM: int = 1 ## Minimum round number before match-end check activates. const MIN_ROUNDS_FOR_MATCH_END: int = 1 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var round_state: int = RoundState.INACTIVE ## Current round number (1-based, increments each round). var round_number: int = 0 ## Team A score (rounds won). var team_a_score: int = 0 ## Team B score (rounds won). var team_b_score: int = 0 ## Dictionary of peer_id → player state dictionary. ## Each entry: { "team": int (0=A, 1=B), "alive": bool, "spectating": bool, ## "spectate_target": int (peer_id or 0), "spawn_pos": Vector3 } var players: Dictionary = {} ## Sequence of spawn positions for team A (cycled round-robin). var spawn_points_a: Array[Vector3] = [] ## Sequence of spawn positions for team B (cycled round-robin). var spawn_points_b: Array[Vector3] = [] ## Internal timers var _warmup_timer: float = 0.0 var _round_timer: float = 0.0 var _post_round_timer: float = 0.0 var _elapsed_seconds: float = 0.0 ## Config values cached from ServerConfig at init. var round_time_seconds: int = 600 var _warmup_time_seconds: int = 60 var _win_limit: int = 3 var _respawn_time_seconds: float = 5.0 var _spectate_enabled: bool = true # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _cache_config() print("[RoundManager] Initialised. Win limit: %d, Round time: %ds, Warmup: %ds" % [ _win_limit, round_time_seconds, _warmup_time_seconds ]) func _exit_tree() -> void: stop() ## Cache config values from ServerConfig (if available). func _cache_config() -> void: if ServerConfig and ServerConfig.has_method(&"get_config_path"): round_time_seconds = ServerConfig.round_time_seconds _warmup_time_seconds = ServerConfig.warmup_time_seconds _win_limit = ServerConfig.win_limit _respawn_time_seconds = ServerConfig.respawn_time_seconds _spectate_enabled = ServerConfig.spectate_enabled else: push_warning("[RoundManager] ServerConfig not available — using defaults") ## Start the round manager. Must be called after spawn points are set. func start() -> void: _reset_all_state() _set_state(RoundState.WAITING_FOR_PLAYERS) print("[RoundManager] Started — waiting for players") ## Stop the round manager and reset all state. func stop() -> void: _set_state(RoundState.INACTIVE) _reset_all_state() print("[RoundManager] Stopped") # --------------------------------------------------------------------------- # Player management # --------------------------------------------------------------------------- ## Register a player with the round manager. ## team: 0 = Team A, 1 = Team B. func register_player(peer_id: int, team: int) -> void: if peer_id in players: push_warning("[RoundManager] Player %d already registered, updating team" % peer_id) var spawn_pos: Vector3 = _get_spawn_position(peer_id, team) players[peer_id] = { "team": team, "alive": true, "spectating": false, "spectate_target": 0, "spawn_pos": spawn_pos, } print("[RoundManager] Registered player %d on team %s at (%.1f, %.1f)" % [ peer_id, "A" if team == 0 else "B", spawn_pos.x, spawn_pos.z ]) _check_start_condition() ## Unregister a player (on disconnect). func unregister_player(peer_id: int) -> void: if peer_id in players: var was_alive: bool = players[peer_id]["alive"] players.erase(peer_id) print("[RoundManager] Unregistered player %d" % peer_id) # If the round is LIVE and a team just lost all members, check win if round_state == RoundState.LIVE and was_alive: if _check_elimination(): return # round ended, no further action else: push_warning("[RoundManager] Cannot unregister unknown player %d" % peer_id) ## Set spawn points from the map. func set_spawn_points(spawns_a: Array[Vector3], spawns_b: Array[Vector3]) -> void: spawn_points_a = spawns_a.duplicate() spawn_points_b = spawns_b.duplicate() print("[RoundManager] Spawn points set: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()]) ## Get the spawn position for a player on a given team. func _get_spawn_position(peer_id: int, team: int) -> Vector3: var pool: Array[Vector3] = spawn_points_a if team == 0 else spawn_points_b if pool.size() == 0: return Vector3.ZERO # Round-robin assignment based on peer_id for distribution var idx: int = peer_id % pool.size() var pos: Vector3 = pool[idx] pos.y = 0.0 return pos ## Return the number of alive players on a given team. func get_alive_count(team: int) -> int: var count: int = 0 for p in players.values(): if p["team"] == team and p["alive"]: count += 1 return count ## Return the total number of players registered. func get_player_count() -> int: return players.size() ## Return the number of players on a given team. func get_team_player_count(team: int) -> int: var count: int = 0 for p in players.values(): if p["team"] == team: count += 1 return count ## Return the team of a player (0=A, 1=B), or -1 if not registered. func get_player_team(peer_id: int) -> int: if peer_id in players: return players[peer_id]["team"] return -1 ## Return whether a player is alive. func is_player_alive(peer_id: int) -> bool: if peer_id in players: return players[peer_id]["alive"] return false ## Return whether a player is spectating. func is_player_spectating(peer_id: int) -> bool: if peer_id in players: return players[peer_id]["spectating"] return false ## Return a list of alive peer IDs on a given team. func get_alive_players_on_team(team: int) -> Array[int]: var result: Array[int] = [] for pid in players: if players[pid]["team"] == team and players[pid]["alive"]: result.append(pid) return result ## Return the spectate target for a player. func get_spectate_target(peer_id: int) -> int: if peer_id in players: return players[peer_id]["spectate_target"] return 0 # --------------------------------------------------------------------------- # Death / Elimination # --------------------------------------------------------------------------- ## Called when a player dies. ## This is the main elimination detection entry point. ## Returns true if the round ended as a result. func on_player_death(victim_id: int, killer_id: int, weapon: String) -> bool: if not victim_id in players: push_warning("[RoundManager] Death event for unknown player %d" % victim_id) return false var victim_team: int = players[victim_id]["team"] players[victim_id]["alive"] = false print("[RoundManager] Player %d (team %s) killed by %s with '%s'" % [ victim_id, "A" if victim_team == 0 else "B", "world" if killer_id == 0 else str(killer_id), weapon ]) # Self-RPC death notification (call_local ensures server signal too) _rpc_player_died.rpc(victim_id, killer_id, weapon) # If spectate enabled, put the player in spectator mode if _spectate_enabled: _start_spectating(victim_id) # Check elimination — only when round is LIVE if round_state == RoundState.LIVE: return _check_elimination() return false ## Check if a team has been eliminated. Ends the round if so. ## Returns true if the round ended. func _check_elimination() -> bool: var alive_a: int = get_alive_count(0) var alive_b: int = get_alive_count(1) # Both teams alive — no win yet if alive_a > 0 and alive_b > 0: return false # Eliminate the possibility of "nobody alive" edge case if alive_a == 0 and alive_b == 0: _end_round(0, "all_dead_draw") return true # One team is eliminated if alive_a == 0 and alive_b > 0: _end_round(2, "elimination") # Team B wins return true if alive_b == 0 and alive_a > 0: _end_round(1, "elimination") # Team A wins return true return false # --------------------------------------------------------------------------- # Spectate system # --------------------------------------------------------------------------- ## Start spectating for a dead player. Finds the best alive teammate to watch. func _start_spectating(viewer_id: int) -> void: if not _spectate_enabled: return if not viewer_id in players: return var viewer_team: int = players[viewer_id]["team"] var alive_teammates: Array[int] = get_alive_players_on_team(viewer_team) players[viewer_id]["spectating"] = true if alive_teammates.size() > 0: # Target the first alive teammate (simple cycling later via RCON/UI) var target_id: int = alive_teammates[0] players[viewer_id]["spectate_target"] = target_id spectate_target_changed.emit(viewer_id, target_id) print("[RoundManager] Player %d now spectating %d" % [viewer_id, target_id]) else: # No alive teammates — spectate the map / overview players[viewer_id]["spectate_target"] = 0 spectate_target_changed.emit(viewer_id, 0) print("[RoundManager] Player %d spectating (no alive teammates)" % viewer_id) player_spectating.emit(viewer_id) ## Cycle to the next spectate target for a player. func cycle_spectate_target(viewer_id: int) -> void: if not viewer_id in players or not players[viewer_id]["spectating"]: return var viewer_team: int = players[viewer_id]["team"] var alive_teammates: Array[int] = get_alive_players_on_team(viewer_team) if alive_teammates.size() == 0: players[viewer_id]["spectate_target"] = 0 spectate_target_changed.emit(viewer_id, 0) return # Find current target index and advance var current_target: int = players[viewer_id]["spectate_target"] var idx: int = alive_teammates.find(current_target) var next_idx: int = (idx + 1) % alive_teammates.size() if idx >= 0 else 0 var new_target: int = alive_teammates[next_idx] players[viewer_id]["spectate_target"] = new_target spectate_target_changed.emit(viewer_id, new_target) print("[RoundManager] Player %d now spectating %d (cycled)" % [viewer_id, new_target]) ## Stop spectating for a player (on respawn). func _stop_spectating(peer_id: int) -> void: if peer_id in players: players[peer_id]["spectating"] = false players[peer_id]["spectate_target"] = 0 player_unspectated.emit(peer_id) # --------------------------------------------------------------------------- # Respawn system # --------------------------------------------------------------------------- ## Respawn all dead players on both teams at round start. ## Sets their position to their assigned spawn point and marks them alive. func respawn_all_players() -> void: for pid in players: var state = players[pid] state["alive"] = true state["spawn_pos"] = _get_spawn_position(pid, state["team"]) if state["spectating"]: _stop_spectating(pid) _rpc_player_respawned.rpc(pid) print("[RoundManager] Respawned all %d players for round %d" % [players.size(), round_number]) ## Get the spawn position for a player (for the current round). func get_player_spawn(peer_id: int) -> Vector3: if peer_id in players: return players[peer_id]["spawn_pos"] return Vector3.ZERO # --------------------------------------------------------------------------- # Round lifecycle # --------------------------------------------------------------------------- ## Check if we have enough players to start. Transitions from WAITING to WARMUP. func _check_start_condition() -> void: if round_state != RoundState.WAITING_FOR_PLAYERS: return var team_a_count: int = get_team_player_count(0) var team_b_count: int = get_team_player_count(1) if team_a_count >= MIN_PLAYERS_PER_TEAM and team_b_count >= MIN_PLAYERS_PER_TEAM: _begin_warmup() else: print("[RoundManager] Waiting for players: A=%d, B=%d (need %d each)" % [ team_a_count, team_b_count, MIN_PLAYERS_PER_TEAM ]) ## Begin the warmup phase. func _begin_warmup() -> void: round_number += 1 _warmup_timer = float(_warmup_time_seconds) _set_state(RoundState.WARMUP) print("[RoundManager] Warmup started — round %d will begin in %ds" % [round_number, _warmup_time_seconds]) ## Begin the live round. func _begin_round() -> void: # Respawn all players respawn_all_players() _round_timer = float(round_time_seconds) _elapsed_seconds = 0.0 _set_state(RoundState.LIVE) _rpc_round_started.rpc(round_number, round_time_seconds) print("[RoundManager] Round %d started! %ds time limit" % [round_number, round_time_seconds]) ## End the current round with the given winner and reason. func _end_round(winner_team: int, reason: String) -> void: _set_state(RoundState.POST_ROUND) # Update score if winner_team == 1: team_a_score += 1 elif winner_team == 2: team_b_score += 1 _post_round_timer = DEFAULT_POST_ROUND_DELAY _rpc_round_ended.rpc(winner_team, reason, team_a_score, team_b_score) _rpc_score_changed.rpc(team_a_score, team_b_score) var winner_name: String = "Draw" if winner_team == 1: winner_name = "Team A" elif winner_team == 2: winner_name = "Team B" print("[RoundManager] Round %d ended: %s wins (%s). Score: A=%d, B=%d" % [ round_number, winner_name, reason, team_a_score, team_b_score ]) # Check match end if _check_match_end(): return # Schedule next round via post-round timer (handled in _process) # Actually we handle this in the state machine below — no need for separate logic ## Check if a team has reached the win limit. Ends the match if so. ## Returns true if match ended. func _check_match_end() -> bool: if _win_limit <= 0: return false # No win limit = infinite play if team_a_score >= _win_limit: _end_match(1) return true elif team_b_score >= _win_limit: _end_match(2) return true return false ## End the match with the given winning team. func _end_match(winner_team: int) -> void: _set_state(RoundState.MATCH_END) _rpc_match_ended.rpc(winner_team, team_a_score, team_b_score) var winner: String = "Team A" if winner_team == 1 else "Team B" print("[RoundManager] MATCH OVER! %s wins %d-%d" % [winner, team_a_score, team_b_score]) ## Reset all per-match state (keeps the round manager alive for a new match). func reset_for_new_match() -> void: _reset_all_state() _set_state(RoundState.WAITING_FOR_PLAYERS) print("[RoundManager] Reset for new match") # --------------------------------------------------------------------------- # Per-frame tick # --------------------------------------------------------------------------- func _process(delta: float) -> void: match round_state: RoundState.WARMUP: _warmup_timer -= delta if _warmup_timer <= 0.0: _warmup_timer = 0.0 _begin_round() RoundState.LIVE: _elapsed_seconds += delta _round_timer -= delta # Time limit reached — end round if _round_timer <= 0.0 and round_time_seconds > 0: _round_timer = 0.0 _handle_round_time_expiry() RoundState.POST_ROUND: _post_round_timer -= delta if _post_round_timer <= 0.0: _post_round_timer = 0.0 # Check if match ended (already checked in _end_round, but safeguard) if _check_match_end(): return # Start next round _begin_warmup() ## Handle round time expiry — determine winner based on alive count. func _handle_round_time_expiry() -> void: var alive_a: int = get_alive_count(0) var alive_b: int = get_alive_count(1) if alive_a > alive_b: _end_round(1, "time_limit") elif alive_b > alive_a: _end_round(2, "time_limit") else: # Equal alive count or both zero — stalemate _end_round(0, "time_limit_draw") # --------------------------------------------------------------------------- # State management # --------------------------------------------------------------------------- ## Set the round state and emit the change signal. func _set_state(new_state: int) -> void: if round_state != new_state: _rpc_round_state.rpc(new_state) ## Reset all per-round state (scores, timers, player states). func _reset_all_state() -> void: round_number = 0 team_a_score = 0 team_b_score = 0 _warmup_timer = 0.0 _round_timer = 0.0 _post_round_timer = 0.0 _elapsed_seconds = 0.0 # Reset player states (but keep registrations) for pid in players: var state = players[pid] state["alive"] = false state["spectating"] = false state["spectate_target"] = 0 # --------------------------------------------------------------------------- # Public query API # --------------------------------------------------------------------------- ## Get the current round time remaining in seconds (0 if not LIVE). func get_time_remaining() -> float: if round_state == RoundState.LIVE: return max(_round_timer, 0.0) return 0.0 ## Get the elapsed round time in seconds. func get_elapsed_time() -> float: return _elapsed_seconds ## Get the formatted score string. func get_score_string() -> String: return "%d - %d" % [team_a_score, team_b_score] ## Return a dictionary of current round state for network broadcast / RCON status. func get_round_state_snapshot() -> Dictionary: var state_name: String match round_state: RoundState.INACTIVE: state_name = "inactive" RoundState.WAITING_FOR_PLAYERS: state_name = "waiting" RoundState.WARMUP: state_name = "warmup" RoundState.LIVE: state_name = "live" RoundState.POST_ROUND: state_name = "post_round" RoundState.MATCH_END: state_name = "match_end" _: state_name = "unknown" return { "state": state_name, "round_number": round_number, "team_a_score": team_a_score, "team_b_score": team_b_score, "win_limit": _win_limit, "time_remaining": get_time_remaining(), "elapsed": _elapsed_seconds, "alive_a": get_alive_count(0), "alive_b": get_alive_count(1), "total_players": get_player_count(), } ## Return the current state as a human-readable string. func get_state_name() -> String: match round_state: RoundState.INACTIVE: return "inactive" RoundState.WAITING_FOR_PLAYERS: return "waiting_for_players" RoundState.WARMUP: return "warmup" RoundState.LIVE: return "live" RoundState.POST_ROUND: return "post_round" RoundState.MATCH_END: return "match_end" _: return "unknown"