Merge pull request 'feat(client): #122 monologue display — multi-line, priority queue, 34 tests' (#49) from client into main
This commit was merged in pull request #49.
This commit is contained in:
@@ -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
|
||||
|
||||
+17
-4
@@ -19,8 +19,9 @@ extends Node2D
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
||||
@@ -288,7 +289,12 @@ 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),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
@@ -296,6 +302,7 @@ func _consume_monologue() -> void:
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
@@ -331,9 +338,15 @@ 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.
|
||||
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if monologue_display:
|
||||
monologue_display.show_monologue(text, duration)
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-064: Handle walk-away → send WalkAway{npc_id} to server
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
## #122: Monologue display — client tests (Sprint 14)
|
||||
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
|
||||
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
|
||||
##
|
||||
## 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 node = load("res://ui/monologue_display.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _label_text(d: Node) -> String:
|
||||
if d._visible.is_empty():
|
||||
return ""
|
||||
var slot_node: Node = d._visible[0].node
|
||||
return (slot_node.get_child(0) as RichTextLabel).text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle — ensure clean GameState between every test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
||||
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
|
||||
## current_monologue: GameState integration tests need null as start state.
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_nothing_visible_on_init() -> void:
|
||||
var d = _make_display()
|
||||
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_stagger_timer_zero_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_show_monologue_with_empty_text_does_not_set_displaying() -> void:
|
||||
# Empty text must be ignored — no visible slot created, no queue entry.
|
||||
# Prevents ghost display nodes and stagger timer contamination.
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_empty_text_does_not_advance_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("", 5.0)
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_empty_text_when_slots_full_does_not_enqueue() -> 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("", 5.0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-line display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_single_line_goes_to_visible() -> void:
|
||||
var d = _make_display()
|
||||
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_sets_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-overwrite contract (P0, #477)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
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_two_visible_lines_coexist_without_overwriting() -> void:
|
||||
var d = _make_display()
|
||||
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)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_stagger_elapsed_allows_second_visible() -> void:
|
||||
var d = _make_display()
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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_text_has_color_bbcode() -> void:
|
||||
var d = _make_display()
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_visible_slot_has_tween() -> void:
|
||||
var d = _make_display()
|
||||
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_visible_slot_stores_priority() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Priority 7.", 5.0, 7)
|
||||
assert_int(d._visible[0].priority).is_equal(7)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_expire_timer_decrements_in_process() -> void:
|
||||
var d = _make_display()
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState integration — current_monologue field (v5, #414)
|
||||
# These tests do not instantiate the scene — they verify apply_snapshot()
|
||||
# correctly populates and clears current_monologue so the display layer
|
||||
# receives valid data (or null) every tick.
|
||||
#
|
||||
# Joint test plan (sprint-14/joint.md §Test Plan Alignment):
|
||||
# "Verify current_monologue: None produces no display (no ghost text from previous tick)"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_monologue_null_when_snapshot_omits_field() -> void:
|
||||
## D-016: Snapshot without current_monologue must clear the field.
|
||||
## Prevents a stale line from a previous tick persisting as ghost text.
|
||||
GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0}
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_monologue_set_from_snapshot() -> void:
|
||||
## apply_snapshot populates current_monologue when the field is a dict.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("A thought.")
|
||||
|
||||
|
||||
func test_gamestate_monologue_null_when_field_is_non_dict() -> void:
|
||||
## Defensive: a non-dictionary value from the server must be rejected (null).
|
||||
GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void:
|
||||
## duration_seconds feeds show_monologue()'s duration param — must not be lost.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5},
|
||||
})
|
||||
var dur: float = float(GameState.current_monologue.get("duration_seconds"))
|
||||
assert_that(dur).is_equal_approx(7.5, 0.001)
|
||||
|
||||
|
||||
func test_gamestate_monologue_id_survives_round_trip() -> void:
|
||||
## The id field is used by SimBridge carry-forward deduplication (#477).
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001")
|
||||
|
||||
|
||||
func test_gamestate_monologue_replaced_by_next_snapshot() -> void:
|
||||
## Each snapshot with a monologue replaces the previous value — no accumulation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0},
|
||||
})
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue.get("id")).is_equal("second")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-049 Z-layer / canvas scope compliance
|
||||
# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20).
|
||||
# Two tests: (1) constant sanity, (2) scene tree structural verification.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_ui_constant_is_20() -> void:
|
||||
## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display.
|
||||
## Sanity check — constant must not drift from the spec.
|
||||
assert_that(Constants.CANVAS_UI).is_equal(20)
|
||||
|
||||
|
||||
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
|
||||
## D-049: Structural verification — MonologueDisplay must be a direct child of
|
||||
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
|
||||
## Catches regressions where the node gets accidentally moved to InsertOverlay
|
||||
## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack.
|
||||
##
|
||||
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay")
|
||||
assert_that(mono != null).is_true()
|
||||
|
||||
var parent: Node = mono.get_parent()
|
||||
assert_that(parent is CanvasLayer).is_true()
|
||||
assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI)
|
||||
+150
-35
@@ -1,48 +1,163 @@
|
||||
extends Control
|
||||
|
||||
# Internal monologue display (per D-015)
|
||||
# Shows character's internal thoughts as text overlay
|
||||
# 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).
|
||||
|
||||
@onready var text_panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
|
||||
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")
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
# 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
|
||||
|
||||
var fade_timer: float = 0.0
|
||||
var fade_duration: float = 5.0 # Display duration before fade
|
||||
var is_visible: bool = false
|
||||
var _active_tween: Tween = null
|
||||
|
||||
func _ready() -> void:
|
||||
print("MonologueDisplay: Initialized")
|
||||
text_panel.modulate.a = 0.0
|
||||
is_visible = false
|
||||
pass
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Auto-fade after display
|
||||
if is_visible:
|
||||
fade_timer += delta
|
||||
if fade_timer >= fade_duration:
|
||||
_fade_out()
|
||||
# Expire visible lines
|
||||
for slot in _visible.duplicate():
|
||||
slot.expire_timer -= delta
|
||||
if slot.expire_timer <= 0.0:
|
||||
_retire_slot(slot)
|
||||
|
||||
# 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
|
||||
# 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, next.lattice_profile)
|
||||
|
||||
# Cancel any active tween before starting a new one
|
||||
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:
|
||||
# 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).
|
||||
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)
|
||||
|
||||
is_visible = 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)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 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 _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
|
||||
|
||||
@@ -2,37 +2,23 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"]
|
||||
|
||||
; 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 = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -150.0
|
||||
grow_horizontal = 2
|
||||
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
|
||||
clip_contents = true
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_monologue")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_left = 100.0
|
||||
offset_right = -100.0
|
||||
offset_bottom = 120.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[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
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
text = "Internal monologue will appear here..."
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
Reference in New Issue
Block a user