## Sprint 16 #540: Sprite integration tests. ## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64 ## bounding box, sprite asset existence from #541, and fog shader independence. ## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541. class_name TestSpriteIntegration extends GdUnitTestSuite var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") func before_test() -> void: GameState.player_entity_id = 1 GameState.player_position = Vector2.ZERO GameState.visible_entities = [] # -- Helpers ------------------------------------------------------------------- func _make_entity_renderer() -> Node2D: var renderer = Node2D.new() renderer.set_script(EntityRendererScript) add_child(renderer) return renderer # -- Footprint constants: D-044 spec (S16-S01, S16-S02) ----------------------- func test_entity_footprint_matches_d044_spec() -> void: # S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile. # (64x64 source sprite scaled to 32px runtime at 2x retina per D-066). assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message( "D-044: ENTITY_WIDTH must be 24px" ).is_equal(24) assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message( "D-044: ENTITY_HEIGHT must be 32px" ).is_equal(32) func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void: # S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile # bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction # range (2 sim tiles) accurate with the tilted perspective. var tile_2x: int = Constants.TILE_SIZE * 2 assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message( "D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [ EntityRenderer.ENTITY_WIDTH, tile_2x] ).is_true() assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message( "D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [ EntityRenderer.ENTITY_HEIGHT, tile_2x] ).is_true() func test_entity_width_fits_within_single_tile() -> void: # S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile. # Ensures horizontal centering offset is positive and entity doesn't overflow. assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message( "Entity width must be less than TILE_SIZE for centered layout" ).is_true() assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message( "ENTITY_OFFSET_X must be non-negative for horizontal centering" ).is_true() # -- Pixel position (S16-S04) ------------------------------------------------- func test_entity_pixel_position_at_tile_3_7() -> void: # S16-S04: Entity at tile (3.0, 7.0) → pixel position must be # (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y). var renderer := _make_entity_renderer() var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}}] renderer.update_entities(entity) var node = renderer.entity_nodes[10] var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y assert_that(node.position.x).override_failure_message( "Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X" ).is_equal_approx(expected_x, 0.1) assert_that(node.position.y).override_failure_message( "Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y" ).is_equal_approx(expected_y, 0.1) renderer.queue_free() # -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) ----------------------- func test_z_sort_south_entity_has_higher_pixel_y() -> void: # S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y # than entity at y=4 (north). Godot y-sort renders higher-y on top. # With tilted sprites, south-facing entity must visually overlap northern. var renderer := _make_entity_renderer() var entities := [ {"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, {"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, ] renderer.update_entities(entities) var north_node = renderer.entity_nodes[20] var south_node = renderer.entity_nodes[21] assert_that(south_node.position.y > north_node.position.y).override_failure_message( "Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort" ).is_true() renderer.queue_free() func test_z_sort_y_position_difference_equals_tile_size() -> void: # S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE. # Verifies position calculation is consistent for adjacent tiles. var renderer := _make_entity_renderer() var entities := [ {"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, {"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, ] renderer.update_entities(entities) var node3 = renderer.entity_nodes[30] var node4 = renderer.entity_nodes[31] var delta_y := node4.position.y - node3.position.y assert_that(delta_y).override_failure_message( "Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE ).is_equal_approx(float(Constants.TILE_SIZE), 0.1) renderer.queue_free() func test_z_sort_same_y_different_x_no_y_difference() -> void: # S16-S07: Two entities at same y but different x → same pixel.y. # Horizontal position must not affect y-sort order. var renderer := _make_entity_renderer() var entities := [ {"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, {"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, ] renderer.update_entities(entities) var left_node = renderer.entity_nodes[40] var right_node = renderer.entity_nodes[41] assert_that(left_node.position.y).override_failure_message( "Entities at same y-tile must have same pixel.y regardless of x" ).is_equal_approx(right_node.position.y, 0.1) renderer.queue_free() # -- Sprite assets from #541 (S16-S08, S16-S09) -------------------------------- func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void: # S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions. # entity_renderer.gd must be able to load these paths. for direction in ["north", "east", "south", "west"]: var path := "res://assets/sprites/npc_generic_%s_64.png" % direction assert_that(ResourceLoader.exists(path)).override_failure_message( "NPC sprite missing: %s" % path ).is_true() func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void: # S16-S09: #541 delivers 64px wall sprites for all four cardinal directions. for direction in ["north", "east", "south", "west"]: var path := "res://assets/sprites/wall_structural_%s_64.png" % direction assert_that(ResourceLoader.exists(path)).override_failure_message( "Wall sprite missing: %s" % path ).is_true() # -- Fog shader independence: D-019 (S16-S10, S16-S11) ----------------------- func test_fog_shader_script_and_gdshader_load_correctly() -> void: # S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite # changes. D-019: "fog vision cone math remains pure 2D" — unaffected by # the art-direction tilt baked into sprites. assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message( "fog_shader.gd must load correctly — must not be affected by sprite changes" ).is_true() assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message( "fog.gdshader must exist — fog is screen-space and sprite-independent" ).is_true() func test_fog_update_runs_independently_of_entity_renderer_state() -> void: # S16-S11: FogState.update_from_state() must succeed with no entity renderer # active. D-019: fog driven by LOS mask (visible_positions), not sprites. var fog = get_node_or_null("/root/FogState") if fog == null: push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped") return # Provide visibility data but no entity renderer context GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true} GameState.visibility_sectors = { Vector2i(5, 5): "Forward", Vector2i(6, 5): "Peripheral", } if fog.has_method("update_from_state"): fog.update_from_state() assert_that(fog.visibility_texture).override_failure_message( "FogState visibility_texture must be populated independently of sprite state" ).is_not_null() GameState.visible_positions.clear() GameState.visibility_sectors.clear() # -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) -------- func test_octant_north_maps_to_north() -> void: # S16-S12: "North" → "north" assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north") func test_octant_northwest_maps_to_north() -> void: # S16-S13: "Northwest" → "north" (grouped with North per mapping spec) assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north") func test_octant_northeast_maps_to_east() -> void: # S16-S14: "Northeast" → "east" assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east") func test_octant_east_maps_to_east() -> void: # S16-S15: "East" → "east" assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east") func test_octant_southeast_maps_to_south() -> void: # S16-S16: "Southeast" → "south" assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south") func test_octant_south_maps_to_south() -> void: # S16-S17: "South" → "south" assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south") func test_octant_southwest_maps_to_west() -> void: # S16-S18: "Southwest" → "west" assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west") func test_octant_west_maps_to_west() -> void: # S16-S19: "West" → "west" assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west") func test_octant_unknown_string_falls_back_to_south() -> void: # S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019) assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south") assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south") func test_octant_empty_string_falls_back_to_south() -> void: # S16-S21: Empty string → "south" fallback assert_that(EntityRenderer._octant_to_direction("")).is_equal("south") # -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ---------- func test_entity_direction_npc_always_south() -> void: # S16-S22: NPC entity → always "south" regardless of any data field. # NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle). var renderer := _make_entity_renderer() GameState.player_entity_id = 1 # entity_id 99 is not the player var dir := renderer._entity_direction(99, {"entity_id": 99, "kind": {"variant": "Npc", "data": null}}) assert_that(dir).override_failure_message( "NPC entity must always return 'south'" ).is_equal("south") renderer.queue_free() func test_entity_direction_player_uses_player_facing() -> void: # S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction. var renderer := _make_entity_renderer() GameState.player_entity_id = 1 GameState.player_facing = "North" var dir := renderer._entity_direction(1, {"entity_id": 1, "kind": {"variant": "Player", "data": null}}) assert_that(dir).override_failure_message( "Player entity with player_facing='North' must return 'north'" ).is_equal("north") renderer.queue_free() func test_entity_direction_player_facing_east() -> void: # S16-S24: Player facing "East" → "east" var renderer := _make_entity_renderer() GameState.player_entity_id = 1 GameState.player_facing = "East" var dir := renderer._entity_direction(1, {"entity_id": 1, "kind": {"variant": "Player", "data": null}}) assert_that(dir).override_failure_message( "Player entity with player_facing='East' must return 'east'" ).is_equal("east") renderer.queue_free() func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void: # S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping). # Diagonal octants map to one of the four cardinal sprite sets. var renderer := _make_entity_renderer() GameState.player_entity_id = 1 GameState.player_facing = "Northwest" var dir := renderer._entity_direction(1, {"entity_id": 1, "kind": {"variant": "Player", "data": null}}) assert_that(dir).override_failure_message( "Player entity with player_facing='Northwest' must return 'north'" ).is_equal("north") renderer.queue_free()