Wire SimBridge to use LocalBridge for TCP transport in non-test mode: _process() polls for incoming snapshots and flushes outbound inputs. Add 4 diagonal movement variants (NE, SE, SW, NW) to InputMapper and wire mapping, ordered clockwise. Register diagonal input actions in project.godot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
6.7 KiB
GDScript
196 lines
6.7 KiB
GDScript
extends Node
|
|
|
|
# Connection states
|
|
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
|
|
|
var state: ConnectionState = ConnectionState.DISCONNECTED
|
|
var test_mode: bool = true # Enable test mode for development without Rust server
|
|
var _test_tick: int = 0
|
|
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
|
var _outbound_buffer: Array[PackedByteArray] = [] # Encoded inputs awaiting transport
|
|
|
|
# Transport layer (non-test mode)
|
|
var _bridge: LocalBridge = null
|
|
var _server: ServerProcess = null
|
|
var server_port: int = 9800
|
|
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
|
|
|
# Signals
|
|
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
|
signal snapshot_received(snapshot: Dictionary)
|
|
|
|
func _ready() -> void:
|
|
if test_mode:
|
|
print("SimBridge: Running in test mode (hardcoded snapshot)")
|
|
|
|
# Change connection state and emit signal
|
|
func _set_state(new_state: ConnectionState) -> void:
|
|
if state != new_state:
|
|
var old_state = state
|
|
state = new_state
|
|
connection_state_changed.emit(old_state, new_state)
|
|
|
|
# Connect to simulation server.
|
|
# In test mode, immediately transitions to CONNECTED.
|
|
# In live mode, spawns server subprocess and connects via TCP.
|
|
func connect_to_sim() -> void:
|
|
_set_state(ConnectionState.CONNECTING)
|
|
|
|
if test_mode:
|
|
_set_state(ConnectionState.CONNECTED)
|
|
return
|
|
|
|
# Spawn server subprocess
|
|
if not server_path.is_empty():
|
|
_server = ServerProcess.new()
|
|
var pid := _server.start(server_path, ["--port", str(server_port)])
|
|
if pid <= 0:
|
|
push_error("SimBridge: failed to start server")
|
|
_set_state(ConnectionState.ERROR)
|
|
return
|
|
|
|
# Connect TCP bridge
|
|
_bridge = LocalBridge.new()
|
|
var err := _bridge.connect_to_server("127.0.0.1", server_port)
|
|
if err != OK:
|
|
push_error("SimBridge: failed to initiate TCP connection: %s" % error_string(err))
|
|
_set_state(ConnectionState.ERROR)
|
|
return
|
|
# State transitions to CONNECTED in _process() when TCP handshake completes
|
|
|
|
# Disconnect from simulation server
|
|
func disconnect_from_sim() -> void:
|
|
if _bridge != null:
|
|
_bridge.disconnect_from_server()
|
|
_bridge = null
|
|
if _server != null:
|
|
_server.stop()
|
|
_server = null
|
|
_set_state(ConnectionState.DISCONNECTED)
|
|
|
|
# Poll transport layer every frame (non-test mode only)
|
|
func _process(_delta: float) -> void:
|
|
if test_mode or _bridge == null:
|
|
return
|
|
|
|
_bridge.poll()
|
|
|
|
match _bridge.get_status():
|
|
StreamPeerTCP.STATUS_CONNECTED:
|
|
if state != ConnectionState.CONNECTED:
|
|
_set_state(ConnectionState.CONNECTED)
|
|
# Receive: drain all complete messages from the bridge
|
|
var msg := _bridge.poll_message()
|
|
while msg.size() > 0:
|
|
receive_bytes(msg)
|
|
msg = _bridge.poll_message()
|
|
# Send: flush outbound buffer through the bridge
|
|
var outbound := drain_outbound()
|
|
for payload in outbound:
|
|
var err := _bridge.send_message(payload)
|
|
if err != OK:
|
|
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
|
StreamPeerTCP.STATUS_CONNECTING:
|
|
pass # Still connecting, wait
|
|
StreamPeerTCP.STATUS_ERROR:
|
|
if state != ConnectionState.ERROR:
|
|
push_error("SimBridge: TCP connection error")
|
|
_set_state(ConnectionState.ERROR)
|
|
StreamPeerTCP.STATUS_NONE:
|
|
if state == ConnectionState.CONNECTED:
|
|
push_warning("SimBridge: connection lost")
|
|
_set_state(ConnectionState.DISCONNECTED)
|
|
|
|
# Send input to simulation server.
|
|
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
|
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
|
|
func send_input(player_input: Dictionary) -> void:
|
|
if state != ConnectionState.CONNECTED:
|
|
return
|
|
if test_mode:
|
|
return
|
|
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
|
if action_name.is_empty():
|
|
return
|
|
var tick: int = player_input.get("timestamp_msec", 0)
|
|
var encoded := Protocol.encode_player_input(tick, action_name)
|
|
if encoded.size() == 0:
|
|
push_error("SimBridge: failed to encode player input (action=%s)" % action_name)
|
|
return
|
|
_outbound_buffer.append(encoded)
|
|
|
|
# Poll for snapshot from simulation.
|
|
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
|
|
func poll_snapshot() -> Variant:
|
|
if state != ConnectionState.CONNECTED:
|
|
return null
|
|
|
|
if test_mode:
|
|
var snapshot = _test_snapshot()
|
|
snapshot_received.emit(snapshot)
|
|
return snapshot
|
|
|
|
if _last_snapshot != null:
|
|
var snapshot = _last_snapshot
|
|
_last_snapshot = null
|
|
snapshot_received.emit(snapshot)
|
|
return snapshot
|
|
|
|
return null
|
|
|
|
# Called by transport layer when raw bytes arrive from the server.
|
|
# Latest-wins semantics: newer snapshots replace unconsumed ones. This is correct
|
|
# for real-time rendering (stale frames are worthless). Upgrade to queue if needed.
|
|
func receive_bytes(bytes: PackedByteArray) -> void:
|
|
var snapshot = Protocol.decode_snapshot(bytes)
|
|
if snapshot != null:
|
|
if _last_snapshot != null:
|
|
push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick])
|
|
_last_snapshot = snapshot
|
|
|
|
# Drain the outbound buffer. Returns encoded messages for transport.
|
|
func drain_outbound() -> Array[PackedByteArray]:
|
|
var messages = _outbound_buffer.duplicate()
|
|
_outbound_buffer.clear()
|
|
return messages
|
|
|
|
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
|
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
|
static func _action_enum_to_wire(action: int) -> String:
|
|
match action:
|
|
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
|
|
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
|
|
InputMapper.Action.MOVE_EAST: return "MoveEast"
|
|
InputMapper.Action.MOVE_SOUTHEAST: return "MoveSoutheast"
|
|
InputMapper.Action.MOVE_SOUTH: return "MoveSouth"
|
|
InputMapper.Action.MOVE_SOUTHWEST: return "MoveSouthwest"
|
|
InputMapper.Action.MOVE_WEST: return "MoveWest"
|
|
InputMapper.Action.MOVE_NORTHWEST: return "MoveNorthwest"
|
|
InputMapper.Action.INTERACT: return "Interact"
|
|
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
|
|
InputMapper.Action.PAUSE: return "Pause"
|
|
InputMapper.Action.OPEN_MENU:
|
|
# Client-only action, not part of wire protocol
|
|
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
|
|
return ""
|
|
_:
|
|
push_warning("SimBridge: unknown action enum %s" % action)
|
|
return ""
|
|
|
|
# Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4).
|
|
# Uses the same {tick, entities} schema as Protocol.decode_snapshot() returns.
|
|
func _test_snapshot() -> Dictionary:
|
|
_test_tick += 1
|
|
return {
|
|
"tick": _test_tick,
|
|
"entities": [
|
|
{
|
|
"entity_id": 1,
|
|
"x": 10.0,
|
|
"y": 10.0,
|
|
"z": 0,
|
|
"kind": { "variant": "Npc", "data": null },
|
|
},
|
|
],
|
|
}
|