diff --git a/client/project.godot b/client/project.godot index 6085c83a7..c60e68590 100644 --- a/client/project.godot +++ b/client/project.godot @@ -98,3 +98,4 @@ pause={ renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" +environment/defaults/default_clear_color=Color(0.05, 0.05, 0.08, 1) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e51e917f6..da297226c 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,12 +1,13 @@ -[gd_scene load_steps=8 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=9 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] [ext_resource type="Script" path="res://scripts/rendering/entity_renderer.gd" id="3_entity"] [ext_resource type="Script" path="res://scripts/rendering/fog_renderer.gd" id="4_fog"] -[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="5_hud"] -[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="6_minimap"] -[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="7_monologue"] +[ext_resource type="Script" path="res://scripts/rendering/tile_renderer.gd" id="5_tile"] +[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"] +[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"] +[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -15,19 +16,23 @@ script = ExtResource("1_main") script = ExtResource("2_world") [node name="TileMapLayer" type="TileMapLayer" parent="World"] +script = ExtResource("5_tile") + +[node name="FogOverlay" type="TileMapLayer" parent="World"] +script = ExtResource("4_fog") [node name="Entities" type="Node2D" parent="World"] script = ExtResource("3_entity") -[node name="FogOverlay" type="Node2D" parent="World"] -script = ExtResource("4_fog") - [node name="Camera2D" type="Camera2D" parent="."] +position_smoothing_enabled = true +position_smoothing_speed = 6.0 +zoom = Vector2(2, 2) [node name="UILayer" type="CanvasLayer" parent="."] -[node name="HUD" parent="UILayer" instance=ExtResource("5_hud")] +[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")] -[node name="Minimap" parent="UILayer" instance=ExtResource("6_minimap")] +[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")] -[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("7_monologue")] +[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index ad08a89ad..35d8af777 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -1,11 +1,14 @@ extends Node -# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities}). +# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}). # Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}. +# Tiles use format: [{x, y, z, type}]. var current_snapshot: Dictionary = {} var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] +var visible_tiles: Array = [] +var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups # Player entity ID — the first entity is assumed to be the player (will be # refined when the server assigns explicit player entity IDs). @@ -24,3 +27,11 @@ func apply_snapshot(snapshot: Dictionary) -> void: if entity.has("entity_id") and entity.entity_id == player_entity_id: player_position = Vector2(entity.x, entity.y) break + + if snapshot.has("tiles"): + visible_tiles = snapshot.tiles + + if snapshot.has("visible_positions"): + visible_positions.clear() + for pos in snapshot.visible_positions: + visible_positions[Vector2i(pos.x, pos.y)] = true diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 954626f0f..7079e6981 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -235,7 +235,7 @@ static func _action_enum_to_wire(action: int) -> String: return "" # Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4). -# Uses the same {tick, entities} schema as Protocol.decode_snapshot() returns. +# Uses the same {tick, entities, tiles} schema as Protocol.decode_snapshot() returns. func _test_snapshot() -> Dictionary: _test_tick += 1 return { @@ -246,7 +246,71 @@ func _test_snapshot() -> Dictionary: "x": 10.0, "y": 10.0, "z": 0, + "kind": { "variant": "Player", "data": null }, + }, + { + "entity_id": 2, + "x": 12.0, + "y": 10.0, + "z": 0, "kind": { "variant": "Npc", "data": null }, }, ], + "tiles": _test_tiles(), + "visible_positions": _test_visible_positions(), } + +# 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 visibility: player at (10,10) can see tiles within radius 4, blocked by walls +func _test_visible_positions() -> Array: + var positions: Array = [] + var player_x := 10 + var player_y := 10 + var radius := 4 + + # Room bounds (inner floor area) + var room_x := 7 + var room_y := 7 + var room_w := 8 + var room_h := 8 + + for x in range(player_x - radius, player_x + radius + 1): + for y in range(player_y - radius, player_y + radius + 1): + var dist := absf(x - player_x) + absf(y - player_y) + if dist <= radius: + # Walls are visible but block further vision + # For test purposes, include all tiles within radius that are inside the room + 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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index d23871ed1..bee074ec1 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -21,8 +21,9 @@ func _process(_delta: float) -> void: if world_renderer and world_renderer.has_method("update_from_state"): world_renderer.update_from_state() - # Track camera to player position (D-015) - camera.position = GameState.player_position * 32 # tile-space to pixel-space + # Track camera to player position every frame (D-015: locked, no panning) + # Camera2D smoothing handles interpolation — we just set the target + camera.global_position = GameState.player_position * 32.0 # Send queued input to simulation var inputs = InputMapper.flush_queue() diff --git a/client/scripts/rendering/fog_renderer.gd b/client/scripts/rendering/fog_renderer.gd index 446419d32..4f23a98bf 100644 --- a/client/scripts/rendering/fog_renderer.gd +++ b/client/scripts/rendering/fog_renderer.gd @@ -1,16 +1,89 @@ -extends Node2D +extends TileMapLayer -# Fog renderer — manages fog of war overlay -# Controls visibility based on player position and fog radius +# Fog renderer — draws fog overlay on non-visible tiles (D-011) +# Three visibility states per tile: +# visible = no fog tile (clear) +# fog-edge = semi-transparent dark overlay (adjacent to visible) +# hidden = opaque black overlay +# +# Atlas layout: +# (0,0) = full fog (opaque black) +# (1,0) = fog edge (semi-transparent) + +const TILE_SIZE: int = 32 + +var _initialized: bool = false +var _all_tile_positions: Dictionary = {} # Vector2i -> true, all known map tiles func _ready() -> void: + _setup_tileset() + _initialized = true 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 +func _setup_tileset() -> void: + var ts := TileSet.new() + ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE) + + var source := TileSetAtlasSource.new() + var img := Image.create(TILE_SIZE * 2, TILE_SIZE, false, Image.FORMAT_RGBA8) + + # Full fog (0,0) — opaque black + _fill_tile(img, 0, Color(0.02, 0.02, 0.05, 1.0)) + # Fog edge (1,0) — semi-transparent dark + _fill_tile(img, 1, Color(0.02, 0.02, 0.05, 0.6)) + + var tex := ImageTexture.create_from_image(img) + source.texture = tex + source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE) + + source.create_tile(Vector2i(0, 0)) + source.create_tile(Vector2i(1, 0)) + + ts.add_source(source) + tile_set = ts + +# Register all known tile positions (called when tile data arrives) +func register_tile_positions(tiles: Array) -> void: + _all_tile_positions.clear() + for tile_data in tiles: + if tile_data.has("x") and tile_data.has("y"): + _all_tile_positions[Vector2i(tile_data.x, tile_data.y)] = true + +# Update fog based on visible positions +# visible_positions: Dictionary of Vector2i -> true +func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void: + if not _initialized: + return + + clear() + + if _all_tile_positions.is_empty() or visible_positions.is_empty(): + return + + # Build set of fog-edge positions (adjacent to visible but not visible themselves) + var fog_edge: Dictionary = {} + var neighbors := [ + Vector2i(-1, 0), Vector2i(1, 0), Vector2i(0, -1), Vector2i(0, 1), + Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1), + ] + + for pos in visible_positions: + for offset in neighbors: + var neighbor_pos: Vector2i = pos + offset + if not visible_positions.has(neighbor_pos) and _all_tile_positions.has(neighbor_pos): + fog_edge[neighbor_pos] = true + + # Place fog tiles on all known positions that aren't visible + for pos in _all_tile_positions: + if visible_positions.has(pos): + continue # Visible — no fog + elif fog_edge.has(pos): + set_cell(pos, 0, Vector2i(1, 0)) # Fog edge — semi-transparent + else: + set_cell(pos, 0, Vector2i(0, 0)) # Full fog — opaque + +func _fill_tile(img: Image, tile_index: int, color: Color) -> void: + var x_offset := tile_index * TILE_SIZE + for x in range(TILE_SIZE): + for y in range(TILE_SIZE): + img.set_pixel(x_offset + x, y, color) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd new file mode 100644 index 000000000..b543249e5 --- /dev/null +++ b/client/scripts/rendering/tile_renderer.gd @@ -0,0 +1,95 @@ +extends TileMapLayer + +# Tile renderer — draws map tiles from ObserverSnapshot tile data +# Uses a programmatic TileSet with placeholder colored rectangles (D-014) +# +# Tile types (atlas coords in the programmatic source): +# (0,0) = floor — dark gray +# (1,0) = wall — lighter gray +# (2,0) = door — brown +# (3,0) = object — teal + +const TILE_SIZE: int = 32 + +enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 } + +# Wire-format string to TileType mapping +const TILE_TYPE_MAP: Dictionary = { + "floor": TileType.FLOOR, + "wall": TileType.WALL, + "door": TileType.DOOR, + "object": TileType.OBJECT, +} + +var _initialized: bool = false + +func _ready() -> void: + _setup_tileset() + _initialized = true + print("TileRenderer: Initialized") + +# Build a programmatic TileSet with colored placeholder tiles +func _setup_tileset() -> void: + var ts := TileSet.new() + ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE) + + # Create an atlas source backed by a programmatic image + var source := TileSetAtlasSource.new() + var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8) + + # Floor (0,0) — dark gray + _fill_tile(img, 0, Color(0.18, 0.18, 0.22)) + # Wall (1,0) — lighter gray with subtle border + _fill_tile_with_border(img, 1, Color(0.4, 0.4, 0.45), Color(0.25, 0.25, 0.3)) + # Door (2,0) — brown + _fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15)) + # Object (3,0) — teal + _fill_tile(img, 3, Color(0.2, 0.45, 0.45)) + + var tex := ImageTexture.create_from_image(img) + source.texture = tex + source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE) + + # Create tile entries in the atlas + for i in range(4): + source.create_tile(Vector2i(i, 0)) + + var source_id := ts.add_source(source) + tile_set = ts + +# Update tiles from snapshot data +# tiles: Array of {x: int, y: int, z: int, type: String} +func update_tiles(tiles: Array) -> void: + if not _initialized: + return + + clear() + + for tile_data in tiles: + if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"): + continue + + var tile_type_str: String = tile_data.type + if not TILE_TYPE_MAP.has(tile_type_str): + continue + + var atlas_x: int = TILE_TYPE_MAP[tile_type_str] + var coords := Vector2i(tile_data.x, tile_data.y) + set_cell(coords, 0, Vector2i(atlas_x, 0)) + +# Fill a tile region with a solid color +func _fill_tile(img: Image, tile_index: int, color: Color) -> void: + var x_offset := tile_index * TILE_SIZE + for x in range(TILE_SIZE): + for y in range(TILE_SIZE): + img.set_pixel(x_offset + x, y, color) + +# Fill a tile region with a color and a 1px border +func _fill_tile_with_border(img: Image, tile_index: int, fill: Color, border: Color) -> void: + var x_offset := tile_index * TILE_SIZE + for x in range(TILE_SIZE): + for y in range(TILE_SIZE): + if x == 0 or y == 0 or x == TILE_SIZE - 1 or y == TILE_SIZE - 1: + img.set_pixel(x_offset + x, y, border) + else: + img.set_pixel(x_offset + x, y, fill) diff --git a/client/scripts/rendering/world_renderer.gd b/client/scripts/rendering/world_renderer.gd index 12155ead8..51480404a 100644 --- a/client/scripts/rendering/world_renderer.gd +++ b/client/scripts/rendering/world_renderer.gd @@ -2,19 +2,36 @@ extends Node2D # World renderer — manages all visual representation from GameState # Attached to the World node in main.tscn +# Render order (scene tree): TileMapLayer -> FogOverlay -> Entities -@onready var entity_renderer = $Entities +@onready var tile_renderer = $TileMapLayer @onready var fog_renderer = $FogOverlay +@onready var entity_renderer = $Entities + +var _tiles_dirty: bool = true func _ready() -> void: print("WorldRenderer: Initialized") # Called each frame to update visuals from game state func update_from_state() -> void: + # Update tiles (only when tile data changes) + if tile_renderer and tile_renderer.has_method("update_tiles"): + if _tiles_dirty and GameState.visible_tiles.size() > 0: + tile_renderer.update_tiles(GameState.visible_tiles) + # Register tile positions with fog renderer for coverage + if fog_renderer and fog_renderer.has_method("register_tile_positions"): + fog_renderer.register_tile_positions(GameState.visible_tiles) + _tiles_dirty = false + + # Update fog overlay + if fog_renderer and fog_renderer.has_method("update_fog"): + fog_renderer.update_fog(GameState.visible_positions, GameState.player_position) + # Update entity sprites if entity_renderer and entity_renderer.has_method("update_entities"): entity_renderer.update_entities(GameState.visible_entities) - # Update fog overlay (fog data will come in D-020 expansion) - if fog_renderer and fog_renderer.has_method("update_fog"): - fog_renderer.update_fog({}, GameState.player_position) +# Mark tiles as needing re-render (call when tile data changes significantly) +func invalidate_tiles() -> void: + _tiles_dirty = true