From 7fc1a4645c75a43d5d90eb7f226fa62225bbd7d2 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Fri, 12 Jun 2026 14:15:31 -0500 Subject: [PATCH] Dialogue system really good --- .claude/docs/dialogue.md | 31 ++++++++++- ui/component/DialogueChoiceBox.gd | 89 +++++++++++++++++++------------ 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/.claude/docs/dialogue.md b/.claude/docs/dialogue.md index 4c40171..a0692c6 100644 --- a/.claude/docs/dialogue.md +++ b/.claude/docs/dialogue.md @@ -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. diff --git a/ui/component/DialogueChoiceBox.gd b/ui/component/DialogueChoiceBox.gd index 2fab57a..a3c3f51 100644 --- a/ui/component/DialogueChoiceBox.gd +++ b/ui/component/DialogueChoiceBox.gd @@ -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)