feat(client): integrate D-019 angle sprites into entity renderer (#540)

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>
This commit is contained in:
2026-02-23 21:01:30 +01:00
co-authored by Claude Opus 4.6
parent ebc973b558
commit a0260176b4
3 changed files with 310 additions and 64 deletions
+86 -37
View File
@@ -2,7 +2,7 @@ class_name EntityRenderer
extends Node2D
# Entity renderer — manages entity sprites under the Entities node
# Creates/updates/removes ColorRect children based on entity data
# Creates/updates/removes Sprite2D children based on entity data
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
#
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
@@ -11,13 +11,15 @@ extends Node2D
#
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
# color from RelationshipState via the knowledge graph.
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
const TILE_SIZE: int = Constants.TILE_SIZE
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime)
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
const ENTITY_WIDTH: int = 24
const ENTITY_HEIGHT: int = 32
const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally
const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering.
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
# Lerp speed — framerate-independent exponential smoothing.
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
@@ -27,7 +29,8 @@ const LERP_SPEED: float = 12.0
var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
# #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5
@@ -48,7 +51,7 @@ func _process(delta: float) -> void:
if not node.position.is_equal_approx(target):
node.position = node.position.lerp(target, weight)
# #521: Advance color transitions (manual lerp, testable without SceneTree)
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
var finished_ids: Array = []
for entity_id in _entity_tweens.keys():
if not entity_nodes.has(entity_id):
@@ -57,8 +60,9 @@ func _process(delta: float) -> void:
var tween_data: Dictionary = _entity_tweens[entity_id]
tween_data.elapsed += delta
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
node_c.color = tween_data.from.lerp(tween_data.target, t)
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
if node_s:
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
if t >= 1.0:
finished_ids.append(entity_id)
for eid in finished_ids:
@@ -92,15 +96,23 @@ func update_entities(entities: Array) -> void:
for entity_id in ids_to_remove:
_remove_entity_node(entity_id)
# 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_WIDTH, ENTITY_HEIGHT)
entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
# D-033 color by relationship (#521)
entity_node.color = _color_for_kind(entity_data)
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
var entity_node := Sprite2D.new()
entity_node.name = "Entity_" + str(entity_id)
# centered=false: top-left origin aligns with tile grid.
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
entity_node.centered = false
entity_node.scale = Vector2(0.5, 0.5)
# Load sprite for current facing direction
var direction := _entity_direction(entity_id, entity_data)
_entity_facing[entity_id] = direction
entity_node.texture = _load_sprite_texture(direction)
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
entity_node.self_modulate = _color_for_kind(entity_data)
add_child(entity_node)
entity_nodes[entity_id] = entity_node
@@ -121,12 +133,13 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
_update_entity_node(entity_id, entity_data)
# Update an existing entity node (target position, visibility dimming, facing)
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not entity_nodes.has(entity_id):
return
var entity_node = entity_nodes[entity_id]
var node = entity_nodes[entity_id]
# Update target position — the lerp in _process() will smoothly move there.
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
@@ -136,38 +149,39 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
)
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
# #540: Update sprite texture when facing direction changes
var new_dir := _entity_direction(entity_id, entity_data)
if new_dir != _entity_facing.get(entity_id, ""):
_entity_facing[entity_id] = new_dir
(node as Sprite2D).texture = _load_sprite_texture(new_dir)
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
var new_rel: String = entity_data.get("relationship", "Unknown")
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
if new_rel != old_rel:
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
_entity_relationships[entity_id] = new_rel
var new_color := _color_for_kind(entity_data)
_entity_tweens[entity_id] = {
"from": entity_node.color,
"target": new_color,
"from": (node as Sprite2D).self_modulate,
"target": _color_for_kind(entity_data),
"elapsed": 0.0,
}
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
# are compositionally independent — both can change simultaneously without
# interference. If alpha tweening is added later, coordinate with color tween.
# v2: Peripheral vision dimming (D-015)
# null visibility (v1 backward compat) defaults to full alpha
# D-015: Peripheral vision dimming via modulate.a.
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
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
if not is_equal_approx(node.modulate.a, target_alpha):
node.modulate.a = target_alpha
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
var indicator = node.get_node_or_null("FacingIndicator")
if indicator != null:
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
if not entity_nodes.has(entity_id):
@@ -179,13 +193,47 @@ func _remove_entity_node(entity_id: int) -> void:
_entity_targets.erase(entity_id)
_entity_relationships.erase(entity_id)
_entity_tweens.erase(entity_id)
_entity_facing.erase(entity_id)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
return Constants.color_for_entity_kind(entity_data)
# Add a facing direction indicator triangle to the player entity
func _add_facing_indicator(parent_node: Control) -> void:
# #540: Map entity to current 4-direction sprite key.
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
if entity_id == GameState.player_entity_id:
return _octant_to_direction(GameState.player_facing)
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
return "south"
# Map 8-direction octant string to nearest 4-direction sprite key.
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
static func _octant_to_direction(octant: String) -> String:
match octant:
"North", "Northwest": return "north"
"Northeast", "East": return "east"
"Southeast", "South": return "south"
"Southwest", "West": return "west"
_: return "south"
# Load the sprite texture for the given 4-direction key.
# Falls back to null with a push_warning if the asset is missing.
static func _load_sprite_texture(direction: String) -> Texture2D:
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
if ResourceLoader.exists(path):
return load(path) as Texture2D
push_warning("EntityRenderer: sprite not found: %s" % path)
return null
# Add a facing direction indicator triangle to the player entity.
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
func _add_facing_indicator(parent_node: Node2D) -> void:
var indicator := Polygon2D.new()
indicator.name = "FacingIndicator"
var s := Constants.FACING_INDICATOR_SIZE
@@ -197,6 +245,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
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_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
# Indicator rotates around this point to track player facing direction.
indicator.position = Vector2(32.0, 32.0)
parent_node.add_child(indicator)
+29 -27
View File
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
renderer.queue_free()
func test_npc_uses_relationship_color_friendly() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
renderer.queue_free()
func test_npc_uses_relationship_color_poi() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
renderer.queue_free()
func test_npc_uses_relationship_color_hostile() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
renderer.queue_free()
func test_player_color_ignores_relationship() -> void:
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(1, "Player", "Hostile")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
renderer.queue_free()
func test_object_color_ignores_relationship() -> void:
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities: Array = [_make_entity(3, "Object", "Friendly")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
renderer.queue_free()
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
var renderer: Node2D = _make_entity_renderer()
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
renderer.update_entities(entities_before)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
if not _entity_uses_relationship(renderer):
renderer.queue_free()
return
# Change relationship to Hostile
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
renderer.update_entities(entities_after)
# Immediately after update, color should NOT yet be the target
var color_after_immediate: Color = node.color
# Immediately after update, self_modulate should NOT yet be the target
var color_after_immediate: Color = node.self_modulate
if not _renderer_has_tween_support(renderer):
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
renderer.queue_free()
return
# The color should NOT be exactly the target yet (tween in progress)
# The self_modulate should NOT be exactly the target yet (tween in progress)
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
renderer.queue_free()
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
while elapsed < 1.5:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
func test_color_shift_mid_transition_retrigger() -> void:
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
while elapsed < 1.0:
renderer._process(1.0 / 60.0)
elapsed += 1.0 / 60.0
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
renderer.queue_free()
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
return
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
renderer.update_entities(entities)
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
var color_first: Color = node.color
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
var color_first: Color = node.self_modulate
renderer.update_entities(entities)
var color_second: Color = node.color
var color_second: Color = node.self_modulate
assert_that(color_first).is_equal(color_second)
renderer.queue_free()
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
renderer.update_entities([friendly, hostile])
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
return false
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
var f_color: Color = f_node.color
var h_color: Color = h_node.color
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
if not f_node or not h_node:
return false
var f_color: Color = f_node.self_modulate
var h_color: Color = h_node.self_modulate
var uses_rel: bool = not f_color.is_equal_approx(h_color)
renderer.update_entities([])
return uses_rel
+195
View File
@@ -0,0 +1,195 @@
## 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()