Dialogue system really good
This commit is contained in:
@@ -145,7 +145,7 @@ The `[wait=N]` and `[speed=N]` BBCode tags are extracted from the text by the pl
|
||||
|
||||
**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.
|
||||
> **Implemented:** `DialogueTextbox` has character-by-character reveal, punctuation pauses, `...` detection, hold-to-skip (requires releasing Interact first — guarded by `_hasLetGoOfInteract`), and a pulsing advance indicator (▼) shown when waiting for input. Punctuation pauses are suppressed for the last visible character on a page or at end of text so there is no delay before the player can advance. `[wait=N]` / `[speed=N]` BBCode tag support is not yet wired.
|
||||
|
||||
### Advancement modes
|
||||
|
||||
@@ -166,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.
|
||||
|
||||
> **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.
|
||||
> **Implemented:** `DialogueChoiceBox` is shown when allowed responses exist, anchored to the player entity via `OVERWORLD.getPlayerEntity()`. Selected item shows a `▶ ` prefix and yellow highlight. Navigation uses Godot's native focus system — labels have `focus_mode = FOCUS_ALL` with explicit `focus_neighbor_top/bottom` to prevent focus escaping the list at the edges; `focus_entered` signals update `_selectedIndex`. `_input` handles only the game-specific `interact` confirm; `ui_up`/`ui_down`/`ui_accept` are handled natively by the engine.
|
||||
|
||||
### Trigger types
|
||||
|
||||
@@ -322,6 +322,33 @@ Do not hardcode any visible text outside of `.dialogue` files. All player-facing
|
||||
|
||||
---
|
||||
|
||||
## Technical implementation notes
|
||||
|
||||
### DialogueTextbox layout
|
||||
|
||||
`DialogueTextbox` (`ui/component/DialogueTextbox.gd`) does its entire layout synchronously in `setup()` — there is no deferred pre-pass frame:
|
||||
|
||||
1. `size.x = MAX_WIDTH` is set on the PanelContainer.
|
||||
2. `_bodyLabelWidth()` reads the panel `StyleBox` margins to compute the BodyLabel's inner width.
|
||||
3. `_bodyLabel.size.x` is set explicitly so that `get_character_line(i)` has correct metrics when called next.
|
||||
4. `get_character_line()` internally calls `_validate_line_caches()` which forces synchronous text shaping — no render frame needed.
|
||||
5. `_buildPreWrappedText()` iterates characters, detects wrap boundaries via `get_character_line()`, and inserts explicit `\n` characters.
|
||||
6. The pre-wrapped text is set back on the label with `autowrap_mode = AUTOWRAP_OFF` so layout never changes during reveal.
|
||||
7. Page detection during reveal uses `_parsedText.left(idx+1).count("\n")` — pure string math, no Godot layout calls.
|
||||
8. `_revealNextChar()` is called at end of `setup()` and `_advance()` so the box first appears with one character already visible.
|
||||
|
||||
**Do not** re-introduce a pre-pass frame (setting `visible = true` with an off-screen position or `modulate.a = 0`) — this causes a one-frame flicker that the user can see.
|
||||
|
||||
### DialogueChoiceBox layout
|
||||
|
||||
`DialogueChoiceBox` (`ui/component/DialogueChoiceBox.gd`) computes its height synchronously in `setup()`:
|
||||
|
||||
1. For each label: `add_child(label)`, then `label.size.x = _labelWidth()`, then `label.get_minimum_size().y` (forces synchronous shaping).
|
||||
2. Sum label heights + list separator gaps + panel top/bottom margins.
|
||||
3. `size = Vector2(MAX_WIDTH, totalHeight)` is set before `visible = true`.
|
||||
|
||||
Navigation uses Godot's native focus system. Labels have `focus_mode = FOCUS_ALL`; `focus_neighbor_top/bottom` is set explicitly to keep focus within the list (edge labels point back to themselves). `focus_entered` signals update `_selectedIndex`. `_input` handles only the game-specific `interact` confirm; `ui_up`/`ui_down` are handled natively by the engine. `_confirm()` guards `if not visible` to prevent double-firing if `interact` and `ui_accept` share a physical key.
|
||||
|
||||
## Open questions
|
||||
|
||||
None currently. Update this section as new design questions arise.
|
||||
|
||||
@@ -25,44 +25,70 @@ func setup(responses:Array[DialogueResponse], entity:Entity) -> void:
|
||||
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)
|
||||
var innerWidth:float = _labelWidth()
|
||||
var sep:int = _list.get_theme_constant("separation")
|
||||
var totalHeight:float = 0.0
|
||||
|
||||
size.x = MAX_WIDTH
|
||||
modulate.a = 0.0
|
||||
for i in range(_responses.size()):
|
||||
var label:Label = Label.new()
|
||||
label.text = _responses[i].text
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.focus_mode = Control.FOCUS_ALL
|
||||
_list.add_child(label)
|
||||
label.size.x = innerWidth
|
||||
totalHeight += label.get_minimum_size().y
|
||||
if i > 0:
|
||||
totalHeight += sep
|
||||
var idx:int = i
|
||||
label.focus_entered.connect(func(): _onFocused(idx))
|
||||
|
||||
# Prevent focus escaping the list at the edges
|
||||
var count:int = _list.get_child_count()
|
||||
for i in range(count):
|
||||
var label:Label = _list.get_child(i)
|
||||
label.focus_neighbor_top = _list.get_child(max(0, i - 1)).get_path()
|
||||
label.focus_neighbor_bottom = _list.get_child(min(count - 1, i + 1)).get_path()
|
||||
|
||||
var style:StyleBox = get_theme_stylebox("panel")
|
||||
if style:
|
||||
totalHeight += style.get_margin(SIDE_TOP) + style.get_margin(SIDE_BOTTOM)
|
||||
|
||||
size = Vector2(MAX_WIDTH, totalHeight)
|
||||
_updateWorldPosition()
|
||||
visible = true
|
||||
_updateSelection()
|
||||
_list.get_child(0).grab_focus()
|
||||
|
||||
func _labelWidth() -> float:
|
||||
var style:StyleBox = get_theme_stylebox("panel")
|
||||
if style == null:
|
||||
return MAX_WIDTH
|
||||
return MAX_WIDTH - style.get_margin(SIDE_LEFT) - style.get_margin(SIDE_RIGHT)
|
||||
|
||||
func _process(_delta:float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
_updateWorldPosition()
|
||||
_processInput()
|
||||
|
||||
func _processInput() -> void:
|
||||
if Input.is_action_just_released("interact"):
|
||||
func _input(event:InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if event.is_action_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"):
|
||||
return
|
||||
if event.is_action_pressed("interact") and _hasLetGoOfInteract:
|
||||
_confirm()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _onFocused(idx:int) -> void:
|
||||
_selectedIndex = idx
|
||||
_updateSelection()
|
||||
|
||||
func _confirm() -> void:
|
||||
if _responses.is_empty():
|
||||
if not visible:
|
||||
return
|
||||
var response:DialogueResponse = _responses[_selectedIndex]
|
||||
chosen.emit(response)
|
||||
visible = false
|
||||
chosen.emit(_responses[_selectedIndex])
|
||||
queue_free()
|
||||
|
||||
func _updateSelection() -> void:
|
||||
@@ -70,8 +96,10 @@ func _updateSelection() -> void:
|
||||
for i in children.size():
|
||||
var label:Label = children[i]
|
||||
if i == _selectedIndex:
|
||||
label.text = "▶ " + _responses[i].text
|
||||
label.add_theme_color_override("font_color", Color.YELLOW)
|
||||
else:
|
||||
label.text = _responses[i].text
|
||||
label.remove_theme_color_override("font_color")
|
||||
|
||||
func _updateWorldPosition() -> void:
|
||||
@@ -80,18 +108,9 @@ func _updateWorldPosition() -> void:
|
||||
var camera:Camera3D = get_viewport().get_camera_3d()
|
||||
if camera == null:
|
||||
return
|
||||
if size.y == 0:
|
||||
return
|
||||
var worldPos:Vector3 = _entity.global_position + Vector3(0, 2.5, 0)
|
||||
if camera.is_position_behind(worldPos):
|
||||
modulate.a = 0.0
|
||||
return
|
||||
modulate.a = 1.0
|
||||
var screenPos:Vector2 = camera.unproject_position(worldPos)
|
||||
var viewportSize:Vector2 = get_viewport().get_visible_rect().size
|
||||
position.x = clamp(screenPos.x - size.x * 0.5, 0.0, viewportSize.x - size.x)
|
||||
var yAbove:float = screenPos.y - size.y
|
||||
if yAbove >= 0.0:
|
||||
position.y = yAbove
|
||||
else:
|
||||
position.y = clamp(screenPos.y + 10.0, 0.0, viewportSize.y - size.y)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user