diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e0ee2d1..e8afae084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Symmetric shadowcasting (#110, #359) — Albert Ford algorithm with rational fraction slopes, benchmarked 1.2-10.5x faster than recursive, symmetry guaranteed (D-035) - ObserverSnapshot v2 schema (#358, #25) — version field, GameTime, FacingDirection, VisibleTile, VisibilitySector types, visibility tag on entities - D-035 decision record — symmetric shadowcasting selected over recursive (resolves Q-018) +- Tile rendering engine (#129) — programmatic TileSet with floor/wall/door/object placeholders, renders from snapshot tile data +- Fog overlay rendering (#131) — three visibility states (visible/fog-edge/hidden) via TileMapLayer overlay +- Camera lock to character (#116) — Camera2D smoothing at 2x zoom, locked to player position (D-015) +- Test room environment — 8x8 room with corridor and Manhattan-distance visibility for development without server - `ticket team` command and `--team` filter — comma-separated team assignment for tickets (server, client, joint, content) ### Changed - `start-sprint` skill now requires plan mode — agent must create and get approval for a concrete sprint plan before starting implementation ### Fixed +- Entity renderer protocol field mismatches — "id"→"entity_id", "type"→"kind.variant", "position"→x/y fields now match Protocol.decode_entity() output +- Entity centering — entities (24x24) now centered within 32px tiles instead of top-left aligned - `start-sprint` skill uses `git rev-parse --show-toplevel` for worktree-safe absolute paths — fixes "No such file or directory" errors on team branches +### Changed +- Background clear color set to near-black for unexplored areas (was default Godot gray) +- Scene render order: Tiles → FogOverlay → Entities (fog covers tiles, entities render on top) +- FogOverlay node type changed from Node2D to TileMapLayer for tile-based fog rendering +- GameState now stores visible_tiles and visible_positions from snapshots +- Test snapshot includes player entity (kind "Player"), second NPC entity, tile data, and visibility data + ### Added - Sprint 2 "See" briefings (server, client, joint) — fog of perception through the bridge - `/plan-sprint` skill — automates sprint planning workflow and briefing file generation 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..e2fe9802b 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). @@ -20,7 +23,20 @@ func apply_snapshot(snapshot: Dictionary) -> void: if snapshot.has("entities"): visible_entities = snapshot.entities # Derive player position from the player entity + var found_player := false for entity in visible_entities: if entity.has("entity_id") and entity.entity_id == player_entity_id: player_position = Vector2(entity.x, entity.y) + found_player = true break + if not found_player and visible_entities.size() > 0: + push_warning("GameState: player entity_id %d not found in %d entities" % [ + player_entity_id, visible_entities.size()]) + + 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/constants.gd b/client/scripts/constants.gd new file mode 100644 index 000000000..93eef2669 --- /dev/null +++ b/client/scripts/constants.gd @@ -0,0 +1,6 @@ +class_name Constants + +# Shared constants used across rendering, game state, and protocol layers. + +## Tile size in pixels — all renderers and coordinate conversions use this. +const TILE_SIZE: int = 32 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index d23871ed1..3d26cc0a3 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 * Constants.TILE_SIZE # Send queued input to simulation var inputs = InputMapper.flush_queue() diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 21e40b559..8bf9847bc 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -1,11 +1,15 @@ +class_name EntityRenderer extends Node2D # Entity renderer — manages entity sprites under the Entities node -# Creates/updates/removes Sprite2D children based on entity data +# Creates/updates/removes ColorRect children based on entity data +# Entity format (from Protocol): {entity_id, x, y, z, kind: {variant, data}} -const TILE_SIZE: int = 32 +const TILE_SIZE: int = Constants.TILE_SIZE +const ENTITY_SIZE: int = 24 +const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile -var entity_nodes: Dictionary = {} # id -> Node2D mapping +var entity_nodes: Dictionary = {} # entity_id -> Node2D mapping func _ready() -> void: print("EntityRenderer: Initialized") @@ -16,10 +20,10 @@ func update_entities(entities: Array) -> void: # Create or update entities for entity_data in entities: - if not entity_data.has("id"): + if not entity_data.has("entity_id"): continue - var entity_id = entity_data.id + var entity_id = entity_data.entity_id active_ids.append(entity_id) # Create entity node if it doesn't exist @@ -41,14 +45,19 @@ func update_entities(entities: Array) -> void: 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) + entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE) + entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) - # 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 + # Color based on entity kind (from Protocol decoded format). + # TODO(#130): replace with D-033 relationship colors (teal/green/amber/red). + var kind_variant: String = entity_data.get("kind", {}).get("variant", "") + match kind_variant: + "Npc": + entity_node.color = Color(0.3, 0.6, 0.9) # Placeholder blue + "Player": + entity_node.color = Color(0.88, 0.91, 1.0) # Placeholder light blue + _: + entity_node.color = Color(0.8, 0.8, 0.8) # Gray for unknown add_child(entity_node) entity_nodes[entity_id] = entity_node @@ -62,13 +71,12 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: var entity_node = entity_nodes[entity_id] - # Update position - if entity_data.has("position"): - var pos = entity_data.position - if pos is Array and pos.size() >= 2: - entity_node.position = Vector2(pos[0] * TILE_SIZE, pos[1] * TILE_SIZE) - else: - push_warning("EntityRenderer: malformed position for entity %s" % entity_id) + # Update position from x, y fields (Protocol format), centered within tile + if entity_data.has("x") and entity_data.has("y"): + entity_node.position = Vector2( + entity_data.x * TILE_SIZE + ENTITY_OFFSET, + entity_data.y * TILE_SIZE + ENTITY_OFFSET + ) # Remove an entity node func _remove_entity_node(entity_id: int) -> void: diff --git a/client/scripts/rendering/fog_renderer.gd b/client/scripts/rendering/fog_renderer.gd index 446419d32..41b1cd2c6 100644 --- a/client/scripts/rendering/fog_renderer.gd +++ b/client/scripts/rendering/fog_renderer.gd @@ -1,16 +1,88 @@ -extends Node2D +class_name FogRenderer +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) +# +# Note: fog-edge uses 8-directional neighbors for visual smoothness. +# Actual visibility boundaries come from the server's shadowcasting (D-011). +# Fog-returns-over-time (D-011 decay) is tracked in #113, not here. + +const TILE_SIZE: int = Constants.TILE_SIZE + +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 + img.fill_rect(Rect2i(0, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 1.0)) + # Fog edge (1,0) — semi-transparent dark + img.fill_rect(Rect2i(TILE_SIZE, 0, TILE_SIZE, TILE_SIZE), 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. +# player_pos reserved for future fog-decay tracking (#113). +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 (8-directional neighbors of visible tiles) + 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 diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd new file mode 100644 index 000000000..6e2f1f229 --- /dev/null +++ b/client/scripts/rendering/tile_renderer.gd @@ -0,0 +1,94 @@ +class_name TileRenderer +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 = Constants.TILE_SIZE + +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): + push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [ + tile_type_str, tile_data.x, tile_data.y]) + 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 rect := Rect2i(tile_index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE) + img.fill_rect(rect, 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 + # Border (full tile) + img.fill_rect(Rect2i(x_offset, 0, TILE_SIZE, TILE_SIZE), border) + # Fill (inset by 1px) + img.fill_rect(Rect2i(x_offset + 1, 1, TILE_SIZE - 2, TILE_SIZE - 2), fill) diff --git a/client/scripts/rendering/world_renderer.gd b/client/scripts/rendering/world_renderer.gd index 12155ead8..ad0c140d6 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 _last_tick: int = -1 func _ready() -> void: print("WorldRenderer: Initialized") -# Called each frame to update visuals from game state +# Called each frame to update visuals from game state. +# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives. func update_from_state() -> void: + var tick := GameState.current_tick + if tick == _last_tick: + return + _last_tick = tick + + # Update tiles (may include new chunks or modified tiles) + if tile_renderer and tile_renderer.has_method("update_tiles"): + if GameState.visible_tiles.size() > 0: + tile_renderer.update_tiles(GameState.visible_tiles) + if fog_renderer and fog_renderer.has_method("register_tile_positions"): + fog_renderer.register_tile_positions(GameState.visible_tiles) + + # Update fog overlay from visibility data + 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) diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd new file mode 100644 index 000000000..c51fa31d8 --- /dev/null +++ b/client/tests/test_rendering.gd @@ -0,0 +1,239 @@ +## D-030 Layer 1: Tests for rendering components (tile, fog, entity renderers) +## Validates renderers handle snapshot data correctly. +class_name TestRendering +extends GdUnitTestSuite + +var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") +var FogRendererScript = load("res://scripts/rendering/fog_renderer.gd") +var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd") + +# -- Test data matching Protocol decoded format -- + +var _test_tiles: Array = [ + {"x": 0, "y": 0, "z": 0, "type": "floor"}, + {"x": 1, "y": 0, "z": 0, "type": "wall"}, + {"x": 2, "y": 0, "z": 0, "type": "door"}, + {"x": 3, "y": 0, "z": 0, "type": "object"}, + {"x": 0, "y": 1, "z": 0, "type": "floor"}, +] + +var _test_entities: Array = [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + {"entity_id": 2, "x": 7.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, +] + + +# -- Constants -- + +func test_tile_size_constant() -> void: + assert_that(Constants.TILE_SIZE).is_equal(32) + + +# -- GameState: tile and visibility data -- + +func test_game_state_tick_advances() -> void: + GameState.apply_snapshot({"tick": 1, "tiles": _test_tiles}) + assert_that(GameState.current_tick).is_equal(1) + GameState.apply_snapshot({"tick": 2, "tiles": _test_tiles}) + assert_that(GameState.current_tick).is_equal(2) + +func test_game_state_stores_tiles() -> void: + GameState.apply_snapshot({"tick": 1, "tiles": _test_tiles}) + assert_that(GameState.visible_tiles.size()).is_equal(5) + +func test_game_state_stores_visible_positions() -> void: + var positions := [{"x": 5, "y": 5}, {"x": 6, "y": 5}] + GameState.apply_snapshot({"tick": 1, "visible_positions": positions}) + assert_that(GameState.visible_positions.size()).is_equal(2) + assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true() + assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true() + +func test_game_state_clears_old_visible_positions() -> void: + GameState.apply_snapshot({"tick": 1, "visible_positions": [{"x": 1, "y": 1}]}) + assert_that(GameState.visible_positions.size()).is_equal(1) + GameState.apply_snapshot({"tick": 2, "visible_positions": [{"x": 2, "y": 2}, {"x": 3, "y": 3}]}) + assert_that(GameState.visible_positions.size()).is_equal(2) + assert_that(GameState.visible_positions.has(Vector2i(1, 1))).is_false() + +func test_game_state_same_count_different_tiles_new_tick() -> void: + var tiles_a := [{"x": 0, "y": 0, "z": 0, "type": "floor"}] + var tiles_b := [{"x": 0, "y": 0, "z": 0, "type": "door"}] + GameState.apply_snapshot({"tick": 1, "tiles": tiles_a}) + assert_that(GameState.visible_tiles[0].type).is_equal("floor") + GameState.apply_snapshot({"tick": 2, "tiles": tiles_b}) + assert_that(GameState.current_tick).is_equal(2) + assert_that(GameState.visible_tiles[0].type).is_equal("door") + +func test_game_state_same_count_different_visibility_new_tick() -> void: + GameState.apply_snapshot({"tick": 1, "visible_positions": [{"x": 1, "y": 1}]}) + assert_that(GameState.visible_positions.has(Vector2i(1, 1))).is_true() + GameState.apply_snapshot({"tick": 2, "visible_positions": [{"x": 2, "y": 2}]}) + assert_that(GameState.current_tick).is_equal(2) + assert_that(GameState.visible_positions.has(Vector2i(1, 1))).is_false() + assert_that(GameState.visible_positions.has(Vector2i(2, 2))).is_true() + +func test_game_state_warns_on_missing_player() -> void: + GameState.player_entity_id = 999 + GameState.player_position = Vector2(5, 5) + GameState.apply_snapshot({"tick": 1, "entities": _test_entities}) + assert_that(GameState.player_position).is_equal(Vector2(5, 5)) + + +# -- SimBridge: test data completeness -- + +func test_sim_bridge_test_snapshot_has_tiles() -> void: + SimBridge._test_tick = 0 + var snap = SimBridge._test_snapshot() + assert_that(snap.has("tiles")).is_true() + assert_that(snap.tiles.size()).is_greater(0) + var tile = snap.tiles[0] + assert_that(tile.has("x")).is_true() + assert_that(tile.has("y")).is_true() + assert_that(tile.has("type")).is_true() + +func test_sim_bridge_test_snapshot_has_visible_positions() -> void: + SimBridge._test_tick = 0 + var snap = SimBridge._test_snapshot() + assert_that(snap.has("visible_positions")).is_true() + assert_that(snap.visible_positions.size()).is_greater(0) + var pos = snap.visible_positions[0] + assert_that(pos.has("x")).is_true() + assert_that(pos.has("y")).is_true() + +func test_sim_bridge_test_snapshot_has_player_entity() -> void: + SimBridge._test_tick = 0 + var snap = SimBridge._test_snapshot() + var has_player := false + for entity in snap.entities: + if entity.kind.variant == "Player": + has_player = true + break + assert_that(has_player).is_true() + +func test_sim_bridge_test_snapshot_has_npc() -> void: + SimBridge._test_tick = 0 + var snap = SimBridge._test_snapshot() + var has_npc := false + for entity in snap.entities: + if entity.kind.variant == "Npc": + has_npc = true + break + assert_that(has_npc).is_true() + +func test_sim_bridge_test_tiles_contain_all_types() -> void: + SimBridge._test_tick = 0 + var snap = SimBridge._test_snapshot() + var types: Dictionary = {} + for tile in snap.tiles: + types[tile.type] = true + assert_that(types.has("floor")).is_true() + assert_that(types.has("wall")).is_true() + assert_that(types.has("door")).is_true() + + +# -- EntityRenderer: lifecycle -- + +func _make_entity_renderer() -> Node2D: + var renderer = Node2D.new() + renderer.set_script(EntityRendererScript) + add_child(renderer) + return renderer + +func test_entity_renderer_creates_nodes() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities) + + assert_that(renderer.entity_nodes.size()).is_equal(2) + assert_that(renderer.entity_nodes.has(1)).is_true() + assert_that(renderer.entity_nodes.has(2)).is_true() + renderer.queue_free() + +func test_entity_renderer_removes_stale_entities() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities) + assert_that(renderer.entity_nodes.size()).is_equal(2) + + renderer.update_entities([_test_entities[0]]) + assert_that(renderer.entity_nodes.size()).is_equal(1) + assert_that(renderer.entity_nodes.has(1)).is_true() + renderer.queue_free() + +func test_entity_renderer_positions_centered() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities([_test_entities[0]]) + + var node = renderer.entity_nodes[1] + var offset: float = (Constants.TILE_SIZE - 24) / 2.0 + var expected_x: float = 5.0 * Constants.TILE_SIZE + offset + var expected_y: float = 5.0 * Constants.TILE_SIZE + offset + assert_that(node.position.x).is_equal_approx(expected_x, 0.01) + assert_that(node.position.y).is_equal_approx(expected_y, 0.01) + renderer.queue_free() + +func test_entity_renderer_skips_missing_entity_id() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities([{"x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}}]) + assert_that(renderer.entity_nodes.size()).is_equal(0) + renderer.queue_free() + +func test_entity_renderer_player_color_differs_from_npc() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities) + + var player_node = renderer.entity_nodes[1] as ColorRect + var npc_node = renderer.entity_nodes[2] as ColorRect + assert_that(player_node.color != npc_node.color).is_true() + renderer.queue_free() + +func test_entity_renderer_empty_entities_clears_all() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities) + assert_that(renderer.entity_nodes.size()).is_equal(2) + + renderer.update_entities([]) + assert_that(renderer.entity_nodes.size()).is_equal(0) + renderer.queue_free() + + +# -- FogRenderer: position registration -- + +func _make_fog_renderer() -> TileMapLayer: + var fog = TileMapLayer.new() + fog.set_script(FogRendererScript) + return fog + +func test_fog_renderer_registers_positions() -> void: + var fog := _make_fog_renderer() + fog.register_tile_positions(_test_tiles) + assert_that(fog._all_tile_positions.size()).is_equal(5) + assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_true() + assert_that(fog._all_tile_positions.has(Vector2i(3, 0))).is_true() + assert_that(fog._all_tile_positions.has(Vector2i(0, 1))).is_true() + fog.free() + +func test_fog_renderer_clears_on_re_register() -> void: + var fog := _make_fog_renderer() + fog.register_tile_positions(_test_tiles) + assert_that(fog._all_tile_positions.size()).is_equal(5) + + fog.register_tile_positions([{"x": 10, "y": 10, "z": 0, "type": "floor"}]) + assert_that(fog._all_tile_positions.size()).is_equal(1) + assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_false() + fog.free() + +func test_fog_renderer_handles_empty_data() -> void: + var fog := _make_fog_renderer() + fog._initialized = true + fog.update_fog({}, Vector2.ZERO) + fog.register_tile_positions([]) + assert_that(fog._all_tile_positions.size()).is_equal(0) + fog.free() + + +# -- TileRenderer: tile type constants -- + +func test_tile_type_map_covers_required_types() -> void: + var tile_script = TileRendererScript + var required := ["floor", "wall", "door", "object"] + for tile_type in required: + assert_that(tile_script.TILE_TYPE_MAP.has(tile_type)).is_true()