From b364fae1c79cd58f46da9722b9811d1394ecb18b Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Fri, 12 Jun 2026 09:08:13 -0500 Subject: [PATCH] in-world ui textboxes --- cutscene/dialogue/DialogueAction.gd | 19 +++++- overworld/entity/Entity.gd | 4 ++ overworld/entity/EntityInteractableArea.gd | 14 ++++- overworld/entity/EntityMovement.gd | 5 +- overworld/entity/EntityProximityArea.gd | 20 ++++++ overworld/entity/EntityProximityArea.gd.uid | 1 + overworld/map/TestMap.tscn | 27 +++++++- ui/RootUI.gd | 3 +- ui/RootUI.tscn | 16 ++++- ui/UISingleton.gd | 27 +++++++- ui/component/VNTextbox.gd | 68 ++++++++++++++++++--- ui/component/VNTextbox.tscn | 2 +- ui/component/WorldChatBox.gd | 62 +++++++++++++++++++ ui/component/WorldChatBox.gd.uid | 1 + ui/component/WorldChatBox.tscn | 21 +++++++ ui/gamemenu/GameMenu.gd | 2 +- 16 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 overworld/entity/EntityProximityArea.gd create mode 100644 overworld/entity/EntityProximityArea.gd.uid create mode 100644 ui/component/WorldChatBox.gd create mode 100644 ui/component/WorldChatBox.gd.uid create mode 100644 ui/component/WorldChatBox.tscn diff --git a/cutscene/dialogue/DialogueAction.gd b/cutscene/dialogue/DialogueAction.gd index a5a878f..054b858 100644 --- a/cutscene/dialogue/DialogueAction.gd +++ b/cutscene/dialogue/DialogueAction.gd @@ -11,6 +11,7 @@ static func dialogueCallable(params:Dictionary) -> int: var resource:DialogueResource = params['resource'] var title:String = params.get('title', 'start') var extraStates:Array = params.get('extraStates', []) + var characterTargets:Dictionary = params.get('characterTargets', {}) UI.dialogueActive = true 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: text = line.character + ": " + text + var target:Node3D = _getLineTarget(line.character, characterTargets) + if line.responses.size() > 0: # Show text then auto-pick the first allowed response. # 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 = "" for response:DialogueResponse in line.responses: if response.is_allowed: @@ -30,16 +33,26 @@ static func dialogueCallable(params:Dictionary) -> int: break line = await DialogueManager.get_next_dialogue_line(resource, nextId, extraStates) 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) UI.dialogueActive = false 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 { 'function': dialogueCallable, 'resource': resource, 'title': title, 'extraStates': extraStates, + 'characterTargets': characterTargets, } diff --git a/overworld/entity/Entity.gd b/overworld/entity/Entity.gd index d519ab2..512da8a 100644 --- a/overworld/entity/Entity.gd +++ b/overworld/entity/Entity.gd @@ -14,6 +14,8 @@ enum InteractType { ONE_TIME_ITEM, CUTSCENE, BATTLE_TEST, + CHATBOX, + PROXIMITY_CHATBOX, }; @export_category("Identification") @@ -33,6 +35,8 @@ var button := func(): @export var dialogueTitle:String = "start" @export var oneTimeItem:ItemResource = null @export var cutscene:CutsceneResource = null +@export var chatboxMessage:String = "" +@export var chatboxDuration:float = 3.0 # TEST BATTLE @export_category("Test Battle") diff --git a/overworld/entity/EntityInteractableArea.gd b/overworld/entity/EntityInteractableArea.gd index 5d25db4..5d6fd5f 100644 --- a/overworld/entity/EntityInteractableArea.gd +++ b/overworld/entity/EntityInteractableArea.gd @@ -33,15 +33,19 @@ func isInteractable() -> bool: if entity.interactType == Entity.InteractType.BATTLE_TEST: return true + if entity.interactType == Entity.InteractType.CHATBOX: + return entity.chatboxMessage != "" + return false -func _onConversationInteract(_other:Entity) -> void: +func _onConversationInteract(other:Entity) -> void: assert(entity.dialogueResource != null) var cutscene:Cutscene = Cutscene.new() cutscene.addCallable(DialogueAction.getDialogueCallable( entity.dialogueResource, entity.dialogueTitle, - [entity] + [entity], + {"npc": entity, "player": other} )) cutscene.start() @@ -83,5 +87,11 @@ func onInteract(other:Entity) -> void: cutscene.start() return + Entity.InteractType.CHATBOX: + assert(entity.chatboxMessage != "") + var chatbox:WorldChatBox = UI.spawnWorldChatBox(entity) + chatbox.showAdvanceable(entity.chatboxMessage) + return + _: pass diff --git a/overworld/entity/EntityMovement.gd b/overworld/entity/EntityMovement.gd index e821efb..4cdb492 100644 --- a/overworld/entity/EntityMovement.gd +++ b/overworld/entity/EntityMovement.gd @@ -20,7 +20,8 @@ func _applyGravity() -> void: func _applyPlayerMovement(delta:float): # Interactions, may move if Input.is_action_just_pressed("interact") && interactingArea && interactingArea.hasInteraction(): - interactingArea.interact() + if !UI.hasAdvanceableChatBox(): + interactingArea.interact() return # Directional input @@ -64,7 +65,7 @@ func _applyFriction(delta:float) -> void: func _canMove() -> bool: if UI.dialogueActive: return false - if !UI.TEXTBOX.isClosed: + if !UI.MAIN_CHATBOX.isClosed: return false if UI.GAME_MENU && UI.GAME_MENU.isOpen(): return false diff --git a/overworld/entity/EntityProximityArea.gd b/overworld/entity/EntityProximityArea.gd new file mode 100644 index 0000000..11e020d --- /dev/null +++ b/overworld/entity/EntityProximityArea.gd @@ -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) diff --git a/overworld/entity/EntityProximityArea.gd.uid b/overworld/entity/EntityProximityArea.gd.uid new file mode 100644 index 0000000..c2f1cf9 --- /dev/null +++ b/overworld/entity/EntityProximityArea.gd.uid @@ -0,0 +1 @@ +uid://bq2lsd8uyrtcf diff --git a/overworld/map/TestMap.tscn b/overworld/map/TestMap.tscn index bcbbd12..8f2cdef 100644 --- a/overworld/map/TestMap.tscn +++ b/overworld/map/TestMap.tscn @@ -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="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://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://bq2lsd8uyrtcf" path="res://overworld/entity/EntityProximityArea.gd" id="8_prox"] [sub_resource type="Resource" id="Resource_125nt"] script = ExtResource("4_xf0pb") @@ -17,6 +18,9 @@ metadata/_custom_type_script = "uid://38ya6vphm5bu" script = ExtResource("5_125nt") metadata/_custom_type_script = "uid://b5c8g5frishjs" +[sub_resource type="SphereShape3D" id="SphereShape3D_prox"] +radius = 3.0 + [node name="TestMap" type="Node3D"] script = ExtResource("1_6ms5s") @@ -42,6 +46,27 @@ entityId = "ad5a1504-7fbf-45d6-b1bf-6e7af6314066" interactType = 3 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="Player" parent="." instance=ExtResource("2_jmygs")] diff --git a/ui/RootUI.gd b/ui/RootUI.gd index 9b65add..0b48b0c 100644 --- a/ui/RootUI.gd +++ b/ui/RootUI.gd @@ -1,9 +1,10 @@ class_name RootUI extends Control @export var debugMenu:DebugMenu -@export var textBox:VNTextbox +@export var mainChatBox:ChatBox @export var gameMenu:GameMenu @export var pauseMenu:PauseMenu +@export var chatBoxContainer:Control func _enter_tree() -> void: UI.rootUi = self diff --git a/ui/RootUI.tscn b/ui/RootUI.tscn index ff1dc5c..157edae 100644 --- a/ui/RootUI.tscn +++ b/ui/RootUI.tscn @@ -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://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 anchors_preset = 15 anchor_right = 1.0 @@ -16,9 +16,10 @@ grow_vertical = 2 mouse_filter = 2 script = ExtResource("1_son71") debugMenu = NodePath("DebugMenu") -textBox = NodePath("VNTextbox") +mainChatBox = NodePath("ChatBox") gameMenu = NodePath("GameMenu") pauseMenu = NodePath("PauseMenu") +chatBoxContainer = NodePath("WorldChatBoxContainer") metadata/_custom_type_script = "uid://dq3qyyayugt5l" [node name="DebugMenu" parent="." instance=ExtResource("4_u132g")] @@ -34,6 +35,15 @@ process_mode = 3 visible = false 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 layout_mode = 1 diff --git a/ui/UISingleton.gd b/ui/UISingleton.gd index 6a0a1b0..c45d369 100644 --- a/ui/UISingleton.gd +++ b/ui/UISingleton.gd @@ -6,16 +6,18 @@ var rootUi:RootUI = null # between lines where the textbox is momentarily closed. var dialogueActive:bool = false +var _chatBoxes:Array = [] + var DEBUG_MENU: get(): if rootUi && rootUi.debugMenu: return rootUi.debugMenu return null -var TEXTBOX: +var MAIN_CHATBOX: get(): - if rootUi && rootUi.textBox: - return rootUi.textBox + if rootUi && rootUi.mainChatBox: + return rootUi.mainChatBox return null var GAME_MENU: @@ -29,3 +31,22 @@ var PAUSE_MENU: if rootUi && rootUi.pauseMenu: 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 diff --git a/ui/component/VNTextbox.gd b/ui/component/VNTextbox.gd index 201b523..0c2639d 100644 --- a/ui/component/VNTextbox.gd +++ b/ui/component/VNTextbox.gd @@ -1,6 +1,10 @@ -class_name VNTextbox extends PanelContainer +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 = "" @@ -12,6 +16,7 @@ var currentViewScrolled = true; var isSpeedupDown = false; var hasLetGoOfInteract:bool = true; +var worldTarget:Node3D = null var isClosed:bool = false: get(): @@ -19,20 +24,63 @@ var isClosed:bool = false: set(value): self.visible = !value; -signal textboxClosing +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 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() == "": isClosed = true; return; - + if Input.is_action_just_released("interact") && !hasLetGoOfInteract: hasLetGoOfInteract = true; return @@ -51,7 +99,7 @@ func _process(delta: float) -> void: else: # On last page if Input.is_action_just_released("interact") && hasLetGoOfInteract: - textboxClosing.emit(); + chatboxClosing.emit(); isClosed = true; currentViewScrolled = true return; @@ -73,15 +121,15 @@ func _process(delta: float) -> void: revealTimer = 0; label.visible_characters += 1; -func setText(text:String) -> void: - # Prepare textbox for scrolling +func setText(text:String, target:Node3D = null) -> void: + worldTarget = target + _setWorldLayout(worldTarget != null) # Resets scroll revealTimer = 0; currentViewScrolled = false; label.startLine = 0; - # I had a frame wait here before. label.text = text; label.visible_characters = 0; @@ -90,7 +138,7 @@ func setText(text:String) -> void: isSpeedupDown = false isClosed = false; -func setTextAndWait(text:String) -> void: - self.setText(text); - await self.textboxClosing +func setTextAndWait(text:String, target:Node3D = null) -> void: + self.setText(text, target); + await self.chatboxClosing await get_tree().process_frame diff --git a/ui/component/VNTextbox.tscn b/ui/component/VNTextbox.tscn index 777571c..f1e09ac 100644 --- a/ui/component/VNTextbox.tscn +++ b/ui/component/VNTextbox.tscn @@ -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://bjj6upgk1uvxd" path="res://ui/component/advancedrichtext/AdvancedRichText.gd" id="3_m60k3"] -[node name="VNTextbox" type="PanelContainer"] +[node name="ChatBox" type="PanelContainer"] clip_contents = true anchors_preset = 12 anchor_top = 1.0 diff --git a/ui/component/WorldChatBox.gd b/ui/component/WorldChatBox.gd new file mode 100644 index 0000000..b9e94b2 --- /dev/null +++ b/ui/component/WorldChatBox.gd @@ -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() diff --git a/ui/component/WorldChatBox.gd.uid b/ui/component/WorldChatBox.gd.uid new file mode 100644 index 0000000..0ca3a04 --- /dev/null +++ b/ui/component/WorldChatBox.gd.uid @@ -0,0 +1 @@ +uid://m1keb2pw8bqq diff --git a/ui/component/WorldChatBox.tscn b/ui/component/WorldChatBox.tscn new file mode 100644 index 0000000..f3f95be --- /dev/null +++ b/ui/component/WorldChatBox.tscn @@ -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 diff --git a/ui/gamemenu/GameMenu.gd b/ui/gamemenu/GameMenu.gd index ef0ad98..46f2958 100644 --- a/ui/gamemenu/GameMenu.gd +++ b/ui/gamemenu/GameMenu.gd @@ -41,7 +41,7 @@ func _unhandled_input(event:InputEvent) -> void: if event.is_action_pressed("menu"): if visible: close() - elif !UI.dialogueActive && UI.TEXTBOX.isClosed: + elif !UI.dialogueActive && UI.MAIN_CHATBOX.isClosed: open() get_viewport().set_input_as_handled() return