diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd new file mode 100644 index 000000000..9d475968b --- /dev/null +++ b/client/ui/dialogue_box.gd @@ -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() diff --git a/client/ui/dialogue_box.tscn b/client/ui/dialogue_box.tscn new file mode 100644 index 000000000..f1a70ef64 --- /dev/null +++ b/client/ui/dialogue_box.tscn @@ -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