Initial commit: Transcience MVP
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated server with client-side prediction. Clients send input only; the server resolves every hit for both players and enemies (no PvP). - SimWorld: whole simulation as plain RefCounted objects (no nodes, no physics server), ~0.24ms/tick at peak load -- runs headless for free and drives 78 tests in under a second - BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn events rather than per-tick state - Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash enemies and bosses -- a new boss is data in src/content/content.gd, no simulation changes - The Warden of the Fold: stationary 4-phase boss built entirely on that format - Lobby hub with a portal into on-demand dungeon instances; one process hosts the hub plus every concurrent dungeon - Emergency escape: 3s server-owned channel, cancelled by damage - tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over ENet), bench.gd; git hooks wired to the same scripts - docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
extends Node
|
||||
## Transport and RPC surface. This is the only autoload in the project.
|
||||
##
|
||||
## It has to be an autoload because Godot routes an RPC by node path: the sender
|
||||
## and receiver must agree on where the node lives, and `/root/Net` is the one
|
||||
## path that exists identically on a dedicated server and on every client.
|
||||
##
|
||||
## The naming rule here is load-bearing: `c_*` is client -> server, `s_*` is
|
||||
## server -> client. The `s_*` methods are annotated "authority", so the engine
|
||||
## itself drops any attempt by a client to fake one.
|
||||
|
||||
signal state_changed(new_state: State)
|
||||
|
||||
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
|
||||
|
||||
## Peer id the engine gives the host. On a listen server this is also a real
|
||||
## player, which is why every send goes through the helpers below.
|
||||
const LOCAL_PEER := 1
|
||||
|
||||
var state: State = State.OFFLINE
|
||||
var server: ServerRuntime = null
|
||||
var client: ClientRuntime = null
|
||||
var last_error: String = ""
|
||||
|
||||
|
||||
func _set_state(s: State) -> void:
|
||||
state = s
|
||||
state_changed.emit(s)
|
||||
|
||||
|
||||
# --- Lifecycle --------------------------------------------------------------
|
||||
|
||||
func host(port: int) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, Protocol.MAX_CLIENTS, Protocol.CHANNEL_COUNT)
|
||||
if err != OK:
|
||||
last_error = "could not bind port %d (error %d)" % [port, err]
|
||||
GameLog.error("net", last_error)
|
||||
_set_state(State.FAILED)
|
||||
return err
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
server = ServerRuntime.new()
|
||||
server.name = "Server"
|
||||
add_child(server)
|
||||
_set_state(State.ONLINE)
|
||||
GameLog.info("net", "listening on %d" % port)
|
||||
return OK
|
||||
|
||||
|
||||
func join(address: String, port: int) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port, Protocol.CHANNEL_COUNT)
|
||||
if err != OK:
|
||||
last_error = "could not reach %s:%d (error %d)" % [address, port, err]
|
||||
GameLog.error("net", last_error)
|
||||
_set_state(State.FAILED)
|
||||
return err
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(_on_connect_failed)
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
client = ClientRuntime.new()
|
||||
client.name = "Client"
|
||||
add_child(client)
|
||||
# Nothing is simulated until the server says hello back; on_welcome turns
|
||||
# the physics process on.
|
||||
client.set_physics_process(false)
|
||||
_set_state(State.CONNECTING)
|
||||
GameLog.info("net", "connecting to %s:%d" % [address, port])
|
||||
return OK
|
||||
|
||||
|
||||
func shutdown() -> void:
|
||||
if multiplayer.multiplayer_peer != null \
|
||||
and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
multiplayer.multiplayer_peer = null
|
||||
for sig in [multiplayer.peer_connected, multiplayer.peer_disconnected,
|
||||
multiplayer.connected_to_server, multiplayer.connection_failed,
|
||||
multiplayer.server_disconnected]:
|
||||
for c in sig.get_connections():
|
||||
sig.disconnect(c["callable"])
|
||||
if server != null:
|
||||
server.queue_free()
|
||||
server = null
|
||||
if client != null:
|
||||
client.queue_free()
|
||||
client = null
|
||||
_set_state(State.OFFLINE)
|
||||
|
||||
|
||||
func kick(peer_id: int) -> void:
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
|
||||
|
||||
|
||||
func is_server() -> bool:
|
||||
return server != null
|
||||
|
||||
|
||||
# --- Transport signals ------------------------------------------------------
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
if server != null:
|
||||
server.on_peer_connected(peer_id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
if server != null:
|
||||
server.on_peer_disconnected(peer_id)
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
_set_state(State.ONLINE)
|
||||
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
|
||||
|
||||
|
||||
func _on_connect_failed() -> void:
|
||||
last_error = "connection refused"
|
||||
GameLog.error("net", last_error)
|
||||
_set_state(State.FAILED)
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
last_error = "server closed the connection"
|
||||
GameLog.warn("net", last_error)
|
||||
shutdown()
|
||||
|
||||
|
||||
## Start a client inside the hosting process. The result is a listen server:
|
||||
## the host plays through exactly the same code path as a remote player, sending
|
||||
## input and learning outcomes from snapshots, with the transport short-circuited
|
||||
## to a function call. No simulation code knows the difference.
|
||||
func start_local_client() -> void:
|
||||
if server == null:
|
||||
GameLog.error("net", "start_local_client called with no server")
|
||||
return
|
||||
client = ClientRuntime.new()
|
||||
client.name = "Client"
|
||||
add_child(client)
|
||||
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
|
||||
|
||||
|
||||
func _is_local(peer_id: int) -> bool:
|
||||
return peer_id == LOCAL_PEER and client != null
|
||||
|
||||
|
||||
# --- Send helpers -----------------------------------------------------------
|
||||
# Every server -> client message goes through one of these rather than calling
|
||||
# rpc_id directly, so the listen server's own player is reachable too.
|
||||
|
||||
func send_welcome(peer_id: int) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_welcome(peer_id)
|
||||
else:
|
||||
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
|
||||
|
||||
|
||||
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
|
||||
boss_id: String, spawn: Vector2) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
|
||||
else:
|
||||
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
|
||||
|
||||
|
||||
func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_snapshot(data)
|
||||
else:
|
||||
s_snapshot.rpc_id(peer_id, data)
|
||||
|
||||
|
||||
func send_events(peer_id: int, data: PackedByteArray) -> void:
|
||||
if _is_local(peer_id):
|
||||
client.on_events(data)
|
||||
else:
|
||||
s_events.rpc_id(peer_id, data)
|
||||
|
||||
|
||||
func send_reject(peer_id: int, reason: String) -> void:
|
||||
if _is_local(peer_id):
|
||||
GameLog.error("net", "local client rejected: %s" % reason)
|
||||
else:
|
||||
s_reject.rpc_id(peer_id, reason)
|
||||
|
||||
|
||||
func send_input(data: PackedByteArray) -> void:
|
||||
if server != null:
|
||||
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
|
||||
elif state == State.ONLINE and client != null:
|
||||
c_input.rpc_id(1, data)
|
||||
|
||||
|
||||
# --- Client -> server -------------------------------------------------------
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 1)
|
||||
func c_hello(version: int, display_name: String) -> void:
|
||||
if server == null:
|
||||
return
|
||||
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
|
||||
func c_input(data: PackedByteArray) -> void:
|
||||
if server == null:
|
||||
return
|
||||
server.on_input(multiplayer.get_remote_sender_id(), data)
|
||||
|
||||
|
||||
# --- Server -> client -------------------------------------------------------
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_welcome(peer_id: int, _version: int) -> void:
|
||||
if client == null:
|
||||
return
|
||||
client.on_welcome(peer_id)
|
||||
client.set_physics_process(true)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
spawn: Vector2) -> void:
|
||||
if client == null:
|
||||
return
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
func s_reject(reason: String) -> void:
|
||||
last_error = reason
|
||||
GameLog.error("net", "rejected by server: %s" % reason)
|
||||
_set_state(State.FAILED)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable", 2)
|
||||
func s_snapshot(data: PackedByteArray) -> void:
|
||||
if client != null:
|
||||
client.on_snapshot(data)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 3)
|
||||
func s_events(data: PackedByteArray) -> void:
|
||||
if client != null:
|
||||
client.on_events(data)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8itnd1lucfeu
|
||||
Reference in New Issue
Block a user