e7299b17e9
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
695 lines
23 KiB
GDScript
695 lines
23 KiB
GDScript
## 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"
|