class_name DialogueTextbox extends PanelContainer const SCENE:PackedScene = preload("res://ui/component/DialogueTextbox.tscn") enum AdvancementMode { PLAYER, TIMED } const LINES_PER_PAGE:int = 4 const CHARS_PER_SECOND:float = 24.0 const PAUSE_COMMA:float = 0.15 const PAUSE_SENTENCE:float = 0.4 const PAUSE_ELLIPSIS_DOT:float = 0.3 const READING_CHARS_PER_SECOND:float = 16.0 const MAX_WIDTH:float = 120.0 signal dismissed var _entity:Entity = null var _parsedText:String = "" var _startLine:int = 0 var _linesPerPage:int = 0 var _revealTimer:float = 0.0 var _pauseTimer:float = 0.0 var _autoAdvanceTimer:float = 0.0 var _isRevealing:bool = false var _isWaitingForInput:bool = false var _advancementMode:AdvancementMode = AdvancementMode.PLAYER @onready var _speakerLabel:Label = $VBoxContainer/SpeakerLabel @onready var _bodyLabel:RichTextLabel = $VBoxContainer/BodyLabel @onready var _advanceIndicator:Label = $VBoxContainer/AdvanceIndicator func _ready() -> void: size.x = MAX_WIDTH visible = false var scrollbar = _bodyLabel.get_v_scroll_bar() if scrollbar: scrollbar.modulate.a = 0.0 scrollbar.mouse_filter = Control.MOUSE_FILTER_IGNORE func setup(line:DialogueLine, entity:Entity, mode:AdvancementMode = AdvancementMode.PLAYER) -> void: _entity = entity _advancementMode = mode _speakerLabel.text = entity.displayName if entity else "" _speakerLabel.visible = _speakerLabel.text != "" # Set width explicitly before text so get_character_line() uses correct metrics. # The Container layout pass hasn't run yet, so we derive inner width from the # panel StyleBox margins rather than waiting for a deferred layout frame. size.x = MAX_WIDTH _bodyLabel.size.x = _bodyLabelWidth() _bodyLabel.text = line.text _bodyLabel.visible_characters = -1 _bodyLabel.scroll_to_line(0) # get_character_line() forces synchronous text shaping via _validate_line_caches(), # so we can compute pre-wrapped text right now without any pre-pass frame. _parsedText = _buildPreWrappedText(_bodyLabel.get_parsed_text()) _bodyLabel.text = _parsedText _bodyLabel.autowrap_mode = TextServer.AUTOWRAP_OFF _bodyLabel.visible_characters = 0 _startLine = 0 _linesPerPage = LINES_PER_PAGE _revealTimer = 0.0 _pauseTimer = 0.0 _autoAdvanceTimer = 0.0 _isRevealing = true _isWaitingForInput = false _updateWorldPosition() visible = true _revealNextChar() func _bodyLabelWidth() -> float: var style:StyleBox = get_theme_stylebox("panel") if style == null: return MAX_WIDTH return MAX_WIDTH - style.get_margin(SIDE_LEFT) - style.get_margin(SIDE_RIGHT) func _process(delta:float) -> void: if not visible: return _updateWorldPosition() if _isWaitingForInput: _advanceIndicator.modulate.a = 0.5 + 0.5 * sin(Time.get_ticks_msec() / 300.0) else: _advanceIndicator.modulate.a = 0.0 if _isRevealing: _processReveal(delta) return if _isWaitingForInput: _processAdvanceInput() return if _advancementMode == AdvancementMode.TIMED: _processAutoAdvance(delta) func _buildPreWrappedText(parsed:String) -> String: if parsed.is_empty(): return parsed var result:String = "" var prevLine:int = 0 for i in range(len(parsed)): var ch:String = parsed[i] if ch == "\n": result += "\n" prevLine += 1 continue var charLine:int = _bodyLabel.get_character_line(i) if charLine > prevLine: result += "\n" prevLine = charLine result += ch return result func _processReveal(delta:float) -> void: var scaledDelta:float = delta * SETTINGS.textSpeed if _pauseTimer > 0.0: _pauseTimer -= scaledDelta return _revealTimer += scaledDelta while _revealTimer >= 1.0 / CHARS_PER_SECOND: _revealTimer -= 1.0 / CHARS_PER_SECOND if not _revealNextChar(): return func _revealNextChar() -> bool: var totalChars:int = len(_parsedText) if _bodyLabel.visible_characters >= totalChars: _onRevealComplete() return false _bodyLabel.visible_characters += 1 var idx:int = _bodyLabel.visible_characters - 1 var charLine:int = _parsedText.left(idx + 1).count("\n") if charLine >= _startLine + _linesPerPage: _bodyLabel.visible_characters -= 1 _onPageFull() return false if idx < len(_parsedText): var isLastVisible:bool = idx >= len(_parsedText) - 1 if not isLastVisible: var nextCharLine:int = _parsedText.left(idx + 2).count("\n") isLastVisible = nextCharLine >= _startLine + _linesPerPage if not isLastVisible: _pauseTimer = _getPauseForChar(idx) return true func _getPauseForChar(idx:int) -> float: var ch:String = _parsedText[idx] if ch == "," or ch == ";": return PAUSE_COMMA if ch == "." or ch == "!" or ch == "?": if ch == ".": var prevDot:bool = idx > 0 and _parsedText[idx - 1] == "." var nextDot:bool = idx < len(_parsedText) - 1 and _parsedText[idx + 1] == "." if prevDot or nextDot: return PAUSE_ELLIPSIS_DOT return PAUSE_SENTENCE return 0.0 func _onPageFull() -> void: _isRevealing = false _isWaitingForInput = true func _onRevealComplete() -> void: _isRevealing = false if _advancementMode == AdvancementMode.TIMED: _autoAdvanceTimer = len(_parsedText) / READING_CHARS_PER_SECOND else: _isWaitingForInput = true func _processAdvanceInput() -> void: if Input.is_action_just_pressed("interact"): _advance() func _processAutoAdvance(delta:float) -> void: _autoAdvanceTimer -= delta if _autoAdvanceTimer <= 0.0: _advance() func _advance() -> void: _advanceIndicator.modulate.a = 0.0 var totalLines:int = _parsedText.count("\n") + 1 var hasMorePages:bool = _startLine + _linesPerPage < totalLines if hasMorePages: _startLine += _linesPerPage _bodyLabel.scroll_to_line(_startLine) _isWaitingForInput = false _revealTimer = 0.0 _pauseTimer = 0.0 _isRevealing = true _revealNextChar() else: dismissed.emit() visible = false queue_free() func _updateWorldPosition() -> void: if _entity == null: return var camera:Camera3D = get_viewport().get_camera_3d() if camera == null: return var worldPos:Vector3 = _entity.global_position + Vector3(0, 2.5, 0) var screenPos:Vector2 = camera.unproject_position(worldPos) 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)