refactor(ui): monologue display — multi-line architecture per Tyre review (#122)
Rewrites the monologue display system per Tyre architecture review (Sprint 14): Rendering: - Up to 3 simultaneous visible lines (VBoxContainer, dynamic node creation) - Lines created programmatically as MarginContainer > RichTextLabel per slot - Percentage-based anchors: 5% left, 75–98% vertical (25% area from bottom), 50% max width - Z-layer 7 in UILayer (CanvasLayer 20, D-049) Queue: - 5-entry priority queue; highest priority drains first - On overflow: incoming line replaces lowest-priority queued entry if it outranks it - Lower/equal priority incoming lines silently dropped when queue full API: show_monologue(text, duration, priority=2, is_urgent=false) - Replaces old (text, duration, character_type) signature - main.gd passes priority and is_urgent from MonologueEvent fields - Confrontation monologue: priority=3, is_urgent=true (D-063) Colour: - Reads GameState.lattice_profile at render time (D-032) - lattice_augmented (detective): standard #d0d4e0 / urgent #e0e8f8 - lattice_baseline (smuggler): standard #d8d0c4 / urgent #f0e4d4 - Fallback for unknown profiles; no crash Stagger: 0.15s between consecutive fade-ins (spec §5.4) Opacity: standard 0.85, urgent 1.0; bloom deferred GameState: adds lattice_profile field, parsed from snapshot Tests: 27 gdUnit4 test cases — queue order, priority drop, overflow, stagger, no-overwrite (P0 #477), BBCode output, palette selection, slot lifecycle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+128
-64
@@ -1,89 +1,153 @@
|
||||
extends Control
|
||||
|
||||
# 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).
|
||||
# Internal monologue display — multi-line, priority-queued (per D-016, #122).
|
||||
# Per Tyre architecture review, Sprint 14.
|
||||
#
|
||||
# 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.
|
||||
# Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer.
|
||||
# Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is
|
||||
# dropped when the queue is full and a higher-priority line arrives.
|
||||
#
|
||||
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
|
||||
# Colour: derived from GameState.lattice_profile at render time (D-032).
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
const MAX_QUEUE_DEPTH: int = 8
|
||||
const MAX_VISIBLE: int = 3
|
||||
const MAX_QUEUE: int = 5
|
||||
|
||||
# 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)
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
|
||||
@onready var text_panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
|
||||
# Lattice colour palette — keyed by GameState.lattice_profile.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented": { # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline": { # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
|
||||
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
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text: String, duration: float, priority: int, is_urgent: bool}
|
||||
var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
text_panel.modulate.a = 0.0
|
||||
_displaying = false
|
||||
pass
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _displaying:
|
||||
return
|
||||
_fade_timer += delta
|
||||
if _fade_timer >= _current_duration:
|
||||
_fade_out()
|
||||
# Expire visible lines
|
||||
for slot in _visible.duplicate():
|
||||
slot.expire_timer -= delta
|
||||
if slot.expire_timer <= 0.0:
|
||||
_retire_slot(slot)
|
||||
|
||||
# Drain queue into available visible slots (one per stagger interval)
|
||||
if not _queue.is_empty() and _visible.size() < MAX_VISIBLE:
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if now >= _next_fade_in_msec:
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent)
|
||||
|
||||
|
||||
# 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)
|
||||
# Display a monologue line.
|
||||
# priority: higher number = more important (default 2; urgent beats normal).
|
||||
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_line(text, duration, priority, is_urgent)
|
||||
else:
|
||||
_enqueue(text, duration, priority, is_urgent)
|
||||
|
||||
|
||||
func _display(text: String, duration: float, character_type: String) -> void:
|
||||
_displaying = true
|
||||
_fade_timer = 0.0
|
||||
_current_duration = duration
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var color_hex: String = _color_for_character(character_type).to_html(false)
|
||||
text_label.text = "[i][color=#%s]%s[/color][/i]" % [color_hex, text]
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
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)
|
||||
var slot := {
|
||||
node = line_node,
|
||||
expire_timer = duration,
|
||||
priority = priority,
|
||||
tween = null as Tween,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
|
||||
line_node.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
slot.tween = tween
|
||||
var target_opacity := 1.0 if is_urgent else 0.85
|
||||
tween.tween_property(line_node, "modulate:a", target_opacity, FADE_IN_SEC)
|
||||
|
||||
|
||||
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 _retire_slot(slot: Dictionary) -> void:
|
||||
_visible.erase(slot)
|
||||
var node: Node = slot.node
|
||||
var t: Tween = slot.tween
|
||||
if t and t.is_valid():
|
||||
t.kill()
|
||||
var tween := create_tween()
|
||||
tween.tween_property(node, "modulate:a", 0.0, FADE_OUT_SEC)
|
||||
tween.tween_callback(node.queue_free)
|
||||
|
||||
|
||||
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 _enqueue(text: String, duration: float, priority: int, is_urgent: bool) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent})
|
||||
else:
|
||||
# Replace the lowest-priority queued entry if new one outranks it
|
||||
var lowest := _lowest_priority_idx()
|
||||
if priority > _queue[lowest].priority:
|
||||
_queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent}
|
||||
# else: incoming line is lower/equal priority — silently drop
|
||||
# Re-sort: highest priority at front (next to display)
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
|
||||
|
||||
func _color_for_character(character_type: String) -> Color:
|
||||
match character_type:
|
||||
"detective": return COLOR_DETECTIVE
|
||||
"smuggler": return COLOR_SMUGGLER
|
||||
_: return COLOR_DEFAULT
|
||||
func _lowest_priority_idx() -> int:
|
||||
var idx := 0
|
||||
for i in range(1, _queue.size()):
|
||||
if _queue[i].priority < _queue[idx].priority:
|
||||
idx = i
|
||||
return idx
|
||||
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool) -> Control:
|
||||
var profile: String = GameState.lattice_profile
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(profile, {})
|
||||
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.add_theme_font_size_override("normal_font_size", 13)
|
||||
label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), text]
|
||||
|
||||
container.add_child(label)
|
||||
return container
|
||||
|
||||
@@ -2,42 +2,22 @@
|
||||
|
||||
[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).
|
||||
; Monologue display area — bottom-left of viewport.
|
||||
; Anchors: 5% left margin, 50% max width, 25% height from bottom (spec §3.1, z-layer 7).
|
||||
; Lines are created dynamically inside VBoxContainer by monologue_display.gd.
|
||||
[node name="MonologueDisplay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 2
|
||||
anchor_left = 0.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 16.0
|
||||
offset_top = -200.0
|
||||
offset_right = 436.0
|
||||
offset_bottom = -80.0
|
||||
layout_mode = 1
|
||||
anchor_left = 0.05
|
||||
anchor_top = 0.75
|
||||
anchor_right = 0.55
|
||||
anchor_bottom = 0.98
|
||||
grow_horizontal = 1
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_monologue")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[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
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
text = ""
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
theme_override_font_sizes/normal_font_size = 13
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
Reference in New Issue
Block a user