Save workspace artifacts: character-controller, gdextension scaffold, network scripts, map-pipeline, server config

Includes code from task workspaces that was never pushed:
- client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd
- gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files)
- scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd
- scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README
- scripts/config/ — server_config.gd (480 lines)
- scenes/ — client_main, server_main, player, test_range
- project.godot, export_presets.cfg
This commit is contained in:
2026-07-01 18:30:44 -04:00
parent 2cf57a989f
commit e9dc05983c
42 changed files with 6249 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
## Client Main — Client entry point for testing.
##
## Connects to the server. Receives broadcast_spawn_player /
## broadcast_despawn_player RPCs from the server and creates
## visual player nodes locally so each client sees every other player.
##
## Phase 0: simple position replication via RPC + box mesh player nodes.
extends Node3D
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
@export var server_host: String = "127.0.0.1"
@export var server_port: int = 34197
@export var player_scene: PackedScene = preload("res://scenes/player.tscn")
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
# remote_players[peer_id] = Node3D — visual representation of other players
var remote_players: Dictionary = {}
var connected: bool = false
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Connect to server after a short delay
await get_tree().create_timer(0.5).timeout
_connect_to_server()
func _connect_to_server() -> void:
if OS.has_environment("SERVER_HOST"):
server_host = OS.get_environment("SERVER_HOST")
if OS.has_environment("SERVER_PORT"):
server_port = int(OS.get_environment("SERVER_PORT"))
print("[ClientMain] Connecting to %s:%d ..." % [server_host, server_port])
var err: Error = NetworkManager.join_server(server_host, server_port)
if err != OK:
push_error("[ClientMain] Connection failed: %s" % error_string(err))
await get_tree().create_timer(2.0).timeout
_connect_to_server()
return
# Setup camera and lights
_setup_scene()
connected = true
# Connect replication signals from NetworkManager
# These fire when the server broadcasts spawn_player / despawn_player
NetworkManager.remote_player_spawned.connect(_on_remote_player_spawned)
NetworkManager.remote_player_despawned.connect(_on_remote_player_despawned)
print("[ClientMain] Connected to server. Peer ID: %d" % multiplayer.get_unique_id())
func _setup_scene() -> void:
# Simple 3/4 top-down camera for Phase 0 testing
var camera := Camera3D.new()
camera.current = true
camera.position = Vector3(0, 16, 12)
camera.rotation_degrees.x = -55
add_child(camera)
# Ambient light so we can see the box meshes
var light := DirectionalLight3D.new()
light.light_energy = 1.0
light.shadow_enabled = true
light.position = Vector3(10, 20, 10)
light.look_at(Vector3.ZERO)
add_child(light)
# ---------------------------------------------------------------------------
# Player replication handlers (called when server broadcasts via RPC)
# ---------------------------------------------------------------------------
func _on_remote_player_spawned(peer_id: int, pos: Vector3) -> void:
if peer_id == multiplayer.get_unique_id():
# This is OUR player — the server has authority over it,
# but we don't need to create a duplicate. Our local player
# node is managed by the server's authority system.
# The player.gd script will handle input on this peer.
return
if peer_id in remote_players:
push_warning("[ClientMain] Remote player %d already exists, skipping" % peer_id)
return
# Create a remote player node for visualization
var player: Node3D
if player_scene:
player = player_scene.instantiate()
else:
player = Node3D.new()
player.set_script(preload("res://scripts/network/player.gd"))
player.name = "RemotePlayer_%d" % peer_id
player.set_multiplayer_authority(peer_id)
player.position = pos
add_child(player, true)
remote_players[peer_id] = player
print("[ClientMain] Spawned remote player %d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z])
func _on_remote_player_despawned(peer_id: int) -> void:
if peer_id == multiplayer.get_unique_id():
return
if peer_id in remote_players:
remote_players[peer_id].queue_free()
remote_players.erase(peer_id)
print("[ClientMain] Despawned remote player %d" % peer_id)
func _exit_tree() -> void:
if connected:
NetworkManager.stop()
# Clean up any remaining remote players
for p in remote_players.values():
p.queue_free()
remote_players.clear()
+1
View File
@@ -0,0 +1 @@
uid://blpy6rdyfy2b1
+168
View File
@@ -0,0 +1,168 @@
## NetworkManager — ENet Transport + Player Replication Singleton
##
## Autoload that wraps Godot 4's ENetMultiplayerPeer and provides
## player spawn/despawn broadcasting to all connected clients.
## Server creates RPC broadcasts; clients receive and emit signals.
##
## Architecture:
## server mode → start_server(port) → ENetMultiplayerPeer server
## client mode → join_server(host,port)→ ENetMultiplayerPeer client
## replication → _broadcast_spawn_player / _broadcast_despawn_player
## (server→all clients RPC)
##
## Channels (3-lane layout per Phase 0 research):
## 0 unreliable-ordered → 128Hz input / transform deltas
## 1 reliable-ordered → game events, spawn, damage, chat
## 2 unreliable → telemetry / VOIP metadata
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal server_started(port: int)
signal server_stopped()
signal player_connected(peer_id: int)
signal player_disconnected(peer_id: int)
signal connection_succeeded()
signal connection_failed(error_message: String)
# --- Player replication signals (emitted on all peers after RPC broadcast) ---
signal remote_player_spawned(peer_id: int, pos: Vector3)
signal remote_player_despawned(peer_id: int)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_PORT: int = 34197
const CHANNELS: int = 3 # 0=input, 1=events, 2=telemetry
# Max clients is read from ServerConfig at start_server() time.
# This default is used before ServerConfig is available.
var max_clients: int = 16
# Channel indices
enum Chan {
INPUT = 0,
EVENTS = 1,
TELEMETRY = 2,
}
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var is_server: bool = false : get = _is_server
var is_client: bool = false : get = _is_client
var peer: ENetMultiplayerPeer = null
func _is_server() -> bool:
return is_server
func _is_client() -> bool:
return is_client
# ---------------------------------------------------------------------------
# Server API
# ---------------------------------------------------------------------------
## Start a dedicated server on [port].
## Uses max_clients (which should be set from ServerConfig before calling).
## Returns OK or ERR_* on failure.
func start_server(port: int = DEFAULT_PORT) -> Error:
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
stop()
# If ServerConfig is available, use it for max_clients
if ServerConfig and ServerConfig.has_method(&"get_config_path"):
max_clients = ServerConfig.max_players
peer = ENetMultiplayerPeer.new()
peer.set_bind_ip("*")
var err: Error = peer.create_server(port, max_clients, CHANNELS, 0, 0)
if err != OK:
peer = null
return err
multiplayer.multiplayer_peer = peer
multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected)
multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected)
is_server = true
server_started.emit(port)
print("[NetworkManager] Server started on port %d" % port)
return OK
## Stop the server / disconnect.
func stop() -> void:
if not peer:
return
if is_server:
multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected)
multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected)
peer.close()
multiplayer.multiplayer_peer = null
peer = null
is_server = false
is_client = false
server_stopped.emit()
print("[NetworkManager] Stopped")
# ---------------------------------------------------------------------------
# Client API
# ---------------------------------------------------------------------------
## Connect to a remote server.
func join_server(host: String, port: int = DEFAULT_PORT) -> Error:
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
stop()
peer = ENetMultiplayerPeer.new()
var err: Error = peer.create_client(host, port, CHANNELS, 0, 0)
if err != OK:
peer = null
return err
multiplayer.multiplayer_peer = peer
connection_succeeded.emit()
print("[NetworkManager] Connecting to %s:%d ..." % [host, port])
is_client = true
return OK
# ---------------------------------------------------------------------------
# Player Replication RPCs (broadcast server → all clients)
# ---------------------------------------------------------------------------
## Server calls this when a new player joins.
## Broadcasts to all clients so they can create a visual player node.
@rpc("authority", "call_local", "reliable")
func broadcast_spawn_player(peer_id: int, pos: Vector3, is_team_a: bool) -> void:
if not multiplayer.is_server():
print("[NetworkManager] Client received spawn: peer=%d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z])
remote_player_spawned.emit(peer_id, pos)
## Server calls this when a player leaves.
@rpc("authority", "call_local", "reliable")
func broadcast_despawn_player(peer_id: int) -> void:
if not multiplayer.is_server():
print("[NetworkManager] Client received despawn: peer=%d" % peer_id)
remote_player_despawned.emit(peer_id)
# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------
func _on_peer_connected(id: int) -> void:
print("[NetworkManager] Peer connected: %d" % id)
player_connected.emit(id)
func _on_peer_disconnected(id: int) -> void:
print("[NetworkManager] Peer disconnected: %d" % id)
player_disconnected.emit(id)
func _process(_delta: float) -> void:
# Godot does internal ENet polling via MultiplayerAPI;
# explicit polling is reserved for future custom packet handling.
pass
func _exit_tree() -> void:
stop()
+1
View File
@@ -0,0 +1 @@
uid://crq322sfr42al
+100
View File
@@ -0,0 +1,100 @@
## Player — Replicated player state.
##
## Server-authoritative movement model (Phase 0 placeholder).
## The player who owns this node (multiplayer authority) sends input,
## server validates and broadcasts position to all peers.
##
## Phase 0: simple position RPC replication.
## Phase 1+: full input → movement → state broadcast loop.
extends Node3D
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
@export var movement_speed: float = 5.0
@export var sync_rate: float = 10.0 # Hz — how often to broadcast state
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var is_local: bool = false # True for the local player instance
var is_client_controlled: bool = false
var _sync_timer: float = 0.0
# Interpolation state for remote players
var _remote_position: Vector3 = Vector3.ZERO
var _remote_velocity: Vector3 = Vector3.ZERO
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
if is_local:
# This is our own player — we'll send input to server
is_client_controlled = not multiplayer.is_server()
if is_client_controlled:
print("[Player] Local player (client-controlled): %d" % multiplayer.get_unique_id())
else:
# Remote player — disable direct control
set_process(false)
set_physics_process(false)
print("[Player] Remote player: %d (authority: %d)" % [multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
# ---------------------------------------------------------------------------
# Client-side: send position to server
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if not is_client_controlled:
return
# Simple movement for Phase 0 testing
var input_dir := Vector3.ZERO
if Input.is_action_pressed("ui_right"):
input_dir.x += 1.0
if Input.is_action_pressed("ui_left"):
input_dir.x -= 1.0
if Input.is_action_pressed("ui_up"):
input_dir.z -= 1.0
if Input.is_action_pressed("ui_down"):
input_dir.z += 1.0
if input_dir != Vector3.ZERO:
input_dir = input_dir.normalized()
position += input_dir * movement_speed * delta
# Rate-limited position update to server
_sync_timer += delta
if _sync_timer >= (1.0 / sync_rate):
_sync_timer = 0.0
_send_position.rpc_id(1, position)
@rpc("unreliable", "any_peer")
func _send_position(pos: Vector3) -> void:
# Server receives position from client
if not multiplayer.is_server():
return
var peer_id: int = multiplayer.get_remote_sender_id()
if peer_id != multiplayer.get_multiplayer_authority():
# Peer is not authorized for this player — reject
push_warning("[Player] Unauthorized position update from peer %d" % peer_id)
return
# Server validates and broadcasts to all other peers
position = pos
_broadcast_position.rpc(pos)
@rpc("unreliable", "authority", "call_local")
func _broadcast_position(pos: Vector3) -> void:
# All peers (including server) update this player's position
if not is_local:
# Remote player — store for interpolation
_remote_position = pos
position = pos
else:
# Local player — authoritative position already set via input
pass
+1
View File
@@ -0,0 +1 @@
uid://c5b3teqp6yha
+183
View File
@@ -0,0 +1,183 @@
## Server Main — Headless dedicated server entry point.
##
## Runs as `server_main.tscn` which is the project's main scene.
## In headless mode (--headless), this is the only running scene.
##
## On ready:
## 1. Load config (env overrides)
## 2. Load the first map from the config's map rotation
## 3. Start ENet server on the configured port
## 4. Spawn players on the map using spawn markers
## 5. Broadcast spawn via RPC so clients create visual player nodes
## 6. Log player join/leave and replicate position
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal player_spawned(peer_id: int)
signal player_despawned(peer_id: int)
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
@export var player_scene: PackedScene = preload("res://scenes/player.tscn")
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var players: Dictionary = {} # peer_id → Node (player instance)
var spawn_points_a: Array[Vector3] = [] # Team A spawns
var spawn_points_b: Array[Vector3] = [] # Team B spawns
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Config driven — ServerConfig singleton loaded at autoload time.
# Allow port override via env var for backwards compatibility
# (container/VPS deployments that set SERVER_PORT).
var effective_port: int = ServerConfig.port
if OS.has_environment("SERVER_PORT"):
effective_port = int(OS.get_environment("SERVER_PORT"))
print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port)
# Instance the map from the config's map rotation
_load_map()
# Start the ENet server
var err: Error = NetworkManager.start_server(effective_port)
if err != OK:
push_error("[ServerMain] Failed to start server: %s" % error_string(err))
get_tree().quit(1)
return
# Connect signals
NetworkManager.player_connected.connect(_on_player_connected)
NetworkManager.player_disconnected.connect(_on_player_disconnected)
print("[ServerMain] Tactical Shooter server ready")
print("[ServerMain] Port: %d" % effective_port)
print("[ServerMain] Name: \"%s\"" % ServerConfig.server_name)
print("[ServerMain] Tick rate: %d Hz" % ServerConfig.tick_rate)
print("[ServerMain] Maps: %s" % ServerConfig.map_list)
print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless"))
print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
func _exit_tree() -> void:
NetworkManager.stop()
# ---------------------------------------------------------------------------
# Map loading
# ---------------------------------------------------------------------------
func _load_map() -> void:
# Load the first map from the config's rotation list
var maps_list: Array[String] = ServerConfig.map_list
if maps_list.is_empty():
push_warning("[ServerMain] Map rotation is empty — running without map geometry")
return
var first_map_path: String = maps_list[0]
var full_path: String = "res://scenes/map/%s.tscn" % first_map_path
if not ResourceLoader.exists(full_path):
push_error("[ServerMain] Map scene not found: %s" % full_path)
return
var map_scene_res: PackedScene = load(full_path)
var map_instance: Node = map_scene_res.instantiate()
map_instance.name = "Map_%s" % first_map_path
add_child(map_instance, true)
print("[ServerMain] Loaded map: %s" % first_map_path)
# Collect spawn markers from the map scene
_collect_spawn_points(map_instance)
## Walk the map scene looking for Marker3D nodes named "SpawnA*" and "SpawnB*".
func _collect_spawn_points(map_root: Node) -> void:
_collect_spawn_points_recursive(map_root)
# Fallback: only after full recursion, if still empty
if spawn_points_a.is_empty() and spawn_points_b.is_empty():
print("[ServerMain] No spawn markers found — using default positions")
spawn_points_a.append(Vector3(-16, 0, -4))
spawn_points_a.append(Vector3(-16, 0, 4))
spawn_points_b.append(Vector3(16, 0, -4))
spawn_points_b.append(Vector3(16, 0, 4))
func _collect_spawn_points_recursive(node: Node) -> void:
if node is Marker3D:
var name_lower: String = node.name.to_lower()
if name_lower.begins_with("spawna") or name_lower.begins_with("spawn_a"):
spawn_points_a.append(node.position)
elif name_lower.begins_with("spawnb") or name_lower.begins_with("spawn_b"):
spawn_points_b.append(node.position)
for child in node.get_children():
_collect_spawn_points_recursive(child)
# ---------------------------------------------------------------------------
# Player management
# ---------------------------------------------------------------------------
func _on_player_connected(peer_id: int) -> void:
print("[ServerMain] Player joined: %d. Total: %d" % [peer_id, multiplayer.get_peers().size() + 1])
_spawn_player(peer_id)
func _on_player_disconnected(peer_id: int) -> void:
print("[ServerMain] Player left: %d" % peer_id)
_despawn_player(peer_id)
func _spawn_player(peer_id: int) -> void:
# Create player node on the server
var player: Node
if player_scene:
player = player_scene.instantiate()
else:
player = Node3D.new()
player.set_script(preload("res://scripts/network/player.gd"))
player.name = "Player_%d" % peer_id
player.set_multiplayer_authority(peer_id)
# Assign spawn position — alternate between Team A and Team B spawns
var is_team_a: bool = (peer_id % 2) == 0
var pool: Array[Vector3] = spawn_points_a if is_team_a else spawn_points_b
var spawn_pos: Vector3 = pool[peer_id % pool.size()] if pool.size() > 0 else Vector3.ZERO
spawn_pos.y = 0.0
player.position = spawn_pos
add_child(player, true)
players[peer_id] = player
player_spawned.emit(peer_id)
# Broadcast spawn to all clients so they create a visual player node
NetworkManager.broadcast_spawn_player.rpc(peer_id, spawn_pos, is_team_a)
print("[ServerMain] Spawned player %d at (%.1f, %.1f) team=%s" % [peer_id, spawn_pos.x, spawn_pos.z, "A" if is_team_a else "B"])
func _despawn_player(peer_id: int) -> void:
if peer_id in players:
var player: Node = players[peer_id]
player.queue_free()
players.erase(peer_id)
player_despawned.emit(peer_id)
# Broadcast despawn to all clients
NetworkManager.broadcast_despawn_player.rpc(peer_id)
print("[ServerMain] Despawned player %d" % peer_id)
else:
# Still broadcast even if server doesn't have the player
NetworkManager.broadcast_despawn_player.rpc(peer_id)
# ---------------------------------------------------------------------------
# Server tick (128 Hz)
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
if not NetworkManager.is_server:
return
# Future: authoritative physics tick, state broadcast, etc.
pass
+1
View File
@@ -0,0 +1 @@
uid://dtwj2vairvxnt