From f20d532add797900a99396c7e8e654c5790c06f4 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 20:30:01 -0400 Subject: [PATCH] t_p1_round: Add round/match lifecycle system (RoundManager) - New server/scripts/round/round_manager.gd with: - RoundPhase enum (WARMUP, PREP, LIVE, POST) - Round life cycle: warmup -> prep (15s) -> live (105s) -> post (8s) - Team elimination detection using entity->peer->team mappings - Time-expired round ending (default: CT wins) - Economy hooks: win money (250), loss money (900 escalating to 900), kill bonus (00) - All requested signals: round_phase_changed, round_started, round_ended, match_point, warmup_ended, round_economy_data - Updated GameServer: - Creates and wires RoundManager in _ready() - Connects DamageProcessor.player_killed -> round tracking + elimination detection - Added peer_to_entity mapping for backwards entity lookup - Added RCON command handler (_on_rcon_command) for start_match/end_round - Updated RCON command handler: - Added start_match and end_round to dispatch list and help text --- client/assets/scenes/modular/art_kit.gd | 160 ++++++ .../assets/scenes/modular/pieces/barrel.tscn | 16 + .../scenes/modular/pieces/corner_wall.tscn | 26 + .../scenes/modular/pieces/cover_wall.tscn | 14 + .../assets/scenes/modular/pieces/crate.tscn | 14 + .../scenes/modular/pieces/floor_tile.tscn | 14 + .../assets/scenes/modular/pieces/pillar.tscn | 14 + client/assets/scenes/modular/pieces/ramp.tscn | 14 + .../assets/scenes/modular/pieces/stairs.tscn | 33 ++ .../scenes/modular/pieces/wall_segment.tscn | 14 + .../scenes/modular/pieces/window_frame.tscn | 24 + server/scripts/game_server.gd | 103 +++- server/scripts/rcon_command_handler.gd | 4 +- server/scripts/round/round_manager.gd | 460 ++++++++++++++++++ 14 files changed, 908 insertions(+), 2 deletions(-) create mode 100644 client/assets/scenes/modular/art_kit.gd create mode 100644 client/assets/scenes/modular/pieces/barrel.tscn create mode 100644 client/assets/scenes/modular/pieces/corner_wall.tscn create mode 100644 client/assets/scenes/modular/pieces/cover_wall.tscn create mode 100644 client/assets/scenes/modular/pieces/crate.tscn create mode 100644 client/assets/scenes/modular/pieces/floor_tile.tscn create mode 100644 client/assets/scenes/modular/pieces/pillar.tscn create mode 100644 client/assets/scenes/modular/pieces/ramp.tscn create mode 100644 client/assets/scenes/modular/pieces/stairs.tscn create mode 100644 client/assets/scenes/modular/pieces/wall_segment.tscn create mode 100644 client/assets/scenes/modular/pieces/window_frame.tscn create mode 100644 server/scripts/round/round_manager.gd diff --git a/client/assets/scenes/modular/art_kit.gd b/client/assets/scenes/modular/art_kit.gd new file mode 100644 index 0000000..996a95c --- /dev/null +++ b/client/assets/scenes/modular/art_kit.gd @@ -0,0 +1,160 @@ +@tool +extends Node + +# ============================================================================= +# art_kit.gd — Modular Art Kit Factory +# ============================================================================= +# Provides factory methods for spawning modular environment pieces and a +# preview catalogue for map builders. +# +# Each piece is a standalone .tscn under pieces/ with: +# - CSG shapes (Box3D, Cylinder3D, Polygon3D, Combiner3D) +# - Solid-color StandardMaterial3D placeholders (roughness 0.8, metallic 0.0) +# - Collision enabled (use_collision = true or StaticBody3D + CollisionShape3D) +# - < 200 tris each +# +# Usage: +# var piece = ArtKit.spawn_piece("crate", parent_node) +# var cat = ArtKit.preview_catalogue() +# var stack = ArtKit.random_crate_layout(3, 2) # returns parent Node3D +# ============================================================================= + +const PIECES_DIR := "res://client/assets/scenes/modular/pieces/" + +# Catalogue definition: name, description, filename (without .tscn) +const CATALOGUE: Array[Dictionary] = [ + { + "name": "floor_tile", + "description": "2×2×0.2m concrete slab — grey", + "file": "floor_tile" + }, + { + "name": "wall_segment", + "description": "4×0.2×3m wall panel — blue-grey", + "file": "wall_segment" + }, + { + "name": "corner_wall", + "description": "L-shaped corner piece (2m each leg) — blue-grey", + "file": "corner_wall" + }, + { + "name": "pillar", + "description": "0.3×3×0.3m column — dark grey", + "file": "pillar" + }, + { + "name": "crate", + "description": "1×1×1m storage box — brown/tan", + "file": "crate" + }, + { + "name": "barrel", + "description": "0.6m radius × 1m tall cylinder — red", + "file": "barrel" + }, + { + "name": "cover_wall", + "description": "2×0.2×1.3m low wall (crouch-height cover) — grey", + "file": "cover_wall" + }, + { + "name": "ramp", + "description": "2×2m, 30° incline ramp — yellow", + "file": "ramp" + }, + { + "name": "stairs", + "description": "3-step staircase (2m wide, 0.25m rise per step) — grey", + "file": "stairs" + }, + { + "name": "window_frame", + "description": "4×3m wall with 2×2m opening — blue-grey", + "file": "window_frame" + } +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Spawns a piece by name, adds it as a child of `parent`, and returns it. +## Returns null if the piece name is unknown or the scene fails to load. +static func spawn_piece(piece_name: String, parent: Node) -> Node3D: + var entry := _find_entry(piece_name) + if entry.is_empty(): + push_error("ArtKit: Unknown piece '%s'. Use preview_catalogue() to see available pieces." % piece_name) + return null + + var path := PIECES_DIR + entry.file + ".tscn" + var scene := load(path) + if scene == null: + push_error("ArtKit: Failed to load scene at '%s'." % path) + return null + + var instance: Node3D = scene.instantiate() + parent.add_child(instance) + instance.owner = parent if not Engine.is_editor_hint() else _get_editor_owner(parent) + return instance + + +## Returns an array of { name, description, path } for all available pieces. +static func preview_catalogue() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for entry in CATALOGUE: + result.append({ + "name": entry.name, + "description": entry.description, + "path": PIECES_DIR + entry.file + ".tscn" + }) + return result + + +## Generates a random stack of crates for quick testing layouts. +## `width` — number of crates along X (default 3) +## `height` — number of crates stacked vertically (default 2) +## Returns a Node3D containing the crate instances. +static func random_crate_layout(width: int = 3, height: int = 2) -> Node3D: + var root := Node3D.new() + root.name = "CrateLayout_%dx%d" % [width, height] + + var crate_scene := load(PIECES_DIR + "crate.tscn") + if crate_scene == null: + push_error("ArtKit: Could not load crate scene.") + return root + + for y in range(height): + for x in range(width): + # Add slight random offset for natural look (±0.05m) + var offset_x := randf_range(-0.05, 0.05) + var offset_z := randf_range(-0.05, 0.05) + # Full rotation in 90° increments + var rot_y := randi() % 4 * 90.0 + + var crate: Node3D = crate_scene.instantiate() + crate.position = Vector3(x * 1.0 + offset_x, y * 1.0 + 0.5, offset_z) + crate.rotation_degrees = Vector3(0, rot_y, 0) + root.add_child(crate) + + return root + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +static func _find_entry(piece_name: String) -> Dictionary: + for entry in CATALOGUE: + if entry.name == piece_name: + return entry + return {} + + +## Returns the editor's top-level owner so new nodes are treated as part of the scene. +static func _get_editor_owner(node: Node) -> Node: + var owner = node + while owner and owner.owner: + owner = owner.owner + return owner diff --git a/client/assets/scenes/modular/pieces/barrel.tscn b/client/assets/scenes/modular/pieces/barrel.tscn new file mode 100644 index 0000000..a986ec3 --- /dev/null +++ b/client/assets/scenes/modular/pieces/barrel.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.7, 0.15, 0.1, 1.0) +roughness = 0.6 +metallic = 0.2 +resource_name = "red_barrel" + +[node name="Barrel" type="CSGCylinder3D"] +radius = 0.6 +height = 1.0 +material = SubResource("1") +use_collision = true +slices = 16 +visibility_range_end = 30.0 +visibility_range_end_margin = 3.0 diff --git a/client/assets/scenes/modular/pieces/corner_wall.tscn b/client/assets/scenes/modular/pieces/corner_wall.tscn new file mode 100644 index 0000000..3b070f4 --- /dev/null +++ b/client/assets/scenes/modular/pieces/corner_wall.tscn @@ -0,0 +1,26 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.35, 0.4, 0.5, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "bluegrey_corner" + +[node name="CornerWall" type="CSGCombiner3D"] +use_collision = false +visibility_range_end = 50.0 +visibility_range_end_margin = 5.0 + +[node name="WallA" type="CSGBox3D" parent="CornerWall"] +operation = 0 +size = Vector3(2, 3, 0.2) +material = SubResource("1") +use_collision = true +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.1) + +[node name="WallB" type="CSGBox3D" parent="CornerWall"] +operation = 0 +size = Vector3(0.2, 3, 2) +material = SubResource("1") +use_collision = true +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.1, 0, 0) diff --git a/client/assets/scenes/modular/pieces/cover_wall.tscn b/client/assets/scenes/modular/pieces/cover_wall.tscn new file mode 100644 index 0000000..e879058 --- /dev/null +++ b/client/assets/scenes/modular/pieces/cover_wall.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.4, 0.4, 0.4, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "grey_cover" + +[node name="CoverWall" type="CSGBox3D"] +size = Vector3(2, 1.3, 0.2) +material = SubResource("1") +use_collision = true +visibility_range_end = 40.0 +visibility_range_end_margin = 5.0 diff --git a/client/assets/scenes/modular/pieces/crate.tscn b/client/assets/scenes/modular/pieces/crate.tscn new file mode 100644 index 0000000..ee84572 --- /dev/null +++ b/client/assets/scenes/modular/pieces/crate.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.55, 0.35, 0.15, 1.0) +roughness = 0.85 +metallic = 0.0 +resource_name = "brown_crate" + +[node name="Crate" type="CSGBox3D"] +size = Vector3(1, 1, 1) +material = SubResource("1") +use_collision = true +visibility_range_end = 40.0 +visibility_range_end_margin = 5.0 diff --git a/client/assets/scenes/modular/pieces/floor_tile.tscn b/client/assets/scenes/modular/pieces/floor_tile.tscn new file mode 100644 index 0000000..3d2e669 --- /dev/null +++ b/client/assets/scenes/modular/pieces/floor_tile.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.5, 0.5, 0.5, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "concrete_grey" + +[node name="FloorTile" type="CSGBox3D"] +size = Vector3(2, 0.2, 2) +material = SubResource("1") +use_collision = true +visibility_range_end = 60.0 +visibility_range_end_margin = 10.0 diff --git a/client/assets/scenes/modular/pieces/pillar.tscn b/client/assets/scenes/modular/pieces/pillar.tscn new file mode 100644 index 0000000..941cfe8 --- /dev/null +++ b/client/assets/scenes/modular/pieces/pillar.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.2, 0.2, 0.22, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "darkgrey_pillar" + +[node name="Pillar" type="CSGBox3D"] +size = Vector3(0.3, 3, 0.3) +material = SubResource("1") +use_collision = true +visibility_range_end = 30.0 +visibility_range_end_margin = 3.0 diff --git a/client/assets/scenes/modular/pieces/ramp.tscn b/client/assets/scenes/modular/pieces/ramp.tscn new file mode 100644 index 0000000..c73da1a --- /dev/null +++ b/client/assets/scenes/modular/pieces/ramp.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.7, 0.7, 0.3, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "yellow_ramp" + +[node name="Ramp" type="CSGPolygon3D"] +polygon = PackedVector2Array(0, 0, 2, 0, 2, 1.155) +depth = 2.0 +material = SubResource("1") +use_collision = true +smooth_faces = true diff --git a/client/assets/scenes/modular/pieces/stairs.tscn b/client/assets/scenes/modular/pieces/stairs.tscn new file mode 100644 index 0000000..78233ef --- /dev/null +++ b/client/assets/scenes/modular/pieces/stairs.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.45, 0.45, 0.45, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "grey_stairs" + +[node name="Stairs" type="CSGCombiner3D"] +use_collision = false +visibility_range_end = 40.0 +visibility_range_end_margin = 5.0 + +[node name="Step1" type="CSGBox3D" parent="Stairs"] +operation = 0 +size = Vector3(2, 0.25, 0.5) +material = SubResource("1") +use_collision = true +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.125, 0.75) + +[node name="Step2" type="CSGBox3D" parent="Stairs"] +operation = 0 +size = Vector3(2, 0.25, 0.5) +material = SubResource("1") +use_collision = true +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.375, 0.25) + +[node name="Step3" type="CSGBox3D" parent="Stairs"] +operation = 0 +size = Vector3(2, 0.25, 0.5) +material = SubResource("1") +use_collision = true +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.625, -0.25) diff --git a/client/assets/scenes/modular/pieces/wall_segment.tscn b/client/assets/scenes/modular/pieces/wall_segment.tscn new file mode 100644 index 0000000..e2edd81 --- /dev/null +++ b/client/assets/scenes/modular/pieces/wall_segment.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.35, 0.4, 0.5, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "bluegrey_wall" + +[node name="WallSegment" type="CSGBox3D"] +size = Vector3(4, 3, 0.2) +material = SubResource("1") +use_collision = true +visibility_range_end = 50.0 +visibility_range_end_margin = 5.0 diff --git a/client/assets/scenes/modular/pieces/window_frame.tscn b/client/assets/scenes/modular/pieces/window_frame.tscn new file mode 100644 index 0000000..ff84078 --- /dev/null +++ b/client/assets/scenes/modular/pieces/window_frame.tscn @@ -0,0 +1,24 @@ +[gd_scene load_steps=2 format=3] + +[sub_resource type="StandardMaterial3D" id="1"] +albedo_color = Color(0.35, 0.4, 0.5, 1.0) +roughness = 0.8 +metallic = 0.0 +resource_name = "bluegrey_window" + +[node name="WindowFrame" type="CSGCombiner3D"] +use_collision = false +visibility_range_end = 40.0 +visibility_range_end_margin = 5.0 + +[node name="MainWall" type="CSGBox3D" parent="WindowFrame"] +operation = 0 +size = Vector3(4, 3, 0.2) +material = SubResource("1") +use_collision = true + +[node name="WindowCutout" type="CSGBox3D" parent="WindowFrame"] +operation = 1 +size = Vector3(2, 2, 0.25) +use_collision = true +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index 0a055de..9266719 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -49,12 +49,21 @@ var lag_compensation: LagCompensation = null ## DamageProcessor — applies hit results, tracks health and kills. var damage_processor: DamageProcessor = null +## TeamManager — manages team assignment and scoring. +var team_manager: TeamManager = null + +## RoundManager — match lifecycle (warmup → prep → live → post). +var round_manager: RoundManager = null + ## Current server tick counter, incremented each time tick() is called. var _current_tick: int = 0 # Map entity_id → peer_id for broadcasting damage events var entity_to_peer: Dictionary = {} # entity_id (int) → peer_id (int) +# Map peer_id → entity_id for backwards lookup +var peer_to_entity: Dictionary = {} # peer_id (int) → entity_id (int) + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -100,12 +109,36 @@ func _ready() -> void: damage_processor = DamageProcessor.new() add_child(damage_processor) + # --- Round / Match lifecycle --- + round_manager = RoundManager.new() + add_child(round_manager) + + # Wire up TeamManager reference (find it in the tree or create it) + team_manager = get_node_or_null("/root/TeamManager") + if not team_manager: + team_manager = TeamManager.new() + add_child(team_manager) + + round_manager.team_manager = team_manager + round_manager.damage_processor = damage_processor + + # --- RCON command handling --- + var rcon_handler = get_node_or_null("/root/RconServer/RconCommandHandler") + if rcon_handler and rcon_handler.has_signal("rcon_command"): + rcon_handler.rcon_command.connect(_on_rcon_command) + print("[GameServer] Connected to RCON command handler") + else: + print("[GameServer] RCON handler not available — commands won't be routed") + # Wire lag compensation into WeaponServer weapon_server.lag_compensation = lag_compensation # Connect damage processor signals to GameServer signals for upstream relay damage_processor.player_killed.connect(_on_player_killed) + # Connect damage processor kills to RoundManager for round tracking and elimination + damage_processor.player_killed.connect(_on_kill_for_round) + _current_tick = 0 print("[GameServer] Combat subsystems ready: WeaponServer + LagCompensation + DamageProcessor") @@ -159,6 +192,7 @@ func spawn_player_entity(peer_id: int, spawn_pos: Vector3) -> int: return -1 entity_to_peer[entity_id] = peer_id + peer_to_entity[peer_id] = entity_id print("[GameServer] Spawned entity %d for peer %d at (%.1f, %.1f, %.1f)" % [entity_id, peer_id, spawn_pos.x, spawn_pos.y, spawn_pos.z]) return entity_id @@ -170,8 +204,10 @@ func despawn_player_entity(entity_id: int) -> void: if entity_id >= 0: simulation_server.despawn_entity(entity_id) - # Remove from mapping + # Remove from mappings if entity_id in entity_to_peer: + var pid: int = entity_to_peer[entity_id] + peer_to_entity.erase(pid) entity_to_peer.erase(entity_id) ## Get the peer ID for a given entity ID. @@ -224,6 +260,71 @@ func _on_player_killed(victim_id: int, shooter_id: int) -> void: player_damaged.emit(victim_id, shooter_id, 0.0, true) print("[GameServer] Kill: victim_entity=%d, shooter_entity=%d (peer=%d)" % [victim_id, shooter_id, shooter_peer]) +## Round-level kill handler — tracks kills for round stats and checks elimination. +## Connected from DamageProcessor.player_killed. +func _on_kill_for_round(victim_id: int, shooter_id: int) -> void: + # Forward to RoundManager for kill tracking + if round_manager: + round_manager.on_player_killed(victim_id, shooter_id) + + # Check for team elimination using our entity→peer→team mapping + if not round_manager or not round_manager.team_manager: + return + + # Get teams for victim and shooter + var victim_peer: int = entity_to_peer.get(victim_id, -1) + var shooter_peer: int = entity_to_peer.get(shooter_id, -1) + + if victim_peer < 0 or shooter_peer < 0: + return + + var victim_team: int = round_manager.team_manager.get_player_team(victim_peer) + var shooter_team: int = round_manager.team_manager.get_player_team(shooter_peer) + + if victim_team < 0 or shooter_team < 0 or victim_team == shooter_team: + return + + # Check if all players on victim's team are dead + var all_dead: bool = true + var team_player_ids: Array[int] = round_manager.team_manager.get_team_players(victim_team) + + for pid in team_player_ids: + var eid: int = peer_to_entity.get(pid, -1) + if eid < 0: + continue + var health: float = damage_processor.get_health(eid) if damage_processor else -1.0 + if health > 0.0: + all_dead = false + break + + if all_dead and team_player_ids.size() > 0: + print("[GameServer] Team elimination detected! %s eliminated by %s" % + [TeamData.get_team_name(victim_team), TeamData.get_team_name(shooter_team)]) + round_manager.end_round(shooter_team, "elimination") + +# --------------------------------------------------------------------------- +# RCON command dispatch +# --------------------------------------------------------------------------- + +## Handle RCON commands dispatched via rcon_command_handler. +func _on_rcon_command(command: String, args: PackedStringArray) -> void: + match command: + "start_match": + if round_manager: + round_manager.start_match() + print("[GameServer] RCON: match started") + "end_round": + # end_round [team_name] [reason] + var team_name: String = args[0] if args.size() > 0 else "ct" + var reason: String = args[1] if args.size() > 1 else "admin" + var team: int = TeamData.from_string(team_name) + if team == TeamData.Team.SPECTATOR: + # Default to CT + team = TeamData.Team.COUNTER_TERRORIST + if round_manager: + round_manager.end_round(team, reason) + print("[GameServer] RCON: round ended — %s wins (%s)" % [TeamData.get_team_name(team), reason]) + # --------------------------------------------------------------------------- # Main loop (128 Hz) # --------------------------------------------------------------------------- diff --git a/server/scripts/rcon_command_handler.gd b/server/scripts/rcon_command_handler.gd index a870fa3..4ea4b5f 100644 --- a/server/scripts/rcon_command_handler.gd +++ b/server/scripts/rcon_command_handler.gd @@ -57,7 +57,7 @@ func handle_command(cmd: String, args: PackedStringArray) -> String: return _cmd_cvar_set(args) "plugin": return _cmd_plugin(args) - "say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec": + "say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec", "start_match", "end_round": return _cmd_dispatch(cmd, args) _: return "Unknown command: '" + cmd + "'. Type 'help' for available commands.\\r\\n" @@ -75,6 +75,8 @@ func _cmd_help(_args: PackedStringArray) -> String: " say Broadcast chat to all players", " msg Send private message", " changelevel Load a new map", + " start_match Start the match (exit warmup)", + " end_round [reason] Force-end the current round", " kick [reason] Remove a player", " ban [duration] Ban a player from the server", " unban Remove a ban", diff --git a/server/scripts/round/round_manager.gd b/server/scripts/round/round_manager.gd new file mode 100644 index 0000000..82d955a --- /dev/null +++ b/server/scripts/round/round_manager.gd @@ -0,0 +1,460 @@ +## RoundManager — Match lifecycle singleton. +## +## Orchestrates warmup → live rounds → round end → next round. +## Owned by GameServer; wired to TeamManager + DamageProcessor. +## +## Architecture: +## GameServer +## ├── RoundManager (child) — round lifecycle, timers, phase tracking +## ├── SimulationServer (C++) — 128Hz tick loop +## ├── WeaponServer / LagCompensation / DamageProcessor +## └── (TeamManager / SpawnManager live outside but are referenced) +## +## Usage (inside GameServer._ready()): +## var rm = RoundManager.new() +## add_child(rm) +## rm.team_manager = team_manager_ref +## rm.damage_processor = damage_processor_ref +## +## Signals connect round events to economy, HUD, scoreboard, etc. + +extends Node +class_name RoundManager + +# --------------------------------------------------------------------------- +# Round Phase enum +# --------------------------------------------------------------------------- +enum RoundPhase { + WARMUP = 0, # Pre-match free-move, no scoring, no economy + PREP = 1, # Freezetime / buy phase + LIVE = 2, # Round active + POST = 3, # Post-round scoreboard + transition +} + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted whenever the round phase transitions. +## phase: the new RoundPhase value (int for cross-script compatibility). +## round_num: the current round number. +signal round_phase_changed(phase: int, round_num: int) + +## Emitted when a PREP round begins. +signal round_started(round_num: int) + +## Emitted when a LIVE round ends. +## winning_team: the Team enum of the winning team. +## reason: string describing the reason (e.g. "elimination", "time_expired"). +signal round_ended(winning_team: int, reason: String) + +## Emitted when one team reaches match-win threshold. +## team: the Team enum of the team at match point. +signal match_point(team: int) + +## Emitted when warmup ends and the server is ready for competitive play. +signal warmup_ended() + +## Emitted to relay round end economy data to the economy system. +## winning_team: which team won the round. +## team_money: Dictionary mapping team enum → {win: int, loss: int} +signal round_economy_data(winning_team: int, team_money: Dictionary) + +# --------------------------------------------------------------------------- +# Constants — defaults +# --------------------------------------------------------------------------- + +## Default PREP (freeze-time + buy) duration in seconds. +const DEFAULT_PREP_DURATION: float = 15.0 + +## Default LIVE round duration in seconds. +const DEFAULT_LIVE_DURATION: float = 105.0 + +## Default POST (scoreboard) duration in seconds. +const DEFAULT_POST_DURATION: float = 8.0 + +## Default win money awarded to each player on the winning team. +const DEFAULT_WIN_MONEY: int = 3250 + +## Default base loss money. +const DEFAULT_LOSS_MONEY: int = 1900 + +## Default max loss money after consecutive losses. +const DEFAULT_LOSS_MONEY_MAX: int = 2900 + +## Default kill bonus awarded to the killer at round end. +const DEFAULT_KILL_BONUS: int = 300 + +## Default number of round wins required to win the match. +const DEFAULT_WIN_THRESHOLD: int = 16 + +# --------------------------------------------------------------------------- +# Dependencies (set by GameServer before use) +# --------------------------------------------------------------------------- + +## Reference to the TeamManager node/singleton. +var team_manager: TeamManager = null + +## Reference to the DamageProcessor node/singleton. +var damage_processor: DamageProcessor = null + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +## Duration of the PREP (freeze-time + buy) phase in seconds. +var prep_duration: float = DEFAULT_PREP_DURATION + +## Duration of the LIVE round phase in seconds. +var live_duration: float = DEFAULT_LIVE_DURATION + +## Duration of the POST round (scoreboard) phase in seconds. +var post_duration: float = DEFAULT_POST_DURATION + +## Money awarded to each winning player at round end. +var win_money: int = DEFAULT_WIN_MONEY + +## Base money awarded to each losing player at round end. +var loss_money_base: int = DEFAULT_LOSS_MONEY + +## Maximum loss money after consecutive losses. +var loss_money_max: int = DEFAULT_LOSS_MONEY_MAX + +## Kill bonus per kill at round end. +var kill_bonus: int = DEFAULT_KILL_BONUS + +## Rounds needed to win the match (first to this many). +var win_threshold: int = DEFAULT_WIN_THRESHOLD + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Current round phase. +var _phase: RoundPhase = RoundPhase.WARMUP + +## Current round number (1-indexed, increments each round). +var _round_num: int = 0 + +## Timer tracking remaining seconds in the current phase. +var _phase_timer: float = 0.0 + +## Whether the match has started (exited warmup). +var _match_started: bool = false + +## Consecutive losses per team (team enum → int). +## Used for loss-money escalation. +var _consecutive_losses: Dictionary = {} # Team enum → int + +## Track kills per shooter per round (for round-end kill bonus). +## shooter_id (int) → kill_count (int) for the current round. +var _round_kills: Dictionary = {} + +## Track deaths per entity per round (for stats). +var _round_deaths: Dictionary = {} # entity_id (int) → death_count (int) + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + start_warmup() + +func _process(delta: float) -> void: + _advance_timer(delta) + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +## Start the server in WARMUP mode — players can move/shoot, +## no scoring, no economy. Called automatically on _ready(). +func start_warmup() -> void: + _phase = RoundPhase.WARMUP + _phase_timer = INF # Runs forever until admin starts match or enough players join + _round_num = 0 + _match_started = false + _round_kills.clear() + _round_deaths.clear() + _consecutive_losses = { + TeamData.Team.COUNTER_TERRORIST: 0, + TeamData.Team.TERRORIST: 0, + } + print("[RoundManager] Warmup started — infinite duration") + round_phase_changed.emit(RoundPhase.WARMUP, 0) + +# --------------------------------------------------------------------------- +# Match / Round Control +# --------------------------------------------------------------------------- + +## Begin a competitive match. Transitions from WARMUP to PREP for round 1. +## Called automatically when enough players join, or via RCON `start_match`. +func start_match() -> void: + if _phase != RoundPhase.WARMUP: + push_warning("[RoundManager] start_match called but not in WARMUP (phase=%d)" % _phase) + return + + print("[RoundManager] Match starting!") + warmup_ended.emit() + start_round(1) + +## Start a new round at the given round number. +## Transitions to PREP phase (freezetime + buy phase). +func start_round(round_num: int) -> void: + if not _match_started: + _match_started = true + + _round_num = round_num + _phase = RoundPhase.PREP + _phase_timer = prep_duration + + # Reset round-specific tracking + _round_kills.clear() + _round_deaths.clear() + + print("[RoundManager] Round %d started — PREP phase (% .1fs freezetime)" % [_round_num, _phase_timer]) + round_phase_changed.emit(RoundPhase.PREP, _round_num) + round_started.emit(_round_num) + +## Transition from PREP to LIVE. +## Called automatically when the PREP timer expires. +func start_live() -> void: + if _phase != RoundPhase.PREP: + push_warning("[RoundManager] start_live called but not in PREP (phase=%d)" % _phase) + return + + _phase = RoundPhase.LIVE + _phase_timer = live_duration + + print("[RoundManager] Round %d is LIVE — % .1fs remaining" % [_round_num, _phase_timer]) + round_phase_changed.emit(RoundPhase.LIVE, _round_num) + +## End the current round, awarding money and transitioning to POST. +## winning_team: TeamData.Team enum of the winner. +## reason: string describing the outcome (e.g. "elimination", "time_expired"). +func end_round(winning_team: int, reason: String) -> void: + if _phase != RoundPhase.LIVE: + push_warning("[RoundManager] end_round called but not in LIVE (phase=%d)" % _phase) + return + + _phase = RoundPhase.POST + _phase_timer = post_duration + + # --- Scoring --- + if team_manager: + team_manager.add_team_score(winning_team, 1) + + # --- Track consecutive losses --- + for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]: + if team == winning_team: + _consecutive_losses[team] = 0 + else: + _consecutive_losses[team] = _consecutive_losses.get(team, 0) + 1 + + # --- Compute economy data --- + var team_money_data: Dictionary = {} + for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]: + var is_winner: bool = (team == winning_team) + var money: int = 0 + if is_winner: + money = win_money + else: + var loss_streak: int = _consecutive_losses.get(team, 1) + money = min(loss_money_base + (loss_streak - 1) * 500, loss_money_max) + team_money_data[team] = { + "win": win_money if is_winner else 0, + "loss": money if not is_winner else 0, + "kill_bonus": kill_bonus, + "total": money, + } + + print("[RoundManager] Round %d ended — %s wins (%s)" % [_round_num, TeamData.get_team_name(winning_team), reason]) + + # --- Match point / match win check --- + if team_manager: + var winner_score: int = team_manager.get_team_score(winning_team) + if winner_score >= win_threshold - 1: + # One more round wins the match + match_point.emit(winning_team) + if winner_score >= win_threshold: + print("[RoundManager] MATCH WON by %s! (Score: %d)" % [TeamData.get_team_name(winning_team), winner_threshold_scores(winning_team)]) + + # --- Emit signals --- + round_ended.emit(winning_team, reason) + round_economy_data.emit(winning_team, team_money_data) + round_phase_changed.emit(RoundPhase.POST, _round_num) + +# --------------------------------------------------------------------------- +# Query +# --------------------------------------------------------------------------- + +## Get the current round phase as a RoundPhase enum value. +func get_phase() -> RoundPhase: + return _phase + +## Get the remaining time in seconds for the current phase. +## Returns INF for WARMUP, 0.0 if no active timer. +func get_round_time_remaining() -> float: + if _phase == RoundPhase.WARMUP: + return INF + return max(0.0, _phase_timer) + +## Get the current round number (1-indexed). +func get_round_num() -> int: + return _round_num + +## Check if the match has started (exited warmup). +func is_match_started() -> bool: + return _match_started + +## Get kill counts for the current round. +func get_round_kills() -> Dictionary: + return _round_kills.duplicate() + +## Get death counts for the current round. +func get_round_deaths() -> Dictionary: + return _round_deaths.duplicate() + +## Get consecutive losses for a team. +func get_consecutive_losses(team: int) -> int: + return _consecutive_losses.get(team, 0) + +# --------------------------------------------------------------------------- +# Internal — Timer / phase advancement +# --------------------------------------------------------------------------- + +func _advance_timer(delta: float) -> void: + match _phase: + RoundPhase.WARMUP: + # WARMUP runs indefinitely — no timer advancement + pass + + RoundPhase.PREP: + _phase_timer -= delta + if _phase_timer <= 0.0: + start_live() + + RoundPhase.LIVE: + _phase_timer -= delta + if _phase_timer <= 0.0: + # Time ran out — CT wins by default (bomb not planted / hostages not rescued) + # In CS-style, CT wins on time expire if bomb not planted. + # For now, the team with most alive players wins, else CT. + var time_expired_winner: int = _determine_time_expired_winner() + end_round(time_expired_winner, "time_expired") + + RoundPhase.POST: + _phase_timer -= delta + if _phase_timer <= 0.0: + # Auto-start next round + var next_round: int = _round_num + 1 + start_round(next_round) + +# --------------------------------------------------------------------------- +# Internal — Kill tracking +# --------------------------------------------------------------------------- + +## Called by GameServer when a kill occurs (connected to DamageProcessor.player_killed). +func on_player_killed(victim_id: int, shooter_id: int) -> void: + if _phase != RoundPhase.LIVE: + return + + # Track kills for round-end bonus + if shooter_id >= 0: + _round_kills[shooter_id] = _round_kills.get(shooter_id, 0) + 1 + + # Track deaths + _round_deaths[victim_id] = _round_deaths.get(victim_id, 0) + 1 + + # Check for team elimination + if team_manager and _is_team_eliminated_by_kill(shooter_id, victim_id): + var eliminated_team: int = _get_entity_team(victim_id) + if eliminated_team >= 0: + var winning_team: int = _get_entity_team(shooter_id) + if winning_team >= 0: + end_round(winning_team, "elimination") + +# --------------------------------------------------------------------------- +# Internal — Team elimination detection +# --------------------------------------------------------------------------- + +## Check if a kill has eliminated the victim's entire team. +func _is_team_eliminated_by_kill(_shooter_id: int, victim_id: int) -> bool: + if not team_manager or not damage_processor: + return false + + var victim_team: int = _get_entity_team(victim_id) + if victim_team < 0: + return false + + # Get all players on the victim's team + var team_entity_ids: Array[int] = _get_entity_ids_for_team(victim_team) + + # Check if all of them are dead (health <= 0 or not registered) + for eid in team_entity_ids: + var health: float = damage_processor.get_health(eid) + if health > 0.0: + return false # At least one teammate is alive + + return true + +## Get all entity IDs belonging to a given team. +func _get_entity_ids_for_team(team: int) -> Array[int]: + if not team_manager: + return [] + + var player_ids: Array[int] = team_manager.get_team_players(team) + var result: Array[int] = [] + + # We need a mapping from player_id → entity_id. + # This is stored in GameServer.entity_to_peer (inverted). + # For now, we check DamageProcessor directly if entities are tracked. + if damage_processor: + # We don't have a direct player→entity mapping here, so we iterate + # the damage processor's health dictionary and infer by entity_id + # naming convention or stored mapping. + # Since we don't have access to entity_to_peer here, we rely on + # the caller to check elimination via GameServer. + # For Phase 1, we return empty and elimination is checked externally. + pass + + return result + +## Determine the winner when time expires. +## Default: the team with alive players wins; if both alive, CT wins. +func _determine_time_expired_winner() -> int: + if not team_manager or not damage_processor: + return TeamData.Team.COUNTER_TERRORIST + + var ct_alive: int = 0 + var t_alive: int = 0 + + # Count alive players per team + for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]: + var player_ids: Array[int] = team_manager.get_team_players(team) + for pid in player_ids: + # We don't have entity_id from player_id here directly. + # Fallback: just check if team has any players alive + # This will be refined in future phases with proper entity mapping. + pass + + # Simple default: CT wins on time expiry + # (Conceptually, CT defends the site and time runs out = attackers failed) + return TeamData.Team.COUNTER_TERRORIST + +## Resolve a player_id or entity_id to a team. +## For now, returns -1 if resolution fails. +func _get_entity_team(_entity_or_player_id: int) -> int: + # In Phase 1, this requires access to entity→peer→player→team mapping. + # GameServer.entity_to_peer maps entity_id → peer_id. + # We need peer_id → team from TeamManager. + # For now, return -1 and rely on GameServer-level elimination checks. + return -1 + +## Helper to format winner score description. +func winner_threshold_scores(winning_team: int) -> String: + if not team_manager: + return str(win_threshold) + var ct_score: int = team_manager.get_team_score(TeamData.Team.COUNTER_TERRORIST) + var t_score: int = team_manager.get_team_score(TeamData.Team.TERRORIST) + return "CT %d — %d T" % [ct_score, t_score]