extends Control const DEFAULT_DURATION: float = 5.0 const TARGET_OFFSET: Vector2 = Vector2(0, -84) const TARGET_TOP_GAP: float = 10.0 const FALLBACK_TARGET_HEIGHT: float = 64.0 const MIN_TEXT_WIDTH: float = 96.0 const MAX_TEXT_WIDTH: float = 230.0 const TEXT_FONT_SIZE: float = 18.0 const LINE_HEIGHT: float = 24.0 @onready var label: Label = $PanelContainer/Label @onready var panel: PanelContainer = $PanelContainer var _targetNode: Node2D var _targetOffset: Vector2 = TARGET_OFFSET var _originalText: String = "" func _process(_delta: float) -> void: if _targetNode == null or not is_instance_valid(_targetNode): return _update_position() func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = TARGET_OFFSET) -> void: _originalText = text _targetNode = targetNode _targetOffset = targetOffset _update_size() if _targetNode != null: _update_position() await get_tree().create_timer(DEFAULT_DURATION).timeout queue_free() func _update_size() -> void: if panel == null: return var metrics := _get_text_metrics(_originalText) label.text = str(metrics.get("display_text", _originalText)) label.autowrap_mode = TextServer.AUTOWRAP_OFF label.custom_minimum_size = Vector2(float(metrics.get("width", MIN_TEXT_WIDTH)), float(metrics.get("height", LINE_HEIGHT))) label.size = label.custom_minimum_size panel.reset_size() reset_size() panel.size = panel.get_combined_minimum_size() size = panel.size func _update_position() -> void: var anchor := _get_target_top_screen_position() var bubbleSize := _get_bubble_size() global_position = Vector2( anchor.x - bubbleSize.x * 0.5 + _targetOffset.x, anchor.y - bubbleSize.y - TARGET_TOP_GAP ).round() func _get_bubble_size() -> Vector2: if panel != null: var combined := panel.get_combined_minimum_size() if combined.x > 0.0 and combined.y > 0.0: return combined if size.x > 0.0 and size.y > 0.0: return size return Vector2(280.0, 52.0) func _get_text_metrics(text: String) -> Dictionary: var lines: Array[String] = [] var currentLine := "" var currentWidth := 0.0 var maxLineWidth := 0.0 for index in range(text.length()): var character := text.substr(index, 1) var characterWidth := _measure_text_width(character) if not currentLine.is_empty() and currentWidth + characterWidth > MAX_TEXT_WIDTH: lines.append(currentLine) maxLineWidth = max(maxLineWidth, currentWidth) currentLine = character currentWidth = characterWidth else: currentLine += character currentWidth += characterWidth if not currentLine.is_empty() or lines.is_empty(): lines.append(currentLine) maxLineWidth = max(maxLineWidth, currentWidth) return { "display_text": "\n".join(lines), "width": clamp(maxLineWidth + 4.0, MIN_TEXT_WIDTH, MAX_TEXT_WIDTH), "height": LINE_HEIGHT * float(lines.size()) } func _measure_text_width(text: String) -> float: if label != null: var font := label.get_theme_font("font") if font != null: var fontSize := label.get_theme_font_size("font_size") if fontSize <= 0: fontSize = int(TEXT_FONT_SIZE) var measured := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, fontSize).x if measured > 0.0: return measured var estimated := 0.0 for index in range(text.length()): var code := text.unicode_at(index) estimated += TEXT_FONT_SIZE * 0.56 if code <= 0x7f else TEXT_FONT_SIZE return estimated func _get_target_top_screen_position() -> Vector2: var targetScreenPosition: Vector2 = _targetNode.get_global_transform_with_canvas().origin var visualTopOffset := _get_target_visual_top_offset() return targetScreenPosition + visualTopOffset func _get_target_visual_top_offset() -> Vector2: var topOffset := Vector2(0.0, -FALLBACK_TARGET_HEIGHT) if _targetNode == null: return topOffset var sprite := _targetNode.get_node_or_null("Sprite2D") as Sprite2D if sprite == null: return topOffset var rect := sprite.get_rect() var localTop := sprite.position + Vector2(0.0, rect.position.y * sprite.scale.y) var nodeToCanvas := _targetNode.get_global_transform_with_canvas() var topScreenPosition := nodeToCanvas * localTop var targetScreenPosition: Vector2 = nodeToCanvas.origin return topScreenPosition - targetScreenPosition