feat(ui): monologue display — queue, character colours, italic BBCode (#122)
- 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 <noreply@anthropic.com>
This commit is contained in:
+22
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user