Files
settled-reach/client/ui/monologue_display.gd
T
2026-04-05 11:09:10 +02:00

261 lines
8.4 KiB
GDScript

extends Control
# Internal monologue display — multi-line, priority-queued (per D-016, #122).
# Per Tyre architecture review, Sprint 14.
#
# 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 an equal-or-higher-priority line arrives
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
#
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
const MAX_VISIBLE: int = 3
const MAX_QUEUE: int = 5
const STAGGER_SEC: float = 0.15
const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# 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")
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
const _NOTIFICATION_DURATION: float = 2.5
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
var _visible: Array[Dictionary] = []
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
@onready var _vbox: VBoxContainer = $VBoxContainer
func _ready() -> void:
pass
func _process(delta: float) -> void:
# 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()
if next.get("is_notification", false):
_show_notification_line(next.text)
else:
_show_line(
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
)
# Display a monologue line.
# priority: higher number = more important (default 2; urgent beats normal).
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# decoupled from the autoload (D-020 renderer contract).
# #554: Show a brief system notification (save/load result, connection status).
# Uses neutral color, short duration, bypasses lattice_profile styling.
func show_notification(text: String) -> void:
if text.is_empty():
return
var now := float(Time.get_ticks_msec())
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_notification_line(text)
else:
var entry := {
text = text,
duration = _NOTIFICATION_DURATION,
priority = 1,
is_urgent = false,
lattice_profile = "",
is_notification = true
}
if _queue.size() < MAX_QUEUE:
_queue.append(entry)
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
)
else:
var lowest := _lowest_priority_idx()
if 1 >= _queue[lowest].priority:
_queue[lowest] = entry
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
)
func show_monologue(
text: String, duration: float, priority: int = 2, is_urgent: bool = false
) -> void:
if text.is_empty():
return
var profile := GameState.lattice_profile
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, profile)
else:
_enqueue(text, duration, priority, is_urgent, profile)
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
func _show_notification_line(text: String) -> void:
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)
var safe_text := text.replace("[", "[lb]")
label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text]
container.add_child(label)
_vbox.add_child(container)
var slot := {
node = container,
expire_timer = _NOTIFICATION_DURATION,
priority = 1,
tween = null,
}
_visible.append(slot)
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
container.modulate.a = 0.0
var tween := create_tween()
slot.tween = tween
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC)
func _show_line(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
) -> void:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
_vbox.add_child(line_node)
var slot := {
node = line_node,
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
priority = priority,
tween = null,
}
_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 _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 _enqueue(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
) -> void:
if _queue.size() < MAX_QUEUE:
_queue.append(
{
text = text,
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
)
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
)
else:
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
var lowest := _lowest_priority_idx()
if priority >= _queue[lowest].priority:
_queue[lowest] = {
text = text,
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
)
# else: incoming line is strictly lower priority — silently drop; no sort needed
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, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_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)
# Escape [ to prevent BBCode injection from server-sourced text.
# [lb] is Godot's BBCode entity for a literal left bracket.
var safe_text := text.replace("[", "[lb]")
label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), safe_text]
container.add_child(label)
return container