feat(ui): monologue display — queue, character colours, italic BBCode (#122)

- Queue management: enqueue on mid-display arrival, drain in order, MAX_QUEUE_DEPTH=8 cap, no-overwrite contract (P0 #477)
- Character colours: detective=#c8e0ff (cool blue), smuggler=#f0c870 (warm amber), fallback to neutral insert text
- Typography: italic via BBCode [i] tags; font size 13px (smaller than dialogue)
- Positioning: bottom-left of viewport, 420×120px, 80px bottom margin above verb list
- main.gd: _get_active_character_type() reads player entity kind.data.character_type; passed to show_monologue() and _on_confrontation_monologue()
- gdUnit4 tests: queue order, depth cap, no-overwrite, BBCode output, colour mapping, fade timer integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:31:13 +01:00
co-authored by Claude Sonnet 4.6
parent 4e53a2e979
commit 4365871846
4 changed files with 360 additions and 41 deletions
+66 -25
View File
@@ -1,48 +1,89 @@
extends Control
# Internal monologue display (per D-015)
# Shows character's internal thoughts as text overlay
# Internal monologue display (per D-016)
# Queue-managed text overlay — bottom-left of viewport, italic, character-coloured.
# Spec: Sprint 14 briefing (D-032, D-055).
#
# Queue contract (P0, #477):
# - Never overwrites a mid-display line.
# - New arrivals queue up to MAX_QUEUE_DEPTH; deeper arrivals are silently dropped.
# - When the current line fades out, the next queued line starts immediately.
const MAX_QUEUE_DEPTH: int = 8
# Character text colours (D-048 insert palette, Sprint 14 briefing)
const COLOR_DETECTIVE: Color = Color("#c8e0ff") # Cool blue-white — analytical
const COLOR_SMUGGLER: Color = Color("#f0c870") # Warm amber — street-smart
const COLOR_DEFAULT: Color = Color("#c8d0e0") # Neutral fallback (insert text)
@onready var text_panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
var fade_timer: float = 0.0
var fade_duration: float = 5.0 # Display duration before fade
var is_visible: bool = false
var _queue: Array[Dictionary] = [] # {text, duration, character_type}
var _displaying: bool = false
var _fade_timer: float = 0.0
var _current_duration: float = 0.0
var _active_tween: Tween = null
func _ready() -> void:
print("MonologueDisplay: Initialized")
text_panel.modulate.a = 0.0
is_visible = false
_displaying = false
func _process(delta: float) -> void:
# Auto-fade after display
if is_visible:
fade_timer += delta
if fade_timer >= fade_duration:
_fade_out()
if not _displaying:
return
_fade_timer += delta
if _fade_timer >= _current_duration:
_fade_out()
# Show internal monologue text
func show_monologue(text: String, duration: float = 5.0) -> void:
text_label.text = text
fade_duration = duration
fade_timer = 0.0
is_visible = true
# Cancel any active tween before starting a new one
# Show a monologue line. If a line is already displaying, enqueue it instead.
# character_type: "detective", "smuggler", or "" (default colour).
func show_monologue(text: String, duration: float = 5.0, character_type: String = "") -> void:
if _displaying:
if _queue.size() < MAX_QUEUE_DEPTH:
_queue.append({text = text, duration = duration, character_type = character_type})
return
_display(text, duration, character_type)
func _display(text: String, duration: float, character_type: String) -> void:
_displaying = true
_fade_timer = 0.0
_current_duration = duration
var color_hex: String = _color_for_character(character_type).to_html(false)
text_label.text = "[i][color=#%s]%s[/color][/i]" % [color_hex, text]
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3)
# Fade out the monologue
func _fade_out() -> void:
if not is_visible:
return
is_visible = false
func _fade_out() -> void:
if not _displaying:
return
_displaying = false
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5)
_active_tween.tween_callback(_on_fade_complete)
func _on_fade_complete() -> void:
if _queue.is_empty():
return
var next: Dictionary = _queue.pop_front()
_display(next.text, next.duration, next.character_type)
func _color_for_character(character_type: String) -> Color:
match character_type:
"detective": return COLOR_DETECTIVE
"smuggler": return COLOR_SMUGGLER
_: return COLOR_DEFAULT
+19 -14
View File
@@ -2,37 +2,42 @@
[ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"]
; Bottom-left of viewport, 420px wide, up to 120px tall.
; 80px bottom margin reserves space above the interaction verb list (InsertOverlay).
; Positioned in UILayer (CanvasLayer 20, D-049).
[node name="MonologueDisplay" type="Control"]
layout_mode = 3
anchors_preset = 12
anchors_preset = 2
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_right = 0.0
anchor_bottom = 1.0
offset_top = -150.0
grow_horizontal = 2
offset_left = 16.0
offset_top = -200.0
offset_right = 436.0
offset_bottom = -80.0
grow_horizontal = 1
grow_vertical = 0
mouse_filter = 2
script = ExtResource("1_monologue")
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 10
anchors_preset = 15
anchor_right = 1.0
offset_left = 100.0
offset_right = -100.0
offset_bottom = 120.0
grow_horizontal = 2
anchor_bottom = 1.0
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
layout_mode = 2
bbcode_enabled = true
text = "Internal monologue will appear here..."
text = ""
fit_content = true
scroll_active = false
theme_override_font_sizes/normal_font_size = 13