diff --git a/assets/ccs_cases_2/images/doctor/office-backdrop.png b/assets/ccs_cases_2/images/doctor/office-backdrop.png index 1147ee3..74aeba1 100644 Binary files a/assets/ccs_cases_2/images/doctor/office-backdrop.png and b/assets/ccs_cases_2/images/doctor/office-backdrop.png differ diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index f97f0f6..132d600 100644 --- a/scenes/ccs_cases_2/doctor_office.tscn +++ b/scenes/ccs_cases_2/doctor_office.tscn @@ -105,12 +105,12 @@ antialiased = true z_index = 30 layout_mode = 1 anchors_preset = 0 -anchor_left = 0.51 -anchor_top = 0.055 -anchor_right = 0.51 -anchor_bottom = 0.055 -offset_right = 420.0 -offset_bottom = 140.0 +anchor_left = 0.53 +anchor_top = 0.04 +anchor_right = 0.53 +anchor_bottom = 0.04 +offset_right = 650.0 +offset_bottom = 220.0 mouse_filter = 2 [node name="Background" type="TextureRect" parent="DialogueBubble"] @@ -134,7 +134,7 @@ anchor_bottom = 1.0 offset_left = 76.0 offset_top = 31.0 offset_right = -48.0 -offset_bottom = -43.0 +offset_bottom = -31.0 grow_horizontal = 2 grow_vertical = 2 theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1) @@ -143,17 +143,56 @@ text = "Hello, doctor." autowrap_mode = 2 vertical_alignment = 1 -[node name="DialogueChoices" type="GridContainer" parent="."] +[node name="DialogueChoiceArea" type="Control" parent="."] z_index = 29 layout_mode = 1 -anchors_preset = 0 -anchor_left = 0.5 +anchors_preset = 12 anchor_top = 1.0 -anchor_right = 0.5 +anchor_right = 1.0 anchor_bottom = 1.0 -offset_left = -450.0 -offset_top = -206.0 -offset_right = 450.0 +offset_left = 18.0 +offset_top = -194.0 +offset_right = -18.0 offset_bottom = -4.0 -theme_override_constants/separation = -8 -columns = 2 + +[node name="ChoicePager" type="HBoxContainer" parent="DialogueChoiceArea"] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 42.0 +grow_horizontal = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="Previous" type="Button" parent="DialogueChoiceArea/ChoicePager"] +custom_minimum_size = Vector2(108, 38) +layout_mode = 2 +text = "Previous" + +[node name="Page" type="Label" parent="DialogueChoiceArea/ChoicePager"] +custom_minimum_size = Vector2(100, 38) +layout_mode = 2 +theme_override_colors/font_color = Color(0.94, 0.95, 0.91, 1) +theme_override_colors/font_shadow_color = Color(0.02, 0.035, 0.05, 0.9) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 17 +text = "Page 1 of 2" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Next" type="Button" parent="DialogueChoiceArea/ChoicePager"] +custom_minimum_size = Vector2(108, 38) +layout_mode = 2 +text = "Next" + +[node name="DialogueChoices" type="HBoxContainer" parent="DialogueChoiceArea"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = 46.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 10 +alignment = 1 diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index 8c4c851..2a7ae76 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -9,6 +9,8 @@ const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue- const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png") const CHOICE_PRESSED := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png") const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0) +const CHOICES_PER_PAGE := 4 +const RESPONSE_HOLD_SECONDS := 1.25 @onready var hour_hand: Node2D = $ClockMount/HourHand @onready var minute_hand: Node2D = $ClockMount/MinuteHand @@ -16,17 +18,27 @@ const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0) @onready var patient: TextureRect = $Patient @onready var dialogue_bubble: Control = $DialogueBubble @onready var dialogue_text: Label = $DialogueBubble/Text -@onready var choice_container: GridContainer = $DialogueChoices +@onready var choice_area: Control = $DialogueChoiceArea +@onready var choice_container: HBoxContainer = $DialogueChoiceArea/DialogueChoices +@onready var choice_pager: HBoxContainer = $DialogueChoiceArea/ChoicePager +@onready var previous_page_button: Button = $DialogueChoiceArea/ChoicePager/Previous +@onready var page_label: Label = $DialogueChoiceArea/ChoicePager/Page +@onready var next_page_button: Button = $DialogueChoiceArea/ChoicePager/Next var blink_tween: Tween var dialogue_tween: Tween +var response_hold_tween: Tween var clock_seconds := 9.0 * 60.0 * 60.0 var debrief_panel: PanelContainer var experience_mode := "management" var pending_choices: Array = [] +var available_choices: Array = [] +var choice_page := 0 func _ready() -> void: + previous_page_button.pressed.connect(_change_choice_page.bind(-1)) + next_page_button.pressed.connect(_change_choice_page.bind(1)) _update_clock() _schedule_patient_blink() hide_patient_dialogue() @@ -83,10 +95,12 @@ func _set_patient_blink(amount: float) -> void: func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() + if response_hold_tween and response_hold_tween.is_valid(): + response_hold_tween.kill() dialogue_text.text = text dialogue_text.visible_ratio = 0.0 dialogue_bubble.visible = true - _resize_dialogue_bubble() + call_deferred("_resize_dialogue_bubble") var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0) dialogue_tween = create_tween() dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration) @@ -96,6 +110,8 @@ func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> func hide_patient_dialogue() -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() + if response_hold_tween and response_hold_tween.is_valid(): + response_hold_tween.kill() dialogue_bubble.visible = false pending_choices = [] @@ -105,10 +121,8 @@ func is_patient_dialogue_visible() -> bool: func _resize_dialogue_bubble() -> void: - # Label wrapping is calculated from the fixed text width immediately after - # assignment, so explicit newlines and automatic wraps both affect height. var line_count := maxi(dialogue_text.get_line_count(), 1) - var target_height := clampf(110.0 + line_count * 30.0, 140.0, 320.0) + var target_height := clampf(82.0 + line_count * 29.0, 150.0, 310.0) dialogue_bubble.size.y = target_height @@ -142,8 +156,20 @@ func complete_dialogue_reveal() -> void: func _on_dialogue_reveal_finished() -> void: dialogue_tween = null + if response_hold_tween and response_hold_tween.is_valid(): + response_hold_tween.kill() + response_hold_tween = create_tween() + response_hold_tween.tween_interval(RESPONSE_HOLD_SECONDS) + response_hold_tween.tween_callback(complete_response_transition) + + +func complete_response_transition() -> void: + if response_hold_tween and response_hold_tween.is_valid(): + response_hold_tween.kill() + response_hold_tween = null var choices := pending_choices pending_choices = [] + dialogue_bubble.visible = false show_dialogue_choices(choices) @@ -151,19 +177,33 @@ func set_choices_pending(pending: bool) -> void: for child in choice_container.get_children(): if child is BaseButton: child.disabled = pending + previous_page_button.disabled = pending or choice_page <= 0 + next_page_button.disabled = pending or choice_page >= _choice_page_count() - 1 func show_dialogue_choices(options: Array) -> void: hide_dialogue_choices() - var row_count := ceili(float(options.size()) / 2.0) - var stack_height := row_count * 97.0 + 8.0 - choice_container.offset_top = -4.0 - stack_height - for index in options.size(): - var option: Dictionary = options[index] + available_choices = options.duplicate(true) + choice_page = 0 + _render_choice_page() + + +func _render_choice_page() -> void: + _clear_choice_buttons() + var page_count := _choice_page_count() + if page_count == 0: + choice_area.visible = false + return + choice_page = clampi(choice_page, 0, page_count - 1) + var start_index := choice_page * CHOICES_PER_PAGE + var end_index := mini(start_index + CHOICES_PER_PAGE, available_choices.size()) + for index in range(start_index, end_index): + var option: Dictionary = available_choices[index] var choice_id := str(option.get("id", "")) var option_text := str(option.get("text", "")) var button := TextureButton.new() - button.custom_minimum_size = Vector2(440.0, 105.0) + button.set_meta("choice_id", choice_id) + button.custom_minimum_size = Vector2(350.0, 136.0) button.ignore_texture_size = true button.stretch_mode = TextureButton.STRETCH_SCALE button.texture_normal = _crop_choice_texture(CHOICE_NORMAL) @@ -181,24 +221,56 @@ func show_dialogue_choices(options: Array) -> void: var label := Label.new() label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.offset_left = 48.0 - label.offset_top = 14.0 + label.offset_top = 12.0 label.offset_right = -48.0 - label.offset_bottom = -14.0 + label.offset_bottom = -12.0 label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145)) - label.add_theme_font_size_override("font_size", 19) + label.add_theme_font_size_override("font_size", _choice_font_size(option_text)) label.text = option_text label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.clip_text = true label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER button.add_child(label) choice_container.add_child(button) - choice_container.visible = not options.is_empty() + choice_pager.visible = page_count > 1 + page_label.text = "Page %d of %d" % [choice_page + 1, page_count] + previous_page_button.disabled = choice_page <= 0 + next_page_button.disabled = choice_page >= page_count - 1 + choice_area.visible = true + choice_container.visible = true func hide_dialogue_choices() -> void: - for child in choice_container.get_children(): - child.queue_free() + _clear_choice_buttons() + available_choices = [] + choice_page = 0 choice_container.visible = false + choice_pager.visible = false + choice_area.visible = false + + +func _clear_choice_buttons() -> void: + for child in choice_container.get_children(): + choice_container.remove_child(child) + child.queue_free() + + +func _choice_page_count() -> int: + return ceili(float(available_choices.size()) / float(CHOICES_PER_PAGE)) + + +func _change_choice_page(direction: int) -> void: + choice_page = clampi(choice_page + direction, 0, maxi(_choice_page_count() - 1, 0)) + _render_choice_page() + + +func _choice_font_size(text: String) -> int: + if text.length() > 105: + return 15 + if text.length() > 75: + return 16 + return 17 func _crop_choice_texture(source: Texture2D) -> AtlasTexture: diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 8aeef0c..6feee3b 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -74,9 +74,25 @@ func _run() -> void: await process_frame _assert(screen.active_scene.is_patient_dialogue_visible(), "a selected topic should display the patient's response") _assert(not screen.active_scene.choice_container.visible, "the next choices should wait until the patient finishes speaking") + _assert(_label_content_fits(screen.active_scene.dialogue_text), "the complete patient response should fit inside its speech bubble") screen.active_scene.complete_dialogue_reveal() - _assert(screen.active_scene.choice_container.columns == 2, "branching interview choices should use two columns") - _assert(screen.active_scene.choice_container.get_child_count() == 8, "all available branching topics should render") + _assert(not screen.active_scene.choice_container.visible, "the completed response should remain visible during the short reading hold") + await create_timer(screen.active_scene.RESPONSE_HOLD_SECONDS + 0.1).timeout + await process_frame + _assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace the patient response after the reading hold") + _assert(screen.active_scene.choice_container.get_child_count() == 4, "the first question page should contain four choices") + _assert(screen.active_scene.choice_pager.visible, "eight choices should display paging controls") + _assert(screen.active_scene.choice_page == 0, "new question sets should begin on the first page") + _assert(_visible_choice_ids(screen.active_scene) == ["topic_0", "topic_1", "topic_2", "topic_3"], "the first page should preserve choice order and IDs") + _assert(_choice_labels_fit(screen.active_scene), "first-page question text should fit within every button") + screen.active_scene._change_choice_page(1) + await process_frame + _assert(screen.active_scene.choice_page == 1, "Next should advance to the second question page") + _assert(screen.active_scene.choice_container.get_child_count() == 4, "the second question page should contain the remaining choices") + _assert(_visible_choice_ids(screen.active_scene) == ["topic_4", "topic_5", "topic_6", "topic_7"], "the second page should preserve choice order and IDs") + _assert(_choice_labels_fit(screen.active_scene), "second-page question text should fit within every button") + screen.active_scene._change_choice_page(-1) + _assert(screen.active_scene.choice_page == 0, "Previous should return to the first question page") service.attempt = _sample_attempt(true) screen._on_attempt_changed() @@ -84,7 +100,9 @@ func _run() -> void: _assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response") _assert(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response") screen.active_scene.complete_dialogue_reveal() + screen.active_scene.complete_response_transition() _assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff") + _assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls") screen.active_scene.interview_continue_requested.emit() _assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player") @@ -143,9 +161,19 @@ func _sample_graphical_debrief() -> Dictionary: func _sample_branching_attempt() -> Dictionary: + var option_texts := [ + "Tell me more about the dizziness. Does the room spin, and have you fainted?", + "How has the fatigue affected you, and how have you been sleeping and eating?", + "You mentioned your stomach. Please describe the discomfort and when it occurs.", + "Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?", + "What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?", + "Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?", + "Do you smoke, drink alcohol, or use recreational drugs?", + "Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?" + ] var choices: Array = [] for index in 8: - choices.append({"id": "topic_%d" % index, "text": "Ask about history topic %d" % (index + 1), "nextNodeId": "topic_%d" % index}) + choices.append({"id": "topic_%d" % index, "text": option_texts[index], "nextNodeId": "topic_%d" % index}) return { "id": "attempt-1", "state": "active", "revision": 2, "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, @@ -153,12 +181,41 @@ func _sample_branching_attempt() -> Dictionary: "openingCompleted": false, "interview": { "currentNodeId": "chief_concern", - "currentNode": {"id": "chief_concern", "patientText": "I feel tired and lightheaded.", "isTerminal": false, "choices": choices}, + "currentNode": { + "id": "chief_concern", + "patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.", + "isTerminal": false, + "choices": choices + }, "visitedChoiceIds": ["chief_concern"], "completed": false } } +func _visible_choice_ids(scene: Control) -> Array: + var ids: Array = [] + for child in scene.choice_container.get_children(): + ids.append(str(child.get_meta("choice_id", ""))) + return ids + + +func _choice_labels_fit(scene: Control) -> bool: + for button in scene.choice_container.get_children(): + var label := button.get_child(0) as Label + if label == null or not _label_content_fits(label): + return false + if not scene.choice_area.get_global_rect().encloses(button.get_global_rect()): + return false + return true + + +func _label_content_fits(label: Label) -> bool: + var font := label.get_theme_font("font") + var font_size := label.get_theme_font_size("font_size") + var content_height := float(label.get_line_count()) * font.get_height(font_size) + return content_height <= label.size.y + 0.5 + + func _assert(condition: bool, message: String) -> void: if condition: return