diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index b718362..ae8137e 100644 --- a/scenes/ccs_cases_2/doctor_office.tscn +++ b/scenes/ccs_cases_2/doctor_office.tscn @@ -133,9 +133,9 @@ layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -offset_left = 76.0 +offset_left = 96.0 offset_top = 31.0 -offset_right = -48.0 +offset_right = -64.0 offset_bottom = -52.0 grow_horizontal = 2 grow_vertical = 2 diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index c0ed673..a185e11 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -12,6 +12,9 @@ const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0) const CHOICES_PER_PAGE := 4 const DIALOGUE_PAGE_MIN_CHARS := 32 const DIALOGUE_PAGE_MAX_CHARS := 82 +const DIALOGUE_BUBBLE_MIN_HEIGHT := 150.0 +const DIALOGUE_BUBBLE_MAX_HEIGHT := 240.0 +const DIALOGUE_BUBBLE_VERTICAL_CHROME := 83.0 @onready var hour_hand: Node2D = $ClockMount/HourHand @onready var minute_hand: Node2D = $ClockMount/MinuteHand @@ -39,6 +42,7 @@ var dialogue_pages: Array[String] = [] var dialogue_page_index := 0 var dialogue_characters_per_second := 35.0 var dialogue_reveal_complete := false +var dialogue_layout_generation := 0 func _ready() -> void: @@ -119,6 +123,17 @@ func _show_dialogue_page() -> void: dialogue_continue_hint.visible = false dialogue_bubble.visible = true dialogue_bubble.grab_focus() + dialogue_layout_generation += 1 + call_deferred("_prepare_dialogue_page", dialogue_layout_generation) + + +func _prepare_dialogue_page(generation: int) -> void: + if generation != dialogue_layout_generation or not dialogue_bubble.visible \ + or dialogue_reveal_complete: + return + var measured_height := dialogue_text.get_content_height() + DIALOGUE_BUBBLE_VERTICAL_CHROME + dialogue_bubble.size.y = clampf( + measured_height, DIALOGUE_BUBBLE_MIN_HEIGHT, DIALOGUE_BUBBLE_MAX_HEIGHT) var reveal_duration := float(dialogue_text.text.length()) / maxf(dialogue_characters_per_second, 1.0) dialogue_tween = create_tween() dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration) @@ -128,6 +143,7 @@ func _show_dialogue_page() -> void: func hide_patient_dialogue() -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() + dialogue_layout_generation += 1 dialogue_bubble.visible = false dialogue_continue_hint.visible = false dialogue_pages = [] @@ -166,6 +182,7 @@ func complete_dialogue_reveal() -> void: return if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() + dialogue_layout_generation += 1 dialogue_text.visible_ratio = 1.0 _on_dialogue_reveal_finished() diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 1621eb6..3633a6f 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -90,6 +90,8 @@ func _run() -> void: _assert(not screen.active_scene.choice_container.visible, "the next choices should wait until the patient finishes speaking") _assert(screen.active_scene.dialogue_pages.size() > 1, "long patient responses should split into readable dialogue pages") _assert(_label_content_fits(screen.active_scene.dialogue_text), "the first patient response page should fit inside its speech bubble") + _assert(is_equal_approx(screen.active_scene.dialogue_text.offset_left, 96.0), "patient dialogue should retain the increased left inset") + _assert(_bubble_matches_content(screen.active_scene), "the speech bubble should size itself to the wrapped response page") screen.active_scene.advance_dialogue() _assert(screen.active_scene.dialogue_page_index == 0, "clicking during typing should complete rather than skip the current page") _assert(screen.active_scene.dialogue_continue_hint.visible, "completed dialogue pages should invite the learner to continue") @@ -143,6 +145,22 @@ func _run() -> void: _assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2") _assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief") + var sizing_scene = load("res://scenes/ccs_cases_2/doctor_office.tscn").instantiate() + host.add_child(sizing_scene) + await process_frame + sizing_scene.show_patient_dialogue("Short response.") + await process_frame + var short_height: float = sizing_scene.dialogue_bubble.size.y + _assert(is_equal_approx(short_height, sizing_scene.DIALOGUE_BUBBLE_MIN_HEIGHT), "short dialogue should use the minimum bubble height") + sizing_scene.show_patient_dialogue("Line one explains the symptom.\nLine two adds timing.\nLine three adds context.\nLine four concludes.") + await process_frame + _assert(sizing_scene.dialogue_bubble.size.y > short_height, "multi-line dialogue should increase the bubble height") + _assert(sizing_scene.dialogue_bubble.size.y <= sizing_scene.DIALOGUE_BUBBLE_MAX_HEIGHT, "dialogue bubble growth should remain bounded") + _assert(_bubble_matches_content(sizing_scene), "the expanded bubble should contain all wrapped text") + sizing_scene.complete_dialogue_reveal() + _assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should remain inside the resized bubble") + sizing_scene.queue_free() + screen.on_hide() await process_frame _assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene") @@ -245,6 +263,14 @@ func _label_content_fits(label: Control) -> bool: return content_height <= plain_label.size.y + 0.5 +func _bubble_matches_content(scene: Control) -> bool: + var required_height: float = scene.dialogue_text.get_content_height() + scene.DIALOGUE_BUBBLE_VERTICAL_CHROME + var expected_height := clampf( + required_height, scene.DIALOGUE_BUBBLE_MIN_HEIGHT, scene.DIALOGUE_BUBBLE_MAX_HEIGHT) + return is_equal_approx(scene.dialogue_bubble.size.y, expected_height) \ + and _label_content_fits(scene.dialogue_text) + + func _assert(condition: bool, message: String) -> void: if condition: return