Three fixes to make the gameplay loop functional end-to-end: - Add Interactable component to NPC spawn so E-prompt detection works - Build monologue trigger system (enter_location + time_idle) with MonologueBuffer/MonologueState components, wire through ObserverSnapshot as current_monologue field, decode on client and display via HUD - Change PlayerAction::Interact from unit to struct variant carrying optional target_entity_id and verb fields Bumps protocol version from 4 to 5. Regenerates MessagePack fixtures. All 200 tests pass (170 unit + 30 integration). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
478 lines
16 KiB
GDScript
478 lines
16 KiB
GDScript
extends Node
|
|
|
|
# Connection states
|
|
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
|
|
|
var state: ConnectionState = ConnectionState.DISCONNECTED
|
|
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
|
var _test_tick: int = 0
|
|
var _test_player_pos: Vector2i = Vector2i(10, 10)
|
|
var _test_facing: String = "North"
|
|
var _test_input_queue: Array = [] # Queued actions for test mode
|
|
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
|
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
|
|
|
# Transport layer (non-test mode)
|
|
var _bridge: LocalBridge = null
|
|
var _server: ServerProcess = null
|
|
var server_port: int = 9876 # Default matches server's default bind address
|
|
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
|
|
|
# Connection retry state — handles server startup delay (Critical fix #1)
|
|
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
|
|
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
|
var _connect_retries: int = 0
|
|
var _retry_timer: float = 0.0
|
|
|
|
# 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 (dynamic snapshot)")
|
|
|
|
# Reset test state — call before tests that use _test_snapshot()
|
|
func reset_test_state() -> void:
|
|
_test_tick = 0
|
|
_test_player_pos = Vector2i(10, 10)
|
|
_test_facing = "North"
|
|
_test_input_queue.clear()
|
|
|
|
# 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 defers TCP connection to _process()
|
|
# to allow the server time to bind its port.
|
|
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()
|
|
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
|
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
|
if pid <= 0:
|
|
push_error("SimBridge: failed to start server")
|
|
_set_state(ConnectionState.ERROR)
|
|
return
|
|
|
|
# Defer TCP connection to _process() — server needs time to bind its port
|
|
_connect_retries = 0
|
|
_retry_timer = 0.0
|
|
_bridge = null
|
|
|
|
# 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
|
|
_connect_retries = 0
|
|
_set_state(ConnectionState.DISCONNECTED)
|
|
|
|
# Attempt TCP connection. Called from _process() during CONNECTING state.
|
|
func _try_connect() -> void:
|
|
_bridge = LocalBridge.new()
|
|
var err := _bridge.connect_to_server("127.0.0.1", server_port)
|
|
if err != OK:
|
|
push_warning("SimBridge: TCP connect attempt %d/%d failed: %s" % [
|
|
_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)])
|
|
_bridge = null
|
|
|
|
# Poll transport layer every frame (non-test mode only)
|
|
func _process(delta: float) -> void:
|
|
if test_mode:
|
|
return
|
|
|
|
# CONNECTING state: retry TCP connection until server is ready
|
|
if state == ConnectionState.CONNECTING:
|
|
if _bridge == null:
|
|
_retry_timer += delta
|
|
if _retry_timer >= CONNECT_RETRY_INTERVAL or _connect_retries == 0:
|
|
_retry_timer = 0.0
|
|
_connect_retries += 1
|
|
if _connect_retries > MAX_CONNECT_RETRIES:
|
|
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
|
|
_set_state(ConnectionState.ERROR)
|
|
return
|
|
_try_connect()
|
|
return
|
|
|
|
# Bridge exists — poll for connection completion
|
|
_bridge.poll()
|
|
match _bridge.get_status():
|
|
StreamPeerTCP.STATUS_CONNECTED:
|
|
_set_state(ConnectionState.CONNECTED)
|
|
StreamPeerTCP.STATUS_CONNECTING:
|
|
pass # Still connecting, wait
|
|
StreamPeerTCP.STATUS_ERROR:
|
|
# Connection attempt failed — retry
|
|
_bridge = null
|
|
if _connect_retries >= MAX_CONNECT_RETRIES:
|
|
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
|
|
_set_state(ConnectionState.ERROR)
|
|
StreamPeerTCP.STATUS_NONE:
|
|
_bridge = null # Reset and retry
|
|
return
|
|
|
|
if _bridge == null:
|
|
return
|
|
|
|
_bridge.poll()
|
|
|
|
match _bridge.get_status():
|
|
StreamPeerTCP.STATUS_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: batch-encode and flush outbound buffer as one frame (Vec<PlayerInput>).
|
|
# Inputs are drained before encoding. On encode failure the inputs are
|
|
# intentionally dropped — re-queuing would retry the same bad data and
|
|
# the server tick has already advanced, making stale inputs invalid.
|
|
var outbound := drain_outbound()
|
|
if outbound.size() > 0:
|
|
var encoded := Protocol.encode_player_inputs(outbound)
|
|
if encoded.size() > 0:
|
|
var err := _bridge.send_message(encoded)
|
|
if err != OK:
|
|
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
|
else:
|
|
push_error("SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size())
|
|
StreamPeerTCP.STATUS_CONNECTING:
|
|
pass # Should not happen in CONNECTED state
|
|
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.
|
|
# Returns OK on success, or an error code on failure.
|
|
func send_input(player_input: Dictionary) -> Error:
|
|
if state != ConnectionState.CONNECTED:
|
|
return ERR_CONNECTION_ERROR
|
|
if test_mode:
|
|
var action: int = player_input.get("action", -1)
|
|
var wire_name: String = _action_enum_to_wire(action)
|
|
if not wire_name.is_empty():
|
|
_test_input_queue.append(wire_name)
|
|
return OK
|
|
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
|
if action_name.is_empty():
|
|
# _action_enum_to_wire already emits push_warning for invalid actions
|
|
return ERR_INVALID_PARAMETER
|
|
# Use the server's current tick so drain_for_tick processes this input immediately.
|
|
# The client-side timestamp_msec is only useful for ordering within a frame.
|
|
var tick: int = GameState.current_tick
|
|
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
|
# Data variants (e.g. UsePerceptionMode) carry payload
|
|
var action_data: Variant = player_input.get("action_data")
|
|
if action_data != null:
|
|
entry["action_data"] = action_data
|
|
_outbound_buffer.append(entry)
|
|
return OK
|
|
|
|
# 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 raw input entries for batch encoding.
|
|
func drain_outbound() -> Array[Dictionary]:
|
|
var inputs = _outbound_buffer.duplicate()
|
|
_outbound_buffer.clear()
|
|
return inputs
|
|
|
|
# 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 ""
|
|
|
|
# Dynamic test snapshot — processes queued inputs to move player, generates
|
|
# visibility based on current position. Matches Protocol.decode_snapshot() format.
|
|
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
|
|
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
|
|
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
|
|
# map layout and can exercise the rendering pipeline independently.
|
|
func _test_snapshot() -> Dictionary:
|
|
_test_tick += 1
|
|
|
|
# Process queued inputs
|
|
for action_name in _test_input_queue:
|
|
var delta := _action_to_delta(action_name)
|
|
var new_pos := _test_player_pos + delta
|
|
if _test_is_walkable(new_pos):
|
|
_test_player_pos = new_pos
|
|
if delta != Vector2i.ZERO:
|
|
_test_facing = _delta_to_facing(delta)
|
|
_test_input_queue.clear()
|
|
|
|
var px := _test_player_pos.x
|
|
var py := _test_player_pos.y
|
|
|
|
# Build entities — player always visible
|
|
var entities: Array = [{
|
|
"entity_id": 1,
|
|
"x": float(px),
|
|
"y": float(py),
|
|
"z": 0,
|
|
"kind": { "variant": "Player", "data": null },
|
|
"visibility": "Forward",
|
|
}]
|
|
|
|
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
|
var npc_pos := Vector2i(12, 9)
|
|
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
|
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
|
|
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
|
entities.append({
|
|
"entity_id": 2,
|
|
"x": float(npc_pos.x),
|
|
"y": float(npc_pos.y),
|
|
"z": 0,
|
|
"kind": { "variant": "Npc", "data": null },
|
|
"visibility": sector,
|
|
})
|
|
|
|
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
|
var nearby: Array = []
|
|
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
|
|
nearby.append({
|
|
"entity_id": 2,
|
|
"entity_type": "Npc",
|
|
"distance": npc_dist,
|
|
"verbs": [
|
|
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
|
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
|
],
|
|
})
|
|
|
|
# v5: monologue on first tick (#414)
|
|
var monologue: Variant = null
|
|
if _test_tick == 1:
|
|
monologue = {
|
|
"id": "test_enter_001",
|
|
"text": "Sova Transit District. Population twelve thousand and change.",
|
|
"duration_seconds": 5.0,
|
|
}
|
|
|
|
return {
|
|
"tick": _test_tick,
|
|
"version": Protocol.PROTOCOL_VERSION,
|
|
"game_time": {
|
|
"day": 0,
|
|
"time_of_day": _test_tick * 10,
|
|
"day_phase": "Morning",
|
|
"tick_rate": "Full",
|
|
},
|
|
"player_facing": _test_facing,
|
|
"entities": entities,
|
|
"tiles": _test_tiles(),
|
|
"visible_tiles": _test_visible_tiles(),
|
|
"visible_positions": _test_visible_positions(),
|
|
"nearby_interactions": nearby,
|
|
"current_monologue": monologue,
|
|
}
|
|
|
|
# Generate a small test room: 8x6 room with walls, a door, and floor
|
|
func _test_tiles() -> Array:
|
|
var tiles: Array = []
|
|
var room_x := 7
|
|
var room_y := 7
|
|
var room_w := 8
|
|
var room_h := 8
|
|
|
|
for x in range(room_x, room_x + room_w):
|
|
for y in range(room_y, room_y + room_h):
|
|
var is_edge := (x == room_x or x == room_x + room_w - 1
|
|
or y == room_y or y == room_y + room_h - 1)
|
|
var tile_type: String
|
|
if is_edge:
|
|
# Door on the south wall, center
|
|
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
|
tile_type = "door"
|
|
else:
|
|
tile_type = "wall"
|
|
else:
|
|
tile_type = "floor"
|
|
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
|
|
|
# Corridor south of the door
|
|
var door_x := room_x + room_w / 2
|
|
for y in range(room_y + room_h, room_y + room_h + 4):
|
|
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
|
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
|
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
|
|
|
return tiles
|
|
|
|
# Test visible tiles with visibility sectors (v2 format)
|
|
# Tiles ahead of the player are Forward, others Peripheral.
|
|
func _test_visible_tiles() -> Array:
|
|
var vtiles: Array = []
|
|
var px := _test_player_pos.x
|
|
var py := _test_player_pos.y
|
|
var radius := 4
|
|
var room_x := 7
|
|
var room_y := 7
|
|
var room_w := 8
|
|
var room_h := 8
|
|
|
|
for x in range(px - radius, px + radius + 1):
|
|
for y in range(py - radius, py + radius + 1):
|
|
var dist := absf(x - px) + absf(y - py)
|
|
if dist <= radius:
|
|
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
|
var sector: String = "Forward" if y <= py else "Peripheral"
|
|
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
|
return vtiles
|
|
|
|
# Test visibility: tiles within radius 4 of player, inside room bounds
|
|
func _test_visible_positions() -> Array:
|
|
var positions: Array = []
|
|
var px := _test_player_pos.x
|
|
var py := _test_player_pos.y
|
|
var radius := 4
|
|
var room_x := 7
|
|
var room_y := 7
|
|
var room_w := 8
|
|
var room_h := 8
|
|
|
|
for x in range(px - radius, px + radius + 1):
|
|
for y in range(py - radius, py + radius + 1):
|
|
var dist := absf(x - px) + absf(y - py)
|
|
if dist <= radius:
|
|
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
|
positions.append({"x": x, "y": y})
|
|
return positions
|
|
|
|
|
|
# -- Test mode helpers --
|
|
|
|
const _TEST_WALLS: Array = [
|
|
# Room walls (8x8 room from (7,7) to (14,14))
|
|
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
|
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
|
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
|
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
|
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
|
Vector2i(7,12), Vector2i(7,13),
|
|
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
|
Vector2i(14,12), Vector2i(14,13),
|
|
# Interior wall blocking NPC
|
|
Vector2i(12, 10),
|
|
]
|
|
|
|
func _test_is_walkable(pos: Vector2i) -> bool:
|
|
return not _TEST_WALLS.has(pos)
|
|
|
|
# Simple LOS check — blocked if a wall tile sits between start and end
|
|
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
|
# Bresenham-lite: check tiles along the line
|
|
var dx := absi(to.x - from.x)
|
|
var dy := absi(to.y - from.y)
|
|
var sx := 1 if from.x < to.x else -1
|
|
var sy := 1 if from.y < to.y else -1
|
|
var err := dx - dy
|
|
var cx := from.x
|
|
var cy := from.y
|
|
while true:
|
|
if cx == to.x and cy == to.y:
|
|
return true
|
|
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
|
|
return false
|
|
var e2 := 2 * err
|
|
if e2 > -dy:
|
|
err -= dy
|
|
cx += sx
|
|
if e2 < dx:
|
|
err += dx
|
|
cy += sy
|
|
return true
|
|
|
|
static func _action_to_delta(action_name: String) -> Vector2i:
|
|
match action_name:
|
|
"MoveNorth": return Vector2i(0, -1)
|
|
"MoveNortheast": return Vector2i(1, -1)
|
|
"MoveEast": return Vector2i(1, 0)
|
|
"MoveSoutheast": return Vector2i(1, 1)
|
|
"MoveSouth": return Vector2i(0, 1)
|
|
"MoveSouthwest": return Vector2i(-1, 1)
|
|
"MoveWest": return Vector2i(-1, 0)
|
|
"MoveNorthwest": return Vector2i(-1, -1)
|
|
_: return Vector2i.ZERO
|
|
|
|
static func _delta_to_facing(delta: Vector2i) -> String:
|
|
match delta:
|
|
Vector2i(0, -1): return "North"
|
|
Vector2i(1, -1): return "Northeast"
|
|
Vector2i(1, 0): return "East"
|
|
Vector2i(1, 1): return "Southeast"
|
|
Vector2i(0, 1): return "South"
|
|
Vector2i(-1, 1): return "Southwest"
|
|
Vector2i(-1, 0): return "West"
|
|
Vector2i(-1, -1): return "Northwest"
|
|
_: return "North"
|