fix(client): address PR review — invalidation, constants, tests

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:15:42 +01:00
co-authored by Claude Opus 4.6
parent 1443f9dc9e
commit aa3a3fea7d
8 changed files with 267 additions and 39 deletions
+5
View File
@@ -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
+6
View File
@@ -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
+1 -1
View File
@@ -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()
+6 -4
View File
@@ -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
+11 -12
View File
@@ -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)
+10 -11
View File
@@ -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)
+12 -11
View File
@@ -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