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
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 <message> Broadcast chat to all players",
|
||||
" msg <player> <message> Send private message",
|
||||
" changelevel <map_name> Load a new map",
|
||||
" start_match Start the match (exit warmup)",
|
||||
" end_round <team> [reason] Force-end the current round",
|
||||
" kick <player> [reason] Remove a player",
|
||||
" ban <player> [duration] Ban a player from the server",
|
||||
" unban <player_or_steamid> Remove a ban",
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user