feat(ui): add dialogue box — bottom screen, max 20% height, walk-away (#434)

D-061 compliant UI skeleton: NPC speech top, max 3 response options
below, left-aligned. Insert-styled colors (white-blue, amber hover,
green pressed). WASD walk-away fades over 300ms, no close button.
InsertOverlay (CanvasLayer 10, z-layer 6). Mock data in next commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:19:15 +01:00
co-authored by Claude Opus 4.6
parent 88fc344ed9
commit 2d56131221
2 changed files with 177 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
extends Control
# Dialogue box — D-061: bottom screen, max 20% height, no portraits.
# InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled.
# NPC speech top, player response options below, left-aligned.
# Max 3 visible options. No close button — walk-away (WASD) or option select only.
# Sprint 7: UI skeleton with mock data. Server wiring deferred to #305.
signal option_selected(index: int, text: String)
signal dialogue_dismissed # Walk-away or conversation end
@onready var panel: PanelContainer = $PanelContainer
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
var _is_showing: bool = false
var _active_tween: Tween = null
var _option_buttons: Array[Button] = []
var _npc_name: String = ""
const FADE_IN: float = 0.2
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
func _ready() -> void:
panel.modulate.a = 0.0
visible = false
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _unhandled_input(event: InputEvent) -> void:
if not _is_showing:
return
# D-064: WASD during dialogue → walk-away, 300ms fade
if event is InputEventKey and event.pressed:
if (event.is_action_pressed("move_north")
or event.is_action_pressed("move_south")
or event.is_action_pressed("move_east")
or event.is_action_pressed("move_west")
or event.is_action_pressed("move_northeast")
or event.is_action_pressed("move_southeast")
or event.is_action_pressed("move_southwest")
or event.is_action_pressed("move_northwest")):
hide_dialogue()
dialogue_dismissed.emit()
# Show dialogue with NPC speech and response options.
# npc_name: who is speaking (displayed as prefix)
# speech: the NPC's dialogue text
# options: Array of Strings — player response choices (max 3 shown)
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
_npc_name = npc_name
# NPC speech — name prefix in bold
if npc_name.is_empty():
npc_speech.text = speech
else:
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
# Clear old option buttons
_clear_options()
# Build response option buttons (max 3)
var count := mini(options.size(), MAX_OPTIONS)
for i in range(count):
var btn := Button.new()
btn.text = options[i]
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.flat = true
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
# Insert-styled: geometric, minimal
btn.add_theme_color_override("font_color", Color("#c8d0e0"))
btn.add_theme_color_override("font_hover_color", Color("#e8c547"))
btn.add_theme_color_override("font_pressed_color", Color("#6bc9a6"))
var idx := i
btn.pressed.connect(func(): _on_option_pressed(idx))
options_container.add_child(btn)
_option_buttons.append(btn)
# Show with fade
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
_is_showing = true
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
# Hide dialogue with fade (D-064: 300ms)
func hide_dialogue() -> void:
if not _is_showing:
return
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
_clear_options()
)
func is_dialogue_active() -> bool:
return _is_showing
func _on_option_pressed(index: int) -> void:
if index < _option_buttons.size():
option_selected.emit(index, _option_buttons[index].text)
hide_dialogue()
func _clear_options() -> void:
for btn in _option_buttons:
if is_instance_valid(btn):
btn.queue_free()
_option_buttons.clear()
+50
View File
@@ -0,0 +1,50 @@
[gd_scene load_steps=2 format=3 uid="uid://d4k7g2nxp1h8j"]
[ext_resource type="Script" path="res://ui/dialogue_box.gd" id="1_dialogue"]
[node name="DialogueBox" type="Control"]
layout_mode = 3
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -200.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
script = ExtResource("1_dialogue")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -416.0
offset_top = -180.0
offset_right = 416.0
grow_horizontal = 2
grow_vertical = 0
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 20
theme_override_constants/margin_bottom = 14
[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
bbcode_enabled = true
text = ""
fit_content = true
scroll_active = false
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 4