feat(client): add tile rendering, fog overlay, and camera smoothing

Sprint 2 rendering pipeline: tiles (#129), fog (#131), camera (#116).

- tile_renderer.gd: programmatic TileSet with floor/wall/door/object
  placeholder tiles, renders from snapshot tile data
- fog_renderer.gd: TileMapLayer overlay with three visibility states
  (visible/fog-edge/hidden), computed from visible_positions data
- Camera2D: smoothing enabled (speed 6.0), 2x zoom, locked to player
- game_state.gd: stores visible_tiles and visible_positions from snapshots
- sim_bridge.gd: test data with 8x8 room, corridor, and Manhattan
  distance visibility for development without server
- Scene render order: Tiles -> FogOverlay -> Entities
- Background clear color set to near-black for unexplored areas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 23:48:02 +01:00
co-authored by Claude Opus 4.6
parent 55b775ee73
commit dcf887cb49
8 changed files with 296 additions and 29 deletions
+84 -11
View File
@@ -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)
+95
View File
@@ -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)
+21 -4
View File
@@ -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