in-world ui textboxes

This commit is contained in:
2026-06-12 09:08:13 -05:00
parent a4d47d7f00
commit b364fae1c7
16 changed files with 265 additions and 27 deletions
+16 -3
View File
@@ -11,6 +11,7 @@ static func dialogueCallable(params:Dictionary) -> int:
var resource:DialogueResource = params['resource'] var resource:DialogueResource = params['resource']
var title:String = params.get('title', 'start') var title:String = params.get('title', 'start')
var extraStates:Array = params.get('extraStates', []) var extraStates:Array = params.get('extraStates', [])
var characterTargets:Dictionary = params.get('characterTargets', {})
UI.dialogueActive = true UI.dialogueActive = true
var line:DialogueLine = await DialogueManager.get_next_dialogue_line(resource, title, extraStates) var line:DialogueLine = await DialogueManager.get_next_dialogue_line(resource, title, extraStates)
@@ -19,10 +20,12 @@ static func dialogueCallable(params:Dictionary) -> int:
if line.character: if line.character:
text = line.character + ": " + text text = line.character + ": " + text
var target:Node3D = _getLineTarget(line.character, characterTargets)
if line.responses.size() > 0: if line.responses.size() > 0:
# Show text then auto-pick the first allowed response. # Show text then auto-pick the first allowed response.
# Replace this block with a real response UI when branching dialogue is needed. # Replace this block with a real response UI when branching dialogue is needed.
await UI.TEXTBOX.setTextAndWait(text) await UI.MAIN_CHATBOX.setTextAndWait(text, target)
var nextId:String = "" var nextId:String = ""
for response:DialogueResponse in line.responses: for response:DialogueResponse in line.responses:
if response.is_allowed: if response.is_allowed:
@@ -30,16 +33,26 @@ static func dialogueCallable(params:Dictionary) -> int:
break break
line = await DialogueManager.get_next_dialogue_line(resource, nextId, extraStates) line = await DialogueManager.get_next_dialogue_line(resource, nextId, extraStates)
else: else:
await UI.TEXTBOX.setTextAndWait(text) await UI.MAIN_CHATBOX.setTextAndWait(text, target)
line = await DialogueManager.get_next_dialogue_line(resource, line.next_id, extraStates) line = await DialogueManager.get_next_dialogue_line(resource, line.next_id, extraStates)
UI.dialogueActive = false UI.dialogueActive = false
return Cutscene.CUTSCENE_CONTINUE return Cutscene.CUTSCENE_CONTINUE
static func getDialogueCallable(resource:DialogueResource, title:String = 'start', extraStates:Array = []) -> Dictionary: # "player" (case-insensitive) maps to the player entity; any other named
# character maps to the npc entity; empty character (narrator) uses no target.
static func _getLineTarget(character:String, characterTargets:Dictionary) -> Node3D:
if characterTargets.is_empty() or character == "":
return null
if character.to_lower() == "player":
return characterTargets.get("player", null)
return characterTargets.get("npc", null)
static func getDialogueCallable(resource:DialogueResource, title:String = 'start', extraStates:Array = [], characterTargets:Dictionary = {}) -> Dictionary:
return { return {
'function': dialogueCallable, 'function': dialogueCallable,
'resource': resource, 'resource': resource,
'title': title, 'title': title,
'extraStates': extraStates, 'extraStates': extraStates,
'characterTargets': characterTargets,
} }
+4
View File
@@ -14,6 +14,8 @@ enum InteractType {
ONE_TIME_ITEM, ONE_TIME_ITEM,
CUTSCENE, CUTSCENE,
BATTLE_TEST, BATTLE_TEST,
CHATBOX,
PROXIMITY_CHATBOX,
}; };
@export_category("Identification") @export_category("Identification")
@@ -33,6 +35,8 @@ var button := func():
@export var dialogueTitle:String = "start" @export var dialogueTitle:String = "start"
@export var oneTimeItem:ItemResource = null @export var oneTimeItem:ItemResource = null
@export var cutscene:CutsceneResource = null @export var cutscene:CutsceneResource = null
@export var chatboxMessage:String = ""
@export var chatboxDuration:float = 3.0
# TEST BATTLE # TEST BATTLE
@export_category("Test Battle") @export_category("Test Battle")
+12 -2
View File
@@ -33,15 +33,19 @@ func isInteractable() -> bool:
if entity.interactType == Entity.InteractType.BATTLE_TEST: if entity.interactType == Entity.InteractType.BATTLE_TEST:
return true return true
if entity.interactType == Entity.InteractType.CHATBOX:
return entity.chatboxMessage != ""
return false return false
func _onConversationInteract(_other:Entity) -> void: func _onConversationInteract(other:Entity) -> void:
assert(entity.dialogueResource != null) assert(entity.dialogueResource != null)
var cutscene:Cutscene = Cutscene.new() var cutscene:Cutscene = Cutscene.new()
cutscene.addCallable(DialogueAction.getDialogueCallable( cutscene.addCallable(DialogueAction.getDialogueCallable(
entity.dialogueResource, entity.dialogueResource,
entity.dialogueTitle, entity.dialogueTitle,
[entity] [entity],
{"npc": entity, "player": other}
)) ))
cutscene.start() cutscene.start()
@@ -83,5 +87,11 @@ func onInteract(other:Entity) -> void:
cutscene.start() cutscene.start()
return return
Entity.InteractType.CHATBOX:
assert(entity.chatboxMessage != "")
var chatbox:WorldChatBox = UI.spawnWorldChatBox(entity)
chatbox.showAdvanceable(entity.chatboxMessage)
return
_: _:
pass pass
+3 -2
View File
@@ -20,7 +20,8 @@ func _applyGravity() -> void:
func _applyPlayerMovement(delta:float): func _applyPlayerMovement(delta:float):
# Interactions, may move # Interactions, may move
if Input.is_action_just_pressed("interact") && interactingArea && interactingArea.hasInteraction(): if Input.is_action_just_pressed("interact") && interactingArea && interactingArea.hasInteraction():
interactingArea.interact() if !UI.hasAdvanceableChatBox():
interactingArea.interact()
return return
# Directional input # Directional input
@@ -64,7 +65,7 @@ func _applyFriction(delta:float) -> void:
func _canMove() -> bool: func _canMove() -> bool:
if UI.dialogueActive: if UI.dialogueActive:
return false return false
if !UI.TEXTBOX.isClosed: if !UI.MAIN_CHATBOX.isClosed:
return false return false
if UI.GAME_MENU && UI.GAME_MENU.isOpen(): if UI.GAME_MENU && UI.GAME_MENU.isOpen():
return false return false
+20
View File
@@ -0,0 +1,20 @@
class_name EntityProximityArea extends Area3D
@export var entity:Entity
var _triggered:bool = false
func _ready() -> void:
body_entered.connect(_onBodyEntered)
func _onBodyEntered(body:Node3D) -> void:
if _triggered:
return
if !(body is Entity):
return
if (body as Entity).entityId != "player":
return
_triggered = true
assert(entity != null && entity.chatboxMessage != "")
var chatbox:WorldChatBox = UI.spawnWorldChatBox(entity)
chatbox.showTimed(entity.chatboxMessage, entity.chatboxDuration)
@@ -0,0 +1 @@
uid://bq2lsd8uyrtcf
+26 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=9 format=3 uid="uid://d0ywgijpuqy0r"] [gd_scene load_steps=11 format=3 uid="uid://d0ywgijpuqy0r"]
[ext_resource type="Script" uid="uid://xe6pcuq741xi" path="res://overworld/map/TestMap.gd" id="1_6ms5s"] [ext_resource type="Script" uid="uid://xe6pcuq741xi" path="res://overworld/map/TestMap.gd" id="1_6ms5s"]
[ext_resource type="PackedScene" uid="uid://cluuhtfjeodwb" path="res://overworld/map/TestMapBase.tscn" id="1_ox0si"] [ext_resource type="PackedScene" uid="uid://cluuhtfjeodwb" path="res://overworld/map/TestMapBase.tscn" id="1_ox0si"]
@@ -6,6 +6,7 @@
[ext_resource type="Script" uid="uid://38ya6vphm5bu" path="res://item/ItemResource.gd" id="4_xf0pb"] [ext_resource type="Script" uid="uid://38ya6vphm5bu" path="res://item/ItemResource.gd" id="4_xf0pb"]
[ext_resource type="Script" uid="uid://b5c8g5frishjs" path="res://cutscene/cutscene/TestCutscene.gd" id="5_125nt"] [ext_resource type="Script" uid="uid://b5c8g5frishjs" path="res://cutscene/cutscene/TestCutscene.gd" id="5_125nt"]
[ext_resource type="Script" uid="uid://8tsov4ihmnxl" path="res://overworld/camera/OverworldCamera.gd" id="7_tr4a0"] [ext_resource type="Script" uid="uid://8tsov4ihmnxl" path="res://overworld/camera/OverworldCamera.gd" id="7_tr4a0"]
[ext_resource type="Script" uid="uid://bq2lsd8uyrtcf" path="res://overworld/entity/EntityProximityArea.gd" id="8_prox"]
[sub_resource type="Resource" id="Resource_125nt"] [sub_resource type="Resource" id="Resource_125nt"]
script = ExtResource("4_xf0pb") script = ExtResource("4_xf0pb")
@@ -17,6 +18,9 @@ metadata/_custom_type_script = "uid://38ya6vphm5bu"
script = ExtResource("5_125nt") script = ExtResource("5_125nt")
metadata/_custom_type_script = "uid://b5c8g5frishjs" metadata/_custom_type_script = "uid://b5c8g5frishjs"
[sub_resource type="SphereShape3D" id="SphereShape3D_prox"]
radius = 3.0
[node name="TestMap" type="Node3D"] [node name="TestMap" type="Node3D"]
script = ExtResource("1_6ms5s") script = ExtResource("1_6ms5s")
@@ -42,6 +46,27 @@ entityId = "ad5a1504-7fbf-45d6-b1bf-6e7af6314066"
interactType = 3 interactType = 3
cutscene = SubResource("Resource_tr4a0") cutscene = SubResource("Resource_tr4a0")
[node name="ChatboxNPC" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 1.11219, -3)
entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567891"
interactType = 5
chatboxMessage = "Hey! Press interact again to close this box."
[node name="ProximityNPC" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 1.11219, -4)
entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567892"
interactType = 6
chatboxMessage = "Hey, over here!"
[node name="EntityProximityArea" type="Area3D" parent="ProximityNPC" node_paths=PackedStringArray("entity")]
collision_layer = 0
collision_mask = 2
script = ExtResource("8_prox")
entity = NodePath("..")
[node name="CollisionShape3D" type="CollisionShape3D" parent="ProximityNPC/EntityProximityArea"]
shape = SubResource("SphereShape3D_prox")
[node name="TestMapBase" parent="." instance=ExtResource("1_ox0si")] [node name="TestMapBase" parent="." instance=ExtResource("1_ox0si")]
[node name="Player" parent="." instance=ExtResource("2_jmygs")] [node name="Player" parent="." instance=ExtResource("2_jmygs")]
+2 -1
View File
@@ -1,9 +1,10 @@
class_name RootUI extends Control class_name RootUI extends Control
@export var debugMenu:DebugMenu @export var debugMenu:DebugMenu
@export var textBox:VNTextbox @export var mainChatBox:ChatBox
@export var gameMenu:GameMenu @export var gameMenu:GameMenu
@export var pauseMenu:PauseMenu @export var pauseMenu:PauseMenu
@export var chatBoxContainer:Control
func _enter_tree() -> void: func _enter_tree() -> void:
UI.rootUi = self UI.rootUi = self
+13 -3
View File
@@ -6,7 +6,7 @@
[ext_resource type="PackedScene" uid="uid://b38dr0wkix76t" path="res://ui/debugmenu/DebugMenu.tscn" id="4_u132g"] [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"] [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", "textBox", "gameMenu", "pauseMenu")] [node name="RootUI" type="Control" node_paths=PackedStringArray("debugMenu", "mainChatBox", "gameMenu", "pauseMenu", "chatBoxContainer")]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
@@ -16,9 +16,10 @@ grow_vertical = 2
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_son71") script = ExtResource("1_son71")
debugMenu = NodePath("DebugMenu") debugMenu = NodePath("DebugMenu")
textBox = NodePath("VNTextbox") mainChatBox = NodePath("ChatBox")
gameMenu = NodePath("GameMenu") gameMenu = NodePath("GameMenu")
pauseMenu = NodePath("PauseMenu") pauseMenu = NodePath("PauseMenu")
chatBoxContainer = NodePath("WorldChatBoxContainer")
metadata/_custom_type_script = "uid://dq3qyyayugt5l" metadata/_custom_type_script = "uid://dq3qyyayugt5l"
[node name="DebugMenu" parent="." instance=ExtResource("4_u132g")] [node name="DebugMenu" parent="." instance=ExtResource("4_u132g")]
@@ -34,6 +35,15 @@ process_mode = 3
visible = false visible = false
layout_mode = 1 layout_mode = 1
[node name="VNTextbox" parent="." instance=ExtResource("1_1mtk3")] [node name="WorldChatBoxContainer" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="ChatBox" parent="." instance=ExtResource("1_1mtk3")]
visible = false visible = false
layout_mode = 1 layout_mode = 1
+24 -3
View File
@@ -6,16 +6,18 @@ var rootUi:RootUI = null
# between lines where the textbox is momentarily closed. # between lines where the textbox is momentarily closed.
var dialogueActive:bool = false var dialogueActive:bool = false
var _chatBoxes:Array = []
var DEBUG_MENU: var DEBUG_MENU:
get(): get():
if rootUi && rootUi.debugMenu: if rootUi && rootUi.debugMenu:
return rootUi.debugMenu return rootUi.debugMenu
return null return null
var TEXTBOX: var MAIN_CHATBOX:
get(): get():
if rootUi && rootUi.textBox: if rootUi && rootUi.mainChatBox:
return rootUi.textBox return rootUi.mainChatBox
return null return null
var GAME_MENU: var GAME_MENU:
@@ -29,3 +31,22 @@ var PAUSE_MENU:
if rootUi && rootUi.pauseMenu: if rootUi && rootUi.pauseMenu:
return rootUi.pauseMenu return rootUi.pauseMenu
return null 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
+57 -9
View File
@@ -1,6 +1,10 @@
class_name VNTextbox extends PanelContainer class_name ChatBox extends PanelContainer
const VN_REVEAL_TIME = 0.01 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 label:AdvancedRichText;
var parsedOutText = "" var parsedOutText = ""
@@ -12,6 +16,7 @@ var currentViewScrolled = true;
var isSpeedupDown = false; var isSpeedupDown = false;
var hasLetGoOfInteract:bool = true; var hasLetGoOfInteract:bool = true;
var worldTarget:Node3D = null
var isClosed:bool = false: var isClosed:bool = false:
get(): get():
@@ -19,16 +24,59 @@ var isClosed:bool = false:
set(value): set(value):
self.visible = !value; self.visible = !value;
signal textboxClosing signal chatboxClosing
func _ready() -> void: func _ready() -> void:
label = $MarginContainer/Label label = $MarginContainer/Label
isClosed = true 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: func _process(delta: float) -> void:
if isClosed: if isClosed:
return; return;
_updateWorldPosition()
# _recalcText always resets fit_content to false; re-apply so the
# PanelContainer can grow to its content height in world-space mode.
if worldTarget != null and !label.fit_content:
label.fit_content = true
if label.getFinalText() == "": if label.getFinalText() == "":
isClosed = true; isClosed = true;
return; return;
@@ -51,7 +99,7 @@ func _process(delta: float) -> void:
else: else:
# On last page # On last page
if Input.is_action_just_released("interact") && hasLetGoOfInteract: if Input.is_action_just_released("interact") && hasLetGoOfInteract:
textboxClosing.emit(); chatboxClosing.emit();
isClosed = true; isClosed = true;
currentViewScrolled = true currentViewScrolled = true
return; return;
@@ -73,15 +121,15 @@ func _process(delta: float) -> void:
revealTimer = 0; revealTimer = 0;
label.visible_characters += 1; label.visible_characters += 1;
func setText(text:String) -> void: func setText(text:String, target:Node3D = null) -> void:
# Prepare textbox for scrolling worldTarget = target
_setWorldLayout(worldTarget != null)
# Resets scroll # Resets scroll
revealTimer = 0; revealTimer = 0;
currentViewScrolled = false; currentViewScrolled = false;
label.startLine = 0; label.startLine = 0;
# I had a frame wait here before.
label.text = text; label.text = text;
label.visible_characters = 0; label.visible_characters = 0;
@@ -90,7 +138,7 @@ func setText(text:String) -> void:
isSpeedupDown = false isSpeedupDown = false
isClosed = false; isClosed = false;
func setTextAndWait(text:String) -> void: func setTextAndWait(text:String, target:Node3D = null) -> void:
self.setText(text); self.setText(text, target);
await self.textboxClosing await self.chatboxClosing
await get_tree().process_frame await get_tree().process_frame
+1 -1
View File
@@ -4,7 +4,7 @@
[ext_resource type="Script" uid="uid://h8lw23ypcfty" path="res://ui/component/VNTextbox.gd" id="2_uo1gm"] [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"] [ext_resource type="Script" uid="uid://bjj6upgk1uvxd" path="res://ui/component/advancedrichtext/AdvancedRichText.gd" id="3_m60k3"]
[node name="VNTextbox" type="PanelContainer"] [node name="ChatBox" type="PanelContainer"]
clip_contents = true clip_contents = true
anchors_preset = 12 anchors_preset = 12
anchor_top = 1.0 anchor_top = 1.0
+62
View File
@@ -0,0 +1,62 @@
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 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
View File
@@ -0,0 +1 @@
uid://m1keb2pw8bqq
+21
View File
@@ -0,0 +1,21 @@
[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 -1
View File
@@ -41,7 +41,7 @@ func _unhandled_input(event:InputEvent) -> void:
if event.is_action_pressed("menu"): if event.is_action_pressed("menu"):
if visible: if visible:
close() close()
elif !UI.dialogueActive && UI.TEXTBOX.isClosed: elif !UI.dialogueActive && UI.MAIN_CHATBOX.isClosed:
open() open()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return