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)