class_name EntityRenderer extends Node2D # Entity renderer — manages entity sprites under the Entities node # Creates/updates/removes ColorRect 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. # The server moves entities in discrete tile steps; the lerp makes this look fluid. # Speed is tuned so Sprint feels snappy and Walk/Careful/Crouch feel deliberate. # # D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive # color from RelationshipState via the knowledge graph. const TILE_SIZE: int = Constants.TILE_SIZE # D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 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. # Lerp speed — framerate-independent exponential smoothing. # At 12.0: ~70% there after 0.1s, ~95% after 0.25s. # Fast enough for Sprint snappiness, slow enough for Walk to show sliding. 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} # #521: Color transition duration in seconds (D-033: "0.5s fade") const COLOR_FADE_DURATION: float = 0.5 func _ready() -> void: print("EntityRenderer: Initialized") func _process(delta: float) -> void: # Lerp all entity visual positions toward their targets each frame. # Uses framerate-independent exponential smoothing. var weight := 1.0 - exp(-LERP_SPEED * delta) for entity_id in entity_nodes.keys(): if not _entity_targets.has(entity_id): continue var node = entity_nodes[entity_id] var target: Vector2 = _entity_targets[entity_id] if not node.position.is_equal_approx(target): node.position = node.position.lerp(target, weight) # #521: Advance color transitions (manual lerp, testable without SceneTree) var finished_ids: Array = [] for entity_id in _entity_tweens.keys(): if not entity_nodes.has(entity_id): finished_ids.append(entity_id) continue 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) if t >= 1.0: finished_ids.append(entity_id) for eid in finished_ids: _entity_tweens.erase(eid) # Update entities from snapshot data func update_entities(entities: Array) -> void: var active_ids: Array = [] # Create or update entities for entity_data in entities: if not entity_data.has("entity_id"): continue var entity_id = entity_data.entity_id active_ids.append(entity_id) # Create entity node if it doesn't exist if not entity_nodes.has(entity_id): _create_entity_node(entity_id, entity_data) else: _update_entity_node(entity_id, entity_data) # Remove entities that are no longer visible var ids_to_remove: Array = [] for entity_id in entity_nodes.keys(): if entity_id not in active_ids: ids_to_remove.append(entity_id) 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) add_child(entity_node) entity_nodes[entity_id] = entity_node _entity_relationships[entity_id] = entity_data.get("relationship", "Unknown") # Add facing indicator for the player entity if entity_id == GameState.player_entity_id: _add_facing_indicator(entity_node) # Snap to initial position (no lerp on first appearance) if entity_data.has("x") and entity_data.has("y"): var target := Vector2( floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X, floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y ) entity_node.position = target _entity_targets[entity_id] = target _update_entity_node(entity_id, entity_data) # Update an existing entity node (target position, 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] # 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. if entity_data.has("x") and entity_data.has("y"): _entity_targets[entity_id] = Vector2( floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X, floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y ) # #521: Detect relationship change → fade D-033 color (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: _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, "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 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 # 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") 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): return var entity_node = entity_nodes[entity_id] entity_node.queue_free() entity_nodes.erase(entity_id) _entity_targets.erase(entity_id) _entity_relationships.erase(entity_id) _entity_tweens.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: var indicator := Polygon2D.new() indicator.name = "FacingIndicator" var s := Constants.FACING_INDICATOR_SIZE var offset := Constants.FACING_INDICATOR_OFFSET # Triangle pointing up (North), offset from center. Rotates around (0,0). indicator.polygon = PackedVector2Array([ Vector2(0, -offset - s), Vector2(-s * 0.6, -offset + s * 0.4), 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) parent_node.add_child(indicator)