From 4365871846a15254c41f84d8cf4e924a957e3267 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:31:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(ui):=20monologue=20display=20=E2=80=94=20q?= =?UTF-8?q?ueue,=20character=20colours,=20italic=20BBCode=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- client/scripts/main.gd | 24 ++- client/tests/test_monologue_display.gd | 253 +++++++++++++++++++++++++ client/ui/monologue_display.gd | 91 ++++++--- client/ui/monologue_display.tscn | 33 ++-- 4 files changed, 360 insertions(+), 41 deletions(-) create mode 100644 client/tests/test_monologue_display.gd diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c7ab84c59..b9c7c95c0 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -288,7 +288,11 @@ func _consume_monologue() -> void: return _last_monologue_tick = GameState.current_tick var mono: Dictionary = GameState.current_monologue - monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) + monologue_display.show_monologue( + mono.get("text", ""), + mono.get("duration_seconds", 5.0), + _get_active_character_type() + ) # #502: Amber flash on room reset var mono_id: String = mono.get("id", "") if mono_id.begins_with("room_reset"): @@ -296,6 +300,22 @@ 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. func _consume_dialogue() -> void: @@ -333,7 +353,7 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void: # D-063: Handle confrontation beat monologue → show on monologue display (layer 7) func _on_confrontation_monologue(text: String, duration: float) -> void: if monologue_display: - monologue_display.show_monologue(text, duration) + monologue_display.show_monologue(text, duration, _get_active_character_type()) # D-064: Handle walk-away → send WalkAway{npc_id} to server diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd new file mode 100644 index 000000000..75eb93056 --- /dev/null +++ b/client/tests/test_monologue_display.gd @@ -0,0 +1,253 @@ +## #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). +## +## 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. +class_name TestMonologueDisplay +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +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() + add_child(node) + # _ready() fires here; panel.modulate.a = 0, _displaying = false + return node + + +# --------------------------------------------------------------------------- +# Colour mapping +# --------------------------------------------------------------------------- + +func test_detective_colour_is_cool_blue() -> 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) + d.queue_free() + + +func test_smuggler_colour_is_warm_amber() -> 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) + d.queue_free() + + +# --------------------------------------------------------------------------- +# BBCode output — italic + colour tags +# --------------------------------------------------------------------------- + +func test_show_monologue_wraps_text_in_italic_bbcode() -> 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.") + d.queue_free() + + +func test_show_monologue_includes_color_tag() -> 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]") + d.queue_free() + + +# --------------------------------------------------------------------------- +# No-overwrite contract (P0, #477) +# --------------------------------------------------------------------------- + +func test_second_call_does_not_overwrite_active_display() -> 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) + d.queue_free() + + +func test_second_call_while_active_is_queued() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("First.", 10.0, "") + d.show_monologue("Second.", 5.0, "") + 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: + 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.") + 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 + + # No items queued — should not crash and _displaying stays false + d._on_fade_complete() + assert_that(d._displaying).is_false() + d.queue_free() + + +func test_queue_preserves_character_type() -> 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() + d.queue_free() + + +# --------------------------------------------------------------------------- +# Queue depth cap +# --------------------------------------------------------------------------- + +func test_queue_does_not_exceed_max_depth() -> void: + var d = _make_display() + if d == null: + return + # First call starts displaying immediately (not queued) + d.show_monologue("Active.", 99.0, "") + + # Fill queue to max + for i in range(d.MAX_QUEUE_DEPTH + 5): + d.show_monologue("Overflow %d" % i, 1.0, "") + + assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE_DEPTH) + d.queue_free() + + +# --------------------------------------------------------------------------- +# Fade timer integration +# --------------------------------------------------------------------------- + +func test_process_triggers_fade_after_duration() -> 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() + d.queue_free() + + +func test_process_does_not_fade_before_duration() -> 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() + d.queue_free() + + +# --------------------------------------------------------------------------- +# Initial state +# --------------------------------------------------------------------------- + +func test_panel_starts_invisible() -> 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() + d.queue_free() diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index 860c6d58a..047046bdf 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -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 diff --git a/client/ui/monologue_display.tscn b/client/ui/monologue_display.tscn index 10dd02758..850407cca 100644 --- a/client/ui/monologue_display.tscn +++ b/client/ui/monologue_display.tscn @@ -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