feat(ui): dialogue UI hardening and examine result overlay (#174)

Confrontation options use RichTextLabel with italic tags for
first-person voice (D-063). Examine result overlay auto-dismisses
after 5s with confidence-based color tinting. Dismisses when
dialogue opens. D-062 invisible locked options confirmed correct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 02:29:55 +01:00
co-authored by Claude Opus 4.6
parent eeb82535f6
commit 9b80ff69c3
4 changed files with 228 additions and 18 deletions
+46
View File
@@ -15,6 +15,9 @@ extends Node2D
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@@ -99,6 +102,8 @@ func _process(delta: float) -> void:
interaction_list.set_insert_active(insert_state)
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
interaction_prompt.set_insert_active(insert_state)
if minimap and minimap.has_method("set_insert_active"):
minimap.set_insert_active(insert_state)
# D-057: Update interaction list from game state
# Suppress during dialogue — player is in conversation, verb list is noise
@@ -136,6 +141,10 @@ func _process(delta: float) -> void:
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #264: Update journal panel — auto-close on dialogue, refresh if open
if journal_panel and journal_panel.has_method("update_from_state"):
journal_panel.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
@@ -150,6 +159,9 @@ func _process(delta: float) -> void:
# Only activates when no dialogue/confrontation dip is active (D-070).
_update_listening_focus()
# #174: Show examine result if server sent one this tick (#242)
_consume_examine_result()
# Show monologue if server sent one this tick (#414)
_consume_monologue()
@@ -184,6 +196,10 @@ func _process(delta: float) -> void:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #264: J — client-only, toggle knowledge journal panel
if input.action == InputMapper.Action.OPEN_JOURNAL:
_toggle_journal()
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
@@ -318,6 +334,9 @@ func _consume_dialogue() -> void:
GameState.current_dialogue = null
return
_last_dialogue_tick = GameState.current_tick
# #264: Close journal when dialogue opens (cannot be open simultaneously)
if journal_panel and journal_panel.has_method("close"):
journal_panel.close()
var dlg: Dictionary = GameState.current_dialogue
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
_last_dialogue_npc_name = dlg.get("npc_name", "")
@@ -456,6 +475,33 @@ func _teleport_transition() -> void:
tween.tween_callback(_flash_rect.queue_free)
# #174: Consume examine result — show overlay when server sends character-filtered observation.
# Clears after display (single-consume). Dismiss examine when dialogue opens.
func _consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
return
var result: Dictionary = GameState.current_examine_result
# Dismiss existing examine result if dialogue is active (focus priority)
if dialogue_box and dialogue_box.is_dialogue_active():
if examine_display.has_method("dismiss"):
examine_display.dismiss()
else:
if examine_display.has_method("show_result"):
examine_display.show_result(result)
GameState.current_examine_result = null
# #264: Toggle journal panel. Called from input handler when J key pressed.
func _toggle_journal() -> void:
if not journal_panel:
return
# Journal and dialogue cannot be open simultaneously (sprint briefing)
if dialogue_box and dialogue_box.is_dialogue_active():
return
if journal_panel.has_method("toggle"):
journal_panel.toggle()
# #502: Full-screen color flash — fades from color to transparent over duration.
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
func _screen_flash(color: Color, duration: float) -> void:
+44 -18
View File
@@ -354,26 +354,45 @@ func _show_options(options: Array) -> void:
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
var label := Label.new()
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
var numbered_text := "%d. %s" % [i + 1, raw_text]
label.text = numbered_text
# D-063: Confrontation options render italic — first-person voice, weighted differently.
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
var ctrl: Control
if is_confrontation:
var rtl := RichTextLabel.new()
rtl.bbcode_enabled = true
rtl.fit_content = true
rtl.scroll_active = false
rtl.add_theme_font_size_override("normal_font_size", 14)
rtl.add_theme_color_override("default_color", Color(
Constants.INSERT_COLOR_TEXT.r * 1.08,
Constants.INSERT_COLOR_TEXT.g * 0.96,
Constants.INSERT_COLOR_TEXT.b * 0.90,
1.0
)) # Slight warm tint for confrontation weight
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
ctrl = rtl
else:
var label := Label.new()
label.add_theme_font_size_override("font_size", 14)
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
label.text = "%d. %s" % [i + 1, raw_text]
ctrl = label
var idx := i
label.gui_input.connect(func(event: InputEvent):
ctrl.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_option_pressed(idx)
)
label.mouse_entered.connect(_make_hover_on(label))
label.mouse_exited.connect(_make_hover_off(label))
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
options_container.add_child(label)
_option_controls.append(label)
options_container.add_child(ctrl)
_option_controls.append(ctrl)
_option_response_ids.append(opt.get("response_id", ""))
_option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation)
@@ -623,11 +642,18 @@ func _clear_options() -> void:
# Hover callbacks
static func _make_hover_on(label: Control) -> Callable:
static func _make_hover_on(ctrl: Control) -> Callable:
return func():
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
# RichTextLabel uses "default_color"; Label uses "font_color"
if ctrl is RichTextLabel:
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
else:
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
static func _make_hover_off(label: Control) -> Callable:
static func _make_hover_off(ctrl: Control) -> Callable:
return func():
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
if ctrl is RichTextLabel:
ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
else:
ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
+88
View File
@@ -0,0 +1,88 @@
extends Control
## Examine result display — #174, D-061 adjacent.
##
## Shows the character-filtered text returned by the Examine verb (#242).
## Non-interactive overlay. Auto-dismisses after DISMISS_DELAY seconds.
## Diegetic: reads as the neural insert processing what the character observed.
##
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
## Only one examine result is shown at a time — new result replaces old.
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
const FADE_IN: float = 0.18
const FADE_OUT: float = 0.35
# Confidence → alpha modifier: Direct is brightest, Suspects is dimmest
const CONFIDENCE_ALPHA: Dictionary = {
"Direct": 1.0,
"KnowsDetails": 0.9,
"KnowsOf": 0.75,
"Suspects": 0.6,
}
@onready var panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
var _dismiss_tween: Tween = null
var _active: bool = false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
modulate.a = 0.0
visible = false
## Show an examine result. Called from main.gd when GameState.current_examine_result is set.
## result: {entity_id, text, confidence}
func show_result(result: Dictionary) -> void:
var text: String = result.get("text", "")
var confidence: String = result.get("confidence", "KnowsOf")
if text.is_empty():
return
# Cancel any in-progress dismiss
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
# Apply confidence-based alpha to the insert color
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
Constants.INSERT_COLOR_TEXT.b, alpha)
text_label.add_theme_color_override("default_color", col)
text_label.text = text
visible = true
_active = true
modulate.a = 0.0
# Fade in, then auto-dismiss
_dismiss_tween = create_tween()
_dismiss_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
_dismiss_tween.tween_interval(DISMISS_DELAY)
_dismiss_tween.tween_callback(_start_fade_out)
func _start_fade_out() -> void:
if not _active:
return
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func():
visible = false
_active = false
)
## Dismiss immediately (e.g. when dialogue opens).
func dismiss() -> void:
if not _active:
return
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
_start_fade_out()
func is_active() -> bool:
return _active
+50
View File
@@ -0,0 +1,50 @@
[gd_scene load_steps=2 format=3 uid="uid://examine_display_sr"]
[ext_resource type="Script" path="res://ui/examine_display.gd" id="1_examine"]
; ExamineDisplay — non-interactive observe result overlay. D-013, #174.
; Auto-dismisses after 5s. Positioned center-right, 40% from top.
; InsertOverlay (CanvasLayer 10). Diegetic: insert processing observed data.
[node name="ExamineDisplay" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 0.0
offset_left = -440.0
offset_top = 120.0
offset_right = -16.0
offset_bottom = 240.0
grow_horizontal = 0
grow_vertical = 2
mouse_filter = 2
modulate = Color(1, 1, 1, 0)
script = ExtResource("1_examine")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
mouse_filter = 2
[node name="TextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
layout_mode = 2
bbcode_enabled = true
fit_content = true
scroll_active = false
mouse_filter = 2
theme_override_font_sizes/normal_font_size = 13
theme_override_colors/default_color = Color(0.784, 0.816, 0.878, 0.75)