diff --git a/CHANGELOG.md b/CHANGELOG.md index ff56c2a9d..0e17af82f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed +- Type safety in GameState visible_tiles loop — validates Dictionary with x/y keys before access (PR #11 review) +- Entity renderer skips redundant modulate.a writes when alpha unchanged (PR #11 review) + ### Added - Knowledge graph system (#361, #362, #363, #365) — per-entity KnowledgeGraph component (D-041), StableEntityId + EntityRegistry, KnowledgeEventQueue, decay system, 4-level confidence hierarchy - Direct observation knowledge flow (#364) — perception emits DirectObservation/LeftLOS events to knowledge graph, entities entering/leaving LOS tracked +- Protocol v2 decoder — extracts game_time, player_facing, visible_tiles, and per-entity visibility sectors from ObserverSnapshot v2 +- D-033 entity color palette (#130) — relationship-based colors (teal/green/amber/red), Phase 1 defaults by entity kind +- Peripheral vision dimming — entities in peripheral vision rendered at 50% alpha (D-015) +- Player facing direction indicator — Polygon2D triangle on player entity showing 8-directional facing +- GameState v2 fields — game_time, player_facing, visibility_sectors stored from snapshot data +- Test snapshot updated to v2 format with visibility sectors, game_time, and player_facing - Observer visibility query (#112) — replaces unfiltered generate_snapshot with LOS-filtered compute_observer_snapshot combining shadowcasting + vision cone - Vision cone system (#111) — forward/peripheral/blind sectors per D-015, Facing component updated on movement - Symmetric shadowcasting (#110, #359) — Albert Ford algorithm with rational fraction slopes, benchmarked 1.2-10.5x faster than recursive, symmetry guaranteed (D-035) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index e2fe9802b..c695e08e1 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -10,6 +10,11 @@ var visible_entities: Array = [] var visible_tiles: Array = [] var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups +# v2 fields (D-015, D-031) +var game_time: Dictionary = {} # {day, time_of_day, day_phase, paused} or empty +var player_facing: String = "North" # 8-directional facing direction +var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral" + # Player entity ID — the first entity is assumed to be the player (will be # refined when the server assigns explicit player entity IDs). var player_entity_id: int = 1 @@ -40,3 +45,27 @@ func apply_snapshot(snapshot: Dictionary) -> void: visible_positions.clear() for pos in snapshot.visible_positions: visible_positions[Vector2i(pos.x, pos.y)] = true + + # v2: game_time (D-031) + if snapshot.has("game_time") and snapshot.game_time is Dictionary: + game_time = snapshot.game_time + + # v2: player_facing (D-015) + if snapshot.has("player_facing") and snapshot.player_facing is String: + player_facing = snapshot.player_facing + + # v2: visible_tiles with visibility sectors + # Derives visible_positions when not explicitly provided (real server mode) + if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: + visibility_sectors.clear() + var has_explicit_positions := snapshot.has("visible_positions") + if not has_explicit_positions: + visible_positions.clear() + for vtile in snapshot.visible_tiles: + if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"): + continue + var pos := Vector2i(vtile.x, vtile.y) + if vtile.has("visibility"): + visibility_sectors[pos] = vtile.visibility + if not has_explicit_positions: + visible_positions[pos] = true diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 7079e6981..bee14b883 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -240,6 +240,14 @@ func _test_snapshot() -> Dictionary: _test_tick += 1 return { "tick": _test_tick, + "version": 2, + "game_time": { + "day": 0, + "time_of_day": 0, + "day_phase": "Morning", + "paused": false, + }, + "player_facing": "North", "entities": [ { "entity_id": 1, @@ -247,6 +255,7 @@ func _test_snapshot() -> Dictionary: "y": 10.0, "z": 0, "kind": { "variant": "Player", "data": null }, + "visibility": "Forward", }, { "entity_id": 2, @@ -254,9 +263,11 @@ func _test_snapshot() -> Dictionary: "y": 10.0, "z": 0, "kind": { "variant": "Npc", "data": null }, + "visibility": "Peripheral", }, ], "tiles": _test_tiles(), + "visible_tiles": _test_visible_tiles(), "visible_positions": _test_visible_positions(), } @@ -292,6 +303,27 @@ func _test_tiles() -> Array: return tiles +# Test visible tiles with visibility sectors (v2 format) +# Tiles ahead of the player (y <= player_y) are Forward, others Peripheral. +func _test_visible_tiles() -> Array: + var vtiles: Array = [] + var player_x := 10 + var player_y := 10 + var radius := 4 + 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: + if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: + var sector: String = "Forward" if y <= player_y else "Peripheral" + vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) + return vtiles + # Test visibility: player at (10,10) can see tiles within radius 4, blocked by walls func _test_visible_positions() -> Array: var positions: Array = [] diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 93eef2669..f7df9800f 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -4,3 +4,21 @@ class_name Constants ## Tile size in pixels — all renderers and coordinate conversions use this. const TILE_SIZE: int = 32 + +# D-033: Entity relationship color palette +# Color represents the player's RELATIONSHIP to the entity, not an objective property. +# Phase 1: default colors mapped by entity kind (Player/Npc/Object/Terrain). +# Phase 2 (#361): colors derived from RelationshipState via the knowledge graph. +const ENTITY_COLOR_UNKNOWN: Color = Color("#4a9ebb") # Unknown/Neutral — cool teal +const ENTITY_COLOR_FRIENDLY: Color = Color("#6bc9a6") # Known/Friendly — soft green +const ENTITY_COLOR_POI: Color = Color("#e8c547") # Person of Interest — warm amber +const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — muted red +const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey +const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective) + +# D-015: Peripheral vision dimming +const PERIPHERAL_ALPHA: float = 0.5 + +# Facing direction indicator +const FACING_INDICATOR_SIZE: float = 6.0 +const FACING_INDICATOR_OFFSET: float = 14.0 diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 319737f52..dad23ca0c 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -13,7 +13,9 @@ class_name Protocol # -- Decode: bytes from server → GDScript types -------------------------------- ## Decode an ObserverSnapshot from MessagePack bytes. -## Returns { "tick": int, "entities": Array[Dictionary] } or null on error. +## Returns decoded snapshot Dictionary or null on error. +## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty +## when decoding v1 snapshots for backward compatibility. static func decode_snapshot(bytes: PackedByteArray) -> Variant: var result = Messagepack.decode(bytes) if result.status != null: @@ -41,10 +43,41 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: # GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63 # in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031). var tick: int = raw["tick"] + + # v2 fields — optional for backward compatibility + var version: Variant = raw.get("version") + var game_time: Variant = raw.get("game_time") + + # player_facing: FacingDirection is a unit enum → bare string in rmp_serde + var player_facing: Variant = null + var raw_facing: Variant = raw.get("player_facing") + if raw_facing is String: + player_facing = raw_facing + + # visible_tiles: Array of {x, y, z, visibility} + var visible_tiles: Array = [] + var raw_vtiles: Variant = raw.get("visible_tiles") + if raw_vtiles is Array: + for raw_tile in raw_vtiles: + if raw_tile is Dictionary and raw_tile.has("x") and raw_tile.has("y") and raw_tile.has("z"): + var tile_entry := { + "x": int(raw_tile["x"]), + "y": int(raw_tile["y"]), + "z": int(raw_tile["z"]), + } + var vis: Variant = raw_tile.get("visibility") + if vis is String: + tile_entry["visibility"] = vis + visible_tiles.append(tile_entry) + return { "tick": tick, "entities": entities, "decode_errors": dropped, + "version": version, + "game_time": game_time, + "player_facing": player_facing, + "visible_tiles": visible_tiles, } @@ -56,12 +89,20 @@ static func _decode_entity(raw: Dictionary) -> Variant: return null var entity_id: int = raw["entity_id"] + + # v2: visibility sector (Forward/Peripheral). null for v1 entities. + var visibility: Variant = null + var raw_vis: Variant = raw.get("visibility") + if raw_vis is String: + visibility = raw_vis + return { "entity_id": entity_id, "x": float(raw["x"]), "y": float(raw["y"]), "z": int(raw["z"]), "kind": _decode_enum_variant(raw["kind"]), + "visibility": visibility, } diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 8bf9847bc..cd6a439a5 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -3,7 +3,10 @@ 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}} +# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility} +# +# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive +# color from RelationshipState via the knowledge graph. const TILE_SIZE: int = Constants.TILE_SIZE const ENTITY_SIZE: int = 24 @@ -41,30 +44,27 @@ func update_entities(entities: Array) -> void: for entity_id in ids_to_remove: _remove_entity_node(entity_id) -# Create a new entity node (placeholder visual) +# Create a new entity node with D-033 color and optional facing indicator 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(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). - # 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 + # D-033 color by entity kind (Phase 1 default) + # TODO(#361): derive from RelationshipState via knowledge graph + entity_node.color = _color_for_kind(entity_data) add_child(entity_node) entity_nodes[entity_id] = entity_node + # Add facing indicator for the player entity + if entity_id == GameState.player_entity_id: + _add_facing_indicator(entity_node) + _update_entity_node(entity_id, entity_data) -# Update an existing entity node +# Update an existing entity node (position, visibility dimming, facing) func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: if not entity_nodes.has(entity_id): return @@ -78,6 +78,19 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: entity_data.y * TILE_SIZE + ENTITY_OFFSET ) + # v2: Peripheral vision dimming (D-015) + # null visibility (v1 backward compat) defaults to full alpha + var visibility: Variant = entity_data.get("visibility") + var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0 + if not is_equal_approx(entity_node.modulate.a, target_alpha): + entity_node.modulate.a = target_alpha + + # v2: Update facing indicator rotation (player entity only) + if entity_id == GameState.player_entity_id: + var indicator = entity_node.get_node_or_null("FacingIndicator") + if indicator != null: + indicator.rotation = _facing_to_rotation(GameState.player_facing) + # Remove an entity node func _remove_entity_node(entity_id: int) -> void: if not entity_nodes.has(entity_id): @@ -86,3 +99,46 @@ func _remove_entity_node(entity_id: int) -> void: var entity_node = entity_nodes[entity_id] entity_node.queue_free() entity_nodes.erase(entity_id) + +# D-033 color by entity kind (Phase 1: defaults by kind, not relationship) +static func _color_for_kind(entity_data: Dictionary) -> Color: + var kind_variant: String = entity_data.get("kind", {}).get("variant", "") + match kind_variant: + "Player": + return Constants.ENTITY_COLOR_PLAYER + "Npc": + return Constants.ENTITY_COLOR_UNKNOWN + "Object", "Terrain": + return Constants.ENTITY_COLOR_OBJECT + _: + return Constants.ENTITY_COLOR_OBJECT + +# Add a facing direction indicator triangle to the player entity +func _add_facing_indicator(parent_node: Control) -> void: + var indicator := Polygon2D.new() + indicator.name = "FacingIndicator" + var s := Constants.FACING_INDICATOR_SIZE + var offset := Constants.FACING_INDICATOR_OFFSET + # Triangle pointing up (North), offset from center. Rotates around (0,0). + indicator.polygon = PackedVector2Array([ + Vector2(0, -offset - s), + Vector2(-s * 0.6, -offset + s * 0.4), + Vector2(s * 0.6, -offset + s * 0.4), + ]) + indicator.color = Constants.ENTITY_COLOR_PLAYER + # Position at center of parent ColorRect — rotation around this point + indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) + parent_node.add_child(indicator) + +# Convert facing direction string to rotation in radians (0 = North/up) +static func _facing_to_rotation(facing: String) -> float: + match facing: + "North": return 0.0 + "Northeast": return PI / 4.0 + "East": return PI / 2.0 + "Southeast": return 3.0 * PI / 4.0 + "South": return PI + "Southwest": return 5.0 * PI / 4.0 + "West": return 3.0 * PI / 2.0 + "Northwest": return 7.0 * PI / 4.0 + _: return 0.0 diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 80004b695..818f7659c 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -271,6 +271,80 @@ func test_encode_player_inputs_empty() -> void: assert_that(raw.value.size()).is_equal(0) +# -- v2 snapshot decoding ------------------------------------------------------- + +func test_decode_snapshot_v2_full() -> void: + var bytes = _load_fixture("snapshot_v2_full") + var snapshot = Protocol.decode_snapshot(bytes) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(500) + assert_that(snapshot.version).is_equal(2) + + # game_time + assert_that(snapshot.game_time).is_not_null() + assert_that(snapshot.game_time.day).is_equal(1) + assert_that(snapshot.game_time.time_of_day).is_equal(720) + assert_that(snapshot.game_time.paused).is_false() + + # player_facing (unit enum → bare string) + assert_that(snapshot.player_facing).is_equal("Southeast") + + # entities with visibility sector + assert_that(snapshot.entities.size()).is_equal(1) + assert_that(snapshot.entities[0].visibility).is_equal("Forward") + + # visible_tiles + assert_that(snapshot.visible_tiles.size()).is_equal(3) + assert_that(snapshot.visible_tiles[0].x).is_equal(10) + assert_that(snapshot.visible_tiles[0].visibility).is_equal("Forward") + assert_that(snapshot.visible_tiles[1].visibility).is_equal("Peripheral") + + +func test_existing_fixtures_have_v2_fields() -> void: + # All fixtures are generated by v2 fixture_snapshot() — verify decoder extracts v2 fields + for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]: + var bytes = _load_fixture(fixture_name) + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.version).is_equal(2) + assert_that(snapshot.player_facing).is_equal("North") + assert_that(snapshot.game_time).is_not_null() + + +func test_multi_entity_visibility_sectors() -> void: + # snapshot_multi_entity has: Player=Forward, Npc=Peripheral, Object=Forward, Terrain=Forward + var bytes = _load_fixture("snapshot_multi_entity") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.entities[0].visibility).is_equal("Forward") + assert_that(snapshot.entities[1].visibility).is_equal("Peripheral") + assert_that(snapshot.entities[2].visibility).is_equal("Forward") + assert_that(snapshot.entities[3].visibility).is_equal("Forward") + + +# -- v1 backward compatibility (no v2 fields → graceful null defaults) -------- + +func test_decode_v1_snapshot_graceful_defaults() -> void: + # Minimal v1 snapshot — only tick + entities, no v2 fields + var v1_raw := {"tick": 10, "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player"}, + ]} + var encoded: Variant = Messagepack.encode(v1_raw) + assert_that(encoded.status).is_null() + + var snapshot: Variant = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(10) + assert_that(snapshot.entities.size()).is_equal(1) + # v2 fields should be null/empty, not crash + assert_that(snapshot.version).is_null() + assert_that(snapshot.game_time).is_null() + assert_that(snapshot.player_facing).is_null() + assert_that(snapshot.visible_tiles.size()).is_equal(0) + # Entity should have null visibility + assert_that(snapshot.entities[0].visibility).is_null() + + # -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ---------------- func test_decode_batch_input_fixture() -> void: diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index c51fa31d8..21b662395 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -22,6 +22,11 @@ var _test_entities: Array = [ {"entity_id": 2, "x": 7.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, ] +var _test_entities_v2: Array = [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}, + {"entity_id": 2, "x": 7.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}, +] + # -- Constants -- @@ -72,6 +77,42 @@ func test_game_state_same_count_different_visibility_new_tick() -> void: 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_stores_game_time() -> void: + var gt := {"day": 1, "time_of_day": 720, "day_phase": "Evening", "paused": false} + GameState.apply_snapshot({"tick": 1, "game_time": gt}) + assert_that(GameState.game_time.day).is_equal(1) + assert_that(GameState.game_time.time_of_day).is_equal(720) + +func test_game_state_stores_player_facing() -> void: + GameState.apply_snapshot({"tick": 1, "player_facing": "Southeast"}) + assert_that(GameState.player_facing).is_equal("Southeast") + +func test_game_state_derives_visible_positions_from_visible_tiles() -> void: + var vtiles := [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, + {"x": 6, "y": 5, "z": 0, "visibility": "Peripheral"}, + ] + GameState.apply_snapshot({"tick": 1, "visible_tiles": vtiles}) + assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true() + assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true() + assert_that(GameState.visibility_sectors[Vector2i(5, 5)]).is_equal("Forward") + assert_that(GameState.visibility_sectors[Vector2i(6, 5)]).is_equal("Peripheral") + +func test_game_state_skips_malformed_visible_tiles() -> void: + var vtiles := [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, + null, + "not_a_dict", + {"z": 0}, # missing x, y + {"x": 6, "y": 6, "z": 0, "visibility": "Peripheral"}, + ] + GameState.apply_snapshot({"tick": 1, "visible_tiles": vtiles}) + # Only the 2 valid entries should be stored + 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, 6))).is_true() + assert_that(GameState.visibility_sectors.size()).is_equal(2) + func test_game_state_warns_on_missing_player() -> void: GameState.player_entity_id = 999 GameState.player_position = Vector2(5, 5) @@ -130,6 +171,20 @@ func test_sim_bridge_test_tiles_contain_all_types() -> void: assert_that(types.has("wall")).is_true() assert_that(types.has("door")).is_true() +func test_sim_bridge_test_snapshot_has_v2_fields() -> void: + SimBridge._test_tick = 0 + var snap = SimBridge._test_snapshot() + assert_that(snap.has("version")).is_true() + assert_that(snap.version).is_equal(2) + assert_that(snap.has("game_time")).is_true() + assert_that(snap.has("player_facing")).is_true() + assert_that(snap.has("visible_tiles")).is_true() + assert_that(snap.visible_tiles.size()).is_greater(0) + var vtile = snap.visible_tiles[0] + assert_that(vtile.has("visibility")).is_true() + # Entities should have visibility + assert_that(snap.entities[0].has("visibility")).is_true() + # -- EntityRenderer: lifecycle -- @@ -195,6 +250,86 @@ func test_entity_renderer_empty_entities_clears_all() -> void: renderer.queue_free() +# -- EntityRenderer: D-033 colors and v2 features -- + +func test_entity_renderer_player_uses_d033_color() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities_v2) + var player_node = renderer.entity_nodes[1] as ColorRect + assert_that(player_node.color).is_equal(Constants.ENTITY_COLOR_PLAYER) + renderer.queue_free() + +func test_entity_renderer_npc_uses_unknown_teal() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities_v2) + var npc_node = renderer.entity_nodes[2] as ColorRect + assert_that(npc_node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + renderer.queue_free() + +func test_entity_renderer_object_uses_grey() -> void: + var renderer := _make_entity_renderer() + var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}] + renderer.update_entities(obj) + var node = renderer.entity_nodes[3] as ColorRect + assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT) + renderer.queue_free() + +func test_entity_renderer_peripheral_entity_dimmed() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities_v2) + var npc_node = renderer.entity_nodes[2] + assert_that(npc_node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01) + renderer.queue_free() + +func test_entity_renderer_forward_entity_full_alpha() -> void: + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities_v2) + var player_node = renderer.entity_nodes[1] + assert_that(player_node.modulate.a).is_equal_approx(1.0, 0.01) + renderer.queue_free() + +func test_entity_renderer_player_has_facing_indicator() -> void: + GameState.player_entity_id = 1 + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities_v2) + var player_node = renderer.entity_nodes[1] + var indicator = player_node.get_node_or_null("FacingIndicator") + assert_that(indicator != null).is_true() + assert_that(indicator is Polygon2D).is_true() + renderer.queue_free() + +func test_entity_renderer_facing_indicator_rotation_accuracy() -> void: + GameState.player_entity_id = 1 + var renderer := _make_entity_renderer() + var directions := { + "North": 0.0, + "Northeast": PI / 4.0, + "East": PI / 2.0, + "Southeast": 3.0 * PI / 4.0, + "South": PI, + "Southwest": 5.0 * PI / 4.0, + "West": 3.0 * PI / 2.0, + "Northwest": 7.0 * PI / 4.0, + } + renderer.update_entities(_test_entities_v2) + var player_node = renderer.entity_nodes[1] + var indicator = player_node.get_node_or_null("FacingIndicator") + for dir_name in directions: + GameState.player_facing = dir_name + renderer.update_entities(_test_entities_v2) + assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001) + renderer.queue_free() + +func test_entity_renderer_npc_has_no_facing_indicator() -> void: + GameState.player_entity_id = 1 + var renderer := _make_entity_renderer() + renderer.update_entities(_test_entities_v2) + var npc_node = renderer.entity_nodes[2] + var indicator = npc_node.get_node_or_null("FacingIndicator") + assert_that(indicator == null).is_true() + renderer.queue_free() + + # -- FogRenderer: position registration -- func _make_fog_renderer() -> TileMapLayer: