Improved UI textbox

This commit is contained in:
2026-06-12 11:56:30 -05:00
parent f6a0bb156e
commit 2f3a4eab66
39 changed files with 574 additions and 706 deletions
+46 -24
View File
@@ -102,7 +102,7 @@ The speaker name in a `.dialogue` line (e.g. `Guard: Halt!`) is matched against
- The **display name** shown to the player is a separate property and can differ (e.g. `dialogueName = "guard_captain"` but display name = `"???"` before the character has revealed their name). This lets you write dialogue that references a speaker before the player knows who they are. - The **display name** shown to the player is a separate property and can differ (e.g. `dialogueName = "guard_captain"` but display name = `"???"` before the character has revealed their name). This lets you write dialogue that references a speaker before the player knows who they are.
- **Every dialogue line must have a speaker.** There is no narrator/no-speaker path — if a line has no natural speaker, use a named invisible anchor Entity in the scene. - **Every dialogue line must have a speaker.** There is no narrator/no-speaker path — if a line has no natural speaker, use a named invisible anchor Entity in the scene.
> **Not yet implemented:** the entity-lookup and 3D→UI projection logic. Currently `DialogueAction` passes a target node manually. > **Implemented:** Entity lookup is live — `OVERWORLD.getEntityByDialogueName(line.character)` resolves the speaker. 3D→UI projection runs every frame in `DialogueTextbox._updateWorldPosition()`.
### All conversation routes through DialogueManager ### All conversation routes through DialogueManager
@@ -114,7 +114,7 @@ Whether a line of dialogue is triggered by:
…it is always written in a `.dialogue` file and played back via `DialogueManager.get_next_dialogue_line()`. There is no separate "dumb string" path for simple one-liners. …it is always written in a `.dialogue` file and played back via `DialogueManager.get_next_dialogue_line()`. There is no separate "dumb string" path for simple one-liners.
> **Migration needed:** the current `CHATBOX` interact type (WorldChatBox, plain string) should be replaced with a one-line `.dialogue` file going through the same pipeline. > **Done:** `CHATBOX` interact type removed. All NPC text now routes through DialogueManager.
### Dialogue and movement control ### Dialogue and movement control
@@ -125,7 +125,7 @@ Dialogue does **not** automatically block movement. Each dialogue sequence decla
This is configured per `DialogueAction` call, not per line. This is configured per `DialogueAction` call, not per line.
> **Partially implemented:** `DialogueAction` always sets `UI.dialogueActive`. The non-blocking path does not exist yet. > **Implemented:** `DialogueMode.CONVERSATION` sets `UI.activeConversation = true` (blocks movement). `NARRATION` and `AMBIENT` are non-blocking. `UI.dialogueActive` is driven by `DialogueManager.dialogue_started/ended` signals and is true for any running dialogue regardless of mode.
### Text reveal (scrolling) ### Text reveal (scrolling)
@@ -139,11 +139,13 @@ Body text is revealed character-by-character at a speed approximating natural hu
Holding the **Interact** bind speeds up reveal to near-instant. Once all characters on the current page are revealed, pressing Interact advances to the next page or the next dialogue line. Holding the **Interact** bind speeds up reveal to near-instant. Once all characters on the current page are revealed, pressing Interact advances to the next page or the next dialogue line.
The `[wait=N]` and `[speed=N]` BBCode tags from DialogueManager can override reveal behaviour inline for specific moments (e.g. a shocked line flashing by at `[speed=4]`). The plugin returns `DialogueLine.text` as a plain BBCode string — it does **not** handle punctuation pauses or `...` detection. All reveal pacing is implemented in our textbox, not the plugin.
**ADA note:** fast reveal speeds (via `[speed=N]` or hold-to-skip) should not flash entire blocks of text in a way that could trigger photosensitive responses. Avoid using `[speed=N]` values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input. The `[wait=N]` and `[speed=N]` BBCode tags are extracted from the text by the plugin during line resolution (stored in `DialogueLine.speeds` and inline mutation data). Our textbox must read these to adjust reveal behaviour inline — e.g. a shocked line using `[speed=4]` to flash past quickly.
> **Partially implemented:** `VNTextbox` has character-by-character reveal and hold-to-skip. Punctuation pause logic is not yet implemented. **ADA note:** fast reveal speeds (via `[speed=N]` or hold-to-skip) should not flash entire blocks of text in a way that could trigger photosensitive responses. Avoid `[speed=N]` values so high that multiple lines appear simultaneously. The hold-to-skip path is safe since it requires sustained player input.
> **Implemented:** `DialogueTextbox` has character-by-character reveal, punctuation pauses, `...` detection, and hold-to-skip. `[wait=N]` / `[speed=N]` BBCode tag support is not yet wired.
### Advancement modes ### Advancement modes
@@ -151,12 +153,12 @@ Each dialogue sequence uses one of two advancement modes:
| Mode | Behaviour | | Mode | Behaviour |
|---|---| |---|---|
| `PLAYER` (default) | Player presses Interact to advance to the next page or line once reveal is complete. | | `PLAYER` (default) | Player presses Interact to advance to the next page or line once reveal is complete. Used by `CONVERSATION` and `NARRATION` modes. |
| `TIMED` | Each line auto-advances after a delay estimated from line length (reading speed). A per-line `[next=N]` override is supported for lines that need a specific timing. Player input is ignored. | | `TIMED` | Each line auto-advances after a delay estimated from line length (reading speed). A per-line `[next=N]` tag overrides the estimated delay. Player input is ignored. Used by `AMBIENT` mode. |
Mixed sequences (some lines timed, some player-advanced) are not supported — the mode is set per conversation, not per line. Mixed sequences (some lines timed, some player-advanced) are not supported — advancement mode is determined by `DialogueMode`, not per line.
> **Not yet implemented:** `TIMED` mode. Currently all dialogue requires player input. > **Implemented:** `TIMED` auto-advance is wired in `DialogueTextbox._processAutoAdvance`. Delay is estimated from line length (characters / `READING_CHARS_PER_SECOND`). Per-line `[next=N]` override is not yet wired.
### Response / choice UI ### Response / choice UI
@@ -164,7 +166,7 @@ When a `DialogueLine` has a non-empty `responses` array, reveal pauses and a cho
The choice textbox follows the same world-space anchor and screen-edge clamping rules as all other textboxes. The choice textbox follows the same world-space anchor and screen-edge clamping rules as all other textboxes.
> **Not yet implemented:** `DialogueAction` currently auto-selects the first allowed response. Replace the auto-select block with a call to the response UI and await its selection signal. > **Implemented:** `DialogueChoiceBox` is shown when allowed responses exist, anchored to the player entity via `OVERWORLD.getPlayerEntity()`. Player navigates with ui_up/ui_down or move_forward/move_back, confirms with Interact.
### Trigger types ### Trigger types
@@ -179,21 +181,37 @@ The choice textbox follows the same world-space anchor and screen-edge clamping
--- ---
## DialogueMode enum
Every dialogue sequence has a `DialogueMode` that controls movement blocking and advancement behaviour. Set it per `DialogueAction` call — not per line.
| Mode | Movement | Advancement | Typical use |
|---|---|---|---|
| `CONVERSATION` | Blocked | Player (Interact) | NPC interactions, cutscene dialogue |
| `NARRATION` | Non-blocking | Player (Interact) | Item pickups, announcements the player can dismiss when ready |
| `AMBIENT` | Non-blocking | Timed (auto) | Background NPC-to-NPC chatter, timed popups |
`UI.dialogueActive` is driven by `DialogueManager.dialogue_started` / `dialogue_ended` signals and is true whenever any dialogue is running, regardless of mode. Movement blocking is checked separately: `EntityMovement._canMove()` is false only when an active `CONVERSATION` sequence is in progress.
More modes can be added to this enum as new use cases arise.
---
## DialogueAction — the Cutscene bridge ## DialogueAction — the Cutscene bridge
[cutscene/dialogue/DialogueAction.gd](../../cutscene/dialogue/DialogueAction.gd) is the glue between the `Cutscene` queue and `DialogueManager`. It fetches lines via `get_next_dialogue_line()`, displays them in the world-space textbox, and returns `CUTSCENE_CONTINUE` when the last line is dismissed. [cutscene/dialogue/DialogueAction.gd](../../cutscene/dialogue/DialogueAction.gd) is the glue between the `Cutscene` queue and `DialogueManager`. It fetches lines via `get_next_dialogue_line()`, displays them in the world-space textbox, and returns `CUTSCENE_CONTINUE` when the last line is dismissed.
```gdscript ```gdscript
# Add a blocking dialogue step to any Cutscene # Add a dialogue step to any Cutscene
cutscene.addCallable(DialogueAction.getDialogueCallable( cutscene.addCallable(DialogueAction.getDialogueCallable(
load("res://dialogue/npc/test.dialogue"), "res://dialogue/npc/guard", # base path — no locale suffix, no extension
"start", # section to begin from "start", # section to begin from
[entity], # extra_game_states exposed to {{variables}} in the file [entity], # extra_game_states exposed to {{variables}} in the file
true # blocking — pauses player movement DialogueAction.DialogueMode.CONVERSATION
)) ))
``` ```
The `blockMovement` argument (fourth param, default `true`) controls whether `UI.dialogueActive` is set for the duration. `DialogueAction` resolves the base path to the correct locale file at runtime (e.g. `guard.en.dialogue`), falling back to `en` if the active locale file is missing.
--- ---
@@ -266,10 +284,12 @@ Set these exports on an Entity node whose `interactType = CONVERSATION`:
| Export | Purpose | | Export | Purpose |
|---|---| |---|---|
| `dialogueResource:DialogueResource` | The `.dialogue` file to run | | `dialogueBasePath:String` | Base path to the dialogue file, without locale suffix or extension (e.g. `"res://dialogue/npc/guard"`) |
| `dialogueTitle:String` | Section to start from (default `"start"`) | | `dialogueTitle:String` | Section to start from (default `"start"`) |
| `dialogueName:String` | Key matched against `.dialogue` speaker names to find this entity's textbox anchor |
| `displayName:String` | Player-visible name shown in the textbox speaker label (can differ from `dialogueName`, e.g. `"???"`) |
The entity's `entityId` must match the speaker name used in the dialogue file so the textbox anchor resolves correctly. `dialogueName` must match the speaker name used in the dialogue file. `entityId` is unrelated to dialogue lookup.
--- ---
@@ -277,9 +297,9 @@ The entity's `entityId` must match the speaker name used in the dialogue file so
| File | Used by | | File | Used by |
|---|---| |---|---|
| `dialogue/npc/test.dialogue` | TestMap NPC (NotPlayer) | | `dialogue/npc/test.en.dialogue` | TestMap NPC (NotPlayer / "Stranger") |
| `dialogue/item/pickup.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` | | `dialogue/item/pickup.en.dialogue` | `ItemAction` — item pickup text with `{{item_name}}` and `{{quantity}}` |
| `dialogue/battle/narration.dialogue` | `BattleCutsceneAction` — move announcements, victory/defeat | | `dialogue/battle/narration.en.dialogue` | `BattleCutsceneAction` — move announcements, victory/defeat |
--- ---
@@ -292,11 +312,13 @@ dialogue/npc/test.en.dialogue
dialogue/npc/test.ja.dialogue dialogue/npc/test.ja.dialogue
``` ```
`DialogueAction.getDialogueCallable()` accepts a base path (without locale suffix) and resolves the active locale automatically, falling back to `en` if the locale file is missing. `DialogueAction.getDialogueCallable()` accepts a base path (no locale suffix, no extension — e.g. `"res://dialogue/npc/test"`) and resolves the active locale automatically via `TranslationServer.get_locale()` (e.g. → `test.en.dialogue`), falling back to `en` if the locale file is missing.
To switch language at runtime: `TranslationServer.set_locale("ja")` — all subsequent dialogue loads will use the new locale. The project default is configured in Godot's **Project Settings → Localization** tab.
Do not hardcode any visible text outside of `.dialogue` files. All player-facing strings — including item pickup messages, battle narration, and UI prompts — must live in a dialogue file so they are covered by the locale system. Do not hardcode any visible text outside of `.dialogue` files. All player-facing strings — including item pickup messages, battle narration, and UI prompts — must live in a dialogue file so they are covered by the locale system.
> **Not yet implemented:** locale resolution in `DialogueAction`. Currently loads the resource directly. > **Implemented:** `DialogueAction._loadLocaleResource(basePath)` resolves the active locale via `TranslationServer.get_locale().left(2)`, falling back to `en` if the locale file is missing.
--- ---
+4 -5
View File
@@ -1,7 +1,6 @@
class_name BattleCutsceneAction class_name BattleCutsceneAction
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
static var NARRATION:DialogueResource = preload("res://dialogue/battle/narration.dialogue") const _NARRATION_BASE:String = "res://dialogue/battle/narration"
# State object passed as extra_game_states so {{variable}} tokens resolve in the dialogue file. # State object passed as extra_game_states so {{variable}} tokens resolve in the dialogue file.
class BattleNarrationState: class BattleNarrationState:
@@ -29,7 +28,7 @@ static func battleDecisionCallable(params:Dictionary) -> int:
var cutscene:Cutscene = params['cutscene'] var cutscene:Cutscene = params['cutscene']
cutscene.addCallable( cutscene.addCallable(
DialogueAction.getDialogueCallable(NARRATION, 'move_perform', [state]).merged( DialogueAction.getDialogueCallable(_NARRATION_BASE, 'move_perform', [state], DialogueAction.DialogueMode.NARRATION).merged(
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false {'position': Cutscene.CUTSCENE_ADD_NEXT}, false
) )
) )
@@ -58,7 +57,7 @@ static func playerDecisionCallable(params:Dictionary) -> int:
if allPlayersDead: if allPlayersDead:
params['cutscene'].addCallable( params['cutscene'].addCallable(
DialogueAction.getDialogueCallable(NARRATION, 'battle_defeat').merged( DialogueAction.getDialogueCallable(_NARRATION_BASE, 'battle_defeat', [], DialogueAction.DialogueMode.NARRATION).merged(
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false {'position': Cutscene.CUTSCENE_ADD_NEXT}, false
) )
) )
@@ -66,7 +65,7 @@ static func playerDecisionCallable(params:Dictionary) -> int:
if allEnemiesDead: if allEnemiesDead:
params['cutscene'].addCallable( params['cutscene'].addCallable(
DialogueAction.getDialogueCallable(NARRATION, 'battle_victory').merged( DialogueAction.getDialogueCallable(_NARRATION_BASE, 'battle_victory', [], DialogueAction.DialogueMode.NARRATION).merged(
{'position': Cutscene.CUTSCENE_ADD_NEXT}, false {'position': Cutscene.CUTSCENE_ADD_NEXT}, false
) )
) )
+56 -38
View File
@@ -1,55 +1,73 @@
class_name DialogueAction class_name DialogueAction
# Runs a .dialogue file through the VNTextbox and returns CUTSCENE_CONTINUE when const _TextboxGd = preload("res://ui/component/DialogueTextbox.gd")
# the last line is dismissed. Mutations in the .dialogue file are executed const _ChoiceBoxGd = preload("res://ui/component/DialogueChoiceBox.gd")
# automatically by DialogueManager before the line is returned.
# enum DialogueMode {
# extra_game_states: additional objects/dicts whose properties and methods are CONVERSATION, # blocks movement, player advances
# accessible inside the .dialogue file (alongside all autoloads). NARRATION, # non-blocking, player advances
AMBIENT, # non-blocking, timed auto-advance
}
static func dialogueCallable(params:Dictionary) -> int: static func dialogueCallable(params:Dictionary) -> int:
assert(params.has('resource')) assert(params.has('basePath'))
var resource:DialogueResource = params['resource'] var basePath:String = params['basePath']
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', {}) var mode:DialogueMode = params.get('mode', DialogueMode.CONVERSATION)
var resource:DialogueResource = _loadLocaleResource(basePath)
assert(resource != null, "DialogueAction: could not load resource for path: " + basePath)
if mode == DialogueMode.CONVERSATION:
UI.activeConversation = true
var advancementMode:int = (
_TextboxGd.AdvancementMode.TIMED
if mode == DialogueMode.AMBIENT
else _TextboxGd.AdvancementMode.PLAYER
)
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)
while line != null: while line != null:
var target:Node3D = _getLineTarget(line.character, characterTargets) var entity:Entity = OVERWORLD.getEntityByDialogueName(line.character)
var text:String = line.text
if line.responses.size() > 0: var textbox = _TextboxGd.SCENE.instantiate()
# Show text then auto-pick the first allowed response. UI.chatBoxContainer.add_child(textbox)
# Replace this block with a real response UI when branching dialogue is needed. textbox.setup(line, entity, advancementMode)
await UI.MAIN_CHATBOX.setTextAndWait(text, target) await textbox.dismissed
var nextId:String = ""
for response:DialogueResponse in line.responses: var allowedResponses:Array = line.responses.filter(func(r): return r.is_allowed)
if response.is_allowed: if allowedResponses.size() > 0:
nextId = response.next_id var playerEntity:Entity = OVERWORLD.getPlayerEntity()
break var choiceBox = _ChoiceBoxGd.SCENE.instantiate()
line = await DialogueManager.get_next_dialogue_line(resource, nextId, extraStates) UI.chatBoxContainer.add_child(choiceBox)
choiceBox.setup(line.responses, playerEntity)
var chosen:DialogueResponse = await choiceBox.chosen
line = await DialogueManager.get_next_dialogue_line(resource, chosen.next_id, extraStates)
else: else:
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 if mode == DialogueMode.CONVERSATION:
UI.activeConversation = false
return Cutscene.CUTSCENE_CONTINUE return Cutscene.CUTSCENE_CONTINUE
# "player" (case-insensitive) maps to the player entity; any other named static func _loadLocaleResource(basePath:String) -> DialogueResource:
# character maps to the npc entity; empty character (narrator) uses no target. var lang:String = TranslationServer.get_locale().left(2)
static func _getLineTarget(character:String, characterTargets:Dictionary) -> Node3D: var localePath:String = basePath + "." + lang + ".dialogue"
if characterTargets.is_empty() or character == "": if ResourceLoader.exists(localePath):
return null return load(localePath)
if character.to_lower() == "player": var fallback:String = basePath + ".en.dialogue"
return characterTargets.get("player", null) if ResourceLoader.exists(fallback):
return characterTargets.get("npc", null) return load(fallback)
return null
static func getDialogueCallable(resource:DialogueResource, title:String = 'start', extraStates:Array = [], characterTargets:Dictionary = {}) -> Dictionary: static func getDialogueCallable(basePath:String, title:String = "start", extraStates:Array = [], mode:DialogueMode = DialogueMode.CONVERSATION) -> Dictionary:
return { return {
'function': dialogueCallable, "function": dialogueCallable,
'resource': resource, "basePath": basePath,
'title': title, "title": title,
'extraStates': extraStates, "extraStates": extraStates,
'characterTargets': characterTargets, "mode": mode,
} }
+8 -7
View File
@@ -1,23 +1,24 @@
class_name ItemAction class_name ItemAction
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
# Passed as extra_game_states so {{item_name}} and {{quantity}} resolve in the .dialogue file.
class ItemDialogueState: class ItemDialogueState:
var item_name:String var item_name:String
var quantity:int var quantity:int
func _init(name:String, qty:int) -> void: func _init(itemName:String, qty:int) -> void:
item_name = name item_name = itemName
quantity = qty quantity = qty
static var PICKUP_DIALOGUE:DialogueResource = preload("res://dialogue/item/pickup.dialogue")
static func itemGetCallable(params:Dictionary) -> int: static func itemGetCallable(params:Dictionary) -> int:
assert(params.has('stack')) assert(params.has('stack'))
var stack:ItemStack = params['stack'] var stack:ItemStack = params['stack']
PARTY.BACKPACK.addStack(stack) PARTY.BACKPACK.addStack(stack)
var state = ItemDialogueState.new(Item.getItemName(stack.item), stack.quantity) var state = ItemDialogueState.new(Item.getItemName(stack.item), stack.quantity)
var dialogueParams:Dictionary = DialogueAction.getDialogueCallable(PICKUP_DIALOGUE, 'start', [state]) var dialogueParams:Dictionary = DialogueAction.getDialogueCallable(
"res://dialogue/item/pickup",
"start",
[state],
DialogueAction.DialogueMode.CONVERSATION
)
dialogueParams['position'] = Cutscene.CUTSCENE_ADD_NEXT dialogueParams['position'] = Cutscene.CUTSCENE_ADD_NEXT
params['cutscene'].addCallable(dialogueParams) params['cutscene'].addCallable(dialogueParams)
return Cutscene.CUTSCENE_CONTINUE return Cutscene.CUTSCENE_CONTINUE
-16
View File
@@ -1,16 +0,0 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://b0xspt5l72ta4"
path="res://.godot/imported/narration.dialogue-d3cec8f2ca7d5fcccf22c774ea16000c.tres"
[deps]
source_file="res://dialogue/battle/narration.dialogue"
dest_files=["res://.godot/imported/narration.dialogue-d3cec8f2ca7d5fcccf22c774ea16000c.tres"]
[params]
defaults=true
@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://c4ik5l43fllwe"
path="res://.godot/imported/narration.en.dialogue-306b824321c5ffb5e2681c2a3febbbf1.tres"
[deps]
source_file="res://dialogue/battle/narration.en.dialogue"
dest_files=["res://.godot/imported/narration.en.dialogue-306b824321c5ffb5e2681c2a3febbbf1.tres"]
[params]
defaults=true
-16
View File
@@ -1,16 +0,0 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://b1xscm8cjvdwa"
path="res://.godot/imported/pickup.dialogue-002022bf79323195869be8ebaf81f1dd.tres"
[deps]
source_file="res://dialogue/item/pickup.dialogue"
dest_files=["res://.godot/imported/pickup.dialogue-002022bf79323195869be8ebaf81f1dd.tres"]
[params]
defaults=true
+16
View File
@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://mldhv5ofaxmf"
path="res://.godot/imported/pickup.en.dialogue-9582a3392a037e5325ef2829f22ff4cb.tres"
[deps]
source_file="res://dialogue/item/pickup.en.dialogue"
dest_files=["res://.godot/imported/pickup.en.dialogue-9582a3392a037e5325ef2829f22ff4cb.tres"]
[params]
defaults=true
-16
View File
@@ -1,16 +0,0 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://b7hdnwp46h3hi"
path="res://.godot/imported/test.dialogue-3675c9be06c1457d57c9a2cca7088875.tres"
[deps]
source_file="res://dialogue/npc/test.dialogue"
dest_files=["res://.godot/imported/test.dialogue-3675c9be06c1457d57c9a2cca7088875.tres"]
[params]
defaults=true
+16
View File
@@ -0,0 +1,16 @@
[remap]
importer="dialogue_manager"
importer_version=15
type="Resource"
uid="uid://bh0d47hd3edu7"
path="res://.godot/imported/test.en.dialogue-5bcd16c222052c4220f719dbcbf91fa5.tres"
[deps]
source_file="res://dialogue/npc/test.en.dialogue"
dest_files=["res://.godot/imported/test.en.dialogue-5bcd16c222052c4220f719dbcbf91fa5.tres"]
[params]
defaults=true
+19
View File
@@ -7,6 +7,25 @@ var hasFadedOut:bool = false
var playerDestinationNodeName:String var playerDestinationNodeName:String
var newMapLoaded:bool = false var newMapLoaded:bool = false
var _dialogueEntities:Dictionary = {}
var _playerEntity:Entity = null
func registerDialogueEntity(entity:Entity) -> void:
_dialogueEntities[entity.dialogueName.to_lower()] = entity
if entity.movementType == Entity.MovementType.PLAYER:
_playerEntity = entity
func unregisterDialogueEntity(entity:Entity) -> void:
_dialogueEntities.erase(entity.dialogueName.to_lower())
if _playerEntity == entity:
_playerEntity = null
func getEntityByDialogueName(dialogueName:String) -> Entity:
return _dialogueEntities.get(dialogueName.to_lower(), null)
func getPlayerEntity() -> Entity:
return _playerEntity
func isMapChanging() -> bool: func isMapChanging() -> bool:
return newMapPath != "" return newMapPath != ""
+18 -8
View File
@@ -14,9 +14,8 @@ enum InteractType {
ONE_TIME_ITEM, ONE_TIME_ITEM,
CUTSCENE, CUTSCENE,
BATTLE_TEST, BATTLE_TEST,
CHATBOX,
PROXIMITY_CHATBOX, PROXIMITY_CHATBOX,
}; }
@export_category("Identification") @export_category("Identification")
@export var entityId:String = UUID.uuidv4() @export var entityId:String = UUID.uuidv4()
@@ -24,19 +23,30 @@ enum InteractType {
var button := func(): var button := func():
entityId = UUID.uuidv4() entityId = UUID.uuidv4()
# Movement settings @export_category("Dialogue")
@export var dialogueName:String = ""
@export var displayName:String = ""
@export_category("Movement") @export_category("Movement")
@export var movementType:MovementType = MovementType.NONE @export var movementType:MovementType = MovementType.NONE
# Interaction settings
@export_category("Interactions") @export_category("Interactions")
@export var interactType:InteractType = InteractType.NONE @export var interactType:InteractType = InteractType.NONE
@export var dialogueResource:DialogueResource = null @export var dialogueBasePath:String = ""
@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
@export_category("Test Battle") @export_category("Test Battle")
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
if dialogueName != "":
OVERWORLD.registerDialogueEntity(self)
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
if dialogueName != "":
OVERWORLD.unregisterDialogueEntity(self)
+20 -50
View File
@@ -1,6 +1,5 @@
class_name EntityInteractableArea extends Area3D class_name EntityInteractableArea extends Area3D
const ItemAction = preload("res://cutscene/item/ItemAction.gd") const ItemAction = preload("res://cutscene/item/ItemAction.gd")
const DialogueAction = preload("res://cutscene/dialogue/DialogueAction.gd")
@export var entity:Entity @export var entity:Entity
@@ -8,44 +7,32 @@ func isInteractable() -> bool:
if !entity: if !entity:
return false return false
if entity.interactType == Entity.InteractType.NONE: match entity.interactType:
return false Entity.InteractType.NONE:
if entity.interactType == Entity.InteractType.CONVERSATION:
return entity.dialogueResource != null
if entity.interactType == Entity.InteractType.CUTSCENE:
if entity.cutscene == null:
return false return false
if !entity.cutscene.canRun(): Entity.InteractType.CONVERSATION:
return false return entity.dialogueBasePath != ""
return true Entity.InteractType.CUTSCENE:
return entity.cutscene != null and entity.cutscene.canRun()
if entity.interactType == Entity.InteractType.ONE_TIME_ITEM: Entity.InteractType.ONE_TIME_ITEM:
if entity.oneTimeItem == null: return (
return false entity.oneTimeItem != null
if entity.oneTimeItem.quantity <= 0: and entity.oneTimeItem.quantity > 0
return false and entity.oneTimeItem.item != Item.Id.NULL
if entity.oneTimeItem.item == Item.Id.NULL: )
return false Entity.InteractType.BATTLE_TEST:
return true return true
if entity.interactType == Entity.InteractType.BATTLE_TEST:
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.dialogueBasePath != "")
var cutscene:Cutscene = Cutscene.new() var cutscene:Cutscene = Cutscene.new()
cutscene.addCallable(DialogueAction.getDialogueCallable( cutscene.addCallable(DialogueAction.getDialogueCallable(
entity.dialogueResource, entity.dialogueBasePath,
entity.dialogueTitle, entity.dialogueTitle,
[entity], [entity],
{"npc": entity, "player": other} DialogueAction.DialogueMode.CONVERSATION
)) ))
cutscene.start() cutscene.start()
@@ -57,24 +44,17 @@ func _onItemInteract(_other:Entity) -> void:
entity.queue_free() entity.queue_free()
func onInteract(other:Entity) -> void: func onInteract(other:Entity) -> void:
if entity.interactType == Entity.InteractType.NONE:
return
match entity.interactType: match entity.interactType:
Entity.InteractType.NONE:
return
Entity.InteractType.CONVERSATION: Entity.InteractType.CONVERSATION:
_onConversationInteract(other) _onConversationInteract(other)
return
Entity.InteractType.ONE_TIME_ITEM: Entity.InteractType.ONE_TIME_ITEM:
_onItemInteract(other) _onItemInteract(other)
return
Entity.InteractType.CUTSCENE: Entity.InteractType.CUTSCENE:
var cutscene:Cutscene = Cutscene.new() var cutscene:Cutscene = Cutscene.new()
entity.cutscene.queue(cutscene) entity.cutscene.queue(cutscene)
cutscene.start() cutscene.start()
return
Entity.InteractType.BATTLE_TEST: Entity.InteractType.BATTLE_TEST:
var testEnemy = BattleFighter.new({ var testEnemy = BattleFighter.new({
'controller': BattleFighter.FighterController.AI 'controller': BattleFighter.FighterController.AI
@@ -85,13 +65,3 @@ func onInteract(other:Entity) -> void:
BATTLE.BattlePosition.LEFT_MIDDLE_FRONT: testEnemy BATTLE.BattlePosition.LEFT_MIDDLE_FRONT: testEnemy
})) }))
cutscene.start() cutscene.start()
return
Entity.InteractType.CHATBOX:
assert(entity.chatboxMessage != "")
var chatbox:WorldChatBox = UI.spawnWorldChatBox(entity)
chatbox.showAdvanceable(entity.chatboxMessage)
return
_:
pass
+2 -2
View File
@@ -20,7 +20,7 @@ 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():
if !UI.hasAdvanceableChatBox() && !UI.dialogueActive: if !UI.dialogueActive:
interactingArea.interact() interactingArea.interact()
return return
@@ -63,7 +63,7 @@ func _applyFriction(delta:float) -> void:
entity.velocity.z *= delta * FRICTION entity.velocity.z *= delta * FRICTION
func _canMove() -> bool: func _canMove() -> bool:
if !UI.MAIN_CHATBOX.isClosed: if UI.activeConversation:
return false return false
if UI.GAME_MENU && UI.GAME_MENU.isOpen(): if UI.GAME_MENU && UI.GAME_MENU.isOpen():
return false return false
+4 -23
View File
@@ -2,31 +2,12 @@ class_name EntityProximityArea extends Area3D
@export var entity:Entity @export var entity:Entity
var _triggered:bool = false
var _chatbox:WorldChatBox = null
func _ready() -> void: func _ready() -> void:
body_entered.connect(_onBodyEntered) body_entered.connect(_onBodyEntered)
body_exited.connect(_onBodyExited) body_exited.connect(_onBodyExited)
func _onBodyEntered(body:Node3D) -> void: func _onBodyEntered(_body:Node3D) -> void:
if _triggered: pass
return
if !(body is Entity):
return
if (body as Entity).entityId != "player":
return
_triggered = true
assert(entity != null && entity.chatboxMessage != "")
if is_instance_valid(_chatbox) and _chatbox.visible:
_chatbox.resetTimer(entity.chatboxDuration)
else:
_chatbox = UI.spawnWorldChatBox(entity)
_chatbox.showTimed(entity.chatboxMessage, entity.chatboxDuration)
func _onBodyExited(body:Node3D) -> void: func _onBodyExited(_body:Node3D) -> void:
if !(body is Entity): pass
return
if (body as Entity).entityId != "player":
return
_triggered = false
+1 -4
View File
@@ -1,10 +1,7 @@
extends Node3D extends Node3D
func _ready() -> void: func _ready() -> void:
# Assign dialogue resources after the plugin has imported the .dialogue files.
# Once the DialogueManager plugin is enabled in the editor, you can assign
# dialogueResource directly in the Inspector instead.
var npc:Entity = $NotPlayer var npc:Entity = $NotPlayer
if npc: if npc:
npc.dialogueResource = load("res://dialogue/npc/test.dialogue") npc.dialogueBasePath = "res://dialogue/npc/test"
npc.dialogueTitle = "start" npc.dialogueTitle = "start"
+4 -2
View File
@@ -27,6 +27,8 @@ script = ExtResource("1_6ms5s")
[node name="NotPlayer" parent="." instance=ExtResource("2_jmygs")] [node name="NotPlayer" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00883961, 1.11219, 0.0142021) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00883961, 1.11219, 0.0142021)
entityId = "bcabec96-8d33-4c16-a997-3bb3b0562b33" entityId = "bcabec96-8d33-4c16-a997-3bb3b0562b33"
dialogueName = "stranger"
displayName = "Stranger"
interactType = 1 interactType = 1
[node name="NotPlayer4" parent="." instance=ExtResource("2_jmygs")] [node name="NotPlayer4" parent="." instance=ExtResource("2_jmygs")]
@@ -50,13 +52,11 @@ cutscene = SubResource("Resource_tr4a0")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 1.11219, -3) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 1.11219, -3)
entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567891" entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567891"
interactType = 5 interactType = 5
chatboxMessage = "Hey! Press interact again to close this box."
[node name="ProximityNPC" parent="." instance=ExtResource("2_jmygs")] [node name="ProximityNPC" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 1.11219, -4) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 1.11219, -4)
entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567892" entityId = "c1a2b3c4-d5e6-7890-abcd-ef1234567892"
interactType = 6 interactType = 6
chatboxMessage = "Hey, over here!"
[node name="EntityProximityArea" type="Area3D" parent="ProximityNPC" node_paths=PackedStringArray("entity")] [node name="EntityProximityArea" type="Area3D" parent="ProximityNPC" node_paths=PackedStringArray("entity")]
collision_layer = 0 collision_layer = 0
@@ -72,6 +72,8 @@ shape = SubResource("SphereShape3D_prox")
[node name="Player" parent="." instance=ExtResource("2_jmygs")] [node name="Player" parent="." instance=ExtResource("2_jmygs")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.1915, 1.05, 0.125589) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3.1915, 1.05, 0.125589)
entityId = "player" entityId = "player"
dialogueName = "john"
displayName = "John"
movementType = 2 movementType = 2
[node name="Camera3D" type="Camera3D" parent="." node_paths=PackedStringArray("targetNode")] [node name="Camera3D" type="Camera3D" parent="." node_paths=PackedStringArray("targetNode")]
+1 -1
View File
@@ -209,7 +209,7 @@ center_camera={
[internationalization] [internationalization]
locale/translations=PackedStringArray("res://locale/en_AU.po") locale/translations=PackedStringArray("res://locale/en_AU.po")
locale/translations_pot_files=PackedStringArray("res://dialogue/battle/narration.dialogue", "res://dialogue/item/pickup.dialogue", "res://dialogue/npc/test.dialogue") locale/translations_pot_files=PackedStringArray("res://dialogue/battle/narration.en.dialogue", "res://dialogue/item/pickup.en.dialogue", "res://dialogue/npc/test.en.dialogue")
locale/language_filter=["ja"] locale/language_filter=["ja"]
locale/country_filter=["JP"] locale/country_filter=["JP"]
-1
View File
@@ -1,7 +1,6 @@
class_name RootUI extends Control class_name RootUI extends Control
@export var debugMenu:DebugMenu @export var debugMenu:DebugMenu
@export var mainChatBox:ChatBox
@export var gameMenu:GameMenu @export var gameMenu:GameMenu
@export var pauseMenu:PauseMenu @export var pauseMenu:PauseMenu
@export var chatBoxContainer:Control @export var chatBoxContainer:Control
+4 -10
View File
@@ -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="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://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://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", "mainChatBox", "gameMenu", "pauseMenu", "chatBoxContainer")] [node name="RootUI" type="Control" node_paths=PackedStringArray("debugMenu", "gameMenu", "pauseMenu", "chatBoxContainer")]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
@@ -16,10 +15,9 @@ grow_vertical = 2
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_son71") script = ExtResource("1_son71")
debugMenu = NodePath("DebugMenu") debugMenu = NodePath("DebugMenu")
mainChatBox = NodePath("ChatBox")
gameMenu = NodePath("GameMenu") gameMenu = NodePath("GameMenu")
pauseMenu = NodePath("PauseMenu") pauseMenu = NodePath("PauseMenu")
chatBoxContainer = NodePath("WorldChatBoxContainer") chatBoxContainer = NodePath("ChatBoxContainer")
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")]
@@ -35,7 +33,7 @@ process_mode = 3
visible = false visible = false
layout_mode = 1 layout_mode = 1
[node name="WorldChatBoxContainer" type="Control" parent="."] [node name="ChatBoxContainer" type="Control" parent="."]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
@@ -43,7 +41,3 @@ anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
mouse_filter = 2 mouse_filter = 2
[node name="ChatBox" parent="." instance=ExtResource("1_1mtk3")]
visible = false
layout_mode = 1
+26 -34
View File
@@ -2,51 +2,43 @@ extends Node
var rootUi:RootUI = null var rootUi:RootUI = null
# True for the entire duration of a DialogueAction run, including the frames # True whenever any dialogue resource is being processed by DialogueManager.
# between lines where the textbox is momentarily closed. # Driven by DialogueManager.dialogue_started / dialogue_ended signals.
var dialogueActive:bool = false 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(): get():
if rootUi && rootUi.debugMenu: if rootUi:
return rootUi.chatBoxContainer
return null
var DEBUG_MENU:DebugMenu:
get():
if rootUi:
return rootUi.debugMenu return rootUi.debugMenu
return null return null
var MAIN_CHATBOX: var GAME_MENU:GameMenu:
get(): get():
if rootUi && rootUi.mainChatBox: if rootUi:
return rootUi.mainChatBox
return null
var GAME_MENU:
get():
if rootUi && rootUi.gameMenu:
return rootUi.gameMenu return rootUi.gameMenu
return null return null
var PAUSE_MENU: var PAUSE_MENU:PauseMenu:
get(): get():
if rootUi && rootUi.pauseMenu: if rootUi:
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
+87
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cbgsqs5t10l06
+18
View File
@@ -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
+178
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://drivjdgk70cqq
+27
View File
@@ -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
-155
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://h8lw23ypcfty
-63
View File
@@ -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
-65
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://m1keb2pw8bqq
-21
View File
@@ -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
+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.MAIN_CHATBOX.isClosed: elif !UI.dialogueActive:
open() open()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return