feat(client): initialize Godot 4 project boilerplate (epic #277)

Set up the complete Godot 4.6 client foundation as a pure renderer (D-020):
- project.godot with 2D rendering, autoloads, input actions
- Main scene: Game > World (TileMapLayer, Entities, FogOverlay) + Camera2D + UILayer
- Autoloads: SimBridge (connection state machine + test mode), GameState, InputMapper
- Rendering stubs: WorldRenderer, EntityRenderer, FogRenderer
- UI stubs: HUD (health/perception/time), Minimap, MonologueDisplay
- Input mapping: WASD/arrows, E (interact), Tab (perception), Esc (menu), Space (pause)
- gdUnit4 smoke tests: scene loads, autoloads registered (2/2 passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 16:52:27 +01:00
co-authored by Claude Opus 4.6
parent 61e0762a84
commit 603c24e177
34 changed files with 656 additions and 0 deletions
View File
+28
View File
@@ -0,0 +1,28 @@
extends Node
# Updated each frame from ObserverSnapshot data
var current_snapshot: Dictionary = {}
var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
var fog_state: Dictionary = {}
var hud_data: Dictionary = {}
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
# Parse player data
if snapshot.has("player") and snapshot.player.has("position"):
var pos = snapshot.player.position
player_position = Vector2(pos[0], pos[1])
# Parse entities
if snapshot.has("entities"):
visible_entities = snapshot.entities
# Parse fog state
if snapshot.has("fog"):
fog_state = snapshot.fog
# Parse HUD data
if snapshot.has("hud"):
hud_data = snapshot.hud
@@ -0,0 +1 @@
uid://dojylr6xlag0s
+58
View File
@@ -0,0 +1,58 @@
extends Node
# Semantic actions — NO raw key codes cross the bridge
enum Action {
MOVE_NORTH, MOVE_SOUTH, MOVE_EAST, MOVE_WEST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE
}
var input_queue: Array[Dictionary] = []
func _unhandled_input(event: InputEvent) -> void:
# Only process key press events (not releases or repeats)
if not event is InputEventKey:
return
if not event.pressed or event.echo:
return
var action: Action = -1
var action_name: String = ""
# Map input actions to semantic Action enum
if event.is_action_pressed("move_north"):
action = Action.MOVE_NORTH
action_name = "move_north"
elif event.is_action_pressed("move_south"):
action = Action.MOVE_SOUTH
action_name = "move_south"
elif event.is_action_pressed("move_east"):
action = Action.MOVE_EAST
action_name = "move_east"
elif event.is_action_pressed("move_west"):
action = Action.MOVE_WEST
action_name = "move_west"
elif event.is_action_pressed("interact"):
action = Action.INTERACT
action_name = "interact"
elif event.is_action_pressed("perception_mode"):
action = Action.USE_PERCEPTION_MODE
action_name = "perception_mode"
elif event.is_action_pressed("open_menu"):
action = Action.OPEN_MENU
action_name = "open_menu"
elif event.is_action_pressed("pause"):
action = Action.PAUSE
action_name = "pause"
# Queue the action if valid
if action != -1:
input_queue.append({
"action": action,
"timestamp_msec": Time.get_ticks_msec()
})
get_viewport().set_input_as_handled()
func flush_queue() -> Array[Dictionary]:
var queue = input_queue.duplicate()
input_queue.clear()
return queue
@@ -0,0 +1 @@
uid://censpds5c1g8r
+80
View File
@@ -0,0 +1,80 @@
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
# 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 (real implementation comes later)
func connect_to_sim() -> void:
_set_state(ConnectionState.CONNECTING)
# TODO: Actual connection logic when IPC/MessagePack is implemented
if test_mode:
_set_state(ConnectionState.CONNECTED)
else:
_set_state(ConnectionState.ERROR)
# Disconnect from simulation server
func disconnect_from_sim() -> void:
_set_state(ConnectionState.DISCONNECTED)
# Send input to simulation (real implementation comes later)
func send_input(player_input: Dictionary) -> void:
if state != ConnectionState.CONNECTED:
return
# TODO: Serialize and send via MessagePack when IPC is implemented
pass
# Poll for snapshot from simulation
func poll_snapshot() -> Variant:
if state != ConnectionState.CONNECTED:
return null
if test_mode:
var snapshot = _test_snapshot()
snapshot_received.emit(snapshot)
return snapshot
# TODO: Actual polling logic when IPC/MessagePack is implemented
return null
# Hardcoded test snapshot for development
func _test_snapshot() -> Dictionary:
return {
"tick": Time.get_ticks_msec() / 100, # Increment over time for testing
"player": {
"position": [10, 10],
"health": 100
},
"entities": [
{
"id": 1,
"type": "npc",
"position": [12, 8],
"name": "Test NPC"
},
],
"fog": {
"radius": 8
},
"hud": {
"perception_mode": "baseline",
"time": "08:00"
}
}
@@ -0,0 +1 @@
uid://cmjlrxs0vkvbb
+31
View File
@@ -0,0 +1,31 @@
extends Node2D
@onready var world_renderer = $World
@onready var hud = $UILayer/HUD
func _ready() -> void:
print("The Settled Reach — client initialized")
# Connect to simulation (will use test mode initially)
SimBridge.connect_to_sim()
func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot = SimBridge.poll_snapshot()
if snapshot != null:
GameState.apply_snapshot(snapshot)
# Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# Update HUD
if hud and hud.has_method("update_from_hud_data"):
hud.update_from_hud_data(GameState.hud_data)
if snapshot.has("player") and snapshot.player.has("health"):
hud.update_health(snapshot.player.health)
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
SimBridge.send_input(input)
+1
View File
@@ -0,0 +1 @@
uid://lcfje72ikd6b
@@ -0,0 +1,75 @@
extends Node2D
# Entity renderer — manages entity sprites under the Entities node
# Creates/updates/removes Sprite2D children based on entity data
var entity_nodes: Dictionary = {} # id -> Node2D mapping
func _ready() -> void:
print("EntityRenderer: Initialized")
# Update entities from snapshot data
func update_entities(entities: Array) -> void:
var active_ids: Array = []
# Create or update entities
for entity_data in entities:
if not entity_data.has("id"):
continue
var entity_id = entity_data.id
active_ids.append(entity_id)
# Create entity node if it doesn't exist
if not entity_nodes.has(entity_id):
_create_entity_node(entity_id, entity_data)
else:
_update_entity_node(entity_id, entity_data)
# Remove entities that are no longer visible
var ids_to_remove: Array = []
for entity_id in entity_nodes.keys():
if entity_id not in active_ids:
ids_to_remove.append(entity_id)
for entity_id in ids_to_remove:
_remove_entity_node(entity_id)
# Create a new entity node (placeholder visual)
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node = ColorRect.new()
entity_node.name = "Entity_" + str(entity_id)
entity_node.size = Vector2(32, 32)
entity_node.pivot_offset = Vector2(16, 16)
# Color based on type
if entity_data.get("type") == "npc":
entity_node.color = Color(0.3, 0.6, 0.9) # Blue for NPCs
else:
entity_node.color = Color(0.8, 0.8, 0.8) # Gray for unknown
add_child(entity_node)
entity_nodes[entity_id] = entity_node
_update_entity_node(entity_id, entity_data)
# Update an existing entity node
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not entity_nodes.has(entity_id):
return
var entity_node = entity_nodes[entity_id]
# Update position
if entity_data.has("position"):
var pos = entity_data.position
entity_node.position = Vector2(pos[0] * 32, pos[1] * 32) # 32px grid
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
if not entity_nodes.has(entity_id):
return
var entity_node = entity_nodes[entity_id]
entity_node.queue_free()
entity_nodes.erase(entity_id)
@@ -0,0 +1 @@
uid://civev5c8xvtc3
+16
View File
@@ -0,0 +1,16 @@
extends Node2D
# Fog renderer — manages fog of war overlay
# Controls visibility based on player position and fog radius
func _ready() -> void:
print("FogRenderer: Initialized")
# Update fog visibility (stub for now)
func update_fog(fog_data: Dictionary, player_pos: Vector2) -> void:
# TODO: Implement fog of war rendering
# This will control what the player can see based on:
# - fog_data.radius (visibility radius)
# - player_pos (center of visible area)
# - Perception mode state (affects visibility)
pass
@@ -0,0 +1 @@
uid://cfyv4qt7yybib
@@ -0,0 +1,20 @@
extends Node2D
# World renderer — manages all visual representation from GameState
# Attached to the World node in main.tscn
@onready var entity_renderer = $Entities
@onready var fog_renderer = $FogOverlay
func _ready() -> void:
print("WorldRenderer: Initialized")
# Called each frame to update visuals from game state
func update_from_state() -> void:
# Update entity sprites
if entity_renderer and entity_renderer.has_method("update_entities"):
entity_renderer.update_entities(GameState.visible_entities)
# Update fog overlay
if fog_renderer and fog_renderer.has_method("update_fog"):
fog_renderer.update_fog(GameState.fog_state, GameState.player_position)
@@ -0,0 +1 @@
uid://djon0gnkn6sh2