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:
2026-02-20 18:39:49 +01:00
co-authored by Claude Sonnet 4.6
parent 4365871846
commit 1765e2922f
5 changed files with 406 additions and 277 deletions
+10 -1
View File
@@ -23,7 +23,12 @@ var player_entity_id: int = 1
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds} or null
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
# Server sends this field as part of the player's capability snapshot.
var lattice_profile: String = "lattice_baseline"
# v6 fields (#449, D-053, D-065)
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
@@ -138,6 +143,10 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
current_monologue = null
# #122: lattice_profile — character insert capability level for monologue colour
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
lattice_profile = snapshot.lattice_profile
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
player_stance = snapshot.player_stance
+4 -17
View File
@@ -291,7 +291,8 @@ func _consume_monologue() -> void:
monologue_display.show_monologue(
mono.get("text", ""),
mono.get("duration_seconds", 5.0),
_get_active_character_type()
mono.get("priority", 2),
mono.get("is_urgent", false)
)
# #502: Amber flash on room reset
var mono_id: String = mono.get("id", "")
@@ -300,21 +301,6 @@ func _consume_monologue() -> void:
GameState.current_monologue = null
# Derive the active character type from the player entity kind data.
# Server populates kind.data.character_type ("detective" or "smuggler") per D-032.
# Returns "" when the field is absent (display falls back to neutral colour).
func _get_active_character_type() -> String:
for entity in GameState.visible_entities:
if not entity is Dictionary:
continue
var kind = entity.get("kind", {})
if not kind is Dictionary or kind.get("variant") != "Player":
continue
var data = kind.get("data", {})
if data is Dictionary:
return data.get("character_type", "")
return ""
# Consume-once per tick with ID tracking: show dialogue, then clear.
# Tick guard + is_dialogue_active check prevent re-triggering.
@@ -351,9 +337,10 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void:
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
func _on_confrontation_monologue(text: String, duration: float) -> void:
if monologue_display:
monologue_display.show_monologue(text, duration, _get_active_character_type())
monologue_display.show_monologue(text, duration, 3, true)
# D-064: Handle walk-away → send WalkAway{npc_id} to server
+254 -165
View File
@@ -1,10 +1,10 @@
## #122: Monologue display — client tests (Sprint 14)
## Tests queue management, character colour, italic BBCode, and no-overwrite contract.
## Spec: Sprint 14 briefing (D-016, D-032, D-055, P0 #477).
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
##
## Approach: instantiate the scene, drive show_monologue() directly, inspect internal
## state and label text. Fade timing is tested by manipulating _fade_timer and calling
## _process() rather than awaiting real time — keeps the suite fast and deterministic.
## API per Tyre architecture review:
## show_monologue(text, duration, priority=2, is_urgent=false)
## GameState.lattice_profile selects colour palette
class_name TestMonologueDisplay
extends GdUnitTestSuite
@@ -17,73 +17,81 @@ func _make_display() -> Node:
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
push_warning("TestMonologueDisplay: scene not found — tests skipped")
return null
var scene = load("res://ui/monologue_display.tscn")
var node = scene.instantiate()
var node = load("res://ui/monologue_display.tscn").instantiate()
add_child(node)
# _ready() fires here; panel.modulate.a = 0, _displaying = false
return node
func _label_text(d: Node) -> String:
var slot_node: Node = d._visible[0].node
return (slot_node.get_child(0) as RichTextLabel).text
# ---------------------------------------------------------------------------
# Colour mapping
# Initial state
# ---------------------------------------------------------------------------
func test_detective_colour_is_cool_blue() -> void:
func test_nothing_visible_on_init() -> void:
var d = _make_display()
if d == null:
return
var c: Color = d._color_for_character("detective")
# Must not be the default/neutral colour
assert_that(c).is_not_equal(d.COLOR_DEFAULT)
# Blue channel dominant
assert_float(c.b).is_greater(c.r)
if d == null: return
assert_int(d._visible.size()).is_equal(0)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_smuggler_colour_is_warm_amber() -> void:
func test_stagger_timer_zero_on_init() -> void:
var d = _make_display()
if d == null:
return
var c: Color = d._color_for_character("smuggler")
assert_that(c).is_not_equal(d.COLOR_DEFAULT)
# Red channel dominant (amber)
assert_float(c.r).is_greater(c.b)
d.queue_free()
func test_unknown_character_returns_default_colour() -> void:
var d = _make_display()
if d == null:
return
assert_that(d._color_for_character("")).is_equal(d.COLOR_DEFAULT)
assert_that(d._color_for_character("merchant")).is_equal(d.COLOR_DEFAULT)
if d == null: return
assert_float(d._next_fade_in_msec).is_equal(0.0)
d.queue_free()
# ---------------------------------------------------------------------------
# BBCode output — italic + colour tags
# Single-line display
# ---------------------------------------------------------------------------
func test_show_monologue_wraps_text_in_italic_bbcode() -> void:
func test_single_line_goes_to_visible() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("Test line.", 5.0, "")
var txt: String = d.text_label.text
assert_that(txt).contains("[i]")
assert_that(txt).contains("[/i]")
assert_that(txt).contains("Test line.")
if d == null: return
d.show_monologue("One.", 5.0)
assert_int(d._visible.size()).is_equal(1)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_show_monologue_includes_color_tag() -> void:
func test_show_monologue_sets_stagger_timer() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("Colour test.", 5.0, "detective")
var txt: String = d.text_label.text
assert_that(txt).contains("[color=#")
assert_that(txt).contains("[/color]")
if d == null: return
var before := float(Time.get_ticks_msec())
d.show_monologue("Stagger.", 5.0)
assert_float(d._next_fade_in_msec).is_greater(before)
d.queue_free()
# ---------------------------------------------------------------------------
# MAX_VISIBLE = 3 simultaneous lines
# ---------------------------------------------------------------------------
func test_three_lines_all_visible() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
assert_int(d._visible.size()).is_equal(3)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_fourth_line_queues_when_slots_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("D", 5.0)
assert_int(d._visible.size()).is_equal(3)
assert_int(d._queue.size()).is_equal(1)
d.queue_free()
@@ -91,163 +99,244 @@ func test_show_monologue_includes_color_tag() -> void:
# No-overwrite contract (P0, #477)
# ---------------------------------------------------------------------------
func test_second_call_does_not_overwrite_active_display() -> void:
func test_new_line_does_not_replace_first_visible_line() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("First line.", 10.0, "")
assert_that(d._displaying).is_true()
var first_text: String = d.text_label.text
d.show_monologue("Second line.", 5.0, "")
# Text label must still show the first line
assert_that(d.text_label.text).is_equal(first_text)
if d == null: return
d.show_monologue("First line.", 10.0)
var first_text := _label_text(d)
# Force stagger active; second call must queue, not display
d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0
d.show_monologue("Second line.", 5.0)
assert_that(_label_text(d)).is_equal(first_text)
d.queue_free()
func test_second_call_while_active_is_queued() -> void:
func test_two_visible_lines_coexist_without_overwriting() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("First.", 10.0, "")
d.show_monologue("Second.", 5.0, "")
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0)
assert_int(d._visible.size()).is_equal(2)
d.queue_free()
# ---------------------------------------------------------------------------
# Priority queue
# ---------------------------------------------------------------------------
func test_queue_sorted_highest_priority_first() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("low", 5.0, 1)
d.show_monologue("high", 5.0, 4)
d.show_monologue("normal", 5.0, 2)
assert_int(d._queue[0].priority).is_equal(4)
d.queue_free()
func test_queue_drop_replaces_lowest_when_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE):
d.show_monologue("low_%d" % i, 5.0, 1)
d.show_monologue("critical!", 5.0, 9)
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
var has_critical := false
for e in d._queue:
if e.priority == 9:
has_critical = true
assert_that(has_critical).is_true()
d.queue_free()
func test_queue_ignores_lower_priority_when_full() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE):
d.show_monologue("hi_%d" % i, 5.0, 5)
d.show_monologue("noise", 1.0, 1)
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
for e in d._queue:
assert_int(e.priority).is_equal(5)
d.queue_free()
func test_queue_never_exceeds_max_depth() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
for i in range(d.MAX_QUEUE + 20):
d.show_monologue("flood_%d" % i, 1.0, 2)
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
d.queue_free()
# ---------------------------------------------------------------------------
# Expire and drain
# ---------------------------------------------------------------------------
func test_expired_slot_removed_from_visible() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Expires.", 1.0)
d._visible[0].expire_timer = -0.1
d._process(0.0)
assert_int(d._visible.size()).is_equal(0)
d.queue_free()
func test_queue_drains_when_slot_opens() -> void:
var d = _make_display()
if d == null: return
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
d.show_monologue("Queued.", 5.0)
d._visible[0].expire_timer = -0.1
d._next_fade_in_msec = 0.0 # stagger elapsed
d._process(0.0)
assert_int(d._queue.size()).is_equal(0)
assert_int(d._visible.size()).is_equal(3)
d.queue_free()
# ---------------------------------------------------------------------------
# Stagger
# ---------------------------------------------------------------------------
func test_second_call_within_stagger_period_queues() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("First.", 5.0)
# _next_fade_in_msec is now ~150ms in the future
d.show_monologue("Second.", 5.0)
assert_int(d._visible.size()).is_equal(1)
assert_int(d._queue.size()).is_equal(1)
assert_that(d._queue[0].text).is_equal("Second.")
d.queue_free()
# ---------------------------------------------------------------------------
# Queue management
# ---------------------------------------------------------------------------
func test_queue_drains_after_fade_complete() -> void:
func test_stagger_elapsed_allows_second_visible() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("Line A.", 1.0, "")
d.show_monologue("Line B.", 2.0, "")
# Simulate fade completing on line A
d._displaying = false
d._on_fade_complete()
assert_that(d._displaying).is_true()
assert_that(d.text_label.text).contains("Line B.")
if d == null: return
d.show_monologue("First.", 5.0)
d._next_fade_in_msec = 0.0 # manually expire stagger
d.show_monologue("Second.", 5.0)
assert_int(d._visible.size()).is_equal(2)
assert_int(d._queue.size()).is_equal(0)
d.queue_free()
func test_queue_empty_after_fade_complete_does_nothing() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("Only line.", 1.0, "")
d._displaying = false
# ---------------------------------------------------------------------------
# BBCode output
# ---------------------------------------------------------------------------
# No items queued — should not crash and _displaying stays false
d._on_fade_complete()
assert_that(d._displaying).is_false()
func test_text_wrapped_in_italic_bbcode() -> void:
var d = _make_display()
if d == null: return
d.show_monologue("Italic line.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[i]")
assert_that(txt).contains("[/i]")
assert_that(txt).contains("Italic line.")
d.queue_free()
func test_queue_preserves_character_type() -> void:
func test_text_has_color_bbcode() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("First.", 5.0, "detective")
d.show_monologue("Second.", 3.0, "smuggler")
assert_that(d._queue[0].character_type).is_equal("smuggler")
d.queue_free()
func test_multiple_queued_items_drain_in_order() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("A", 1.0, "")
d.show_monologue("B", 1.0, "")
d.show_monologue("C", 1.0, "")
d._displaying = false
d._on_fade_complete() # should display B
assert_that(d.text_label.text).contains("B")
d._displaying = false
d._on_fade_complete() # should display C
assert_that(d.text_label.text).contains("C")
d._displaying = false
d._on_fade_complete() # queue empty — nothing new
assert_that(d._displaying).is_false()
if d == null: return
d.show_monologue("Coloured.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#")
assert_that(txt).contains("[/color]")
d.queue_free()
# ---------------------------------------------------------------------------
# Queue depth cap
# Lattice colour palette
# ---------------------------------------------------------------------------
func test_queue_does_not_exceed_max_depth() -> void:
func test_augmented_colour_differs_from_baseline() -> void:
var d = _make_display()
if d == null:
return
# First call starts displaying immediately (not queued)
d.show_monologue("Active.", 99.0, "")
if d == null: return
# Fill queue to max
for i in range(d.MAX_QUEUE_DEPTH + 5):
d.show_monologue("Overflow %d" % i, 1.0, "")
GameState.lattice_profile = "lattice_augmented"
d.show_monologue("Detective.", 5.0)
var aug_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE_DEPTH)
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Smuggler.", 5.0)
var base_txt := _label_text(d)
assert_that(aug_txt).is_not_equal(base_txt)
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
func test_urgent_colour_differs_from_standard() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_baseline"
d.show_monologue("Normal.", 5.0, 2, false)
var std_txt := _label_text(d)
d._visible[0].expire_timer = -0.1; d._process(0.0)
d._next_fade_in_msec = 0.0
d.show_monologue("Urgent!", 5.0, 3, true)
var urg_txt := _label_text(d)
assert_that(std_txt).is_not_equal(urg_txt)
d.queue_free()
func test_unknown_profile_falls_back_without_crash() -> void:
var d = _make_display()
if d == null: return
GameState.lattice_profile = "lattice_hypothetical_tier_x"
d.show_monologue("Future proof.", 5.0)
var txt := _label_text(d)
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
GameState.lattice_profile = "lattice_baseline"
d.queue_free()
# ---------------------------------------------------------------------------
# Fade timer integration
# Slot lifecycle
# ---------------------------------------------------------------------------
func test_process_triggers_fade_after_duration() -> void:
func test_visible_slot_has_tween() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("Timer test.", 2.0, "")
assert_that(d._displaying).is_true()
# Drive timer past duration without awaiting real time
d._fade_timer = 2.1
d._process(0.0)
# _displaying should now be false (fade_out called)
assert_that(d._displaying).is_false()
if d == null: return
d.show_monologue("Has tween.", 5.0)
assert_that(d._visible[0].tween).is_not_null()
d.queue_free()
func test_process_does_not_fade_before_duration() -> void:
func test_visible_slot_stores_priority() -> void:
var d = _make_display()
if d == null:
return
d.show_monologue("Still showing.", 5.0, "")
d._fade_timer = 2.0
d._process(0.0)
assert_that(d._displaying).is_true()
if d == null: return
d.show_monologue("Priority 7.", 5.0, 7)
assert_int(d._visible[0].priority).is_equal(7)
d.queue_free()
# ---------------------------------------------------------------------------
# Initial state
# ---------------------------------------------------------------------------
func test_panel_starts_invisible() -> void:
func test_expire_timer_decrements_in_process() -> void:
var d = _make_display()
if d == null:
return
assert_float(d.text_panel.modulate.a).is_equal(0.0)
d.queue_free()
func test_not_displaying_on_init() -> void:
var d = _make_display()
if d == null:
return
assert_that(d._displaying).is_false()
if d == null: return
d.show_monologue("Timer.", 10.0)
d._process(1.5)
assert_float(d._visible[0].expire_timer).is_less(10.0)
d.queue_free()
+128 -64
View File
@@ -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
+10 -30
View File
@@ -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