From 1883a6a6d43b7df240fda9313b110027e0f1cd07 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 22:34:24 +0100 Subject: [PATCH 1/6] docs(sprints): add updated joint briefing for sprint 2 Joint tasks with ticket IDs (#356-#360), dependency chains, and sprint completion proof criteria for client team reference. Co-Authored-By: Claude Opus 4.6 --- docs/sprints/sprint-2/joint.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/sprints/sprint-2/joint.md b/docs/sprints/sprint-2/joint.md index bea579cb0..62c6edae5 100644 --- a/docs/sprints/sprint-2/joint.md +++ b/docs/sprints/sprint-2/joint.md @@ -4,21 +4,20 @@ **Branches:** `server` + `client` (merge to main for integration) -## Pre-Sprint +## Design Tasks -| # | Task | Owner | Notes | -|---|------|-------|-------| -| #359 | Resolve Q-018 (shadowcast algorithm) | Tyre, Dudley | Blocks #110. Benchmark symmetric vs recursive at 150x150 scale. | -| #360 | Resolve Q-019 (entity ID stability) | Tyre, Dudley | Affects client entity lifecycle (#130). | -| #358 | Design ObserverSnapshot v2 schema | Tyre | Add visible_tiles, player_facing, game_time fields. Both teams need this before fog integration. | +| # | Title | Owner | Blocks | +|---|-------|-------|--------| +| #359 | Resolve Q-018: shadowcasting algorithm selection | server (Tyre, Dudley) | #110 | +| #360 | Resolve Q-019: entity ID stability | joint (Tyre, Dudley) | #130 | +| #358 | Design ObserverSnapshot v2 schema | joint (Tyre) | #112, #113, #25 | ## Integration Tickets | # | Title | Blocked by | |---|-------|------------| -| #81 | End-to-end connection test | ~~Sprint 1 carry-over~~ **done** | -| #356 | Fog data through bridge | Server #112, Client #113 | -| #357 | Sprint 2 proof: fog of perception | All above | +| #356 | Fog data through bridge | #112, #113 | +| #357 | Sprint 2 proof: fog of perception | #356, #116, #25 | Use `db/connectors/ticket show ` for full details. @@ -43,9 +42,9 @@ This is the first moment the game *feels* like an immersive sim — you can't se ## Notes -- **#81 (done):** End-to-end connection test completed in Sprint 1. Full round-trip verified. -- **ObserverSnapshot v2:** The current `ObserverSnapshot` (`server/src/bridge/types.rs`) only has `tick` + `entities: Vec`. Sprint 2 needs: tile visibility data, facing direction, time-of-day. Design the expanded schema before both teams start, so the contract is clear. -- **Fog data through bridge:** Server #112 produces visibility-filtered snapshots. Client #113 consumes them. The bridge already handles MessagePack serialization — new fields just need to be added to both sides. +- **#81 (done):** End-to-end connection test completed in Sprint 1. IPC bridge is operational. +- **#358 (ObserverSnapshot v2):** The current `ObserverSnapshot` (`server/src/bridge/types.rs`) only has `tick` + `entities: Vec`. Sprint 2 needs: tile visibility data, facing direction, time-of-day. Design the expanded schema before both teams start, so the contract is clear. +- **#356 (Fog data through bridge):** Server #112 produces visibility-filtered snapshots. Client #113 consumes them. The bridge already handles MessagePack serialization — new fields just need to be added to both sides. - **Test plan per D-030 Phase 1:** Sprint 2 aligns with D-030's "Phase 1 (sprint 1-2): test infra + collision/pathfinding/time." Collision tests exist (`movement.rs`). Shadowcasting (#110) needs unit tests for known LOS scenarios (corridor, corner peek, wall block). Vision cone (#111) needs sector boundary tests. ## PR Workflow From 55b775ee73f6d752efa6a992288cecb948630f87 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 23:47:49 +0100 Subject: [PATCH 2/6] fix(client): fix entity renderer to match protocol format entity_renderer.gd used wrong field names ("id" instead of "entity_id", "type" instead of "kind.variant", "position" instead of x/y). Now matches Protocol.decode_entity() output exactly. Also centers entities (24x24) within 32px tiles. Co-Authored-By: Claude Opus 4.6 --- client/scripts/rendering/entity_renderer.gd | 42 ++++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 21e40b559..97d288461 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -1,11 +1,14 @@ 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 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 +19,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 +44,18 @@ 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) + var kind_variant: String = entity_data.get("kind", {}).get("variant", "") + match kind_variant: + "Npc": + entity_node.color = Color(0.3, 0.6, 0.9) # Blue for NPCs + "Player": + entity_node.color = Color(0.88, 0.91, 1.0) # Light blue for player + _: + 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 +69,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: From dcf887cb4911003a9245ab0df2e0b1c4ed6c82a5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 23:48:02 +0100 Subject: [PATCH 3/6] 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 --- client/project.godot | 1 + client/scenes/main.tscn | 25 +++--- client/scripts/autoloads/game_state.gd | 13 ++- client/scripts/autoloads/sim_bridge.gd | 66 ++++++++++++++- client/scripts/main.gd | 5 +- client/scripts/rendering/fog_renderer.gd | 95 +++++++++++++++++++--- client/scripts/rendering/tile_renderer.gd | 95 ++++++++++++++++++++++ client/scripts/rendering/world_renderer.gd | 25 +++++- 8 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 client/scripts/rendering/tile_renderer.gd 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 From 1443f9dc9ee7217ec6ce4bac6de9998253b90ea5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 23:48:28 +0100 Subject: [PATCH 4/6] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f1e39b1..be7664ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,24 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- 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) ### 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 From aa3a3fea7d0e9747a3e2f357b4ec81639c79ebda Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:15:42 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix(client):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20invalidation,=20constants,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from Hoshe + Tyre on PR #8: - Extract shared TILE_SIZE to Constants class_name (Tyre #5, Hoshe #7) - Fix tile invalidation: detect tile/visibility count changes instead of one-shot dirty flag, supports chunk loading (Tyre #3, #4) - Fog dirty tracking: only re-render when visible_positions changes - Add bounds warning for unknown tile types (Hoshe #1) - Add player-not-found warning in GameState (Hoshe #5) - Use Image.fill_rect() instead of pixel loops (Tyre #10) - Document _player_pos as reserved for fog decay #113 (Tyre #6) - Add TODO(#130) for D-033 relationship colors (Hoshe #3, Tyre #8) - Add class_name to EntityRenderer, FogRenderer, TileRenderer - 20 new rendering tests (D-030 Layer 1): entity lifecycle, fog registration, tile type mapping, snapshot completeness, constants - 65 total tests passing, 0 failures Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/game_state.gd | 5 + client/scripts/constants.gd | 6 + client/scripts/main.gd | 2 +- client/scripts/rendering/entity_renderer.gd | 10 +- client/scripts/rendering/fog_renderer.gd | 23 +-- client/scripts/rendering/tile_renderer.gd | 21 +- client/scripts/rendering/world_renderer.gd | 23 ++- client/tests/test_rendering.gd | 216 ++++++++++++++++++++ 8 files changed, 267 insertions(+), 39 deletions(-) create mode 100644 client/scripts/constants.gd create mode 100644 client/tests/test_rendering.gd diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 35d8af777..e2fe9802b 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -23,10 +23,15 @@ 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 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 bee074ec1..3d26cc0a3 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -23,7 +23,7 @@ func _process(_delta: float) -> void: # 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 + 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 97d288461..8bf9847bc 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -1,10 +1,11 @@ +class_name EntityRenderer extends Node2D # Entity renderer — manages entity sprites under the Entities node # 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 @@ -47,13 +48,14 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE) entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) - # Color based on entity kind (from Protocol decoded format) + # 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) # Blue for NPCs + entity_node.color = Color(0.3, 0.6, 0.9) # Placeholder blue "Player": - entity_node.color = Color(0.88, 0.91, 1.0) # Light blue for 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 diff --git a/client/scripts/rendering/fog_renderer.gd b/client/scripts/rendering/fog_renderer.gd index 4f23a98bf..41b1cd2c6 100644 --- a/client/scripts/rendering/fog_renderer.gd +++ b/client/scripts/rendering/fog_renderer.gd @@ -1,3 +1,4 @@ +class_name FogRenderer extends TileMapLayer # Fog renderer — draws fog overlay on non-visible tiles (D-011) @@ -9,8 +10,12 @@ extends TileMapLayer # 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 = 32 +const TILE_SIZE: int = Constants.TILE_SIZE var _initialized: bool = false var _all_tile_positions: Dictionary = {} # Vector2i -> true, all known map tiles @@ -28,9 +33,9 @@ func _setup_tileset() -> void: 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)) + 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 - _fill_tile(img, 1, Color(0.02, 0.02, 0.05, 0.6)) + 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 @@ -49,8 +54,8 @@ func register_tile_positions(tiles: Array) -> void: 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 +# 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 @@ -60,7 +65,7 @@ func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void: 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) + # 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), @@ -81,9 +86,3 @@ func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void: 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 index b543249e5..6e2f1f229 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -1,3 +1,4 @@ +class_name TileRenderer extends TileMapLayer # Tile renderer — draws map tiles from ObserverSnapshot tile data @@ -9,7 +10,7 @@ extends TileMapLayer # (2,0) = door — brown # (3,0) = object — teal -const TILE_SIZE: int = 32 +const TILE_SIZE: int = Constants.TILE_SIZE enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 } @@ -71,6 +72,8 @@ func update_tiles(tiles: Array) -> void: 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] @@ -79,17 +82,13 @@ func update_tiles(tiles: Array) -> void: # 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) + 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 - 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) + # 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 51480404a..7e2d9421a 100644 --- a/client/scripts/rendering/world_renderer.gd +++ b/client/scripts/rendering/world_renderer.gd @@ -8,30 +8,31 @@ extends Node2D @onready var fog_renderer = $FogOverlay @onready var entity_renderer = $Entities -var _tiles_dirty: bool = true +var _last_tile_count: int = 0 +var _last_visible_count: int = 0 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) + var tile_count := GameState.visible_tiles.size() + var visible_count := GameState.visible_positions.size() + + # Update tiles when tile data changes (new chunks loaded, D-012) if tile_renderer and tile_renderer.has_method("update_tiles"): - if _tiles_dirty and GameState.visible_tiles.size() > 0: + if tile_count > 0 and tile_count != _last_tile_count: 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 + _last_tile_count = tile_count - # Update fog overlay + # Update fog overlay when visibility changes if fog_renderer and fog_renderer.has_method("update_fog"): - fog_renderer.update_fog(GameState.visible_positions, GameState.player_position) + if visible_count != _last_visible_count: + fog_renderer.update_fog(GameState.visible_positions, GameState.player_position) + _last_visible_count = visible_count # Update entity sprites if entity_renderer and entity_renderer.has_method("update_entities"): entity_renderer.update_entities(GameState.visible_entities) - -# Mark tiles as needing re-render (call when tile data changes significantly) -func invalidate_tiles() -> void: - _tiles_dirty = true diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd new file mode 100644 index 000000000..ad28cbb82 --- /dev/null +++ b/client/tests/test_rendering.gd @@ -0,0 +1,216 @@ +## 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_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_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() From 6ff6be9a78ee754f7e07b289c8ec055e5bf4d501 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 00:24:30 +0100 Subject: [PATCH 6/6] fix(client): use tick-based invalidation for world renderer Count-based dirty tracking missed content changes when tile/visibility count stayed the same (e.g., door opening, player moving through corridor). Now re-renders all layers when GameState.current_tick advances, which correctly handles every snapshot change. Adds 3 tests for tick advancement and same-count-different-content scenarios. 68 total tests, 0 failures. Co-Authored-By: Claude Opus 4.6 --- client/scripts/rendering/world_renderer.gd | 23 +++++++++++----------- client/tests/test_rendering.gd | 23 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/client/scripts/rendering/world_renderer.gd b/client/scripts/rendering/world_renderer.gd index 7e2d9421a..ad0c140d6 100644 --- a/client/scripts/rendering/world_renderer.gd +++ b/client/scripts/rendering/world_renderer.gd @@ -8,30 +8,29 @@ extends Node2D @onready var fog_renderer = $FogOverlay @onready var entity_renderer = $Entities -var _last_tile_count: int = 0 -var _last_visible_count: int = 0 +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 tile_count := GameState.visible_tiles.size() - var visible_count := GameState.visible_positions.size() + var tick := GameState.current_tick + if tick == _last_tick: + return + _last_tick = tick - # Update tiles when tile data changes (new chunks loaded, D-012) + # Update tiles (may include new chunks or modified tiles) if tile_renderer and tile_renderer.has_method("update_tiles"): - if tile_count > 0 and tile_count != _last_tile_count: + 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) - _last_tile_count = tile_count - # Update fog overlay when visibility changes + # Update fog overlay from visibility data if fog_renderer and fog_renderer.has_method("update_fog"): - if visible_count != _last_visible_count: - fog_renderer.update_fog(GameState.visible_positions, GameState.player_position) - _last_visible_count = visible_count + fog_renderer.update_fog(GameState.visible_positions, GameState.player_position) # Update entity sprites if entity_renderer and entity_renderer.has_method("update_entities"): diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index ad28cbb82..c51fa31d8 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -31,6 +31,12 @@ func test_tile_size_constant() -> void: # -- 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) @@ -49,6 +55,23 @@ func test_game_state_clears_old_visible_positions() -> void: 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)