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:
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user