14 tests covering _octant_to_direction (all 8 octants + 2 fallbacks) and _entity_direction (NPC default south, player facing 3 cases). Closes review warning on zero test coverage for direction system. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
12 KiB
GDScript
293 lines
12 KiB
GDScript
## 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()
|