Improved UI textbox
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
class_name RootUI extends Control
|
||||
|
||||
@export var debugMenu:DebugMenu
|
||||
@export var mainChatBox:ChatBox
|
||||
@export var gameMenu:GameMenu
|
||||
@export var pauseMenu:PauseMenu
|
||||
@export var chatBoxContainer:Control
|
||||
|
||||
+4
-10
@@ -1,12 +1,11 @@
|
||||
[gd_scene load_steps=6 format=3 uid="uid://baos0arpiskbp"]
|
||||
[gd_scene load_steps=5 format=3 uid="uid://baos0arpiskbp"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://bkx3l0kckf4a8" path="res://ui/component/VNTextbox.tscn" id="1_1mtk3"]
|
||||
[ext_resource type="Script" uid="uid://dq3qyyayugt5l" path="res://ui/RootUI.gd" id="1_son71"]
|
||||
[ext_resource type="PackedScene" uid="uid://c0i5e2dj11d8c" path="res://ui/pause/PauseMenu.tscn" id="2_atyu8"]
|
||||
[ext_resource type="PackedScene" uid="uid://b38dr0wkix76t" path="res://ui/debugmenu/DebugMenu.tscn" id="4_u132g"]
|
||||
[ext_resource type="PackedScene" uid="uid://bv5r2x9m4k7n1" path="res://ui/gamemenu/GameMenu.tscn" id="5_gmenu"]
|
||||
|
||||
[node name="RootUI" type="Control" node_paths=PackedStringArray("debugMenu", "mainChatBox", "gameMenu", "pauseMenu", "chatBoxContainer")]
|
||||
[node name="RootUI" type="Control" node_paths=PackedStringArray("debugMenu", "gameMenu", "pauseMenu", "chatBoxContainer")]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -16,10 +15,9 @@ grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_son71")
|
||||
debugMenu = NodePath("DebugMenu")
|
||||
mainChatBox = NodePath("ChatBox")
|
||||
gameMenu = NodePath("GameMenu")
|
||||
pauseMenu = NodePath("PauseMenu")
|
||||
chatBoxContainer = NodePath("WorldChatBoxContainer")
|
||||
chatBoxContainer = NodePath("ChatBoxContainer")
|
||||
metadata/_custom_type_script = "uid://dq3qyyayugt5l"
|
||||
|
||||
[node name="DebugMenu" parent="." instance=ExtResource("4_u132g")]
|
||||
@@ -35,7 +33,7 @@ process_mode = 3
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
|
||||
[node name="WorldChatBoxContainer" type="Control" parent="."]
|
||||
[node name="ChatBoxContainer" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -43,7 +41,3 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="ChatBox" parent="." instance=ExtResource("1_1mtk3")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
|
||||
+26
-34
@@ -2,51 +2,43 @@ extends Node
|
||||
|
||||
var rootUi:RootUI = null
|
||||
|
||||
# True for the entire duration of a DialogueAction run, including the frames
|
||||
# between lines where the textbox is momentarily closed.
|
||||
# True whenever any dialogue resource is being processed by DialogueManager.
|
||||
# Driven by DialogueManager.dialogue_started / dialogue_ended signals.
|
||||
var dialogueActive:bool = false
|
||||
|
||||
var _chatBoxes:Array = []
|
||||
# True only during a CONVERSATION-mode sequence. Blocks player movement.
|
||||
var activeConversation:bool = false
|
||||
|
||||
var DEBUG_MENU:
|
||||
func _ready() -> void:
|
||||
DialogueManager.dialogue_started.connect(_onDialogueStarted)
|
||||
DialogueManager.dialogue_ended.connect(_onDialogueEnded)
|
||||
|
||||
func _onDialogueStarted(_resource:DialogueResource) -> void:
|
||||
dialogueActive = true
|
||||
|
||||
func _onDialogueEnded(_resource:DialogueResource) -> void:
|
||||
dialogueActive = false
|
||||
|
||||
var chatBoxContainer:Control:
|
||||
get():
|
||||
if rootUi && rootUi.debugMenu:
|
||||
if rootUi:
|
||||
return rootUi.chatBoxContainer
|
||||
return null
|
||||
|
||||
var DEBUG_MENU:DebugMenu:
|
||||
get():
|
||||
if rootUi:
|
||||
return rootUi.debugMenu
|
||||
return null
|
||||
|
||||
var MAIN_CHATBOX:
|
||||
var GAME_MENU:GameMenu:
|
||||
get():
|
||||
if rootUi && rootUi.mainChatBox:
|
||||
return rootUi.mainChatBox
|
||||
return null
|
||||
|
||||
var GAME_MENU:
|
||||
get():
|
||||
if rootUi && rootUi.gameMenu:
|
||||
if rootUi:
|
||||
return rootUi.gameMenu
|
||||
return null
|
||||
|
||||
var PAUSE_MENU:
|
||||
var PAUSE_MENU:PauseMenu:
|
||||
get():
|
||||
if rootUi && rootUi.pauseMenu:
|
||||
if rootUi:
|
||||
return rootUi.pauseMenu
|
||||
return null
|
||||
|
||||
func addChatBox(box:WorldChatBox) -> void:
|
||||
_chatBoxes.append(box)
|
||||
|
||||
func removeChatBox(box:WorldChatBox) -> void:
|
||||
_chatBoxes.erase(box)
|
||||
|
||||
func hasAdvanceableChatBox() -> bool:
|
||||
for box in _chatBoxes:
|
||||
if box.mode == WorldChatBox.Mode.ADVANCEABLE:
|
||||
return true
|
||||
return false
|
||||
|
||||
func spawnWorldChatBox(target:Node3D = null) -> WorldChatBox:
|
||||
assert(rootUi != null)
|
||||
var chatbox:WorldChatBox = WorldChatBox.SCENE.instantiate()
|
||||
rootUi.chatBoxContainer.add_child(chatbox)
|
||||
chatbox.worldTarget = target
|
||||
return chatbox
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
class_name DialogueChoiceBox extends PanelContainer
|
||||
|
||||
const SCENE:PackedScene = preload("res://ui/component/DialogueChoiceBox.tscn")
|
||||
const MAX_WIDTH:float = 120.0
|
||||
|
||||
signal chosen(response:DialogueResponse)
|
||||
|
||||
var _responses:Array[DialogueResponse] = []
|
||||
var _entity:Entity = null
|
||||
var _selectedIndex:int = 0
|
||||
var _hasLetGoOfInteract:bool = true
|
||||
|
||||
@onready var _list:VBoxContainer = $VBoxContainer/List
|
||||
|
||||
func _ready() -> void:
|
||||
size.x = MAX_WIDTH
|
||||
visible = false
|
||||
|
||||
func setup(responses:Array[DialogueResponse], entity:Entity) -> void:
|
||||
_entity = entity
|
||||
_responses = responses.filter(func(r): return r.is_allowed)
|
||||
_selectedIndex = 0
|
||||
_hasLetGoOfInteract = !Input.is_action_pressed("interact")
|
||||
|
||||
for child in _list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
for response in _responses:
|
||||
var label:Label = Label.new()
|
||||
label.text = response.text
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_list.add_child(label)
|
||||
|
||||
size.x = MAX_WIDTH
|
||||
visible = true
|
||||
_updateSelection()
|
||||
|
||||
func _process(_delta:float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
_updateWorldPosition()
|
||||
_processInput()
|
||||
|
||||
func _processInput() -> void:
|
||||
if Input.is_action_just_released("interact"):
|
||||
_hasLetGoOfInteract = true
|
||||
|
||||
if Input.is_action_just_pressed("ui_up") or Input.is_action_just_pressed("move_forward"):
|
||||
_selectedIndex = max(0, _selectedIndex - 1)
|
||||
_updateSelection()
|
||||
|
||||
if Input.is_action_just_pressed("ui_down") or Input.is_action_just_pressed("move_back"):
|
||||
_selectedIndex = min(_responses.size() - 1, _selectedIndex + 1)
|
||||
_updateSelection()
|
||||
|
||||
if _hasLetGoOfInteract and Input.is_action_just_pressed("interact"):
|
||||
_confirm()
|
||||
|
||||
func _confirm() -> void:
|
||||
if _responses.is_empty():
|
||||
return
|
||||
var response:DialogueResponse = _responses[_selectedIndex]
|
||||
chosen.emit(response)
|
||||
visible = false
|
||||
queue_free()
|
||||
|
||||
func _updateSelection() -> void:
|
||||
var children:Array = _list.get_children()
|
||||
for i in children.size():
|
||||
var label:Label = children[i]
|
||||
if i == _selectedIndex:
|
||||
label.add_theme_color_override("font_color", Color.YELLOW)
|
||||
else:
|
||||
label.remove_theme_color_override("font_color")
|
||||
|
||||
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 - Vector2(size.x * 0.5, size.y)
|
||||
position.x = clamp(position.x, 0.0, viewportSize.x - size.x)
|
||||
position.y = clamp(position.y, 0.0, viewportSize.y - size.y)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbgsqs5t10l06
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Theme" path="res://ui/UI Theme.tres" id="1"]
|
||||
[ext_resource type="Script" path="res://ui/component/DialogueChoiceBox.gd" id="2"]
|
||||
|
||||
[node name="DialogueChoiceBox" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
mouse_filter = 2
|
||||
theme = ExtResource("1")
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 1
|
||||
|
||||
[node name="List" type="VBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
@@ -0,0 +1,178 @@
|
||||
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 = 20.0
|
||||
const SPEEDUP_MULTIPLIER:float = 8.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 _hasLetGoOfInteract:bool = true
|
||||
var _advancementMode:AdvancementMode = AdvancementMode.PLAYER
|
||||
|
||||
@onready var _speakerLabel:Label = $VBoxContainer/SpeakerLabel
|
||||
@onready var _bodyLabel:RichTextLabel = $VBoxContainer/BodyLabel
|
||||
|
||||
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 != ""
|
||||
_bodyLabel.text = line.text
|
||||
_bodyLabel.visible_characters = 0
|
||||
_bodyLabel.scroll_to_line(0)
|
||||
_startLine = 0
|
||||
_linesPerPage = LINES_PER_PAGE
|
||||
_revealTimer = 0.0
|
||||
_pauseTimer = 0.0
|
||||
_autoAdvanceTimer = 0.0
|
||||
_isWaitingForInput = false
|
||||
_hasLetGoOfInteract = !Input.is_action_pressed("interact")
|
||||
size.x = MAX_WIDTH
|
||||
visible = true
|
||||
_parsedText = _bodyLabel.get_parsed_text()
|
||||
_isRevealing = true
|
||||
|
||||
func _process(delta:float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
|
||||
_updateWorldPosition()
|
||||
|
||||
if _isRevealing:
|
||||
_processReveal(delta)
|
||||
return
|
||||
|
||||
if _isWaitingForInput:
|
||||
_processAdvanceInput()
|
||||
return
|
||||
|
||||
if _advancementMode == AdvancementMode.TIMED:
|
||||
_processAutoAdvance(delta)
|
||||
|
||||
func _processReveal(delta:float) -> void:
|
||||
if _pauseTimer > 0.0:
|
||||
var speedMult:float = SPEEDUP_MULTIPLIER if Input.is_action_pressed("interact") else 1.0
|
||||
_pauseTimer -= delta * speedMult
|
||||
return
|
||||
|
||||
var speedMult:float = SPEEDUP_MULTIPLIER if Input.is_action_pressed("interact") else 1.0
|
||||
_revealTimer += delta * speedMult
|
||||
|
||||
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
|
||||
|
||||
# Stop if this character has wrapped onto the next page.
|
||||
if idx > 0:
|
||||
var charLine:int = _bodyLabel.get_character_line(idx)
|
||||
if charLine >= _startLine + _linesPerPage:
|
||||
_bodyLabel.visible_characters -= 1
|
||||
_onPageFull()
|
||||
return false
|
||||
|
||||
if idx < len(_parsedText):
|
||||
_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_released("interact"):
|
||||
_hasLetGoOfInteract = true
|
||||
|
||||
if not _hasLetGoOfInteract:
|
||||
return
|
||||
|
||||
if Input.is_action_just_pressed("interact"):
|
||||
_advance()
|
||||
|
||||
func _processAutoAdvance(delta:float) -> void:
|
||||
_autoAdvanceTimer -= delta
|
||||
if _autoAdvanceTimer <= 0.0:
|
||||
_advance()
|
||||
|
||||
func _advance() -> void:
|
||||
var hasMorePages:bool = _startLine + _linesPerPage < _bodyLabel.get_line_count()
|
||||
if hasMorePages:
|
||||
_startLine += _linesPerPage
|
||||
_bodyLabel.scroll_to_line(_startLine)
|
||||
_isWaitingForInput = false
|
||||
_revealTimer = 0.0
|
||||
_pauseTimer = 0.0
|
||||
_isRevealing = true
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://drivjdgk70cqq
|
||||
@@ -0,0 +1,27 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Theme" path="res://ui/UI Theme.tres" id="1"]
|
||||
[ext_resource type="Script" path="res://ui/component/DialogueTextbox.gd" id="2"]
|
||||
|
||||
[node name="DialogueTextbox" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(120, 70)
|
||||
mouse_filter = 2
|
||||
theme = ExtResource("1")
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 1
|
||||
|
||||
[node name="SpeakerLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = ""
|
||||
visible = false
|
||||
|
||||
[node name="BodyLabel" type="RichTextLabel" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 36)
|
||||
size_flags_vertical = 3
|
||||
bbcode_enabled = true
|
||||
scroll_active = true
|
||||
autowrap_mode = 3
|
||||
@@ -1,155 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
uid://h8lw23ypcfty
|
||||
@@ -1,63 +0,0 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://bkx3l0kckf4a8"]
|
||||
|
||||
[ext_resource type="Theme" uid="uid://dm7ee4aqjr2dl" path="res://ui/UI Theme.tres" id="1_wx4lp"]
|
||||
[ext_resource type="Script" uid="uid://h8lw23ypcfty" path="res://ui/component/VNTextbox.gd" id="2_uo1gm"]
|
||||
[ext_resource type="Script" uid="uid://bjj6upgk1uvxd" path="res://ui/component/advancedrichtext/AdvancedRichText.gd" id="3_m60k3"]
|
||||
|
||||
[node name="ChatBox" type="PanelContainer"]
|
||||
clip_contents = true
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -58.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
theme = ExtResource("1_wx4lp")
|
||||
script = ExtResource("2_uo1gm")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
clip_contents = true
|
||||
layout_mode = 2
|
||||
theme = ExtResource("1_wx4lp")
|
||||
theme_override_constants/margin_left = 4
|
||||
theme_override_constants/margin_top = 4
|
||||
theme_override_constants/margin_right = 4
|
||||
theme_override_constants/margin_bottom = 4
|
||||
|
||||
[node name="Label" type="RichTextLabel" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme = ExtResource("1_wx4lp")
|
||||
bbcode_enabled = true
|
||||
text = "Hello, I'm an NPC!
|
||||
This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot's built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing
|
||||
Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
Line 4
|
||||
Line 5
|
||||
Line 6
|
||||
Line 7
|
||||
Line 8
|
||||
Line 9
|
||||
Line 10"
|
||||
script = ExtResource("3_m60k3")
|
||||
userText = "Hello, I'm an NPC!
|
||||
This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot's built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing
|
||||
Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
Line 4
|
||||
Line 5
|
||||
Line 6
|
||||
Line 7
|
||||
Line 8
|
||||
Line 9
|
||||
Line 10"
|
||||
_finalText = "Hello, I'm an NPC!
|
||||
This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot's built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing
|
||||
Line 1
|
||||
Line 2"
|
||||
_newLineIndexes = Array[int]([0])
|
||||
_lines = PackedStringArray("Hello, I\'m an NPC!", "This is the second line here, I am purposefully adding a tonne of words so that it is forced to go across multiple lines and you can see how the word wrapping works, not only using Godot\'s built in word wrapping but with my advanced visibile characters smart wrapping. Now I am doing a multiline thing", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5", "Line 6", "Line 7", "Line 8", "Line 9", "Line 10")
|
||||
maxLines = 4
|
||||
@@ -1,65 +0,0 @@
|
||||
class_name WorldChatBox extends PanelContainer
|
||||
|
||||
enum Mode { ADVANCEABLE, TIMED }
|
||||
|
||||
const SCENE = preload("res://ui/component/WorldChatBox.tscn")
|
||||
|
||||
signal chatboxClosed
|
||||
|
||||
var mode:Mode = Mode.TIMED
|
||||
var worldTarget:Node3D = null
|
||||
var _timer:float = 0.0
|
||||
var _label:Label
|
||||
|
||||
func _ready() -> void:
|
||||
_label = $MarginContainer/Label
|
||||
UI.addChatBox(self)
|
||||
visible = false
|
||||
|
||||
func showTimed(text:String, duration:float) -> void:
|
||||
mode = Mode.TIMED
|
||||
_timer = duration
|
||||
_label.text = text
|
||||
visible = true
|
||||
|
||||
func resetTimer(duration:float) -> void:
|
||||
_timer = duration
|
||||
|
||||
func showAdvanceable(text:String) -> void:
|
||||
mode = Mode.ADVANCEABLE
|
||||
_label.text = text
|
||||
visible = true
|
||||
|
||||
func showAndWait(text:String) -> void:
|
||||
showAdvanceable(text)
|
||||
await chatboxClosed
|
||||
|
||||
func close() -> void:
|
||||
UI.removeChatBox(self)
|
||||
chatboxClosed.emit()
|
||||
queue_free()
|
||||
|
||||
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 !visible:
|
||||
return
|
||||
_updateWorldPosition()
|
||||
match mode:
|
||||
Mode.TIMED:
|
||||
_timer -= delta
|
||||
if _timer <= 0.0:
|
||||
close()
|
||||
Mode.ADVANCEABLE:
|
||||
if Input.is_action_just_pressed("interact"):
|
||||
close()
|
||||
@@ -1 +0,0 @@
|
||||
uid://m1keb2pw8bqq
|
||||
@@ -1,21 +0,0 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Theme" uid="uid://dm7ee4aqjr2dl" path="res://ui/UI Theme.tres" id="1_wx4lp"]
|
||||
[ext_resource type="Script" path="res://ui/component/WorldChatBox.gd" id="2_wcb"]
|
||||
|
||||
[node name="WorldChatBox" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(90, 0)
|
||||
mouse_filter = 2
|
||||
theme = ExtResource("1_wx4lp")
|
||||
script = ExtResource("2_wcb")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 5
|
||||
theme_override_constants/margin_top = 3
|
||||
theme_override_constants/margin_right = 5
|
||||
theme_override_constants/margin_bottom = 3
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
@@ -1,141 +0,0 @@
|
||||
@tool
|
||||
class_name AdvancedRichText extends RichTextLabel
|
||||
|
||||
@export_multiline var userText:String = "" # The text the user is asking for
|
||||
@export_multiline var _finalText:String = "" # The final text after processing (translation, wrapping, etc.)
|
||||
|
||||
@export var _newLineIndexes:Array[int] = [] # The indexes of where each line starts in finalText
|
||||
@export var _lines:PackedStringArray = [];
|
||||
|
||||
# Hides the original RichTextLabel text property
|
||||
func _set(property: StringName, value) -> bool:
|
||||
if property == "text":
|
||||
userText = value
|
||||
_recalcText()
|
||||
return true
|
||||
elif property == "richtextlabel_text":
|
||||
text = value
|
||||
return true
|
||||
return false
|
||||
|
||||
func _get(property: StringName):
|
||||
if property == "text":
|
||||
return userText
|
||||
elif property == "richtextlabel_text":
|
||||
return text
|
||||
return null
|
||||
|
||||
@export var translate:bool = true:
|
||||
set(value):
|
||||
translate = value
|
||||
_recalcText()
|
||||
get():
|
||||
return translate
|
||||
|
||||
@export var smartWrap:bool = true:
|
||||
set(value):
|
||||
smartWrap = value
|
||||
_recalcText()
|
||||
get():
|
||||
return smartWrap
|
||||
|
||||
@export var maxLines:int = -1:
|
||||
set(value):
|
||||
maxLines = value
|
||||
_recalcText()
|
||||
get():
|
||||
return maxLines
|
||||
|
||||
@export var startLine:int = 0:
|
||||
set(value):
|
||||
startLine = value
|
||||
_recalcText()
|
||||
get():
|
||||
return startLine
|
||||
|
||||
# Returns count of characters that can be displayed, assuming visible_chars = -1
|
||||
func getCharactersDisplayedCount() -> int:
|
||||
# Count characters
|
||||
var count = 0
|
||||
var lineCount = min(startLine + maxLines, _lines.size()) - startLine
|
||||
for i in range(startLine, startLine + lineCount):
|
||||
count += _lines[i].length()
|
||||
if lineCount > 1:
|
||||
count += lineCount - 1 # Add newlines
|
||||
return count
|
||||
|
||||
func getFinalText() -> String:
|
||||
return _finalText
|
||||
|
||||
func getTotalLineCount() -> int:
|
||||
return _lines.size()
|
||||
|
||||
func _enter_tree() -> void:
|
||||
self.threaded = false;
|
||||
|
||||
func _recalcText() -> void:
|
||||
_lines.clear()
|
||||
|
||||
if userText.is_empty():
|
||||
self.richtextlabel_text = ""
|
||||
return
|
||||
|
||||
# Translate if needed
|
||||
var textTranslated = userText
|
||||
if self.translate:
|
||||
textTranslated = tr(textTranslated)
|
||||
|
||||
# Replace input bb tags.
|
||||
var regex = RegEx.new()
|
||||
regex.compile(r"\[input action=(.*?)\](.*?)\[/input\]")
|
||||
var inputIconText = textTranslated
|
||||
for match in regex.search_all(textTranslated):
|
||||
var action = match.get_string(1).to_lower()
|
||||
var height:int = get_theme_font_size("normal_font_size")
|
||||
var img_tag = "[img height=%d valign=center,center]res://ui/input/%s.tres[/img]" % [ height, action ]
|
||||
inputIconText = inputIconText.replace(match.get_string(0), img_tag)
|
||||
|
||||
# Perform smart wrapping
|
||||
var wrappedText = inputIconText
|
||||
if smartWrap:
|
||||
var unwrappedText = wrappedText.strip_edges()
|
||||
|
||||
self.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART;
|
||||
self.richtextlabel_text = unwrappedText
|
||||
self.visible_characters = -1;
|
||||
self.fit_content = false;
|
||||
_newLineIndexes = [];
|
||||
|
||||
# Determine where the wrapped newlines are
|
||||
var line = 0;
|
||||
var wasNewLine = false;
|
||||
for i in range(0, self.richtextlabel_text.length()):
|
||||
var tLine = self.get_character_line(i);
|
||||
if tLine == line:
|
||||
wasNewLine = false
|
||||
if self.richtextlabel_text[i] == "\n":
|
||||
wasNewLine = true
|
||||
continue;
|
||||
if !wasNewLine:
|
||||
_newLineIndexes.append(i);
|
||||
line = tLine;
|
||||
|
||||
# Create fake pre-wrapped text.
|
||||
wrappedText = "";
|
||||
for i in range(0, self.richtextlabel_text.length()):
|
||||
if _newLineIndexes.find(i) != -1 and i != 0:
|
||||
wrappedText += "\n";
|
||||
wrappedText += self.richtextlabel_text[i];
|
||||
|
||||
# Handle max and start line(s)
|
||||
var maxText = wrappedText
|
||||
if maxLines > 0:
|
||||
_lines = maxText.split("\n", true);
|
||||
var selectedLines = [];
|
||||
for i in range(startLine, min(startLine + maxLines, _lines.size())):
|
||||
selectedLines.append(_lines[i]);
|
||||
maxText = "\n".join(selectedLines);
|
||||
|
||||
_finalText = maxText
|
||||
self.richtextlabel_text = maxText
|
||||
# print("Updated text")
|
||||
@@ -1 +0,0 @@
|
||||
uid://bjj6upgk1uvxd
|
||||
@@ -41,7 +41,7 @@ func _unhandled_input(event:InputEvent) -> void:
|
||||
if event.is_action_pressed("menu"):
|
||||
if visible:
|
||||
close()
|
||||
elif !UI.dialogueActive && UI.MAIN_CHATBOX.isClosed:
|
||||
elif !UI.dialogueActive:
|
||||
open()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user