class_name ChatBox extends PanelContainer const VN_REVEAL_TIME = 0.01 const _DEFAULT_ANCHOR_TOP = 1.0 const _DEFAULT_ANCHOR_RIGHT = 1.0 const _DEFAULT_ANCHOR_BOTTOM = 1.0 const _DEFAULT_OFFSET_TOP = -58.0 var label:AdvancedRichText; var parsedOutText = "" var revealTimer:float = 0; var lineStarts:Array[int] = []; var newlineIndexes:Array[int] = []; var currentViewScrolled = true; var isSpeedupDown = false; var hasLetGoOfInteract:bool = true; var worldTarget:Node3D = null var isClosed:bool = false: get(): return !self.visible; set(value): self.visible = !value; signal chatboxClosing func _ready() -> void: label = $MarginContainer/Label isClosed = true func _setWorldLayout(hasTarget:bool) -> void: if hasTarget: anchor_left = 0.0 anchor_top = 0.0 anchor_right = 0.0 anchor_bottom = 0.0 offset_left = 0.0 offset_top = 0.0 offset_right = 0.0 offset_bottom = 0.0 custom_minimum_size = Vector2(90, 0) else: if label != null: label.fit_content = false anchor_left = 0.0 anchor_top = _DEFAULT_ANCHOR_TOP anchor_right = _DEFAULT_ANCHOR_RIGHT anchor_bottom = _DEFAULT_ANCHOR_BOTTOM offset_left = 0.0 offset_top = _DEFAULT_OFFSET_TOP offset_right = 0.0 offset_bottom = 0.0 custom_minimum_size = Vector2.ZERO func _updateWorldPosition() -> void: if worldTarget == null: return var camera:Camera3D = get_viewport().get_camera_3d() if camera == null: return var screenPos:Vector2 = camera.unproject_position(worldTarget.global_position + Vector3(0, 2.5, 0)) var viewportSize:Vector2 = get_viewport().get_visible_rect().size position = screenPos - size * 0.5 position.x = clamp(position.x, 0.0, viewportSize.x - size.x) position.y = clamp(position.y, 0.0, viewportSize.y - size.y) func _process(delta: float) -> void: if isClosed: return _updateWorldPosition() # _recalcText always resets fit_content; keep it on in world-space mode so # the PanelContainer auto-sizes to the full speech-bubble content. if worldTarget != null and !label.fit_content: label.fit_content = true if label.getFinalText() == "": isClosed = true return if Input.is_action_just_released("interact") and !hasLetGoOfInteract: hasLetGoOfInteract = true return # World-space (speech-bubble) mode: all text is shown immediately with no # typing reveal. One press advances to the next page; release closes the last. if worldTarget != null: if (label.maxLines + label.startLine) < label.getTotalLineCount(): if Input.is_action_just_pressed("interact") and hasLetGoOfInteract: label.startLine += label.maxLines label.fit_content = true else: if Input.is_action_just_released("interact") and hasLetGoOfInteract: chatboxClosing.emit() isClosed = true return # Bottom-bar mode: typing reveal with paging. if label.visible_characters >= label.getCharactersDisplayedCount(): if (label.maxLines + label.startLine) < label.getTotalLineCount(): if Input.is_action_just_pressed("interact") and hasLetGoOfInteract: label.startLine += label.maxLines label.visible_characters = 0 currentViewScrolled = false return currentViewScrolled = true else: if Input.is_action_just_released("interact") and hasLetGoOfInteract: chatboxClosing.emit() isClosed = true currentViewScrolled = true return if Input.is_action_just_pressed("interact") and hasLetGoOfInteract: isSpeedupDown = true elif Input.is_action_just_released("interact") and hasLetGoOfInteract: isSpeedupDown = false elif !Input.is_action_pressed("interact") and hasLetGoOfInteract: isSpeedupDown = false revealTimer += delta if isSpeedupDown: revealTimer += delta if revealTimer > VN_REVEAL_TIME: revealTimer = 0 label.visible_characters += 1 func setText(text:String, target:Node3D = null) -> void: worldTarget = target _setWorldLayout(worldTarget != null) revealTimer = 0 currentViewScrolled = false label.startLine = 0 label.text = text if worldTarget != null: # Speech-bubble mode: show all text immediately and auto-size the container. # _recalcText already sets visible_characters = -1; just enable fit_content. label.fit_content = true else: label.visible_characters = 0 hasLetGoOfInteract = !Input.is_action_pressed("interact") isSpeedupDown = false isClosed = false func setTextAndWait(text:String, target:Node3D = null) -> void: self.setText(text, target); await self.chatboxClosing await get_tree().process_frame