3639fb3132
checkpoint / checkpoint (push) Successful in 1m40s
- Clients now hard-snap to the server's spawn position on the first snapshot. player.gd starts everyone on the fixed PlayerSpawn marker but the server spreads spawns out -- so one client saw players ~1m apart (correct) while the other saw them stacked (its local player still on the marker, never corrected because the ~1m drift was under the old 1.5m threshold). Threshold also tightened to 0.6m. - game.gd._exit_tree() closes the ENet connection cleanly when you leave a world, so the server fires peer_disconnected and removes your player. Before, leaving just changed scene and the server never knew -- everyone else kept seeing a frozen "ghost". - server.gd: once-a-second sweep of players whose peer is no longer connected, as a backstop for a missed disconnect. - Build stamp: checkpoint_ops bakes "<PC> <commit>" into application/config/version at export; the menu and in-world HUD show "build PC3 <hash>" so playtest reports aren't ambiguous about which build is running. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
195 lines
6.1 KiB
GDScript
195 lines
6.1 KiB
GDScript
## ServerMain - headless dedicated server for one world.
|
|
##
|
|
## Accepts ENet connections, spawns a server-authoritative Player per peer in a
|
|
## real loaded level, and broadcasts a full snapshot (every peer's state + the
|
|
## last input seq processed for that peer) every physics frame. The project's
|
|
## physics rate is pinned to 30 Hz so the sim is deterministic and matches the
|
|
## client's prediction step.
|
|
##
|
|
## Launched by the backend's game-server supervisor
|
|
## (backend/app/services/game_servers.py):
|
|
## godot --headless --path . res://scenes/server.tscn -- \
|
|
## --port 27000 --world 3 --status-file /path/to/status.json
|
|
## All args are optional; the defaults let `godot res://scenes/server.tscn`
|
|
## run standalone on 7777 for smoke_test_network_client.gd.
|
|
extends Node
|
|
|
|
const MAX_PLAYERS := 16
|
|
const DEFAULT_PORT := 7777
|
|
const PHYSICS_HZ := 30
|
|
## Rewrite the status file (player count, for the supervisor's idle reaper)
|
|
## every this many physics frames.
|
|
const STATUS_WRITE_EVERY := 30
|
|
|
|
var network_peer: ENetMultiplayerPeer
|
|
var port: int = DEFAULT_PORT
|
|
var world_id: String = ""
|
|
var status_file_path: String = ""
|
|
|
|
## peer_id -> Player node
|
|
var players: Dictionary = {}
|
|
var tick: int = 0
|
|
var is_running: bool = false
|
|
## Monotonic count of joins ever -- drives spawn spread so two players never
|
|
## land on the same spot (which used to shove one through the floor).
|
|
var _join_count: int = 0
|
|
|
|
var _player_scene: PackedScene = preload("res://scenes/server_player.tscn")
|
|
var _level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
|
|
var _spawn_point: Vector3 = Vector3(0, 1.5, 0)
|
|
|
|
|
|
func _enter_tree() -> void:
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
|
|
|
|
func _ready() -> void:
|
|
_parse_args()
|
|
Engine.physics_ticks_per_second = PHYSICS_HZ
|
|
|
|
# Load the world's level so players have real ground + collision.
|
|
var level := _level_scene.instantiate()
|
|
level.name = "World"
|
|
add_child(level)
|
|
var marker := level.find_child("PlayerSpawn", true, false)
|
|
if marker is Node3D:
|
|
_spawn_point = (marker as Node3D).global_position
|
|
|
|
network_peer = ENetMultiplayerPeer.new()
|
|
var err := network_peer.create_server(port, MAX_PLAYERS)
|
|
if err != OK:
|
|
printerr("[Server] create_server(%d) failed: %d" % [port, err])
|
|
get_tree().quit(1)
|
|
return
|
|
multiplayer.multiplayer_peer = network_peer
|
|
is_running = true
|
|
_write_status()
|
|
print("[Server] world=%s listening on ENet %d (%d Hz)" % [world_id, port, PHYSICS_HZ])
|
|
|
|
|
|
## A spawn point offset from the level's marker so joins don't stack. Golden-
|
|
## angle spiral: evenly spread, never exactly repeats, grows slowly outward.
|
|
func _spawn_for(index: int) -> Vector3:
|
|
if index == 0:
|
|
return _spawn_point
|
|
var angle := index * 2.399963 # golden angle (radians)
|
|
var radius := 0.9 + 0.25 * float(index)
|
|
return _spawn_point + Vector3(cos(angle) * radius, 0.0, sin(angle) * radius)
|
|
|
|
|
|
func _parse_args() -> void:
|
|
var args := OS.get_cmdline_user_args()
|
|
var i := 0
|
|
while i < args.size():
|
|
match args[i]:
|
|
"--port":
|
|
if i + 1 < args.size():
|
|
port = int(args[i + 1])
|
|
i += 1
|
|
"--world":
|
|
if i + 1 < args.size():
|
|
world_id = args[i + 1]
|
|
i += 1
|
|
"--status-file":
|
|
if i + 1 < args.size():
|
|
status_file_path = args[i + 1]
|
|
i += 1
|
|
i += 1
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
if not is_running:
|
|
return
|
|
tick += 1
|
|
if tick % PHYSICS_HZ == 0:
|
|
_sweep_stale_players()
|
|
_broadcast_snapshot()
|
|
if tick % STATUS_WRITE_EVERY == 0:
|
|
_write_status()
|
|
|
|
|
|
## Backstop for _on_peer_disconnected: once a second, drop any player whose
|
|
## peer is no longer connected (covers a missed disconnect signal, so a
|
|
## rejoining client never sees a frozen ghost of its old self).
|
|
func _sweep_stale_players() -> void:
|
|
var connected := multiplayer.get_peers()
|
|
for peer_id in players.keys():
|
|
if peer_id not in connected:
|
|
var p: Node = players[peer_id]
|
|
if is_instance_valid(p):
|
|
p.queue_free()
|
|
players.erase(peer_id)
|
|
print("[Server] swept stale player %d (%d left)" % [peer_id, players.size()])
|
|
|
|
|
|
func _broadcast_snapshot() -> void:
|
|
var snap := {"tick": tick, "players": {}}
|
|
for peer_id in players:
|
|
var player: Node = players[peer_id]
|
|
if is_instance_valid(player):
|
|
var s: Dictionary = player.get_server_state()
|
|
s["last_seq"] = player.last_applied_seq
|
|
snap["players"][peer_id] = s
|
|
for pid in multiplayer.get_peers():
|
|
if pid == 1:
|
|
continue
|
|
rpc_id(pid, "_receive_snapshot", snap)
|
|
|
|
|
|
# Client-side receiver lives on the NetworkClient's proxy at the same path.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _receive_snapshot(_snapshot_data: Dictionary) -> void:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_peer_connected(peer_id: int) -> void:
|
|
if players.size() >= MAX_PLAYERS:
|
|
print("[Server] full; rejecting peer %d" % peer_id)
|
|
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
|
return
|
|
var player: Node3D = _player_scene.instantiate()
|
|
player.name = "Player_%d" % peer_id
|
|
player.peer_id = peer_id
|
|
add_child(player)
|
|
var spawn := _spawn_for(_join_count)
|
|
_join_count += 1
|
|
player.global_position = spawn
|
|
player.spawn_position = spawn
|
|
players[peer_id] = player
|
|
print("[Server] peer %d joined (%d players)" % [peer_id, players.size()])
|
|
_write_status()
|
|
|
|
|
|
func _on_peer_disconnected(peer_id: int) -> void:
|
|
if players.has(peer_id):
|
|
var player: Node = players[peer_id]
|
|
if is_instance_valid(player):
|
|
player.queue_free()
|
|
players.erase(peer_id)
|
|
print("[Server] peer %d left (%d players)" % [peer_id, players.size()])
|
|
_write_status()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status file for the supervisor's idle reaper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _write_status() -> void:
|
|
if status_file_path.is_empty():
|
|
return
|
|
var f := FileAccess.open(status_file_path, FileAccess.WRITE)
|
|
if f:
|
|
f.store_string(JSON.stringify({
|
|
"world_id": world_id,
|
|
"port": port,
|
|
"players": players.size(),
|
|
"tick": tick,
|
|
"updated": Time.get_unix_time_from_system(),
|
|
}))
|
|
f.close()
|