Files
Dawn-Godot/ui/component/DialogueTextbox.gd
T

220 lines
6.4 KiB
GDScript

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)