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