Migrate entity rendering from ColorRect placeholders to Sprite2D with rendered PNGs at -72.5° from horizontal. Key changes: - Sprite2D.centered=false, scale=0.5 for 64px source → 32px runtime - self_modulate for D-033 relationship tinting (modulate.a reserved for D-015 peripheral dimming) - 8-octant to 4-cardinal direction mapping for sprite selection - Feet-anchored ENTITY_OFFSET_Y for correct y-sort with tilted sprites - Facing indicator repositioned to sprite local center (32,32) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
8.5 KiB
GDScript
196 lines
8.5 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()
|