Untitled

Anonymous
plain_text
02/24/2026 2:39 AM
6.7 KB
9
Indexable
extends Control

signal choice_selected(choice_index: int)

@onready var box: Panel = $Box as Panel
@onready var portrait_rect: TextureRect = $Box/BoxCol/ContentRow/PortraitContainer/Portrait as TextureRect
@onready var name_label: Label = $Box/BoxCol/ContentRow/TextCol/NameLabel as Label
@onready var body_text: RichTextLabel = $Box/BoxCol/ContentRow/TextCol/BodyText as RichTextLabel
@onready var choices_col: VBoxContainer = $Box/BoxCol/ContentRow/TextCol/Choices as VBoxContainer
@onready var hint_label: Label = $Box/BoxCol/HintLabel as Label

var _is_open: bool = false
var _is_typing: bool = false

var _full_text: String = ""
var _char_index: int = 0
var _type_timer: float = 0.0
var _type_speed: float = 0.03

var _has_choices: bool = false

# --- New: deferred start to avoid first-open scroll bug ---
var _pending_start: bool = false
var _layout_warmup_frames: int = 0

# Scroll helpers
var _scroll_requested: bool = false


func _ready() -> void:
	if box == null or name_label == null or body_text == null or choices_col == null or hint_label == null or portrait_rect == null:
		push_error("DialogueUI: One or more UI node paths are wrong. Check DialogueUI tree.")
		hide_dialogue()
		return

	body_text.scroll_active = true
	body_text.scroll_following = true

	hide_dialogue()


func show_dialogue(speaker_name: String, text: String, portrait_tex: Texture2D = null, choices: Array = []) -> void:
	_is_open = true
	visible = true
	box.visible = true

	name_label.text = speaker_name

	# Portrait
	if portrait_tex != null:
		portrait_rect.texture = portrait_tex
		portrait_rect.visible = true
	else:
		portrait_rect.texture = null
		portrait_rect.visible = false

	# Save the text, but DO NOT start typing yet (layout not ready on first open)
	_full_text = text
	_char_index = 0
	_type_timer = 0.0
	_is_typing = true
	body_text.text = ""  # clear immediately so it feels responsive

	# Choices
	_clear_choices()
	_has_choices = (choices.size() > 0)
	if _has_choices:
		_build_choices(choices)

	# Hint
	hint_label.visible = not _has_choices
	hint_label.text = "E: continue" if not _has_choices else ""

	# Ensure scrolling flags are on each time
	body_text.scroll_active = true
	body_text.scroll_following = true

	# Critical: warm up layout for a couple frames, then start typewriter.
	_pending_start = true
	_layout_warmup_frames = 2
	_request_scroll_to_bottom()


func hide_dialogue() -> void:
	_is_open = false
	_is_typing = false
	_pending_start = false
	_layout_warmup_frames = 0

	_full_text = ""
	_char_index = 0
	_type_timer = 0.0

	visible = false


func is_open() -> bool:
	return _is_open


func is_typing() -> bool:
	return _is_typing


func fast_reveal() -> void:
	if not _is_typing:
		return
	_is_typing = false
	body_text.text = _full_text
	_request_scroll_to_bottom()


func _process(delta: float) -> void:
	if not _is_open:
		return

	# Warm-up frames: allow containers to compute size/line wrapping.
	# This is what fixes the "first dialogue doesn't scroll" problem.
	if _pending_start:
		_layout_warmup_frames -= 1
		_request_scroll_to_bottom()
		_flush_scroll_now()

		if _layout_warmup_frames <= 0:
			_pending_start = false
		return

	# Typewriter
	if _is_typing:
		var char_speed: float = _type_speed
		if Input.is_action_pressed("fast_text"):
			char_speed = 0.005

		_type_timer += delta

		while _type_timer >= char_speed and _char_index < _full_text.length():
			_type_timer -= char_speed
			_char_index += 1
			body_text.text = _full_text.substr(0, _char_index)
			_request_scroll_to_bottom()

		if _char_index >= _full_text.length():
			_is_typing = false
			_request_scroll_to_bottom()

	_flush_scroll_now()


func _unhandled_input(event: InputEvent) -> void:
	if not _is_open:
		return

	if event.is_action_pressed("interact"):
		if _is_typing:
			fast_reveal()
			return
		# If there are no choices, owner handles E.
		# We intentionally do nothing here.


func _clear_choices() -> void:
	for c: Node in choices_col.get_children():
		c.queue_free()


func _build_choices(choices: Array) -> void:
	for i in range(choices.size()):
		var label_text: String = ""

		# Support BOTH formats:
		# 1) [{ "label": "..." }, ...]  (Aurora style)
		# 2) ["...", "..."]            (older scenes / quick ones)
		if choices[i] is Dictionary:
			label_text = str((choices[i] as Dictionary).get("label", ""))
		else:
			label_text = str(choices[i])

		var b: Button = Button.new()
		b.text = label_text
		b.focus_mode = Control.FOCUS_ALL

		# Capture stable index (avoids closure weirdness)
		var idx: int = i
		b.pressed.connect(func() -> void:
			emit_signal("choice_selected", idx)
		)

		choices_col.add_child(b)

	if choices_col.get_child_count() > 0:
		var first: Node = choices_col.get_child(0)
		if first is Control:
			(first as Control).grab_focus()


# --- Scroll helpers ---
func _request_scroll_to_bottom() -> void:
	_scroll_requested = true


func _flush_scroll_now() -> void:
	if not _scroll_requested:
		return
	_scroll_requested = false

	if body_text == null:
		return

	var max_line: int = max(body_text.get_line_count() - 1, 0)
	body_text.scroll_to_line(max_line)
Editor is loading...
Leave a Comment